Initial commit
Change-Id: Ie79f257c46a2c50abdd7ce63bfeceaad976ca878
This commit is contained in:
parent
0b7ce7d3a2
commit
1d6b0fd881
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.pyc
|
202
LICENSE
Normal file
202
LICENSE
Normal 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
README.rst
Normal file
111
README.rst
Normal 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
gertty/__init__.py
Normal file
0
gertty/__init__.py
Normal file
46
gertty/config.py
Normal file
46
gertty/config.py
Normal 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
gertty/db.py
Normal file
446
gertty/db.py
Normal 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
gertty/gertty.py
Normal file
186
gertty/gertty.py
Normal 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
gertty/gitrepo.py
Normal file
196
gertty/gitrepo.py
Normal 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
gertty/mywid.py
Normal file
61
gertty/mywid.py
Normal 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
gertty/sync.py
Normal file
453
gertty/sync.py
Normal 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
gertty/view/__init__.py
Normal file
0
gertty/view/__init__.py
Normal file
372
gertty/view/change.py
Normal file
372
gertty/view/change.py
Normal 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
gertty/view/change_list.py
Normal file
140
gertty/view/change_list.py
Normal 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)
|
||||
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 = """
|
||||
<l> Toggle whether only unreviewed or all changes are displayed.
|
||||
<r> Toggle the reviewed flag for the currently selected change.
|
||||
<ESC> 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))
|
261
gertty/view/diff.py
Normal file
261
gertty/view/diff.py
Normal file
@ -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 = """
|
||||
<Enter> Add an inline comment.
|
||||
<ESC> 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
|
127
gertty/view/project_list.py
Normal file
127
gertty/view/project_list.py
Normal file
@ -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 = """
|
||||
<l> Toggle whether only subscribed projects or all projects are listed.
|
||||
<s> 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)
|
||||
|
||||
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
urwid
|
||||
sqlalchemy
|
||||
GitPython>=0.3.2.RC1
|
||||
python-dateutil
|
||||
requests
|
Loading…
Reference in New Issue
Block a user