From e4c972b80314286c8b8ce5c2e0e033cd038f7080 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Sat, 15 Oct 2016 07:31:19 -0700 Subject: [PATCH] Initial fork from gertty -> boartty Change-Id: I8c0ce5550f2287f77fb31c790c3923d3d1b80481 --- .gitignore | 2 +- .gitreview | 2 +- CONTRIBUTING.rst | 36 +- README.rst | 177 +- {gertty => boartty}/__init__.py | 0 {gertty => boartty}/alembic.ini | 2 +- {gertty => boartty}/alembic/README | 0 {gertty => boartty}/alembic/env.py | 4 +- {gertty => boartty}/alembic/script.py.mako | 0 .../versions/183755ac91df_initial_schema.py | 193 ++ {gertty => boartty}/app.py | 184 +- {gertty => boartty}/commentlink.py | 2 +- {gertty => boartty}/config.py | 94 +- boartty/db.py | 676 +++++++ {gertty => boartty}/dbsupport.py | 12 +- {gertty => boartty}/keymap.py | 69 +- {gertty => boartty}/mywid.py | 98 +- boartty/palette.py | 103 ++ {gertty => boartty}/requestsexceptions.py | 0 {gertty => boartty}/search/__init__.py | 28 +- {gertty => boartty}/search/parser.py | 213 +-- {gertty => boartty}/search/tokenizer.py | 10 +- boartty/sync.py | 1137 ++++++++++++ {gertty => boartty}/version.py | 2 +- {gertty => boartty}/view/__init__.py | 0 .../view/mouse_scroll_decorator.py | 0 {gertty => boartty}/view/project_list.py | 92 +- boartty/view/story.py | 837 +++++++++ boartty/view/story_list.py | 513 ++++++ doc/Makefile | 8 +- doc/source/conf.py | 22 +- doc/source/configuration.rst | 244 +-- doc/source/contributing.rst | 4 +- doc/source/index.rst | 23 +- doc/source/installation.rst | 42 +- doc/source/usage.rst | 56 +- examples/googlesource-gertty.yaml | 88 - examples/minimal-boartty.yaml | 15 + examples/minimal-gertty.yaml | 13 - examples/openstack-boartty.yaml | 42 + examples/openstack-gertty.yaml | 142 -- examples/reference-boartty.yaml | 187 ++ examples/reference-gertty.yaml | 248 --- .../1bb187bcd401_add_query_sync_table.py | 26 - .../1cdd4e2e74c_add_revision_indexes.py | 22 - .../254ac5fc3941_attach_comments_to_files.py | 64 - .../versions/2a11dd14665_fix_account_table.py | 25 - .../312cd5a9f878_add_can_submit_column.py | 36 - .../3610c2543e07_add_conflicts_table.py | 27 - ...02b7f58e_add_last_seen_column_to_change.py | 26 - ...104b4c1b84_added_project_updated_column.py | 33 - .../alembic/versions/3cc7e3753dc3_add_hold.py | 37 - .../versions/3d429503a29a_add_draft_fields.py | 49 - .../versions/4388de50824a_add_topic_table.py | 35 - .../versions/44402069e137_initial_schema.py | 176 -- .../46b175bfa277_add_pending_actions.py | 66 - .../versions/4a802b741d2f_add_starred.py | 41 - .../4cc9c46f9d8b_add_account_table.py | 73 - .../versions/50344aecd1c2_add_files_table.py | 94 - ...6e48a4a064a_increase_status_field_width.py | 26 - .../725816dc500_add_fetch_ref_column.py | 41 - .../7ef7dfa2ca3a_add_change_outdated.py | 37 - gertty/auth.py | 69 - gertty/db.py | 1006 ----------- gertty/gitrepo.py | 540 ------ gertty/palette.py | 140 -- gertty/sync.py | 1577 ----------------- gertty/view/change.py | 1169 ------------ gertty/view/change_list.py | 844 --------- gertty/view/diff.py | 540 ------ gertty/view/side_diff.py | 241 --- gertty/view/unified_diff.py | 262 --- requirements.txt | 1 - setup.cfg | 12 +- tox.ini | 4 +- 75 files changed, 4316 insertions(+), 8643 deletions(-) rename {gertty => boartty}/__init__.py (100%) rename {gertty => boartty}/alembic.ini (96%) rename {gertty => boartty}/alembic/README (100%) rename {gertty => boartty}/alembic/env.py (97%) rename {gertty => boartty}/alembic/script.py.mako (100%) create mode 100644 boartty/alembic/versions/183755ac91df_initial_schema.py rename {gertty => boartty}/app.py (86%) rename {gertty => boartty}/commentlink.py (99%) rename {gertty => boartty}/config.py (72%) create mode 100644 boartty/db.py rename {gertty => boartty}/dbsupport.py (91%) rename {gertty => boartty}/keymap.py (85%) rename {gertty => boartty}/mywid.py (86%) create mode 100644 boartty/palette.py rename {gertty => boartty}/requestsexceptions.py (100%) rename {gertty => boartty}/search/__init__.py (72%) rename {gertty => boartty}/search/parser.py (51%) rename {gertty => boartty}/search/tokenizer.py (93%) create mode 100644 boartty/sync.py rename {gertty => boartty}/version.py (92%) rename {gertty => boartty}/view/__init__.py (100%) rename {gertty => boartty}/view/mouse_scroll_decorator.py (100%) rename {gertty => boartty}/view/project_list.py (87%) create mode 100644 boartty/view/story.py create mode 100644 boartty/view/story_list.py delete mode 100644 examples/googlesource-gertty.yaml create mode 100644 examples/minimal-boartty.yaml delete mode 100644 examples/minimal-gertty.yaml create mode 100644 examples/openstack-boartty.yaml delete mode 100644 examples/openstack-gertty.yaml create mode 100644 examples/reference-boartty.yaml delete mode 100644 examples/reference-gertty.yaml delete mode 100644 gertty/alembic/versions/1bb187bcd401_add_query_sync_table.py delete mode 100644 gertty/alembic/versions/1cdd4e2e74c_add_revision_indexes.py delete mode 100644 gertty/alembic/versions/254ac5fc3941_attach_comments_to_files.py delete mode 100644 gertty/alembic/versions/2a11dd14665_fix_account_table.py delete mode 100644 gertty/alembic/versions/312cd5a9f878_add_can_submit_column.py delete mode 100644 gertty/alembic/versions/3610c2543e07_add_conflicts_table.py delete mode 100644 gertty/alembic/versions/37a702b7f58e_add_last_seen_column_to_change.py delete mode 100644 gertty/alembic/versions/38104b4c1b84_added_project_updated_column.py delete mode 100644 gertty/alembic/versions/3cc7e3753dc3_add_hold.py delete mode 100644 gertty/alembic/versions/3d429503a29a_add_draft_fields.py delete mode 100644 gertty/alembic/versions/4388de50824a_add_topic_table.py delete mode 100644 gertty/alembic/versions/44402069e137_initial_schema.py delete mode 100644 gertty/alembic/versions/46b175bfa277_add_pending_actions.py delete mode 100644 gertty/alembic/versions/4a802b741d2f_add_starred.py delete mode 100644 gertty/alembic/versions/4cc9c46f9d8b_add_account_table.py delete mode 100644 gertty/alembic/versions/50344aecd1c2_add_files_table.py delete mode 100644 gertty/alembic/versions/56e48a4a064a_increase_status_field_width.py delete mode 100644 gertty/alembic/versions/725816dc500_add_fetch_ref_column.py delete mode 100644 gertty/alembic/versions/7ef7dfa2ca3a_add_change_outdated.py delete mode 100644 gertty/auth.py delete mode 100644 gertty/db.py delete mode 100644 gertty/gitrepo.py delete mode 100644 gertty/palette.py delete mode 100644 gertty/sync.py delete mode 100644 gertty/view/change.py delete mode 100644 gertty/view/change_list.py delete mode 100644 gertty/view/diff.py delete mode 100644 gertty/view/side_diff.py delete mode 100644 gertty/view/unified_diff.py diff --git a/.gitignore b/.gitignore index eefd69a..7ed1fb2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ *.pyc *.egg* -gertty-env +boartty-env .tox doc/build diff --git a/.gitreview b/.gitreview index ea7aa38..f414cdb 100644 --- a/.gitreview +++ b/.gitreview @@ -1,4 +1,4 @@ [gerrit] host=review.openstack.org port=29418 -project=openstack/gertty.git +project=openstack/boartty.git diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 200dc12..0a53b62 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -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. diff --git a/README.rst b/README.rst index c00b43c..f20fb8c 100644 --- a/README.rst +++ b/README.rst @@ -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 `_. - -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 `_ -or the share/gertty/examples directory after installation. +`source distribution `_ +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/ diff --git a/gertty/__init__.py b/boartty/__init__.py similarity index 100% rename from gertty/__init__.py rename to boartty/__init__.py diff --git a/gertty/alembic.ini b/boartty/alembic.ini similarity index 96% rename from gertty/alembic.ini rename to boartty/alembic.ini index ffb380d..6180fce 100644 --- a/gertty/alembic.ini +++ b/boartty/alembic.ini @@ -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 diff --git a/gertty/alembic/README b/boartty/alembic/README similarity index 100% rename from gertty/alembic/README rename to boartty/alembic/README diff --git a/gertty/alembic/env.py b/boartty/alembic/env.py similarity index 97% rename from gertty/alembic/env.py rename to boartty/alembic/env.py index 55d37d8..eab2cb1 100644 --- a/gertty/alembic/env.py +++ b/boartty/alembic/env.py @@ -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: diff --git a/gertty/alembic/script.py.mako b/boartty/alembic/script.py.mako similarity index 100% rename from gertty/alembic/script.py.mako rename to boartty/alembic/script.py.mako diff --git a/boartty/alembic/versions/183755ac91df_initial_schema.py b/boartty/alembic/versions/183755ac91df_initial_schema.py new file mode 100644 index 0000000..17aefd1 --- /dev/null +++ b/boartty/alembic/versions/183755ac91df_initial_schema.py @@ -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 diff --git a/gertty/app.py b/boartty/app.py similarity index 86% rename from gertty/app.py rename to boartty/app.py index 50c217c..7d3cd2a 100644 --- a/gertty/app.py +++ b/boartty/app.py @@ -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', diff --git a/gertty/commentlink.py b/boartty/commentlink.py similarity index 99% rename from gertty/commentlink.py rename to boartty/commentlink.py index 2804e6e..3647feb 100644 --- a/gertty/commentlink.py +++ b/boartty/commentlink.py @@ -22,7 +22,7 @@ import re import six import urwid -from gertty import mywid +from boartty import mywid try: OrderedDict = collections.OrderedDict diff --git a/gertty/config.py b/boartty/config.py similarity index 72% rename from gertty/config.py rename to boartty/config.py index cf38b11..dcecd2b 100644 --- a/gertty/config.py +++ b/boartty/config.py @@ -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="(?Phttps?://\\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. diff --git a/boartty/db.py b/boartty/db.py new file mode 100644 index 0000000..7bd1b6f --- /dev/null +++ b/boartty/db.py @@ -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 + diff --git a/gertty/dbsupport.py b/boartty/dbsupport.py similarity index 91% rename from gertty/dbsupport.py rename to boartty/dbsupport.py index ff94b28..991f7f9 100644 --- a/gertty/dbsupport.py +++ b/boartty/dbsupport.py @@ -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. diff --git a/gertty/keymap.py b/boartty/keymap.py similarity index 85% rename from gertty/keymap.py rename to boartty/keymap.py index 56c380f..5e58d87 100644 --- a/gertty/keymap.py +++ b/boartty/keymap.py @@ -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', diff --git a/gertty/mywid.py b/boartty/mywid.py similarity index 86% rename from gertty/mywid.py rename to boartty/mywid.py index efa1f95..5daef6a 100644 --- a/gertty/mywid.py +++ b/boartty/mywid.py @@ -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') diff --git a/boartty/palette.py b/boartty/palette.py new file mode 100644 index 0000000..af72be2 --- /dev/null +++ b/boartty/palette.py @@ -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 diff --git a/gertty/requestsexceptions.py b/boartty/requestsexceptions.py similarity index 100% rename from gertty/requestsexceptions.py rename to boartty/requestsexceptions.py diff --git a/gertty/search/__init__.py b/boartty/search/__init__.py similarity index 72% rename from gertty/search/__init__.py rename to boartty/search/__init__.py index 8babda9..4b584e6 100644 --- a/gertty/search/__init__.py +++ b/boartty/search/__init__.py @@ -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 diff --git a/gertty/search/parser.py b/boartty/search/parser.py similarity index 51% rename from gertty/search/parser.py rename to boartty/search/parser.py index f1cd295..522579a 100644 --- a/gertty/search/parser.py +++ b/boartty/search/parser.py @@ -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