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