Change-Id: Ie79f257c46a2c50abdd7ce63bfeceaad976ca878changes/91/91191/1
@ -0,0 +1 @@ | |||
*.pyc |
@ -0,0 +1,202 @@ | |||
Apache License | |||
Version 2.0, January 2004 | |||
http://www.apache.org/licenses/ | |||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | |||
1. Definitions. | |||
"License" shall mean the terms and conditions for use, reproduction, | |||
and distribution as defined by Sections 1 through 9 of this document. | |||
"Licensor" shall mean the copyright owner or entity authorized by | |||
the copyright owner that is granting the License. | |||
"Legal Entity" shall mean the union of the acting entity and all | |||
other entities that control, are controlled by, or are under common | |||
control with that entity. For the purposes of this definition, | |||
"control" means (i) the power, direct or indirect, to cause the | |||
direction or management of such entity, whether by contract or | |||
otherwise, or (ii) ownership of fifty percent (50%) or more of the | |||
outstanding shares, or (iii) beneficial ownership of such entity. | |||
"You" (or "Your") shall mean an individual or Legal Entity | |||
exercising permissions granted by this License. | |||
"Source" form shall mean the preferred form for making modifications, | |||
including but not limited to software source code, documentation | |||
source, and configuration files. | |||
"Object" form shall mean any form resulting from mechanical | |||
transformation or translation of a Source form, including but | |||
not limited to compiled object code, generated documentation, | |||
and conversions to other media types. | |||
"Work" shall mean the work of authorship, whether in Source or | |||
Object form, made available under the License, as indicated by a | |||
copyright notice that is included in or attached to the work | |||
(an example is provided in the Appendix below). | |||
"Derivative Works" shall mean any work, whether in Source or Object | |||
form, that is based on (or derived from) the Work and for which the | |||
editorial revisions, annotations, elaborations, or other modifications | |||
represent, as a whole, an original work of authorship. For the purposes | |||
of this License, Derivative Works shall not include works that remain | |||
separable from, or merely link (or bind by name) to the interfaces of, | |||
the Work and Derivative Works thereof. | |||
"Contribution" shall mean any work of authorship, including | |||
the original version of the Work and any modifications or additions | |||
to that Work or Derivative Works thereof, that is intentionally | |||
submitted to Licensor for inclusion in the Work by the copyright owner | |||
or by an individual or Legal Entity authorized to submit on behalf of | |||
the copyright owner. For the purposes of this definition, "submitted" | |||
means any form of electronic, verbal, or written communication sent | |||
to the Licensor or its representatives, including but not limited to | |||
communication on electronic mailing lists, source code control systems, | |||
and issue tracking systems that are managed by, or on behalf of, the | |||
Licensor for the purpose of discussing and improving the Work, but | |||
excluding communication that is conspicuously marked or otherwise | |||
designated in writing by the copyright owner as "Not a Contribution." | |||
"Contributor" shall mean Licensor and any individual or Legal Entity | |||
on behalf of whom a Contribution has been received by Licensor and | |||
subsequently incorporated within the Work. | |||
2. Grant of Copyright License. Subject to the terms and conditions of | |||
this License, each Contributor hereby grants to You a perpetual, | |||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||
copyright license to reproduce, prepare Derivative Works of, | |||
publicly display, publicly perform, sublicense, and distribute the | |||
Work and such Derivative Works in Source or Object form. | |||
3. Grant of Patent License. Subject to the terms and conditions of | |||
this License, each Contributor hereby grants to You a perpetual, | |||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable | |||
(except as stated in this section) patent license to make, have made, | |||
use, offer to sell, sell, import, and otherwise transfer the Work, | |||
where such license applies only to those patent claims licensable | |||
by such Contributor that are necessarily infringed by their | |||
Contribution(s) alone or by combination of their Contribution(s) | |||
with the Work to which such Contribution(s) was submitted. If You | |||
institute patent litigation against any entity (including a | |||
cross-claim or counterclaim in a lawsuit) alleging that the Work | |||
or a Contribution incorporated within the Work constitutes direct | |||
or contributory patent infringement, then any patent licenses | |||
granted to You under this License for that Work shall terminate | |||
as of the date such litigation is filed. | |||
4. Redistribution. You may reproduce and distribute copies of the | |||
Work or Derivative Works thereof in any medium, with or without | |||
modifications, and in Source or Object form, provided that You | |||
meet the following conditions: | |||
(a) You must give any other recipients of the Work or | |||
Derivative Works a copy of this License; and | |||
(b) You must cause any modified files to carry prominent notices | |||
stating that You changed the files; and | |||
(c) You must retain, in the Source form of any Derivative Works | |||
that You distribute, all copyright, patent, trademark, and | |||
attribution notices from the Source form of the Work, | |||
excluding those notices that do not pertain to any part of | |||
the Derivative Works; and | |||
(d) If the Work includes a "NOTICE" text file as part of its | |||
distribution, then any Derivative Works that You distribute must | |||
include a readable copy of the attribution notices contained | |||
within such NOTICE file, excluding those notices that do not | |||
pertain to any part of the Derivative Works, in at least one | |||
of the following places: within a NOTICE text file distributed | |||
as part of the Derivative Works; within the Source form or | |||
documentation, if provided along with the Derivative Works; or, | |||
within a display generated by the Derivative Works, if and | |||
wherever such third-party notices normally appear. The contents | |||
of the NOTICE file are for informational purposes only and | |||
do not modify the License. You may add Your own attribution | |||
notices within Derivative Works that You distribute, alongside | |||
or as an addendum to the NOTICE text from the Work, provided | |||
that such additional attribution notices cannot be construed | |||
as modifying the License. | |||
You may add Your own copyright statement to Your modifications and | |||
may provide additional or different license terms and conditions | |||
for use, reproduction, or distribution of Your modifications, or | |||
for any such Derivative Works as a whole, provided Your use, | |||
reproduction, and distribution of the Work otherwise complies with | |||
the conditions stated in this License. | |||
5. Submission of Contributions. Unless You explicitly state otherwise, | |||
any Contribution intentionally submitted for inclusion in the Work | |||
by You to the Licensor shall be under the terms and conditions of | |||
this License, without any additional terms or conditions. | |||
Notwithstanding the above, nothing herein shall supersede or modify | |||
the terms of any separate license agreement you may have executed | |||
with Licensor regarding such Contributions. | |||
6. Trademarks. This License does not grant permission to use the trade | |||
names, trademarks, service marks, or product names of the Licensor, | |||
except as required for reasonable and customary use in describing the | |||
origin of the Work and reproducing the content of the NOTICE file. | |||
7. Disclaimer of Warranty. Unless required by applicable law or | |||
agreed to in writing, Licensor provides the Work (and each | |||
Contributor provides its Contributions) on an "AS IS" BASIS, | |||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | |||
implied, including, without limitation, any warranties or conditions | |||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | |||
PARTICULAR PURPOSE. You are solely responsible for determining the | |||
appropriateness of using or redistributing the Work and assume any | |||
risks associated with Your exercise of permissions under this License. | |||
8. Limitation of Liability. In no event and under no legal theory, | |||
whether in tort (including negligence), contract, or otherwise, | |||
unless required by applicable law (such as deliberate and grossly | |||
negligent acts) or agreed to in writing, shall any Contributor be | |||
liable to You for damages, including any direct, indirect, special, | |||
incidental, or consequential damages of any character arising as a | |||
result of this License or out of the use or inability to use the | |||
Work (including but not limited to damages for loss of goodwill, | |||
work stoppage, computer failure or malfunction, or any and all | |||
other commercial damages or losses), even if such Contributor | |||
has been advised of the possibility of such damages. | |||
9. Accepting Warranty or Additional Liability. While redistributing | |||
the Work or Derivative Works thereof, You may choose to offer, | |||
and charge a fee for, acceptance of support, warranty, indemnity, | |||
or other liability obligations and/or rights consistent with this | |||
License. However, in accepting such obligations, You may act only | |||
on Your own behalf and on Your sole responsibility, not on behalf | |||
of any other Contributor, and only if You agree to indemnify, | |||
defend, and hold each Contributor harmless for any liability | |||
incurred by, or claims asserted against, such Contributor by reason | |||
of your accepting any such warranty or additional liability. | |||
END OF TERMS AND CONDITIONS | |||
APPENDIX: How to apply the Apache License to your work. | |||
To apply the Apache License to your work, attach the following | |||
boilerplate notice, with the fields enclosed by brackets "[]" | |||
replaced with your own identifying information. (Don't include | |||
the brackets!) The text should be enclosed in the appropriate | |||
comment syntax for the file format. We also recommend that a | |||
file or class name and description of purpose be included on the | |||
same "printed page" as the copyright notice for easier | |||
identification within third-party archives. | |||
Copyright [yyyy] [name of copyright owner] | |||
Licensed under the Apache License, Version 2.0 (the "License"); | |||
you may not use this file except in compliance with the License. | |||
You may obtain a copy of the License at | |||
http://www.apache.org/licenses/LICENSE-2.0 | |||
Unless required by applicable law or agreed to in writing, software | |||
distributed under the License is distributed on an "AS IS" BASIS, | |||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||
See the License for the specific language governing permissions and | |||
limitations under the License. |
@ -0,0 +1,111 @@ | |||
Gertty | |||
====== | |||
Gertty is a console-based interface to the Gerrit Code Review system. | |||
As compared to the web interface, the main advantages are: | |||
* Workflow -- the interface is designed to support a workflow similar | |||
to reading network news or mail. In particular, it is designed to | |||
deal with a large number of review requests across a large number | |||
of projects. | |||
* Offline Use -- Gertty syncs information about changes in subscribed | |||
projects to a local database and local git repos. All review | |||
operations are performed against that database and then synced back | |||
to Gerrit. | |||
* Speed -- user actions modify locally cached content and need not | |||
wait for server interaction. | |||
* Convenience -- because Gertty downloads all changes to local git | |||
repos, a single command instructs it to checkout a change into that | |||
repo for detailed examination or testing of larger changes. | |||
Usage | |||
----- | |||
Create a file at ``~/.gerttyrc`` with the following contents:: | |||
[gerrit] | |||
url=https://review.example.org/ | |||
username=<gerrit username> | |||
password=<gerrit password> | |||
git_root=~/git/ | |||
You can generate or retrieve your Gerrit password by navigating to | |||
Settings, then HTTP Password. Set ``git_root`` to a directory where | |||
Gertty should find or clone git repositories for your projects. | |||
If your Gerrit uses a self-signed certificate, you can add:: | |||
verify_ssl=False | |||
To the section. | |||
The config file is designed to support multiple Gerrit instances, but | |||
currently, only the first one is used. | |||
After installing the requirements (listed in requirements.txt), you | |||
should be able to simply run Gertty. You will need to start by | |||
subscribing to some projects. Use 'l' to list all of the projects and | |||
then 's' to subscribe to them. | |||
In general, pressing the F1 key will show help text on any screen, and | |||
ESC will take you to the previous screen. | |||
To select text (e.g., to copy to the clipboard), hold Shift while | |||
selecting the text. | |||
Philosophy | |||
---------- | |||
Gertty is based on the following precepts which should inform changes | |||
to the program: | |||
* Support large numbers of review requests across large numbers of | |||
projects. Help the user prioritize those reviews. | |||
* Adopt a news/mailreader-like workflow in support of the above. | |||
Being able to subscribe to projects, mark reviews as "read" without | |||
reviewing, etc, are all useful concepts to support a heavy review | |||
load (they have worked extremely well in supporting people who | |||
read/write a lot of mail/news). | |||
* Support off-line use. Gertty should be completely usable off-line | |||
with reliable syncing between local data and Gerrit when a | |||
connection is available (just like git or mail or news). | |||
* Ample use of color. Unlike a web interface, a good text interface | |||
relies mostly on color and precise placement rather than whitespace | |||
and decoration to indicate to the user the purpose of a given piece | |||
of information. Gertty should degrade well to 16 colors, but more | |||
(88 or 256) may be used. | |||
* Keyboard navigation (with easy-to-remember commands) should be | |||
considered the primary mode of interaction. Mouse interaction | |||
should also be supported. | |||
* The navigation philosophy is a stack of screens, where each | |||
selection pushes a new screen onto the stack, and ESC pops the | |||
screen off. This makes sense when drilling down to a change from | |||
lists, but also supports linking from change to change (via commit | |||
messages or comments) and navigating back intuitive (it matches | |||
expectations set by the web browsers). | |||
Contributing | |||
------------ | |||
To browse the latest code, see: https://git.openstack.org/cgit/stackforge/gertty/tree/ | |||
To clone the latest code, use `git clone git://git.openstack.org/stackforge/gertty` | |||
Bugs are handled at: https://storyboard.openstack.org/ | |||
Code reviews are handled by gerrit at: https://review.openstack.org | |||
Use `git review` to submit patches (after creating a gerrit account | |||
that links to your launchpad account). Example:: | |||
# Do your commits | |||
$ git review | |||
# Enter your username if prompted |
@ -0,0 +1,46 @@ | |||
# Copyright 2014 OpenStack Foundation | |||
# | |||
# Licensed under the Apache License, Version 2.0 (the "License"); you may | |||
# not use this file except in compliance with the License. You may obtain | |||
# a copy of the License at | |||
# | |||
# http://www.apache.org/licenses/LICENSE-2.0 | |||
# | |||
# Unless required by applicable law or agreed to in writing, software | |||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |||
# License for the specific language governing permissions and limitations | |||
# under the License. | |||
import os | |||
import ConfigParser | |||
DEFAULT_CONFIG_PATH='~/.gerttyrc' | |||
class Config(object): | |||
def __init__(self, server=None, path=DEFAULT_CONFIG_PATH): | |||
self.path = os.path.expanduser(path) | |||
self.config = ConfigParser.RawConfigParser() | |||
self.config.read(self.path) | |||
if server is None: | |||
server = self.config.sections()[0] | |||
self.server = server | |||
self.url = self.config.get(server, 'url') | |||
self.username = self.config.get(server, 'username') | |||
self.password = self.config.get(server, 'password') | |||
if self.config.has_option(server, 'verify_ssl'): | |||
self.verify_ssl = self.config.getboolean(server, 'verify_ssl') | |||
else: | |||
self.verify_ssl = True | |||
if not self.verify_ssl: | |||
os.environ['GIT_SSL_NO_VERIFY']='true' | |||
self.git_root = os.path.expanduser(self.config.get(server, 'git_root')) | |||
if self.config.has_option(server, 'dburi'): | |||
self.dburi = self.config.get(server, 'dburi') | |||
else: | |||
self.dburi = 'sqlite:///' + os.path.expanduser('~/.gertty.db') | |||
if self.config.has_option(server, 'log_file'): | |||
self.log_file = os.path.expanduser(self.config.get(server, 'log_file')) | |||
else: | |||
self.log_file = os.path.expanduser('~/.gertty.log') |
@ -0,0 +1,446 @@ | |||
# Copyright 2014 OpenStack Foundation | |||
# | |||
# Licensed under the Apache License, Version 2.0 (the "License"); you may | |||
# not use this file except in compliance with the License. You may obtain | |||
# a copy of the License at | |||
# | |||
# http://www.apache.org/licenses/LICENSE-2.0 | |||
# | |||
# Unless required by applicable law or agreed to in writing, software | |||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |||
# License for the specific language governing permissions and limitations | |||
# under the License. | |||
import sqlalchemy | |||
from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, DateTime, Text, select, func | |||
from sqlalchemy.schema import ForeignKey | |||
from sqlalchemy.orm import mapper, sessionmaker, relationship, column_property, scoped_session | |||
from sqlalchemy.orm.session import Session | |||
from sqlalchemy.sql.expression import and_ | |||
metadata = MetaData() | |||
project_table = Table( | |||
'project', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('name', String(255), index=True, unique=True, nullable=False), | |||
Column('subscribed', Boolean, index=True, default=False), | |||
Column('description', Text, nullable=False, default=''), | |||
) | |||
change_table = Table( | |||
'change', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('project_key', Integer, ForeignKey("project.key"), index=True), | |||
Column('id', String(255), index=True, unique=True, nullable=False), | |||
Column('number', Integer, index=True, unique=True, nullable=False), | |||
Column('branch', String(255), index=True, nullable=False), | |||
Column('change_id', String(255), index=True, nullable=False), | |||
Column('topic', String(255), index=True), | |||
Column('owner', String(255), index=True), | |||
Column('subject', Text, nullable=False), | |||
Column('created', DateTime, index=True, nullable=False), | |||
Column('updated', DateTime, index=True, nullable=False), | |||
Column('status', String(8), index=True, nullable=False), | |||
Column('hidden', Boolean, index=True, nullable=False), | |||
Column('reviewed', Boolean, index=True, nullable=False), | |||
) | |||
revision_table = Table( | |||
'revision', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('change_key', Integer, ForeignKey("change.key"), index=True), | |||
Column('number', Integer, index=True, nullable=False), | |||
Column('message', Text, nullable=False), | |||
Column('commit', String(255), nullable=False), | |||
Column('parent', String(255), nullable=False), | |||
) | |||
message_table = Table( | |||
'message', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('revision_key', Integer, ForeignKey("revision.key"), index=True), | |||
Column('id', String(255), index=True), #, unique=True, nullable=False), | |||
Column('created', DateTime, index=True, nullable=False), | |||
Column('name', String(255)), | |||
Column('message', Text, nullable=False), | |||
Column('pending', Boolean, index=True, nullable=False), | |||
) | |||
comment_table = Table( | |||
'comment', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('revision_key', Integer, ForeignKey("revision.key"), index=True), | |||
Column('id', String(255), index=True), #, unique=True, nullable=False), | |||
Column('in_reply_to', String(255)), | |||
Column('created', DateTime, index=True, nullable=False), | |||
Column('name', String(255)), | |||
Column('file', Text, nullable=False), | |||
Column('parent', Boolean, nullable=False), | |||
Column('line', Integer), | |||
Column('message', Text, nullable=False), | |||
Column('pending', Boolean, index=True, nullable=False), | |||
) | |||
label_table = Table( | |||
'label', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('change_key', Integer, ForeignKey("change.key"), index=True), | |||
Column('category', String(255), nullable=False), | |||
Column('value', Integer, nullable=False), | |||
Column('description', String(255), nullable=False), | |||
) | |||
permitted_label_table = Table( | |||
'permitted_label', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('change_key', Integer, ForeignKey("change.key"), index=True), | |||
Column('category', String(255), nullable=False), | |||
Column('value', Integer, nullable=False), | |||
) | |||
approval_table = Table( | |||
'approval', metadata, | |||
Column('key', Integer, primary_key=True), | |||
Column('change_key', Integer, ForeignKey("change.key"), index=True), | |||
Column('name', String(255)), | |||
Column('category', String(255), nullable=False), | |||
Column('value', Integer, nullable=False), | |||
Column('pending', Boolean, index=True, nullable=False), | |||
) | |||
class Project(object): | |||
def __init__(self, name, subscribed=False, description=''): | |||
self.name = name | |||
self.subscribed = subscribed | |||
self.description = description | |||
def createChange(self, *args, **kw): | |||
session = Session.object_session(self) | |||
args = [self] + list(args) | |||
c = Change(*args, **kw) | |||
self.changes.append(c) | |||
session.add(c) | |||
session.flush() | |||
return c | |||
class Change(object): | |||
def __init__(self, project, id, number, branch, change_id, | |||
owner, subject, created, updated, status, | |||
topic=False, hidden=False, reviewed=False): | |||
self.project_key = project.key | |||
self.id = id | |||
self.number = number | |||
self.branch = branch | |||
self.change_id = change_id | |||
self.topic = topic | |||
self.owner = owner | |||
self.subject = subject | |||
self.created = created | |||
self.updated = updated | |||
self.status = status | |||
self.hidden = hidden | |||
self.reviewed = reviewed | |||
def getCategories(self): | |||
categories = [] | |||
for label in self.labels: | |||
if label.category in categories: | |||
continue | |||
categories.append(label.category) | |||
return categories | |||
def getMaxForCategory(self, category): | |||
if not hasattr(self, '_approval_cache'): | |||
self._updateApprovalCache() | |||
return self._approval_cache.get(category, 0) | |||
def _updateApprovalCache(self): | |||
cat_min = {} | |||
cat_max = {} | |||
cat_value = {} | |||
for approval in self.approvals: | |||
cur_min = cat_min.get(approval.category, 0) | |||
cur_max = cat_max.get(approval.category, 0) | |||
cur_min = min(approval.value, cur_min) | |||
cur_max = max(approval.value, cur_max) | |||
cat_min[approval.category] = cur_min | |||
cat_max[approval.category] = cur_max | |||
cur_value = cat_value.get(approval.category, 0) | |||
if abs(cur_min) > abs(cur_value): | |||
cur_value = cur_min | |||
if abs(cur_max) > abs(cur_value): | |||
cur_value = cur_max | |||
cat_value[approval.category] = cur_value | |||
self._approval_cache = cat_value | |||
def createRevision(self, *args, **kw): | |||
session = Session.object_session(self) | |||
args = [self] + list(args) | |||
r = Revision(*args, **kw) | |||
self.revisions.append(r) | |||
session.add(r) | |||
session.flush() | |||
return r | |||
def createLabel(self, *args, **kw): | |||
session = Session.object_session(self) | |||
args = [self] + list(args) | |||
l = Label(*args, **kw) | |||
self.labels.append(l) | |||
session.add(l) | |||
session.flush() | |||
return l | |||
def createApproval(self, *args, **kw): | |||
session = Session.object_session(self) | |||
args = [self] + list(args) | |||
l = Approval(*args, **kw) | |||
self.approvals.append(l) | |||
session.add(l) | |||
session.flush() | |||
return l | |||
def createPermittedLabel(self, *args, **kw): | |||
session = Session.object_session(self) | |||
args = [self] + list(args) | |||
l = PermittedLabel(*args, **kw) | |||
self.permitted_labels.append(l) | |||
session.add(l) | |||
session.flush() | |||
return l | |||
class Revision(object): | |||
def __init__(self, change, number, message, commit, parent): | |||
self.change_key = change.key | |||
self.number = number | |||
self.message = message | |||
self.commit = commit | |||
self.parent = parent | |||
def createMessage(self, *args, **kw): | |||
session = Session.object_session(self) | |||
args = [self] + list(args) | |||
m = Message(*args, **kw) | |||
self.messages.append(m) | |||
session.add(m) | |||
session.flush() | |||
return m | |||
def createComment(self, *args, **kw): | |||
session = Session.object_session(self) | |||
args = [self] + list(args) | |||
c = Comment(*args, **kw) | |||
self.comments.append(c) | |||
session.add(c) | |||
session.flush() | |||
return c | |||
class Message(object): | |||
def __init__(self, revision, id, created, name, message, pending=False): | |||
self.revision_key = revision.key | |||
self.id = id | |||
self.created = created | |||
self.name = name | |||
self.message = message | |||
self.pending = pending | |||
class Comment(object): | |||
def __init__(self, revision, id, in_reply_to, created, name, file, parent, line, message, pending=False): | |||
self.revision_key = revision.key | |||
self.id = id | |||
self.in_reply_to = in_reply_to | |||
self.created = created | |||
self.name = name | |||
self.file = file | |||
self.parent = parent | |||
self.line = line | |||
self.message = message | |||
self.pending = pending | |||
class Label(object): | |||
def __init__(self, change, category, value, description): | |||
self.change_key = change.key | |||
self.category = category | |||
self.value = value | |||
self.description = description | |||
class PermittedLabel(object): | |||
def __init__(self, change, category, value): | |||
self.change_key = change.key | |||
self.category = category | |||
self.value = value | |||
class Approval(object): | |||
def __init__(self, change, name, category, value, pending=False): | |||
self.change_key = change.key | |||
self.name = name | |||
self.category = category | |||
self.value = value | |||
self.pending = pending | |||
mapper(Project, project_table, properties=dict( | |||
changes=relationship(Change, backref='project', | |||
order_by=change_table.c.number), | |||
unreviewed_changes=relationship(Change, | |||
primaryjoin=and_(project_table.c.key==change_table.c.project_key, | |||
change_table.c.hidden==False, | |||
change_table.c.reviewed==False), | |||
order_by=change_table.c.number, | |||
), | |||
reviewed_changes=relationship(Change, | |||
primaryjoin=and_(project_table.c.key==change_table.c.project_key, | |||
change_table.c.hidden==False, | |||
change_table.c.reviewed==True), | |||
order_by=change_table.c.number, | |||
), | |||
updated = column_property( | |||
select([func.max(change_table.c.updated)]).where( | |||
change_table.c.project_key==project_table.c.key) | |||
), | |||
)) | |||
mapper(Change, change_table, properties=dict( | |||
revisions=relationship(Revision, backref='change', | |||
order_by=revision_table.c.number), | |||
messages=relationship(Message, | |||
secondary=revision_table, | |||
order_by=message_table.c.created), | |||
labels=relationship(Label, backref='change', order_by=(label_table.c.category, | |||
label_table.c.value)), | |||
permitted_labels=relationship(PermittedLabel, backref='change', | |||
order_by=(permitted_label_table.c.category, | |||
permitted_label_table.c.value)), | |||
approvals=relationship(Approval, backref='change', order_by=(approval_table.c.category, | |||
approval_table.c.value)), | |||
pending_approvals=relationship(Approval, | |||
primaryjoin=and_(change_table.c.key==approval_table.c.change_key, | |||
approval_table.c.pending==True), | |||
order_by=(approval_table.c.category, | |||
approval_table.c.value)) | |||
)) | |||
mapper(Revision, revision_table, properties=dict( | |||
messages=relationship(Message, backref='revision'), | |||
comments=relationship(Comment, backref='revision', | |||
order_by=(comment_table.c.line, | |||
comment_table.c.created)), | |||
pending_comments=relationship(Comment, | |||
primaryjoin=and_(revision_table.c.key==comment_table.c.revision_key, | |||
comment_table.c.pending==True), | |||
order_by=(comment_table.c.line, | |||
comment_table.c.created)), | |||
)) | |||
mapper(Message, message_table) | |||
mapper(Comment, comment_table) | |||
mapper(Label, label_table) | |||
mapper(PermittedLabel, permitted_label_table) | |||
mapper(Approval, approval_table) | |||
class Database(object): | |||
def __init__(self, app): | |||
self.app = app | |||
self.engine = create_engine(self.app.config.dburi) | |||
metadata.create_all(self.engine) | |||
self.session_factory = sessionmaker(bind=self.engine) | |||
self.session = scoped_session(self.session_factory) | |||
def getSession(self): | |||
return DatabaseSession(self.session) | |||
class DatabaseSession(object): | |||
def __init__(self, session): | |||
self.session = session | |||
def __enter__(self): | |||
return self | |||
def __exit__(self, etype, value, tb): | |||
if etype: | |||
self.session().rollback() | |||
else: | |||
self.session().commit() | |||
self.session().close() | |||
self.session = None | |||
def abort(self): | |||
self.session().rollback() | |||
def commit(self): | |||
self.session().commit() | |||
def delete(self, obj): | |||
self.session().delete(obj) | |||
def getProjects(self, subscribed=False): | |||
if subscribed: | |||
return self.session().query(Project).filter_by(subscribed=subscribed).order_by(Project.name).all() | |||
else: | |||
return self.session().query(Project).order_by(Project.name).all() | |||
def getProject(self, key): | |||
try: | |||
return self.session().query(Project).filter_by(key=key).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getProjectByName(self, name): | |||
try: | |||
return self.session().query(Project).filter_by(name=name).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getChange(self, key): | |||
try: | |||
return self.session().query(Change).filter_by(key=key).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getChangeByID(self, id): | |||
try: | |||
return self.session().query(Change).filter_by(id=id).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getRevision(self, key): | |||
try: | |||
return self.session().query(Revision).filter_by(key=key).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getRevisionByCommit(self, commit): | |||
try: | |||
return self.session().query(Revision).filter_by(commit=commit).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getRevisionByNumber(self, change, number): | |||
try: | |||
return self.session().query(Revision).filter_by(change_key=change.key, number=number).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getComment(self, key): | |||
try: | |||
return self.session().query(Comment).filter_by(key=key).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getCommentByID(self, id): | |||
try: | |||
return self.session().query(Comment).filter_by(id=id).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getMessage(self, key): | |||
try: | |||
return self.session().query(Message).filter_by(key=key).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getMessageByID(self, id): | |||
try: | |||
return self.session().query(Message).filter_by(id=id).one() | |||
except sqlalchemy.orm.exc.NoResultFound: | |||
return None | |||
def getPendingMessages(self): | |||
return self.session().query(Message).filter_by(pending=True).all() | |||
def createProject(self, *args, **kw): | |||
o = Project(*args, **kw) | |||
self.session().add(o) | |||
self.session().flush() | |||
return o |
@ -0,0 +1,186 @@ | |||
# Copyright 2014 OpenStack Foundation | |||
# | |||
# Licensed under the Apache License, Version 2.0 (the "License"); you may | |||
# not use this file except in compliance with the License. You may obtain | |||
# a copy of the License at | |||
# | |||
# http://www.apache.org/licenses/LICENSE-2.0 | |||
# | |||
# Unless required by applicable law or agreed to in writing, software | |||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |||
# License for the specific language governing permissions and limitations | |||
# under the License. | |||
import argparse | |||
import logging | |||
import os | |||
import sys | |||
import threading | |||
import urwid | |||
import db | |||
import config | |||
import gitrepo | |||
import mywid | |||
import sync | |||
import view.project_list | |||
palette=[('reversed', 'default,standout', ''), | |||
('header', 'white,bold', 'dark blue'), | |||
('error', 'light red', 'dark blue'), | |||
('table-header', 'white,bold', ''), | |||
# Diff | |||
('removed-line', 'dark red', ''), | |||
('removed-word', 'light red', ''), | |||
('added-line', 'dark green', ''), | |||
('added-word', 'light green', ''), | |||
('nonexistent', 'default', ''), | |||
('reversed-removed-line', 'dark red,standout', ''), | |||
('reversed-removed-word', 'light red,standout', ''), | |||
('reversed-added-line', 'dark green,standout', ''), | |||
('reversed-added-word', 'light green,standout', ''), | |||
('reversed-nonexistent', 'default,standout', ''), | |||
('draft-comment', 'default', 'dark gray'), | |||
('comment', 'white', 'dark gray'), | |||
# Change view | |||
('change-data', 'light cyan', ''), | |||
('change-header', 'light blue', ''), | |||
('revision-name', 'light blue', ''), | |||
('revision-commit', 'dark blue', ''), | |||
('revision-drafts', 'dark red', ''), | |||
('reversed-revision-name', 'light blue,standout', ''), | |||
('reversed-revision-commit', 'dark blue,standout', ''), | |||
('reversed-revision-drafts', 'dark red,standout', ''), | |||
('change-message-name', 'light blue', ''), | |||
('change-message-header', 'dark blue', ''), | |||
# project list | |||
('unreviewed-project', 'white', ''), | |||
('subscribed-project', 'default', ''), | |||
('unsubscribed-project', 'dark gray', ''), | |||
('reversed-unreviewed-project', 'white,standout', ''), | |||
('reversed-subscribed-project', 'default,standout', ''), | |||
('reversed-unsubscribed-project', 'dark gray,standout', ''), | |||
# change list | |||
('unreviewed-change', 'default', ''), | |||
('reviewed-change', 'dark gray', ''), | |||
('reversed-unreviewed-change', 'default,standout', ''), | |||
('reversed-reviewed-change', 'dark gray,standout', ''), | |||
] | |||
class StatusHeader(urwid.WidgetWrap): | |||
def __init__(self, app): | |||
super(StatusHeader, self).__init__(urwid.Columns([])) | |||
self.app = app | |||
self.title = urwid.Text(u'Start') | |||
self.error = urwid.Text('') | |||
self.offline = urwid.Text('') | |||
self.sync = urwid.Text(u'Sync: 0') | |||
self._w.contents.append((self.title, ('pack', None, False))) | |||
self._w.contents.append((urwid.Text(u''), ('weight', 1, False))) | |||
self._w.contents.append((self.error, ('pack', None, False))) | |||
self._w.contents.append((self.offline, ('pack', None, False))) | |||
self._w.contents.append((self.sync, ('pack', None, False))) | |||
def update(self, title=None, error=False, offline=None): | |||
if title: | |||
self.title.set_text(title) | |||
if error: | |||
self.error.set_text(('error', u'Error')) | |||
if offline is not None: | |||
if offline: | |||
self.error.set_text(u'Offline') | |||
else: | |||
self.error.set_text(u'') | |||
self.sync.set_text(u' Sync: %i' % self.app.sync.queue.qsize()) | |||
class App(object): | |||
def __init__(self, server=None, debug=False): | |||
self.server = server | |||
self.config = config.Config(server) | |||
if debug: | |||
level = logging.DEBUG | |||
else: | |||
level = logging.WARNING | |||
logging.basicConfig(filename=self.config.log_file, filemode='w', | |||
format='%(asctime)s %(message)s', | |||
level=level) | |||
self.log = logging.getLogger('gertty.App') | |||
self.log.debug("Starting") | |||
self.db = db.Database(self) | |||
self.sync = sync.Sync(self) | |||
self.screens = [] | |||
self.status = StatusHeader(self) | |||
self.header = urwid.AttrMap(self.status, 'header') | |||
screen = view.project_list.ProjectListView(self) | |||
self.status.update(title=screen.title) | |||
self.loop = urwid.MainLoop(screen, palette=palette, | |||
unhandled_input=self.unhandledInput) | |||
sync_pipe = self.loop.watch_pipe(self.refresh) | |||
#self.loop.screen.set_terminal_properties(colors=88) | |||
self.sync_thread = threading.Thread(target=self.sync.run, args=(sync_pipe,)) | |||
self.sync_thread.start() | |||
self.loop.run() | |||
def changeScreen(self, widget): | |||
self.status.update(title=widget.title) | |||
self.screens.append(self.loop.widget) | |||
self.loop.widget = widget | |||
def backScreen(self): | |||
if not self.screens: | |||
return | |||
widget = self.screens.pop() | |||
self.status.update(title=widget.title) | |||
self.loop.widget = widget | |||
self.refresh() | |||
def refresh(self, data=None): | |||
widget = self.loop.widget | |||
while isinstance(widget, urwid.Overlay): | |||
widget = widget.contents[0][0] | |||
widget.refresh() | |||
def popup(self, widget, | |||
relative_width=50, relative_height=25, | |||
min_width=20, min_height=8): | |||
overlay = urwid.Overlay(widget, self.loop.widget, | |||
'center', ('relative', relative_width), | |||
'middle', ('relative', relative_height), | |||
min_width=min_width, min_height=min_height) | |||
self.screens.append(self.loop.widget) | |||
self.loop.widget = overlay | |||
def help(self): | |||
if not hasattr(self.loop.widget, 'help'): | |||
return | |||
dialog = mywid.MessageDialog('Help', self.loop.widget.help) | |||
lines = self.loop.widget.help.split('\n') | |||
urwid.connect_signal(dialog, 'close', | |||
lambda button: self.backScreen()) | |||
self.popup(dialog, min_width=76, min_height=len(lines)+2) | |||
def unhandledInput(self, key): | |||
if key == 'esc': | |||
self.backScreen() | |||
elif key == 'f1': | |||
self.help() | |||
def getRepo(self, project_name): | |||
local_path = os.path.join(self.config.git_root, project_name) | |||
local_root = os.path.abspath(self.config.git_root) | |||
assert os.path.commonprefix((local_root, local_path)) == local_root | |||
return gitrepo.Repo(self.config.url+'p/'+project_name, | |||
local_path) | |||
if __name__ == '__main__': | |||
parser = argparse.ArgumentParser( | |||
description='Console client for Gerrit Code Review.') | |||
parser.add_argument('-d', dest='debug', action='store_true', | |||
help='enable debug logging') | |||
parser.add_argument('server', nargs='?', | |||
help='the server to use (as specified in config file)') | |||
args = parser.parse_args() | |||
g = App(args.server, args.debug) |
@ -0,0 +1,196 @@ | |||
# Copyright 2014 OpenStack Foundation | |||
# | |||
# Licensed under the Apache License, Version 2.0 (the "License"); you may | |||
# not use this file except in compliance with the License. You may obtain | |||
# a copy of the License at | |||
# | |||
# http://www.apache.org/licenses/LICENSE-2.0 | |||
# | |||
# Unless required by applicable law or agreed to in writing, software | |||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |||
# License for the specific language governing permissions and limitations | |||
# under the License. | |||
import difflib | |||
import os | |||
import re | |||
import git | |||
class DiffFile(object): | |||
def __init__(self): | |||
self.newname = None | |||
self.oldname = None | |||
self.oldlines = [] | |||
self.newlines = [] | |||
class GitCheckoutError(Exception): | |||
def __init__(self, msg): | |||
super(GitCheckoutError, self).__init__(msg) | |||
self.msg = msg | |||
class Repo(object): | |||
def __init__(self, url, path): | |||
self.url = url | |||
self.path = path | |||
self.differ = difflib.Differ() | |||
if not os.path.exists(path): | |||
git.Repo.clone_from(self.url, self.path) | |||
def fetch(self, url, refspec): | |||
repo = git.Repo(self.path) | |||
try: | |||
repo.git.fetch(url, refspec) | |||
except AssertionError: | |||
repo.git.fetch(url, refspec) | |||
def checkout(self, ref): | |||
repo = git.Repo(self.path) | |||
try: | |||
repo.git.checkout(ref) | |||
except git.exc.GitCommandError as e: | |||
raise GitCheckoutError(e.stderr.replace('\t', ' ')) | |||
def diffstat(self, old, new): | |||
repo = git.Repo(self.path) | |||
diff = repo.git.diff('-M', '--numstat', old, new) | |||
ret = [] | |||
for x in diff.split('\n'): | |||
# Added, removed, filename | |||
ret.append(x.split('\t')) | |||
return ret | |||
def intraline_diff(self, old, new): | |||
prevline = None | |||
prevstyle = None | |||
output_old = [] | |||
output_new = [] | |||
#socket.send('startold' + repr(old)+'\n') | |||
#socket.send('startnew' + repr(new)+'\n') | |||
for line in self.differ.compare(old, new): | |||
#socket.sendall('diff output: ' + line+'\n') | |||
key = line[0] | |||
rest = line[2:] | |||
if key == '?': | |||
result = [] | |||
accumulator = '' | |||
emphasis = False | |||
rest = rest[:-1] # It has a newline. | |||
for i, c in enumerate(prevline): | |||
if i >= len(rest): | |||
indicator = ' ' | |||
else: | |||
indicator = rest[i] | |||
#socket.sendall('%s %s %s %s %s\n' % (i, c, indicator, emphasis, accumulator)) | |||
if indicator != ' ' and not emphasis: | |||
# changing from not emph to emph | |||
if accumulator: | |||
result.append((prevstyle+'-line', accumulator)) | |||
accumulator = '' | |||
emphasis = True | |||
elif indicator == ' ' and emphasis: | |||
# changing from emph to not emph | |||
if accumulator: | |||
result.append((prevstyle+'-word', accumulator)) | |||
accumulator = '' | |||
emphasis = False | |||
accumulator += c | |||
if accumulator: | |||
if emphasis: | |||
result.append((prevstyle+'-word', accumulator)) | |||
else: | |||
result.append((prevstyle+'-line', accumulator)) | |||
if prevstyle == 'added': | |||
output_new.append(result) | |||
elif prevstyle == 'removed': | |||
output_old.append(result) | |||
prevline = None | |||
continue | |||
if prevline is not None: | |||
if prevstyle == 'added': | |||
output_new.append((prevstyle+'-line', prevline)) | |||
elif prevstyle == 'removed': | |||
output_old.append((prevstyle+'-line', prevline)) | |||
if key == '+': | |||
prevstyle = 'added' | |||
elif key == '-': | |||
prevstyle = 'removed' | |||
prevline = rest | |||
#socket.sendall('prev'+repr(prevline)+'\n') | |||
if prevline is not None: | |||
if prevstyle == 'added': | |||
output_new.append((prevstyle+'-line', prevline)) | |||
elif prevstyle == 'removed': | |||
output_old.append((prevstyle+'-line', prevline)) | |||
#socket.sendall(repr(output_old)+'\n') | |||
#socket.sendall(repr(output_new)+'\n') | |||
#socket.sendall('\n') | |||
return output_old, output_new | |||
header_re = re.compile('@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@') | |||
def diff(self, old, new, context=20): | |||
repo = git.Repo(self.path) | |||
#'-y', '-x', 'diff -C10', old, new, path).split('\n'): | |||
oldc = repo.commit(old) | |||
newc = repo.commit(new) | |||
files = [] | |||
for context in oldc.diff(newc, create_patch=True, U=context): | |||
f = DiffFile() | |||
files.append(f) | |||
old_lineno = 0 | |||
new_lineno = 0 | |||
offset = 0 | |||
oldchunk = [] | |||
newchunk = [] | |||
for line in context.diff.split('\n'): | |||
if line.startswith('---'): | |||
f.oldname = line[6:] | |||
continue | |||
if line.startswith('+++'): | |||
f.newname = line[6:] | |||
continue | |||
if line.startswith('@@'): | |||
#socket.sendall(line) | |||
m = self.header_re.match(line) | |||
#socket.sendall(str(m.groups())) | |||
old_lineno = int(m.group(1)) | |||
new_lineno = int(m.group(3)) | |||
continue | |||
if not line: | |||
line = ' ' | |||
key = line[0] | |||
rest = line[1:] | |||
if key == '-': | |||
oldchunk.append(rest) | |||
continue | |||
if key == '+': | |||
newchunk.append(rest) | |||
continue | |||
# end of chunk | |||
if oldchunk or newchunk: | |||
oldchunk, newchunk = self.intraline_diff(oldchunk, newchunk) | |||
for l in oldchunk: | |||
f.oldlines.append((old_lineno, '-', l)) | |||
old_lineno += 1 | |||
offset -= 1 | |||
for l in newchunk: | |||
f.newlines.append((new_lineno, '+', l)) | |||
new_lineno += 1 | |||
offset += 1 | |||
oldchunk = [] | |||
newchunk = [] | |||
while offset > 0: | |||
f.oldlines.append((None, '', '')) | |||
offset -= 1 | |||
while offset < 0: | |||
f.newlines.append((None, '', '')) | |||
offset += 1 | |||
if key == ' ': | |||
f.oldlines.append((old_lineno, ' ', rest)) | |||
f.newlines.append((new_lineno, ' ', rest)) | |||
old_lineno += 1 | |||
new_lineno += 1 | |||
continue | |||
raise Exception("Unhandled line: %s" % line) | |||
return files |
@ -0,0 +1,61 @@ | |||
# Copyright 2014 OpenStack Foundation | |||
# | |||
# Licensed under the Apache License, Version 2.0 (the "License"); you may | |||
# not use this file except in compliance with the License. You may obtain | |||
# a copy of the License at | |||
# | |||
# http://www.apache.org/licenses/LICENSE-2.0 | |||
# | |||
# Unless required by applicable law or agreed to in writing, software | |||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |||
# License for the specific language governing permissions and limitations | |||
# under the License. | |||
import urwid | |||
class TextButton(urwid.Button): | |||
def selectable(self): | |||
return True | |||
def __init__(self, text, on_press=None, user_data=None): | |||
super(TextButton, self).__init__('', on_press=on_press, user_data=user_data) | |||
text = urwid.Text(text) | |||
self._w = urwid.AttrMap(text, None, focus_map='reversed') | |||
class FixedButton(urwid.Button): | |||
def sizing(self): | |||
return frozenset([urwid.FIXED]) | |||
def pack(self, size, focus=False): | |||
return (len(self.get_label())+4, 1) | |||
class TableColumn(urwid.Pile): | |||
def pack(self, size, focus=False): | |||
mx = max([len(i[0].text) for i in self.contents]) | |||
return (mx+2, len(self.contents)) | |||
class Table(urwid.WidgetWrap): | |||
def __init__(self, headers=[]): | |||
super(Table, self).__init__( | |||
urwid.Columns([('pack', TableColumn([('pack', w)])) for w in headers])) | |||
def addRow(self, cells=[]): | |||
for i, widget in enumerate(cells): | |||
self._w.contents[i][0].contents.append((widget, ('pack', None))) | |||
class MessageDialog(urwid.WidgetWrap): | |||
signals = ['close'] | |||
def __init__(self, title, message): | |||
ok_button = FixedButton(u'OK') | |||
urwid.connect_signal(ok_button, 'click', | |||
lambda button:self._emit('close')) | |||
buttons = urwid.Columns([('pack', ok_button)], | |||
dividechars=2) | |||
rows = [] | |||
rows.append(urwid.Text(message)) | |||
rows.append(urwid.Divider()) | |||
rows.append(buttons) | |||
pile = urwid.Pile(rows) | |||
fill = urwid.Filler(pile, valign='top') | |||
super(MessageDialog, self).__init__(urwid.LineBox(fill, title)) |
@ -0,0 +1,453 @@ | |||
# Copyright 2014 OpenStack Foundation | |||
# | |||
# Licensed under the Apache License, Version 2.0 (the "License"); you may | |||
# not use this file except in compliance with the License. You may obtain | |||
# a copy of the License at | |||
# | |||
# http://www.apache.org/licenses/LICENSE-2.0 | |||
# | |||
# Unless required by applicable law or agreed to in writing, software | |||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT | |||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the | |||
# License for the specific language governing permissions and limitations | |||
# under the License. | |||
import collections | |||
import logging | |||
import math | |||
import os | |||
import threading | |||
import urlparse | |||
import json | |||
import time | |||
import Queue | |||
import datetime | |||
import dateutil.parser | |||
import requests | |||
HIGH_PRIORITY=0 | |||
NORMAL_PRIORITY=1 | |||
LOW_PRIORITY=2 | |||
class MultiQueue(object): | |||
def __init__(self, priorities): | |||
self.queues = collections.OrderedDict() | |||
for key in priorities: | |||
self.queues[key] = collections.deque() | |||
self.condition = threading.Condition() | |||
def qsize(self): | |||
count = 0 | |||
for queue in self.queues.values(): | |||
count += len(queue) | |||
return count | |||
def put(self, item, priority): | |||
self.condition.acquire() | |||
try: | |||
self.queues[priority].append(item) | |||
self.condition.notify() | |||
finally: | |||
self.condition.release() | |||
def get(self): | |||
self.condition.acquire() | |||
try: | |||
while True: | |||
for queue in self.queues.values(): | |||
try: | |||
ret = queue.popleft() | |||
return ret | |||
except IndexError: | |||
pass | |||
self.condition.wait() | |||
finally: | |||
self.condition.release() | |||
class Task(object): | |||
def __init__(self, priority=NORMAL_PRIORITY): | |||
self.log = logging.getLogger('gertty.sync') | |||
self.priority = priority | |||
self.succeeded = None | |||
self.event = threading.Event() | |||
def complete(self, success): | |||
self.succeeded = success | |||
self.event.set() | |||
def wait(self): | |||
self.event.wait() | |||
class SyncProjectListTask(Task): | |||
def __repr__(self): | |||
return '<SyncProjectListTask>' | |||
def run(self, sync): | |||
app = sync.app | |||
with app.db.getSession() as session: | |||
remote = sync.get('projects/?d') | |||
remote_keys = set(remote.keys()) | |||
local = {} | |||
for p in session.getProjects(): | |||
local[p.name] = p | |||
local_keys = set(local.keys()) | |||
for name in local_keys-remote_keys: | |||
session.delete(local[name]) | |||
for name in remote_keys-local_keys: | |||
p = remote[name] | |||
session.createProject(name, description=p.get('description', '')) | |||
class SyncSubscribedProjectsTask(Task): | |||
def __repr__(self): | |||
return '<SyncSubscribedProjectsTask>' | |||
def run(self, sync): | |||
app = sync.app | |||
with app.db.getSession() as session: | |||
for p in session.getProjects(subscribed=True): | |||
sync.submitTask(SyncProjectTask(p.key, self.priority)) | |||
class SyncProjectTask(Task): | |||
_closed_statuses = ['MERGED', 'ABANDONED'] | |||
def __init__(self, project_key, priority=NORMAL_PRIORITY): | |||
super(SyncProjectTask, self).__init__(priority) | |||
self.project_key = project_key | |||
def __repr__(self): | |||
return '<SyncProjectTask %s>' % (self.project_key,) | |||
def run(self, sync): | |||
app = sync.app | |||
with app.db.getSession() as session: | |||
project = session.getProject(self.project_key) | |||
query = 'project:%s' % project.name | |||
if project.updated: | |||
query += ' -age:%ss' % (int(math.ceil((datetime.datetime.utcnow()-project.updated).total_seconds())) + 0,) | |||
changes = sync.get('changes/?q=%s' % query) | |||
self.log.debug('Query: %s ' % (query,)) | |||
for c in reversed(changes): | |||
# The list we get is newest to oldest; if we are | |||
# interrupted, we will have already synced the newest | |||
# change and a subsequent sync will not catch up the | |||
# old ones. So reverse the list before we process it | |||
# so that the updated time is accurate. | |||
# For now, just sync open changes or changes already | |||
# in the db optionally we could sync all changes ever | |||
change = session.getChangeByID(c['id']) | |||
if change or (c['status'] not in self._closed_statuses): | |||
sync.submitTask(SyncChangeTask(c['id'], self.priority)) | |||
self.log.debug("Change %s update %s" % (c['id'], c['updated'])) | |||
class SyncChangeTask(Task): | |||
def __init__(self, change_id, priority=NORMAL_PRIORITY): | |||
super(SyncChangeTask, self).__init__(priority) | |||
self.change_id = change_id | |||
def __repr__(self): | |||
return '<SyncChangeTask %s>' % (self.change_id,) | |||
def run(self, sync): | |||
app = sync.app | |||
remote_change = sync.get('changes/%s?o=DETAILED_LABELS&o=ALL_REVISIONS&o=ALL_COMMITS&o=MESSAGES&o=DETAILED_ACCOUNTS' % self.change_id) | |||
fetches = [] | |||
with app.db.getSession() as session: | |||
change = session.getChangeByID(self.change_id) | |||
if not change: | |||
project = session.getProjectByName(remote_change['project']) | |||
created = dateutil.parser.parse(remote_change['created']) | |||
updated = dateutil.parser.parse(remote_change['updated']) | |||
change = project.createChange(remote_change['id'], remote_change['_number'], | |||
remote_change['branch'], remote_change['change_id'], | |||
remote_change['owner']['name'], | |||
remote_change['subject'], created, | |||
updated, remote_change['status'], | |||
topic=remote_change.get('topic')) | |||
change.status = remote_change['status'] | |||
change.subject = remote_change['subject'] | |||
change.updated = dateutil.parser.parse(remote_change['updated']) | |||
change.topic = remote_change.get('topic') | |||
repo = app.getRepo(change.project.name) | |||
new_revision = False | |||
for remote_commit, remote_revision in remote_change.get('revisions', {}).items(): | |||
revision = session.getRevisionByCommit(remote_commit) | |||
if not revision: | |||
# TODO: handle multiple parents | |||
url = sync.app.config.url + change.project.name | |||
if 'anonymous http' in remote_revision['fetch']: | |||
ref = remote_revision['fetch']['anonymous http']['ref'] | |||
else: | |||
ref = remote_revision['fetch']['http']['ref'] | |||
url = list(urlparse.urlsplit(url)) | |||
url[1] = '%s:%s@%s' % (sync.app.config.username, | |||
sync.app.config.password, url[1]) | |||
url = urlparse.urlunsplit(url) | |||
fetches.append((url, ref)) | |||
revision = change.createRevision(remote_revision['_number'], | |||
remote_revision['commit']['message'], remote_commit, | |||
remote_revision['commit']['parents'][0]['commit']) | |||
new_revision = True | |||
remote_comments = sync.get('changes/%s/revisions/%s/comments' % (self.change_id, revision.commit)) | |||
for remote_file, remote_comments in remote_comments.items(): | |||
for remote_comment in remote_comments: | |||
comment = session.getCommentByID(remote_comment['id']) | |||
if not comment: | |||
# Normalize updated -> created | |||
created = dateutil.parser.parse(remote_comment['updated']) | |||
parent = False | |||
if remote_comment.get('side', '') == 'PARENT': | |||
parent = True | |||
comment = revision.createComment(remote_comment['id'], | |||
remote_comment.get('in_reply_to'), | |||
created, remote_comment['author']['name'], | |||
remote_file, parent, remote_comment.get('line'), | |||
remote_comment['message']) | |||
new_message = False | |||
for remote_message in remote_change.get('messages', []): | |||
message = session.getMessageByID(remote_message['id']) | |||
if not message: | |||
revision = session.getRevisionByNumber(change, remote_message['_revision_number']) | |||
# Normalize date -> created | |||
created = dateutil.parser.parse(remote_message['date']) | |||
if 'author' in remote_message: | |||
author_name = remote_message['author']['name'] | |||
if remote_message['author']['username'] != app.config.username: | |||
new_message = True | |||
else: | |||
author_name = 'Gerrit Code Review' | |||
message = revision.createMessage(remote_message['id'], created, | |||
author_name, | |||
remote_message['message']) | |||
remote_approval_entries = {} | |||
remote_label_entries = {} | |||
user_voted = False | |||
for remote_label_name, remote_label_dict in remote_change.get('labels', {}).items(): | |||
for remote_approval in remote_label_dict.get('all', []): | |||
if remote_approval.get('value') is None: | |||
continue | |||
remote_approval['category'] = remote_label_name | |||
key = '%s~%s' % (remote_approval['category'], remote_approval['name']) | |||
remote_approval_entries[key] = remote_approval | |||
if remote_approval.get('username', None) == app.config.username and int(remote_approval['value']) != 0: | |||
user_voted = True | |||
for key, value in remote_label_dict.get('values', {}).items(): | |||
# +1: "LGTM" | |||
label = dict(value=key, | |||
description=value, | |||
category=remote_label_name) | |||
key = '%s~%s~%s' % (label['category'], label['value'], label['description']) | |||
remote_label_entries[key] = label | |||
remote_approval_keys = set(remote_approval_entries.keys()) | |||
remote_label_keys = set(remote_label_entries.keys()) | |||
local_approvals = {} | |||
local_labels = {} | |||
for approval in change.approvals: | |||
key = '%s~%s' % (approval.category, approval.name) | |||
local_approvals[key] = approval | |||
local_approval_keys = set(local_approvals.keys()) | |||
for label in change.labels: | |||
key = '%s~%s~%s' % (label.category, label.value, label.description) | |||
local_labels[key] = label | |||
local_label_keys = set(local_labels.keys()) | |||
for key in local_approval_keys-remote_approval_keys: | |||
session.delete(local_approvals[key]) | |||
for key in local_label_keys-remote_label_keys: | |||
session.delete(local_labels[key]) | |||
for key in remote_approval_keys-local_approval_keys: | |||
remote_approval = remote_approval_entries[key] | |||
change.createApproval(remote_approval['name'], | |||
remote_approval['category'], | |||
remote_approval['value']) | |||
for key in remote_label_keys-local_label_keys: | |||
remote_label = remote_label_entries[key] | |||
change.createLabel(remote_label['category'], | |||
remote_label['value'], | |||
remote_label['description']) | |||
for key in remote_approval_keys.intersection(local_approval_keys): | |||
local_approval = local_approvals[key] | |||
remote_approval = remote_approval_entries[key] | |||
local_approval.value = remote_approval['value'] | |||
remote_permitted_entries = {} | |||
for remote_label_name, remote_label_values in remote_change.get('permitted_labels', {}).items(): | |||
for remote_label_value in remote_label_values: | |||
remote_label = dict(category=remote_label_name, | |||
value=remote_label_value) | |||
key = '%s~%s' % (remote_label['category'], remote_label['value']) | |||
remote_permitted_entries[key] = remote_label | |||
remote_permitted_keys = set(remote_permitted_entries.keys()) | |||
local_permitted = {} | |||
for permitted in change.permitted_labels: | |||
key = '%s~%s' % (permitted.category, permitted.value) | |||
local_permitted[key] = permitted | |||
local_permitted_keys = set(local_permitted.keys()) | |||
for key in local_permitted_keys-remote_permitted_keys: | |||
session.delete(local_permitted[key]) | |||
for key in remote_permitted_keys-local_permitted_keys: | |||
remote_permitted = remote_permitted_entries[key] | |||
change.createPermittedLabel(remote_permitted['category'], | |||
remote_permitted['value']) | |||
if not user_voted: | |||
# Only consider changing the reviewed state if we don't have a vote | |||
if new_revision or new_message: | |||
change.reviewed = False | |||
for (url, ref) in fetches: | |||
self.log.debug("git fetch %s %s" % (url, ref)) | |||
repo.fetch(url, ref) | |||
class UploadReviewsTask(Task): | |||
def __repr__(self): | |||