Browse Source

Initial fork from gertty -> boartty

Change-Id: I8c0ce5550f2287f77fb31c790c3923d3d1b80481
changes/91/391991/2
James E. Blair 3 years ago
parent
commit
e4c972b803
75 changed files with 4318 additions and 8645 deletions
  1. 1
    1
      .gitignore
  2. 1
    1
      .gitreview
  3. 14
    22
      CONTRIBUTING.rst
  4. 57
    120
      README.rst
  5. 0
    0
      boartty/__init__.py
  6. 1
    1
      boartty/alembic.ini
  7. 0
    0
      boartty/alembic/README
  8. 2
    2
      boartty/alembic/env.py
  9. 0
    0
      boartty/alembic/script.py.mako
  10. 193
    0
      boartty/alembic/versions/183755ac91df_initial_schema.py
  11. 81
    107
      boartty/app.py
  12. 1
    1
      boartty/commentlink.py
  13. 40
    54
      boartty/config.py
  14. 676
    0
      boartty/db.py
  15. 1
    11
      boartty/dbsupport.py
  16. 27
    42
      boartty/keymap.py
  17. 91
    7
      boartty/mywid.py
  18. 103
    0
      boartty/palette.py
  19. 0
    0
      boartty/requestsexceptions.py
  20. 14
    14
      boartty/search/__init__.py
  21. 109
    104
      boartty/search/parser.py
  22. 5
    5
      boartty/search/tokenizer.py
  23. 1137
    0
      boartty/sync.py
  24. 1
    1
      boartty/version.py
  25. 0
    0
      boartty/view/__init__.py
  26. 0
    0
      boartty/view/mouse_scroll_decorator.py
  27. 39
    53
      boartty/view/project_list.py
  28. 837
    0
      boartty/view/story.py
  29. 513
    0
      boartty/view/story_list.py
  30. 4
    4
      doc/Makefile
  31. 11
    11
      doc/source/conf.py
  32. 69
    175
      doc/source/configuration.rst
  33. 2
    2
      doc/source/contributing.rst
  34. 9
    14
      doc/source/index.rst
  35. 4
    38
      doc/source/installation.rst
  36. 23
    33
      doc/source/usage.rst
  37. 0
    88
      examples/googlesource-gertty.yaml
  38. 15
    0
      examples/minimal-boartty.yaml
  39. 0
    13
      examples/minimal-gertty.yaml
  40. 42
    0
      examples/openstack-boartty.yaml
  41. 0
    142
      examples/openstack-gertty.yaml
  42. 187
    0
      examples/reference-boartty.yaml
  43. 0
    248
      examples/reference-gertty.yaml
  44. 0
    26
      gertty/alembic/versions/1bb187bcd401_add_query_sync_table.py
  45. 0
    22
      gertty/alembic/versions/1cdd4e2e74c_add_revision_indexes.py
  46. 0
    64
      gertty/alembic/versions/254ac5fc3941_attach_comments_to_files.py
  47. 0
    25
      gertty/alembic/versions/2a11dd14665_fix_account_table.py
  48. 0
    36
      gertty/alembic/versions/312cd5a9f878_add_can_submit_column.py
  49. 0
    27
      gertty/alembic/versions/3610c2543e07_add_conflicts_table.py
  50. 0
    26
      gertty/alembic/versions/37a702b7f58e_add_last_seen_column_to_change.py
  51. 0
    33
      gertty/alembic/versions/38104b4c1b84_added_project_updated_column.py
  52. 0
    37
      gertty/alembic/versions/3cc7e3753dc3_add_hold.py
  53. 0
    49
      gertty/alembic/versions/3d429503a29a_add_draft_fields.py
  54. 0
    35
      gertty/alembic/versions/4388de50824a_add_topic_table.py
  55. 0
    176
      gertty/alembic/versions/44402069e137_initial_schema.py
  56. 0
    66
      gertty/alembic/versions/46b175bfa277_add_pending_actions.py
  57. 0
    41
      gertty/alembic/versions/4a802b741d2f_add_starred.py
  58. 0
    73
      gertty/alembic/versions/4cc9c46f9d8b_add_account_table.py
  59. 0
    94
      gertty/alembic/versions/50344aecd1c2_add_files_table.py
  60. 0
    26
      gertty/alembic/versions/56e48a4a064a_increase_status_field_width.py
  61. 0
    41
      gertty/alembic/versions/725816dc500_add_fetch_ref_column.py
  62. 0
    37
      gertty/alembic/versions/7ef7dfa2ca3a_add_change_outdated.py
  63. 0
    69
      gertty/auth.py
  64. 0
    1006
      gertty/db.py
  65. 0
    540
      gertty/gitrepo.py
  66. 0
    140
      gertty/palette.py
  67. 0
    1577
      gertty/sync.py
  68. 0
    1169
      gertty/view/change.py
  69. 0
    844
      gertty/view/change_list.py
  70. 0
    540
      gertty/view/diff.py
  71. 0
    241
      gertty/view/side_diff.py
  72. 0
    262
      gertty/view/unified_diff.py
  73. 0
    1
      requirements.txt
  74. 6
    6
      setup.cfg
  75. 2
    2
      tox.ini

+ 1
- 1
.gitignore View File

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

+ 1
- 1
.gitreview View File

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

+ 14
- 22
CONTRIBUTING.rst 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.

+ 57
- 120
README.rst 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 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.
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.

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/

gertty/__init__.py → boartty/__init__.py View File


gertty/alembic.ini → boartty/alembic.ini 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

gertty/alembic/README → boartty/alembic/README View File


gertty/alembic/env.py → boartty/alembic/env.py 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:

gertty/alembic/script.py.mako → boartty/alembic/script.py.mako View File


+ 193
- 0
boartty/alembic/versions/183755ac91df_initial_schema.py 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

gertty/app.py → boartty/app.py 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 saveNewStory(self, dialog):
with self.db.getSession() as session:
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)

self.sync.submitTask(
sync.UpdateStoryTask(story.key, sync.HIGH_PRIORITY))
self.sync.submitTask(
sync.UpdateTaskTask(task.key, sync.HIGH_PRIORITY))
self.backScreen()

def saveReviews(self, revision_keys, approvals, message, upload, submit):
message_keys = []
def initSystemData(self):
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

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

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
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',

gertty/commentlink.py → boartty/commentlink.py View File

@@ -22,7 +22,7 @@ import re
import six
import urwid

from gertty import mywid
from boartty import mywid

try:
OrderedDict = collections.OrderedDict

gertty/config.py → boartty/config.py 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
- 0
boartty/db.py 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


gertty/dbsupport.py → boartty/dbsupport.py 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.

gertty/keymap.py → boartty/keymap.py 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',

gertty/mywid.py → boartty/mywid.py 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):