Browse Source

Initial commit

Change-Id: Ie79f257c46a2c50abdd7ce63bfeceaad976ca878
tags/0.9.0
James E. Blair 5 years ago
parent
commit
1d6b0fd881
16 changed files with 2607 additions and 0 deletions
  1. 1
    0
      .gitignore
  2. 202
    0
      LICENSE
  3. 111
    0
      README.rst
  4. 0
    0
      gertty/__init__.py
  5. 46
    0
      gertty/config.py
  6. 446
    0
      gertty/db.py
  7. 186
    0
      gertty/gertty.py
  8. 196
    0
      gertty/gitrepo.py
  9. 61
    0
      gertty/mywid.py
  10. 453
    0
      gertty/sync.py
  11. 0
    0
      gertty/view/__init__.py
  12. 372
    0
      gertty/view/change.py
  13. 140
    0
      gertty/view/change_list.py
  14. 261
    0
      gertty/view/diff.py
  15. 127
    0
      gertty/view/project_list.py
  16. 5
    0
      requirements.txt

+ 1
- 0
.gitignore View File

@@ -0,0 +1 @@
*.pyc

+ 202
- 0
LICENSE View File

@@ -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.

+ 111
- 0
README.rst View File

@@ -0,0 +1,111 @@
Gertty
======

Gertty is a console-based interface to the Gerrit Code Review system.

As compared to the web interface, the main advantages are:

* Workflow -- the interface is designed to support a workflow similar
to reading network news or mail. In particular, it is designed to
deal with a large number of review requests across a large number
of projects.

* Offline Use -- Gertty syncs information about changes in subscribed
projects to a local database and local git repos. All review
operations are performed against that database and then synced back
to Gerrit.

* Speed -- user actions modify locally cached content and need not
wait for server interaction.

* Convenience -- because Gertty downloads all changes to local git
repos, a single command instructs it to checkout a change into that
repo for detailed examination or testing of larger changes.

Usage
-----

Create a file at ``~/.gerttyrc`` with the following contents::

[gerrit]
url=https://review.example.org/
username=<gerrit username>
password=<gerrit password>
git_root=~/git/

You can generate or retrieve your Gerrit password by navigating to
Settings, then HTTP Password. Set ``git_root`` to a directory where
Gertty should find or clone git repositories for your projects.

If your Gerrit uses a self-signed certificate, you can add::

verify_ssl=False

To the section.

The config file is designed to support multiple Gerrit instances, but
currently, only the first one is used.

After installing the requirements (listed in requirements.txt), you
should be able to simply run Gertty. You will need to start by
subscribing to some projects. Use 'l' to list all of the projects and
then 's' to subscribe to them.

In general, pressing the F1 key will show help text on any screen, and
ESC will take you to the previous screen.

To select text (e.g., to copy to the clipboard), hold Shift while
selecting the text.

Philosophy
----------

Gertty is based on the following precepts which should inform changes
to the program:

* Support large numbers of review requests across large numbers of
projects. Help the user prioritize those reviews.

* Adopt a news/mailreader-like workflow in support of the above.
Being able to subscribe to projects, mark reviews as "read" without
reviewing, etc, are all useful concepts to support a heavy review
load (they have worked extremely well in supporting people who
read/write a lot of mail/news).

* Support off-line use. Gertty should be completely usable off-line
with reliable syncing between local data and Gerrit when a
connection is available (just like git or mail or news).

* Ample use of color. Unlike a web interface, a good text interface
relies mostly on color and precise placement rather than whitespace
and decoration to indicate to the user the purpose of a given piece
of information. Gertty should degrade well to 16 colors, but more
(88 or 256) may be used.

* Keyboard navigation (with easy-to-remember commands) should be
considered the primary mode of interaction. Mouse interaction
should also be supported.

* The navigation philosophy is a stack of screens, where each
selection pushes a new screen onto the stack, and ESC pops the
screen off. This makes sense when drilling down to a change from
lists, but also supports linking from change to change (via commit
messages or comments) and navigating back intuitive (it matches
expectations set by the web browsers).

Contributing
------------

To browse the latest code, see: https://git.openstack.org/cgit/stackforge/gertty/tree/
To clone the latest code, use `git clone git://git.openstack.org/stackforge/gertty`

Bugs are handled at: https://storyboard.openstack.org/

Code reviews are handled by gerrit at: https://review.openstack.org

Use `git review` to submit patches (after creating a gerrit account
that links to your launchpad account). Example::

# Do your commits
$ git review
# Enter your username if prompted

+ 0
- 0
gertty/__init__.py View File


+ 46
- 0
gertty/config.py View File

@@ -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')

+ 446
- 0
gertty/db.py View File

@@ -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

+ 186
- 0
gertty/gertty.py View File

@@ -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)

+ 196
- 0
gertty/gitrepo.py View File

@@ -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

+ 61
- 0
gertty/mywid.py View File

@@ -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))

+ 453
- 0
gertty/sync.py View File

@@ -0,0 +1,453 @@
# Copyright 2014 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

import collections
import logging
import math
import os
import threading
import urlparse
import json
import time
import Queue
import datetime

import dateutil.parser
import requests

HIGH_PRIORITY=0
NORMAL_PRIORITY=1
LOW_PRIORITY=2

class MultiQueue(object):
def __init__(self, priorities):
self.queues = collections.OrderedDict()
for key in priorities:
self.queues[key] = collections.deque()
self.condition = threading.Condition()

def qsize(self):
count = 0
for queue in self.queues.values():
count += len(queue)
return count

def put(self, item, priority):
self.condition.acquire()
try:
self.queues[priority].append(item)
self.condition.notify()
finally:
self.condition.release()

def get(self):
self.condition.acquire()
try:
while True:
for queue in self.queues.values():
try:
ret = queue.popleft()
return ret
except IndexError:
pass
self.condition.wait()
finally:
self.condition.release()

class Task(object):
def __init__(self, priority=NORMAL_PRIORITY):
self.log = logging.getLogger('gertty.sync')
self.priority = priority
self.succeeded = None
self.event = threading.Event()

def complete(self, success):
self.succeeded = success
self.event.set()

def wait(self):
self.event.wait()

class SyncProjectListTask(Task):
def __repr__(self):
return '<SyncProjectListTask>'

def run(self, sync):
app = sync.app
with app.db.getSession() as session:
remote = sync.get('projects/?d')
remote_keys = set(remote.keys())

local = {}
for p in session.getProjects():
local[p.name] = p
local_keys = set(local.keys())

for name in local_keys-remote_keys:
session.delete(local[name])

for name in remote_keys-local_keys:
p = remote[name]
session.createProject(name, description=p.get('description', ''))

class SyncSubscribedProjectsTask(Task):
def __repr__(self):
return '<SyncSubscribedProjectsTask>'

def run(self, sync):
app = sync.app
with app.db.getSession() as session:
for p in session.getProjects(subscribed=True):
sync.submitTask(SyncProjectTask(p.key, self.priority))

class SyncProjectTask(Task):
_closed_statuses = ['MERGED', 'ABANDONED']

def __init__(self, project_key, priority=NORMAL_PRIORITY):
super(SyncProjectTask, self).__init__(priority)
self.project_key = project_key

def __repr__(self):
return '<SyncProjectTask %s>' % (self.project_key,)

def run(self, sync):
app = sync.app
with app.db.getSession() as session:
project = session.getProject(self.project_key)
query = 'project:%s' % project.name
if project.updated:
query += ' -age:%ss' % (int(math.ceil((datetime.datetime.utcnow()-project.updated).total_seconds())) + 0,)
changes = sync.get('changes/?q=%s' % query)
self.log.debug('Query: %s ' % (query,))
for c in reversed(changes):
# The list we get is newest to oldest; if we are
# interrupted, we will have already synced the newest
# change and a subsequent sync will not catch up the
# old ones. So reverse the list before we process it
# so that the updated time is accurate.
# For now, just sync open changes or changes already
# in the db optionally we could sync all changes ever
change = session.getChangeByID(c['id'])
if change or (c['status'] not in self._closed_statuses):
sync.submitTask(SyncChangeTask(c['id'], self.priority))
self.log.debug("Change %s update %s" % (c['id'], c['updated']))

class SyncChangeTask(Task):
def __init__(self, change_id, priority=NORMAL_PRIORITY):
super(SyncChangeTask, self).__init__(priority)
self.change_id = change_id

def __repr__(self):
return '<SyncChangeTask %s>' % (self.change_id,)

def run(self, sync):
app = sync.app
remote_change = sync.get('changes/%s?o=DETAILED_LABELS&o=ALL_REVISIONS&o=ALL_COMMITS&o=MESSAGES&o=DETAILED_ACCOUNTS' % self.change_id)
fetches = []
with app.db.getSession() as session:
change = session.getChangeByID(self.change_id)
if not change:
project = session.getProjectByName(remote_change['project'])
created = dateutil.parser.parse(remote_change['created'])
updated = dateutil.parser.parse(remote_change['updated'])
change = project.createChange(remote_change['id'], remote_change['_number'],
remote_change['branch'], remote_change['change_id'],
remote_change['owner']['name'],
remote_change['subject'], created,
updated, remote_change['status'],
topic=remote_change.get('topic'))
change.status = remote_change['status']
change.subject = remote_change['subject']
change.updated = dateutil.parser.parse(remote_change['updated'])
change.topic = remote_change.get('topic')
repo = app.getRepo(change.project.name)
new_revision = False
for remote_commit, remote_revision in remote_change.get('revisions', {}).items():
revision = session.getRevisionByCommit(remote_commit)
if not revision:
# TODO: handle multiple parents
url = sync.app.config.url + change.project.name
if 'anonymous http' in remote_revision['fetch']:
ref = remote_revision['fetch']['anonymous http']['ref']
else:
ref = remote_revision['fetch']['http']['ref']
url = list(urlparse.urlsplit(url))
url[1] = '%s:%s@%s' % (sync.app.config.username,
sync.app.config.password, url[1])
url = urlparse.urlunsplit(url)
fetches.append((url, ref))
revision = change.createRevision(remote_revision['_number'],
remote_revision['commit']['message'], remote_commit,
remote_revision['commit']['parents'][0]['commit'])
new_revision = True
remote_comments = sync.get('changes/%s/revisions/%s/comments' % (self.change_id, revision.commit))
for remote_file, remote_comments in remote_comments.items():
for remote_comment in remote_comments:
comment = session.getCommentByID(remote_comment['id'])
if not comment:
# Normalize updated -> created
created = dateutil.parser.parse(remote_comment['updated'])
parent = False
if remote_comment.get('side', '') == 'PARENT':
parent = True
comment = revision.createComment(remote_comment['id'],
remote_comment.get('in_reply_to'),
created, remote_comment['author']['name'],
remote_file, parent, remote_comment.get('line'),
remote_comment['message'])
new_message = False
for remote_message in remote_change.get('messages', []):
message = session.getMessageByID(remote_message['id'])
if not message:
revision = session.getRevisionByNumber(change, remote_message['_revision_number'])
# Normalize date -> created
created = dateutil.parser.parse(remote_message['date'])
if 'author' in remote_message:
author_name = remote_message['author']['name']
if remote_message['author']['username'] != app.config.username:
new_message = True
else:
author_name = 'Gerrit Code Review'
message = revision.createMessage(remote_message['id'], created,
author_name,
remote_message['message'])
remote_approval_entries = {}
remote_label_entries = {}
user_voted = False
for remote_label_name, remote_label_dict in remote_change.get('labels', {}).items():
for remote_approval in remote_label_dict.get('all', []):
if remote_approval.get('value') is None:
continue
remote_approval['category'] = remote_label_name
key = '%s~%s' % (remote_approval['category'], remote_approval['name'])
remote_approval_entries[key] = remote_approval
if remote_approval.get('username', None) == app.config.username and int(remote_approval['value']) != 0:
user_voted = True
for key, value in remote_label_dict.get('values', {}).items():
# +1: "LGTM"
label = dict(value=key,
description=value,
category=remote_label_name)
key = '%s~%s~%s' % (label['category'], label['value'], label['description'])
remote_label_entries[key] = label
remote_approval_keys = set(remote_approval_entries.keys())
remote_label_keys = set(remote_label_entries.keys())
local_approvals = {}
local_labels = {}
for approval in change.approvals:
key = '%s~%s' % (approval.category, approval.name)
local_approvals[key] = approval
local_approval_keys = set(local_approvals.keys())
for label in change.labels:
key = '%s~%s~%s' % (label.category, label.value, label.description)
local_labels[key] = label
local_label_keys = set(local_labels.keys())

for key in local_approval_keys-remote_approval_keys:
session.delete(local_approvals[key])

for key in local_label_keys-remote_label_keys:
session.delete(local_labels[key])

for key in remote_approval_keys-local_approval_keys:
remote_approval = remote_approval_entries[key]
change.createApproval(remote_approval['name'],
remote_approval['category'],
remote_approval['value'])

for key in remote_label_keys-local_label_keys:
remote_label = remote_label_entries[key]
change.createLabel(remote_label['category'],
remote_label['value'],
remote_label['description'])

for key in remote_approval_keys.intersection(local_approval_keys):
local_approval = local_approvals[key]
remote_approval = remote_approval_entries[key]
local_approval.value = remote_approval['value']

remote_permitted_entries = {}
for remote_label_name, remote_label_values in remote_change.get('permitted_labels', {}).items():
for remote_label_value in remote_label_values:
remote_label = dict(category=remote_label_name,
value=remote_label_value)
key = '%s~%s' % (remote_label['category'], remote_label['value'])
remote_permitted_entries[key] = remote_label
remote_permitted_keys = set(remote_permitted_entries.keys())
local_permitted = {}
for permitted in change.permitted_labels:
key = '%s~%s' % (permitted.category, permitted.value)
local_permitted[key] = permitted
local_permitted_keys = set(local_permitted.keys())

for key in local_permitted_keys-remote_permitted_keys:
session.delete(local_permitted[key])

for key in remote_permitted_keys-local_permitted_keys:
remote_permitted = remote_permitted_entries[key]
change.createPermittedLabel(remote_permitted['category'],
remote_permitted['value'])

if not user_voted:
# Only consider changing the reviewed state if we don't have a vote
if new_revision or new_message:
change.reviewed = False
for (url, ref) in fetches:
self.log.debug("git fetch %s %s" % (url, ref))
repo.fetch(url, ref)


class UploadReviewsTask(Task):
def __repr__(self):
return '<UploadReviewsTask>'

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 '<UploadReviewTask %s>' % (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()

+ 0
- 0
gertty/view/__init__.py View File


+ 372
- 0
gertty/view/change.py View File

@@ -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 = """
<r> Toggle the reviewed flag for the current change.
<ESC> 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))

+ 140
- 0
gertty/view/change_list.py View File

@@ -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)