From 1d6b0fd88165938b11be1abd5ffaeeec16278c9e Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Tue, 29 Apr 2014 16:28:49 -0700 Subject: [PATCH] Initial commit Change-Id: Ie79f257c46a2c50abdd7ce63bfeceaad976ca878 --- .gitignore | 1 + LICENSE | 202 ++++++++++++++++ README.rst | 111 +++++++++ gertty/__init__.py | 0 gertty/config.py | 46 ++++ gertty/db.py | 446 +++++++++++++++++++++++++++++++++++ gertty/gertty.py | 186 +++++++++++++++ gertty/gitrepo.py | 196 ++++++++++++++++ gertty/mywid.py | 61 +++++ gertty/sync.py | 453 ++++++++++++++++++++++++++++++++++++ gertty/view/__init__.py | 0 gertty/view/change.py | 372 +++++++++++++++++++++++++++++ gertty/view/change_list.py | 140 +++++++++++ gertty/view/diff.py | 261 +++++++++++++++++++++ gertty/view/project_list.py | 127 ++++++++++ requirements.txt | 5 + 16 files changed, 2607 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 gertty/__init__.py create mode 100644 gertty/config.py create mode 100644 gertty/db.py create mode 100644 gertty/gertty.py create mode 100644 gertty/gitrepo.py create mode 100644 gertty/mywid.py create mode 100644 gertty/sync.py create mode 100644 gertty/view/__init__.py create mode 100644 gertty/view/change.py create mode 100644 gertty/view/change_list.py create mode 100644 gertty/view/diff.py create mode 100644 gertty/view/project_list.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75b5248 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f828874 --- /dev/null +++ b/README.rst @@ -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= + 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 diff --git a/gertty/__init__.py b/gertty/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gertty/config.py b/gertty/config.py new file mode 100644 index 0000000..02c412b --- /dev/null +++ b/gertty/config.py @@ -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') diff --git a/gertty/db.py b/gertty/db.py new file mode 100644 index 0000000..b7e3c97 --- /dev/null +++ b/gertty/db.py @@ -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 diff --git a/gertty/gertty.py b/gertty/gertty.py new file mode 100644 index 0000000..9a63b52 --- /dev/null +++ b/gertty/gertty.py @@ -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) diff --git a/gertty/gitrepo.py b/gertty/gitrepo.py new file mode 100644 index 0000000..57fe18e --- /dev/null +++ b/gertty/gitrepo.py @@ -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 diff --git a/gertty/mywid.py b/gertty/mywid.py new file mode 100644 index 0000000..e7370ea --- /dev/null +++ b/gertty/mywid.py @@ -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)) diff --git a/gertty/sync.py b/gertty/sync.py new file mode 100644 index 0000000..40b9970 --- /dev/null +++ b/gertty/sync.py @@ -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 '' + + 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 '' + + 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 '' % (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 '' % (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): + return '' + + def run(self, sync): + app = sync.app + with app.db.getSession() as session: + for m in session.getPendingMessages(): + sync.submitTask(UploadReviewTask(m.key, self.priority)) + +class UploadReviewTask(Task): + def __init__(self, message_key, priority=NORMAL_PRIORITY): + super(UploadReviewTask, self).__init__(priority) + self.message_key = message_key + + def __repr__(self): + return '' % (self.message_key,) + + def run(self, sync): + app = sync.app + with app.db.getSession() as session: + message = session.getMessage(self.message_key) + revision = message.revision + change = message.revision.change + current_revision = change.revisions[-1] + data = dict(message=message.message, + strict_labels=False) + if revision == current_revision: + data['labels'] = {} + for approval in change.pending_approvals: + data['labels'][approval.category] = approval.value + session.delete(approval) + if revision.pending_comments: + data['comments'] = {} + last_file = None + comment_list = [] + for comment in revision.pending_comments: + if comment.file != last_file: + last_file = comment.file + comment_list = [] + data['comments'][comment.file] = comment_list + d = dict(line=comment.line, + message=comment.message) + if comment.parent: + d['side'] = 'PARENT' + comment_list.append(d) + session.delete(comment) + session.delete(message) + sync.post('changes/%s/revisions/%s/review' % (change.id, revision.commit), + data) + sync.submitTask(SyncChangeTask(change.id, self.priority)) + +class Sync(object): + def __init__(self, app): + self.offline = False + self.app = app + self.log = logging.getLogger('gertty.sync') + self.queue = MultiQueue([HIGH_PRIORITY, NORMAL_PRIORITY, LOW_PRIORITY]) + self.submitTask(SyncProjectListTask(HIGH_PRIORITY)) + self.submitTask(SyncSubscribedProjectsTask(HIGH_PRIORITY)) + self.submitTask(UploadReviewsTask(HIGH_PRIORITY)) + self.periodic_thread = threading.Thread(target=self.periodicSync) + self.periodic_thread.start() + + def periodicSync(self): + while True: + try: + time.sleep(60) + self.syncSubscribedProjects() + except Exception: + self.log.exception('Exception in periodicSync') + + def submitTask(self, task): + if not self.offline: + self.queue.put(task, task.priority) + + def run(self, pipe): + task = None + while True: + task = self._run(pipe, task) + + def _run(self, pipe, task=None): + if not task: + task = self.queue.get() + self.log.debug('Run: %s' % (task,)) + try: + task.run(self) + task.complete(True) + except requests.ConnectionError, e: + self.log.warning("Offline due to: %s" % (e,)) + if not self.offline: + self.submitTask(SyncSubscribedProjectsTask(HIGH_PRIORITY)) + self.submitTask(UploadReviewsTask(HIGH_PRIORITY)) + self.offline = True + self.app.status.update(offline=True) + os.write(pipe, 'refresh\n') + time.sleep(30) + return task + except Exception: + task.complete(False) + self.log.exception('Exception running task %s' % (task,)) + self.app.status.update(error=True) + self.offline = False + self.app.status.update(offline=False) + os.write(pipe, 'refresh\n') + return None + + def url(self, path): + return self.app.config.url + 'a/' + path + + def get(self, path): + url = self.url(path) + self.log.debug('GET: %s' % (url,)) + r = requests.get(url, + verify=self.app.config.verify_ssl, + auth=requests.auth.HTTPDigestAuth(self.app.config.username, + self.app.config.password), + headers = {'Accept': 'application/json', + 'Accept-Encoding': 'gzip'}) + self.log.debug('Received: %s' % (r.text,)) + ret = json.loads(r.text[4:]) + return ret + + def post(self, path, data): + url = self.url(path) + self.log.debug('POST: %s' % (url,)) + self.log.debug('data: %s' % (data,)) + r = requests.post(url, data=json.dumps(data).encode('utf8'), + verify=self.app.config.verify_ssl, + auth=requests.auth.HTTPDigestAuth(self.app.config.username, + self.app.config.password), + headers = {'Content-Type': 'application/json;charset=UTF-8'}) + self.log.debug('Received: %s' % (r.text,)) + + def syncSubscribedProjects(self): + keys = [] + with self.app.db.getSession() as session: + for p in session.getProjects(subscribed=True): + keys.append(p.key) + for key in keys: + t = SyncProjectTask(key, LOW_PRIORITY) + self.submitTask(t) + t.wait() diff --git a/gertty/view/__init__.py b/gertty/view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gertty/view/change.py b/gertty/view/change.py new file mode 100644 index 0000000..f2f34b8 --- /dev/null +++ b/gertty/view/change.py @@ -0,0 +1,372 @@ +# 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 datetime + +import urwid + +import gitrepo +import mywid +import sync +import view.diff + +class ReviewDialog(urwid.WidgetWrap): + signals = ['save', 'cancel'] + def __init__(self, revision_row): + self.revision_row = revision_row + self.change_view = revision_row.change_view + self.app = self.change_view.app + save_button = mywid.FixedButton(u'Save') + cancel_button = mywid.FixedButton(u'Cancel') + urwid.connect_signal(save_button, 'click', + lambda button:self._emit('save')) + urwid.connect_signal(cancel_button, 'click', + lambda button:self._emit('cancel')) + buttons = urwid.Columns([('pack', save_button), ('pack', cancel_button)], + dividechars=2) + rows = [] + categories = [] + values = {} + self.button_groups = {} + message = '' + with self.app.db.getSession() as session: + revision = session.getRevision(self.revision_row.revision_key) + change = revision.change + if revision == change.revisions[-1]: + for label in change.permitted_labels: + if label.category not in categories: + categories.append(label.category) + values[label.category] = [] + values[label.category].append(label.value) + pending_approvals = {} + for approval in change.pending_approvals: + pending_approvals[approval.category] = approval + for category in categories: + rows.append(urwid.Text(category)) + group = [] + self.button_groups[category] = group + current = pending_approvals.get(category) + if current is None: + current = 0 + else: + current = current.value + for value in values[category]: + if value > 0: + strvalue = '+%s' % value + elif value == 0: + strvalue = ' 0' + else: + strvalue = str(value) + b = urwid.RadioButton(group, strvalue, state=(value == current)) + rows.append(b) + rows.append(urwid.Divider()) + for m in revision.messages: + if m.pending: + message = m.message + break + self.message = urwid.Edit("Message: \n", edit_text=message, multiline=True) + rows.append(self.message) + rows.append(urwid.Divider()) + rows.append(buttons) + pile = urwid.Pile(rows) + fill = urwid.Filler(pile, valign='top') + super(ReviewDialog, self).__init__(urwid.LineBox(fill, 'Review')) + + def save(self): + message_key = None + with self.app.db.getSession() as session: + revision = session.getRevision(self.revision_row.revision_key) + change = revision.change + pending_approvals = {} + for approval in change.pending_approvals: + pending_approvals[approval.category] = approval + for category, group in self.button_groups.items(): + approval = pending_approvals.get(category) + if not approval: + approval = change.createApproval(u'(draft)', category, 0, pending=True) + pending_approvals[category] = approval + for button in group: + if button.state: + approval.value = int(button.get_label()) + message = None + for m in revision.messages: + if m.pending: + message = m + break + if not message: + message = revision.createMessage(None, + datetime.datetime.utcnow(), + u'(draft)', '', pending=True) + message.message = self.message.edit_text.strip() + message_key = message.key + change.reviewed = True + return message_key + + def keypress(self, size, key): + r = super(ReviewDialog, self).keypress(size, key) + if r=='esc': + self._emit('cancel') + return None + return r + +class ReviewButton(mywid.FixedButton): + def __init__(self, revision_row): + super(ReviewButton, self).__init__(u'Review') + self.revision_row = revision_row + self.change_view = revision_row.change_view + urwid.connect_signal(self, 'click', + lambda button: self.openReview()) + + def openReview(self): + self.dialog = ReviewDialog(self.revision_row) + urwid.connect_signal(self.dialog, 'save', + lambda button: self.closeReview(True)) + urwid.connect_signal(self.dialog, 'cancel', + lambda button: self.closeReview(False)) + self.change_view.app.popup(self.dialog, + relative_width=50, relative_height=75, + min_width=60, min_height=20) + + def closeReview(self, save): + if save: + message_key = self.dialog.save() + self.change_view.app.sync.submitTask( + sync.UploadReviewTask(message_key, sync.HIGH_PRIORITY)) + self.change_view.refresh() + self.change_view.app.backScreen() + +class RevisionRow(urwid.WidgetWrap): + revision_focus_map = { + 'revision-name': 'reversed-revision-name', + 'revision-commit': 'reversed-revision-commit', + 'revision-drafts': 'reversed-revision-drafts', + } + + def __init__(self, app, change_view, repo, revision, expanded=False): + super(RevisionRow, self).__init__(urwid.Pile([])) + self.app = app + self.change_view = change_view + self.revision_key = revision.key + self.project_name = revision.change.project.name + self.commit_sha = revision.commit + line = [('revision-name', 'Patch Set %s ' % revision.number), + ('revision-commit', revision.commit)] + if len(revision.pending_comments): + line.append(('revision-drafts', ' (%s drafts)' % len(revision.pending_comments))) + self.title = mywid.TextButton(line, on_press = self.expandContract) + stats = repo.diffstat(revision.parent, revision.commit) + rows = [] + total_added = 0 + total_removed = 0 + for added, removed, filename in stats: + total_added += int(added) + total_removed += int(removed) + rows.append(urwid.Columns([urwid.Text(filename), + (10, urwid.Text('+%s, -%s' % (added, removed))), + ])) + rows.append(urwid.Columns([urwid.Text(''), + (10, urwid.Text('+%s, -%s' % (total_added, total_removed))), + ])) + table = urwid.Pile(rows) + buttons = urwid.Columns([('pack', ReviewButton(self)), + ('pack', mywid.FixedButton("Diff", on_press=self.diff)), + ('pack', mywid.FixedButton("Checkout", on_press=self.checkout)), + urwid.Text(''), + ], dividechars=2) + self.more = urwid.Pile([table, buttons]) + self.pile = urwid.Pile([self.title]) + self._w = urwid.AttrMap(self.pile, None, focus_map=self.revision_focus_map) + self.expanded = False + if expanded: + self.expandContract(None) + + def expandContract(self, button): + if self.expanded: + self.pile.contents.pop() + self.expanded = False + else: + self.pile.contents.append((self.more, ('pack', None))) + self.expanded = True + + def diff(self, button): + self.change_view.diff(self.revision_key) + + def checkout(self, button): + repo = self.app.getRepo(self.project_name) + try: + repo.checkout(self.commit_sha) + dialog = mywid.MessageDialog('Checkout', 'Change checked out in %s' % repo.path) + min_height=8 + except gitrepo.GitCheckoutError as e: + dialog = mywid.MessageDialog('Error', e.msg) + min_height=12 + urwid.connect_signal(dialog, 'close', + lambda button: self.app.backScreen()) + self.app.popup(dialog, min_height=min_height) + +class ChangeMessageBox(urwid.Text): + def __init__(self, message): + super(ChangeMessageBox, self).__init__(u'') + lines = message.message.split('\n') + text = [('change-message-name', message.name), + ('change-message-header', ': '+lines.pop(0))] + if lines and lines[-1]: + lines.append('') + text += '\n'.join(lines) + self.set_text(text) + +class ChangeView(urwid.WidgetWrap): + help = """ + Toggle the reviewed flag for the current change. + Go back to the previous screen. +""" + + def __init__(self, app, change_key): + super(ChangeView, self).__init__(urwid.Pile([])) + self.app = app + self.change_key = change_key + self.revision_rows = {} + self.message_rows = {} + self.change_id_label = urwid.Text(u'', wrap='clip') + self.owner_label = urwid.Text(u'', wrap='clip') + self.project_label = urwid.Text(u'', wrap='clip') + self.branch_label = urwid.Text(u'', wrap='clip') + self.topic_label = urwid.Text(u'', wrap='clip') + self.created_label = urwid.Text(u'', wrap='clip') + self.updated_label = urwid.Text(u'', wrap='clip') + self.status_label = urwid.Text(u'', wrap='clip') + change_info = [] + for l, v in [("Change-Id", self.change_id_label), + ("Owner", self.owner_label), + ("Project", self.project_label), + ("Branch", self.branch_label), + ("Topic", self.topic_label), + ("Created", self.created_label), + ("Updated", self.updated_label), + ("Status", self.status_label), + ]: + row = urwid.Columns([(12, urwid.Text(('change-header', l), wrap='clip')), v]) + change_info.append(row) + change_info = urwid.Pile(change_info) + self.commit_message = urwid.Text(u'') + top = urwid.Columns([change_info, ('weight', 1, self.commit_message)]) + votes = mywid.Table([]) + + self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) + self._w.contents.append((self.app.header, ('pack', 1))) + self._w.contents.append((urwid.Divider(), ('pack', 1))) + self._w.contents.append((top, ('pack', None))) + self._w.contents.append((urwid.Divider(), ('pack', 1))) + self._w.contents.append((votes, ('pack', None))) + self._w.contents.append((urwid.Divider(), ('pack', 1))) + self._w.contents.append((self.listbox, ('weight', 1))) + self._w.set_focus(6) + + self.refresh() + + def refresh(self): + change_info = [] + with self.app.db.getSession() as session: + change = session.getChange(self.change_key) + if change.reviewed: + reviewed = ' (reviewed)' + else: + reviewed = '' + self.title = 'Change %s%s' % (change.number, reviewed) + self.app.status.update(title=self.title) + self.project_key = change.project.key + + self.change_id_label.set_text(('change-data', change.change_id)) + self.owner_label.set_text(('change-data', change.owner)) + self.project_label.set_text(('change-data', change.project.name)) + self.branch_label.set_text(('change-data', change.branch)) + self.topic_label.set_text(('change-data', change.topic or '')) + self.created_label.set_text(('change-data', str(change.created))) + self.updated_label.set_text(('change-data', str(change.updated))) + self.status_label.set_text(('change-data', change.status)) + self.commit_message.set_text(change.revisions[-1].message) + + categories = [] + approval_headers = [urwid.Text(('table-header', 'Name'))] + for label in change.labels: + if label.category in categories: + continue + approval_headers.append(urwid.Text(('table-header', label.category))) + categories.append(label.category) + votes = mywid.Table(approval_headers) + approvals_for_name = {} + for approval in change.approvals: + approvals = approvals_for_name.get(approval.name) + if not approvals: + approvals = {} + row = [] + row.append(urwid.Text(approval.name)) + for i, category in enumerate(categories): + w = urwid.Text(u'') + approvals[category] = w + row.append(w) + approvals_for_name[approval.name] = approvals + votes.addRow(row) + if str(approval.value) != '0': + approvals[approval.category].set_text(str(approval.value)) + votes = urwid.Padding(votes, width='pack') + + # TODO: update the existing table rather than replacing it + # wholesale. It will become more important if the table + # gets selectable items (like clickable names). + self._w.contents[4] = (votes, ('pack', None)) + + repo = self.app.getRepo(change.project.name) + # The listbox has both revisions and messages in it (and + # may later contain the vote table and change header), so + # keep track of the index separate from the loop. + listbox_index = 0 + for revno, revision in enumerate(change.revisions): + row = self.revision_rows.get(revision.key) + if not row: + row = RevisionRow(self.app, self, repo, revision, + expanded=(revno==len(change.revisions)-1)) + self.listbox.body.insert(listbox_index, row) + self.revision_rows[revision.key] = row + # Revisions are extremely unlikely to be deleted, skip + # that case. + listbox_index += 1 + if len(self.listbox.body) == listbox_index: + self.listbox.body.insert(listbox_index, urwid.Divider()) + listbox_index += 1 + for message in change.messages: + row = self.message_rows.get(message.key) + if not row: + row = ChangeMessageBox(message) + self.listbox.body.insert(listbox_index, row) + self.message_rows[message.key] = row + # Messages are extremely unlikely to be deleted, skip + # that case. + listbox_index += 1 + + def toggleReviewed(self): + with self.app.db.getSession() as session: + change = session.getChange(self.change_key) + change.reviewed = not change.reviewed + + def keypress(self, size, key): + r = super(ChangeView, self).keypress(size, key) + if r=='r': + self.toggleReviewed() + self.refresh() + return None + return r + + def diff(self, revision_key): + self.app.changeScreen(view.diff.DiffView(self.app, revision_key)) diff --git a/gertty/view/change_list.py b/gertty/view/change_list.py new file mode 100644 index 0000000..4d3b194 --- /dev/null +++ b/gertty/view/change_list.py @@ -0,0 +1,140 @@ +# 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 + +import view.change + +class ChangeRow(urwid.Button): + change_focus_map = {None: 'reversed', + 'unreviewed-change': 'reversed-unreviewed-change', + 'reviewed-change': 'reversed-reviewed-change', + } + + def selectable(self): + return True + + def __init__(self, change, callback=None): + super(ChangeRow, self).__init__('', on_press=callback, user_data=change.key) + self.change_key = change.key + self.subject = urwid.Text(u'', wrap='clip') + self.number = urwid.Text(u'') + cols = [(8, self.number), self.subject] + self.columns = urwid.Columns(cols) + self.row_style = urwid.AttrMap(self.columns, '') + self._w = urwid.AttrMap(self.row_style, None, focus_map=self.change_focus_map) + self.update(change) + + def update(self, change): + if change.reviewed: + style = 'reviewed-change' + else: + style = 'unreviewed-change' + self.row_style.set_attr_map({None: style}) + self.subject.set_text(change.subject) + self.number.set_text(str(change.number)) + del self.columns.contents[2:] + for category in change.getCategories(): + v = change.getMaxForCategory(category) + if v == 0: + v = '' + else: + v = '%2i' % v + self.columns.contents.append((urwid.Text(v), self.columns.options('given', 3))) + +class ChangeListHeader(urwid.WidgetWrap): + def __init__(self): + cols = [(8, urwid.Text(u'Number')), urwid.Text(u'Subject')] + super(ChangeListHeader, self).__init__(urwid.Columns(cols)) + + def update(self, change): + del self._w.contents[2:] + for category in change.getCategories(): + self._w.contents.append((urwid.Text(' %s' % category[0]), self._w.options('given', 3))) + +class ChangeListView(urwid.WidgetWrap): + help = """ + Toggle whether only unreviewed or all changes are displayed. + Toggle the reviewed flag for the currently selected change. + Go back to the previous screen. +""" + + def __init__(self, app, project_key): + super(ChangeListView, self).__init__(urwid.Pile([])) + self.app = app + self.project_key = project_key + self.unreviewed = True + self.change_rows = {} + self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) + self.header = ChangeListHeader() + self.refresh() + self._w.contents.append((app.header, ('pack', 1))) + self._w.contents.append((urwid.Divider(), ('pack', 1))) + self._w.contents.append((urwid.AttrWrap(self.header, 'table-header'), ('pack', 1))) + self._w.contents.append((self.listbox, ('weight', 1))) + self._w.set_focus(3) + + def refresh(self): + unseen_keys = set(self.change_rows.keys()) + with self.app.db.getSession() as session: + project = session.getProject(self.project_key) + self.project_name = project.name + if self.unreviewed: + self.title = u'Unreviewed changes in %s' % project.name + lst = project.unreviewed_changes + else: + self.title = u'Open changes in %s' % project.name + lst = project.changes + self.app.status.update(title=self.title) + i = 0 + for change in lst: + row = self.change_rows.get(change.key) + if not row: + row = ChangeRow(change, self.onSelect) + self.listbox.body.insert(i, row) + self.change_rows[change.key] = row + else: + row.update(change) + unseen_keys.remove(change.key) + i += 1 + if project.changes: + self.header.update(project.changes[0]) + for key in unseen_keys: + row = self.change_rows[key] + self.listbox.body.remove(row) + del self.change_rows[key] + + def toggleReviewed(self, change_key): + with self.app.db.getSession() as session: + change = session.getChange(change_key) + change.reviewed = not change.reviewed + ret = change.reviewed + return ret + + def keypress(self, size, key): + if key=='l': + self.unreviewed = not self.unreviewed + self.refresh() + return None + if key=='r': + if not len(self.listbox.body): + return None + pos = self.listbox.focus_position + reviewed = self.toggleReviewed(self.listbox.body[pos].change_key) + self.refresh() + return None + return super(ChangeListView, self).keypress(size, key) + + def onSelect(self, button, change_key): + self.app.changeScreen(view.change.ChangeView(self.app, change_key)) diff --git a/gertty/view/diff.py b/gertty/view/diff.py new file mode 100644 index 0000000..589d078 --- /dev/null +++ b/gertty/view/diff.py @@ -0,0 +1,261 @@ +# 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 datetime + +import urwid + +class LineContext(object): + def __init__(self, old_revision_key, new_revision_key, + old_revision_num, new_revision_num, + old_fn, new_fn, old_ln, new_ln): + self.old_revision_key = old_revision_key + self.new_revision_key = new_revision_key + self.old_revision_num = old_revision_num + self.new_revision_num = new_revision_num + self.old_fn = old_fn + self.new_fn = new_fn + self.old_ln = old_ln + self.new_ln = new_ln + +class DiffCommentEdit(urwid.Columns): + def __init__(self, context, old_key=None, new_key=None, old=u'', new=u''): + super(DiffCommentEdit, self).__init__([]) + self.context = context + # If we save a comment, the resulting key will be stored here + self.old_key = old_key + self.new_key = new_key + self.old = urwid.Edit(edit_text=old, multiline=True) + self.new = urwid.Edit(edit_text=new, multiline=True) + self.contents.append((urwid.Text(u''), ('given', 4, False))) + self.contents.append((urwid.AttrMap(self.old, 'draft-comment'), ('weight', 1, False))) + self.contents.append((urwid.Text(u''), ('given', 4, False))) + self.contents.append((urwid.AttrMap(self.new, 'draft-comment'), ('weight', 1, False))) + self.focus_position = 3 + + def keypress(self, size, key): + r = super(DiffCommentEdit, self).keypress(size, key) + if r in ['tab', 'shift tab']: + if self.focus_position == 3: + self.focus_position = 1 + else: + self.focus_position = 3 + return None + return r + +class DiffComment(urwid.Columns): + def __init__(self, context, old, new): + super(DiffComment, self).__init__([]) + self.context = context + self.old = urwid.AttrMap(urwid.Text(old), 'comment') + self.new = urwid.AttrMap(urwid.Text(new), 'comment') + self.contents.append((urwid.Text(u''), ('given', 4, False))) + self.contents.append((self.old, ('weight', 1, False))) + self.contents.append((urwid.Text(u''), ('given', 4, False))) + self.contents.append((self.new, ('weight', 1, False))) + +class DiffLine(urwid.Button): + def selectable(self): + return True + + def __init__(self, app, context, old, new, callback=None): + super(DiffLine, self).__init__('', on_press=callback) + self.context = context + columns = [] + for (ln, action, line) in (old, new): + if ln is None: + ln = '' + else: + ln = str(ln) + ln_col = urwid.Text(ln) + ln_col.set_wrap_mode('clip') + line_col = urwid.Text(line) + line_col.set_wrap_mode('clip') + if action == '': + line_col = urwid.AttrMap(line_col, 'nonexistent') + columns += [(4, ln_col), line_col] + col = urwid.Columns(columns) + map = {None: 'reversed', + 'added-line': 'reversed-added-line', + 'added-word': 'reversed-added-word', + 'removed-line': 'reversed-removed-line', + 'removed-word': 'reversed-removed-word', + 'nonexistent': 'reversed-nonexistent', + } + self._w = urwid.AttrMap(col, None, focus_map=map) + +class DiffView(urwid.WidgetWrap): + help = """ + Add an inline comment. + Go back to the previous screen. +""" + + def __init__(self, app, new_revision_key): + super(DiffView, self).__init__(urwid.Pile([])) + self.app = app + self.new_revision_key = new_revision_key + with self.app.db.getSession() as session: + revision = session.getRevision(new_revision_key) + self.title = u'Diff of %s change %s patchset %s' % ( + revision.change.project.name, + revision.change.number, + revision.number) + self.new_revision_num = revision.number + self.change_key = revision.change.key + self.project_name = revision.change.project.name + self.parent = revision.parent + self.commit = revision.commit + comment_lists = {} + for comment in revision.comments: + if comment.parent: + key = 'old' + else: + key = 'new' + if comment.pending: + key += 'draft' + key += '-' + str(comment.line) + key += '-' + str(comment.file) + comment_list = comment_lists.get(key, []) + comment_list.append((comment.key, comment.message)) + comment_lists[key] = comment_list + repo = self.app.getRepo(self.project_name) + self._w.contents.append((app.header, ('pack', 1))) + self._w.contents.append((urwid.Divider(), ('pack', 1))) + lines = [] + # this is a list of files: + for i, diff in enumerate(repo.diff(self.parent, self.commit)): + if i > 0: + lines.append(urwid.Text('')) + lines.append(urwid.Columns([ + urwid.Text(diff.oldname), + urwid.Text(diff.newname)])) + for i, old in enumerate(diff.oldlines): + new = diff.newlines[i] + context = LineContext( + None, self.new_revision_key, + None, self.new_revision_num, + diff.oldname, diff.newname, + old[0], new[0]) + lines.append(DiffLine(self.app, context, old, new, + callback=self.onSelect)) + # see if there are any comments for this line + key = 'old-%s-%s' % (old[0], diff.oldname) + old_list = comment_lists.get(key, []) + key = 'new-%s-%s' % (old[0], diff.oldname) + new_list = comment_lists.get(key, []) + while old_list or new_list: + old_comment_key = new_comment_key = None + old_comment = new_comment = u'' + if old_list: + (old_comment_key, old_comment) = old_list.pop(0) + if new_list: + (new_comment_key, new_comment) = new_list.pop(0) + lines.append(DiffComment(context, old_comment, new_comment)) + # see if there are any draft comments for this line + key = 'olddraft-%s-%s' % (old[0], diff.oldname) + old_list = comment_lists.get(key, []) + key = 'newdraft-%s-%s' % (old[0], diff.oldname) + new_list = comment_lists.get(key, []) + while old_list or new_list: + old_comment_key = new_comment_key = None + old_comment = new_comment = u'' + if old_list: + (old_comment_key, old_comment) = old_list.pop(0) + if new_list: + (new_comment_key, new_comment) = new_list.pop(0) + lines.append(DiffCommentEdit(context, + old_comment_key, + new_comment_key, + old_comment, new_comment)) + listwalker = urwid.SimpleFocusListWalker(lines) + self.listbox = urwid.ListBox(listwalker) + self._w.contents.append((self.listbox, ('weight', 1))) + self.old_focus = 2 + self.draft_comments = [] + self._w.set_focus(self.old_focus) + + def refresh(self): + #TODO + pass + + def keypress(self, size, key): + old_focus = self.listbox.focus + r = super(DiffView, self).keypress(size, key) + new_focus = self.listbox.focus + if old_focus != new_focus and isinstance(old_focus, DiffCommentEdit): + self.cleanupEdit(old_focus) + return r + + def mouse_event(self, size, event, button, x, y, focus): + old_focus = self.listbox.focus + r = super(DiffView, self).mouse_event(size, event, button, x, y, focus) + new_focus = self.listbox.focus + if old_focus != new_focus and isinstance(old_focus, DiffCommentEdit): + self.cleanupEdit(old_focus) + return r + + def onSelect(self, button): + pos = self.listbox.focus_position + e = DiffCommentEdit(self.listbox.body[pos].context) + self.listbox.body.insert(pos+1, e) + self.listbox.focus_position = pos+1 + + def cleanupEdit(self, edit): + if edit.old_key: + self.deleteComment(edit.old_key) + edit.old_key = None + if edit.new_key: + self.deleteComment(edit.new_key) + edit.new_key = None + old = edit.old.edit_text.strip() + new = edit.new.edit_text.strip() + if old or new: + if old: + edit.old_key = self.saveComment( + edit.context, old, new=False) + if new: + edit.new_key = self.saveComment( + edit.context, new, new=True) + else: + self.listbox.body.remove(edit) + + def deleteComment(self, comment_key): + with self.app.db.getSession() as session: + comment = session.getComment(comment_key) + session.delete(comment) + + def saveComment(self, context, text, new=True): + if (not new) and (not context.old_revision_num): + parent = True + revision_key = context.new_revision_key + else: + parent = False + if new: + revision_key = context.new_revision_key + else: + revision_key = context.old_revision_key + if new: + line_num = context.new_ln + filename = context.new_fn + else: + line_num = context.old_ln + filename = context.old_fn + with self.app.db.getSession() as session: + revision = session.getRevision(revision_key) + comment = revision.createComment(None, None, + datetime.datetime.utcnow(), + None, filename, parent, + line_num, text, pending=True) + key = comment.key + return key diff --git a/gertty/view/project_list.py b/gertty/view/project_list.py new file mode 100644 index 0000000..efefffb --- /dev/null +++ b/gertty/view/project_list.py @@ -0,0 +1,127 @@ +# 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 + +import sync +import view.change_list + +class ProjectRow(urwid.Button): + project_focus_map = {None: 'reversed', + 'unreviewed-project': 'reversed-unreviewed-project', + 'subscribed-project': 'reversed-subscribed-project', + 'unsubscribed-project': 'reversed-unsubscribed-project', + } + + def selectable(self): + return True + + def __init__(self, project, callback=None): + super(ProjectRow, self).__init__('', on_press=callback, user_data=project.key) + self.project_key = project.key + name = urwid.Text(u' '+project.name) + name.set_wrap_mode('clip') + self.unreviewed_changes = urwid.Text(u'') + self.reviewed_changes = urwid.Text(u'') + col = urwid.Columns([ + name, + ('fixed', 4, self.unreviewed_changes), + ('fixed', 4, self.reviewed_changes), + ]) + self.row_style = urwid.AttrMap(col, '') + self._w = urwid.AttrMap(self.row_style, None, focus_map=self.project_focus_map) + self.update(project) + + def update(self, project): + if project.subscribed: + if len(project.unreviewed_changes) > 0: + style = 'unreviewed-project' + else: + style = 'subscribed-project' + else: + style = 'unsubscribed-project' + self.row_style.set_attr_map({None: style}) + self.unreviewed_changes.set_text(str(len(project.unreviewed_changes))) + self.reviewed_changes.set_text(str(len(project.reviewed_changes))) + +class ProjectListView(urwid.WidgetWrap): + help = """ + Toggle whether only subscribed projects or all projects are listed. + Toggle the subscription flag for the currently selected project. +""" + + def __init__(self, app): + super(ProjectListView, self).__init__(urwid.Pile([])) + self.app = app + self.subscribed = True + self.project_rows = {} + self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) + self.refresh() + self._w.contents.append((app.header, ('pack', 1))) + self._w.contents.append((urwid.Divider(),('pack', 1))) + self._w.contents.append((self.listbox, ('weight', 1))) + self._w.set_focus(2) + + def refresh(self): + if self.subscribed: + self.title = u'Subscribed Projects' + else: + self.title = u'All Projects' + self.app.status.update(title=self.title) + unseen_keys = set(self.project_rows.keys()) + with self.app.db.getSession() as session: + i = 0 + for project in session.getProjects(subscribed=self.subscribed): + row = self.project_rows.get(project.key) + if not row: + row = ProjectRow(project, self.onSelect) + self.listbox.body.insert(i, row) + self.project_rows[project.key] = row + else: + row.update(project) + unseen_keys.remove(project.key) + i += 1 + for key in unseen_keys: + row = self.project_rows[key] + self.listbox.body.remove(row) + del self.project_rows[key] + + def toggleSubscribed(self, project_key): + with self.app.db.getSession() as session: + project = session.getProject(project_key) + project.subscribed = not project.subscribed + ret = project.subscribed + return ret + + def onSelect(self, button, project_key): + self.app.changeScreen(view.change_list.ChangeListView(self.app, project_key)) + + def keypress(self, size, key): + if key=='l': + self.subscribed = not self.subscribed + self.refresh() + return None + if key=='s': + if not len(self.listbox.body): + return None + pos = self.listbox.focus_position + project_key = self.listbox.body[pos].project_key + subscribed = self.toggleSubscribed(project_key) + self.refresh() + if subscribed: + self.app.sync.submitTask(sync.SyncProjectTask(project_key)) + return None + return super(ProjectListView, self).keypress(size, key) + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e42c15d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +urwid +sqlalchemy +GitPython>=0.3.2.RC1 +python-dateutil +requests