Initial fork from gertty -> boartty

Change-Id: I8c0ce5550f2287f77fb31c790c3923d3d1b80481
This commit is contained in:
James E. Blair 2016-10-15 07:31:19 -07:00
parent 6b8f18331f
commit e4c972b803
75 changed files with 4316 additions and 8643 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
*.pyc
*.egg*
gertty-env
boartty-env
.tox
doc/build

View File

@ -1,4 +1,4 @@
[gerrit]
host=review.openstack.org
port=29418
project=openstack/gertty.git
project=openstack/boartty.git

View File

@ -1,10 +1,10 @@
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`
To browse the latest code, see: https://git.openstack.org/cgit/openstack/boartty/tree/
To clone the latest code, use `git clone git://git.openstack.org/openstack/boartty`
Bugs are handled at: https://storyboard.openstack.org/#!/project/698
Bugs are handled at: https://storyboard.openstack.org/
Code reviews are handled by gerrit at: https://review.openstack.org
@ -18,26 +18,19 @@ that links to your launchpad account). Example::
Philosophy
----------
Gertty is based on the following precepts which should inform changes
Boartty 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.
* Support large numbers of stories across large numbers of projects.
* 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
* Support off-line use. Boartty should be completely usable off-line
with reliable syncing between local data and Storyboard 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
of information. Boartty should degrade well to 16 colors, but more
(88 or 256) may be used.
* Keyboard navigation (with easy-to-remember commands) should be
@ -51,10 +44,9 @@ to the program:
messages or comments) and navigating back intuitive (it matches
expectations set by the web browsers).
* Support a wide variety of Gerrit installations. The initial
development of Gertty is against the OpenStack project's Gerrit, and
many of the features are intended to help its developers with their
workflow, however, those features should be implemented in a generic
way so that the system does not require a specific Gerrit
configuration.
* Support a wide variety of Storyboard installations. The initial
development of Boartty is against the OpenStack project's
Storyboard, and many of the features are intended to help its
developers with their workflow, however, those features should be
implemented in a generic way so that the system does not require a
specific Storyboard configuration.

View File

@ -1,137 +1,84 @@
Gertty
======
Boartty
=======
Gertty is a console-based interface to the Gerrit Code Review system.
Boartty is a console-based interface to the Storyboard task-tracking
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.
deal with a large number of stories 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.
* Offline Use -- Boartty syncs information about changes in
subscribed projects to a local database. All review operations are
performed against that database and then synced back to Storyboard.
* 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.
Installation
------------
Debian
~~~~~~
Gertty is packaged in Debian and is currently available in:
* unstable
* testing
* stable
You can install it with::
apt-get install gertty
Fedora
~~~~~~
Gertty is packaged starting in Fedora 21. You can install it with::
yum install python-gertty
openSUSE
~~~~~~~~
Gertty is packaged for openSUSE 13.1 onwards. You can install it via
`1-click install from the Open Build Service <http://software.opensuse.org/package/python-gertty>`_.
Gentoo
~~~~~~
Gertty is available in the main Gentoo repository. You can install it with::
emerge gertty
Arch Linux
~~~~~~~~~~
Gertty packages are available in the Arch User Repository packages. You
can get the package from::
https://aur.archlinux.org/packages/python2-gertty/
Source
~~~~~~
When installing from source, it is recommended (but not required) to
install Gertty in a virtualenv. To set one up::
install Boartty in a virtualenv. To set one up::
virtualenv gertty-env
source gertty-env/bin/activate
virtualenv boartty-env
source boartty-env/bin/activate
To install the latest version from the cheeseshop::
pip install gertty
pip install boartty
To install from a git checkout::
pip install .
Gertty uses a YAML based configuration file that it looks for at
``~/.gertty.yaml``. Several sample configuration files are included.
Boartty uses a YAML based configuration file that it looks for at
``~/.boartty.yaml``. Several sample configuration files are included.
You can find them in the examples/ directory of the
`source distribution <https://git.openstack.org/cgit/openstack/gertty/tree/examples>`_
or the share/gertty/examples directory after installation.
`source distribution <https://git.openstack.org/cgit/openstack/boartty/tree/examples>`_
or the share/boartty/examples directory after installation.
Select one of the sample config files, copy it to ~/.gertty.yaml and
Select one of the sample config files, copy it to ~/.boartty.yaml and
edit as necessary. Search for ``CHANGEME`` to find parameters that
need to be supplied. The sample config files are as follows:
**minimal-gertty.yaml**
Only contains the parameters required for Gertty to actually run.
**minimal-boartty.yaml**
Only contains the parameters required for Boartty to actually run.
**reference-gertty.yaml**
**reference-boartty.yaml**
An exhaustive list of all supported options with examples.
**openstack-gertty.yaml**
**openstack-boartty.yaml**
A configuration designed for use with OpenStack's installation of
Gerrit.
**googlesource-gertty.yaml**
A configuration designed for use with installations of Gerrit
running on googlesource.com.
You will need a Storyboard authentication token which you can generate
or retrieve by navigating to ``Profile``, then ``Tokens`` (the "key"
icon), or visiting the `/#!/profile/tokens` URI in your Storyboard
installation. Issue a new token if you have not done so before, and
give it a sufficiently long lifetime (for example, one decade). Copy
and paste the resulting token in your ``~/.boartty.yaml`` file.
You will need your Gerrit password which you can generate or retrieve
by navigating to ``Settings``, then ``HTTP Password``.
Gertty uses local git repositories to perform much of its work. These
can be the same git repositories that you use when developing a
project. Gertty will not alter the working directory or index unless
you request it to (and even then, the usual git safeguards against
accidentally losing work remain in place). You will need to supply
the name of a directory where Gertty will find or clone git
repositories for your projects as the ``git-root`` parameter.
The config file is designed to support multiple Gerrit instances. The
first one is used by default, but others can be specified by supplying
the name on the command line.
The config file is designed to support multiple Storyboard instances.
The first one is used by default, but others can be specified by
supplying the name on the command line.
Usage
-----
After installing Gertty, you should be able to run it by invoking
``gertty``. If you installed it in a virtualenv, you can invoke it
without activating the virtualenv with ``/path/to/venv/bin/gertty``
which you may wish to add to your shell aliases. Use ``gertty
After installing Boartty, you should be able to run it by invoking
``boartty``. If you installed it in a virtualenv, you can invoke it
without activating the virtualenv with ``/path/to/venv/bin/boartty``
which you may wish to add to your shell aliases. Use ``boartty
--help`` to see a list of command line options available.
Once Gertty is running, you will need to start by subscribing to some
Once Boartty is running, you will need to start by subscribing to some
projects. Use 'L' to list all of the projects and then 's' to
subscribe to the ones you are interested in. Hit 'L' again to shrink
the list to your subscribed projects.
@ -139,37 +86,27 @@ the list to your subscribed projects.
In general, pressing the F1 key will show help text on any screen, and
ESC will take you to the previous screen.
Gertty works seamlessly offline or online. All of the actions that it
performs are first recorded in a local database (in ``~/.gertty.db``
by default), and are then transmitted to Gerrit. If Gertty is unable
to contact Gerrit for any reason, it will continue to operate against
the local database, and once it re-establishes contact, it will
process any pending changes.
Boartty works seamlessly offline or online. All of the actions that
it performs are first recorded in a local database (in
``~/.boartty.db`` by default), and are then transmitted to Storyboard.
If Boartty is unable to contact Storyboard for any reason, it will
continue to operate against the local database, and once it
re-establishes contact, it will process any pending changes.
The status bar at the top of the screen displays the current number of
outstanding tasks that Gertty must perform in order to be fully up to
outstanding tasks that Boartty must perform in order to be fully up to
date. Some of these tasks are more complicated than others, and some
of them will end up creating new tasks (for instance, one task may be
to search for new changes in a project which will then produce 5 new
tasks if there are 5 new changes).
to search for new stories in a project which will then produce 5 new
tasks if there are 5 new stories).
If Gertty is offline, it will so indicate in the status bar. It will
If Boartty is offline, it will so indicate in the status bar. It will
retry requests if needed, and will switch between offline and online
mode automatically.
If you review a change while offline with a positive vote, and someone
else leaves a negative vote on that change in the same category before
Gertty is able to upload your review, Gertty will detect the situation
and mark the change as "held" so that you may re-inspect the change
and any new comments before uploading the review. The status bar will
alert you to any held changes and direct you to a list of them (the
`F12` key by default). When viewing a change, the "held" flag may be
toggled with the exclamation key (`!`). Once held, a change must be
explicitly un-held in this manner for your review to be uploaded.
If Gertty encounters an error, this will also be indicated in the
status bar. You may wish to examine ~/.gertty.log to see what the
error was. In many cases, Gertty can continue after encountering an
If Boartty encounters an error, this will also be indicated in the
status bar. You may wish to examine ~/.boartty.log to see what the
error was. In many cases, Boartty can continue after encountering an
error. The error flag will be cleared when you leave the current
screen.
@ -180,28 +117,28 @@ Terminal Integration
--------------------
If you use rxvt-unicode, you can add something like the following to
``.Xresources`` to make Gerrit URLs that are displayed in your
``.Xresources`` to make Storyboard URLs that are displayed in your
terminal (perhaps in an email or irc client) clickable links that open
in Gertty::
in Boartty::
URxvt.perl-ext: default,matcher
URxvt.url-launcher: sensible-browser
URxvt.keysym.C-Delete: perl:matcher:last
URxvt.keysym.M-Delete: perl:matcher:list
URxvt.matcher.button: 1
URxvt.matcher.pattern.1: https:\/\/review.example.org/(\\#\/c\/)?(\\d+)[\w]*
URxvt.matcher.launcher.1: gertty --open $0
URxvt.matcher.pattern.1: https:\/\/storyboard.example.org/#!/story/(\\d+)[\w]*
URxvt.matcher.launcher.1: boartty --open $0
You will want to adjust the pattern to match the review site you are
interested in; multiple patterns may be added as needed.
You will want to adjust the pattern to match the Storyboard site you
are interested in; multiple patterns may be added as needed.
Contributing
------------
For information on how to contribute to Gertty, please see the
For information on how to contribute to Boartty, please see the
contents of the CONTRIBUTING.rst file.
Bugs
----
Bugs are handled at: https://storyboard.openstack.org/#!/project/698
Bugs are handled at: https://storyboard.openstack.org/

View File

@ -20,7 +20,7 @@ script_location = alembic
# versions/ directory
# sourceless = false
sqlalchemy.url = sqlite:////tmp/gertty.db
sqlalchemy.url = sqlite:////tmp/boartty.db
# Logging configuration

View File

@ -15,8 +15,8 @@ config = context.config
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
import gertty.db
target_metadata = gertty.db.metadata
import boartty.db
target_metadata = boartty.db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:

View File

@ -0,0 +1,193 @@
"""initial schema
Revision ID: 183755ac91df
Revises: None
Create Date: 2016-10-31 08:54:59.399741
"""
# revision identifiers, used by Alembic.
revision = '183755ac91df'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('comment',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=True),
sa.Column('parent_comment_key', sa.Integer(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('draft', sa.Boolean(), nullable=False),
sa.Column('pending', sa.Boolean(), nullable=False),
sa.Column('pending_delete', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['parent_comment_key'], ['comment.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_comment_draft'), 'comment', ['draft'], unique=False)
op.create_index(op.f('ix_comment_id'), 'comment', ['id'], unique=False)
op.create_index(op.f('ix_comment_pending'), 'comment', ['pending'], unique=False)
op.create_index(op.f('ix_comment_pending_delete'), 'comment', ['pending_delete'], unique=False)
op.create_table('project',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('subscribed', sa.Boolean(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('updated', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_project_id'), 'project', ['id'], unique=False)
op.create_index(op.f('ix_project_name'), 'project', ['name'], unique=False)
op.create_index(op.f('ix_project_subscribed'), 'project', ['subscribed'], unique=False)
op.create_index(op.f('ix_project_updated'), 'project', ['updated'], unique=False)
op.create_table('sync_query',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_sync_query_name'), 'sync_query', ['name'], unique=True)
op.create_index(op.f('ix_sync_query_updated'), 'sync_query', ['updated'], unique=False)
op.create_table('system',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_table('tag',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_tag_id'), 'tag', ['id'], unique=False)
op.create_index(op.f('ix_tag_name'), 'tag', ['name'], unique=False)
op.create_table('topic',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_topic_name'), 'topic', ['name'], unique=False)
op.create_index(op.f('ix_topic_sequence'), 'topic', ['sequence'], unique=True)
op.create_table('user',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=False)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
op.create_index(op.f('ix_user_name'), 'user', ['name'], unique=False)
op.create_table('project_topic',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('project_key', sa.Integer(), nullable=True),
sa.Column('topic_key', sa.Integer(), nullable=True),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['project_key'], ['project.key'], ),
sa.ForeignKeyConstraint(['topic_key'], ['topic.key'], ),
sa.PrimaryKeyConstraint('key'),
sa.UniqueConstraint('topic_key', 'sequence', name='topic_key_sequence_const')
)
op.create_index(op.f('ix_project_topic_project_key'), 'project_topic', ['project_key'], unique=False)
op.create_index(op.f('ix_project_topic_topic_key'), 'project_topic', ['topic_key'], unique=False)
op.create_table('story',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=True),
sa.Column('user_key', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('hidden', sa.Boolean(), nullable=False),
sa.Column('subscribed', sa.Boolean(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('private', sa.Boolean(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created', sa.DateTime(), nullable=True),
sa.Column('updated', sa.DateTime(), nullable=True),
sa.Column('last_seen', sa.DateTime(), nullable=True),
sa.Column('outdated', sa.Boolean(), nullable=False),
sa.Column('pending', sa.Boolean(), nullable=False),
sa.Column('pending_delete', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_key'], ['user.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_story_created'), 'story', ['created'], unique=False)
op.create_index(op.f('ix_story_hidden'), 'story', ['hidden'], unique=False)
op.create_index(op.f('ix_story_id'), 'story', ['id'], unique=False)
op.create_index(op.f('ix_story_last_seen'), 'story', ['last_seen'], unique=False)
op.create_index(op.f('ix_story_outdated'), 'story', ['outdated'], unique=False)
op.create_index(op.f('ix_story_pending'), 'story', ['pending'], unique=False)
op.create_index(op.f('ix_story_pending_delete'), 'story', ['pending_delete'], unique=False)
op.create_index(op.f('ix_story_status'), 'story', ['status'], unique=False)
op.create_index(op.f('ix_story_subscribed'), 'story', ['subscribed'], unique=False)
op.create_index(op.f('ix_story_title'), 'story', ['title'], unique=False)
op.create_index(op.f('ix_story_updated'), 'story', ['updated'], unique=False)
op.create_index(op.f('ix_story_user_key'), 'story', ['user_key'], unique=False)
op.create_table('event',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=True),
sa.Column('type', sa.String(length=255), nullable=False),
sa.Column('user_key', sa.Integer(), nullable=True),
sa.Column('story_key', sa.Integer(), nullable=True),
sa.Column('created', sa.DateTime(), nullable=True),
sa.Column('comment_key', sa.Integer(), nullable=True),
sa.Column('info', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['comment_key'], ['comment.key'], ),
sa.ForeignKeyConstraint(['story_key'], ['story.key'], ),
sa.ForeignKeyConstraint(['user_key'], ['user.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_event_created'), 'event', ['created'], unique=False)
op.create_index(op.f('ix_event_id'), 'event', ['id'], unique=False)
op.create_index(op.f('ix_event_type'), 'event', ['type'], unique=False)
op.create_index(op.f('ix_event_user_key'), 'event', ['user_key'], unique=False)
op.create_table('story_tag',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('story_key', sa.Integer(), nullable=True),
sa.Column('tag_key', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['story_key'], ['story.key'], ),
sa.ForeignKeyConstraint(['tag_key'], ['tag.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_story_tag_story_key'), 'story_tag', ['story_key'], unique=False)
op.create_index(op.f('ix_story_tag_tag_key'), 'story_tag', ['tag_key'], unique=False)
op.create_table('task',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), nullable=True),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('status', sa.String(length=16), nullable=True),
sa.Column('creator_user_key', sa.Integer(), nullable=True),
sa.Column('story_key', sa.Integer(), nullable=True),
sa.Column('project_key', sa.Integer(), nullable=True),
sa.Column('assignee_user_key', sa.Integer(), nullable=True),
sa.Column('priority', sa.String(length=16), nullable=True),
sa.Column('link', sa.Text(), nullable=True),
sa.Column('created', sa.DateTime(), nullable=True),
sa.Column('updated', sa.DateTime(), nullable=True),
sa.Column('pending', sa.Boolean(), nullable=False),
sa.Column('pending_delete', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assignee_user_key'], ['user.key'], ),
sa.ForeignKeyConstraint(['creator_user_key'], ['user.key'], ),
sa.ForeignKeyConstraint(['project_key'], ['project.key'], ),
sa.ForeignKeyConstraint(['story_key'], ['story.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_task_assignee_user_key'), 'task', ['assignee_user_key'], unique=False)
op.create_index(op.f('ix_task_created'), 'task', ['created'], unique=False)
op.create_index(op.f('ix_task_creator_user_key'), 'task', ['creator_user_key'], unique=False)
op.create_index(op.f('ix_task_id'), 'task', ['id'], unique=False)
op.create_index(op.f('ix_task_pending'), 'task', ['pending'], unique=False)
op.create_index(op.f('ix_task_pending_delete'), 'task', ['pending_delete'], unique=False)
op.create_index(op.f('ix_task_project_key'), 'task', ['project_key'], unique=False)
op.create_index(op.f('ix_task_status'), 'task', ['status'], unique=False)
op.create_index(op.f('ix_task_story_key'), 'task', ['story_key'], unique=False)
op.create_index(op.f('ix_task_title'), 'task', ['title'], unique=False)
op.create_index(op.f('ix_task_updated'), 'task', ['updated'], unique=False)
### end Alembic commands ###
def downgrade():
pass

View File

@ -35,28 +35,27 @@ from six.moves.urllib import parse as urlparse
import sqlalchemy.exc
import urwid
from gertty import db
from gertty import config
from gertty import gitrepo
from gertty import keymap
from gertty import mywid
from gertty import palette
from gertty import sync
from gertty import search
from gertty import requestsexceptions
from gertty.view import change_list as view_change_list
from gertty.view import project_list as view_project_list
from gertty.view import change as view_change
import gertty.view
import gertty.version
from boartty import db
from boartty import config
from boartty import keymap
from boartty import mywid
from boartty import palette
from boartty import sync
from boartty import search
from boartty import requestsexceptions
from boartty.view import story_list as view_story_list
from boartty.view import project_list as view_project_list
from boartty.view import story as view_story
import boartty.view
import boartty.version
WELCOME_TEXT = """\
Welcome to Gertty!
Welcome to Boartty!
To get started, you should subscribe to some projects. Press the "L"
key (shift-L) to list all the projects, navigate to the ones you are
interested in, and then press "s" to subscribe to them. Gertty will
automatically sync changes in your subscribed projects.
interested in, and then press "s" to subscribe to them. Boardtty will
automatically sync stories in your subscribed projects.
Press the F1 key anywhere to get help. Your terminal emulator may
require you to press function-F1 or alt-F1 instead.
@ -233,8 +232,8 @@ class ProjectCache(object):
def get(self, project):
if project.key not in self.projects:
self.projects[project.key] = dict(
unreviewed_changes = len(project.unreviewed_changes),
open_changes = len(project.open_changes),
active_stories = len(project.active_stories),
stories = len(project.stories),
)
return self.projects[project.key]
@ -272,14 +271,14 @@ class App(object):
req_logger.setLevel(level)
else:
req_logger.setLevel(req_level_name)
self.log = logging.getLogger('gertty.App')
self.log = logging.getLogger('boartty.App')
self.log.debug("Starting")
self.lock_fd = open(self.config.lock_file, 'w')
try:
fcntl.lockf(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
print("error: another instance of gertty is running for: %s" % self.config.server['name'])
print("error: another instance of boartty is running for: %s" % self.config.server['name'])
sys.exit(1)
self.project_cache = ProjectCache()
@ -291,6 +290,7 @@ class App(object):
self.config.keymap.updateCommandMap()
self.search = search.SearchCompiler(self.config.username)
self.db = db.Database(self, self.config.dburi, self.search)
self.initSystemData()
self.sync = sync.Sync(self, disable_background_sync)
self.status = StatusHeader(self)
@ -405,6 +405,7 @@ class App(object):
if not self.screens:
return
while self.screens:
self.log.debug("screens %s" % (target_widget,))
widget = self.screens.pop()
if (not target_widget) or (widget is target_widget):
break
@ -415,9 +416,9 @@ class App(object):
self.frame.body = widget
self.refresh(force=True)
def findChangeList(self):
def findStoryList(self):
for widget in reversed(self.screens):
if isinstance(widget, view_change_list.ChangeListView):
if isinstance(widget, view_story_list.StoryListView):
return widget
return None
@ -450,6 +451,7 @@ class App(object):
self.status.refresh()
def updateStatusQueries(self):
return # TODO: storyboard
with self.db.getSession() as session:
held = len(session.getHeld())
self.status.update(held=held)
@ -515,6 +517,7 @@ class App(object):
lambda button: self.backScreen())
self.popup(dialog, min_width=76, min_height=len(lines)+4)
#storyboard
def _syncOneChangeFromQuery(self, query):
number = changeid = restid = None
if query.startswith("change:"):
@ -574,7 +577,7 @@ class App(object):
with self.db.getSession() as session:
try:
changes = session.getChanges(query)
except gertty.search.SearchSyntaxError as e:
except boartty.search.SearchSyntaxError as e:
return self.error(e.message)
except sqlalchemy.exc.OperationalError as e:
return self.error(e.message)
@ -587,9 +590,9 @@ class App(object):
if change_key:
view = view_change.ChangeView(self, change_key)
else:
view = view_change_list.ChangeListView(self, query)
view = view_story_list.StoryListView(self, query)
self.changeScreen(view)
except gertty.view.DisplayError as e:
except boartty.view.DisplayError as e:
return self.error(e.message)
def searchDialog(self, default):
@ -677,15 +680,17 @@ class App(object):
self.help()
elif keymap.QUIT in commands:
self.quit()
elif keymap.CHANGE_SEARCH in commands:
elif keymap.STORY_SEARCH in commands:
self.searchDialog('')
elif keymap.NEW_STORY in commands:
self.newStory()
elif keymap.LIST_HELD in commands:
self.doSearch("is:held")
elif key in self.config.dashboards:
d = self.config.dashboards[key]
view = view_change_list.ChangeListView(self, d['query'], d['name'],
sort_by=d.get('sort-by'),
reverse=d.get('reverse'))
view = view_story_list.StoryListView(self, d['query'], d['name'],
sort_by=d.get('sort-by'),
reverse=d.get('reverse'))
self.changeScreen(view)
elif keymap.FURTHER_INPUT in commands:
self.input_buffer.append(key)
@ -711,6 +716,8 @@ class App(object):
self.loop.screen.clear()
def time(self, dt):
if dt is None:
return None
utc = dt.replace(tzinfo=dateutil.tz.tzutc())
if self.config.utc:
return utc
@ -753,6 +760,7 @@ class App(object):
else:
self.log.error("Unable to parse command %s with data %s" % (command, data))
#storyboard
def toggleHeldChange(self, change_key):
with self.db.getSession() as session:
change = session.getChange(change_key)
@ -767,88 +775,54 @@ class App(object):
self.updateStatusQueries()
return ret
def localCheckoutCommit(self, project_name, commit_sha):
repo = gitrepo.get_repo(project_name, self.config)
try:
repo.checkout(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.backScreen())
self.popup(dialog, min_height=min_height)
def newStory(self):
dialog = view_story.NewStoryDialog(self)
urwid.connect_signal(dialog, 'save',
lambda button: self.saveNewStory(dialog))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.cancelNewStory(dialog))
self.popup(dialog,
relative_width=50, relative_height=25,
min_width=60, min_height=8)
def localCherryPickCommit(self, project_name, commit_sha):
repo = gitrepo.get_repo(project_name, self.config)
try:
repo.cherryPick(commit_sha)
dialog = mywid.MessageDialog('Cherry-Pick', 'Change cherry-picked 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.backScreen())
self.popup(dialog, min_height=min_height)
def cancelNewStory(self, dialog):
self.backScreen()
def saveReviews(self, revision_keys, approvals, message, upload, submit):
message_keys = []
def saveNewStory(self, dialog):
with self.db.getSession() as session:
account = session.getAccountByUsername(self.config.username)
for revision_key in revision_keys:
k = self._saveReview(session, account, revision_key,
approvals, message, upload, submit)
if k:
message_keys.append(k)
return message_keys
story = session.createStory(
title=dialog.title_field.edit_text,
description=dialog.description_field.edit_text,
pending=True)
task = story.addTask(
project=session.getProjectByID(dialog.project_button.key),
title=dialog.title_field.edit_text,
pending=True)
def _saveReview(self, session, account, revision_key,
approvals, message, upload, submit):
message_key = None
revision = session.getRevision(revision_key)
change = revision.change
draft_approvals = {}
for approval in change.draft_approvals:
draft_approvals[approval.category] = approval
self.sync.submitTask(
sync.UpdateStoryTask(story.key, sync.HIGH_PRIORITY))
self.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
self.backScreen()
categories = set()
for label in change.permitted_labels:
categories.add(label.category)
for category in categories:
value = approvals.get(category, 0)
approval = draft_approvals.get(category)
if not approval:
approval = change.createApproval(account, category, 0, draft=True)
draft_approvals[category] = approval
approval.value = value
draft_message = revision.getPendingMessage()
if not draft_message:
draft_message = revision.getDraftMessage()
if not draft_message:
if message or upload:
draft_message = revision.createMessage(None, account,
datetime.datetime.utcnow(),
'', draft=True)
if draft_message:
draft_message.created = datetime.datetime.utcnow()
draft_message.message = message
draft_message.pending = upload
message_key = draft_message.key
if upload:
change.reviewed = True
self.project_cache.clear(change.project)
if submit:
change.status = 'SUBMITTED'
change.pending_status = True
change.pending_status_message = None
return message_key
def initSystemData(self):
with self.db.getSession() as session:
system = session.getSystem()
if system is None:
self.user_id = None
else:
self.user_id = system.user_id
def setUserID(self, user_id):
with self.db.getSession() as session:
system = session.getSystem()
if system is None:
system = session.createSystem()
system.user_id = self.user_id = user_id
def version():
return "Gertty version: %s" % gertty.version.version_info.release_string()
return "Boardtty version: %s" % boartty.version.version_info.release_string()
class PrintKeymapAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
@ -900,10 +874,10 @@ def main():
help='print the palette attribute names to stdout')
parser.add_argument('--open', nargs=1, action=OpenChangeAction,
metavar='URL',
help='open the given URL in a running Gertty')
help='open the given URL in a running Boardtty')
parser.add_argument('--version', dest='version', action='version',
version=version(),
help='show Gertty\'s version')
help='show Boardtty\'s version')
parser.add_argument('-p', dest='palette', default='default',
help='color palette to use')
parser.add_argument('-k', dest='keymap', default='default',

View File

@ -22,7 +22,7 @@ import re
import six
import urwid
from gertty import mywid
from boartty import mywid
try:
OrderedDict = collections.OrderedDict

View File

@ -26,27 +26,24 @@ import yaml
from six.moves.urllib import parse as urlparse
import voluptuous as v
import gertty.commentlink
import gertty.palette
import gertty.keymap
import boartty.commentlink
import boartty.palette
import boartty.keymap
try:
OrderedDict = collections.OrderedDict
except AttributeError:
OrderedDict = ordereddict.OrderedDict
DEFAULT_CONFIG_PATH='~/.gertty.yaml'
DEFAULT_CONFIG_PATH='~/.boartty.yaml'
class ConfigSchema(object):
server = {v.Required('name'): str,
v.Required('url'): str,
v.Required('username'): str,
'password': str,
v.Required('token'): str,
'verify-ssl': bool,
'ssl-ca-path': str,
'dburi': str,
v.Required('git-root'): str,
'git-url': str,
'log-file': str,
'socket': str,
'auth-type': v.Any('basic', 'digest', 'form'),
@ -100,7 +97,7 @@ class ConfigSchema(object):
hide_comments = [hide_comment]
change_list_options = {'sort-by': sort_by,
story_list_options = {'sort-by': sort_by,
'reverse': bool}
keymap = {v.Required('name'): str,
@ -117,14 +114,13 @@ class ConfigSchema(object):
'commentlinks': self.commentlinks,
'dashboards': self.dashboards,
'reviewkeys': self.reviewkeys,
'change-list-query': str,
'story-list-query': str,
'diff-view': str,
'hide-comments': self.hide_comments,
'thread-changes': bool,
'display-times-in-utc': bool,
'handle-mouse': bool,
'breadcrumbs': bool,
'change-list-options': self.change_list_options,
'story-list-options': self.story_list_options,
'expire-age': str,
})
return schema
@ -149,21 +145,17 @@ class Config(object):
self.url = url
result = urlparse.urlparse(url)
self.hostname = result.netloc
self.username = server['username']
self.password = server.get('password')
if self.password is None:
self.password = getpass.getpass("Password for %s (%s): "
% (self.url, self.username))
else:
# Ensure file is only readable by user as password is stored in
# file.
mode = os.stat(self.path).st_mode & 0o0777
if not mode == 0o600:
print (
"Error: Config file '{}' contains a password and does "
"not have permissions set to 0600.\n"
"Permissions are: {}".format(self.path, oct(mode)))
exit(1)
self.token = server['token']
self.username = '' # TODO: storyboard
# Ensure file is only readable by user as password is stored in
# file.
mode = os.stat(self.path).st_mode & 0o0777
if not mode == 0o600:
print (
"Error: Config file '{}' contains an api key and does "
"not have permissions set to 0600.\n"
"Permissions are: {}".format(self.path, oct(mode)))
exit(1)
self.auth_type = server.get('auth-type', 'digest')
self.verify_ssl = server.get('verify-ssl', True)
if not self.verify_ssl:
@ -171,54 +163,49 @@ class Config(object):
self.ssl_ca_path = server.get('ssl-ca-path', None)
if self.ssl_ca_path is not None:
self.ssl_ca_path = os.path.expanduser(self.ssl_ca_path)
# Gertty itself uses the Requests library
# Boardtty itself uses the Requests library
os.environ['REQUESTS_CA_BUNDLE'] = self.ssl_ca_path
# And this is to allow Git callouts
os.environ['GIT_SSL_CAINFO'] = self.ssl_ca_path
self.git_root = os.path.expanduser(server['git-root'])
git_url = server.get('git-url', self.url + 'p/')
if not git_url.endswith('/'):
git_url += '/'
self.git_url = git_url
self.dburi = server.get('dburi',
'sqlite:///' + os.path.expanduser('~/.gertty.db'))
socket_path = server.get('socket', '~/.gertty.sock')
'sqlite:///' + os.path.expanduser('~/.boartty.db'))
socket_path = server.get('socket', '~/.boartty.sock')
self.socket_path = os.path.expanduser(socket_path)
log_file = server.get('log-file', '~/.gertty.log')
log_file = server.get('log-file', '~/.boartty.log')
self.log_file = os.path.expanduser(log_file)
lock_file = server.get('lock-file', '~/.gertty.%s.lock' % server['name'])
lock_file = server.get('lock-file', '~/.boartty.%s.lock' % server['name'])
self.lock_file = os.path.expanduser(lock_file)
self.palettes = {'default': gertty.palette.Palette({}),
'light': gertty.palette.Palette(gertty.palette.LIGHT_PALETTE),
self.palettes = {'default': boartty.palette.Palette({}),
'light': boartty.palette.Palette(boartty.palette.LIGHT_PALETTE),
}
for p in self.config.get('palettes', []):
if p['name'] not in self.palettes:
self.palettes[p['name']] = gertty.palette.Palette(p)
self.palettes[p['name']] = boartty.palette.Palette(p)
else:
self.palettes[p['name']].update(p)
self.palette = self.palettes[self.config.get('palette', palette)]
self.keymaps = {'default': gertty.keymap.KeyMap({}),
'vi': gertty.keymap.KeyMap(gertty.keymap.VI_KEYMAP)}
self.keymaps = {'default': boartty.keymap.KeyMap({}),
'vi': boartty.keymap.KeyMap(boartty.keymap.VI_KEYMAP)}
for p in self.config.get('keymaps', []):
if p['name'] not in self.keymaps:
self.keymaps[p['name']] = gertty.keymap.KeyMap(p)
self.keymaps[p['name']] = boartty.keymap.KeyMap(p)
else:
self.keymaps[p['name']].update(p)
self.keymap = self.keymaps[self.config.get('keymap', keymap)]
self.commentlinks = [gertty.commentlink.CommentLink(c)
self.commentlinks = [boartty.commentlink.CommentLink(c)
for c in self.config.get('commentlinks', [])]
self.commentlinks.append(
gertty.commentlink.CommentLink(dict(
boartty.commentlink.CommentLink(dict(
match="(?P<url>https?://\\S*)",
replacements=[
dict(link=dict(
text="{url}",
url="{url}"))])))
self.project_change_list_query = self.config.get('change-list-query', 'status:open')
self.project_story_list_query = self.config.get('story-list-query', '')
self.diff_view = self.config.get('diff-view', 'side-by-side')
@ -235,15 +222,14 @@ class Config(object):
for h in self.config.get('hide-comments', []):
self.hide_comments.append(re.compile(h['author']))
self.thread_changes = self.config.get('thread-changes', True)
self.utc = self.config.get('display-times-in-utc', False)
self.breadcrumbs = self.config.get('breadcrumbs', True)
self.handle_mouse = self.config.get('handle-mouse', True)
change_list_options = self.config.get('change-list-options', {})
self.change_list_options = {
'sort-by': change_list_options.get('sort-by', 'number'),
'reverse': change_list_options.get('reverse', False)}
story_list_options = self.config.get('story-list-options', {})
self.story_list_options = {
'sort-by': story_list_options.get('sort-by', 'number'),
'reverse': story_list_options.get('reverse', False)}
self.expire_age = self.config.get('expire-age', '2 months')
@ -254,11 +240,11 @@ class Config(object):
return None
def printSample(self):
filename = 'share/gertty/examples'
print("""Gertty requires a configuration file at ~/.gertty.yaml
filename = 'share/boartty/examples'
print("""Boardtty requires a configuration file at ~/.boartty.yaml
If the file contains a password then permissions must be set to 0600.
Several sample configuration files were installed with Gertty and are
Several sample configuration files were installed with Boardtty and are
available in %s in the root of the installation.
For more information, please see the README.

676
boartty/db.py Normal file
View File

@ -0,0 +1,676 @@
# Copyright 2014 OpenStack Foundation
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# 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 re
import time
import logging
import threading
import alembic
import alembic.config
import six
import sqlalchemy
from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, DateTime, Text, UniqueConstraint
from sqlalchemy.schema import ForeignKey
from sqlalchemy.orm import mapper, sessionmaker, relationship, scoped_session, joinedload
from sqlalchemy.orm.session import Session
from sqlalchemy.sql import exists
from sqlalchemy.sql.expression import and_
metadata = MetaData()
system_table = Table(
'system', metadata,
Column('key', Integer, primary_key=True),
Column('user_id', Integer),
)
project_table = Table(
'project', metadata,
Column('key', Integer, primary_key=True),
Column('id', Integer, index=True),
Column('name', String(255), index=True, nullable=False),
Column('subscribed', Boolean, index=True, default=False),
Column('description', Text),
Column('updated', DateTime, index=True),
)
topic_table = Table(
'topic', metadata,
Column('key', Integer, primary_key=True),
Column('name', String(255), index=True, nullable=False),
Column('sequence', Integer, index=True, unique=True, nullable=False),
)
project_topic_table = Table(
'project_topic', metadata,
Column('key', Integer, primary_key=True),
Column('project_key', Integer, ForeignKey("project.key"), index=True),
Column('topic_key', Integer, ForeignKey("topic.key"), index=True),
Column('sequence', Integer, nullable=False),
UniqueConstraint('topic_key', 'sequence', name='topic_key_sequence_const'),
)
story_table = Table(
'story', metadata,
Column('key', Integer, primary_key=True),
Column('id', Integer, index=True),
Column('user_key', Integer, ForeignKey("user.key"), index=True),
Column('status', String(16), index=True, nullable=False),
Column('hidden', Boolean, index=True, nullable=False),
Column('subscribed', Boolean, index=True, nullable=False),
Column('title', String(255), index=True),
Column('private', Boolean, nullable=False),
Column('description', Text),
Column('created', DateTime, index=True),
# TODO: make sure updated is never null in storyboard
Column('updated', DateTime, index=True),
Column('last_seen', DateTime, index=True),
Column('outdated', Boolean, index=True, nullable=False),
Column('pending', Boolean, index=True, nullable=False),
Column('pending_delete', Boolean, index=True, nullable=False),
)
tag_table = Table(
'tag', metadata,
Column('key', Integer, primary_key=True),
Column('id', Integer, index=True),
Column('name', String(255), index=True, nullable=False),
)
story_tag_table = Table(
'story_tag', metadata,
Column('key', Integer, primary_key=True),
Column('story_key', Integer, ForeignKey("story.key"), index=True),
Column('tag_key', Integer, ForeignKey("tag.key"), index=True),
)
task_table = Table(
'task', metadata,
Column('key', Integer, primary_key=True),
Column('id', Integer, index=True),
Column('title', String(255), index=True),
Column('status', String(16), index=True),
Column('creator_user_key', Integer, ForeignKey("user.key"), index=True),
Column('story_key', Integer, ForeignKey("story.key"), index=True),
Column('project_key', Integer, ForeignKey("project.key"), index=True),
Column('assignee_user_key', Integer, ForeignKey("user.key"), index=True),
Column('priority', String(16)),
Column('link', Text),
Column('created', DateTime, index=True),
# TODO: make sure updated is never null in storyboard
Column('updated', DateTime, index=True),
Column('pending', Boolean, index=True, nullable=False),
Column('pending_delete', Boolean, index=True, nullable=False),
)
event_table = Table(
'event', metadata,
Column('key', Integer, primary_key=True),
Column('id', Integer, index=True),
Column('type', String(255), index=True, nullable=False),
Column('user_key', Integer, ForeignKey("user.key"), index=True),
Column('story_key', Integer, ForeignKey('story.key'), nullable=True),
#Column('worklist_key', Integer, ForeignKey('worklist.key'), nullable=True),
#Column('board_key', Integer, ForeignKey('board.key'), nullable=True),
Column('created', DateTime, index=True),
Column('comment_key', Integer, ForeignKey('comment.key'), nullable=True),
Column('user_key', ForeignKey('user.key'), nullable=True),
Column('info', Text),
)
comment_table = Table(
'comment', metadata,
Column('key', Integer, primary_key=True),
Column('id', Integer, index=True),
Column('parent_comment_key', Integer, ForeignKey('comment.key'), nullable=True),
Column('content', Text),
Column('draft', Boolean, index=True, nullable=False),
Column('pending', Boolean, index=True, nullable=False),
Column('pending_delete', Boolean, index=True, nullable=False),
)
user_table = Table(
'user', metadata,
Column('key', Integer, primary_key=True),
Column('id', Integer, index=True),
Column('name', String(255), index=True),
Column('email', String(255), index=True),
)
sync_query_table = Table(
'sync_query', metadata,
Column('key', Integer, primary_key=True),
Column('name', String(255), index=True, unique=True, nullable=False),
Column('updated', DateTime, index=True),
)
class System(object):
def __init__(self, user_id=None):
self.user_id = user_id
class User(object):
def __init__(self, id, name=None, email=None):
self.id = id
self.name = name
self.email = email
class Project(object):
def __init__(self, id, name, subscribed=False, description=''):
self.id = id
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
def createBranch(self, *args, **kw):
session = Session.object_session(self)
args = [self] + list(args)
b = Branch(*args, **kw)
self.branches.append(b)
session.add(b)
session.flush()
return b
class ProjectTopic(object):
def __init__(self, project, topic, sequence):
self.project_key = project.key
self.topic_key = topic.key
self.sequence = sequence
class Topic(object):
def __init__(self, name, sequence):
self.name = name
self.sequence = sequence
def addProject(self, project):
session = Session.object_session(self)
seq = max([x.sequence for x in self.project_topics] + [0])
pt = ProjectTopic(project, self, seq+1)
self.project_topics.append(pt)
self.projects.append(project)
session.add(pt)
session.flush()
def removeProject(self, project):
session = Session.object_session(self)
for pt in self.project_topics:
if pt.project_key == project.key:
self.project_topics.remove(pt)
session.delete(pt)
self.projects.remove(project)
session.flush()
def format_name(self):
name = 'Anonymous Coward'
if self.creator:
if self.creator.name:
name = self.creator.name
elif self.creator.email:
name = self.creator.email
return name
class Story(object):
def __init__(self, id=None, creator=None, created=None, title=None,
description=None, pending=False):
self.id = id
self.creator = creator
self.title = title
self.description = description
self.status = 'active'
self.created = created
self.private = False
self.outdated = False
self.hidden = False
self.subscribed = False
self.pending = pending
self.pending_delete = False
@property
def creator_name(self):
return format_name(self)
def addEvent(self, *args, **kw):
session = Session.object_session(self)
e = Event(*args, **kw)
e.story_key = self.key
self.events.append(e)
session.add(e)
session.flush()
return e
def addTask(self, *args, **kw):
session = Session.object_session(self)
t = Task(*args, **kw)
t.story_key = self.key
self.tasks.append(t)
session.add(t)
session.flush()
return t
def getDraftCommentEvent(self, parent):
for event in self.events:
if (event.comment and event.comment.draft and
event.comment.parent==parent):
return event
return None
def setDraftComment(self, creator, parent, content):
event = self.getDraftCommentEvent(parent)
if event is None:
event = self.addEvent(type='user_comment', creator=creator)
event.addComment()
event.comment.content = content
event.comment.draft = True
event.comment.parent = parent
return event
class Tag(object):
def __init__(self, id, name):
self.id = id
self.name = name
class StoryTag(object):
def __init__(self, story, tag):
self.story_key = story.key
self.tag_key = tag.key
class Task(object):
def __init__(self, id=None, title=None, status=None, creator=None,
created=None, pending=False, pending_delete=False,
project=None):
self.id = id
self.title = title
self.status = status
self.pending = pending
self.pending_delete = pending_delete
self.creator = creator
self.created = created
self.project = project
class Event(object):
def __init__(self, id=None, type=None, creator=None, created=None, info=None):
self.id = id
self.type = type
self.creator = creator
if created is None:
created = datetime.datetime.utcnow()
self.created = created
self.info = info
@property
def creator_name(self):
return format_name(self)
@property
def description(self):
return re.sub('_', ' ', self.type)
def addComment(self, *args, **kw):
session = Session.object_session(self)
c = Comment(*args, **kw)
session.add(c)
session.flush()
self.comment_key = c.key
return c
class Comment(object):
def __init__(self, id=None, content=None, parent=None, draft=False,
pending=False, pending_delete=False):
self.id = id
self.content = content
self.parent = parent
self.pending = pending
self.pending_delete = pending_delete
self.draft = draft
class SyncQuery(object):
def __init__(self, name):
self.name = name
mapper(System, system_table)
mapper(User, user_table)
mapper(Project, project_table, properties=dict(
topics=relationship(Topic,
secondary=project_topic_table,
order_by=topic_table.c.name,
viewonly=True),
active_stories=relationship(Story,
secondary=task_table,
primaryjoin=and_(project_table.c.key==task_table.c.project_key,
story_table.c.key==task_table.c.story_key,
story_table.c.status=='active'),
order_by=story_table.c.id,
),
stories=relationship(Story,
secondary=task_table,
order_by=story_table.c.id,
),
))
mapper(Topic, topic_table, properties=dict(
projects=relationship(Project,
secondary=project_topic_table,
order_by=project_table.c.name,
viewonly=True),
project_topics=relationship(ProjectTopic),
))
mapper(ProjectTopic, project_topic_table)
mapper(Story, story_table, properties=dict(
creator=relationship(User),
tags=relationship(Tag,
secondary=story_tag_table,
order_by=tag_table.c.name,
#viewonly=True
),
tasks=relationship(Task, backref='story',
cascade='all, delete-orphan'),
events=relationship(Event, backref='story',
cascade='all, delete-orphan'),
))
mapper(Tag, tag_table)
mapper(StoryTag, story_tag_table)
mapper(Task, task_table, properties=dict(
project=relationship(Project),
assignee=relationship(User, foreign_keys=task_table.c.assignee_user_key),
creator=relationship(User, foreign_keys=task_table.c.creator_user_key),
))
mapper(Event, event_table, properties=dict(
creator=relationship(User),
comment=relationship(Comment, backref='event'),
))
mapper(Comment, comment_table, properties=dict(
parent=relationship(Comment, remote_side=[comment_table.c.key],backref='children'),
))
mapper(SyncQuery, sync_query_table)
def match(expr, item):
if item is None:
return False
return re.match(expr, item) is not None
@sqlalchemy.event.listens_for(sqlalchemy.engine.Engine, "connect")
def add_sqlite_match(dbapi_connection, connection_record):
dbapi_connection.create_function("matches", 2, match)
class Database(object):
def __init__(self, app, dburi, search):
self.log = logging.getLogger('boartty.db')
self.dburi = dburi
self.search = search
self.engine = create_engine(self.dburi)
#metadata.create_all(self.engine)
self.migrate(app)
# If we want the objects returned from query() to be usable
# outside of the session, we need to expunge them from the session,
# and since the DatabaseSession always calls commit() on the session
# when the context manager exits, we need to inform the session to
# expire objects when it does so.
self.session_factory = sessionmaker(bind=self.engine,
expire_on_commit=False,
autoflush=False)
self.session = scoped_session(self.session_factory)
self.lock = threading.Lock()
def getSession(self):
return DatabaseSession(self)
def migrate(self, app):
conn = self.engine.connect()
context = alembic.migration.MigrationContext.configure(conn)
current_rev = context.get_current_revision()
self.log.debug('Current migration revision: %s' % current_rev)
has_table = self.engine.dialect.has_table(conn, "project")
config = alembic.config.Config()
config.set_main_option("script_location", "boartty:alembic")
config.set_main_option("sqlalchemy.url", self.dburi)
config.boartty_app = app
if current_rev is None and has_table:
self.log.debug('Stamping database as initial revision')
alembic.command.stamp(config, "44402069e137")
alembic.command.upgrade(config, 'head')
class DatabaseSession(object):
def __init__(self, database):
self.database = database
self.session = database.session
self.search = database.search
def __enter__(self):
self.database.lock.acquire()
self.start = time.time()
return self
def __exit__(self, etype, value, tb):
if etype:
self.session().rollback()
else:
self.session().commit()
self.session().close()
self.session = None
end = time.time()
self.database.log.debug("Database lock held %s seconds" % (end-self.start,))
self.database.lock.release()
def abort(self):
self.session().rollback()
def commit(self):
self.session().commit()
def delete(self, obj):
self.session().delete(obj)
def vacuum(self):
self.session().execute("VACUUM")
def getProjects(self, subscribed=False, active=False, topicless=False):
"""Retrieve projects.
:param subscribed: If True limit to only subscribed projects.
:param active: If True limit to only projects with active
stories.
:param topicless: If True limit to only projects without topics.
"""
query = self.session().query(Project)
if subscribed:
query = query.filter_by(subscribed=subscribed)
if active:
query = query.filter(exists().where(Project.active_stories))
if topicless:
query = query.filter_by(topics=None)
return query.order_by(Project.name).all()
def getTopics(self):
return self.session().query(Topic).order_by(Topic.sequence).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 getProjectByID(self, id):
try:
return self.session().query(Project).filter_by(id=id).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getTopic(self, key):
try:
return self.session().query(Topic).filter_by(key=key).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getTopicByName(self, name):
try:
return self.session().query(Topic).filter_by(name=name).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getSyncQueryByName(self, name):
try:
return self.session().query(SyncQuery).filter_by(name=name).one()
except sqlalchemy.orm.exc.NoResultFound:
return self.createSyncQuery(name)
def getStory(self, key):
query = self.session().query(Story).filter_by(key=key)
try:
return query.one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getStoryByID(self, id):
try:
return self.session().query(Story).filter_by(id=id).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getStories(self, query, active, sort_by='number'):
self.database.log.debug("Search query: %s sort: %s" % (query, sort_by))
q = self.session().query(Story)
if query:
q = q.filter(self.search.parse(query))
if active:
q = q.filter(story_table.c.hidden==False, story_table.c.status=='active')
if sort_by == 'updated':
q = q.order_by(story_table.c.updated)
elif sort_by == 'last-seen':
q = q.order_by(story_table.c.last_seen)
else:
q = q.order_by(story_table.c.id)
self.database.log.debug("Search SQL: %s" % q)
try:
return q.all()
except sqlalchemy.orm.exc.NoResultFound:
return []
def getTagByID(self, id):
try:
return self.session().query(Tag).filter_by(id=id).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getTask(self, key):
try:
return self.session().query(Task).filter_by(key=key).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getTaskByID(self, id):
try:
return self.session().query(Task).filter_by(id=id).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 getHeld(self):
return self.session().query(Story).filter_by(held=True).all()
def getOutdated(self):
return self.session().query(Story).filter_by(outdated=True).all()
def getPendingStories(self):
return self.session().query(Story).filter_by(pending=True).all()
def getPendingTasks(self):
return self.session().query(Task).filter_by(pending=True).all()
def getUsers(self):
return self.session().query(User).all()
def getUser(self, key):
try:
return self.session().query(User).filter_by(key=key).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getUserByID(self, id):
try:
return self.session().query(User).filter_by(id=id).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getSystem(self):
try:
return self.session().query(System).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def getEvent(self, key):
try:
return self.session().query(Event).filter_by(key=key).one()
except sqlalchemy.orm.exc.NoResultFound:
return None
def createProject(self, *args, **kw):
o = Project(*args, **kw)
self.session().add(o)
self.session().flush()
return o
def createStory(self, *args, **kw):
s = Story(*args, **kw)
self.session().add(s)
self.session().flush()
return s
def createUser(self, *args, **kw):
a = User(*args, **kw)
self.session().add(a)
self.session().flush()
return a
def createSyncQuery(self, *args, **kw):
o = SyncQuery(*args, **kw)
self.session().add(o)
self.session().flush()
return o
def createTopic(self, *args, **kw):
o = Topic(*args, **kw)
self.session().add(o)
self.session().flush()
return o
def createTag(self, *args, **kw):
o = Tag(*args, **kw)
self.session().add(o)
self.session().flush()
return o
def createSystem(self, *args, **kw):
o = System(*args, **kw)
self.session().add(o)
self.session().flush()
return o

View File

@ -135,17 +135,7 @@ def sqlite_drop_columns(table_name, drop_columns):
for key in meta.tables[table_name].foreign_keys:
# If this is a single column constraint for a dropped column,
# don't copy it.
if isinstance(key.constraint.columns, sqlalchemy.sql.base.ColumnCollection):
# This is needed for SQLAlchemy >= 1.0.4
columns = [c.name for c in key.constraint.columns]
else:
# This is needed for SQLAlchemy <= 0.9.9. This is
# backwards compat code just in case someone updates
# Gertty without updating SQLAlchemy. This is simple
# enough to check and will hopefully avoid leaving the
# user's db in an inconsistent state. Remove this after
# Gertty 1.2.0.
columns = key.constraint.columns
columns = [c.name for c in key.constraint.columns]
if (len(columns)==1 and columns[0] in drop_columns):
continue
# Otherwise, recreate the constraint.

View File

@ -30,7 +30,7 @@ CURSOR_PAGE_DOWN = urwid.CURSOR_PAGE_DOWN
CURSOR_MAX_LEFT = urwid.CURSOR_MAX_LEFT
CURSOR_MAX_RIGHT = urwid.CURSOR_MAX_RIGHT
ACTIVATE = urwid.ACTIVATE
# Global gertty commands:
# Global boartty commands:
KILL = 'kill'
YANK = 'yank'
YANK_POP = 'yank pop'
@ -38,37 +38,31 @@ PREV_SCREEN = 'previous screen'
TOP_SCREEN = 'top screen'
HELP = 'help'
QUIT = 'quit'
CHANGE_SEARCH = 'change search'
REFINE_CHANGE_SEARCH = 'refine change search'
LIST_HELD = 'list held changes'
# Change screen:
TOGGLE_REVIEWED = 'toggle reviewed'
STORY_SEARCH = 'story search'
REFINE_STORY_SEARCH = 'refine story search'
LIST_HELD = 'list held stories'
NEW_STORY = 'new story'
# Story screen:
TOGGLE_HIDDEN = 'toggle hidden'
TOGGLE_STARRED = 'toggle starred'
TOGGLE_HELD = 'toggle held'
TOGGLE_MARK = 'toggle process mark'
REVIEW = 'review'
DIFF = 'diff'
LOCAL_CHECKOUT = 'local checkout'
LOCAL_CHERRY_PICK = 'local cherry pick'
LEAVE_COMMENT = 'leave comment'
SEARCH_RESULTS = 'search results'
NEXT_CHANGE = 'next change'
PREV_CHANGE = 'previous change'
NEXT_STORY = 'next story'
PREV_STORY = 'previous story'
TOGGLE_HIDDEN_COMMENTS = 'toggle hidden comments'
ABANDON_CHANGE = 'abandon change'
RESTORE_CHANGE = 'restore change'
REBASE_CHANGE = 'rebase change'
CHERRY_PICK_CHANGE = 'cherry pick change'
NEW_TASK = 'new task'
DELETE_TASK = 'delete task'
REFRESH = 'refresh'
EDIT_TOPIC = 'edit topic'
EDIT_COMMIT_MESSAGE = 'edit commit message'
SUBMIT_CHANGE = 'submit change'
EDIT_TITLE = 'edit title'
EDIT_DESCRIPTION = 'edit description'
SORT_BY_NUMBER = 'sort by number'
SORT_BY_UPDATED = 'sort by updated'
SORT_BY_LAST_SEEN = 'sort by last seen'
SORT_BY_REVERSE = 'reverse the sort'
# Project list screen:
TOGGLE_LIST_REVIEWED = 'toggle list reviewed'
TOGGLE_LIST_ACTIVE = 'toggle list active'
TOGGLE_LIST_SUBSCRIBED = 'toggle list subscribed'
TOGGLE_SUBSCRIBED = 'toggle subscribed'
NEW_PROJECT_TOPIC = 'new project topic'
@ -77,13 +71,11 @@ MOVE_PROJECT_TOPIC = 'move to project topic'
COPY_PROJECT_TOPIC = 'copy to project topic'
REMOVE_PROJECT_TOPIC = 'remove from project topic'
RENAME_PROJECT_TOPIC = 'rename project topic'
# Diff screens:
SELECT_PATCHSETS = 'select patchsets'
# Special:
FURTHER_INPUT = 'further input'
NEXT_SELECTABLE = 'next selectable'
PREV_SELECTABLE = 'prev selectable'
INTERACTIVE_SEARCH = 'interactive search'
# Special:
FURTHER_INPUT = 'further input'
DEFAULT_KEYMAP = {
REDRAW_SCREEN: 'ctrl l',
@ -104,37 +96,31 @@ DEFAULT_KEYMAP = {
TOP_SCREEN: 'meta home',
HELP: ['f1', '?'],
QUIT: ['ctrl q'],
CHANGE_SEARCH: 'ctrl o',
REFINE_CHANGE_SEARCH: 'meta o',
STORY_SEARCH: 'ctrl o',
REFINE_STORY_SEARCH: 'meta o',
LIST_HELD: 'f12',
NEW_STORY: 'ctrl n',
TOGGLE_REVIEWED: 'v',
TOGGLE_HIDDEN: 'k',
TOGGLE_STARRED: '*',
TOGGLE_HELD: '!',
TOGGLE_MARK: '%',
REVIEW: 'r',
DIFF: 'd',
LOCAL_CHECKOUT: 'c',
LOCAL_CHERRY_PICK: 'x',
LEAVE_COMMENT: 'r',
SEARCH_RESULTS: 'u',
NEXT_CHANGE: 'n',
PREV_CHANGE: 'p',
NEXT_STORY: 'n',
PREV_STORY: 'p',
TOGGLE_HIDDEN_COMMENTS: 't',
ABANDON_CHANGE: 'ctrl a',
RESTORE_CHANGE: 'ctrl e',
REBASE_CHANGE: 'ctrl b',
CHERRY_PICK_CHANGE: 'ctrl x',
NEW_TASK: 'N',
DELETE_TASK: 'delete',
REFRESH: 'ctrl r',
EDIT_TOPIC: 'ctrl t',
EDIT_COMMIT_MESSAGE: 'ctrl d',
SUBMIT_CHANGE: 'ctrl u',
EDIT_TITLE: 'ctrl t',
EDIT_DESCRIPTION: 'ctrl d',
SORT_BY_NUMBER: [['S', 'n']],
SORT_BY_UPDATED: [['S', 'u']],
SORT_BY_LAST_SEEN: [['S', 's']],
SORT_BY_REVERSE: [['S', 'r']],
TOGGLE_LIST_REVIEWED: 'l',
TOGGLE_LIST_ACTIVE: 'l',
TOGGLE_LIST_SUBSCRIBED: 'L',
TOGGLE_SUBSCRIBED: 's',
NEW_PROJECT_TOPIC: [['T', 'n']],
@ -144,7 +130,6 @@ DEFAULT_KEYMAP = {
REMOVE_PROJECT_TOPIC: [['T', 'D']],
RENAME_PROJECT_TOPIC: [['T', 'r']],
SELECT_PATCHSETS: 'p',
NEXT_SELECTABLE: 'tab',
PREV_SELECTABLE: 'shift tab',
INTERACTIVE_SEARCH: 'ctrl s',

View File

@ -14,8 +14,8 @@
import urwid
from gertty import keymap
from gertty.view import mouse_scroll_decorator
from boartty import keymap
from boartty.view import mouse_scroll_decorator
GLOBAL_HELP = (
(keymap.HELP,
@ -25,11 +25,11 @@ GLOBAL_HELP = (
(keymap.TOP_SCREEN,
"Back to project list"),
(keymap.QUIT,
"Quit Gertty"),
(keymap.CHANGE_SEARCH,
"Search for changes"),
"Quit Boardtty"),
(keymap.STORY_SEARCH,
"Search for stories"),
(keymap.LIST_HELD,
"List held changes"),
"List held stories"),
(keymap.KILL,
"Kill to end of line (editing)"),
(keymap.YANK,
@ -192,7 +192,8 @@ class LineEditDialog(ButtonDialog):
class TextEditDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin):
signals = ['save', 'cancel']
def __init__(self, title, prompt, button, text, ring=None):
def __init__(self, app, title, prompt, button, text, ring=None):
self.app = app
save_button = FixedButton(button)
cancel_button = FixedButton('Cancel')
urwid.connect_signal(save_button, 'click',
@ -212,6 +213,16 @@ class TextEditDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin):
fill = urwid.Filler(pile, valign='top')
super(TextEditDialog, self).__init__(urwid.LineBox(fill, title))
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(TextEditDialog, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.PREV_SCREEN in commands:
self._emit('cancel')
return None
return key
class MessageDialog(ButtonDialog):
signals = ['close']
def __init__(self, title, message):
@ -530,3 +541,76 @@ class MyGridFlow(urwid.GridFlow):
c.focus_position = i
break
return p
class SearchSelectInnerButton(urwid.Button):
def __init__(self, key, value):
self.key = key
self.value = value
super(SearchSelectInnerButton, self).__init__(value)
class SearchSelectDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin):
"""
A dialog that allows a user to select one item from a list, and
interactively refine the list by searching.
"""
signals = ['save']
def __init__(self, app, title, current_key, values):
self.app = app
rows = []
self.key = None
self.value = None
for key, value in values():
button = SearchSelectInnerButton(key, value)
urwid.connect_signal(button, 'click',
lambda b:self.onSelected(b))
rows.append(button)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
super(SearchSelectDialog, self).__init__(urwid.LineBox(fill, title))
def onSelected(self, b):
self.key = b.key
self.value = b.value
self._emit('save')
self.app.backScreen()
class SearchSelectButton(TextButton):
"""
A button that displays a value; when clicked, a SearchSelectDialog
is opened to select a new value.
"""
signals = ['changed']
def __init__(self, app, title, key, value, values):
self.app = app
self.title = title
self.values = values
urwid.connect_signal(self, 'click',
lambda button:self.onClick())
super(SearchSelectButton, self).__init__(u'')
self.update(key, value)
def onClick(self):
dialog = SearchSelectDialog(self.app, self.title, self.key, self.values)
urwid.connect_signal(dialog, 'save',
lambda d:self.onChanged(d))
self.app.popup(dialog,
relative_width=30, relative_height=75,
min_width=30, min_height=20)
def update(self, key, value):
self.key = key
self.value = value
if self.value is None:
label = u'Select'
else:
label = self.value
self.text.set_text(label)
def onChanged(self, dialog):
if dialog.key is None:
return
self.update(dialog.key, dialog.value)
self._emit('changed')

103
boartty/palette.py Normal file
View File

@ -0,0 +1,103 @@
# 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.
DEFAULT_PALETTE={
'focused': ['default,standout', ''],
'header': ['white,bold', 'dark blue'],
'error': ['light red', 'dark blue'],
'table-header': ['white,bold', ''],
'link': ['dark blue', ''],
'focused-link': ['light blue', ''],
'footer': ['light gray', 'dark gray'],
'search-result': ['default,standout', ''],
# Story view
'story-data': ['dark cyan', ''],
'focused-story-data': ['light cyan', ''],
'story-header': ['light blue', ''],
'task-id': ['dark cyan', ''],
'task-title': ['light green', ''],
'task-project': ['light blue', ''],
'task-status': ['yellow', ''],
'task-assignee': ['light cyan', ''],
'task-note': ['default', ''],
'focused-task-id': ['dark cyan,standout', ''],
'focused-task-title': ['light green,standout', ''],
'focused-task-project': ['light blue,standout', ''],
'focused-task-status': ['yellow,standout', ''],
'focused-task-assignee': ['dark cyan,standout', ''],
'focused-task-note': ['default', ''],
'story-event-name': ['yellow', ''],
'story-event-own-name': ['light cyan', ''],
'story-event-header': ['brown', ''],
'story-event-own-header': ['dark cyan', ''],
'story-event-draft': ['dark red', ''],
'story-event-button': ['dark magenta', ''],
'focused-story-event-button': ['light magenta', ''],
# project list
'active-project': ['white', ''],
'subscribed-project': ['default', ''],
'unsubscribed-project': ['dark gray', ''],
'marked-project': ['light cyan', ''],
'focused-active-project': ['white,standout', ''],
'focused-subscribed-project': ['default,standout', ''],
'focused-unsubscribed-project': ['dark gray,standout', ''],
'focused-marked-project': ['light cyan,standout', ''],
# story list
'active-story': ['default', ''],
'inactive-story': ['dark gray', ''],
'focused-active-story': ['default,standout', ''],
'focused-inactive-story': ['dark gray,standout', ''],
'starred-story': ['light cyan', ''],
'focused-starred-story': ['light cyan,standout', ''],
'held-story': ['light red', ''],
'focused-held-story': ['light red,standout', ''],
'marked-story': ['dark cyan', ''],
'focused-marked-story': ['dark cyan,standout', ''],
}
# A delta from the default palette
LIGHT_PALETTE = {
'table-header': ['black,bold', ''],
'active-project': ['black', ''],
'subscribed-project': ['dark gray', ''],
'unsubscribed-project': ['dark gray', ''],
'focused-active-project': ['black,standout', ''],
'focused-subscribed-project': ['dark gray,standout', ''],
'focused-unsubscribed-project': ['dark gray,standout', ''],
'story-data': ['dark blue,bold', ''],
'focused-story-data': ['dark blue,standout', ''],
'story-event-name': ['brown', ''],
'story-event-own-name': ['dark blue,bold', ''],
'story-event-header': ['black', ''],
'story-event-own-header': ['black,bold', ''],
'focused-link': ['dark blue,bold', ''],
}
class Palette(object):
def __init__(self, config):
self.palette = {}
self.palette.update(DEFAULT_PALETTE)
self.update(config)
def update(self, config):
d = config.copy()
if 'name' in d:
del d['name']
self.palette.update(d)
def getPalette(self):
ret = []
for k,v in self.palette.items():
ret.append(tuple([k]+v))
return ret

View File

@ -15,8 +15,8 @@
import sqlalchemy.sql.expression
from sqlalchemy.sql.expression import and_
from gertty.search import tokenizer, parser
import gertty.db
from boartty.search import tokenizer, parser
import boartty.db
class SearchSyntaxError(Exception):
@ -35,7 +35,7 @@ class SearchCompiler(object):
while stack:
x = stack.pop()
if hasattr(x, 'table'):
if (x.table != gertty.db.change_table
if (x.table != boartty.db.story_table
and hasattr(x.table, 'name')):
tables.add(x.table)
for child in x.get_children():
@ -47,19 +47,19 @@ class SearchCompiler(object):
self.parser.username = self.username
result = self.parser.parse(data, lexer=self.lexer)
tables = self.findTables(result)
if gertty.db.project_table in tables:
result = and_(gertty.db.change_table.c.project_key == gertty.db.project_table.c.key,
if boartty.db.project_table in tables:
result = and_(boartty.db.story_table.c.project_key == boartty.db.project_table.c.key,
result)
tables.remove(gertty.db.project_table)
if gertty.db.account_table in tables:
result = and_(gertty.db.change_table.c.account_key == gertty.db.account_table.c.key,
tables.remove(boartty.db.project_table)
if boartty.db.user_table in tables:
result = and_(boartty.db.story_table.c.user_key == boartty.db.user_table.c.key,
result)
tables.remove(gertty.db.account_table)
if gertty.db.file_table in tables:
result = and_(gertty.db.file_table.c.revision_key == gertty.db.revision_table.c.key,
gertty.db.revision_table.c.change_key == gertty.db.change_table.c.key,
result)
tables.remove(gertty.db.file_table)
tables.remove(boartty.db.user_table)
#if boartty.db.file_table in tables:
# result = and_(boartty.db.file_table.c.revision_key == boartty.db.revision_table.c.key,
# boartty.db.revision_table.c.story_key == boartty.db.story_table.c.key,
# result)
# tables.remove(boartty.db.file_table)
if tables:
raise Exception("Unknown table in search: %s" % tables)
return result

View File

@ -18,9 +18,9 @@ import re
import ply.yacc as yacc
from sqlalchemy.sql.expression import and_, or_, not_, select, func
import gertty.db
import gertty.search
from gertty.search.tokenizer import tokens # NOQA
import boartty.db
import boartty.search
from boartty.search.tokenizer import tokens # NOQA
def age_to_delta(delta, unit):
if unit in ['seconds', 'second', 'sec', 's']:
@ -68,7 +68,7 @@ def SearchParser():
elif p[2].lower() == 'or':
p[0] = or_(p[1], p[3])
else:
raise gertty.search.SearchSyntaxError("Boolean %s not recognized" % p[2])
raise boartty.search.SearchSyntaxError("Boolean %s not recognized" % p[2])
def p_negative_expr(p):
'''negative_expr : NOT expression
@ -78,7 +78,7 @@ def SearchParser():
def p_term(p):
'''term : age_term
| recentlyseen_term
| change_term
| story_term
| owner_term
| reviewer_term
| commit_term
@ -111,102 +111,107 @@ def SearchParser():
delta = p[2]
unit = p[3]
delta = age_to_delta(delta, unit)
p[0] = gertty.db.change_table.c.updated < (now-datetime.timedelta(seconds=delta))
p[0] = boartty.db.story_table.c.updated < (now-datetime.timedelta(seconds=delta))
def p_recentlyseen_term(p):
'''recentlyseen_term : OP_RECENTLYSEEN NUMBER string'''
# A gertty extension
# A boartty extension
now = datetime.datetime.utcnow()
delta = p[2]
unit = p[3]
delta = age_to_delta(delta, unit)
s = select([func.datetime(func.max(gertty.db.change_table.c.last_seen), '-%s seconds' % delta)],
s = select([func.datetime(func.max(boartty.db.story_table.c.last_seen), '-%s seconds' % delta)],
correlate=False)
p[0] = gertty.db.change_table.c.last_seen >= s
p[0] = boartty.db.story_table.c.last_seen >= s
def p_change_term(p):
'''change_term : OP_CHANGE CHANGE_ID
| OP_CHANGE NUMBER'''
def p_story_term(p):
'''story_term : OP_STORY STORY_ID
| OP_STORY NUMBER'''
if type(p[2]) == int:
p[0] = gertty.db.change_table.c.number == p[2]
p[0] = boartty.db.story_table.c.number == p[2]
else:
p[0] = gertty.db.change_table.c.change_id == p[2]
p[0] = boartty.db.story_table.c.story_id == p[2]
def p_owner_term(p):
'''owner_term : OP_OWNER string'''
if p[2] == 'self':
username = p.parser.username
p[0] = gertty.db.account_table.c.username == username
p[0] = boartty.db.user_table.c.username == username
else:
p[0] = or_(gertty.db.account_table.c.username == p[2],
gertty.db.account_table.c.email == p[2],
gertty.db.account_table.c.name == p[2])
p[0] = or_(boartty.db.user_table.c.username == p[2],
boartty.db.user_table.c.email == p[2],
boartty.db.user_table.c.name == p[2])
def p_reviewer_term(p):
'''reviewer_term : OP_REVIEWER string
| OP_REVIEWER NUMBER'''
filters = []
filters.append(gertty.db.approval_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.approval_table.c.account_key == gertty.db.account_table.c.key)
filters.append(boartty.db.approval_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.approval_table.c.user_key == boartty.db.user_table.c.key)
try:
number = int(p[2])
except:
number = None
if number is not None:
filters.append(gertty.db.account_table.c.id == number)
filters.append(boartty.db.user_table.c.id == number)
elif p[2] == 'self':
username = p.parser.username
filters.append(gertty.db.account_table.c.username == username)
filters.append(boartty.db.user_table.c.username == username)
else:
filters.append(or_(gertty.db.account_table.c.username == p[2],
gertty.db.account_table.c.email == p[2],
gertty.db.account_table.c.name == p[2]))
s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = gertty.db.change_table.c.key.in_(s)
filters.append(or_(boartty.db.user_table.c.username == p[2],
boartty.db.user_table.c.email == p[2],
boartty.db.user_table.c.name == p[2]))
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
def p_commit_term(p):
'''commit_term : OP_COMMIT string'''
filters = []
filters.append(gertty.db.revision_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.revision_table.c.commit == p[2])
s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = gertty.db.change_table.c.key.in_(s)
filters.append(boartty.db.revision_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.revision_table.c.commit == p[2])
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
def p_project_term(p):
'''project_term : OP_PROJECT string'''
if p[2].startswith('^'):
p[0] = func.matches(p[2], gertty.db.project_table.c.name)
p[0] = func.matches(p[2], boartty.db.project_table.c.name)
else:
p[0] = gertty.db.project_table.c.name == p[2]
p[0] = boartty.db.project_table.c.name == p[2]
def p_projects_term(p):
'''projects_term : OP_PROJECTS string'''
p[0] = gertty.db.project_table.c.name.like('%s%%' % p[2])
p[0] = boartty.db.project_table.c.name.like('%s%%' % p[2])
def p_project_key_term(p):
'''project_key_term : OP_PROJECT_KEY NUMBER'''
p[0] = gertty.db.change_table.c.project_key == p[2]
#p[0] = boartty.db.story_table.c.project_key == p[2]
filters = []
filters.append(boartty.db.task_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.task_table.c.project_key == p[2])
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
def p_branch_term(p):
'''branch_term : OP_BRANCH string'''
if p[2].startswith('^'):
p[0] = func.matches(p[2], gertty.db.change_table.c.branch)
p[0] = func.matches(p[2], boartty.db.story_table.c.branch)
else:
p[0] = gertty.db.change_table.c.branch == p[2]
p[0] = boartty.db.story_table.c.branch == p[2]
def p_topic_term(p):
'''topic_term : OP_TOPIC string'''
if p[2].startswith('^'):
p[0] = func.matches(p[2], gertty.db.change_table.c.topic)
p[0] = func.matches(p[2], boartty.db.story_table.c.topic)
else:
p[0] = gertty.db.change_table.c.topic == p[2]
p[0] = boartty.db.story_table.c.topic == p[2]
def p_ref_term(p):
'''ref_term : OP_REF string'''
if p[2].startswith('^'):
p[0] = func.matches(p[2], 'refs/heads/'+gertty.db.change_table.c.branch)
p[0] = func.matches(p[2], 'refs/heads/'+boartty.db.story_table.c.branch)
else:
p[0] = gertty.db.change_table.c.branch == p[2][len('refs/heads/'):]
p[0] = boartty.db.story_table.c.branch == p[2][len('refs/heads/'):]
label_re = re.compile(r'(?P<label>[a-zA-Z0-9_-]+([a-zA-Z]|((?<![-+])[0-9])))'
r'(?P<operator>[<>]?=?)(?P<value>[-+]?[0-9]+)'
@ -221,60 +226,60 @@ def SearchParser():
user = args.group('user')
filters = []
filters.append(gertty.db.approval_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.approval_table.c.category == label)
filters.append(boartty.db.approval_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.approval_table.c.category == label)
if op == '=':
filters.append(gertty.db.approval_table.c.value == value)
filters.append(boartty.db.approval_table.c.value == value)
elif op == '>=':
filters.append(gertty.db.approval_table.c.value >= value)
filters.append(boartty.db.approval_table.c.value >= value)
elif op == '<=':
filters.append(gertty.db.approval_table.c.value <= value)
filters.append(boartty.db.approval_table.c.value <= value)
if user is not None:
filters.append(gertty.db.approval_table.c.account_key == gertty.db.account_table.c.key)
filters.append(boartty.db.approval_table.c.user_key == boartty.db.user_table.c.key)
if user == 'self':
filters.append(gertty.db.account_table.c.username == p.parser.username)
filters.append(boartty.db.user_table.c.username == p.parser.username)
else:
filters.append(
or_(gertty.db.account_table.c.username == user,
gertty.db.account_table.c.email == user,
gertty.db.account_table.c.name == user))
s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = gertty.db.change_table.c.key.in_(s)
or_(boartty.db.user_table.c.username == user,
boartty.db.user_table.c.email == user,
boartty.db.user_table.c.name == user))
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
def p_message_term(p):
'''message_term : OP_MESSAGE string'''
filters = []
filters.append(gertty.db.revision_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.revision_table.c.message.like('%%%s%%' % p[2]))
s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = gertty.db.change_table.c.key.in_(s)
filters.append(boartty.db.revision_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.revision_table.c.message.like('%%%s%%' % p[2]))
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
def p_comment_term(p):
'''comment_term : OP_COMMENT string'''
filters = []
filters.append(gertty.db.revision_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.revision_table.c.message == p[2])
revision_select = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
filters.append(boartty.db.revision_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.revision_table.c.message == p[2])
revision_select = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
filters = []
filters.append(gertty.db.revision_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.comment_table.c.revision_key == gertty.db.revision_table.c.key)
filters.append(gertty.db.comment_table.c.message == p[2])
comment_select = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = or_(gertty.db.change_table.c.key.in_(comment_select),
gertty.db.change_table.c.key.in_(revision_select))
filters.append(boartty.db.revision_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.comment_table.c.revision_key == boartty.db.revision_table.c.key)
filters.append(boartty.db.comment_table.c.message == p[2])
comment_select = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = or_(boartty.db.story_table.c.key.in_(comment_select),
boartty.db.story_table.c.key.in_(revision_select))
def p_has_term(p):
'''has_term : OP_HAS string'''
#TODO: implement star
if p[2] == 'draft':
filters = []
filters.append(gertty.db.revision_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.message_table.c.revision_key == gertty.db.revision_table.c.key)
filters.append(gertty.db.message_table.c.draft == True)
s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = gertty.db.change_table.c.key.in_(s)
filters.append(boartty.db.revision_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.message_table.c.revision_key == boartty.db.revision_table.c.key)
filters.append(boartty.db.message_table.c.draft == True)
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
else:
raise gertty.search.SearchSyntaxError('Syntax error: has:%s is not supported' % p[2])
raise boartty.search.SearchSyntaxError('Syntax error: has:%s is not supported' % p[2])
def p_is_term(p):
'''is_term : OP_IS string'''
@ -282,58 +287,58 @@ def SearchParser():
username = p.parser.username
if p[2] == 'reviewed':
filters = []
filters.append(gertty.db.approval_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.approval_table.c.value != 0)
s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = gertty.db.change_table.c.key.in_(s)
filters.append(boartty.db.approval_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.approval_table.c.value != 0)
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
elif p[2] == 'open':
p[0] = gertty.db.change_table.c.status.notin_(['MERGED', 'ABANDONED'])
p[0] = boartty.db.story_table.c.status.notin_(['MERGED', 'ABANDONED'])
elif p[2] == 'closed':
p[0] = gertty.db.change_table.c.status.in_(['MERGED', 'ABANDONED'])
p[0] = boartty.db.story_table.c.status.in_(['MERGED', 'ABANDONED'])
elif p[2] == 'submitted':
p[0] = gertty.db.change_table.c.status == 'SUBMITTED'
p[0] = boartty.db.story_table.c.status == 'SUBMITTED'
elif p[2] == 'merged':
p[0] = gertty.db.change_table.c.status == 'MERGED'
p[0] = boartty.db.story_table.c.status == 'MERGED'
elif p[2] == 'abandoned':
p[0] = gertty.db.change_table.c.status == 'ABANDONED'
p[0] = boartty.db.story_table.c.status == 'ABANDONED'
elif p[2] == 'owner':
p[0] = gertty.db.account_table.c.username == username
p[0] = boartty.db.user_table.c.username == username
elif p[2] == 'starred':
p[0] = gertty.db.change_table.c.starred == True
p[0] = boartty.db.story_table.c.starred == True
elif p[2] == 'held':
# A gertty extension
p[0] = gertty.db.change_table.c.held == True
# A boartty extension
p[0] = boartty.db.story_table.c.held == True
elif p[2] == 'reviewer':
filters = []
filters.append(gertty.db.approval_table.c.change_key == gertty.db.change_table.c.key)
filters.append(gertty.db.approval_table.c.account_key == gertty.db.account_table.c.key)
filters.append(gertty.db.account_table.c.username == username)
s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters))
p[0] = gertty.db.change_table.c.key.in_(s)
filters.append(boartty.db.approval_table.c.story_key == boartty.db.story_table.c.key)
filters.append(boartty.db.approval_table.c.user_key == boartty.db.user_table.c.key)
filters.append(boartty.db.user_table.c.username == username)
s = select([boartty.db.story_table.c.key], correlate=False).where(and_(*filters))
p[0] = boartty.db.story_table.c.key.in_(s)
elif p[2] == 'watched':
p[0] = gertty.db.project_table.c.subscribed == True
p[0] = boartty.db.project_table.c.subscribed == True
else:
raise gertty.search.SearchSyntaxError('Syntax error: is:%s is not supported' % p[2])
raise boartty.search.SearchSyntaxError('Syntax error: is:%s is not supported' % p[2])
def p_file_term(p):
'''file_term : OP_FILE string'''
if p[2].startswith('^'):
p[0] = and_(or_(func.matches(p[2], gertty.db.file_table.c.path),
func.matches(p[2], gertty.db.file_table.c.old_path)),
gertty.db.file_table.c.status is not None)
p[0] = and_(or_(func.matches(p[2], boartty.db.file_table.c.path),
func.matches(p[2], boartty.db.file_table.c.old_path)),
boartty.db.file_table.c.status is not None)
else:
p[0] = and_(or_(gertty.db.file_table.c.path == p[2],
gertty.db.file_table.c.old_path == p[2]),
gertty.db.file_table.c.status is not None)
p[0] = and_(or_(boartty.db.file_table.c.path == p[2],
boartty.db.file_table.c.old_path == p[2]),
boartty.db.file_table.c.status is not None)
def p_status_term(p):
'''status_term : OP_STATUS string'''
if p[2] == 'open':
p[0] = gertty.db.change_table.c.status.notin_(['MERGED', 'ABANDONED'])
p[0] = boartty.db.story_table.c.status.notin_(['MERGED', 'ABANDONED'])
elif p[2] == 'closed':
p[0] = gertty.db.change_table.c.status.in_(['MERGED', 'ABANDONED'])
p[0] = boartty.db.story_table.c.status.in_(['MERGED', 'ABANDONED'])
else:
p[0] = gertty.db.change_table.c.status == p[2].upper()
p[0] = boartty.db.story_table.c.status == p[2]
def p_limit_term(p):
'''limit_term : OP_LIMIT NUMBER'''
@ -341,7 +346,7 @@ def SearchParser():
# applied to the query operation and so can not be returned as
# part of the production here. The information would need to
# be returned out-of-band. In the mean time, since limits are
# not as important in gertty, make this a no-op for now so
# not as important in boartty, make this a no-op for now so
# that it does not produce a syntax error.
p[0] = (True == True)
@ -351,9 +356,9 @@ def SearchParser():
def p_error(p):
if p:
raise gertty.search.SearchSyntaxError('Syntax error at "%s" in search string "%s" (col %s)' % (
raise boartty.search.SearchSyntaxError('Syntax error at "%s" in search string "%s" (col %s)' % (
p.lexer.lexdata[p.lexpos:], p.lexer.lexdata, p.lexpos))
else:
raise gertty.search.SearchSyntaxError('Syntax error: EOF in search string')
raise boartty.search.SearchSyntaxError('Syntax error: EOF in search string')
return yacc.yacc(debug=0, write_tables=0)

View File

@ -17,8 +17,8 @@ import six
operators = {
'age': 'OP_AGE',
'recentlyseen': 'OP_RECENTLYSEEN', # Gertty extension
'change': 'OP_CHANGE',
'recentlyseen': 'OP_RECENTLYSEEN', # Boardtty extension
'story': 'OP_STORY',
'owner': 'OP_OWNER',
#'OP_OWNERIN', # needs local group membership
'reviewer': 'OP_REVIEWER',
@ -26,7 +26,7 @@ operators = {
'commit': 'OP_COMMIT',
'project': 'OP_PROJECT',
'projects': 'OP_PROJECTS',
'_project_key': 'OP_PROJECT_KEY', # internal gertty use only
'_project_key': 'OP_PROJECT_KEY', # internal boartty use only
'branch': 'OP_BRANCH',
'topic': 'OP_TOPIC',
'ref': 'OP_REF',
@ -56,7 +56,7 @@ tokens = [
'LPAREN',
'RPAREN',
'NUMBER',
'CHANGE_ID',
'STORY_ID',
'SSTRING',
'DSTRING',
'USTRING',
@ -75,7 +75,7 @@ def SearchTokenizer():
t.type = operators.get(t.value[:-1], 'OP')
return t
def t_CHANGE_ID(t):
def t_STORY_ID(t):
r'I[a-fA-F0-9]{7,40}'
return t

1137
boartty/sync.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,4 +14,4 @@
import pbr.version
version_info = pbr.version.VersionInfo('gertty')
version_info = pbr.version.VersionInfo('boartty')

View File

@ -16,11 +16,13 @@
import logging
import urwid
from gertty import keymap
from gertty import mywid
from gertty import sync
from gertty.view import change_list as view_change_list
from gertty.view import mouse_scroll_decorator
from boartty import keymap
from boartty import mywid
from boartty import sync
from boartty.view import story_list as view_story_list
from boartty.view import mouse_scroll_decorator
ACTIVE_COL_WIDTH = 7
class TopicSelectDialog(urwid.WidgetWrap):
signals = ['ok', 'cancel']
@ -59,7 +61,7 @@ class TopicSelectDialog(urwid.WidgetWrap):
class ProjectRow(urwid.Button):
project_focus_map = {None: 'focused',
'unreviewed-project': 'focused-unreviewed-project',
'active-project': 'focused-active-project',
'subscribed-project': 'focused-subscribed-project',
'unsubscribed-project': 'focused-unsubscribed-project',
'marked-project': 'focused-marked-project',
@ -94,12 +96,10 @@ class ProjectRow(urwid.Button):
self.name = mywid.SearchableText('')
self._setName(project.name, self.indent)
self.name.set_wrap_mode('clip')
self.unreviewed_changes = urwid.Text(u'', align=urwid.RIGHT)
self.open_changes = urwid.Text(u'', align=urwid.RIGHT)
self.active_stories = urwid.Text(u'', align=urwid.RIGHT)
col = urwid.Columns([
self.name,
('fixed', 11, self.unreviewed_changes),
('fixed', 5, self.open_changes),
('fixed', ACTIVE_COL_WIDTH, self.active_stories),
])
self.row_style = urwid.AttrMap(col, '')
self._w = urwid.AttrMap(self.row_style, None, focus_map=self.project_focus_map)
@ -111,18 +111,14 @@ class ProjectRow(urwid.Button):
def update(self, project):
cache = self.app.project_cache.get(project)
if project.subscribed:
if cache['unreviewed_changes'] > 0:
style = 'unreviewed-project'
else:
style = 'subscribed-project'
style = 'subscribed-project'
else:
style = 'unsubscribed-project'
self._style = style
if self.mark:
style = 'marked-project'
self.row_style.set_attr_map({None: style})
self.unreviewed_changes.set_text('%i ' % cache['unreviewed_changes'])
self.open_changes.set_text('%i ' % cache['open_changes'])
self.active_stories.set_text('%i ' % cache['active_stories'])
def toggleMark(self):
self.mark = not self.mark
@ -160,12 +156,10 @@ class TopicRow(urwid.Button):
self.name = urwid.Text('')
self._setName(topic.name)
self.name.set_wrap_mode('clip')
self.unreviewed_changes = urwid.Text(u'', align=urwid.RIGHT)
self.open_changes = urwid.Text(u'', align=urwid.RIGHT)
self.active_stories = urwid.Text(u'', align=urwid.RIGHT)
col = urwid.Columns([
self.name,
('fixed', 11, self.unreviewed_changes),
('fixed', 5, self.open_changes),
('fixed', ACTIVE_COL_WIDTH, self.active_stories),
])
self.row_style = urwid.AttrMap(col, '')
self._w = urwid.AttrMap(self.row_style, None, focus_map=self.project_focus_map)
@ -173,16 +167,12 @@ class TopicRow(urwid.Button):
self.row_style.set_attr_map({None: self._style})
self.update(topic)
def update(self, topic, unreviewed_changes=None, open_changes=None):
def update(self, topic, active_stories=None):
self._setName(topic.name)
if unreviewed_changes is None:
self.unreviewed_changes.set_text('')
if active_stories is None:
self.active_stories.set_text('')
else:
self.unreviewed_changes.set_text('%i ' % unreviewed_changes)
if open_changes is None:
self.open_changes.set_text('')
else:
self.open_changes.set_text('%i ' % open_changes)
self.active_stories.set_text('%i ' % active_stories)
def toggleMark(self):
self.mark = not self.mark
@ -196,8 +186,7 @@ class TopicRow(urwid.Button):
class ProjectListHeader(urwid.WidgetWrap):
def __init__(self):
cols = [urwid.Text(u' Project'),
(11, urwid.Text(u'Unreviewed')),
(5, urwid.Text(u'Open'))]
(ACTIVE_COL_WIDTH, urwid.Text(u'Active'))]
super(ProjectListHeader, self).__init__(urwid.Columns(cols))
@mouse_scroll_decorator.ScrollByWheel
@ -206,8 +195,8 @@ class ProjectListView(urwid.WidgetWrap, mywid.Searchable):
return [
(keymap.TOGGLE_LIST_SUBSCRIBED,
"Toggle whether only subscribed projects or all projects are listed"),
(keymap.TOGGLE_LIST_REVIEWED,
"Toggle listing of projects with unreviewed changes"),
(keymap.TOGGLE_LIST_ACTIVE,
"Toggle listing of projects with active changes"),
(keymap.TOGGLE_SUBSCRIBED,
"Toggle the subscription flag for the selected project"),
(keymap.REFRESH,
@ -237,10 +226,10 @@ class ProjectListView(urwid.WidgetWrap, mywid.Searchable):
def __init__(self, app):
super(ProjectListView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('gertty.view.project_list')
self.log = logging.getLogger('boartty.view.project_list')
self.searchInit()
self.app = app
self.unreviewed = True
self.active = True
self.subscribed = True
self.project_rows = {}
self.topic_rows = {}
@ -257,10 +246,10 @@ class ProjectListView(urwid.WidgetWrap, mywid.Searchable):
def interested(self, event):
if not (isinstance(event, sync.ProjectAddedEvent)
or
isinstance(event, sync.ChangeAddedEvent)
isinstance(event, sync.StoryAddedEvent)
or
(isinstance(event, sync.ChangeUpdatedEvent) and
(event.status_changed or event.review_flag_changed))):
(isinstance(event, sync.StoryUpdatedEvent) and
event.status_changed)):
self.log.debug("Ignoring refresh project list due to event %s" % (event,))
return False
self.log.debug("Refreshing project list due to event %s" % (event,))
@ -326,8 +315,8 @@ class ProjectListView(urwid.WidgetWrap, mywid.Searchable):
if self.subscribed:
self.title = u'Subscribed projects'
self.short_title = self.title[:]
if self.unreviewed:
self.title += u' with unreviewed changes'
if self.active:
self.title += u' with active stories'
else:
self.title = u'All projects'
self.short_title = self.title[:]
@ -335,28 +324,24 @@ class ProjectListView(urwid.WidgetWrap, mywid.Searchable):
with self.app.db.getSession() as session:
i = 0
for project in session.getProjects(topicless=True,
subscribed=self.subscribed, unreviewed=self.unreviewed):
subscribed=self.subscribed, active=self.active):
#self.log.debug("project: %s" % project.name)
i = self._projectRow(i, project, None)
for topic in session.getTopics():
#self.log.debug("topic: %s" % topic.name)
i = self._topicRow(i, topic)
topic_unreviewed = 0
topic_open = 0
topic_active = 0
for project in topic.projects:
#self.log.debug(" project: %s" % project.name)
cache = self.app.project_cache.get(project)
topic_unreviewed += cache['unreviewed_changes']
topic_open += cache['open_changes']
topic_active += cache['active_stories']
if self.subscribed:
if not project.subscribed:
continue
if self.unreviewed and not cache['unreviewed_changes']:
continue
if topic.key in self.open_topics:
i = self._projectRow(i, project, topic)
topic_row = self.topic_rows.get(topic.key)
topic_row.update(topic, topic_unreviewed, topic_open)
topic_row.update(topic, topic_active)
while i < len(self.listbox.body):
current_row = self.listbox.body[i]
self._deleteRow(current_row)
@ -370,10 +355,11 @@ class ProjectListView(urwid.WidgetWrap, mywid.Searchable):
def onSelect(self, button, data):
project_key, project_name = data
self.app.changeScreen(view_change_list.ChangeListView(
self.app,
"_project_key:%s %s" % (project_key, self.app.config.project_change_list_query),
project_name, project_key=project_key, unreviewed=True))
self.app.changeScreen(view_story_list.StoryListView(
self.app,
"_project_key:%s %s" % (project_key,
self.app.config.project_story_list_query),
project_name, project_key=project_key, active=True))
def onSelectTopic(self, button, data):
topic_key = data[0]
@ -553,8 +539,8 @@ class ProjectListView(urwid.WidgetWrap, mywid.Searchable):
return key
def handleCommands(self, commands):
if keymap.TOGGLE_LIST_REVIEWED in commands:
self.unreviewed = not self.unreviewed
if keymap.TOGGLE_LIST_ACTIVE in commands:
self.active = not self.active
self.refresh()
return True
if keymap.TOGGLE_LIST_SUBSCRIBED in commands:

837
boartty/view/story.py Normal file
View File

@ -0,0 +1,837 @@
# Copyright 2014 OpenStack Foundation
# Copyright 2014 Hewlett-Packard Development Company, L.P.
# Copyright 2016 Red Hat, Inc
#
# 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 datetime
import logging
try:
import ordereddict
except:
pass
import textwrap
from six.moves.urllib import parse as urlparse
import urwid
from boartty import keymap
from boartty import mywid
from boartty import sync
from boartty.view import mouse_scroll_decorator
import boartty.view
try:
OrderedDict = collections.OrderedDict
except AttributeError:
OrderedDict = ordereddict.OrderedDict
class NewStoryDialog(urwid.WidgetWrap, mywid.LineBoxTitlePropertyMixin):
signals = ['save', 'cancel']
def __init__(self, app):
self.app = 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'))
rows = []
buttons = [('pack', save_button),
('pack', cancel_button)]
buttons = urwid.Columns(buttons, dividechars=2)
self.project_button = ProjectButton(self.app)
self.title_field = mywid.MyEdit(u'', edit_text=u'', ring=app.ring)
self.description_field = mywid.MyEdit(u'', edit_text='',
multiline=True, ring=app.ring)
for (label, w) in [
(u'Title:', self.title_field),
(u'Description:', self.description_field),
(u'Project:', ('pack', self.project_button)),
]:
row = urwid.Columns([(12, urwid.Text(label)), w])
rows.append(row)
rows.append(urwid.Divider())
rows.append(buttons)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
super(NewStoryDialog, self).__init__(urwid.LineBox(fill, 'New Story'))
class ProjectButton(mywid.SearchSelectButton):
def __init__(self, app, key=None, value=None):
self.app = app
super(ProjectButton, self).__init__(app, 'Select Project', key, value,
self.getValues)
def getValues(self):
with self.app.db.getSession() as session:
projects = session.getProjects()
for project in projects:
yield (project.key, project.name)
class StatusButton(mywid.SearchSelectButton):
def __init__(self, app):
self.app = app
super(StatusButton, self).__init__(app, 'Select Status', 'todo', 'todo',
self.getValues)
def getValues(self):
return [('todo', 'todo'),
('merged', 'merged'),
('invalid', 'invalid'),
('review', 'review'),
('inprogress', 'inprogress'),
]
class AssigneeButton(mywid.SearchSelectButton):
def __init__(self, app):
self.app = app
super(AssigneeButton, self).__init__(app, 'Select Assignee', None, None,
self.getValues)
def getValues(self):
with self.app.db.getSession() as session:
users = session.getUsers()
for user in users:
yield (user.key, user.name)
class NewTaskDialog(urwid.WidgetWrap, mywid.LineBoxTitlePropertyMixin):
signals = ['save', 'cancel']
def __init__(self, app):
self.app = 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'))
rows = []
buttons = [('pack', save_button),
('pack', cancel_button)]
buttons = urwid.Columns(buttons, dividechars=2)
self.project_button = ProjectButton(self.app)
self.status_button = StatusButton(self.app)
self.assignee_button = AssigneeButton(self.app)
self.title_field = mywid.MyEdit(u'', edit_text=u'', ring=app.ring)
for (label, w) in [
(u'Project:', ('pack', self.project_button)),
(u'Title:', self.title_field),
(u'Status:', ('pack', self.status_button)),
(u'Assignee:', ('pack', self.assignee_button)),
]:
row = urwid.Columns([(12, urwid.Text(label)), w])
rows.append(row)
rows.append(urwid.Divider())
rows.append(buttons)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
super(NewTaskDialog, self).__init__(urwid.LineBox(fill, 'New Task'))
class TaskRow(urwid.WidgetWrap):
task_focus_map = {
'task-title': 'focused-task-title',
'task-project': 'focused-task-project',
'task-status': 'focused-task-status',
'task-assignee': 'focused-task-assignee',
'task-note': 'focused-task-note',
}
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(TaskRow, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.DELETE_TASK in commands:
self.delete()
return None
return key
def __init__(self, app, story_view, task):
super(TaskRow, self).__init__(urwid.Pile([]))
self.app = app
self.story_view = story_view
self.task_key = task.key
self._note = u''
self.taskid = mywid.TextButton(self._note)
urwid.connect_signal(self.taskid, 'click',
lambda b:self.editNote(b))
self.project = ProjectButton(self.app)
urwid.connect_signal(self.project, 'changed',
lambda b:self.updateProject(b))
self.status = StatusButton(self.app)
urwid.connect_signal(self.status, 'changed',
lambda b:self.updateStatus(b))
self._title = u''
self.title = mywid.TextButton(self._title)
urwid.connect_signal(self.title, 'click',
lambda b:self.editTitle(b))
self.assignee = AssigneeButton(self.app)
urwid.connect_signal(self.assignee, 'changed',
lambda b:self.updateAssignee(b))
self.description = urwid.Text(u'')
self.columns = urwid.Columns([], dividechars=1)
for (widget, attr, packing) in [
(self.taskid, 'task-id', ('given', 4, False)),
(self.project, 'task-project', ('weight', 1, False)),
(self.title, 'task-title', ('weight', 2, False)),
(self.status, 'task-status', ('weight', 1, False)),
(self.assignee, 'task-assignee', ('weight', 1, False)),
]:
w = urwid.AttrMap(urwid.Padding(widget, width='pack'), attr,
focus_map={'focused': 'focused-'+attr})
self.columns.contents.append((w, packing))
self.pile = urwid.Pile([self.columns])
self.note = urwid.Text(u'')
self.note_visible = False
self.note_columns = urwid.Columns([], dividechars=1)
self.note_columns.contents.append((urwid.Text(u''), ('given', 1, False)))
self.note_columns.contents.append((self.note, ('weight', 1, False)))
self._w = urwid.AttrMap(self.pile, None)#, focus_map=self.task_focus_map)
self.refresh(task)
def setNote(self, note):
if note:
self._note = note
self.note.set_text(('task-note', self._note))
if not self.note_visible:
self.pile.contents.append((self.note_columns, ('weight', 1)))
self.note_visible = True
elif self.note_visible:
for x in self.pile.contents[:]:
if x[0] is self.note_columns:
self.pile.contents.remove(x)
self.note_visible = False
def refresh(self, task):
self.taskid.text.set_text(str(task.id))
self.project.update(task.project.key, task.project.name)
self.status.update(task.status, task.status)
self._title = task.title
self.title.text.set_text(self._title)
self.setNote(task.link)
if task.assignee:
self.assignee.update(task.assignee.key, task.assignee.name)
else:
self.assignee.update(None, 'Unassigned')
def updateProject(self, project_button):
with self.app.db.getSession() as session:
task = session.getTask(self.task_key)
project = session.getProject(project_button.key)
task.project = project
self.app.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
def updateStatus(self, status_button):
with self.app.db.getSession() as session:
task = session.getTask(self.task_key)
task.status = status_button.key
self.app.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
def updateAssignee(self, assignee_button):
with self.app.db.getSession() as session:
task = session.getTask(self.task_key)
user = session.getUser(assignee_button.key)
task.assignee = user
self.app.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
def editTitle(self, title_button):
dialog = mywid.LineEditDialog(self.app, 'Edit Task Title', '',
'Title: ', self._title,
ring=self.app.ring)
urwid.connect_signal(dialog, 'save',
lambda button: self.updateTitle(dialog, True))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.updateTitle(dialog, False))
self.app.popup(dialog)
def updateTitle(self, dialog, save):
if save:
with self.app.db.getSession() as session:
task = session.getTask(self.task_key)
task.title = dialog.entry.edit_text
self._title = task.title
self.title.text.set_text(self._title)
self.app.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
self.app.backScreen()
def editNote(self, note_button):
dialog = mywid.LineEditDialog(self.app, 'Edit Task Note', '',
'Note: ', self._note,
ring=self.app.ring)
urwid.connect_signal(dialog, 'save',
lambda button: self.updateNote(dialog, True))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.updateNote(dialog, False))
self.app.popup(dialog)
def updateNote(self, dialog, save):
if save:
with self.app.db.getSession() as session:
task = session.getTask(self.task_key)
task.link = dialog.entry.edit_text or None
self.setNote(task.link)
self.app.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
self.app.backScreen()
def delete(self):
dialog = mywid.YesNoDialog(u'Delete Task',
u'Are you sure you want to delete this task?')
urwid.connect_signal(dialog, 'no', lambda d: self.app.backScreen())
urwid.connect_signal(dialog, 'yes', self.finishDelete)
self.app.popup(dialog)
def finishDelete(self, dialog):
with self.app.db.getSession() as session:
task = session.getTask(self.task_key)
task.pending_delete = True
self.app.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.story_view.refresh()
class StoryButton(urwid.Button):
button_left = urwid.Text(u' ')
button_right = urwid.Text(u' ')
def __init__(self, story_view, story_key, text):
super(StoryButton, self).__init__('')
self.set_label(text)
self.story_view = story_view
self.story_key = story_key
urwid.connect_signal(self, 'click',
lambda button: self.openStory())
def set_label(self, text):
super(StoryButton, self).set_label(text)
def openStory(self):
try:
self.story_view.app.changeScreen(StoryView(self.story_view.app, self.story_key))
except boartty.view.DisplayError as e:
self.story_view.app.error(e.message)
class StoryEventBox(mywid.HyperText):
def __init__(self, story_view, event):
super(StoryEventBox, self).__init__(u'')
self.story_view = story_view
self.app = story_view.app
self.refresh(event)
def formatReply(self):
text = self.comment_text
pgraphs = []
pgraph_accumulator = []
wrap = True
for line in text.split('\n'):
if line.startswith('> '):
wrap = False
line = '> ' + line
if not line:
if pgraph_accumulator:
pgraphs.append((wrap, '\n'.join(pgraph_accumulator)))
pgraph_accumulator = []
wrap = True
continue
pgraph_accumulator.append(line)
if pgraph_accumulator:
pgraphs.append((wrap, '\n'.join(pgraph_accumulator)))
pgraph_accumulator = []
wrap = True
wrapper = textwrap.TextWrapper(initial_indent='> ',
subsequent_indent='> ')
wrapped_pgraphs = []
for wrap, pgraph in pgraphs:
if wrap:
wrapped_pgraphs.append('\n'.join(wrapper.wrap(pgraph)))
else:
wrapped_pgraphs.append(pgraph)
return '\n>\n'.join(wrapped_pgraphs)
def reply(self):
reply_text = self.formatReply()
if reply_text:
reply_text = self.event_creator + ' wrote:\n\n' + reply_text + '\n'
self.story_view.leaveComment(reply_text=reply_text)
def refresh(self, event):
self.event_id = event.id
self.event_creator = event.creator_name
description = event.description
if event.comment:
comment = event.comment.content
else:
comment = ''
self.comment_text = comment
created = self.app.time(event.created)
lines = comment.split('\n')
if event.creator.id == self.app.user_id:
name_style = 'story-event-own-name'
header_style = 'story-event-own-header'
creator_string = event.creator.name
else:
name_style = 'story-event-name'
header_style = 'story-event-header'
if event.creator.email:
creator_string = "%s <%s>" % (
event.creator.name,
event.creator.email)
else:
creator_string = event.creator.name
text = [(name_style, creator_string),
(header_style, ': '+description),
(header_style,
created.strftime(' (%Y-%m-%d %H:%M:%S%z)'))]
if event.comment and event.comment.draft and not event.comment.pending:
text.append(('story-event-draft', ' (draft)'))
elif event.comment:
link = mywid.Link('< Reply >',
'story-event-button',
'focused-story-event-button')
urwid.connect_signal(link, 'selected',
lambda link:self.reply())
text.append(' ')
text.append(link)
text.append('\n')
if lines and lines[-1]:
lines.append('')
comment_text = ['\n'.join(lines)]
for commentlink in self.app.config.commentlinks:
comment_text = commentlink.run(self.app, comment_text)
info = event.info or ''
if info:
info = [info + '\n']
else:
info = []
self.set_text(text+comment_text+info)
class DescriptionBox(mywid.HyperText):
def __init__(self, app, description):
self.app = app
super(DescriptionBox, self).__init__(description)
def set_text(self, text):
text = [text]
for commentlink in self.app.config.commentlinks:
text = commentlink.run(self.app, text)
super(DescriptionBox, self).set_text(text)
@mouse_scroll_decorator.ScrollByWheel
class StoryView(urwid.WidgetWrap):
def getCommands(self):
return [
(keymap.TOGGLE_HIDDEN,
"Toggle the hidden flag for the current story"),
(keymap.NEXT_STORY,
"Go to the next story in the list"),
(keymap.PREV_STORY,
"Go to the previous story in the list"),
(keymap.LEAVE_COMMENT,
"Leave a comment on the story"),
(keymap.NEW_TASK,
"Add a new task to the current story"),
(keymap.TOGGLE_HELD,
"Toggle the held flag for the current story"),
(keymap.TOGGLE_HIDDEN_COMMENTS,
"Toggle display of hidden comments"),
(keymap.SEARCH_RESULTS,
"Back to the list of stories"),
(keymap.TOGGLE_STARRED,
"Toggle the starred flag for the current story"),
(keymap.EDIT_DESCRIPTION,
"Edit the commit message of this story"),
(keymap.REFRESH,
"Refresh this story"),
(keymap.EDIT_TITLE,
"Edit the title of this story"),
]
def help(self):
key = self.app.config.keymap.formatKeys
commands = self.getCommands()
ret = [(c[0], key(c[0]), c[1]) for c in commands]
for k in self.app.config.reviewkeys.values():
action = ', '.join(['{category}:{value}'.format(**a) for a in k['approvals']])
ret.append(('', keymap.formatKey(k['key']), action))
return ret
def __init__(self, app, story_key):
super(StoryView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('boartty.view.story')
self.app = app
self.story_key = story_key
self.task_rows = {}
self.event_rows = {}
self.hide_events = True
self.marked_seen = False
self.title_label = urwid.Text(u'', wrap='clip')
self.creator_label = mywid.TextButton(u'', on_press=self.searchCreator)
self.tags_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')
self.permalink_label = mywid.TextButton(u'', on_press=self.openPermalink)
story_info = []
story_info_map={'story-data': 'focused-story-data'}
for l, v in [("Title", self.title_label),
("Creator", urwid.Padding(urwid.AttrMap(self.creator_label, None,
focus_map=story_info_map),
width='pack')),
("Tags", urwid.Padding(urwid.AttrMap(self.tags_label, None,
focus_map=story_info_map),
width='pack')),
("Created", self.created_label),
("Updated", self.updated_label),
("Status", self.status_label),
("Permalink", urwid.Padding(urwid.AttrMap(self.permalink_label, None,
focus_map=story_info_map),
width='pack')),
]:
row = urwid.Columns([(12, urwid.Text(('story-header', l), wrap='clip')), v])
story_info.append(row)
story_info = urwid.Pile(story_info)
self.description = DescriptionBox(app, u'')
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((self.listbox, ('weight', 1)))
self._w.set_focus(2)
self.listbox.body.append(story_info)
self.listbox.body.append(urwid.Divider())
self.listbox_tasks_start = len(self.listbox.body)
self.listbox.body.append(urwid.Divider())
self.listbox.body.append(self.description)
self.listbox.body.append(urwid.Divider())
self.refresh()
self.listbox.set_focus(3)
def interested(self, event):
if not ((isinstance(event, sync.StoryAddedEvent) and
self.story_key == event.story_key)
or
(isinstance(event, sync.StoryUpdatedEvent) and
self.story_key == event.story_key)):
self.log.debug("Ignoring refresh story due to event %s" % (event,))
return False
self.log.debug("Refreshing story due to event %s" % (event,))
return True
def refresh(self):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
# When we first open the story, update its last_seen
# time.
if not self.marked_seen:
story.last_seen = datetime.datetime.utcnow()
self.marked_seen = True
hidden = starred = held = ''
# storyboard
#if story.hidden:
# hidden = ' (hidden)'
#if story.starred:
# starred = '* '
#if story.held:
# held = ' (held)'
self.title = '%sStory %s%s%s' % (starred, story.id,
hidden, held)
self.app.status.update(title=self.title)
self.story_rest_id = story.id
self.story_title = story.title
if story.creator:
self.creator_email = story.creator.email
else:
self.creator_email = None
if self.creator_email:
creator_string = '%s <%s>' % (story.creator_name,
story.creator.email)
else:
creator_string = story.creator_name
self.creator_label.text.set_text(('story-data', creator_string))
tags_string = ' '.join([t.name for t in story.tags])
self.tags_label.set_text(('story-data', tags_string))
self.title_label.set_text(('story-data', story.title))
self.created_label.set_text(('story-data', str(self.app.time(story.created))))
self.updated_label.set_text(('story-data', str(self.app.time(story.updated))))
self.status_label.set_text(('story-data', story.status))
self.permalink_url = '' # storyboard urlparse.urljoin(self.app.config.url, str(story.number))
self.permalink_label.text.set_text(('story-data', self.permalink_url))
self.description.set_text(story.description)
# The listbox has both tasks and events in it, so
# keep track of the index separate from the loop.
listbox_index = self.listbox_tasks_start
# The set of task keys currently displayed
unseen_keys = set(self.task_rows.keys())
for task in story.tasks:
if task.pending_delete:
continue
row = self.task_rows.get(task.key)
if not row:
row = TaskRow(self.app, self, task)
self.listbox.body.insert(listbox_index, row)
self.task_rows[task.key] = row
else:
unseen_keys.remove(task.key)
row.refresh(task)
listbox_index += 1
# Remove any events that should not be displayed
for key in unseen_keys:
row = self.task_rows.get(key)
self.listbox.body.remove(row)
del self.task_rows[key]
listbox_index -= 1
listbox_index = len(self.listbox.body)
# Get the set of events that should be displayed
display_events = []
for event in story.events:
if event.comment or (not self.hide_events):
display_events.append(event)
# The set of event keys currently displayed
unseen_keys = set(self.event_rows.keys())
# Make sure all of the events that should be displayed are
for event in display_events:
row = self.event_rows.get(event.key)
if not row:
box = StoryEventBox(self, event)
row = urwid.Padding(box, width=80)
self.listbox.body.insert(listbox_index, row)
self.event_rows[event.key] = row
else:
unseen_keys.remove(event.key)
row.original_widget.refresh(event)
listbox_index += 1
# Remove any events that should not be displayed
for key in unseen_keys:
row = self.event_rows.get(key)
self.listbox.body.remove(row)
del self.event_rows[key]
listbox_index -= 1
def toggleHidden(self):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
story.hidden = not story.hidden
self.app.project_cache.clear(story.project)
def toggleStarred(self):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
story.starred = not story.starred
story.pending_starred = True
self.app.sync.submitTask(
sync.StoryStarredTask(self.story_key, sync.HIGH_PRIORITY))
def toggleHeld(self):
return self.app.toggleHeldStory(self.story_key)
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(StoryView, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.TOGGLE_HIDDEN in commands:
self.toggleHidden()
self.refresh()
return None
if keymap.TOGGLE_STARRED in commands:
self.toggleStarred()
self.refresh()
return None
if keymap.TOGGLE_HELD in commands:
self.toggleHeld()
self.refresh()
return None
if keymap.LEAVE_COMMENT in commands:
self.leaveComment()
return None
if keymap.NEW_TASK in commands:
self.newTask()
return None
if keymap.SEARCH_RESULTS in commands:
widget = self.app.findStoryList()
if widget:
self.app.backScreen(widget)
return None
if ((keymap.NEXT_STORY in commands) or
(keymap.PREV_STORY in commands)):
widget = self.app.findStoryList()
if widget:
if keymap.NEXT_STORY in commands:
new_story_key = widget.getNextStoryKey(self.story_key)
else:
new_story_key = widget.getPrevStoryKey(self.story_key)
if new_story_key:
try:
view = StoryView(self.app, new_story_key)
self.app.changeScreen(view, push=False)
except boartty.view.DisplayError as e:
self.app.error(e.message)
return None
if keymap.TOGGLE_HIDDEN_COMMENTS in commands:
self.hide_events = not self.hide_events
self.refresh()
return None
if keymap.EDIT_DESCRIPTION in commands:
self.editDescription()
return None
if keymap.REFRESH in commands:
self.app.sync.submitTask(
sync.SyncStoryTask(self.story_rest_id, priority=sync.HIGH_PRIORITY))
self.app.status.update()
return None
if keymap.EDIT_TITLE in commands:
self.editTitle()
return None
return key
def editDescription(self):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
dialog = mywid.TextEditDialog(self.app, u'Edit Description',
u'Description:',
u'Save', story.description)
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doEditDescription(dialog))
self.app.popup(dialog,
relative_width=50, relative_height=75,
min_width=60, min_height=20)
def doEditDescription(self, dialog):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
story.description = dialog.entry.edit_text
story.pending = True
self.app.sync.submitTask(
sync.UpdateStoryTask(self.story_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def leaveComment(self, parent=None, reply_text=None):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
event = story.getDraftCommentEvent(parent)
if event:
text = event.comment.content
else:
text = u''
if reply_text:
text += reply_text
dialog = mywid.TextEditDialog(self.app, u'Leave Comment', u'Comment:',
u'Save', text)
urwid.connect_signal(dialog, 'cancel', lambda button:
self.cancelLeaveComment(dialog, parent))
urwid.connect_signal(dialog, 'save', lambda button:
self.saveLeaveComment(dialog, parent))
self.app.popup(dialog,
relative_width=50, relative_height=75,
min_width=60, min_height=20)
def cancelLeaveComment(self, dialog, parent):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
user = session.getUser(self.app.user_id)
story.setDraftComment(user, parent, dialog.entry.edit_text)
self.app.backScreen()
self.refresh()
def saveLeaveComment(self, dialog, parent):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
user = session.getUser(self.app.user_id)
event = story.setDraftComment(user, parent, dialog.entry.edit_text)
event.comment.pending = True
self.app.sync.submitTask(
sync.AddCommentTask(event.key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def newTask(self):
dialog = NewTaskDialog(self.app)
urwid.connect_signal(dialog, 'save',
lambda button: self.saveNewTask(dialog))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.cancelNewTask(dialog))
self.app.popup(dialog,
relative_width=50, relative_height=25,
min_width=60, min_height=8)
def cancelNewTask(self, dialog):
self.app.backScreen()
def saveNewTask(self, dialog):
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
task = story.addTask()
task.project = session.getProjectByID(dialog.project_button.key)
task.title = dialog.title_field.edit_text
task.status = dialog.status_button.key
if dialog.assignee_button.key:
task.assignee = session.getUserByID(dialog.assignee_button.key)
task.pending = True
self.app.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def editTitle(self):
dialog = mywid.LineEditDialog(self.app, 'Edit Story Title', '',
'Title: ', self.story_title,
ring=self.app.ring)
urwid.connect_signal(dialog, 'save',
lambda button: self.updateTitle(dialog, True))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.updateTitle(dialog, False))
self.app.popup(dialog)
def updateTitle(self, dialog, save):
if save:
with self.app.db.getSession() as session:
story = session.getStory(self.story_key)
story.title = dialog.entry.edit_text
self.app.sync.submitTask(
sync.UpdateStoryTask(story.key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def openPermalink(self, widget):
self.app.openURL(self.permalink_url)
def searchCreator(self, widget):
if self.creator_email:
self.app.doSearch("status:open creator:%s" % (self.creator_email,))
def searchTags(self, widget):
#storyboard
if self.topic:
self.app.doSearch("status:open topic:%s" % (self.topic,))

513
boartty/view/story_list.py Normal file
View File

@ -0,0 +1,513 @@
# Copyright 2014 OpenStack Foundation
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# 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 logging
import six
import urwid
from boartty import keymap
from boartty import mywid
from boartty import sync
from boartty.view import story as view_story
from boartty.view import mouse_scroll_decorator
import boartty.view
class ColumnInfo(object):
def __init__(self, name, packing, value):
self.name = name
self.packing = packing
self.value = value
self.options = (packing, value)
if packing == 'given':
self.spacing = value + 1
else:
self.spacing = (value * 8) + 1
COLUMNS = [
ColumnInfo('ID', 'given', 8),
ColumnInfo('Title', 'weight', 4),
ColumnInfo('Status', 'weight', 1),
ColumnInfo('Creator', 'weight', 1),
ColumnInfo('Updated', 'given', 10),
]
class StoryListColumns(object):
def updateColumns(self):
del self.columns.contents[:]
cols = self.columns.contents
options = self.columns.options
for colinfo in COLUMNS:
if colinfo.name in self.enabled_columns:
attr = colinfo.name.lower().replace(' ', '_')
cols.append((getattr(self, attr),
options(*colinfo.options)))
class StoryRow(urwid.Button, StoryListColumns):
story_focus_map = {None: 'focused',
'active-story': 'focused-active-story',
'inactive-story': 'focused-inactive-story',
'starred-story': 'focused-starred-story',
'held-story': 'focused-held-story',
'marked-story': 'focused-marked-story',
}
def selectable(self):
return True
def __init__(self, app, story,
enabled_columns, callback=None):
super(StoryRow, self).__init__('', on_press=callback, user_data=story.key)
self.app = app
self.story_key = story.key
self.enabled_columns = enabled_columns
self.title = mywid.SearchableText(u'', wrap='clip')
self.id = mywid.SearchableText(u'')
self.updated = mywid.SearchableText(u'')
self.status = mywid.SearchableText(u'')
self.creator = mywid.SearchableText(u'', wrap='clip')
self.mark = False
self.columns = urwid.Columns([], dividechars=1)
self.row_style = urwid.AttrMap(self.columns, '')
self._w = urwid.AttrMap(self.row_style, None, focus_map=self.story_focus_map)
self.update(story)
def search(self, search, attribute):
if self.title.search(search, attribute):
return True
if self.id.search(search, attribute):
return True
if self.creator.search(search, attribute):
return True
if self.status.search(search, attribute):
return True
if self.updated.search(search, attribute):
return True
return False
def update(self, story):
if story.status != 'active' or story.hidden:
style = 'inactive-story'
else:
style = 'active-story'
title = story.title
flag = ' '
#if story.starred:
# flag = '*'
# style = 'starred-story'
#if story.held:
# flag = '!'
# style = 'held-story'
if self.mark:
flag = '%'
style = 'marked-story'
title = flag + title
self.row_style.set_attr_map({None: style})
self.title.set_text(title)
self.id.set_text(str(story.id))
self.creator.set_text(story.creator_name)
self.status.set_text(story.status)
today = self.app.time(datetime.datetime.utcnow()).date()
updated_time = self.app.time(story.updated)
if updated_time:
if today == updated_time.date():
self.updated.set_text(updated_time.strftime("%I:%M %p").upper())
else:
self.updated.set_text(updated_time.strftime("%Y-%m-%d"))
else:
self.updated.set_text('Unknown')
self.updateColumns()
class StoryListHeader(urwid.WidgetWrap, StoryListColumns):
def __init__(self, enabled_columns):
self.enabled_columns = enabled_columns
self.title = urwid.Text(u'Title', wrap='clip')
self.id = urwid.Text(u'ID')
self.updated = urwid.Text(u'Updated')
self.status = urwid.Text(u'Status')
self.creator = urwid.Text(u'Creator', wrap='clip')
self.columns = urwid.Columns([], dividechars=1)
super(StoryListHeader, self).__init__(self.columns)
def update(self):
self.updateColumns()
@mouse_scroll_decorator.ScrollByWheel
class StoryListView(urwid.WidgetWrap, mywid.Searchable):
required_columns = set(['ID', 'Title', 'Updated'])
optional_columns = set([])
def getCommands(self):
if self.project_key:
refresh_help = "Sync current project"
else:
refresh_help = "Sync subscribed projects"
return [
(keymap.TOGGLE_HELD,
"Toggle the held flag for the currently selected story"),
(keymap.TOGGLE_HIDDEN,
"Toggle the hidden flag for the currently selected story"),
(keymap.TOGGLE_LIST_ACTIVE,
"Toggle whether only active or all changes are displayed"),
(keymap.TOGGLE_STARRED,
"Toggle the starred flag for the currently selected story"),
(keymap.TOGGLE_MARK,
"Toggle the process mark for the currently selected story"),
(keymap.REFINE_STORY_SEARCH,
"Refine the current search query"),
(keymap.REFRESH,
refresh_help),
(keymap.SORT_BY_NUMBER,
"Sort stories by number"),
(keymap.SORT_BY_UPDATED,
"Sort stories by how recently the story was updated"),
(keymap.SORT_BY_REVERSE,
"Reverse the sort"),
(keymap.INTERACTIVE_SEARCH,
"Interactive search"),
]
def help(self):
key = self.app.config.keymap.formatKeys
commands = self.getCommands()
return [(c[0], key(c[0]), c[1]) for c in commands]
def __init__(self, app, query, query_desc=None, project_key=None,
active=False, sort_by=None, reverse=None):
super(StoryListView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('boartty.view.story_list')
self.searchInit()
self.app = app
self.query = query
self.query_desc = query_desc or query
self.active = active
self.story_rows = {}
self.enabled_columns = set()
for colinfo in COLUMNS:
if (colinfo.name in self.required_columns or
colinfo.name not in self.optional_columns):
self.enabled_columns.add(colinfo.name)
self.disabled_columns = set()
self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
self.project_key = project_key
if 'Project' not in self.required_columns and project_key is not None:
self.enabled_columns.discard('Project')
self.disabled_columns.add('Project')
#storyboard: creator
if 'Owner' not in self.required_columns and 'owner:' in query:
# This could be or'd with something else, but probably
# not.
self.enabled_columns.discard('Owner')
self.disabled_columns.add('Owner')
self.sort_by = sort_by or app.config.story_list_options['sort-by']
if reverse is not None:
self.reverse = reverse
else:
self.reverse = app.config.story_list_options['reverse']
self.header = StoryListHeader(self.enabled_columns)
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 interested(self, event):
if not ((self.project_key is not None and
isinstance(event, sync.StoryAddedEvent) and
self.project_key in event.related_project_keys)
or
(self.project_key is None and
isinstance(event, sync.StoryAddedEvent))
or
(isinstance(event, sync.StoryUpdatedEvent) and
event.story_key in self.story_rows.keys())):
self.log.debug("Ignoring refresh story list due to event %s" % (event,))
return False
self.log.debug("Refreshing story list due to event %s" % (event,))
return True
def refresh(self):
unseen_keys = set(self.story_rows.keys())
with self.app.db.getSession() as session:
story_list = session.getStories(self.query, self.active,
sort_by=self.sort_by)
if self.active:
self.title = (u'Active %d stories in %s' %
(len(story_list), self.query_desc))
else:
self.title = (u'All %d stories in %s' %
(len(story_list), self.query_desc))
self.short_title = self.query_desc
if '/' in self.short_title and ' ' not in self.short_title:
i = self.short_title.rfind('/')
self.short_title = self.short_title[i+1:]
self.app.status.update(title=self.title)
if 'Status' not in self.required_columns and self.active:
self.enabled_columns.discard('Status')
self.disabled_columns.add('Status')
else:
self.enabled_columns.add('Status')
self.disabled_columns.discard('Status')
self.chooseColumns()
self.header.update()
i = 0
if self.reverse:
story_list.reverse()
new_rows = []
if len(self.listbox.body):
focus_pos = self.listbox.focus_position
focus_row = self.listbox.body[focus_pos]
else:
focus_pos = 0
focus_row = None
for story in story_list:
row = self.story_rows.get(story.key)
if not row:
row = StoryRow(self.app, story,
self.enabled_columns,
callback=self.onSelect)
self.listbox.body.insert(i, row)
self.story_rows[story.key] = row
else:
row.update(story)
unseen_keys.remove(story.key)
new_rows.append(row)
i += 1
self.listbox.body[:] = new_rows
if focus_row in self.listbox.body:
pos = self.listbox.body.index(focus_row)
else:
pos = min(focus_pos, len(self.listbox.body)-1)
self.listbox.body.set_focus(pos)
for key in unseen_keys:
row = self.story_rows[key]
del self.story_rows[key]
def chooseColumns(self):
currently_enabled_columns = self.enabled_columns.copy()
size = self.app.loop.screen.get_cols_rows()
cols = size[0]
for colinfo in COLUMNS:
if (colinfo.name not in self.disabled_columns):
cols -= colinfo.spacing
for colinfo in COLUMNS:
if colinfo.name in self.optional_columns:
if cols >= colinfo.spacing:
self.enabled_columns.add(colinfo.name)
cols -= colinfo.spacing
else:
self.enabled_columns.discard(colinfo.name)
if currently_enabled_columns != self.enabled_columns:
self.header.updateColumns()
for key, value in six.iteritems(self.story_rows):
value.updateColumns()
def getQueryString(self):
if self.project_key is not None:
return "project:%s %s" % (self.query_desc, self.app.config.project_story_list_query)
return self.app.config.project_story_list_query
return self.query
def clearStoryList(self):
for key, value in six.iteritems(self.story_rows):
self.listbox.body.remove(value)
self.story_rows = {}
def getNextStoryKey(self, story_key):
row = self.story_rows.get(story_key)
try:
i = self.listbox.body.index(row)
except ValueError:
return None
if i+1 >= len(self.listbox.body):
return None
row = self.listbox.body[i+1]
return row.story_key
def getPrevStoryKey(self, story_key):
row = self.story_rows.get(story_key)
try:
i = self.listbox.body.index(row)
except ValueError:
return None
if i <= 0:
return None
row = self.listbox.body[i-1]
return row.story_key
def toggleStarred(self, story_key):
with self.app.db.getSession() as session:
story = session.getStory(story_key)
story.starred = not story.starred
ret = story.starred
story.pending_starred = True
self.app.sync.submitTask(
sync.StoryStarredTask(story_key, sync.HIGH_PRIORITY))
return ret
def toggleHeld(self, story_key):
return self.app.toggleHeldStory(story_key)
def toggleHidden(self, story_key):
with self.app.db.getSession() as session:
story = session.getStory(story_key)
story.hidden = not story.hidden
ret = story.hidden
hidden_str = 'hidden' if story.hidden else 'visible'
self.log.debug("Set story %s to %s", story_key, hidden_str)
return ret
def advance(self):
pos = self.listbox.focus_position
if pos < len(self.listbox.body)-1:
pos += 1
self.listbox.focus_position = pos
def keypress(self, size, key):
if self.searchKeypress(size, key):
return None
if not self.app.input_buffer:
key = super(StoryListView, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
ret = self.handleCommands(commands)
if ret is True:
if keymap.FURTHER_INPUT not in commands:
self.app.clearInputBuffer()
return None
return key
def onResize(self):
self.chooseColumns()
def handleCommands(self, commands):
if keymap.TOGGLE_LIST_ACTIVE in commands:
self.active = not self.active
self.refresh()
return True
if keymap.TOGGLE_HIDDEN in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
hidden = self.toggleHidden(story_key)
if hidden:
# Here we can avoid a full refresh by just removing the particular
# row from the story list
row = self.story_rows[story_key]
self.listbox.body.remove(row)
del self.story_rows[story_key]
else:
# Just fall back on doing a full refresh if we're in a situation
# where we're not just popping a row from the list of stories.
self.refresh()
self.advance()
return True
if keymap.TOGGLE_HELD in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
self.toggleHeld(story_key)
row = self.story_rows[story_key]
with self.app.db.getSession() as session:
story = session.getStory(story_key)
row.update(story)
self.advance()
return True
if keymap.TOGGLE_STARRED in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
self.toggleStarred(story_key)
row = self.story_rows[story_key]
with self.app.db.getSession() as session:
story = session.getStory(story_key)
row.update(story)
self.advance()
return True
if keymap.TOGGLE_MARK in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
story_key = self.listbox.body[pos].story_key
row = self.story_rows[story_key]
row.mark = not row.mark
with self.app.db.getSession() as session:
story = session.getStory(story_key)
row.update(story)
self.advance()
return True
if keymap.REFRESH in commands:
if self.project_key:
self.app.sync.submitTask(
sync.SyncProjectTask(self.project_key, sync.HIGH_PRIORITY))
else:
self.app.sync.submitTask(
sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY))
self.app.status.update()
return True
if keymap.SORT_BY_NUMBER in commands:
if not len(self.listbox.body):
return True
self.sort_by = 'number'
self.clearStoryList()
self.refresh()
return True
if keymap.SORT_BY_UPDATED in commands:
if not len(self.listbox.body):
return True
self.sort_by = 'updated'
self.clearStoryList()
self.refresh()
return True
if keymap.SORT_BY_REVERSE in commands:
if not len(self.listbox.body):
return True
if self.reverse:
self.reverse = False
else:
self.reverse = True
self.clearStoryList()
self.refresh()
return True
if keymap.REFINE_STORY_SEARCH in commands:
default = self.getQueryString()
self.app.searchDialog(default)
return True
if keymap.INTERACTIVE_SEARCH in commands:
self.searchStart()
return True
return False
def onSelect(self, button, story_key):
try:
view = view_story.StoryView(self.app, story_key)
self.app.changeScreen(view)
except boartty.view.DisplayError as e:
self.app.error(str(e))

View File

@ -85,17 +85,17 @@ qthelp:
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Gertty.qhcp"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Boartty.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Gertty.qhc"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Boartty.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/Gertty"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Gertty"
@echo "# mkdir -p $$HOME/.local/share/devhelp/Boartty"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Boartty"
@echo "# devhelp"
epub:

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
# Gertty documentation build configuration file, created by
# Boartty documentation build configuration file, created by
# sphinx-quickstart on Fri Jan 15 13:41:54 2016.
#
# This file is execfile()d with the current directory set to its
@ -45,17 +45,17 @@ source_suffix = '.rst'
master_doc = 'index'
# General information about the project.
project = u'Gertty'
copyright = u'%s, Gertty Contributors' % datetime.date.today().year
project = u'Boartty'
copyright = u'%s, Boartty Contributors' % datetime.date.today().year
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
# The full version, including alpha/beta/rc tags.
from gertty.version import version_info as gertty_version
release = gertty_version.version_string_with_vcs()
from boartty.version import version_info as boartty_version
release = boartty_version.version_string_with_vcs()
# The short X.Y version.
version = gertty_version.canonical_version_string()
version = boartty_version.canonical_version_string()
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -178,7 +178,7 @@ html_static_path = ['_static']
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'Gerttydoc'
htmlhelp_basename = 'Boarttydoc'
# -- Options for LaTeX output ---------------------------------------------
@ -198,7 +198,7 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'Gertty.tex', u'Gertty Documentation',
('index', 'Boartty.tex', u'Boartty Documentation',
u'James E. Blair', 'manual'),
]
@ -228,7 +228,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'gertty', u'Gertty Documentation',
('index', 'boartty', u'Boartty Documentation',
[u'James E. Blair'], 1)
]
@ -242,8 +242,8 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'Gertty', u'Gertty Documentation',
u'James E. Blair', 'Gertty', 'One line description of project.',
('index', 'Boartty', u'Boartty Documentation',
u'James E. Blair', 'Boartty', 'One line description of project.',
'Miscellaneous'),
]

View File

@ -1,44 +1,36 @@
Configuration
-------------
Gertty uses a YAML based configuration file that it looks for at
``~/.gertty.yaml``. Several sample configuration files are included.
Boartty uses a YAML based configuration file that it looks for at
``~/.boartty.yaml``. Several sample configuration files are included.
You can find them in the examples/ directory of the
`source distribution <https://git.openstack.org/cgit/openstack/gertty/tree/examples>`_
or the share/gertty/examples directory after installation.
`source distribution <https://git.openstack.org/cgit/openstack/boartty/tree/examples>`_
or the share/boartty/examples directory after installation.
Select one of the sample config files, copy it to ~/.gertty.yaml and
Select one of the sample config files, copy it to ~/.boartty.yaml and
edit as necessary. Search for ``CHANGEME`` to find parameters that
need to be supplied. The sample config files are as follows:
**minimal-gertty.yaml**
Only contains the parameters required for Gertty to actually run.
**minimal-boartty.yaml**
Only contains the parameters required for Boartty to actually run.
**reference-gertty.yaml**
**reference-boartty.yaml**
An exhaustive list of all supported options with examples.
**openstack-gertty.yaml**
**openstack-boartty.yaml**
A configuration designed for use with OpenStack's installation of
Gerrit.
**googlesource-gertty.yaml**
A configuration designed for use with installations of Gerrit
running on googlesource.com.
You will need a Storyboard authentication token which you can generate
or retrieve by navigating to ``Profile``, then ``Tokens`` (the "key"
icon), or visiting the `/#!/profile/tokens` URI in your Storyboard
installation. Issue a new token if you have not done so before, and
give it a sufficiently long lifetime (for example, one decade). Copy
and paste the resulting token in your ``~/.boartty.yaml`` file.
You will need your Gerrit password which you can generate or retrieve
by navigating to ``Settings``, then ``HTTP Password``.
Gertty uses local git repositories to perform much of its work. These
can be the same git repositories that you use when developing a
project. Gertty will not alter the working directory or index unless
you request it to (and even then, the usual git safeguards against
accidentally losing work remain in place). You will need to supply
the name of a directory where Gertty will find or clone git
repositories for your projects as the ``git-root`` parameter.
The config file is designed to support multiple Gerrit instances. The
first one is used by default, but others can be specified by supplying
the name on the command line.
The config file is designed to support multiple Storyboard instances.
The first one is used by default, but others can be specified by
supplying the name on the command line.
Configuration Reference
~~~~~~~~~~~~~~~~~~~~~~~
@ -49,8 +41,8 @@ configuration file.
Servers
+++++++
This section lists the servers that Gertty can talk to. Multiple
servers may be listed; by default, Gertty will use the first one
This section lists the servers that Boartty can talk to. Multiple
servers may be listed; by default, Boartty will use the first one
listed. To select another, simply specify its name on the command
line.
@ -65,30 +57,17 @@ line.
**url (required)**
The URL of the Gerrit server. HTTPS should be preferred.
**username (required)**
Your username in Gerrit. [required]
**password (required)**
Your password in Gerrit. Obtain it from Settings -> HTTP Password
in the Gerrit web interface.
**auth-type**
Authentication type required by the Gerrit server. Can be 'basic',
'digest', or 'form'. Defaults to 'digest'.
**git-root (required)**
A location where Gertty should store its git repositories. These
can be the same git repositories where you do your own work --
Gertty will not modify them unless you tell it to, and even then
the normal git protections against losing work remain in place.
**token (required)**
Your authentication token from Storyboard. Obtain it as described
above in "Configuration".
**dburi**
The location of Gertty's sqlite database. If you have more than
The location of Boartty's sqlite database. If you have more than
one server, you should specify a dburi for any additional servers.
By default a SQLite database called ~/.gertty.db is used.
By default a SQLite database called ~/.boartty.db is used.
**ssl-ca-path**
If your Gerrit server uses a non-standard certificate chain
If your Storyboard server uses a non-standard certificate chain
(e.g. on a test server), you can pass a full path to a bundle of
CA certificates here:
@ -98,18 +77,18 @@ line.
turn off certificate validation.
**log-file**
By default Gertty logs errors to a file and truncates that file
By default Boartty logs errors to a file and truncates that file
each time it starts (so that it does not grow without bound). If
you would like to log to a different location, you may specify it
with this option.
**socket**
Gertty listens on a unix domain socket for remote commands at
~/.gertty.sock. This option may be used to change the path.
Boartty listens on a unix domain socket for remote commands at
~/.boartty.sock. This option may be used to change the path.
**lock-file**
Gertty uses a lock file per server to prevent multiple processes
from running at the same time. The default is ~/.gertty.servername.lock
Boartty uses a lock file per server to prevent multiple processes
from running at the same time. The default is ~/.boartty.servername.lock
Example:
@ -117,14 +96,12 @@ Example:
servers:
- name: CHANGEME
url: https://CHANGEME.example.org/
username: CHANGEME
password: CHANGEME
git-root: ~/git/
token: CHANGEME
Palettes
++++++++
Gertty comes with two palettes defined internally. The default
Boartty comes with two palettes defined internally. The default
palette is suitable for use on a terminal with a dark background. The
`light` palette is for a terminal with a white or light background.
You may customize the colors in either of those palettes, or define
@ -140,7 +117,7 @@ high-color terminals.
For a reference of possible color names, see the `Urwid Manual
<http://urwid.org/manual/displayattributes.html#foreground-and-background-settings>`_
To see the list of possible palette entries, run `gertty --print-palette`.
To see the list of possible palette entries, run `boartty --print-palette`.
The following example alters two colors in the default palette, one
color in the light palette, and one color in a custom palette.
@ -148,12 +125,12 @@ color in the light palette, and one color in a custom palette.
.. code-block: yaml
palettes:
- name: default
added-line: ['dark green', '']
added-word: ['light green', '']
task-title: ['light green', '']
task-id: ['dark cyan', '']
- name: light
filename: ['dark cyan', '']
task-project: ['dark blue', '']
- name: custom
filename: ['light yellow', '']
task-project: ['dark red', '']
Palettes may be selected at runtime with the `-p PALETTE` command
line option, or you may set the default palette in the config file.
@ -170,21 +147,21 @@ may be overridden and custom keymaps defined and selected in the
config file or the command line.
Each keymap contains a mapping of command -> key(s). If a command is
not specified, Gertty will use the keybinding specified in the default
not specified, Boartty will use the keybinding specified in the default
map. More than one key can be bound to a command.
Run `gertty --print-keymap` for a list of commands that can be bound.
Run `boartty --print-keymap` for a list of commands that can be bound.
The following example modifies the `default` keymap:
.. code-block: yaml
keymaps:
- name: default
diff: 'd'
leave-comment: 'r'
- name: custom
review: ['r', 'R']
- name: osx #OS X blocks ctrl+o
change-search: 'ctrl s'
leave-comment: ['r', 'R']
- name: osx # OS X blocks ctrl+o
story-search: 'ctrl s'
To specify a sequence of keys, they must be a list of keystrokes
@ -204,9 +181,9 @@ option, or in the config file.
Commentlinks
++++++++++++
Commentlinks are regular expressions that are applied to commit and
review messages. They can be replaced with internal or external
links, or have colors applied.
Commentlinks are regular expressions that are applied to story
descriptions and comments. They can be replaced with internal or
external links, or have colors applied.
**commentlinks**
This is a list of commentlink patterns. Each commentlink pattern is
@ -247,7 +224,7 @@ links, or have colors applied.
palette entry.
**search**
A hyperlink that will perform a Gertty search when activated.
A hyperlink that will perform a Boartty search when activated.
**text**
The replacement text.
@ -255,31 +232,31 @@ links, or have colors applied.
**query**
The search query to use.
This example matches Gerrit change ids, and replaces them with a link
to an internal Gertty search for that change id.
This example matches story numbers, and replaces them with a link to
an internal Boartty search for that story.
.. code-block: yaml
commentlinks:
- match: "(?P<id>I[0-9a-fA-F]{40})"
- match: "(?P<id>[0-9]+)"
replacements:
- search:
text: "{id}"
query: "change:{id}"
query: "story:{id}"
Change List Options
+++++++++++++++++++
Story List Options
++++++++++++++++++
**change-list-query**
This is the query used for the list of changes when a project is
selected. The default is `status:open`.
**story-list-query**
This is the query used for the list of storyies when a project is
selected. The default is empty.
**change-list-options**
This section defines default sorting options for the change list.
**story-list-options**
This section defines default sorting options for the story list.
**sort-by**
This key specifies the sort order, which can be `number` (the
Change number), `updated` (when the change was last updated), or
`last-seen` (when the change was last opened in Gertty).
Story number), `updated` (when the story was last updated), or
`last-seen` (when the story was last opened in Boartty).
**reverse**
This is a boolean value which indicates whether the list should be
@ -288,45 +265,16 @@ Change List Options
Example:
.. code-block: yaml
change-list-options:
story-list-options:
sort-by: 'number'
reverse: false
**thread-changes**
Dependent changes are displayed as "threads" in the change list by
default. To disable this behavior, set this value to false.
Change View Options
+++++++++++++++++++
**hide-comments**
This is a list of descriptors which cause matching comments to be
hidden by default. Press the `t` key to toggle the display of
matching comments.
The only supported criterion is `author`.
**author**
A regular expression to match against the comment author's name.
For example, to hide comments from a CI system:
.. code-block: yaml
hide-comments:
- author: "^(.*CI|Jenkins)$"
**diff-view**
Specifies how patch diffs should be displayed. The values `unified`
or `side-by-side` (the default) are supported.
Dashboards
++++++++++
This section defines customized dashboards. You may supply any
Gertty search string and bind them to any key. They will appear in
the global help text, and pressing the key anywhere in Gertty will
Boartty search string and bind them to any key. They will appear in
the global help text, and pressing the key anywhere in Boartty will
run the query and display the results.
**dashboards**
@ -337,7 +285,7 @@ run the query and display the results.
bar at the top of the screen.
**query**
The search query to perform to gather changes to be listed in the
The search query to perform to gather stories to be listed in the
dashboard.
**key**
@ -348,60 +296,15 @@ Example:
.. code-block: yaml
dashboards:
- name: "My changes"
query: "owner:self status:open"
- name: "My stories"
query: "creator:self status:active"
key: "f2"
Reviewkeys
++++++++++
Reviewkeys are hotkeys that perform immediate reviews within the
change screen. Any pending comments or review messages will be
attached to the review; otherwise an empty review message will be
left. The approvals list is exhaustive, so if you specify an empty
list, Gertty will submit a review that clears any previous approvals.
Reviewkeys appear in the help text for the change screen.
**reviewkeys**
A list of reviewkey definitions, the format of which is described
below.
**key**
This key to which this review action should be bound.
**approvals**
A list of approvals to include when this reviewkey is activated.
Each element of the list should include both a category and a
value.
**category**
The name of the review label for this approval.
**value**
The value for this approval.
**submit**
Set this to `true` to instruct Gerrit to submit the change when
this reviewkey is activated.
The following example includes a reviewkey that clears all labels, as
well as one that leaves a +1 "Code-Review" approval.
.. code-block: yaml
reviewkeys:
- key: 'meta 0'
approvals: []
- key: 'meta 1'
approvals:
- category: 'Code-Review'
value: 1
General Options
+++++++++++++++
**breadcrumbs**
Gertty displays a footer at the bottom of the screen by default
Boartty displays a footer at the bottom of the screen by default
which contains navigation information in the form of "breadcrumbs"
-- short descriptions of previous screens, with the right-most entry
indicating the screen that will be displayed if you press the `ESC`
@ -412,15 +315,6 @@ General Options
them in UTC instead, set this value to `true`.
**handle-mouse**
Gertty handles mouse input by default. If you don't want it
Boartty handles mouse input by default. If you don't want it
interfering with your terminal's mouse handling, set this value to
`false`.
**expire-age**
By default, closed changes that are older than two months are
removed from the local database (and their refs are removed from the
local git repos so that git may garbage collect them). If you would
like to change the expiration delay or disable it, uncomment the
following line. The time interval is specified in the same way as
the "age:" term in Gerrit's search syntax. To disable it
altogether, set the value to the empty string.

View File

@ -1,10 +1,10 @@
Contributing
------------
For information on how to contribute to Gertty, please see the
For information on how to contribute to Boartty, please see the
contents of the CONTRIBUTING.rst file.
Bugs
~~~~
Bugs are handled at: https://storyboard.openstack.org/#!/project/698
Bugs are handled at: https://storyboard.openstack.org/

View File

@ -1,28 +1,23 @@
Gertty
======
Boartty
=======
Gertty is a console-based interface to the Gerrit Code Review system.
Boartty is a console-based interface to the Storyboard task-tracking
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.
deal with a large number of stories 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.
* Offline Use -- Boartty syncs information about changes in
subscribed projects to a local database. All review operations are
performed against that database and then synced back to Storyboard.
* 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.
Contents:
.. toctree::

View File

@ -1,52 +1,18 @@
Installation
------------
Debian
~~~~~~
Gertty is packaged in Debian and is currently available in:
* unstable
* testing
* stable
You can install it with::
apt-get install gertty
Fedora
~~~~~~
Gertty is packaged starting in Fedora 21. You can install it with::
yum install python-gertty
openSUSE
~~~~~~~~
Gertty is packaged for openSUSE 13.1 onwards. You can install it via
`1-click install from the Open Build Service <http://software.opensuse.org/package/python-gertty>`_.
Arch Linux
~~~~~~~~~~
Gertty packages are available in the Arch User Repository packages. You
can get the package from::
https://aur.archlinux.org/packages/python2-gertty/
Source
~~~~~~
When installing from source, it is recommended (but not required) to
install Gertty in a virtualenv. To set one up::
install Boartty in a virtualenv. To set one up::
virtualenv gertty-env
source gertty-env/bin/activate
virtualenv boartty-env
source boartty-env/bin/activate
To install the latest version from the cheeseshop::
pip install gertty
pip install boartty
To install from a git checkout::

View File

@ -1,13 +1,13 @@
Usage
-----
After installing Gertty, you should be able to run it by invoking
``gertty``. If you installed it in a virtualenv, you can invoke it
without activating the virtualenv with ``/path/to/venv/bin/gertty``
which you may wish to add to your shell aliases. Use ``gertty
After installing Boartty, you should be able to run it by invoking
``boartty``. If you installed it in a virtualenv, you can invoke it
without activating the virtualenv with ``/path/to/venv/bin/boartty``
which you may wish to add to your shell aliases. Use ``boartty
--help`` to see a list of command line options available.
Once Gertty is running, you will need to start by subscribing to some
Once Boartty is running, you will need to start by subscribing to some
projects. Use 'L' to list all of the projects and then 's' to
subscribe to the ones you are interested in. Hit 'L' again to shrink
the list to your subscribed projects.
@ -15,37 +15,27 @@ the list to your subscribed projects.
In general, pressing the F1 key will show help text on any screen, and
ESC will take you to the previous screen.
Gertty works seamlessly offline or online. All of the actions that it
performs are first recorded in a local database (in ``~/.gertty.db``
by default), and are then transmitted to Gerrit. If Gertty is unable
to contact Gerrit for any reason, it will continue to operate against
the local database, and once it re-establishes contact, it will
process any pending changes.
Boartty works seamlessly offline or online. All of the actions that
it performs are first recorded in a local database (in
``~/.boartty.db`` by default), and are then transmitted to Storyboard.
If Boartty is unable to contact Storyboard for any reason, it will
continue to operate against the local database, and once it
re-establishes contact, it will process any pending changes.
The status bar at the top of the screen displays the current number of
outstanding tasks that Gertty must perform in order to be fully up to
outstanding tasks that Boartty must perform in order to be fully up to
date. Some of these tasks are more complicated than others, and some
of them will end up creating new tasks (for instance, one task may be
to search for new changes in a project which will then produce 5 new
tasks if there are 5 new changes).
to search for new stories in a project which will then produce 5 new
tasks if there are 5 new stories).
If Gertty is offline, it will so indicate in the status bar. It will
If Boartty is offline, it will so indicate in the status bar. It will
retry requests if needed, and will switch between offline and online
mode automatically.
If you review a change while offline with a positive vote, and someone
else leaves a negative vote on that change in the same category before
Gertty is able to upload your review, Gertty will detect the situation
and mark the change as "held" so that you may re-inspect the change
and any new comments before uploading the review. The status bar will
alert you to any held changes and direct you to a list of them (the
`F12` key by default). When viewing a change, the "held" flag may be
toggled with the exclamation key (`!`). Once held, a change must be
explicitly un-held in this manner for your review to be uploaded.
If Gertty encounters an error, this will also be indicated in the
status bar. You may wish to examine ~/.gertty.log to see what the
error was. In many cases, Gertty can continue after encountering an
If Boartty encounters an error, this will also be indicated in the
status bar. You may wish to examine ~/.boartty.log to see what the
error was. In many cases, Boartty can continue after encountering an
error. The error flag will be cleared when you leave the current
screen.
@ -56,17 +46,17 @@ Terminal Integration
~~~~~~~~~~~~~~~~~~~~
If you use rxvt-unicode, you can add something like the following to
``.Xresources`` to make Gerrit URLs that are displayed in your
``.Xresources`` to make Storyboard URLs that are displayed in your
terminal (perhaps in an email or irc client) clickable links that open
in Gertty::
in Boartty::
URxvt.perl-ext: default,matcher
URxvt.url-launcher: sensible-browser
URxvt.keysym.C-Delete: perl:matcher:last
URxvt.keysym.M-Delete: perl:matcher:list
URxvt.matcher.button: 1
URxvt.matcher.pattern.1: https:\/\/review.example.org/(\\#\/c\/)?(\\d+)[\w]*
URxvt.matcher.launcher.1: gertty --open $0
URxvt.matcher.pattern.1: https:\/\/storyboard.example.org/#!/story/(\\d+)[\w]*
URxvt.matcher.launcher.1: boartty --open $0
You will want to adjust the pattern to match the review site you are
You will want to adjust the pattern to match the Storyboard site you are
interested in; multiple patterns may be added as needed.

View File

@ -1,88 +0,0 @@
# This is an example ~/.gertty.yaml file for use with installations of
# Gerrit running on googlesource.com. Most of these options are not
# required, rather, they customize Gertty to better deal with the
# particulars of Google's Gerrit configuration.
# This file does not list all of the available options. For a full
# list with explanations, see the 'reference-gertty.yaml' file.
servers:
- name: CHANGEME-review
url: https://CHANGEME-review.googlesource.com/
# Your gerrit username.
username: CHANGEME
# Set password at https://{name}-review.googlesource.com/#/settings/http-password
# Note this is not your Google password.
password: CHANGEME
auth-type: basic
git-root: ~/git/
# Uncomment the next line if your terminal has a white background
# palette: light
# Commentlinks are regexes that are applied to commit and review
# messages. They can be replaced with internal or external links, or
# have colors applied.
commentlinks:
# Match Gerrit change ids, and replace them with a link to an
# internal Gertty search for that change id.
- match: "(?P<id>I[0-9a-fA-F]{40})"
replacements:
- search:
text: "{id}"
query: "change:{id}"
# Uncomment the following line to use a unified diff view instead
# of the default side-by-side:
# diff-view: unified
# This section defines customized dashboards. You can supply any
# Gertty search string and bind them to any key. They will appear in
# the global help text, and pressing the key anywhere in Gertty will
# discard the current display stack and replace it with the results of
# the query.
#
# NB: "recentlyseen:24 hours" does not just return changes seen in the
# last 24 hours -- it returns changes seen within 24 hours of the most
# recently seen change. So you can take the weekend off and pick up
# where you were.
dashboards:
- name: "My changes"
query: "owner:self status:open"
key: "f2"
- name: "Incoming reviews"
query: "is:open is:reviewer"
key: "f3"
- name: "Starred changes"
query: "is:starred"
key: "f4"
- name: "Recently seen changes"
query: "recentlyseen:24 hours"
sort-by: "last-seen"
reverse: True
key: "f5"
# Reviewkeys are hotkeys that perform immediate reviews within the
# change screen. Any pending comments or review messages will be
# attached to the review; otherwise an empty review will be left. The
# approvals list is exhaustive, so if you specify an empty list,
# Gertty will submit a review that clears any previous approvals. To
# submit the change with the review, include 'submit: True' with the
# reviewkey. Reviewkeys appear in the help text for the change
# screen.
reviewkeys:
- key: 'meta 0'
approvals: []
- key: 'meta 1'
approvals:
- category: 'Code-Review'
value: 1
- key: 'meta 2'
approvals:
- category: 'Code-Review'
value: 2
- key: 'meta 3'
approvals:
- category: 'Code-Review'
value: 2
submit: True

View File

@ -0,0 +1,15 @@
# This is an example ~/.boartty.yaml file with only the required
# settings.
# This file does not list all of the available options. For a full
# list with explanations, see the 'reference-boartty.yaml' file.
servers:
- name: CHANGEME
url: https://CHANGEME.example.org/
# Your authentication token for Storyboard. Go to the "Profile"
# and then "Tokens" (the "key" icon) page in the Storyboard web
# interface and create a token. Give it a sufficiently long
# validity period (e.g., one decade), and copy and paste the value
# here.
token: CHANGEME

View File

@ -1,13 +0,0 @@
# This is an example ~/.gertty.yaml file with only the required
# settings.
# This file does not list all of the available options. For a full
# list with explanations, see the 'reference-gertty.yaml' file.
servers:
- name: CHANGEME
url: https://CHANGEME.example.org/
username: CHANGEME
# Set corresponding HTTP password in gerrit settings
password: CHANGEME
git-root: ~/git/

View File

@ -0,0 +1,42 @@
# This is an example ~/.boartty.yaml file for use with OpenStack's
# Storyboard. Most of these options are not required, rather, they
# customize Boartty to better deal with the particulars of OpenStack's
# Storyboard configuration.
# This file does not list all of the available options. For a full
# list with explanations, see the 'reference-boartty.yaml' file.
servers:
- name: openstack
url: https://review.openstack.org/
# Your authentication token for Storyboard. Go to the "Profile"
# and then "Tokens" (the "key" icon) page in the Storyboard web
# interface and create a token. Give it a sufficiently long
# validity period (e.g., one decade), and copy and paste the value
# here.
token: CHANGEME
# Uncomment the next line if your terminal has a white background
# palette: light
# This section defines customized dashboards. You can supply any
# Boartty search string and bind them to any key. They will appear in
# the global help text, and pressing the key anywhere in Boartty will
# run the query and display the results.
#
# NB: "recentlyseen:24 hours" does not just return stories seen in the
# last 24 hours -- it returns stories seen within 24 hours of the most
# recently seen change. So you can take the weekend off and pick up
# where you were.
dashboards:
- name: "My stories"
query: "creator:self status:active"
key: "f2"
- name: "Starred stories"
query: "is:starred"
key: "f3"
- name: "Recently seen stories"
query: "recentlyseen:24 hours"
sort-by: "last-seen"
reverse: True
key: "f4"

View File

@ -1,142 +0,0 @@
# This is an example ~/.gertty.yaml file for use with OpenStack's
# Gerrit. Most of these options are not required, rather, they
# customize Gertty to better deal with the particulars of OpenStack's
# Gerrit configuration.
# This file does not list all of the available options. For a full
# list with explanations, see the 'reference-gertty.yaml' file.
servers:
- name: openstack
url: https://review.openstack.org/
# Your gerrit username.
username: CHANGEME
# Set password at https://review.openstack.org/#/settings/http-password
# Note this is not your launchpad password.
password: CHANGEME
git-root: ~/git/
# This section adds the colors that we will reference later in the
# commentlinks section for test results. You can also change other
# colors here.
palettes:
- name: default
test-SUCCESS: ['light green', '']
test-FAILURE: ['light red', '']
test-UNSTABLE: ['yellow', '']
# Uncomment the next line if your terminal has a white background
# palette: light
# Commentlinks are regexes that are applied to commit and review
# messages. They can be replaced with internal or external links, or
# have colors applied.
commentlinks:
# This matches the job results left by Zuul.
- match: "^- (?P<job>.*?) (?P<url>.*?) : (?P<result>[^ ]+) ?(?P<comment>.*)$"
# This indicates that this is a test result, and should be indexed
# using the "job" match group from the commentlink regex. Gertty
# displays test results in their own area of the screen.
test-result: "{job}"
replacements:
# Replace the matching text with a hyperlink to the "url" match
# group whose text is the "job" match group.
- link:
text: "{job:<42}"
url: "{url}"
# Follow that with the plain text of the "result" match group
# with the color "test-{result}" applied. See the palette
# section above.
- text:
color: "test-{result}"
text: "{result} "
# And then follow that with the plain text of the "comment"
# match group.
- text: "{comment}"
# Match Gerrit change ids, and replace them with a link to an
# internal Gertty search for that change id.
- match: "(?P<id>I[0-9a-fA-F]{40})"
replacements:
- search:
text: "{id}"
query: "change:{id}"
# Match external references to bugs on Launchpad
- match: "(?P<bug_str>(?:[Cc]loses|[Pp]artial|[Rr]elated)-[Bb]ug *: *#?(?P<bug_id>\\d+))"
replacements:
- link:
text: "{bug_str}"
url: "https://launchpad.net/bugs/{bug_id}"
# Match external references to blueprints on Launchpad
- match: "blueprint +(?P<blueprint>[\\w\\-.]+)"
replacements:
- link:
text: "blueprint {blueprint}"
url: "https://blueprints.launchpad.net/openstack/?searchtext={blueprint}"
# This is the query used for the list of changes when a project is
# selected. The default is "status:open". If you don't want to see
# changes which are WIP or have verification failures, use a query like this:
# change-list-query: "status:open not label:Workflow=-1"
# If you also want to exclude reviews with failed tests, the query is slightly
# more complex:
# "status:open not (label:Workflow=-1 or label:Verified=-1)"
# Uncomment the following line to use a unified diff view instead of the
# default side-by-side:
# diff-view: unified
# Hide comments by default that match the following criteria.
# You can toggle their display with 't'.
hide-comments:
- author: "^(.*CI|Jenkins)$"
# This section defines customized dashboards. You can supply any
# Gertty search string and bind them to any key. They will appear in
# the global help text, and pressing the key anywhere in Gertty will
# discard the current display stack and replace it with the results of
# the query.
#
# NB: "recentlyseen:24 hours" does not just return changes seen in the
# last 24 hours -- it returns changes seen within 24 hours of the most
# recently seen change. So you can take the weekend off and pick up
# where you were.
dashboards:
- name: "My changes"
query: "owner:self status:open"
key: "f2"
- name: "Incoming reviews"
query: "is:open is:reviewer"
key: "f3"
- name: "Starred changes"
query: "is:starred"
key: "f4"
- name: "Recently seen changes"
query: "recentlyseen:24 hours"
sort-by: "last-seen"
reverse: True
key: "f5"
# Reviewkeys are hotkeys that perform immediate reviews within the
# change screen. Any pending comments or review messages will be
# attached to the review; otherwise an empty review will be left. The
# approvals list is exhaustive, so if you specify an empty list,
# Gertty will submit a review that clears any previous approvals.
# They will appear in the help text for the change screen.
reviewkeys:
- key: 'meta 0'
approvals: []
- key: 'meta 1'
approvals:
- category: 'Code-Review'
value: 1
- key: 'meta 2'
approvals:
- category: 'Code-Review'
value: 2
- key: 'meta 3'
approvals:
- category: 'Code-Review'
value: 2
- category: 'Workflow'
value: 1

View File

@ -0,0 +1,187 @@
# This is an example ~/.boartty.yaml with an exhaustive listing of
# options with commentary.
# This section lists the servers that Boartty can talk to. Multiple
# servers may be listed; by default, Boartty will use the first one
# listed. To select another, simply specify its name on the command
# line.
servers:
- name: CHANGEME
url: https://CHANGEME.example.org/
# Your authentication token for Storyboard. Go to the "Profile"
# and then "Tokens" (the "key" icon) page in the Storyboard web
# interface and create a token. Give it a sufficiently long
# validity period (e.g., one decade), and copy and paste the value
# here.
token: CHANGEME
lock-file: ~/.boartty.CHANGEME.lock
# Each server section can have the following fields:
# A name that describes the server, to reference on the command line. [required]
# - name: sample
# The URL of the Storyboard server. HTTPS should be preferred. [required]
# url: https://example.org/
# Your authentication token for Storyboard. Go to the "Profile" and
# then "Tokens" (the "key" icon) page in the Storyboard web interface
# and create a token. Give it a sufficiently long validity period
# (e.g., one decade), and copy and paste the value here.
# token: CHANGEME
# The location of Boartty's sqlite database. If you have more than one
# server, you should specify a dburi for any additional servers.
# By default a SQLite database called ~/.boartty.db is used.
# dburi: sqlite:////home/user/.boartty.db
# If your Storyboard server uses a non-standard certificate chain (e.g. on a test
# server), you can pass a full path to a bundle of CA certificates here:
# ssl-ca-path: ~/.pki/ca-chain.pem
# In case you do not care about security and want to use a sledgehammer
# approach to SSL, you can set this value to false to turn off certificate
# validation.
# verify-ssl: true
# By default Boartty logs errors to a file and truncates that file each
# time it starts (so that it does not grow without bound). If you
# would like to log to a different location, you may specify it here.
# log-file: ~/.boartty.log
# Boartty listens on a unix domain socket for remote commands at
# ~/.boartty.sock. You may change the path here:
# socket: ~/.boartty.sock
# Boartty uses a lock file per server to prevent multiple processes
# from running at the same time. Example:
# lock-file: /run/lockme.lock
# Boartty comes with two palettes defined internally. The default
# palette is suitable for use on a terminal with a dark background.
# The "light" palette is for a terminal with a white or light
# background. You may customize the colors in either of those
# palettes, or define your own palette.
# The following alters two colors in the default palette, one color in
# the light palette, and one color in a custom palette. If any color
# is not defined in a palette, the value from the default palette is
# used. The values are a list of at least two elements describing the
# colors to be use for the foreground and background colors.
# Additional elements can specify (in order) the color to use for
# monochrome terminals, the foreground, and background colors to use
# in high-color terminals.
# For a reference of possible color names, see:
# http://urwid.org/manual/displayattributes.html#foreground-and-background-settings
# To see the list of possible palette entries, run "boartty --print-palette".
palettes:
- name: default
task-title: ['light green', '']
task-id: ['dark cyan', '']
- name: light
task-project: ['dark blue', '']
- name: custom
task-project: ['dark red', '']
# Palettes may be selected at runtime with the "-p PALETTE" command
# line option, or you may set the default palette here:
# palette: light
# Keymaps work the same way as palettes. Two keymaps are defined
# internally, the 'default' keymap and the 'vi' keymap. Individual
# keys may be overridden and custom keymaps defined and selected in
# the config file or the command line.
# Each keymap contains a mapping of command -> key(s). If a command
# is not specified, Boartty will use the keybinding specified in the
# default map. More than one key can be bound to a command.
# Run "boartty --print-keymap" for a list of commands that can be
# bound.
keymaps:
- name: default
leave-comment: 'r'
- name: custom
leave-comment: ['r', 'R']
- name: osx # OS X blocks ctrl+o
story-search: 'ctrl s'
# To specify a sequence of keys, they must be a list of keystrokes
# within a list of key series. For example:
- name: vi
quit: [[':', 'q']]
# The default keymap may be selected with the '-k KEYMAP' command line
# option, or with the following line:
# keymap: custom
# Commentlinks are regular expressions that are applied to commit and
# review messages. They can be replaced with internal or external
# links, or have colors applied.
commentlinks:
# This example matches story numbers, and replaces them with a link to
# an internal Boartty search for that story.
- match: "(?P<id>[0-9]+)"
replacements:
- search:
text: "{id}"
query: "story:{id}"
# Any number of commentlink entries may be specified. Start each with
# a "match" key and regex. Named match groups within the regex may be
# used in the replacements section. Any number of replacements may be
# specified. The types of replacement available are:
#
# Text: Plain text whose color may be specified. The color references
# a palette entry.
# - text:
# color: ""
# text: ""
# Link: A hyperlink with the indicated text that when activated will
# open the user's browser with the supplied URL
# - link:
# text: ""
# url: ""
# Search: A hyperlink that will perform a Boartty search when
# activated.
# - search:
# text: "{id}"
# query: "change:{id}"
# This is the query used for the list of stories when a project is
# selected. The default is empty.
# story-list-query: ""
# This section defines default sorting options for the story
# list. The "sort-by" key specifies the sort order, which can be
# 'number', 'updated', or 'last-seen'. The 'reverse' key specifies
# ascending (true) or descending (false) order.
# story-list-options:
# sort-by: 'number'
# reverse: false
# Uncomment the following line to disable the navigation breadcrumbs
# at the bottom of the screen:
# breadcrumbs: false
# Times are displayed in the local timezone by default. To display
# them in UTC instead, uncomment the following line:
# display-times-in-utc: true
# Boartty handles mouse input by default. If you don't want it messing
# with your terminal's mouse handling, uncomment the following line:
# handle-mouse: false
# This section defines customized dashboards. You can supply any
# Boartty search string and bind them to any key. They will appear in
# the global help text, and pressing the key anywhere in Boartty will
# run the query and display the results.
#
# NB: "recentlyseen:24 hours" does not just return stories seen in the
# last 24 hours -- it returns stories seen within 24 hours of the most
# recently seen change. So you can take the weekend off and pick up
# where you were.
dashboards:
- name: "My stories"
query: "creator:self status:active"
key: "f2"
- name: "Starred stories"
query: "is:starred"
key: "f3"
- name: "Recently seen stories"
query: "recentlyseen:24 hours"
sort-by: "last-seen"
reverse: True
key: "f4"

View File

@ -1,248 +0,0 @@
# This is an example ~/.gertty.yaml with an exhaustive listing of
# options with commentary.
# This section lists the servers that Gertty can talk to. Multiple
# servers may be listed; by default, Gertty will use the first one
# listed. To select another, simply specify its name on the command
# line.
servers:
- name: CHANGEME
url: https://CHANGEME.example.org/
username: CHANGEME
# Your HTTP Password for gerrit. Go to the "HTTP Password" section in your
# account settings to generate/retrieve this password.
password: CHANGEME
git-root: ~/git/
lock-file: ~/.gertty.CHANGEME.lock
# Each server section can have the following fields:
# A name that describes the server, to reference on the command line. [required]
# - name: sample
# The URL of the Gerrit server. HTTPS should be preferred. [required]
# url: https://example.org/
# Your username in Gerrit. [required]
# username: CHANGEME
# Your password in Gerrit (Settings -> HTTP Password). [required]
# password: CHANGEME
# Authentication type required by the Gerrit server. Can be 'basic',
# 'digest', or 'form'. Defaults to 'digest'.
# auth-type: digest
# A location where Gertty should store its git repositories. These
# can be the same git repositories where you do your own work --
# Gertty will not modify them unless you tell it to, and even then the
# normal git protections against losing work remain in place. [required]
# git-root: ~/git/
# The URL to clone git repos. By default, <url>/p/<project> is used. For a list
# of valid URLs, see:
# https://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS
# git-url: ssh://user@example.org:29418
# The location of Gertty's sqlite database. If you have more than one
# server, you should specify a dburi for any additional servers.
# By default a SQLite database called ~/.gertty.db is used.
# dburi: sqlite:////home/user/.gertty.db
# If your Gerrit server uses a non-standard certificate chain (e.g. on a test
# server), you can pass a full path to a bundle of CA certificates here:
# ssl-ca-path: ~/.pki/ca-chain.pem
# In case you do not care about security and want to use a sledgehammer
# approach to SSL, you can set this value to false to turn off certificate
# validation.
# verify-ssl: true
# By default Gertty logs errors to a file and truncates that file each
# time it starts (so that it does not grow without bound). If you
# would like to log to a different location, you may specify it here.
# log-file: ~/.gertty.log
# Gertty listens on a unix domain socket for remote commands at
# ~/.gertty.sock. You may change the path here:
# socket: ~/.gertty.sock
# Gertty uses a lock file per server to prevent multiple processes
# from running at the same time. Example:
# lock-file: /run/lockme.lock
# Gertty comes with two palettes defined internally. The default
# palette is suitable for use on a terminal with a dark background.
# The "light" palette is for a terminal with a white or light
# background. You may customize the colors in either of those
# palettes, or define your own palette.
# The following alters two colors in the default palette, one color in
# the light palette, and one color in a custom palette. If any color
# is not defined in a palette, the value from the default palette is
# used. The values are a list of at least two elements describing the
# colors to be use for the foreground and background colors.
# Additional elements can specify (in order) the color to use for
# monochrome terminals, the foreground, and background colors to use
# in high-color terminals.
# For a reference of possible color names, see:
# http://urwid.org/manual/displayattributes.html#foreground-and-background-settings
# To see the list of possible palette entries, run "gertty --print-palette".
palettes:
- name: default
added-line: ['dark green', '']
added-word: ['light green', '']
- name: light
filename: ['dark cyan', '']
- name: custom
filename: ['light yellow', '']
# Palettes may be selected at runtime with the "-p PALETTE" command
# line option, or you may set the default palette here:
# palette: light
# Keymaps work the same way as palettes. Two keymaps are defined
# internally, the 'default' keymap and the 'vi' keymap. Individual
# keys may be overridden and custom keymaps defined and selected in
# the config file or the command line.
# Each keymap contains a mapping of command -> key(s). If a command
# is not specified, Gertty will use the keybinding specified in the
# default map. More than one key can be bound to a command.
# Run "gertty --print-keymap" for a list of commands that can be
# bound.
keymaps:
- name: default
diff: 'd'
- name: custom
review: ['r', 'R']
- name: osx # OS X blocks ctrl+o
change-search: 'ctrl s'
# To specify a sequence of keys, they must be a list of keystrokes
# within a list of key series. For example:
- name: vi
quit: [[':', 'q']]
# The default keymap may be selected with the '-k KEYMAP' command line
# option, or with the following line:
# keymap: custom
# Commentlinks are regular expressions that are applied to commit and
# review messages. They can be replaced with internal or external
# links, or have colors applied.
commentlinks:
# Match Gerrit change ids, and replace them with a link to an internal
# Gertty search for that change id.
- match: "(?P<id>I[0-9a-fA-F]{40})"
replacements:
- search:
text: "{id}"
query: "change:{id}"
# Any number of commentlink entries may be specified. Start each with
# a "match" key and regex. Named match groups within the regex may be
# used in the replacements section. Any number of replacements may be
# specified. The types of replacement available are:
#
# Text: Plain text whose color may be specified. The color references
# a palette entry.
# - text:
# color: ""
# text: ""
# Link: A hyperlink with the indicated text that when activated will
# open the user's browser with the supplied URL
# - link:
# text: ""
# url: ""
# Search: A hyperlink that will perform a Gertty search when
# activated.
# - search:
# text: "{id}"
# query: "change:{id}"
# This is the query used for the list of changes when a project is
# selected. The default is "status:open".
# change-list-query: "status:open"
# This section defines default sorting options for the change
# list. The "sort-by" key specifies the sort order, which can be
# 'number', 'updated', or 'last-seen'. The 'reverse' key specifies
# ascending (true) or descending (false) order.
# change-list-options:
# sort-by: 'number'
# reverse: false
# Uncomment the following line to disable the navigation breadcrumbs
# at the bottom of the screen:
# breadcrumbs: false
# Uncomment the following line to use a unified diff view instead
# of the default side-by-side:
# diff-view: unified
# Dependent changes are displayed as "threads" in the change list by
# default. To disable this behavior, uncomment the following line:
# thread-changes: false
# Times are displayed in the local timezone by default. To display
# them in UTC instead, uncomment the following line:
# display-times-in-utc: true
# Gertty handles mouse input by default. If you don't want it messing
# with your terminal's mouse handling, uncomment the following line:
# handle-mouse: false
# Closed changes that are older than two months are removed from the
# local database (and their refs are removed from the local git repos
# so that git may garbage collect them). If you would like to change
# the expiration delay or disable it, uncomment the following line.
# The time interval is specified in the same way as the "age:" term in
# Gerrit's search syntax. To disable it altogether, set the value to
# the empty string.
# expire-age: '2 months'
# Uncomment the following lines to Hide comments by default that match
# certain criteria. You can toggle their display with 't'. Currently
# the only supported criterion is "author".
# hide-comments:
# - author: "^(.*CI|Jenkins)$"
# This section defines customized dashboards. You can supply any
# Gertty search string and bind them to any key. They will appear in
# the global help text, and pressing the key anywhere in Gertty will
# run the query and display the results.
#
# NB: "recentlyseen:24 hours" does not just return changes seen in the
# last 24 hours -- it returns changes seen within 24 hours of the most
# recently seen change. So you can take the weekend off and pick up
# where you were.
dashboards:
- name: "My changes"
query: "owner:self status:open"
key: "f2"
- name: "Incoming reviews"
query: "is:open is:reviewer"
key: "f3"
- name: "Starred changes"
query: "is:starred"
key: "f4"
- name: "Recently seen changes"
query: "recentlyseen:24 hours"
sort-by: "last-seen"
reverse: True
key: "f5"
# Reviewkeys are hotkeys that perform immediate reviews within the
# change screen. Any pending comments or review messages will be
# attached to the review; otherwise an empty review will be left. The
# approvals list is exhaustive, so if you specify an empty list,
# Gertty will submit a review that clears any previous approvals. To
# submit the change with the review, include 'submit: True' with the
# reviewkey. Reviewkeys appear in the help text for the change
# screen.
reviewkeys:
- key: 'meta 0'
approvals: []
- key: 'meta 1'
approvals:
- category: 'Code-Review'
value: 1
- key: 'meta 2'
approvals:
- category: 'Code-Review'
value: 2
- key: 'meta 3'
approvals:
- category: 'Code-Review'
value: 2
submit: True

View File

@ -1,26 +0,0 @@
"""add query sync table
Revision ID: 1bb187bcd401
Revises: 3cc7e3753dc3
Create Date: 2015-03-26 07:32:33.584657
"""
# revision identifiers, used by Alembic.
revision = '1bb187bcd401'
down_revision = '3cc7e3753dc3'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table('sync_query',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('name', sa.String(255), index=True, unique=True, nullable=False),
sa.Column('updated', sa.DateTime, index=True),
sa.PrimaryKeyConstraint('key')
)
def downgrade():
pass

View File

@ -1,22 +0,0 @@
"""add revision indexes
Revision ID: 1cdd4e2e74c
Revises: 4a802b741d2f
Create Date: 2015-03-10 16:17:41.330825
"""
# revision identifiers, used by Alembic.
revision = '1cdd4e2e74c'
down_revision = '4a802b741d2f'
from alembic import op
def upgrade():
op.create_index(op.f('ix_revision_commit'), 'revision', ['commit'])
op.create_index(op.f('ix_revision_parent'), 'revision', ['parent'])
def downgrade():
pass

View File

@ -1,64 +0,0 @@
"""attach comments to files
Revision ID: 254ac5fc3941
Revises: 50344aecd1c2
Create Date: 2015-04-13 15:52:07.104397
"""
# revision identifiers, used by Alembic.
revision = '254ac5fc3941'
down_revision = '50344aecd1c2'
import sys
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns, sqlite_drop_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('comment', sa.Column('file_key', sa.Integer()))
sqlite_alter_columns('comment', [
sa.Column('file_key', sa.Integer(), sa.ForeignKey('file.key'))
])
update_query = sa.text('update comment set file_key=:file_key where key=:key')
file_query = sa.text('select f.key from file f where f.revision_key=:revision_key and f.path=:path')
file_insert_query = sa.text('insert into file (key, revision_key, path, old_path, status, inserted, deleted) '
' values (NULL, :revision_key, :path, NULL, NULL, NULL, NULL)')
conn = op.get_bind()
countres = conn.execute('select count(*) from comment')
comments = countres.fetchone()[0]
comment_res = conn.execute('select p.name, c.number, c.status, r.key, r.number, m.file, m.key '
'from project p, change c, revision r, comment m '
'where m.revision_key=r.key and r.change_key=c.key and '
'c.project_key=p.key order by p.name')
count = 0
for (pname, cnumber, cstatus, rkey, rnumber, mfile, mkey) in comment_res.fetchall():
count += 1
sys.stdout.write('Comment %s / %s\r' % (count, comments))
sys.stdout.flush()
file_res = conn.execute(file_query, revision_key=rkey, path=mfile)
file_key = file_res.fetchone()
if not file_key:
conn.execute(file_insert_query, revision_key=rkey, path=mfile)
file_res = conn.execute(file_query, revision_key=rkey, path=mfile)
file_key = file_res.fetchone()
fkey = file_key[0]
file_res = conn.execute(update_query, file_key=fkey, key=mkey)
sqlite_drop_columns('comment', ['revision_key', 'file'])
print
def downgrade():
pass

View File

@ -1,25 +0,0 @@
"""fix account table
Revision ID: 2a11dd14665
Revises: 4cc9c46f9d8b
Create Date: 2014-08-20 13:07:25.079603
"""
# revision identifiers, used by Alembic.
revision = '2a11dd14665'
down_revision = '4cc9c46f9d8b'
from alembic import op
def upgrade():
op.drop_index('ix_account_name', 'account')
op.drop_index('ix_account_username', 'account')
op.drop_index('ix_account_email', 'account')
op.create_index(op.f('ix_account_name'), 'account', ['name'])
op.create_index(op.f('ix_account_username'), 'account', ['username'])
op.create_index(op.f('ix_account_email'), 'account', ['email'])
def downgrade():
pass

View File

@ -1,36 +0,0 @@
"""add can_submit column
Revision ID: 312cd5a9f878
Revises: 46b175bfa277
Create Date: 2014-09-18 16:37:13.149729
"""
# revision identifiers, used by Alembic.
revision = '312cd5a9f878'
down_revision = '46b175bfa277'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('revision', sa.Column('can_submit', sa.Boolean()))
conn = op.get_bind()
q = sa.text('update revision set can_submit=:submit')
conn.execute(q, submit=False)
sqlite_alter_columns('revision', [
sa.Column('can_submit', sa.Boolean(), nullable=False),
])
def downgrade():
pass

View File

@ -1,27 +0,0 @@
"""add conflicts table
Revision ID: 3610c2543e07
Revises: 4388de50824a
Create Date: 2016-02-05 16:43:20.047238
"""
# revision identifiers, used by Alembic.
revision = '3610c2543e07'
down_revision = '4388de50824a'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table('change_conflict',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('change1_key', sa.Integer(), sa.ForeignKey('change.key'), index=True),
sa.Column('change2_key', sa.Integer(), sa.ForeignKey('change.key'), index=True),
sa.PrimaryKeyConstraint('key')
)
def downgrade():
pass

View File

@ -1,26 +0,0 @@
"""add last_seen column to change
Revision ID: 37a702b7f58e
Revises: 3610c2543e07
Create Date: 2016-02-06 09:09:38.728225
"""
# revision identifiers, used by Alembic.
revision = '37a702b7f58e'
down_revision = '3610c2543e07'
import warnings
from alembic import op
import sqlalchemy as sa
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('change', sa.Column('last_seen', sa.DateTime, index=True))
def downgrade():
pass

View File

@ -1,33 +0,0 @@
"""Added project updated column
Revision ID: 38104b4c1b84
Revises: 56e48a4a064a
Create Date: 2014-05-31 06:52:12.452205
"""
# revision identifiers, used by Alembic.
revision = '38104b4c1b84'
down_revision = '56e48a4a064a'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column('project', sa.Column('updated', sa.DateTime))
conn = op.get_bind()
res = conn.execute('select "key", name from project')
for (key, name) in res.fetchall():
q = sa.text("select max(updated) from change where project_key=:key")
res = conn.execute(q, key=key)
for (updated,) in res.fetchall():
q = sa.text('update project set updated=:updated where "key"=:key')
conn.execute(q, key=key, updated=updated)
op.create_index(op.f('ix_project_updated'), 'project', ['updated'], unique=False)
def downgrade():
op.drop_index(op.f('ix_project_updated'), table_name='project')
op.drop_column('project', 'updated')

View File

@ -1,37 +0,0 @@
"""add held
Revision ID: 3cc7e3753dc3
Revises: 1cdd4e2e74c
Create Date: 2015-03-22 08:48:15.516289
"""
# revision identifiers, used by Alembic.
revision = '3cc7e3753dc3'
down_revision = '1cdd4e2e74c'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('change', sa.Column('held', sa.Boolean()))
connection = op.get_bind()
change = sa.sql.table('change',
sa.sql.column('held', sa.Boolean()))
connection.execute(change.update().values({'held':False}))
sqlite_alter_columns('change', [
sa.Column('held', sa.Boolean(), index=True, nullable=False),
])
def downgrade():
pass

View File

@ -1,49 +0,0 @@
"""add draft fields
Revision ID: 3d429503a29a
Revises: 2a11dd14665
Create Date: 2014-08-30 13:26:03.698902
"""
# revision identifiers, used by Alembic.
revision = '3d429503a29a'
down_revision = '2a11dd14665'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns, sqlite_drop_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('message', sa.Column('draft', sa.Boolean()))
op.add_column('comment', sa.Column('draft', sa.Boolean()))
op.add_column('approval', sa.Column('draft', sa.Boolean()))
conn = op.get_bind()
conn.execute("update message set draft=pending")
conn.execute("update comment set draft=pending")
conn.execute("update approval set draft=pending")
sqlite_alter_columns('message', [
sa.Column('draft', sa.Boolean(), index=True, nullable=False),
])
sqlite_alter_columns('comment', [
sa.Column('draft', sa.Boolean(), index=True, nullable=False),
])
sqlite_alter_columns('approval', [
sa.Column('draft', sa.Boolean(), index=True, nullable=False),
])
sqlite_drop_columns('comment', ['pending'])
sqlite_drop_columns('approval', ['pending'])
def downgrade():
pass

View File

@ -1,35 +0,0 @@
"""add topic table
Revision ID: 4388de50824a
Revises: 254ac5fc3941
Create Date: 2015-10-31 19:06:38.538948
"""
# revision identifiers, used by Alembic.
revision = '4388de50824a'
down_revision = '254ac5fc3941'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table('topic',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), index=True, nullable=False),
sa.Column('sequence', sa.Integer(), index=True, unique=True, nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_table('project_topic',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('project_key', sa.Integer(), sa.ForeignKey('project.key'), index=True),
sa.Column('topic_key', sa.Integer(), sa.ForeignKey('topic.key'), index=True),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('key'),
sa.UniqueConstraint('topic_key', 'sequence', name='topic_key_sequence_const'),
)
def downgrade():
pass

View File

@ -1,176 +0,0 @@
"""Initial schema
Revision ID: 44402069e137
Revises: None
Create Date: 2014-05-04 17:10:23.127702
"""
# revision identifiers, used by Alembic.
revision = '44402069e137'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('project',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('subscribed', sa.Boolean(), nullable=True),
sa.Column('description', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_project_name'), 'project', ['name'], unique=True)
op.create_index(op.f('ix_project_subscribed'), 'project', ['subscribed'], unique=False)
op.create_table('change',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('project_key', sa.Integer(), nullable=True),
sa.Column('id', sa.String(length=255), nullable=False),
sa.Column('number', sa.Integer(), nullable=False),
sa.Column('branch', sa.String(length=255), nullable=False),
sa.Column('change_id', sa.String(length=255), nullable=False),
sa.Column('topic', sa.String(length=255), nullable=True),
sa.Column('owner', sa.String(length=255), nullable=True),
sa.Column('subject', sa.Text(), nullable=False),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
sa.Column('status', sa.String(length=8), nullable=False),
sa.Column('hidden', sa.Boolean(), nullable=False),
sa.Column('reviewed', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['project_key'], ['project.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_change_branch'), 'change', ['branch'], unique=False)
op.create_index(op.f('ix_change_change_id'), 'change', ['change_id'], unique=False)
op.create_index(op.f('ix_change_created'), 'change', ['created'], unique=False)
op.create_index(op.f('ix_change_hidden'), 'change', ['hidden'], unique=False)
op.create_index(op.f('ix_change_id'), 'change', ['id'], unique=True)
op.create_index(op.f('ix_change_number'), 'change', ['number'], unique=True)
op.create_index(op.f('ix_change_owner'), 'change', ['owner'], unique=False)
op.create_index(op.f('ix_change_project_key'), 'change', ['project_key'], unique=False)
op.create_index(op.f('ix_change_reviewed'), 'change', ['reviewed'], unique=False)
op.create_index(op.f('ix_change_status'), 'change', ['status'], unique=False)
op.create_index(op.f('ix_change_topic'), 'change', ['topic'], unique=False)
op.create_index(op.f('ix_change_updated'), 'change', ['updated'], unique=False)
op.create_table('approval',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('change_key', sa.Integer(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('value', sa.Integer(), nullable=False),
sa.Column('pending', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['change_key'], ['change.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_approval_change_key'), 'approval', ['change_key'], unique=False)
op.create_index(op.f('ix_approval_pending'), 'approval', ['pending'], unique=False)
op.create_table('revision',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('change_key', sa.Integer(), nullable=True),
sa.Column('number', sa.Integer(), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('commit', sa.String(length=255), nullable=False),
sa.Column('parent', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['change_key'], ['change.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_revision_change_key'), 'revision', ['change_key'], unique=False)
op.create_index(op.f('ix_revision_number'), 'revision', ['number'], unique=False)
op.create_table('label',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('change_key', sa.Integer(), nullable=True),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('value', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['change_key'], ['change.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_label_change_key'), 'label', ['change_key'], unique=False)
op.create_table('permitted_label',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('change_key', sa.Integer(), nullable=True),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('value', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['change_key'], ['change.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_permitted_label_change_key'), 'permitted_label', ['change_key'], unique=False)
op.create_table('comment',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('revision_key', sa.Integer(), nullable=True),
sa.Column('id', sa.String(length=255), nullable=True),
sa.Column('in_reply_to', sa.String(length=255), nullable=True),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('file', sa.Text(), nullable=False),
sa.Column('parent', sa.Boolean(), nullable=False),
sa.Column('line', sa.Integer(), nullable=True),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('pending', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['revision_key'], ['revision.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_comment_created'), 'comment', ['created'], unique=False)
op.create_index(op.f('ix_comment_id'), 'comment', ['id'], unique=False)
op.create_index(op.f('ix_comment_pending'), 'comment', ['pending'], unique=False)
op.create_index(op.f('ix_comment_revision_key'), 'comment', ['revision_key'], unique=False)
op.create_table('message',
sa.Column('key', sa.Integer(), nullable=False, quote=True),
sa.Column('revision_key', sa.Integer(), nullable=True),
sa.Column('id', sa.String(length=255), nullable=True),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=True),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('pending', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['revision_key'], ['revision.key'], ),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_message_created'), 'message', ['created'], unique=False)
op.create_index(op.f('ix_message_id'), 'message', ['id'], unique=False)
op.create_index(op.f('ix_message_pending'), 'message', ['pending'], unique=False)
op.create_index(op.f('ix_message_revision_key'), 'message', ['revision_key'], unique=False)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_message_revision_key'), table_name='message')
op.drop_index(op.f('ix_message_pending'), table_name='message')
op.drop_index(op.f('ix_message_id'), table_name='message')
op.drop_index(op.f('ix_message_created'), table_name='message')
op.drop_table('message')
op.drop_index(op.f('ix_comment_revision_key'), table_name='comment')
op.drop_index(op.f('ix_comment_pending'), table_name='comment')
op.drop_index(op.f('ix_comment_id'), table_name='comment')
op.drop_index(op.f('ix_comment_created'), table_name='comment')
op.drop_table('comment')
op.drop_index(op.f('ix_permitted_label_change_key'), table_name='permitted_label')
op.drop_table('permitted_label')
op.drop_index(op.f('ix_label_change_key'), table_name='label')
op.drop_table('label')
op.drop_index(op.f('ix_revision_number'), table_name='revision')
op.drop_index(op.f('ix_revision_change_key'), table_name='revision')
op.drop_table('revision')
op.drop_index(op.f('ix_approval_pending'), table_name='approval')
op.drop_index(op.f('ix_approval_change_key'), table_name='approval')
op.drop_table('approval')
op.drop_index(op.f('ix_change_updated'), table_name='change')
op.drop_index(op.f('ix_change_topic'), table_name='change')
op.drop_index(op.f('ix_change_status'), table_name='change')
op.drop_index(op.f('ix_change_reviewed'), table_name='change')
op.drop_index(op.f('ix_change_project_key'), table_name='change')
op.drop_index(op.f('ix_change_owner'), table_name='change')
op.drop_index(op.f('ix_change_number'), table_name='change')
op.drop_index(op.f('ix_change_id'), table_name='change')
op.drop_index(op.f('ix_change_hidden'), table_name='change')
op.drop_index(op.f('ix_change_created'), table_name='change')
op.drop_index(op.f('ix_change_change_id'), table_name='change')
op.drop_index(op.f('ix_change_branch'), table_name='change')
op.drop_table('change')
op.drop_index(op.f('ix_project_subscribed'), table_name='project')
op.drop_index(op.f('ix_project_name'), table_name='project')
op.drop_table('project')
### end Alembic commands ###

View File

@ -1,66 +0,0 @@
"""add pending actions
Revision ID: 46b175bfa277
Revises: 3d429503a29a
Create Date: 2014-08-31 09:20:11.789330
"""
# revision identifiers, used by Alembic.
revision = '46b175bfa277'
down_revision = '3d429503a29a'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
op.create_table('branch',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('project_key', sa.Integer(), sa.ForeignKey('project.key'), index=True, nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('key')
)
op.create_table('pending_cherry_pick',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('revision_key', sa.Integer(), sa.ForeignKey('revision.key'), index=True, nullable=False),
sa.Column('branch', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('key')
)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('change', sa.Column('pending_rebase', sa.Boolean()))
op.add_column('change', sa.Column('pending_topic', sa.Boolean()))
op.add_column('change', sa.Column('pending_status', sa.Boolean()))
op.add_column('change', sa.Column('pending_status_message', sa.Text()))
op.add_column('revision', sa.Column('pending_message', sa.Boolean()))
connection = op.get_bind()
change = sa.sql.table('change',
sa.sql.column('pending_rebase', sa.Boolean()),
sa.sql.column('pending_topic', sa.Boolean()),
sa.sql.column('pending_status', sa.Boolean()))
connection.execute(change.update().values({'pending_rebase':False,
'pending_topic':False,
'pending_status':False}))
revision = sa.sql.table('revision',
sa.sql.column('pending_message', sa.Boolean()))
connection.execute(revision.update().values({'pending_message':False}))
sqlite_alter_columns('change', [
sa.Column('pending_rebase', sa.Boolean(), index=True, nullable=False),
sa.Column('pending_topic', sa.Boolean(), index=True, nullable=False),
sa.Column('pending_status', sa.Boolean(), index=True, nullable=False),
])
sqlite_alter_columns('revision', [
sa.Column('pending_message', sa.Boolean(), index=True, nullable=False),
])
def downgrade():
pass

View File

@ -1,41 +0,0 @@
"""add starred
Revision ID: 4a802b741d2f
Revises: 312cd5a9f878
Create Date: 2015-02-12 18:10:19.187733
"""
# revision identifiers, used by Alembic.
revision = '4a802b741d2f'
down_revision = '312cd5a9f878'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('change', sa.Column('starred', sa.Boolean()))
op.add_column('change', sa.Column('pending_starred', sa.Boolean()))
connection = op.get_bind()
change = sa.sql.table('change',
sa.sql.column('starred', sa.Boolean()),
sa.sql.column('pending_starred', sa.Boolean()))
connection.execute(change.update().values({'starred':False,
'pending_starred':False}))
sqlite_alter_columns('change', [
sa.Column('starred', sa.Boolean(), index=True, nullable=False),
sa.Column('pending_starred', sa.Boolean(), index=True, nullable=False),
])
def downgrade():
pass

View File

@ -1,73 +0,0 @@
"""add account table
Revision ID: 4cc9c46f9d8b
Revises: 725816dc500
Create Date: 2014-07-23 16:01:47.462597
"""
# revision identifiers, used by Alembic.
revision = '4cc9c46f9d8b'
down_revision = '725816dc500'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns, sqlite_drop_columns
def upgrade():
sqlite_drop_columns('message', ['name'])
sqlite_drop_columns('comment', ['name'])
sqlite_drop_columns('approval', ['name'])
sqlite_drop_columns('change', ['owner'])
op.create_table('account',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), index=True, unique=True, nullable=False),
sa.Column('name', sa.String(length=255)),
sa.Column('username', sa.String(length=255)),
sa.Column('email', sa.String(length=255)),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_account_name'), 'account', ['name'], unique=True)
op.create_index(op.f('ix_account_username'), 'account', ['name'], unique=True)
op.create_index(op.f('ix_account_email'), 'account', ['name'], unique=True)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('message', sa.Column('account_key', sa.Integer()))
op.add_column('comment', sa.Column('account_key', sa.Integer()))
op.add_column('approval', sa.Column('account_key', sa.Integer()))
op.add_column('change', sa.Column('account_key', sa.Integer()))
sqlite_alter_columns('message', [
sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key'))
])
sqlite_alter_columns('comment', [
sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key'))
])
sqlite_alter_columns('approval', [
sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key'))
])
sqlite_alter_columns('change', [
sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key'))
])
op.create_index(op.f('ix_message_account_key'), 'message', ['account_key'], unique=False)
op.create_index(op.f('ix_comment_account_key'), 'comment', ['account_key'], unique=False)
op.create_index(op.f('ix_approval_account_key'), 'approval', ['account_key'], unique=False)
op.create_index(op.f('ix_change_account_key'), 'change', ['account_key'], unique=False)
connection = op.get_bind()
project = sa.sql.table('project', sa.sql.column('updated', sa.DateTime))
connection.execute(project.update().values({'updated':None}))
approval = sa.sql.table('approval', sa.sql.column('pending'))
connection.execute(approval.delete().where(approval.c.pending==False))
def downgrade():
pass

View File

@ -1,94 +0,0 @@
"""add files table
Revision ID: 50344aecd1c2
Revises: 1bb187bcd401
Create Date: 2015-04-13 08:08:08.682803
"""
# revision identifiers, used by Alembic.
revision = '50344aecd1c2'
down_revision = '1bb187bcd401'
import re
import sys
from alembic import op, context
import sqlalchemy as sa
import git.exc
import gertty.db
import gertty.gitrepo
def upgrade():
op.create_table('file',
sa.Column('key', sa.Integer(), nullable=False),
sa.Column('revision_key', sa.Integer(), nullable=False, index=True),
sa.Column('path', sa.Text(), nullable=False, index=True),
sa.Column('old_path', sa.Text(), index=True),
sa.Column('status', sa.String(length=1)),
sa.Column('inserted', sa.Integer()),
sa.Column('deleted', sa.Integer()),
sa.PrimaryKeyConstraint('key')
)
pathre = re.compile('((.*?)\{|^)(.*?) => (.*?)(\}(.*)|$)')
insert = sa.text('insert into file (key, revision_key, path, old_path, status, inserted, deleted) '
' values (NULL, :revision_key, :path, :old_path, :status, :inserted, :deleted)')
conn = op.get_bind()
countres = conn.execute('select count(*) from revision')
revisions = countres.fetchone()[0]
if revisions > 50:
print('')
print('Adding support for searching for changes by file modified. '
'This may take a while.')
qres = conn.execute('select p.name, c.number, c.status, r.key, r.number, r."commit", r.parent from project p, change c, revision r '
'where r.change_key=c.key and c.project_key=p.key order by p.name')
count = 0
for (pname, cnumber, cstatus, rkey, rnumber, commit, parent) in qres.fetchall():
count += 1
sys.stdout.write('Diffstat revision %s / %s\r' % (count, revisions))
sys.stdout.flush()
ires = conn.execute(insert, revision_key=rkey, path='/COMMIT_MSG', old_path=None,
status=None, inserted=None, deleted=None)
repo = gertty.gitrepo.get_repo(pname, context.config.gertty_app.config)
try:
stats = repo.diffstat(parent, commit)
except git.exc.GitCommandError:
# Probably a missing commit
if cstatus not in ['MERGED', 'ABANDONED']:
print("Unable to examine diff for %s %s change %s,%s" % (cstatus, pname, cnumber, rnumber))
continue
for stat in stats:
try:
(added, removed, path) = stat
except ValueError:
if cstatus not in ['MERGED', 'ABANDONED']:
print("Empty diffstat for %s %s change %s,%s" % (cstatus, pname, cnumber, rnumber))
m = pathre.match(path)
status = gertty.db.File.STATUS_MODIFIED
old_path = None
if m:
status = gertty.db.File.STATUS_RENAMED
pre = m.group(2) or ''
post = m.group(6) or ''
old_path = pre+m.group(3)+post
path = pre+m.group(4)+post
try:
added = int(added)
except ValueError:
added = None
try:
removed = int(removed)
except ValueError:
removed = None
conn.execute(insert, revision_key=rkey, path=path, old_path=old_path,
status=status, inserted=added, deleted=removed)
print('')
def downgrade():
pass

View File

@ -1,26 +0,0 @@
"""Increase status field width
Revision ID: 56e48a4a064a
Revises: 44402069e137
Create Date: 2014-05-05 11:49:42.133569
"""
# revision identifiers, used by Alembic.
revision = '56e48a4a064a'
down_revision = '44402069e137'
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
sqlite_alter_columns('change', [
sa.Column('status', sa.String(16), index=True, nullable=False)
])
def downgrade():
sqlite_alter_columns('change', [
sa.Column('status', sa.String(8), index=True, nullable=False)
])

View File

@ -1,41 +0,0 @@
"""Add fetch ref column
Revision ID: 725816dc500
Revises: 38104b4c1b84
Create Date: 2014-05-31 14:51:08.078616
"""
# revision identifiers, used by Alembic.
revision = '725816dc500'
down_revision = '38104b4c1b84'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('revision', sa.Column('fetch_auth', sa.Boolean()))
op.add_column('revision', sa.Column('fetch_ref', sa.String(length=255)))
conn = op.get_bind()
res = conn.execute('select r.key, r.number, c.number from revision r, "change" c where r.change_key=c.key')
for (rkey, rnumber, cnumber) in res.fetchall():
q = sa.text('update revision set fetch_auth=:auth, fetch_ref=:ref where "key"=:key')
ref = 'refs/changes/%s/%s/%s' % (str(cnumber)[-2:], cnumber, rnumber)
res = conn.execute(q, key=rkey, ref=ref, auth=False)
sqlite_alter_columns('revision', [
sa.Column('fetch_auth', sa.Boolean(), nullable=False),
sa.Column('fetch_ref', sa.String(length=255), nullable=False)
])
def downgrade():
op.drop_column('revision', 'fetch_auth')
op.drop_column('revision', 'fetch_ref')

View File

@ -1,37 +0,0 @@
"""add change.outdated
Revision ID: 7ef7dfa2ca3a
Revises: 37a702b7f58e
Create Date: 2016-08-09 08:59:04.441926
"""
# revision identifiers, used by Alembic.
revision = '7ef7dfa2ca3a'
down_revision = '37a702b7f58e'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('change', sa.Column('outdated', sa.Boolean()))
connection = op.get_bind()
change = sa.sql.table('change',
sa.sql.column('outdated', sa.Boolean()))
connection.execute(change.update().values({'outdated':False}))
sqlite_alter_columns('change', [
sa.Column('outdated', sa.Boolean(), index=True, nullable=False),
])
def downgrade():
pass

View File

@ -1,69 +0,0 @@
# Copyright 2015 Christoph Gysin <christoph.gysin@gmail.com>
#
# 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 logging
import requests
from six.moves.urllib import parse as urlparse
class FormAuth(requests.auth.AuthBase):
def __init__(self, username, password):
self.username = username
self.password = password
self.log = logging.getLogger('gertty.auth')
def _retry_using_form_auth(self, response, args):
adapter = requests.adapters.HTTPAdapter()
request = _copy_request(response.request)
u = urlparse.urlparse(response.url)
url = urlparse.urlunparse([u.scheme, u.netloc, '/login',
None, None, None])
auth = {'username': self.username,
'password': self.password}
request2 = requests.Request('POST', url, data=auth).prepare()
response2 = adapter.send(request2, **args)
if response2.status_code == 401:
self.log.error('Login failed: Invalid username or password?')
return response
cookie = response2.headers.get('set-cookie')
if cookie is not None:
request.headers['Cookie'] = cookie
response3 = adapter.send(request, **args)
return response3
def _response_hook(self, response, **kwargs):
if response.status_code == 401:
return self._retry_using_form_auth(response, kwargs)
return response
def __call__(self, request):
request.headers["Connection"] = "Keep-Alive"
request.register_hook('response', self._response_hook)
return request
def _copy_request(request):
new_request = requests.PreparedRequest()
new_request.method = request.method
new_request.url = request.url
new_request.body = request.body
new_request.hooks = request.hooks
new_request.headers = request.headers.copy()
return new_request

File diff suppressed because it is too large Load Diff

View File

@ -1,540 +0,0 @@
# 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.
# Test changes:
# https://review.openstack.org/275862
# https://review.openstack.org/119302
# https://review.openstack.org/133550
import datetime
import logging
import difflib
import itertools
import os
import re
import git
import gitdb
import six
OLD = 0
NEW = 1
START = 0
END = 1
LINENO = 0
LINE = 1
class GitTimeZone(datetime.tzinfo):
"""Because we can't have nice things."""
def __init__(self, offset_seconds):
self._offset = offset_seconds
def utcoffset(self, dt):
return datetime.timedelta(seconds=self._offset)
def dst(self, dt):
return datetime.timedelta(0)
def tzname(self, dt):
return None
class CommitBlob(object):
def __init__(self):
self.path = '/COMMIT_MSG'
class CommitContext(object):
"""A git.diff.Diff for commit messages."""
def decorateGitTime(self, seconds, tz):
dt = datetime.datetime.fromtimestamp(seconds, GitTimeZone(-tz))
return dt.strftime('%Y-%m-%d %H:%M:%S %Z%z')
def decorateMessage(self, commit):
"""Put the Gerrit commit metadata at the front of the message.
e.g.:
Parent: cc8a51ca (Initial commit) 1
Author: Robert Collins <rbtcollins@hp.com> 2
AuthorDate: 2014-05-27 14:05:47 +1200 3
Commit: Robert Collins <rbtcollins@hp.com> 4
CommitDate: 2014-05-27 14:07:57 +1200 5
6
"""
# NB: If folk report that commits have comments at the wrong place
# Then this function, which reproduces gerrit behaviour, will need
# to be fixed (e.g. by making the behaviour match more closely.
if not commit:
return []
if commit.parents:
parentsha = commit.parents[0].hexsha[:8]
else:
parentsha = None
author = commit.author
committer = commit.committer
author_date = self.decorateGitTime(
commit.authored_date, commit.author_tz_offset)
commit_date = self.decorateGitTime(
commit.committed_date, commit.committer_tz_offset)
if isinstance(author.email, six.text_type):
author_email = author.email
else:
author_email = author.email.decode('utf8')
if isinstance(committer.email, six.text_type):
committer_email = committer.email
else:
committer_email = committer.email.decode('utf8')
return [u"Parent: %s\n" % parentsha,
u"Author: %s <%s>\n" % (author.name, author_email),
u"AuthorDate: %s\n" % author_date,
u"Commit: %s <%s>\n" % (committer.name, committer_email),
u"CommitDate: %s\n" % commit_date,
u"\n"] + commit.message.splitlines(True)
def __init__(self, old, new):
"""Create a CommitContext.
:param old: A git.objects.commit object or None.
:param new: A git.objects.commit object.
"""
self.rename_from = self.rename_to = None
if old is None:
self.new_file = True
else:
self.new_file = False
self.deleted_file = False
self.a_blob = CommitBlob()
self.b_blob = CommitBlob()
self.a_path = self.a_blob.path
self.b_path = self.b_blob.path
self.diff = ''.join(difflib.unified_diff(
self.decorateMessage(old), self.decorateMessage(new),
fromfile="/a/COMMIT_MSG", tofile="/b/COMMIT_MSG"))
class DiffChunk(object):
def __init__(self):
self.oldlines = []
self.newlines = []
self.first = False
self.last = False
self.lines = []
self.calcRange()
def __repr__(self):
return '<%s old lines %s-%s / new lines %s-%s>' % (
self.__class__.__name__,
self.range[OLD][START], self.range[OLD][END],
self.range[NEW][START], self.range[NEW][END])
def calcRange(self):
self.range = [[0, 0],
[0, 0]]
for l in self.lines:
if self.range[OLD][START] == 0 and l[OLD][LINENO] is not None:
self.range[OLD][START] = l[OLD][LINENO]
if self.range[NEW][START] == 0 and l[NEW][LINENO] is not None:
self.range[NEW][START] = l[NEW][LINENO]
if (self.range[OLD][START] != 0 and
self.range[NEW][START] != 0):
break
for l in self.lines[::-1]:
if self.range[OLD][END] == 0 and l[OLD][LINENO] is not None:
self.range[OLD][END] = l[OLD][LINENO]
if self.range[NEW][END] == 0 and l[NEW][LINENO] is not None:
self.range[NEW][END] = l[NEW][LINENO]
if (self.range[OLD][END] != 0 and
self.range[NEW][END] != 0):
break
def indexOfLine(self, oldnew, lineno):
for i, l in enumerate(self.lines):
if l[oldnew][LINENO] == lineno:
return i
class DiffContextChunk(DiffChunk):
context = True
class DiffChangedChunk(DiffChunk):
context = False
class DiffFile(object):
def __init__(self):
self.newname = 'Unknown File'
self.oldname = 'Unknown File'
self.old_empty = False
self.new_empty = False
self.chunks = []
self.current_chunk = None
self.old_lineno = 0
self.new_lineno = 0
self.offset = 0
def finalize(self):
if not self.current_chunk:
return
self.current_chunk.lines = list(
six.moves.zip(self.current_chunk.oldlines,
self.current_chunk.newlines))
if not self.chunks:
self.current_chunk.first = True
else:
self.chunks[-1].last = False
self.current_chunk.last = True
self.current_chunk.calcRange()
self.chunks.append(self.current_chunk)
self.current_chunk = None
def addDiffLines(self, old, new):
if (self.current_chunk and
not isinstance(self.current_chunk, DiffChangedChunk)):
self.finalize()
if not self.current_chunk:
self.current_chunk = DiffChangedChunk()
for l in old:
self.current_chunk.oldlines.append((self.old_lineno, '-', l))
self.old_lineno += 1
self.offset -= 1
for l in new:
self.current_chunk.newlines.append((self.new_lineno, '+', l))
self.new_lineno += 1
self.offset += 1
while self.offset > 0:
self.current_chunk.oldlines.append((None, '', ''))
self.offset -= 1
while self.offset < 0:
self.current_chunk.newlines.append((None, '', ''))
self.offset += 1
def addNewLine(self, line):
if (self.current_chunk and
not isinstance(self.current_chunk, DiffChangedChunk)):
self.finalize()
if not self.current_chunk:
self.current_chunk = DiffChangedChunk()
def addContextLine(self, line):
if (self.current_chunk and
not isinstance(self.current_chunk, DiffContextChunk)):
self.finalize()
if not self.current_chunk:
self.current_chunk = DiffContextChunk()
self.current_chunk.oldlines.append((self.old_lineno, ' ', line))
self.current_chunk.newlines.append((self.new_lineno, ' ', line))
self.old_lineno += 1
self.new_lineno += 1
class GitCheckoutError(Exception):
def __init__(self, msg):
super(GitCheckoutError, self).__init__(msg)
self.msg = msg
class GitCloneError(Exception):
def __init__(self, msg):
super(GitCloneError, self).__init__(msg)
self.msg = msg
class Repo(object):
def __init__(self, url, path):
self.log = logging.getLogger('gertty.gitrepo')
self.url = url
self.path = path
self.differ = difflib.Differ()
if not os.path.exists(path):
if url is None:
raise GitCloneError("No URL available for git clone")
git.Repo.clone_from(self.url, self.path)
def hasCommit(self, sha):
repo = git.Repo(self.path)
try:
repo.commit(sha)
except gitdb.exc.BadObject:
return False
except ValueError:
return False
return True
def fetch(self, url, refspec):
repo = git.Repo(self.path)
try:
repo.git.fetch(url, refspec)
except AssertionError:
repo.git.fetch(url, refspec)
def deleteRef(self, ref):
repo = git.Repo(self.path)
git.Reference.delete(repo, ref)
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 cherryPick(self, ref):
repo = git.Repo(self.path)
try:
repo.git.cherry_pick(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', '--color=never', '--numstat', old, new)
ret = []
for x in diff.split('\n'):
# Added, removed, filename
ret.append(x.split('\t'))
return ret
trailing_ws_re = re.compile('\s+$')
def _emph_trail_ws(self, style, line):
result = (style, line)
re_result = self.trailing_ws_re.search(line)
if (re_result):
span = re_result.span()
if len(line[:span[0]]) == 0:
ws_line = ('trailing-ws', line)
else:
ws_line = [(style, line[:span[0]]),
('trailing-ws', line[span[0]:span[1]])]
result = ws_line
return result
def intralineDiff(self, old, new):
# takes a list of old lines and a list of new lines
prevline = None
prevstyle = None
output_old = []
output_new = []
#self.log.debug('startold' + repr(old))
#self.log.debug('startnew' + repr(new))
for line in self.differ.compare(old, new):
#self.log.debug('diff output: ' + line)
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]
#self.log.debug('%s %s %s %s %s' % (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(self._emph_trail_ws(prevstyle+'-word',
accumulator))
else:
result.append(self._emph_trail_ws(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' or prevstyle == 'context':
output_new.append(self._emph_trail_ws(prevstyle+'-line',
prevline))
if prevstyle == 'removed' or prevstyle == 'context':
output_old.append((prevstyle+'-line', prevline))
if key == '+':
prevstyle = 'added'
elif key == '-':
prevstyle = 'removed'
elif key == ' ':
prevstyle = 'context'
prevline = rest
#self.log.debug('prev'+repr(prevline))
if prevline is not None:
if prevstyle == 'added':
output_new.append(self._emph_trail_ws(prevstyle+'-line',
prevline))
elif prevstyle == 'removed':
output_old.append((prevstyle+'-line', prevline))
#self.log.debug(repr(output_old))
#self.log.debug(repr(output_new))
return output_old, output_new
header_re = re.compile('@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@')
def diff(self, old, new, context=10000, show_old_commit=False):
"""Create a diff from old to new.
Note that the commit message is also diffed, and listed as /COMMIT_MSG.
"""
repo = git.Repo(self.path)
#'-y', '-x', 'diff -C10', old, new, path).split('\n'):
oldc = repo.commit(old)
newc = repo.commit(new)
files = []
extra_contexts = []
if show_old_commit:
extra_contexts.append(CommitContext(oldc, newc))
else:
extra_contexts.append(CommitContext(None, newc))
contexts = itertools.chain(
extra_contexts, oldc.diff(
newc, color='never', create_patch=True, unified=context))
for diff_context in contexts:
# Each iteration of this is a file
f = DiffFile()
f.oldname = diff_context.a_path
f.newname = diff_context.b_path
if diff_context.new_file:
f.oldname = 'Empty file'
f.old_empty = True
if diff_context.deleted_file:
f.newname = 'Empty file'
f.new_empty = True
files.append(f)
if diff_context.rename_from:
f.oldname = diff_context.rename_from
if diff_context.rename_to:
f.newname = diff_context.rename_to
oldchunk = []
newchunk = []
prev_key = ''
if isinstance(diff_context.diff, six.string_types):
diff_text = diff_context.diff
else:
diff_text = diff_context.diff.decode('utf-8')
diff_lines = diff_text.split('\n')
for i, line in enumerate(diff_lines):
last_line = (i == len(diff_lines)-1)
if line.startswith('---'):
continue
if line.startswith('+++'):
continue
if line.startswith('@@'):
#socket.sendall(line)
m = self.header_re.match(line)
#socket.sendall(str(m.groups()))
f.old_lineno = int(m.group(1))
f.new_lineno = int(m.group(3))
continue
if not line:
if prev_key != '\\':
# Strangely, we get an extra newline in the
# diff in the case that the last line is "\ No
# newline at end of file". This is a
# workaround for that.
prev_key = ''
line = 'X '
else:
line = ' '
key = line[0]
rest = line[1:]
if key == '\\':
# This is for "\ No newline at end of file" which
# follows either a -, + or ' ' line to indicate
# which file it's talking about (or both). For
# now, treat it like normal text and let the user
# infer from context that it's not actually in the
# file. Potential TODO: highlight it to make that
# more clear.
if prev_key:
key = prev_key
else:
key = ' '
prev_key = '\\'
if key == '-':
prev_key = '-'
oldchunk.append(rest)
if not last_line:
continue
if key == '+':
prev_key = '+'
newchunk.append(rest)
if not last_line:
continue
prev_key = ''
# end of chunk
if oldchunk or newchunk:
oldchunk, newchunk = self.intralineDiff(oldchunk, newchunk)
f.addDiffLines(oldchunk, newchunk)
oldchunk = []
newchunk = []
if key == ' ':
f.addContextLine(rest)
continue
if line.startswith("similarity index"):
continue
if line.startswith("rename"):
continue
if line.startswith("index"):
continue
if line.startswith("Binary files"):
continue
if not last_line:
raise Exception("Unhandled line: %s" % line)
if not diff_context.diff:
# There is no diff, possibly because this is simply a
# rename. Include context lines so that comments may
# appear.
if not f.new_empty:
blob = newc.tree[f.newname]
else:
blob = oldc.tree[f.oldname]
f.old_lineno = 1
f.new_lineno = 1
for line in blob.data_stream.read().splitlines():
f.addContextLine(line)
f.finalize()
return files
def getFile(self, old, new, path):
f = DiffFile()
f.oldname = path
f.newname = path
f.old_lineno = 1
f.new_lineno = 1
repo = git.Repo(self.path)
newc = repo.commit(new)
try:
blob = newc.tree[path]
except KeyError:
return None
for line in blob.data_stream.read().splitlines():
f.addContextLine(line)
f.finalize()
return f
def get_repo(project_name, config):
local_path = os.path.join(config.git_root, project_name)
local_root = os.path.abspath(config.git_root)
assert os.path.commonprefix((local_root, local_path)) == local_root
return Repo(config.git_url + project_name, local_path)

View File

@ -1,140 +0,0 @@
# 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.
DEFAULT_PALETTE={
'focused': ['default,standout', ''],
'header': ['white,bold', 'dark blue'],
'error': ['light red', 'dark blue'],
'table-header': ['white,bold', ''],
'filename': ['light cyan', ''],
'focused-filename': ['light cyan,standout', ''],
'positive-label': ['dark green', ''],
'negative-label': ['dark red', ''],
'max-label': ['light green', ''],
'min-label': ['light red', ''],
'focused-positive-label': ['dark green,standout', ''],
'focused-negative-label': ['dark red,standout', ''],
'focused-max-label': ['light green,standout', ''],
'focused-min-label': ['light red,standout', ''],
'link': ['dark blue', ''],
'focused-link': ['light blue', ''],
'footer': ['light gray', 'dark gray'],
# Diff
'context-button': ['dark magenta', ''],
'focused-context-button': ['light magenta', ''],
'removed-line': ['dark red', ''],
'removed-word': ['light red', ''],
'added-line': ['dark green', ''],
'added-word': ['light green', ''],
'nonexistent': ['default', ''],
'focused-removed-line': ['dark red,standout', ''],
'focused-removed-word': ['light red,standout', ''],
'focused-added-line': ['dark green,standout', ''],
'focused-added-word': ['light green,standout', ''],
'focused-nonexistent': ['default,standout', ''],
'draft-comment': ['default', 'dark gray'],
'comment': ['light gray', 'dark gray'],
'comment-name': ['white', 'dark gray'],
'line-number': ['dark gray', ''],
'focused-line-number': ['dark gray,standout', ''],
'search-result': ['default,standout', ''],
'trailing-ws': ['light red,standout', ''],
# Change view
'change-data': ['dark cyan', ''],
'focused-change-data': ['light cyan', ''],
'change-header': ['light blue', ''],
'revision-name': ['light blue', ''],
'revision-commit': ['dark blue', ''],
'revision-comments': ['default', ''],
'revision-drafts': ['dark red', ''],
'focused-revision-name': ['light blue,standout', ''],
'focused-revision-commit': ['dark blue,standout', ''],
'focused-revision-comments': ['default,standout', ''],
'focused-revision-drafts': ['dark red,standout', ''],
'change-message-name': ['yellow', ''],
'change-message-own-name': ['light cyan', ''],
'change-message-header': ['brown', ''],
'change-message-own-header': ['dark cyan', ''],
'change-message-draft': ['dark red', ''],
'revision-button': ['dark magenta', ''],
'focused-revision-button': ['light magenta', ''],
'lines-added': ['light green', ''],
'lines-removed': ['light red', ''],
'reviewer-name': ['yellow', ''],
'reviewer-own-name': ['light cyan', ''],
# project list
'unreviewed-project': ['white', ''],
'subscribed-project': ['default', ''],
'unsubscribed-project': ['dark gray', ''],
'marked-project': ['light cyan', ''],
'focused-unreviewed-project': ['white,standout', ''],
'focused-subscribed-project': ['default,standout', ''],
'focused-unsubscribed-project': ['dark gray,standout', ''],
'focused-marked-project': ['light cyan,standout', ''],
# change list
'unreviewed-change': ['default', ''],
'reviewed-change': ['dark gray', ''],
'focused-unreviewed-change': ['default,standout', ''],
'focused-reviewed-change': ['dark gray,standout', ''],
'starred-change': ['light cyan', ''],
'focused-starred-change': ['light cyan,standout', ''],
'held-change': ['light red', ''],
'focused-held-change': ['light red,standout', ''],
'marked-change': ['dark cyan', ''],
'focused-marked-change': ['dark cyan,standout', ''],
'added-graph': ['dark green', ''],
'removed-graph': ['dark red', ''],
'added-removed-graph': ['dark green', 'dark red'],
'focused-added-graph': ['default,standout', 'dark green'],
'focused-removed-graph': ['default,standout', 'dark red'],
}
# A delta from the default palette
LIGHT_PALETTE = {
'table-header': ['black,bold', ''],
'unreviewed-project': ['black', ''],
'subscribed-project': ['dark gray', ''],
'unsubscribed-project': ['dark gray', ''],
'focused-unreviewed-project': ['black,standout', ''],
'focused-subscribed-project': ['dark gray,standout', ''],
'focused-unsubscribed-project': ['dark gray,standout', ''],
'change-data': ['dark blue,bold', ''],
'focused-change-data': ['dark blue,standout', ''],
'reviewer-name': ['brown', ''],
'reviewer-own-name': ['dark blue,bold', ''],
'change-message-name': ['brown', ''],
'change-message-own-name': ['dark blue,bold', ''],
'change-message-header': ['black', ''],
'change-message-own-header': ['black,bold', ''],
'focused-link': ['dark blue,bold', ''],
'filename': ['dark cyan', ''],
}
class Palette(object):
def __init__(self, config):
self.palette = {}
self.palette.update(DEFAULT_PALETTE)
self.update(config)
def update(self, config):
d = config.copy()
if 'name' in d:
del d['name']
self.palette.update(d)
def getPalette(self):
ret = []
for k,v in self.palette.items():
ret.append(tuple([k]+v))
return ret

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,844 +0,0 @@
# Copyright 2014 OpenStack Foundation
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# 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 logging
import six
import urwid
from gertty import keymap
from gertty import mywid
from gertty import sync
from gertty.view import change as view_change
from gertty.view import mouse_scroll_decorator
import gertty.view
class ColumnInfo(object):
def __init__(self, name, packing, value):
self.name = name
self.packing = packing
self.value = value
self.options = (packing, value)
if packing == 'given':
self.spacing = value + 1
else:
self.spacing = (value * 8) + 1
COLUMNS = [
ColumnInfo('Number', 'given', 6),
ColumnInfo('Subject', 'weight', 4),
ColumnInfo('Project', 'weight', 1),
ColumnInfo('Branch', 'weight', 1),
ColumnInfo('Topic', 'weight', 1),
ColumnInfo('Owner', 'weight', 1),
ColumnInfo('Updated', 'given', 10),
ColumnInfo('Size', 'given', 4),
]
class ThreadStack(object):
def __init__(self):
self.stack = []
def push(self, change, children):
self.stack.append([change, children])
def pop(self):
while self.stack:
if self.stack[-1][1]:
# handle children at the tip
return self.stack[-1][1].pop(0)
else:
# current tip has no children, walk up
self.stack.pop()
continue
return None
def countChildren(self):
return [len(x[1]) for x in self.stack]
class ChangeListColumns(object):
def updateColumns(self):
del self.columns.contents[:]
cols = self.columns.contents
options = self.columns.options
for colinfo in COLUMNS:
if colinfo.name in self.enabled_columns:
attr = colinfo.name.lower().replace(' ', '_')
cols.append((getattr(self, attr),
options(*colinfo.options)))
for c in self.category_columns:
cols.append(c)
class ChangeRow(urwid.Button, ChangeListColumns):
change_focus_map = {None: 'focused',
'unreviewed-change': 'focused-unreviewed-change',
'reviewed-change': 'focused-reviewed-change',
'starred-change': 'focused-starred-change',
'held-change': 'focused-held-change',
'marked-change': 'focused-marked-change',
'positive-label': 'focused-positive-label',
'negative-label': 'focused-negative-label',
'min-label': 'focused-min-label',
'max-label': 'focused-max-label',
'added-graph': 'focused-added-graph',
'removed-graph': 'focused-removed-graph',
}
def selectable(self):
return True
def __init__(self, app, change, prefix, categories,
enabled_columns, callback=None):
super(ChangeRow, self).__init__('', on_press=callback, user_data=change.key)
self.app = app
self.change_key = change.key
self.prefix = prefix
self.enabled_columns = enabled_columns
self.subject = mywid.SearchableText(u'', wrap='clip')
self.number = mywid.SearchableText(u'')
self.updated = mywid.SearchableText(u'')
self.size = mywid.SearchableText(u'', align='right')
self.project = mywid.SearchableText(u'', wrap='clip')
self.owner = mywid.SearchableText(u'', wrap='clip')
self.branch = mywid.SearchableText(u'', wrap='clip')
self.topic = mywid.SearchableText(u'', wrap='clip')
self.mark = False
self.columns = urwid.Columns([], dividechars=1)
self.row_style = urwid.AttrMap(self.columns, '')
self._w = urwid.AttrMap(self.row_style, None, focus_map=self.change_focus_map)
self.category_columns = []
self.update(change, categories)
def search(self, search, attribute):
if self.subject.search(search, attribute):
return True
if self.number.search(search, attribute):
return True
if self.project.search(search, attribute):
return True
if self.branch.search(search, attribute):
return True
if self.owner.search(search, attribute):
return True
if self.topic.search(search, attribute):
return True
if self.updated.search(search, attribute):
return True
return False
def _makeSize(self, added, removed):
# Removed is a red graph on top, added is a green graph on bottom.
#
# The graph is 4 cells wide. If both the red and green graphs
# are in the cell, we set the bg to red, fg to green, and set
# a box in the bottom half of the cell.
#
# If only one of the graphs is in the cell, we set a box in
# either the top or bottom of the cell, and set the fg color
# appropriately. This is so that the reverse-video which
# operates on the line when focused works as expected.
lower_box = u'\u2584'
upper_box = u'\u2580'
ret = []
# The graph is logarithmic -- one cell for each order of
# magnitude.
for threshold in [1, 10, 100, 1000]:
color = []
if (added > threshold and removed > threshold):
ret.append(('added-removed-graph', lower_box))
elif (added > threshold):
ret.append(('added-graph', lower_box))
elif (removed > threshold):
ret.append(('removed-graph', upper_box))
else:
ret.append(' ')
return ret
def update(self, change, categories):
if change.reviewed or change.hidden:
style = 'reviewed-change'
else:
style = 'unreviewed-change'
subject = '%s%s' % (self.prefix, change.subject)
flag = ' '
if change.starred:
flag = '*'
style = 'starred-change'
if change.held:
flag = '!'
style = 'held-change'
if self.mark:
flag = '%'
style = 'marked-change'
subject = flag + subject
self.row_style.set_attr_map({None: style})
self.subject.set_text(subject)
self.number.set_text(str(change.number))
self.project.set_text(change.project.name.split('/')[-1])
self.owner.set_text(change.owner_name)
self.branch.set_text(change.branch or '')
self.topic.set_text(change.topic or '')
self.project_name = change.project.name
self.commit_sha = change.revisions[-1].commit
self.current_revision_key = change.revisions[-1].key
today = self.app.time(datetime.datetime.utcnow()).date()
updated_time = self.app.time(change.updated)
if today == updated_time.date():
self.updated.set_text(updated_time.strftime("%I:%M %p").upper())
else:
self.updated.set_text(updated_time.strftime("%Y-%m-%d"))
total_added = 0
total_removed = 0
for rfile in change.revisions[-1].files:
if rfile.status is None:
continue
total_added += rfile.inserted or 0
total_removed += rfile.deleted or 0
self.size.set_text(self._makeSize(total_added, total_removed))
self.category_columns = []
for category in categories:
v = change.getMaxForCategory(category)
cat_min, cat_max = change.getMinMaxPermittedForCategory(category)
if v == 0:
val = ''
elif v > 0:
val = '%2i' % v
if v == cat_max:
val = ('max-label', val)
else:
val = ('positive-label', val)
else:
val = '%i' % v
if v == cat_min:
val = ('min-label', val)
else:
val = ('negative-label', val)
self.category_columns.append((urwid.Text(val),
self.columns.options('given', 2)))
self.updateColumns()
class ChangeListHeader(urwid.WidgetWrap, ChangeListColumns):
def __init__(self, enabled_columns):
self.enabled_columns = enabled_columns
self.subject = urwid.Text(u'Subject', wrap='clip')
self.number = urwid.Text(u'Number')
self.updated = urwid.Text(u'Updated')
self.size = urwid.Text(u'Size')
self.project = urwid.Text(u'Project', wrap='clip')
self.owner = urwid.Text(u'Owner', wrap='clip')
self.branch = urwid.Text(u'Branch', wrap='clip')
self.topic = urwid.Text(u'Topic', wrap='clip')
self.columns = urwid.Columns([], dividechars=1)
self.category_columns = []
super(ChangeListHeader, self).__init__(self.columns)
def update(self, categories):
self.category_columns = []
for category in categories:
self.category_columns.append((urwid.Text(' %s' % category[0]),
self._w.options('given', 2)))
self.updateColumns()
@mouse_scroll_decorator.ScrollByWheel
class ChangeListView(urwid.WidgetWrap, mywid.Searchable):
required_columns = set(['Number', 'Subject', 'Updated'])
optional_columns = set(['Topic', 'Branch', 'Size'])
def getCommands(self):
if self.project_key:
refresh_help = "Sync current project"
else:
refresh_help = "Sync subscribed projects"
return [
(keymap.TOGGLE_HELD,
"Toggle the held flag for the currently selected change"),
(keymap.LOCAL_CHECKOUT,
"Checkout the most recent revision of the selected change into the local repo"),
(keymap.TOGGLE_HIDDEN,
"Toggle the hidden flag for the currently selected change"),
(keymap.TOGGLE_LIST_REVIEWED,
"Toggle whether only unreviewed or all changes are displayed"),
(keymap.TOGGLE_REVIEWED,
"Toggle the reviewed flag for the currently selected change"),
(keymap.TOGGLE_STARRED,
"Toggle the starred flag for the currently selected change"),
(keymap.TOGGLE_MARK,
"Toggle the process mark for the currently selected change"),
(keymap.REFINE_CHANGE_SEARCH,
"Refine the current search query"),
(keymap.ABANDON_CHANGE,
"Abandon the marked changes"),
(keymap.EDIT_TOPIC,
"Set the topic of the marked changes"),
(keymap.RESTORE_CHANGE,
"Restore the marked changes"),
(keymap.REFRESH,
refresh_help),
(keymap.REVIEW,
"Leave reviews for the marked changes"),
(keymap.SORT_BY_NUMBER,
"Sort changes by number"),
(keymap.SORT_BY_UPDATED,
"Sort changes by how recently the change was updated"),
(keymap.SORT_BY_REVERSE,
"Reverse the sort"),
(keymap.LOCAL_CHERRY_PICK,
"Cherry-pick the most recent revision of the selected change onto the local repo"),
(keymap.INTERACTIVE_SEARCH,
"Interactive search"),
]
def help(self):
key = self.app.config.keymap.formatKeys
commands = self.getCommands()
return [(c[0], key(c[0]), c[1]) for c in commands]
def __init__(self, app, query, query_desc=None, project_key=None,
unreviewed=False, sort_by=None, reverse=None):
super(ChangeListView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('gertty.view.change_list')
self.searchInit()
self.app = app
self.query = query
self.query_desc = query_desc or query
self.unreviewed = unreviewed
self.change_rows = {}
self.enabled_columns = set()
for colinfo in COLUMNS:
if (colinfo.name in self.required_columns or
colinfo.name not in self.optional_columns):
self.enabled_columns.add(colinfo.name)
self.disabled_columns = set()
self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
self.project_key = project_key
if 'Project' not in self.required_columns and project_key is not None:
self.enabled_columns.discard('Project')
self.disabled_columns.add('Project')
if 'Owner' not in self.required_columns and 'owner:' in query:
# This could be or'd with something else, but probably
# not.
self.enabled_columns.discard('Owner')
self.disabled_columns.add('Owner')
self.sort_by = sort_by or app.config.change_list_options['sort-by']
if reverse is not None:
self.reverse = reverse
else:
self.reverse = app.config.change_list_options['reverse']
self.header = ChangeListHeader(self.enabled_columns)
self.categories = []
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 interested(self, event):
if not ((self.project_key is not None and
isinstance(event, sync.ChangeAddedEvent) and
self.project_key == event.project_key)
or
(self.project_key is None and
isinstance(event, sync.ChangeAddedEvent))
or
(isinstance(event, sync.ChangeUpdatedEvent) and
event.change_key in self.change_rows.keys())):
self.log.debug("Ignoring refresh change list due to event %s" % (event,))
return False
self.log.debug("Refreshing change list due to event %s" % (event,))
return True
def refresh(self):
unseen_keys = set(self.change_rows.keys())
with self.app.db.getSession() as session:
change_list = session.getChanges(self.query, self.unreviewed,
sort_by=self.sort_by)
if self.unreviewed:
self.title = (u'Unreviewed %d changes in %s' %
(len(change_list), self.query_desc))
else:
self.title = (u'All %d changes in %s' %
(len(change_list), self.query_desc))
self.short_title = self.query_desc
if '/' in self.short_title and ' ' not in self.short_title:
i = self.short_title.rfind('/')
self.short_title = self.short_title[i+1:]
self.app.status.update(title=self.title)
categories = set()
for change in change_list:
categories |= set(change.getCategories())
self.categories = sorted(categories)
self.chooseColumns()
self.header.update(self.categories)
i = 0
if self.reverse:
change_list.reverse()
if self.app.config.thread_changes:
change_list, prefixes = self._threadChanges(change_list)
else:
prefixes = {}
new_rows = []
if len(self.listbox.body):
focus_pos = self.listbox.focus_position
focus_row = self.listbox.body[focus_pos]
else:
focus_pos = 0
focus_row = None
for change in change_list:
row = self.change_rows.get(change.key)
if not row:
row = ChangeRow(self.app, change,
prefixes.get(change.key),
self.categories,
self.enabled_columns,
callback=self.onSelect)
self.listbox.body.insert(i, row)
self.change_rows[change.key] = row
else:
row.update(change, self.categories)
unseen_keys.remove(change.key)
new_rows.append(row)
i += 1
self.listbox.body[:] = new_rows
if focus_row in self.listbox.body:
pos = self.listbox.body.index(focus_row)
else:
pos = min(focus_pos, len(self.listbox.body)-1)
self.listbox.body.set_focus(pos)
for key in unseen_keys:
row = self.change_rows[key]
del self.change_rows[key]
def chooseColumns(self):
currently_enabled_columns = self.enabled_columns.copy()
size = self.app.loop.screen.get_cols_rows()
cols = size[0]
for colinfo in COLUMNS:
if (colinfo.name not in self.disabled_columns):
cols -= colinfo.spacing
cols -= 3 * len(self.categories)
for colinfo in COLUMNS:
if colinfo.name in self.optional_columns:
if cols >= colinfo.spacing:
self.enabled_columns.add(colinfo.name)
cols -= colinfo.spacing
else:
self.enabled_columns.discard(colinfo.name)
if currently_enabled_columns != self.enabled_columns:
self.header.updateColumns()
for key, value in six.iteritems(self.change_rows):
value.updateColumns()
def getQueryString(self):
if self.project_key is not None:
return "project:%s %s" % (self.query_desc, self.app.config.project_change_list_query)
return self.query
def _threadChanges(self, changes):
ret = []
prefixes = {}
stack = ThreadStack()
children = {}
commits = {}
orphans = changes[:]
for change in changes:
for revision in change.revisions:
commits[revision.commit] = change
for change in changes:
revision = change.revisions[-1]
parent = commits.get(revision.parent, None)
if parent:
if parent.revisions[-1].commit != revision.parent:
# Our parent is an outdated revision. This could
# cause a cycle, so skip. This change will not
# appear in the thread, but will still appear in
# the list. TODO: use color to indicate it
# depends on an outdated change.
continue
if change in orphans:
orphans.remove(change)
v = children.get(parent, [])
v.append(change)
children[parent] = v
if orphans:
change = orphans.pop(0)
else:
change = None
while change:
prefix = ''
stack_children = stack.countChildren()
for i, nchildren in enumerate(stack_children):
if nchildren:
if i+1 == len(stack_children):
prefix += u'\u251c'
else:
prefix += u'\u2502'
else:
if i+1 == len(stack_children):
prefix += u'\u2514'
else:
prefix += u' '
if i+1 == len(stack_children):
prefix += u'\u2500'
else:
prefix += u' '
subject = '%s%s' % (prefix, change.subject)
change._subject = subject
prefixes[change.key] = prefix
ret.append(change)
if change in children:
stack.push(change, children[change])
change = stack.pop()
if (not change) and orphans:
change = orphans.pop(0)
assert len(ret) == len(changes)
return (ret, prefixes)
def clearChangeList(self):
for key, value in six.iteritems(self.change_rows):
self.listbox.body.remove(value)
self.change_rows = {}
def getNextChangeKey(self, change_key):
row = self.change_rows.get(change_key)
try:
i = self.listbox.body.index(row)
except ValueError:
return None
if i+1 >= len(self.listbox.body):
return None
row = self.listbox.body[i+1]
return row.change_key
def getPrevChangeKey(self, change_key):
row = self.change_rows.get(change_key)
try:
i = self.listbox.body.index(row)
except ValueError:
return None
if i <= 0:
return None
row = self.listbox.body[i-1]
return row.change_key
def toggleReviewed(self, change_key):
with self.app.db.getSession() as session:
change = session.getChange(change_key)
change.reviewed = not change.reviewed
self.app.project_cache.clear(change.project)
ret = change.reviewed
reviewed_str = 'reviewed' if change.reviewed else 'unreviewed'
self.log.debug("Set change %s to %s", change_key, reviewed_str)
return ret
def toggleStarred(self, change_key):
with self.app.db.getSession() as session:
change = session.getChange(change_key)
change.starred = not change.starred
ret = change.starred
change.pending_starred = True
self.app.sync.submitTask(
sync.ChangeStarredTask(change_key, sync.HIGH_PRIORITY))
return ret
def toggleHeld(self, change_key):
return self.app.toggleHeldChange(change_key)
def toggleHidden(self, change_key):
with self.app.db.getSession() as session:
change = session.getChange(change_key)
change.hidden = not change.hidden
ret = change.hidden
hidden_str = 'hidden' if change.hidden else 'visible'
self.log.debug("Set change %s to %s", change_key, hidden_str)
return ret
def advance(self):
pos = self.listbox.focus_position
if pos < len(self.listbox.body)-1:
pos += 1
self.listbox.focus_position = pos
def keypress(self, size, key):
if self.searchKeypress(size, key):
return None
if not self.app.input_buffer:
key = super(ChangeListView, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
ret = self.handleCommands(commands)
if ret is True:
if keymap.FURTHER_INPUT not in commands:
self.app.clearInputBuffer()
return None
return key
def onResize(self):
self.chooseColumns()
def handleCommands(self, commands):
if keymap.TOGGLE_LIST_REVIEWED in commands:
self.unreviewed = not self.unreviewed
self.refresh()
return True
if keymap.TOGGLE_REVIEWED in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
change_key = self.listbox.body[pos].change_key
reviewed = self.toggleReviewed(change_key)
if self.unreviewed and reviewed:
# Here we can avoid a full refresh by just removing the particular
# row from the change list if the view is for the unreviewed changes
# only.
row = self.change_rows[change_key]
self.listbox.body.remove(row)
del self.change_rows[change_key]
else:
# Just fall back on doing a full refresh if we're in a situation
# where we're not just popping a row from the list of unreviewed
# changes.
self.refresh()
self.advance()
return True
if keymap.TOGGLE_HIDDEN in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
change_key = self.listbox.body[pos].change_key
hidden = self.toggleHidden(change_key)
if hidden:
# Here we can avoid a full refresh by just removing the particular
# row from the change list
row = self.change_rows[change_key]
self.listbox.body.remove(row)
del self.change_rows[change_key]
else:
# Just fall back on doing a full refresh if we're in a situation
# where we're not just popping a row from the list of changes.
self.refresh()
self.advance()
return True
if keymap.TOGGLE_HELD in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
change_key = self.listbox.body[pos].change_key
self.toggleHeld(change_key)
row = self.change_rows[change_key]
with self.app.db.getSession() as session:
change = session.getChange(change_key)
row.update(change, self.categories)
self.advance()
return True
if keymap.TOGGLE_STARRED in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
change_key = self.listbox.body[pos].change_key
self.toggleStarred(change_key)
row = self.change_rows[change_key]
with self.app.db.getSession() as session:
change = session.getChange(change_key)
row.update(change, self.categories)
self.advance()
return True
if keymap.TOGGLE_MARK in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
change_key = self.listbox.body[pos].change_key
row = self.change_rows[change_key]
row.mark = not row.mark
with self.app.db.getSession() as session:
change = session.getChange(change_key)
row.update(change, self.categories)
self.advance()
return True
if keymap.EDIT_TOPIC in commands:
self.editTopic()
return True
if keymap.REFRESH in commands:
if self.project_key:
self.app.sync.submitTask(
sync.SyncProjectTask(self.project_key, sync.HIGH_PRIORITY))
else:
self.app.sync.submitTask(
sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY))
self.app.status.update()
return True
if keymap.REVIEW in commands:
rows = [row for row in self.change_rows.values() if row.mark]
if not rows:
pos = self.listbox.focus_position
rows = [self.listbox.body[pos]]
self.openReview(rows)
return True
if keymap.SORT_BY_NUMBER in commands:
if not len(self.listbox.body):
return True
self.sort_by = 'number'
self.clearChangeList()
self.refresh()
return True
if keymap.SORT_BY_UPDATED in commands:
if not len(self.listbox.body):
return True
self.sort_by = 'updated'
self.clearChangeList()
self.refresh()
return True
if keymap.SORT_BY_REVERSE in commands:
if not len(self.listbox.body):
return True
if self.reverse:
self.reverse = False
else:
self.reverse = True
self.clearChangeList()
self.refresh()
return True
if keymap.LOCAL_CHECKOUT in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
row = self.listbox.body[pos]
self.app.localCheckoutCommit(row.project_name, row.commit_sha)
return True
if keymap.LOCAL_CHERRY_PICK in commands:
if not len(self.listbox.body):
return True
pos = self.listbox.focus_position
row = self.listbox.body[pos]
self.app.localCherryPickCommit(row.project_name, row.commit_sha)
return True
if keymap.REFINE_CHANGE_SEARCH in commands:
default = self.getQueryString()
self.app.searchDialog(default)
return True
if keymap.ABANDON_CHANGE in commands:
self.abandonChange()
return True
if keymap.RESTORE_CHANGE in commands:
self.restoreChange()
return True
if keymap.INTERACTIVE_SEARCH in commands:
self.searchStart()
return True
return False
def onSelect(self, button, change_key):
try:
view = view_change.ChangeView(self.app, change_key)
self.app.changeScreen(view)
except gertty.view.DisplayError as e:
self.app.error(str(e))
def openReview(self, rows):
dialog = view_change.ReviewDialog(self.app, rows[0].current_revision_key)
urwid.connect_signal(dialog, 'save',
lambda button: self.closeReview(dialog, rows, True, False))
urwid.connect_signal(dialog, 'submit',
lambda button: self.closeReview(dialog, rows, True, True))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.closeReview(dialog, rows, False, False))
self.app.popup(dialog,
relative_width=50, relative_height=75,
min_width=60, min_height=20)
def closeReview(self, dialog, rows, upload, submit):
approvals, message = dialog.getValues()
revision_keys = [row.current_revision_key for row in rows]
message_keys = self.app.saveReviews(revision_keys, approvals,
message, upload, submit)
if upload:
for message_key in message_keys:
self.app.sync.submitTask(
sync.UploadReviewTask(message_key, sync.HIGH_PRIORITY))
self.refresh()
self.app.backScreen()
def editTopic(self):
dialog = view_change.EditTopicDialog(self.app, '')
urwid.connect_signal(dialog, 'save',
lambda button: self.closeEditTopic(dialog, True))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.closeEditTopic(dialog, False))
self.app.popup(dialog)
def closeEditTopic(self, dialog, save):
if save:
rows = [row for row in self.change_rows.values() if row.mark]
if not rows:
pos = self.listbox.focus_position
rows = [self.listbox.body[pos]]
change_keys = [row.change_key for row in rows]
with self.app.db.getSession() as session:
for change_key in change_keys:
change = session.getChange(change_key)
change.topic = dialog.entry.edit_text
change.pending_topic = True
self.app.sync.submitTask(
sync.SetTopicTask(change_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def abandonChange(self):
dialog = mywid.TextEditDialog(u'Abandon Change', u'Abandon message:',
u'Abandon Change', u'')
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doAbandonRestoreChange(dialog, 'ABANDONED'))
self.app.popup(dialog)
def restoreChange(self):
dialog = mywid.TextEditDialog(u'Restore Change', u'Restore message:',
u'Restore Change', u'')
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doAbandonRestoreChange(dialog, 'NEW'))
self.app.popup(dialog)
def doAbandonRestoreChange(self, dialog, state):
rows = [row for row in self.change_rows.values() if row.mark]
if not rows:
pos = self.listbox.focus_position
rows = [self.listbox.body[pos]]
change_keys = [row.change_key for row in rows]
with self.app.db.getSession() as session:
for change_key in change_keys:
change = session.getChange(change_key)
change.status = state
change.pending_status = True
change.pending_status_message = dialog.entry.edit_text
self.app.sync.submitTask(
sync.ChangeStatusTask(change_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()

View File

@ -1,540 +0,0 @@
# 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 logging
import urwid
from gertty import gitrepo
from gertty import keymap
from gertty import mywid
from gertty import gitrepo
from gertty import sync
from gertty.view import mouse_scroll_decorator
class PatchsetDialog(urwid.WidgetWrap, mywid.LineBoxTitlePropertyMixin):
signals = ['ok', 'cancel']
def __init__(self, patchsets, old, new):
button_widgets = []
ok_button = mywid.FixedButton('OK')
cancel_button = mywid.FixedButton('Cancel')
urwid.connect_signal(ok_button, 'click',
lambda button:self._emit('ok'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
button_widgets.append(('pack', ok_button))
button_widgets.append(('pack', cancel_button))
button_columns = urwid.Columns(button_widgets, dividechars=2)
left = []
right = []
left.append(urwid.Text('Old'))
right.append(urwid.Text('New'))
self.old_buttons = []
self.new_buttons = []
self.patchset_keys = {}
oldb = mywid.FixedRadioButton(self.old_buttons, 'Base',
state=(old==None))
left.append(oldb)
right.append(urwid.Text(''))
self.patchset_keys[oldb] = None
for key, num in patchsets:
oldb = mywid.FixedRadioButton(self.old_buttons, 'Patchset %d' % num,
state=(old==key))
newb = mywid.FixedRadioButton(self.new_buttons, 'Patchset %d' % num,
state=(new==key))
left.append(oldb)
right.append(newb)
self.patchset_keys[oldb] = key
self.patchset_keys[newb] = key
left = urwid.Pile(left)
right = urwid.Pile(right)
table = urwid.Columns([left, right])
rows = []
rows.append(table)
rows.append(urwid.Divider())
rows.append(button_columns)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
title = 'Patchsets'
super(PatchsetDialog, self).__init__(urwid.LineBox(fill, title))
def getSelected(self):
old = new = None
for b in self.old_buttons:
if b.state:
old = self.patchset_keys[b]
break
for b in self.new_buttons:
if b.state:
new = self.patchset_keys[b]
break
return old, new
class LineContext(object):
def __init__(self, old_file_key, new_file_key,
old_fn, new_fn, old_ln, new_ln,
header=False):
self.old_file_key = old_file_key
self.new_file_key = new_file_key
self.old_fn = old_fn
self.new_fn = new_fn
self.old_ln = old_ln
self.new_ln = new_ln
self.header = header
class BaseDiffCommentEdit(urwid.Columns):
pass
class BaseDiffComment(urwid.Columns):
pass
class BaseDiffLine(urwid.Button):
def selectable(self):
return True
def search(self, search, attribute):
pass
class BaseFileHeader(urwid.Button):
def selectable(self):
return True
def search(self, search, attribute):
pass
class BaseFileReminder(urwid.WidgetWrap):
pass
class DiffContextButton(urwid.WidgetWrap):
def selectable(self):
return True
def __init__(self, view, diff, chunk):
focus_map={'context-button':'focused-context-button'}
buttons = [mywid.FixedButton(('context-button', "Expand previous 10"),
on_press=self.prev),
mywid.FixedButton(('context-button', "Expand"),
on_press=self.all),
mywid.FixedButton(('context-button', "Expand next 10"),
on_press=self.next)]
self._buttons = buttons
buttons = [('pack', urwid.AttrMap(b, None, focus_map=focus_map)) for b in buttons]
buttons = urwid.Columns([urwid.Text('')] + buttons + [urwid.Text('')],
dividechars=4)
buttons = urwid.AttrMap(buttons, 'context-button')
super(DiffContextButton, self).__init__(buttons)
self.view = view
self.diff = diff
self.chunk = chunk
self.update()
def update(self):
self._buttons[1].set_label("Expand %s lines of context" %
(len(self.chunk.lines)),)
def prev(self, button):
self.view.expandChunk(self.diff, self.chunk, from_start=10)
def all(self, button):
self.view.expandChunk(self.diff, self.chunk, expand_all=True)
def next(self, button):
self.view.expandChunk(self.diff, self.chunk, from_end=-10)
@mouse_scroll_decorator.ScrollByWheel
class BaseDiffView(urwid.WidgetWrap, mywid.Searchable):
def getCommands(self):
return [
(keymap.ACTIVATE,
"Add an inline comment"),
(keymap.SELECT_PATCHSETS,
"Select old/new patchsets to diff"),
(keymap.INTERACTIVE_SEARCH,
"Interactive search"),
]
def help(self):
key = self.app.config.keymap.formatKeys
commands = self.getCommands()
return [(c[0], key(c[0]), c[1]) for c in commands]
def __init__(self, app, new_revision_key):
super(BaseDiffView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('gertty.view.diff')
self.app = app
self.old_revision_key = None # Base
self.new_revision_key = new_revision_key
self._init()
def _init(self):
del self._w.contents[:]
self.searchInit()
with self.app.db.getSession() as session:
new_revision = session.getRevision(self.new_revision_key)
old_comments = []
new_comments = []
self.old_file_keys = {}
self.new_file_keys = {}
if self.old_revision_key is not None:
old_revision = session.getRevision(self.old_revision_key)
self.old_revision_num = old_revision.number
old_str = 'patchset %s' % self.old_revision_num
self.base_commit = old_revision.commit
for f in old_revision.files:
old_comments += f.comments
self.old_file_keys[f.path] = f.key
show_old_commit = True
else:
old_revision = None
self.old_revision_num = None
old_str = 'base'
self.base_commit = new_revision.parent
show_old_commit = False
# The old files are the same as the new files since we
# are diffing from base -> change, however, we should
# use the old file names for file lookup.
for f in new_revision.files:
if f.old_path:
self.old_file_keys[f.old_path] = f.key
else:
self.old_file_keys[f.path] = f.key
self.title = u'Diff of %s change %s from %s to patchset %s' % (
new_revision.change.project.name,
new_revision.change.number,
old_str, new_revision.number)
self.short_title = u'Diff of %s' % (new_revision.change.number,)
self.new_revision_num = new_revision.number
self.change_key = new_revision.change.key
self.project_name = new_revision.change.project.name
self.commit = new_revision.commit
for f in new_revision.files:
new_comments += f.comments
self.new_file_keys[f.path] = f.key
comment_lists = {}
comment_filenames = set()
for comment in new_comments:
path = comment.file.path
if comment.parent:
if old_revision: # we're not looking at the base
continue
key = 'old'
if comment.file.old_path:
path = comment.file.old_path
else:
key = 'new'
if comment.draft:
key += 'draft'
key += '-' + str(comment.line)
key += '-' + path
comment_list = comment_lists.get(key, [])
if comment.draft:
message = comment.message
else:
message = [('comment-name', comment.author.name),
('comment', u': '+comment.message)]
comment_list.append((comment.key, message))
comment_lists[key] = comment_list
comment_filenames.add(path)
for comment in old_comments:
if comment.parent:
continue
path = comment.file.path
key = 'old'
if comment.draft:
key += 'draft'
key += '-' + str(comment.line)
key += '-' + path
comment_list = comment_lists.get(key, [])
if comment.draft:
message = comment.message
else:
message = [('comment-name', comment.author.name),
('comment', u': '+comment.message)]
comment_list.append((comment.key, message))
comment_lists[key] = comment_list
comment_filenames.add(path)
repo = gitrepo.get_repo(self.project_name, self.app.config)
self._w.contents.append((self.app.header, ('pack', 1)))
self.file_reminder = self.makeFileReminder()
self._w.contents.append((self.file_reminder, ('pack', 1)))
lines = [] # The initial set of lines to display
self.file_diffs = [{}, {}] # Mapping of fn -> DiffFile object (old, new)
# this is a list of files:
diffs = repo.diff(self.base_commit, self.commit,
show_old_commit=show_old_commit)
for diff in diffs:
comment_filenames.discard(diff.oldname)
comment_filenames.discard(diff.newname)
# There are comments referring to these files which do not
# appear in the diff so we should create fake diff objects
# that contain the full text.
for filename in comment_filenames:
diff = repo.getFile(self.base_commit, self.commit, filename)
if diff:
diffs.append(diff)
else:
self.log.debug("Unable to find file %s in commit %s" % (filename, self.commit))
for i, diff in enumerate(diffs):
if i > 0:
lines.append(urwid.Text(''))
self.file_diffs[gitrepo.OLD][diff.oldname] = diff
self.file_diffs[gitrepo.NEW][diff.newname] = diff
lines.extend(self.makeFileHeader(diff, comment_lists))
for chunk in diff.chunks:
if chunk.context:
if not chunk.first:
lines += self.makeLines(diff, chunk.lines[:10], comment_lists)
del chunk.lines[:10]
button = DiffContextButton(self, diff, chunk)
chunk.button = button
lines.append(button)
if not chunk.last:
lines += self.makeLines(diff, chunk.lines[-10:], comment_lists)
del chunk.lines[-10:]
chunk.calcRange()
chunk.button.update()
if not chunk.lines:
lines.remove(button)
else:
lines += self.makeLines(diff, chunk.lines, comment_lists)
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)
self.handleUndisplayedComments(comment_lists)
self.app.status.update(title=self.title)
def handleUndisplayedComments(self, comment_lists):
# Handle comments that landed outside our default diff context
lastlen = 0
while comment_lists:
if len(comment_lists.keys()) == lastlen:
self.log.error("Unable to display all comments: %s" % comment_lists)
return
lastlen = len(comment_lists.keys())
key = comment_lists.keys()[0]
kind, lineno, path = key.split('-', 2)
lineno = int(lineno)
if kind.startswith('old'):
oldnew = gitrepo.OLD
else:
oldnew = gitrepo.NEW
file_diffs = self.file_diffs[oldnew]
if path not in file_diffs:
self.log.error("Unable to display comment: %s" % key)
del comment_lists[key]
continue
diff = self.file_diffs[oldnew][path]
for chunk in diff.chunks:
if (chunk.range[oldnew][gitrepo.START] <= lineno and
chunk.range[oldnew][gitrepo.END] >= lineno):
i = chunk.indexOfLine(oldnew, lineno)
if i < (len(chunk.lines) / 2):
from_start = True
else:
from_start = False
if chunk.first and from_start:
from_start = False
if chunk.last and (not from_start):
from_start = True
if from_start:
self.expandChunk(diff, chunk, comment_lists, from_start=i+10)
else:
self.expandChunk(diff, chunk, comment_lists, from_end=0-(len(chunk.lines)-i)-10)
break
def expandChunk(self, diff, chunk, comment_lists={}, from_start=None, from_end=None,
expand_all=None):
self.log.debug("Expand chunk %s %s %s" % (chunk, from_start, from_end))
add_lines = []
if from_start is not None:
index = self.listbox.body.index(chunk.button)
add_lines = chunk.lines[:from_start]
del chunk.lines[:from_start]
if from_end is not None:
index = self.listbox.body.index(chunk.button)+1
add_lines = chunk.lines[from_end:]
del chunk.lines[from_end:]
if expand_all:
index = self.listbox.body.index(chunk.button)
add_lines = chunk.lines[:]
del chunk.lines[:]
if add_lines:
lines = self.makeLines(diff, add_lines, comment_lists)
self.listbox.body[index:index] = lines
chunk.calcRange()
if not chunk.lines:
self.listbox.body.remove(chunk.button)
else:
chunk.button.update()
def makeContext(self, diff, old_ln, new_ln, header=False):
old_key = None
new_key = None
if not diff.old_empty:
if diff.oldname in self.old_file_keys:
old_key = self.old_file_keys[diff.oldname]
elif diff.newname in self.old_file_keys:
old_key = self.old_file_keys[diff.newname]
if not diff.new_empty:
new_key = self.new_file_keys.get(diff.newname)
return LineContext(
old_key, new_key,
diff.oldname, diff.newname,
old_ln, new_ln, header)
def makeLines(self, diff, lines_to_add, comment_lists):
raise NotImplementedError
def makeFileHeader(self, diff, comment_lists):
raise NotImplementedError
def makeFileReminder(self):
raise NotImplementedError
def interested(self, event):
if not ((isinstance(event, sync.ChangeAddedEvent) and
self.change_key in event.related_change_keys)
or
(isinstance(event, sync.ChangeUpdatedEvent) and
self.change_key in event.related_change_keys)):
#self.log.debug("Ignoring refresh diff due to event %s" % (event,))
return False
#self.log.debug("Refreshing diff due to event %s" % (event,))
return True
def refresh(self, event=None):
#TODO
pass
def getContextAtTop(self, size):
middle, top, bottom = self.listbox.calculate_visible(size, True)
if top and top[1]:
(widget, pos, rows) = top[1][-1]
elif middle:
pos = middle[2]
# Make sure the first header shows up as soon as it scrolls up
if pos > 1:
pos -= 1
context = None
while True:
item = self.listbox.body[pos]
if hasattr(item, 'context'):
break
pos -= 1
if pos > 0:
context = item.context
return context
def keypress(self, size, key):
if self.searchKeypress(size, key):
return None
old_focus = self.listbox.focus
if not self.app.input_buffer:
key = super(BaseDiffView, self).keypress(size, key)
new_focus = self.listbox.focus
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
context = self.getContextAtTop(size)
if context:
self.file_reminder.set(context.old_fn,
context.new_fn)
else:
self.file_reminder.set('', '')
if (isinstance(old_focus, BaseDiffCommentEdit) and
(old_focus != new_focus or (keymap.PREV_SCREEN in commands))):
self.cleanupEdit(old_focus)
if keymap.SELECT_PATCHSETS in commands:
self.openPatchsetDialog()
return None
if keymap.INTERACTIVE_SEARCH in commands:
self.searchStart()
return None
return key
def mouse_event(self, size, event, button, x, y, focus):
old_focus = self.listbox.focus
r = super(BaseDiffView, self).mouse_event(size, event, button, x, y, focus)
new_focus = self.listbox.focus
if old_focus != new_focus and isinstance(old_focus, BaseDiffCommentEdit):
self.cleanupEdit(old_focus)
return r
def makeCommentEdit(self, edit):
raise NotImplementedError
def onSelect(self, button):
pos = self.listbox.focus_position
e = self.makeCommentEdit(self.listbox.body[pos])
self.listbox.body.insert(pos+1, e)
self.listbox.focus_position = pos+1
def cleanupEdit(self, edit):
raise NotImplementedError
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 self.old_revision_num):
parent = True
else:
parent = False
if new:
line_num = context.new_ln
file_key = context.new_file_key
else:
line_num = context.old_ln
file_key = context.old_file_key
if file_key is None:
raise Exception("Comment is not associated with a file")
with self.app.db.getSession() as session:
fileojb = session.getFile(file_key)
account = session.getAccountByUsername(self.app.config.username)
comment = fileojb.createComment(None, account, None,
datetime.datetime.utcnow(),
parent,
line_num, text, draft=True)
key = comment.key
return key
def openPatchsetDialog(self):
revisions = []
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
for r in change.revisions:
revisions.append((r.key, r.number))
dialog = PatchsetDialog(revisions,
self.old_revision_key,
self.new_revision_key)
urwid.connect_signal(dialog, 'cancel',
lambda button: self.app.backScreen())
urwid.connect_signal(dialog, 'ok',
lambda button: self._openPatchsetDialog(dialog))
self.app.popup(dialog, min_width=30, min_height=8)
def _openPatchsetDialog(self, dialog):
self.app.backScreen()
self.old_revision_key, self.new_revision_key = dialog.getSelected()
self._init()

View File

@ -1,241 +0,0 @@
# 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
from gertty import keymap
from gertty import mywid
from gertty.view.diff import BaseDiffComment, BaseDiffCommentEdit, BaseDiffLine
from gertty.view.diff import BaseFileHeader, BaseFileReminder, BaseDiffView
LN_COL_WIDTH = 5
class SideDiffCommentEdit(BaseDiffCommentEdit):
def __init__(self, app, context, old_key=None, new_key=None, old=u'', new=u''):
super(SideDiffCommentEdit, self).__init__([])
self.app = app
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 = mywid.MyEdit(edit_text=old, multiline=True, ring=app.ring)
self.new = mywid.MyEdit(edit_text=new, multiline=True, ring=app.ring)
self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False)))
if context.old_file_key and (context.old_ln is not None or context.header):
self.contents.append((urwid.AttrMap(self.old, 'draft-comment'), ('weight', 1, False)))
else:
self.contents.append((urwid.Text(u''), ('weight', 1, False)))
self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False)))
if context.new_file_key and (context.new_ln is not None or context.header):
self.contents.append((urwid.AttrMap(self.new, 'draft-comment'), ('weight', 1, False)))
new_editable = True
else:
self.contents.append((urwid.Text(u''), ('weight', 1, False)))
new_editable = False
if new_editable:
self.focus_position = 3
else:
self.focus_position = 1
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(SideDiffCommentEdit, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if ((keymap.NEXT_SELECTABLE in commands) or
(keymap.PREV_SELECTABLE in commands)):
if ((self.context.old_ln is not None and
self.context.new_ln is not None) or
self.context.header):
if self.focus_position == 3:
self.focus_position = 1
else:
self.focus_position = 3
return None
return key
class SideDiffComment(BaseDiffComment):
def __init__(self, context, old, new):
super(SideDiffComment, self).__init__([])
self.context = context
oldt = urwid.Text(old)
newt = urwid.Text(new)
if old:
oldt = urwid.AttrMap(oldt, 'comment')
if new:
newt = urwid.AttrMap(newt, 'comment')
self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False)))
self.contents.append((oldt, ('weight', 1, False)))
self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False)))
self.contents.append((newt, ('weight', 1, False)))
class SideDiffLine(BaseDiffLine):
def __init__(self, app, context, old, new, callback=None):
super(SideDiffLine, self).__init__('', on_press=callback)
self.context = context
self.text_widgets = []
columns = []
for (ln, action, line) in (old, new):
if ln is None:
ln = ''
else:
ln = '%*i' % (LN_COL_WIDTH-1, ln)
ln_col = urwid.Text(('line-number', ln))
ln_col.set_wrap_mode('clip')
line_col = mywid.SearchableText(line)
self.text_widgets.append(line_col)
if action == '':
line_col = urwid.AttrMap(line_col, 'nonexistent')
columns += [(LN_COL_WIDTH, ln_col), line_col]
col = urwid.Columns(columns)
map = {None: 'focused',
'added-line': 'focused-added-line',
'added-word': 'focused-added-word',
'removed-line': 'focused-removed-line',
'removed-word': 'focused-removed-word',
'nonexistent': 'focused-nonexistent',
'line-number': 'focused-line-number',
}
self._w = urwid.AttrMap(col, None, focus_map=map)
def search(self, search, attribute):
ret = False
for w in self.text_widgets:
if w.search(search, attribute):
ret = True
return ret
class SideFileHeader(BaseFileHeader):
def __init__(self, app, context, old, new, callback=None):
super(SideFileHeader, self).__init__('', on_press=callback)
self.context = context
col = urwid.Columns([
urwid.Text(('filename', old)),
urwid.Text(('filename', new))])
map = {None: 'focused-filename',
'filename': 'focused-filename'}
self._w = urwid.AttrMap(col, None, focus_map=map)
class SideFileReminder(BaseFileReminder):
def __init__(self):
self.old_text = urwid.Text(('filename', ''))
self.new_text = urwid.Text(('filename', ''))
col = urwid.Columns([self.old_text, self.new_text])
super(SideFileReminder, self).__init__(col)
def set(self, old, new):
self.old_text.set_text(('filename', old))
self.new_text.set_text(('filename', new))
class SideDiffView(BaseDiffView):
def makeLines(self, diff, lines_to_add, comment_lists):
lines = []
for old, new in lines_to_add:
context = self.makeContext(diff, old[0], new[0])
lines.append(SideDiffLine(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.pop(key, [])
key = 'new-%s-%s' % (new[0], diff.newname)
new_list = comment_lists.pop(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(SideDiffComment(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.pop(key, [])
key = 'newdraft-%s-%s' % (new[0], diff.newname)
new_list = comment_lists.pop(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(SideDiffCommentEdit(self.app, context,
old_comment_key,
new_comment_key,
old_comment, new_comment))
return lines
def makeFileReminder(self):
return SideFileReminder()
def makeFileHeader(self, diff, comment_lists):
context = self.makeContext(diff, None, None, header=True)
lines = []
lines.append(SideFileHeader(self.app, context, diff.oldname, diff.newname,
callback=self.onSelect))
# see if there are any comments for this file
key = 'old-None-%s' % (diff.oldname,)
old_list = comment_lists.pop(key, [])
key = 'new-None-%s' % (diff.newname,)
new_list = comment_lists.pop(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(SideDiffComment(context, old_comment, new_comment))
# see if there are any draft comments for this file
key = 'olddraft-None-%s' % (diff.oldname,)
old_list = comment_lists.pop(key, [])
key = 'newdraft-None-%s' % (diff.newname,)
new_list = comment_lists.pop(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(SideDiffCommentEdit(self.app, context,
old_comment_key,
new_comment_key,
old_comment, new_comment))
return lines
def makeCommentEdit(self, edit):
return SideDiffCommentEdit(self.app, edit.context)
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)

View File

@ -1,262 +0,0 @@
# Copyright 2014 OpenStack Foundation
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# 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
from gertty import gitrepo
from gertty import mywid
from gertty.view.diff import BaseDiffCommentEdit, BaseDiffComment, BaseDiffLine
from gertty.view.diff import BaseFileHeader, BaseFileReminder, BaseDiffView
LN_COL_WIDTH = 5
class UnifiedDiffCommentEdit(BaseDiffCommentEdit):
def __init__(self, app, context, oldnew, key=None, comment=u''):
super(UnifiedDiffCommentEdit, self).__init__([])
self.context = context
self.oldnew = oldnew
# If we save a comment, the resulting key will be stored here
self.key = key
self.comment = mywid.MyEdit(edit_text=comment, multiline=True,
ring=app.ring)
self.contents.append((urwid.Text(u''), ('given', 8, False)))
self.contents.append((urwid.AttrMap(self.comment, 'draft-comment'),
('weight', 1, False)))
self.focus_position = 1
class UnifiedDiffComment(BaseDiffComment):
def __init__(self, context, oldnew, comment):
super(UnifiedDiffComment, self).__init__([])
self.context = context
text = urwid.AttrMap(urwid.Text(comment), 'comment')
self.contents.append((urwid.Text(u''), ('given', 8, False)))
self.contents.append((text, ('weight', 1, False)))
class UnifiedDiffLine(BaseDiffLine):
def __init__(self, app, context, oldnew, old, new, callback=None):
super(UnifiedDiffLine, self).__init__('', on_press=callback)
self.context = context
self.oldnew = oldnew
(old_ln, old_action, old_line) = old
(new_ln, new_action, new_line) = new
if old_ln is None:
old_ln = ''
else:
old_ln = '%*i' % (LN_COL_WIDTH-1, old_ln)
if new_ln is None:
new_ln = ''
else:
new_ln = '%*i' % (LN_COL_WIDTH-1, new_ln)
old_ln_col = urwid.Text(('line-number', old_ln))
old_ln_col.set_wrap_mode('clip')
new_ln_col = urwid.Text(('line-number', new_ln))
new_ln_col.set_wrap_mode('clip')
if oldnew == gitrepo.OLD:
action = old_action
line = old_line
columns = [(LN_COL_WIDTH, old_ln_col), (LN_COL_WIDTH, urwid.Text(u''))]
elif oldnew == gitrepo.NEW:
action = new_action
line = new_line
columns = [(LN_COL_WIDTH, urwid.Text(u'')), (LN_COL_WIDTH, new_ln_col)]
if new_action == ' ':
columns = [(LN_COL_WIDTH, old_ln_col), (LN_COL_WIDTH, new_ln_col)]
line_col = mywid.SearchableText(line)
self.text_widget = line_col
if action == '':
line_col = urwid.AttrMap(line_col, 'nonexistent')
columns += [line_col]
col = urwid.Columns(columns)
map = {None: 'focused',
'added-line': 'focused-added-line',
'added-word': 'focused-added-word',
'removed-line': 'focused-removed-line',
'removed-word': 'focused-removed-word',
'nonexistent': 'focused-nonexistent',
'line-number': 'focused-line-number',
}
self._w = urwid.AttrMap(col, None, focus_map=map)
def search(self, search, attribute):
return self.text_widget.search(search, attribute)
class UnifiedFileHeader(BaseFileHeader):
def __init__(self, app, context, oldnew, old, new, callback=None):
super(UnifiedFileHeader, self).__init__('', on_press=callback)
self.context = context
self.oldnew = oldnew
if oldnew == gitrepo.OLD:
col = urwid.Columns([
urwid.Text(('filename', old))])
elif oldnew == gitrepo.NEW:
col = urwid.Columns([
(LN_COL_WIDTH, urwid.Text(u'')),
urwid.Text(('filename', new))])
map = {None: 'focused-filename',
'filename': 'focused-filename'}
self._w = urwid.AttrMap(col, None, focus_map=map)
class UnifiedFileReminder(BaseFileReminder):
def __init__(self):
self.old_text = urwid.Text(('filename', ''))
self.new_text = urwid.Text(('filename', ''))
self.col = urwid.Columns([('pack', self.old_text),
('pack', self.new_text),
urwid.Text(u'')], dividechars=2)
super(UnifiedFileReminder, self).__init__(self.col)
def set(self, old, new):
self.old_text.set_text(('filename', old))
self.new_text.set_text(('filename', new))
self.col._invalidate()
class UnifiedDiffView(BaseDiffView):
def makeLines(self, diff, lines_to_add, comment_lists):
lines = []
old_cache = []
new_cache = []
for old, new in lines_to_add:
context = self.makeContext(diff, old[0], new[0])
if context.old_ln is not None:
old_cache.append(UnifiedDiffLine(self.app, context, gitrepo.OLD, old, new,
callback=self.onSelect))
else:
lines.extend(old_cache)
lines.extend(new_cache)
old_cache = []
new_cache = []
# see if there are any comments for this line
key = 'old-%s-%s' % (old[0], diff.oldname)
old_list = comment_lists.pop(key, [])
while old_list:
(old_comment_key, old_comment) = old_list.pop(0)
old_cache.append(UnifiedDiffComment(context, gitrepo.OLD, old_comment))
# see if there are any draft comments for this line
key = 'olddraft-%s-%s' % (old[0], diff.oldname)
old_list = comment_lists.pop(key, [])
while old_list:
(old_comment_key, old_comment) = old_list.pop(0)
old_cache.append(UnifiedDiffCommentEdit(self.app,
context,
gitrepo.OLD,
old_comment_key,
old_comment))
# new line
if context.new_ln is not None and new[1] != ' ':
if old_cache:
new_cache.append(UnifiedDiffLine(self.app, context, gitrepo.NEW, old, new,
callback=self.onSelect))
else:
lines.append(UnifiedDiffLine(self.app, context, gitrepo.NEW, old, new,
callback=self.onSelect))
# see if there are any comments for this line
key = 'new-%s-%s' % (new[0], diff.newname)
new_list = comment_lists.pop(key, [])
while new_list:
(new_comment_key, new_comment) = new_list.pop(0)
if old_cache:
new_cache.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment))
else:
lines.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment))
# see if there are any draft comments for this line
key = 'newdraft-%s-%s' % (new[0], diff.newname)
new_list = comment_lists.pop(key, [])
while new_list:
(new_comment_key, new_comment) = new_list.pop(0)
if old_cache:
new_cache.append(UnifiedDiffCommentEdit(self.app,
context,
gitrepo.NEW,
new_comment_key,
new_comment))
else:
lines.append(UnifiedDiffCommentEdit(self.app,
context,
gitrepo.NEW,
new_comment_key,
new_comment))
else:
if old_cache:
lines.extend(old_cache)
if new_cache:
lines.extend(new_cache)
return lines
def makeFileReminder(self):
return UnifiedFileReminder()
def makeFileHeader(self, diff, comment_lists):
context = self.makeContext(diff, None, None, header=True)
lines = []
lines.append(UnifiedFileHeader(self.app, context, gitrepo.OLD,
diff.oldname, diff.newname,
callback=self.onSelect))
# see if there are any comments for this file
key = 'old-None-%s' % (diff.oldname,)
old_list = comment_lists.pop(key, [])
while old_list:
(old_comment_key, old_comment) = old_list.pop(0)
lines.append(UnifiedDiffComment(context, gitrepo.OLD, old_comment))
# see if there are any draft comments for this file
key = 'olddraft-None-%s' % (diff.oldname,)
old_list = comment_lists.pop(key, [])
while old_list:
(old_comment_key, old_comment) = old_list.pop(0)
lines.append(UnifiedDiffCommentEdit(self.app,
context,
gitrepo.OLD,
old_comment_key,
old_comment))
# new line
lines.append(UnifiedFileHeader(self.app, context, gitrepo.NEW,
diff.oldname, diff.newname,
callback=self.onSelect))
# see if there are any comments for this file
key = 'new-None-%s' % (diff.newname,)
new_list = comment_lists.pop(key, [])
while new_list:
(new_comment_key, new_comment) = new_list.pop(0)
lines.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment))
# see if there are any draft comments for this file
key = 'newdraft-None-%s' % (diff.newname,)
new_list = comment_lists.pop(key, [])
while new_list:
(new_comment_key, new_comment) = new_list.pop(0)
lines.append(UnifiedDiffCommentEdit(self.app,
context,
gitrepo.NEW,
new_comment_key,
new_comment))
return lines
def makeCommentEdit(self, edit):
return UnifiedDiffCommentEdit(self.app,
edit.context,
edit.oldnew)
def cleanupEdit(self, edit):
if edit.key:
self.deleteComment(edit.key)
edit.key = None
comment = edit.comment.edit_text.strip()
if comment:
new = False
if edit.oldnew == gitrepo.NEW:
new = True
edit.key = self.saveComment(
edit.context, comment, new=new)
else:
self.listbox.body.remove(edit)

View File

@ -2,7 +2,6 @@ pbr>=0.11,<2.0
urwid>=1.2.1,!=1.3.0
SQLAlchemy>=1.0.4
GitPython>=0.3.7
python-dateutil
requests>=2.5.3,<3.0.0
ordereddict

View File

@ -1,6 +1,6 @@
[metadata]
name = gertty
summary = Gertty is a console-based interface to the Gerrit Code Review system.
name = boartty
summary = Boardtty is a console-based interface to Storyboard.
description-file =
README.rst
author = OpenStack
@ -16,14 +16,14 @@ classifier =
Programming Language :: Python
Programming Language :: Python :: 2
Programming Language :: Python :: 2.7
keywords = gerrit console urwid review
keywords = storyboard console urwid
[files]
packages =
gertty
boartty
data_files =
share/gertty/examples = examples/*
share/boartty/examples = examples/*
[entry_points]
console_scripts =
gertty = gertty.app:main
boartty = boartty.app:main

View File

@ -1,10 +1,10 @@
# The official style guide for Gertty is this:
# The official style guide for Boartty is this:
#
# Try to match the existing code style and don't worry about it
# too much.
#
# Please don't submit changes to enable pep8 style checks or change
# the code to match pep8 guidelines. Gertty should be fun to work on
# the code to match pep8 guidelines. Boartty should be fun to work on
# and it shouldn't be hard to go with the flow and not worry too much
# about whitespace.
#