././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1870215 gertty-1.6.1.dev56/0000775000175000017500000000000014667773674014534 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953979.0 gertty-1.6.1.dev56/AUTHORS0000664000175000017500000000616114667773673015607 0ustar00jamespagejamespageAdam Spiers Aleksei Stepanov Aleksei Stepanov Alex Schultz Alex Schultz Andrew Ruthven Anita Kuno Antoine Musso Bradley Jones Cedric Brandily Christian Berendt Christoph Gysin Clark Boylan Clint Adams Clint Byrum Cody A.W. Somerville Cody A.W. Somerville Craige McWhirter David Pursehouse David Shrewsbury David Stanek Dmitry Tantsur Dolph Mathews Dominique Martinet Doug Hellmann Doug Wiegley Doug Wiegley Emilien Macchi Ian Cordasco Ian Wienand James E. Blair James E. Blair James E. Blair James E. Blair James E. Blair James E. Blair James Polley Jan Kundrát Jan Kundrát Jan Kundrát Jay Faulkner Jay Pipes Jeremy Stanley Jim Rollenhagen John L. Villalovos Joshua Harlow Joshua Harlow K Jonathan Harker K Jonathan Harker Kevin Benton Khai Do Logan V Major Hayden Mark McClain Mark McLoughlin Martin André Masayuki Igawa Masayuki Igawa Masayuki Igawa Matthew Oliver Matthew Thode Matthew Treinish Matthias Runge Miguel Grinberg Monty Taylor Natal Ngétal Nate Johnston Nguyen Hung Phuong Paul Belanger Paul Bourke Pierre Riteau Pádraig Brady Robbie Harwood (frozencemetery) Robert Collins Robert Collins Roman Dobosz Russell Bryant Sean M. Collins Sirushti Murugesan Slawek Kaplonski Tobias Henkel Tristan Cacqueray Wouter van Kesteren Zane Bitter ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/CONTRIBUTING.rst0000664000175000017500000000460014667772533017166 0ustar00jamespagejamespageContributing ============ To browse the latest code, see: https://opendev.org/ttygroup/gertty/src/branch/master/ To clone the latest code, use `git clone https://opendev.org/ttygroup/gertty` Bugs are handled at: https://storyboard.openstack.org/#!/project/ttygroup/gertty Code reviews are handled by gerrit at: https://review.opendev.org Use `git review` to submit patches (after creating a Gerrit account that links to your launchpad account). Example:: # Do your commits $ git review # Enter your username if prompted Philosophy ---------- Gertty is based on the following precepts which should inform changes to the program: * Support large numbers of review requests across large numbers of projects. Help the user prioritize those reviews. * Adopt a news/mailreader-like workflow in support of the above. Being able to subscribe to projects, mark reviews as "read" without reviewing, etc, are all useful concepts to support a heavy review load (they have worked extremely well in supporting people who read/write a lot of mail/news). * Support off-line use. Gertty should be completely usable off-line with reliable syncing between local data and Gerrit when a connection is available (just like git or mail or news). * Ample use of color. Unlike a web interface, a good text interface relies mostly on color and precise placement rather than whitespace and decoration to indicate to the user the purpose of a given piece of information. Gertty should degrade well to 16 colors, but more (88 or 256) may be used. * Keyboard navigation (with easy-to-remember commands) should be considered the primary mode of interaction. Mouse interaction should also be supported. * The navigation philosophy is a stack of screens, where each selection pushes a new screen onto the stack, and ESC pops the screen off. This makes sense when drilling down to a change from lists, but also supports linking from change to change (via commit messages or comments) and navigating back intuitive (it matches expectations set by the web browsers). * Support a wide variety of Gerrit installations. The initial development of Gertty is against the OpenDev Gerrit, and many of the features are intended to help its users with their workflow, however, those features should be implemented in a generic way so that the system does not require a specific Gerrit configuration. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953979.0 gertty-1.6.1.dev56/ChangeLog0000664000175000017500000004323414667773673016313 0ustar00jamespagejamespageCHANGES ======= * Fix urwid > 2.4.2 compatibility * Drop prehistoric hack for urwid.GridFlow * Set SQLAlchemy minimum to 1.4 * make gertty work with sqlalchemy-2 * More skip patchset-level comments in diff view * Start cursor at Change-Id on entering ChangeView * Make TextButtons have a cursor * Make related change entries selectable * Correctly locate cursor in hypertext widgets * Import alembic.migration * Make commit and patchset comments nicer * Skip patchset-level comments in diff view * Suggest basic auth in minimal example * examples: match 'commit ' * Suggest a 'cherry-picked from' line when cherry picking * examples: matching storyboard stories * Bump python version * Highlight WIP state in change view * Add support for searching for hashtags * Add WIP support * Handle gerrit 3.x style internal urls * Document removal of /p from default git-url * Fix auth-type in opendev example config * Add version specific changes for git-url * tokenizer: do not try to decode strings on Python 3 * Update author email address * Fix error in message refresh * Fix "Save and Submit" button * Add support for checks plugin * Support robot comments * Display line comments from all patchsets in change view * Hide errors when syncing conflicts * Handle binary data in diffs * Update information about config file location * Update googlesource example * Use account\_id to identify the user's own account * Add support for hashtags * Fix a py3 error with exception formatting * Correct file search implementation * Only search files from the most recent revision * Add prev/next patchset keys to diff view * Update python version trove classifiers 1.6.0 ----- * Change some OpenStack to OpenDev or ttygroup * Change the default location for gertty config file * OpenDev Migration Patch * Fix change view display of inline comments in python3 * Fix tab expansion in inter-patchset diff * Replace openstack.org git:// URLs with https:// * gitrepo DiffFile: convert tab to » + spaces * Fix python3 encoding issues for remote commands * Enable review keys in diffs, and close change on review * Add ctrl-v/meta-v bindings for page-up/page-down * Add inline comments to change overview * Do not decode failed POST response * Fix crash on python3 * typo - s/fileojb/fileobj/ * [Documentation] Change the fedora install * Add ability to set lock file in config * Add message attribute to DisplayError * Document size-column setting * Make size column configurable graph * Don't lose sync requests that get bad responses * Replaces yaml.load() with yaml.safe\_load() * Show merged parents if outdated * Hide Zuul comments * Py3 compat changes for diff view * Adding files/dirs in gitignore * reviewkeys: add 'message' parameter * Actually allow for overriding the lock file * [DOCS] Document MacOS keymap more prominently * Speed up loading change screen * Handle approvals with no name * Change usage of exit() to sys.exit() 1.5.0 ----- * update pbr to remove the cap * Add rdoproject gerrit example * Use open instead of file for python3 * Allow negative comparison on null topics * Fix crash on long review messages * Add option to sort by project * Support multiple sort options * Warn, log, and continue on conflict query failure * Fix exception when setting non-existing change outdated * Fix error when deleting project * Make size a graph * Set force\_fetch=True on --fetch-missing-refs * Handle ReadTimeout * Handle ChunkedEncodingError * Add trailing-whitespace style * Add number of changes to the change list view * Add size column to change list view * Use urlparse from six for python 3 compat * Add an outdated flag for changes * Create new projects automatically when syncing a change 1.4.0 ----- * Add new lock-file setting to the config doc and reference config * Speed up loading a change with eager loading * Fix "too many SQL variables" error * Display gate results ordered * Prevent more than one gertty from running at a time * Add user email addresses to the change view * Treat HTTP 503 responses as offline * Add support for the projects search term * Make unified diffs group changed lines * Handle SNIMissingWarning requests exception * Handle ValueError on missing git commit * Add Gentoo install instructions * Support SyncQueriedChanges batching in Gerrit >=2.9 * Default to prior approval values when (re-)reviewing * Fix unicode in change messages * Add (quoted) reply button to change messages * Add form authentication info to docs * Add support for auth-type=form * Add a short title to diff screen * Make project list searchable * Make change list searchable * Show potential completions * Add an option to disable the breadcrumb footer * Add extra columns to change list if there is room * Use short titles in breadcrumbs * Add matchers for external references on launchpad * Fix rendering error in change screen * Make 'title' attribute available on dialog widgets * Add navigation breadcrumb footer * Add configuration information to docs 1.3.2 ----- * Use diff long options and uncap GitPython * Add config option for git clone URL * Support batch abandon/restore * Handle more than one change result when searching * Use diff long options and uncap GitPython * Indent projects under topics * Make topic selection a select list * Fix diff crash on perm-only changes * Do not use GitPython 1.0.2 * Add another utf8 test change comment * Fix return values in handleCommands in project\_list * Make sorting commands use two two keys * Add support for customized dashboard sorting * Add support for last\_seen * Add debug-sync option * Add support for conflicts-with * Improve handling of abandoned related changes * Fix unicode regression regressions * Fix typo in expire-age setting * Fix unicode regressions * Support fetching more than 1k changes post 2.8 * Add missing import of 'six' to app.py * Add documentation * Document urxvt clickable links * Always sync a specfically queried change * Add vi mode navigation * Display a message when interactively syncing * Fix off-by-one in sync counter * Add option to disable mouse support * Add support for external commands * Allow gertty to run in Py3K environments * Add a vi keymap * Cache counts of project changes * Add process mark to project list * Add project topics * Correct display of comments at start of file * Updated Debian availability * remove python 2.6 trove classifier * Add notes to see reference-gertty.yaml for more info 1.3.1 ----- * Fix list index out of range * Fix multi-key handling in diff view * Support >= 2.9 query batching * Fix multi-key handling at top level * Fix config validation to accept new keymap format 1.3.0 ----- * Fix commit message editing in >= 2.11 * Support multiple key input * Add help entries for kill, yank, isearch * Add navigation to interactive search * Add interactive search to diff view * Add a simple kill ring * Match links by url instead of domain * Separate search and refine search commands * Fix syncing changes with comments on a missing file * Change key binding for reverse sort to shift-r ('R') * Update .gitreview for new namespace * Make permalink clickable * Support '-' as negation operator in query * Redisplay after spawning browser * Fix get\_repo call even more * Supply a default query on search * Show all held changes in held-changes view * Add missing get\_repo calls * Specify color for unstable test job * Allow bulk-edit of topics 1.2.1 ----- * Ignore EPERM when pruning refs * Removes the need to pass around the app object * Refactor: move getRepo out of the App object * Mention the bug tracker in the README file 1.2.0 ----- * Fix crash on displaying renamed file * Fix waiting for tasks * Update PBR requirement to >=0.11 * Advance cursor on change list toggle * Allow reviewing one change in change list * Fix updating flags on threaded changes * Add ability to review multiple changes at once * Fix refresh on project and change lists * Fix diff display of deleted empty files * Add database pruning * Attach comments to files * Add files table * Batch sync change by commit tasks * Make "limit" a noop in queries * Fix searching by reviewer account id * Fix age searching * Fix searching for message * Do not enqueue duplicate tasks * Support regexes in search * Add support for SQLAlchemy 1.0.4 * Make ButtonDialog scrollable * Be more verbose on non-tagged versions * Explain how to install on Arch Linux * Don't display draft approvals in change list * Add extra note about pep8 * flake8: Fix F401,F403 * flake8: Fix F821,F841 * Add notes on pep8 and pyflakes * tox: Fix flake8 setup * Explain how to install on openSUSE * Don't enqueue full syncs when going offline * Fix error in double upload * Fix crash on opening a change with missing commits * Display file header in top line of diff * Open internal URLs in commentlinks * Highlight own name on change screen * Add a key to return to the project list * Do not clear history when opening a dashboard * Make change-id a search link in change screen * Make topic a search link in change screen * Make project name a search link in change screen * Sync change when missing refs * Try git protocol last when fetching * Fix changeset fetching * Add permalink to change view * Support searching by URL * Add checkout and cherry-pick to change list * Sync starred changes regardless of subscription * Sync own changes regardless of subscription * Add held changes * Highlight starred changes in list * Fix repository checking * Add is:watched to p\_is\_term() * Do not display InsecurePlatformWarning * Display warnings as a popup * Handle change id in simple searches * Minor typo - may -> many * Right align line numbers * Add change list options to configuration * Add missing requirement for six * Add mouse wheel scrolling * Switch "Updated" column to fixed width * Don't display project column in project change list * Display times in local tz * Add a 30 second timeout for requests * Upgrade to requests 2.5.3 * Only sync parent commit once * Add indexes to revision table * Hide webbrowser output * Fix searching with uppercase booleans * Expand sample keymaps to ameliorate OSX features 1.1.0 ----- * Fix keymap substitution * Wrap long lines in side-by-side diffs * Fix crashing on files with no changes * Fix syncing messages attached to draft revisions * Add some INFO level log messages * Add INFO log level potential with --verbose flag * Speed up the toggling of reviewed/hidden changes * Release DB session thread lock earlier in syncs * Security: Require config file to be mode 0600 * Add detailed examples and dashboards a la gerrit * Disable InsecureRequestWarning * Fix reversing changes * Set priority of initial change sync to normal * Add support for starred changes * Fix approval sync * Handle (ignore) binary file diffs * Always display full date * Use category min/max in change list colors * Colorize votes on change list * Thread changes * Only decode email if already encoded * Protect against null owner in change view * If reviewed change is updated, unset reviewed flag * Colorize values in review dialog * Include descriptions in review dialog * Search: adjust association of negation * Search: join tables when necessary * Fix searching for labels with self * Do not use urwid 1.3.0 * Always refresh the screen on pop * dburi needs to have sqlite:/// in front * Document Debian/Fedora installation * Do not use requests 2.5.0 * Improve debug logging of sync events * Fix vote order in review dialog box * Allow specifying the path to CA certificate bundle * type fix in help message * Nicer exit on CTRL-c * Make owner name in change screen a search * Selectively refresh screen * Remove call of 'python setup.py testr' in tox.ini * Add ability to sort change list * Unify small vs. capital letters in help output for consistency * Fix help string for --version * Rename gerrit-gertty.yaml to googlesource-gertty.yaml * Add updated column to the list of changes 1.0.3 ----- * gerrit-review.googlesource.com uses basic authentication * Add submit functionality * Add sample config for Gerrit's Gerrit * Be more careful with null accounts * fix typo in git-root example config * Handle variable labels in change list * Associate orphan messages with revision 1 * Fix some username related problems * Allow specifying a config file * Use owner's username or email if display name is not set * Add gertty-env to .gitignore * Allow to authenticate to Gerrit with HTTP basic auth * Fix exception in change list when change owner has no name 1.0.2 ----- * Update alembic requirements * Rename doc environment to docs * Display version in help dialog title * Add help text for HTTP user/pass 1.0.1 ----- * Add additional help text for openstack user/pass * Quote identifiers in migrations * Fix another crash on prev/next change * Fix crash on prev/next change * Have git not colorize output for diffs * Handle unicode emails in git commits 1.0.0 ----- * Add a link to the examples URL in the README 0.9.0 ----- * Add tox.ini * Update README and install sample configs * Clear error flag when changing screen * Change help key * Change \_ to - in config YAML * Query projects in batches * Fix crash on dependency update * Don't modify status widgets outside of main thread * Add command line options to print palette and keymap * Clarify keymap entries for local git operations * Fix immediate change sync on search * Add support for editing commit message * Remove a stray debug line * Add support for cherry-picking to a branch * Add support for abandon/restore * Add support for rebasing a change * Add support for editing topic * Add database pre-reqs for change actions * Support paging in queries * Save draft cover messages * Add user-agent and version * Reduce impact of check revisions task * Add project and owner columns to change list * Move initial focus on change screen * Fix welcome screen * fix typo when raising syntax error * Remove stray debug line * Add a configurable keymap * Add a standard 'light' palette * Change active project toggle key * Don't hide inactive projects when listing all * Hide fully reviewed projects by default * Add test results to top of change view * Add option to hide certain comments * Include more info in dependencies * Fix account table indexes * Add unified diff view * Fix immediate sync of change by change ID * Restrict comment display to 80 columns * Support 80 column terminals in change view * Correct some search problems * Make the commit message box hypertext * Fix crash on diff of new empty file * Fix newline warning overwriting final line * Fix crash on comments from undisplayed files * Add missing joins for account table * Cleanup the .help -> .help() transition * Add commands to go to the prev/next change in the list * Add a command to return to the change list * Allow the default project change list query to be customized * Add an example gertty.yaml for OpenStack * Use account table in search * Add account table * Support (most of) gerrit search syntax * Add refresh command * Depend on SQLAlchemy 0.9.4 or greater * Fix crash on mouse click in change view * Add reviewkeys * Add custom dashboards * Make the open change dialog a search * Add a philosophy note about OpenStackisms * Genericize the change list and add inter-change links * Reuse digest authentication state * Create local refs to prevent pruning * Use a requests.Session object to enable pooling * Fetch all refs for a change at once * Support comments in commits * Remove unneeded bit from setup.cfg * Move contributing section to its own file * Refactor duplicated code in dependencies handling * Add ctrl-o to help dialog * Handle multiple child revisions of same parent * Remove stray debug line * Add dependency navigation * Support background sync of missing refs * Add project updated column * Handle file-level comments * Handle missing commits * Handle (ignore) no-diff renames * Add patchset selection in diff * Correct a problem with tables at very small widths * Add hyperlinks * Re-add alembic to requirements * Perform http calls outside of the db session * Add custom palettes and commentlinks * Change config file to YAML * Add a project list header * Add local cherry-pick button * Ensure single-threaded db access * Fix closing stacked dialogs * Add jump to change * Add alembic to requirements * Add 'killthread' * Change review toggle keybinding to 'v' * Don't show closed changes in the open list * Removed closed changes from unreviewed list * Fix handling no newline at EOF in both files * Sync parent changes * Increase the status field width * Add some helper methods to deal with sqlite migrations * Expand the .gitignore file to ignore .egg files * Add '?' as another way show help dialog * Use alembic * Add timestamps to change messages * Let yes/no dialog accept 'y' or 'n' as input * Make line numbers dark gray * Don't highlight the entire width of the revision row * Colorize votes table * Add colors and adjust alignment to revision file table * Messages might not have an author * Fix comment handling when exiting diff view * Standardize on 'focused' in text attrs * Add some keyboard shortcuts to the change screen * Colorize some buttons * Add buttons to expand hidden context in diff * Expand context as needed to include all comments * Process more diff output * Refactor diff calculation to facilitate more context * Add a pbr compatible setup * Add a welcome screen * Make all of the change view scrollable * Add a Quit dialog * Handle exiting more gracefully * Handle binary files in diffstat * Handle file renames in diff view * Handle "No newline at end of file" and add --no-sync option * Fix editing inline comments * Make ordereddict optional * Normalize URL to ends with a slash * Properly register password from prompt * Print a friendly user message when conf is missing * Fix some diff comment display errors * Fix displaying new files * Add ordereddict requirement * Allow for password prompting * Read using a file handle instead of read() method * Initial commit * Added .gitreview ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/LICENSE0000664000175000017500000002645014667772533015541 0ustar00jamespagejamespage Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1870215 gertty-1.6.1.dev56/PKG-INFO0000644000175000017500000002145414667773674015635 0ustar00jamespagejamespageMetadata-Version: 2.1 Name: gertty Version: 1.6.1.dev56 Summary: Gertty is a console-based interface to the Gerrit Code Review system. Home-page: http://ttygroup.org/ Author: The TTY Group Author-email: openstack-discuss@lists.openstack.org Keywords: gerrit console urwid review Classifier: Topic :: Utilities Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Information Technology Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 License-File: LICENSE Requires-Dist: pbr>=0.11 Requires-Dist: urwid!=1.3.0,>=1.2.1 Requires-Dist: SQLAlchemy>=1.4 Requires-Dist: GitPython>=0.3.7 Requires-Dist: python-dateutil Requires-Dist: requests<3.0.0,>=2.5.3 Requires-Dist: ordereddict Requires-Dist: alembic>=0.6.4 Requires-Dist: PyYAML>=3.1.0 Requires-Dist: voluptuous>=0.7 Requires-Dist: ply>=3.4 Requires-Dist: six Gertty ====== Gertty is a console-based interface to the Gerrit Code Review system. As compared to the web interface, the main advantages are: * Workflow -- the interface is designed to support a workflow similar to reading network news or mail. In particular, it is designed to deal with a large number of review requests across a large number of projects. * Offline Use -- Gertty syncs information about changes in subscribed projects to a local database and local git repos. All review operations are performed against that database and then synced back to Gerrit. * Speed -- user actions modify locally cached content and need not wait for server interaction. * Convenience -- because Gertty downloads all changes to local git repos, a single command instructs it to checkout a change into that repo for detailed examination or testing of larger changes. 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:: dnf 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:: virtualenv gertty-env source gertty-env/bin/activate To install the latest version from the cheeseshop:: pip install gertty To install from a git checkout:: pip install . Gertty uses a YAML based configuration file that it looks for at ``~/.config/gertty/gertty.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. Select one of the sample config files, copy it to ~/.config/gertty/gertty.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. **reference-gertty.yaml** An exhaustive list of all supported options with examples. **opendev-gertty.yaml** A configuration designed for use with OpenDev's installation of Gerrit. **googlesource-gertty.yaml** A configuration designed for use with installations of Gerrit running on googlesource.com. You will need your Gerrit password which you can generate or retrieve by navigating to ``Settings``, then ``HTTP Password``. Gertty uses local git repositories to perform much of its work. These can be the same git repositories that you use when developing a project. Gertty will not alter the working directory or index unless you request it to (and even then, the usual git safeguards against accidentally losing work remain in place). You will need to supply the name of a directory where Gertty will find or clone git repositories for your projects as the ``git-root`` parameter. 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. 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 --help`` to see a list of command line options available. Once Gertty 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. 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. 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 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). If Gertty 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 error. The error flag will be cleared when you leave the current screen. To select text (e.g., to copy to the clipboard), hold Shift while selecting the text. MacOS ~~~~~ The MacOS terminal blocks ctrl+o, which is the default search key combo in Gertty. To fix this, a custom keymap can be used on MacOS which modifies the search key combo. For example:: keymaps: - name: default # MacOS blocks ctrl+o change-search: 'ctrl s' interactive-search: 'ctrl i' 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 terminal (perhaps in an email or irc client) clickable links that open in Gertty:: 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/)?(c/[^\\+]+\\+/)?(\\d+)[\\w]* URxvt.matcher.launcher.1: gertty --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. Contributing ------------ For information on how to contribute to Gertty, please see the contents of the CONTRIBUTING.rst file. Bugs ---- Bugs are handled at: https://storyboard.openstack.org/#!/project/ttygroup/gertty ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/README.rst0000664000175000017500000001667514667773353016234 0ustar00jamespagejamespageGertty ====== Gertty is a console-based interface to the Gerrit Code Review system. As compared to the web interface, the main advantages are: * Workflow -- the interface is designed to support a workflow similar to reading network news or mail. In particular, it is designed to deal with a large number of review requests across a large number of projects. * Offline Use -- Gertty syncs information about changes in subscribed projects to a local database and local git repos. All review operations are performed against that database and then synced back to Gerrit. * Speed -- user actions modify locally cached content and need not wait for server interaction. * Convenience -- because Gertty downloads all changes to local git repos, a single command instructs it to checkout a change into that repo for detailed examination or testing of larger changes. 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:: dnf 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:: virtualenv gertty-env source gertty-env/bin/activate To install the latest version from the cheeseshop:: pip install gertty To install from a git checkout:: pip install . Gertty uses a YAML based configuration file that it looks for at ``~/.config/gertty/gertty.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. Select one of the sample config files, copy it to ~/.config/gertty/gertty.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. **reference-gertty.yaml** An exhaustive list of all supported options with examples. **opendev-gertty.yaml** A configuration designed for use with OpenDev's installation of Gerrit. **googlesource-gertty.yaml** A configuration designed for use with installations of Gerrit running on googlesource.com. You will need your Gerrit password which you can generate or retrieve by navigating to ``Settings``, then ``HTTP Password``. Gertty uses local git repositories to perform much of its work. These can be the same git repositories that you use when developing a project. Gertty will not alter the working directory or index unless you request it to (and even then, the usual git safeguards against accidentally losing work remain in place). You will need to supply the name of a directory where Gertty will find or clone git repositories for your projects as the ``git-root`` parameter. 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. 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 --help`` to see a list of command line options available. Once Gertty 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. 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. 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 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). If Gertty 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 error. The error flag will be cleared when you leave the current screen. To select text (e.g., to copy to the clipboard), hold Shift while selecting the text. MacOS ~~~~~ The MacOS terminal blocks ctrl+o, which is the default search key combo in Gertty. To fix this, a custom keymap can be used on MacOS which modifies the search key combo. For example:: keymaps: - name: default # MacOS blocks ctrl+o change-search: 'ctrl s' interactive-search: 'ctrl i' 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 terminal (perhaps in an email or irc client) clickable links that open in Gertty:: 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/)?(c/[^\\+]+\\+/)?(\\d+)[\\w]* URxvt.matcher.launcher.1: gertty --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. Contributing ------------ For information on how to contribute to Gertty, please see the contents of the CONTRIBUTING.rst file. Bugs ---- Bugs are handled at: https://storyboard.openstack.org/#!/project/ttygroup/gertty ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1800215 gertty-1.6.1.dev56/doc/0000775000175000017500000000000014667773674015301 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/doc/Makefile0000664000175000017500000001516314667772533016740 0ustar00jamespagejamespage# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Gertty.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Gertty.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Gertty" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Gertty" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1800215 gertty-1.6.1.dev56/doc/source/0000775000175000017500000000000014667773674016601 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/doc/source/conf.py0000664000175000017500000002023514667772533020073 0ustar00jamespagejamespage# -*- coding: utf-8 -*- # # Gertty documentation build configuration file, created by # sphinx-quickstart on Fri Jan 15 13:41:54 2016. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import datetime import sys import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath('../..')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Gertty' copyright = u'%s, Gertty Contributors' % datetime.date.today().year # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # The full version, including alpha/beta/rc tags. from gertty.version import version_info as gertty_version release = gertty_version.version_string_with_vcs() # The short X.Y version. version = gertty_version.canonical_version_string() # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'Gerttydoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'Gertty.tex', u'Gertty Documentation', u'James E. Blair', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'gertty', u'Gertty Documentation', [u'James E. Blair'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'Gertty', u'Gertty Documentation', u'James E. Blair', 'Gertty', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/doc/source/configuration.rst0000664000175000017500000003462114667772533022201 0ustar00jamespagejamespageConfiguration ------------- Gertty uses a YAML based configuration file that it looks for at ``~/.config/gertty/gertty.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. Select one of the sample config files, copy it to ~/.config/gertty/gertty.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. **reference-gertty.yaml** An exhaustive list of all supported options with examples. **opendev-gertty.yaml** A configuration designed for use with OpenDev's installation of Gerrit. **googlesource-gertty.yaml** A configuration designed for use with installations of Gerrit running on googlesource.com. You will need your Gerrit password which you can generate or retrieve by navigating to ``Settings``, then ``HTTP Password``. Gertty uses local git repositories to perform much of its work. These can be the same git repositories that you use when developing a project. Gertty will not alter the working directory or index unless you request it to (and even then, the usual git safeguards against accidentally losing work remain in place). You will need to supply the name of a directory where Gertty will find or clone git repositories for your projects as the ``git-root`` parameter. 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. Configuration Reference ~~~~~~~~~~~~~~~~~~~~~~~ The following describes the values that may be set in the configuration file. Servers +++++++ This section lists the servers that Gertty can talk to. Multiple servers may be listed; by default, Gertty will use the first one listed. To select another, simply specify its name on the command line. **servers** A list of server definitions. The format of each entry is described below. **name (required)** A name that describes the server, to reference on the command line. **url (required)** The URL of the Gerrit server. HTTPS should be preferred. **username (required)** Your username in Gerrit. [required] **password (required)** Your password in Gerrit. Obtain it from Settings -> HTTP Password in the Gerrit web interface. **auth-type** Authentication type required by the Gerrit server. Can be 'basic', 'digest', or 'form'. Defaults to 'digest'. **git-root (required)** A location where Gertty should store its git repositories. These can be the same git repositories where you do your own work -- Gertty will not modify them unless you tell it to, and even then the normal git protections against losing work remain in place. **dburi** The location of Gertty's sqlite database. If you have more than one server, you should specify a dburi for any additional servers. By default a SQLite database called ~/.gertty.db is used. **ssl-ca-path** If your Gerrit server uses a non-standard certificate chain (e.g. on a test server), you can pass a full path to a bundle of CA certificates here: **verify-ssl** In case you do not care about security and want to use a sledgehammer approach to SSL, you can set this value to false to turn off certificate validation. **log-file** By default Gertty logs errors to a file and truncates that file each time it starts (so that it does not grow without bound). If you would like to log to a different location, you may specify it with this option. **socket** Gertty listens on a unix domain socket for remote commands at ~/.gertty.sock. This option may be used to change the path. **lock-file** Gertty uses a lock file per server to prevent multiple processes from running at the same time. The default is ~/.gertty.servername.lock Example: .. code-block: yaml servers: - name: CHANGEME url: https://CHANGEME.example.org/ username: CHANGEME password: CHANGEME git-root: ~/git/ Palettes ++++++++ Gertty comes with two palettes defined internally. The default palette is suitable for use on a terminal with a dark background. The `light` palette is for a terminal with a white or light background. You may customize the colors in either of those palettes, or define your own palette. If any color is not defined in a palette, the value from the default palette is used. The values are a list of at least two elements describing the colors to be used for the foreground and background. Additional elements may specify (in order) the color to use for monochrome terminals, the foreground, and background colors to use in high-color terminals. For a reference of possible color names, see the `Urwid Manual `_ To see the list of possible palette entries, run `gertty --print-palette`. The following example alters two colors in the default palette, one color in the light palette, and one color in a custom palette. .. code-block: yaml palettes: - name: default added-line: ['dark green', ''] added-word: ['light green', ''] - name: light filename: ['dark cyan', ''] - name: custom filename: ['light yellow', ''] Palettes may be selected at runtime with the `-p PALETTE` command line option, or you may set the default palette in the config file. **palette** This option specifies the default palette. Keymaps +++++++ Keymaps work the same way as palettes. Two keymaps are defined internally, the `default` keymap and the `vi` keymap. Individual keys may be overridden and custom keymaps defined and selected in the config file or the command line. Each keymap contains a mapping of command -> key(s). If a command is not specified, Gertty will use the keybinding specified in the default map. More than one key can be bound to a command. Run `gertty --print-keymap` for a list of commands that can be bound. The following example modifies the `default` keymap: .. code-block: yaml keymaps: - name: default diff: 'd' - name: custom review: ['r', 'R'] - name: osx #OS X blocks ctrl+o change-search: 'ctrl s' To specify a sequence of keys, they must be a list of keystrokes within a list of key series. For example: .. code-block: yaml keymaps: - name: vi quit: [[':', 'q']] The default keymap may be selected with the `-k KEYMAP` command line option, or in the config file. **keymap** Set the default keymap. Commentlinks ++++++++++++ Commentlinks are regular expressions that are applied to commit and review messages. They can be replaced with internal or external links, or have colors applied. **commentlinks** This is a list of commentlink patterns. Each commentlink pattern is a dictionary with the following values: **match** A regular expression to match against the text of commit or review messages. **replacements** A list of replacement actions to apply to any matches found. Several replacement actions are supported, and each accepts certain options. These options may include strings extracted from the regular expression match in named groups by enclosing the group name in '{}' braces. The following replacement actions are supported: **text** Plain text whose color may be specified. **text** The replacement text. **color** The color in which to display the text. This references a palette entry. **link** A hyperlink with the indicated text that when activated will open the user's browser with the supplied URL **text** The replacement text. **url** The color in which to display the text. This references a palette entry. **search** A hyperlink that will perform a Gertty search when activated. **text** The replacement text. **query** The search query to use. This example matches Gerrit change ids, and replaces them with a link to an internal Gertty search for that change id. .. code-block: yaml commentlinks: - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" Change List Options +++++++++++++++++++ **change-list-query** This is the query used for the list of changes when a project is selected. The default is `status:open`. **change-list-options** This section defines default sorting options for the change list. **sort-by** This key specifies the sort order, which can be `number` (the Change number), `updated` (when the change was last updated), or `last-seen` (when the change was last opened in Gertty). **reverse** This is a boolean value which indicates whether the list should be in ascending (`true`) or descending (`false`) order. Example: .. code-block: yaml change-list-options: sort-by: 'number' reverse: false **thread-changes** Dependent changes are displayed as "threads" in the change list by default. To disable this behavior, set this value to false. Change View Options +++++++++++++++++++ **hide-comments** This is a list of descriptors which cause matching comments to be hidden by default. Press the `t` key to toggle the display of matching comments. The only supported criterion is `author`. **author** A regular expression to match against the comment author's name. For example, to hide comments from a CI system: .. code-block: yaml hide-comments: - author: "^(.*CI|Jenkins)$" **diff-view** Specifies how patch diffs should be displayed. The values `unified` or `side-by-side` (the default) are supported. **close-change-on-review** When a review is saved, close the change view and pop up to the previous screen, which will be the change list for the repo. Dashboards ++++++++++ This section defines customized dashboards. You may supply any Gertty search string and bind them to any key. They will appear in the global help text, and pressing the key anywhere in Gertty will run the query and display the results. **dashboards** A list of dashboards, the format of which is described below. **name** The name of the dashboard. This will be displayed in the status bar at the top of the screen. **query** The search query to perform to gather changes to be listed in the dashboard. **key** The key to which the dashboard should be bound. Example: .. code-block: yaml dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" Reviewkeys ++++++++++ Reviewkeys are hotkeys that perform immediate reviews within the change screen. Any pending comments or review messages will be attached to the review; otherwise an empty review message will be left. The approvals list is exhaustive, so if you specify an empty list, Gertty will submit a review that clears any previous approvals. Reviewkeys appear in the help text for the change screen. **reviewkeys** A list of reviewkey definitions, the format of which is described below. **key** This key to which this review action should be bound. **approvals** A list of approvals to include when this reviewkey is activated. Each element of the list should include both a category and a value. **category** The name of the review label for this approval. **value** The value for this approval. **message** Optional, it can be used to include a message during the review. **submit** Set this to `true` to instruct Gerrit to submit the change when this reviewkey is activated. The following example includes a reviewkey that clears all labels, one that leaves a +1 "Code-Review" approval and another one that leaves 'recheck' on a review. .. code-block: yaml reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: [] message: 'recheck' General Options +++++++++++++++ **breadcrumbs** Gertty displays a footer at the bottom of the screen by default which contains navigation information in the form of "breadcrumbs" -- short descriptions of previous screens, with the right-most entry indicating the screen that will be displayed if you press the `ESC` key. To disable this feature, set this value to `false`. **display-times-in-utc** Times are displayed in the local timezone by default. To display them in UTC instead, set this value to `true`. **handle-mouse** Gertty handles mouse input by default. If you don't want it interfering with your terminal's mouse handling, set this value to `false`. **expire-age** By default, closed changes that are older than two months are removed from the local database (and their refs are removed from the local git repos so that git may garbage collect them). If you would like to change the expiration delay or disable it, uncomment the following line. The time interval is specified in the same way as the "age:" term in Gerrit's search syntax. To disable it altogether, set the value to the empty string. **size-column** By default, the size column is a pair of stacked logarithmic graphs. The top, red graph represents the number of lines removed, the bottom, green graph the number added. For an alternate representation, use this setting. **type** A string with one of the following values: **graph** The default stacked bar graphs. **split-graph** Rather than vertically stacked, the bar graphs are side-by-side **number** A single number which represents the number of lines changed (added and removed). **thresholds** A list of integers to determine the magnitude of the graph increments, or the color coding of the number. If the type is ``graph`` or ``split-graph``, the list should be four elements long. The default is 1, 10, 100, 1000 for a logarithmic representation. If the type is ``number``, the list should be eight elements long; the default in that case is 1, 10, 100, 200, 400, 600, 800, 1000. Example: .. code-block: yaml size-column: type: graph thresholds: [1, 10, 100, 1000] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/doc/source/contributing.rst0000664000175000017500000000032214667772533022030 0ustar00jamespagejamespageContributing ------------ For information on how to contribute to Gertty, please see the contents of the CONTRIBUTING.rst file. Bugs ~~~~ Bugs are handled at: https://storyboard.openstack.org/#!/project/698 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/doc/source/index.rst0000664000175000017500000000212414667772533020432 0ustar00jamespagejamespageGertty ====== Gertty is a console-based interface to the Gerrit Code Review system. As compared to the web interface, the main advantages are: * Workflow -- the interface is designed to support a workflow similar to reading network news or mail. In particular, it is designed to deal with a large number of review requests across a large number of projects. * Offline Use -- Gertty syncs information about changes in subscribed projects to a local database and local git repos. All review operations are performed against that database and then synced back to Gerrit. * Speed -- user actions modify locally cached content and need not wait for server interaction. * Convenience -- because Gertty downloads all changes to local git repos, a single command instructs it to checkout a change into that repo for detailed examination or testing of larger changes. Contents: .. toctree:: :maxdepth: 1 installation.rst configuration.rst usage.rst contributing.rst Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/doc/source/installation.rst0000664000175000017500000000173514667772533022033 0ustar00jamespagejamespageInstallation ------------ 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 `_. 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:: virtualenv gertty-env source gertty-env/bin/activate To install the latest version from the cheeseshop:: pip install gertty To install from a git checkout:: pip install . ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/doc/source/usage.rst0000664000175000017500000000637514667772533020443 0ustar00jamespagejamespageUsage ----- 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 --help`` to see a list of command line options available. Once Gertty 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. 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. 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 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). If Gertty 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 error. The error flag will be cleared when you leave the current screen. To select text (e.g., to copy to the clipboard), hold Shift while selecting the text. 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 terminal (perhaps in an email or irc client) clickable links that open in Gertty:: 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 You will want to adjust the pattern to match the review site you are interested in; multiple patterns may be added as needed. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1810215 gertty-1.6.1.dev56/examples/0000775000175000017500000000000014667773674016352 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/examples/googlesource-gertty.yaml0000664000175000017500000000643214667773353023246 0ustar00jamespagejamespage# This is an example ~/.config/gertty/gertty.yaml file for use with # installations of Gerrit running on googlesource.com. Most of these options # are not required, rather, they customize Gertty to better deal with the # particulars of Google's Gerrit configuration. # This file does not list all of the available options. For a full # list with explanations, see the 'reference-gertty.yaml' file. servers: - name: CHANGEME-review url: https://CHANGEME-review.googlesource.com/ # Get username and password at https://{name}-review.googlesource.com/#/settings/http-password # It will provide a gitcookies file with contents like this: # gerrit-review.googlesource.com FALSE / TRUE 2147483647 o git-user.example.com=ooghaGhu7ohva5xai8LahcheoVahTae5 # The username in this example is "git-user.example.com" and the # password is "ooghaGhu7ohva5xai8LahcheoVahTae5". They are # separated by an "=" character. # Note this is not your Google password. username: CHANGEME password: CHANGEME auth-type: basic git-root: ~/git/ # Uncomment the next line if your terminal has a white background # palette: light # Commentlinks are regexes that are applied to commit and review # messages. They can be replaced with internal or external links, or # have colors applied. commentlinks: # Match Gerrit change ids, and replace them with a link to an # internal Gertty search for that change id. - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" # Uncomment the following line to use a unified diff view instead # of the default side-by-side: # diff-view: unified # This section defines customized dashboards. You can supply any # Gertty search string and bind them to any key. They will appear in # the global help text, and pressing the key anywhere in Gertty will # discard the current display stack and replace it with the results of # the query. # # NB: "recentlyseen:24 hours" does not just return changes seen in the # last 24 hours -- it returns changes seen within 24 hours of the most # recently seen change. So you can take the weekend off and pick up # where you were. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" - name: "Starred changes" query: "is:starred" key: "f4" - name: "Recently seen changes" query: "recentlyseen:24 hours" sort-by: "last-seen" reverse: True key: "f5" # Reviewkeys are hotkeys that perform immediate reviews within the # change screen. Any pending comments or review messages will be # attached to the review; otherwise an empty review will be left. The # approvals list is exhaustive, so if you specify an empty list, # Gertty will submit a review that clears any previous approvals. To # submit the change with the review, include 'submit: True' with the # reviewkey. Reviewkeys appear in the help text for the change # screen. reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: - category: 'Code-Review' value: 2 - key: 'meta 3' approvals: - category: 'Code-Review' value: 2 submit: True ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/examples/minimal-gertty.yaml0000664000175000017500000000073314667773353022175 0ustar00jamespagejamespage# This is an example ~/.config/gertty/gertty.yaml file with only the required # settings. # This file does not list all of the available options. For a full # list with explanations, see the 'reference-gertty.yaml' file. servers: - name: CHANGEME url: https://CHANGEME.example.org/ username: CHANGEME # Set corresponding HTTP password in gerrit settings password: CHANGEME git-root: ~/git/ # Needed for Gerrit 2.16 and later auth-type: basic ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/examples/opendev-gertty.yaml0000664000175000017500000001344314667773353022211 0ustar00jamespagejamespage# This is an example ~/.config/gertty/gertty.yaml file for use with # OpenDev's Gerrit. Most of these options are not required, rather, # they customize Gertty to better deal with the particulars of # OpenDev's Gerrit configuration. # This file does not list all of the available options. For a full # list with explanations, see the 'reference-gertty.yaml' file. servers: - name: opendev url: https://review.opendev.org/ auth-type: basic # Your gerrit username. username: CHANGEME # Set password at https://review.opendev.org/settings/http-password#HTTPCredentials # Note this is not your launchpad password. password: CHANGEME git-root: ~/git/ # This section adds the colors that we will reference later in the # commentlinks section for test results. You can also change other # colors here. palettes: - name: default test-SUCCESS: ['light green', ''] test-FAILURE: ['light red', ''] test-UNSTABLE: ['yellow', ''] # Uncomment the next line if your terminal has a white background # palette: light # Commentlinks are regexes that are applied to commit and review # messages. They can be replaced with internal or external links, or # have colors applied. commentlinks: # This matches the job results left by Zuul. - match: "^- (?P.*?) (?P.*?) : (?P[^ ]+) ?(?P.*)$" # This indicates that this is a test result, and should be indexed # using the "job" match group from the commentlink regex. Gertty # displays test results in their own area of the screen. test-result: "{job}" replacements: # Replace the matching text with a hyperlink to the "url" match # group whose text is the "job" match group. - link: text: "{job:<42}" url: "{url}" # Follow that with the plain text of the "result" match group # with the color "test-{result}" applied. See the palette # section above. - text: color: "test-{result}" text: "{result} " # And then follow that with the plain text of the "comment" # match group. - text: "{comment}" # Match Gerrit change ids, and replace them with a link to an # internal Gertty search for that change id. - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" # Match external references to bugs on Launchpad - match: "(?P(?:[Cc]loses|[Pp]artial|[Rr]elated)-[Bb]ug *: *#?(?P\\d+))" replacements: - link: text: "{bug_str}" url: "https://launchpad.net/bugs/{bug_id}" # Match external references to blueprints on Launchpad - match: "blueprint +(?P[\\w\\-.]+)" replacements: - link: text: "blueprint {blueprint}" url: "https://blueprints.launchpad.net/openstack/?searchtext={blueprint}" # Match external references to stories and tasks on StoryBoard - match: "(?P(?:[Ss]tory) *: *#?(?P\\d+))" replacements: - link: text: "{bug_str}" url: "https://storyboard.openstack.org/#!/story/{bug_id}" - match: "(?P(?:[Tt]ask) *: *#?(?P\\d+))" replacements: - link: text: "{bug_str}" url: "https://storyboard.openstack.org/#!/task/{bug_id}" # Match phrases containing "commit ", e.g. in cherry picks - match: "(?Pcommit +(?P[0-9a-f]{40}))" replacements: - search: text: "{full_str}" query: "commit:'{id}'" # This is the query used for the list of changes when a project is # selected. The default is "status:open". If you don't want to see # changes which are WIP or have verification failures, use a query like this: # change-list-query: "status:open not label:Workflow=-1" # If you also want to exclude reviews with failed tests, the query is slightly # more complex: # "status:open not (label:Workflow=-1 or label:Verified=-1)" # Uncomment the following line to use a unified diff view instead of the # default side-by-side: # diff-view: unified # Hide comments by default that match the following criteria. # You can toggle their display with 't'. hide-comments: - author: "^(.*CI|Jenkins|Zuul)$" # This section defines customized dashboards. You can supply any # Gertty search string and bind them to any key. They will appear in # the global help text, and pressing the key anywhere in Gertty will # discard the current display stack and replace it with the results of # the query. # # NB: "recentlyseen:24 hours" does not just return changes seen in the # last 24 hours -- it returns changes seen within 24 hours of the most # recently seen change. So you can take the weekend off and pick up # where you were. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" - name: "Starred changes" query: "is:starred" key: "f4" - name: "Recently seen changes" query: "recentlyseen:24 hours" sort-by: "last-seen" reverse: True key: "f5" # Reviewkeys are hotkeys that perform immediate reviews within the # change screen. Any pending comments or review messages will be # attached to the review; otherwise an empty review will be left. The # approvals list is exhaustive, so if you specify an empty list, # Gertty will submit a review that clears any previous approvals. # They will appear in the help text for the change screen. reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: - category: 'Code-Review' value: 2 - key: 'meta 3' approvals: - category: 'Code-Review' value: 2 - category: 'Workflow' value: 1 - key: 'meta 4' approvals: [] message: "recheck" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/examples/rdo-gertty.yaml0000664000175000017500000001144514667772533021334 0ustar00jamespagejamespage# This is an example ~/.config/gertty/gertty.yaml file for use with # RDO's Gerrit. Most of these options are not required, rather, they # customize Gertty to better deal with the particulars of RDO's Gerrit # configuration. # This file does not list all of the available options. For a full # list with explanations, see the 'reference-gertty.yaml' file. servers: - name: rdo url: https://review.rdoproject.org/ # Your gerrit username. username: CHANGEME # Set password at https://review.rdoproject.org/r/#/settings/http-password # Note, this is not the password for your ID provider! password: CHANGEME git-root: ~/git/ # This section adds the colors that we will reference later in the # commentlinks section for test results. You can also change other # colors here. palettes: - name: default test-SUCCESS: ['light green', ''] test-FAILURE: ['light red', ''] test-UNSTABLE: ['yellow', ''] # Uncomment the next line if your terminal has a white background # palette: light # Commentlinks are regexes that are applied to commit and review # messages. They can be replaced with internal or external links, or # have colors applied. commentlinks: # This matches the job results left by Zuul. - match: "^- (?P.*?) (?P.*?) : (?P[^ ]+) ?(?P.*)$" # This indicates that this is a test result, and should be indexed # using the "job" match group from the commentlink regex. Gertty # displays test results in their own area of the screen. test-result: "{job}" replacements: # Replace the matching text with a hyperlink to the "url" match # group whose text is the "job" match group. - link: text: "{job:<42}" url: "{url}" # Follow that with the plain text of the "result" match group # with the color "test-{result}" applied. See the palette # section above. - text: color: "test-{result}" text: "{result} " # And then follow that with the plain text of the "comment" # match group. - text: "{comment}" # Match Gerrit change ids, and replace them with a link to an # internal Gertty search for that change id. - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" # Match external references to bugs on Launchpad - match: "(?P(?:[Cc]loses|[Pp]artial|[Rr]elated)-[Bb]ug *: *#?(?P\\d+))" replacements: - link: text: "{bug_str}" url: "https://bugzilla.redhat.com/{bug_id}" # This is the query used for the list of changes when a project is # selected. The default is "status:open". If you don't want to see # changes which are WIP or have verification failures, use a query like this: # change-list-query: "status:open not label:Workflow=-1" # If you also want to exclude reviews with failed tests, the query is slightly # more complex: # "status:open not (label:Workflow=-1 or label:Verified=-1)" # Uncomment the following line to use a unified diff view instead of the # default side-by-side: # diff-view: unified # Hide comments by default that match the following criteria. # You can toggle their display with 't'. hide-comments: - author: "^(.*CI|Jenkins)$" # This section defines customized dashboards. You can supply any # Gertty search string and bind them to any key. They will appear in # the global help text, and pressing the key anywhere in Gertty will # discard the current display stack and replace it with the results of # the query. # # NB: "recentlyseen:24 hours" does not just return changes seen in the # last 24 hours -- it returns changes seen within 24 hours of the most # recently seen change. So you can take the weekend off and pick up # where you were. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" - name: "Starred changes" query: "is:starred" key: "f4" - name: "Recently seen changes" query: "recentlyseen:24 hours" sort-by: "last-seen" reverse: True key: "f5" # Reviewkeys are hotkeys that perform immediate reviews within the # change screen. Any pending comments or review messages will be # attached to the review; otherwise an empty review will be left. The # approvals list is exhaustive, so if you specify an empty list, # Gertty will submit a review that clears any previous approvals. # They will appear in the help text for the change screen. reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: - category: 'Code-Review' value: 2 - key: 'meta 3' approvals: - category: 'Code-Review' value: 2 - category: 'Workflow' value: 1 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/examples/reference-gertty.yaml0000664000175000017500000002371214667773353022507 0ustar00jamespagejamespage# This is an example ~/.config/gertty/gertty.yaml with an exhaustive listing of # options with commentary. # This section lists the servers that Gertty can talk to. Multiple # servers may be listed; by default, Gertty will use the first one # listed. To select another, simply specify its name on the command # line. servers: - name: CHANGEME url: https://CHANGEME.example.org/ username: CHANGEME # Your HTTP Password for gerrit. Go to the "HTTP Password" section in your # account settings to generate/retrieve this password. password: CHANGEME git-root: ~/git/ lock-file: ~/.gertty.CHANGEME.lock # Each server section can have the following fields: # A name that describes the server, to reference on the command line. [required] # - name: sample # The URL of the Gerrit server. HTTPS should be preferred. [required] # url: https://example.org/ # Your username in Gerrit. [required] # username: CHANGEME # Your password in Gerrit (Settings -> HTTP Password). [required] # password: CHANGEME # Authentication type required by the Gerrit server. Can be 'basic', # 'digest', or 'form'. Defaults to 'digest'. # auth-type: digest # A location where Gertty should store its git repositories. These # can be the same git repositories where you do your own work -- # Gertty will not modify them unless you tell it to, and even then the # normal git protections against losing work remain in place. [required] # git-root: ~/git/ # The URL to clone git repos. By default, / is used. For a list # of valid URLs, see: # https://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS # git-url: ssh://user@example.org:29418 # The location of Gertty's sqlite database. If you have more than one # server, you should specify a dburi for any additional servers. # By default a SQLite database called ~/.gertty.db is used. # dburi: sqlite:////home/user/.gertty.db # If your Gerrit server uses a non-standard certificate chain (e.g. on a test # server), you can pass a full path to a bundle of CA certificates here: # ssl-ca-path: ~/.pki/ca-chain.pem # In case you do not care about security and want to use a sledgehammer # approach to SSL, you can set this value to false to turn off certificate # validation. # verify-ssl: true # By default Gertty logs errors to a file and truncates that file each # time it starts (so that it does not grow without bound). If you # would like to log to a different location, you may specify it here. # log-file: ~/.gertty.log # Gertty listens on a unix domain socket for remote commands at # ~/.gertty.sock. You may change the path here: # socket: ~/.gertty.sock # Gertty uses a lock file per server to prevent multiple processes # from running at the same time. Example: # lock-file: /run/lockme.lock # Gertty comes with two palettes defined internally. The default # palette is suitable for use on a terminal with a dark background. # The "light" palette is for a terminal with a white or light # background. You may customize the colors in either of those # palettes, or define your own palette. # The following alters two colors in the default palette, one color in # the light palette, and one color in a custom palette. If any color # is not defined in a palette, the value from the default palette is # used. The values are a list of at least two elements describing the # colors to be use for the foreground and background colors. # Additional elements can specify (in order) the color to use for # monochrome terminals, the foreground, and background colors to use # in high-color terminals. # For a reference of possible color names, see: # http://urwid.org/manual/displayattributes.html#foreground-and-background-settings # To see the list of possible palette entries, run "gertty --print-palette". palettes: - name: default added-line: ['dark green', ''] added-word: ['light green', ''] - name: light filename: ['dark cyan', ''] - name: custom filename: ['light yellow', ''] # Palettes may be selected at runtime with the "-p PALETTE" command # line option, or you may set the default palette here: # palette: light # Keymaps work the same way as palettes. Two keymaps are defined # internally, the 'default' keymap and the 'vi' keymap. Individual # keys may be overridden and custom keymaps defined and selected in # the config file or the command line. # Each keymap contains a mapping of command -> key(s). If a command # is not specified, Gertty will use the keybinding specified in the # default map. More than one key can be bound to a command. # Run "gertty --print-keymap" for a list of commands that can be # bound. keymaps: - name: default diff: 'd' - name: custom review: ['r', 'R'] - name: osx # MacOS blocks ctrl+o change-search: 'ctrl s' # To specify a sequence of keys, they must be a list of keystrokes # within a list of key series. For example: - name: vi quit: [[':', 'q']] # The default keymap may be selected with the '-k KEYMAP' command line # option, or with the following line: # keymap: custom # Commentlinks are regular expressions that are applied to commit and # review messages. They can be replaced with internal or external # links, or have colors applied. commentlinks: # Match Gerrit change ids, and replace them with a link to an internal # Gertty search for that change id. - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" # Any number of commentlink entries may be specified. Start each with # a "match" key and regex. Named match groups within the regex may be # used in the replacements section. Any number of replacements may be # specified. The types of replacement available are: # # Text: Plain text whose color may be specified. The color references # a palette entry. # - text: # color: "" # text: "" # Link: A hyperlink with the indicated text that when activated will # open the user's browser with the supplied URL # - link: # text: "" # url: "" # Search: A hyperlink that will perform a Gertty search when # activated. # - search: # text: "{id}" # query: "change:{id}" # This is the query used for the list of changes when a project is # selected. The default is "status:open". # change-list-query: "status:open" # This section defines default sorting options for the change # list. The "sort-by" key specifies the sort order, which can be # 'number', 'updated', or 'last-seen'. The 'reverse' key specifies # ascending (true) or descending (false) order. # change-list-options: # sort-by: 'number' # reverse: false # Uncomment the following line to disable the navigation breadcrumbs # at the bottom of the screen: # breadcrumbs: false # Uncomment the following line to close a change after saving # a review. # close-change-on-review: true # Uncomment the following line to use a unified diff view instead # of the default side-by-side: # diff-view: unified # Dependent changes are displayed as "threads" in the change list by # default. To disable this behavior, uncomment the following line: # thread-changes: false # Times are displayed in the local timezone by default. To display # them in UTC instead, uncomment the following line: # display-times-in-utc: true # Gertty handles mouse input by default. If you don't want it messing # with your terminal's mouse handling, uncomment the following line: # handle-mouse: false # Closed changes that are older than two months are removed from the # local database (and their refs are removed from the local git repos # so that git may garbage collect them). If you would like to change # the expiration delay or disable it, uncomment the following line. # The time interval is specified in the same way as the "age:" term in # Gerrit's search syntax. To disable it altogether, set the value to # the empty string. # expire-age: '2 months' # Uncomment the following lines to Hide comments by default that match # certain criteria. You can toggle their display with 't'. Currently # the only supported criterion is "author". # hide-comments: # - author: "^(.*CI|Jenkins)$" # This section defines customized dashboards. You can supply any # Gertty search string and bind them to any key. They will appear in # the global help text, and pressing the key anywhere in Gertty will # run the query and display the results. # # NB: "recentlyseen:24 hours" does not just return changes seen in the # last 24 hours -- it returns changes seen within 24 hours of the most # recently seen change. So you can take the weekend off and pick up # where you were. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" - name: "Starred changes" query: "is:starred" key: "f4" - name: "Recently seen changes" query: "recentlyseen:24 hours" sort-by: "last-seen" reverse: True key: "f5" # Reviewkeys are hotkeys that perform immediate reviews within the # change screen. Any pending comments or review messages will be # attached to the review; otherwise an empty review will be left. The # approvals list is exhaustive, so if you specify an empty list, # Gertty will submit a review that clears any previous approvals. To # submit the change with the review, include 'submit: True' with the # reviewkey. Reviewkeys appear in the help text for the change # screen. reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: - category: 'Code-Review' value: 2 - key: 'meta 3' approvals: - category: 'Code-Review' value: 2 submit: True # 'size-column' is a set of customize parameters for the 'Size' column # on your dashboard. # 'type' must be 'graph', 'split-graph' or 'number'. Default is 'graph'. # 'thresholds' is for bar graphs width (when graph) or color styles # (when number). # size-column: # type: 'graph' # thresholds: [1, 10, 100, 1000] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1820214 gertty-1.6.1.dev56/gertty/0000775000175000017500000000000014667773674016052 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/__init__.py0000664000175000017500000000000014667772533020142 0ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1830215 gertty-1.6.1.dev56/gertty/alembic/0000775000175000017500000000000014667773674017446 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/README0000664000175000017500000000004614667772533020317 0ustar00jamespagejamespageGeneric single-database configuration.././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/env.py0000664000175000017500000000401514667772533020601 0ustar00jamespagejamespagefrom __future__ import with_statement from alembic import context from sqlalchemy import engine_from_config, pool #from logging.config import fileConfig # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. #fileConfig(config.config_file_name) # add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata import gertty.db target_metadata = gertty.db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure(url=url, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ engine = engine_from_config( config.get_section(config.config_ini_section), prefix='sqlalchemy.', poolclass=pool.NullPool) connection = engine.connect() context.configure( connection=connection, target_metadata=target_metadata ) try: with context.begin_transaction(): context.run_migrations() finally: connection.close() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/script.py.mako0000664000175000017500000000063414667772533022246 0ustar00jamespagejamespage"""${message} Revision ID: ${up_revision} Revises: ${down_revision} Create Date: ${create_date} """ # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} from alembic import op import sqlalchemy as sa ${imports if imports else ""} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1860216 gertty-1.6.1.dev56/gertty/alembic/versions/0000775000175000017500000000000014667773674021316 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/alembic/versions/02ca927a2b55_fix_server_table.py0000664000175000017500000000133314667773353027013 0ustar00jamespagejamespage"""fix_server_table Revision ID: 02ca927a2b55 Revises: 45d33eccc7a7 Create Date: 2020-12-18 10:41:24.274607 """ # revision identifiers, used by Alembic. revision = '02ca927a2b55' down_revision = '45d33eccc7a7' from alembic import op import sqlalchemy as sa # The original had a reference to own_account.key which is invalid and # caused later upgrades to fail. Drop and recreate the table to # correct; Gertty will fill in the data again. def upgrade(): op.drop_table('server') op.create_table('server', sa.Column('key', sa.Integer(), nullable=False), sa.Column('own_account_key', sa.Integer(), sa.ForeignKey('account.key'), index=True), sa.PrimaryKeyConstraint('key') ) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/1bb187bcd401_add_query_sync_table.py0000664000175000017500000000106714667772533027727 0ustar00jamespagejamespage"""add query sync table Revision ID: 1bb187bcd401 Revises: 3cc7e3753dc3 Create Date: 2015-03-26 07:32:33.584657 """ # revision identifiers, used by Alembic. revision = '1bb187bcd401' down_revision = '3cc7e3753dc3' from alembic import op import sqlalchemy as sa def upgrade(): op.create_table('sync_query', sa.Column('key', sa.Integer(), nullable=False), sa.Column('name', sa.String(255), index=True, unique=True, nullable=False), sa.Column('updated', sa.DateTime, index=True), sa.PrimaryKeyConstraint('key') ) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/1cdd4e2e74c_add_revision_indexes.py0000664000175000017500000000065414667772533027744 0ustar00jamespagejamespage"""add revision indexes Revision ID: 1cdd4e2e74c Revises: 4a802b741d2f Create Date: 2015-03-10 16:17:41.330825 """ # revision identifiers, used by Alembic. revision = '1cdd4e2e74c' down_revision = '4a802b741d2f' from alembic import op def upgrade(): op.create_index(op.f('ix_revision_commit'), 'revision', ['commit']) op.create_index(op.f('ix_revision_parent'), 'revision', ['parent']) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/254ac5fc3941_attach_comments_to_files.py0000664000175000017500000000434414667772533030540 0ustar00jamespagejamespage"""attach comments to files Revision ID: 254ac5fc3941 Revises: 50344aecd1c2 Create Date: 2015-04-13 15:52:07.104397 """ # revision identifiers, used by Alembic. revision = '254ac5fc3941' down_revision = '50344aecd1c2' import sys import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns, sqlite_drop_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('comment', sa.Column('file_key', sa.Integer())) sqlite_alter_columns('comment', [ sa.Column('file_key', sa.Integer(), sa.ForeignKey('file.key')) ]) update_query = sa.text('update comment set file_key=:file_key where key=:key') file_query = sa.text('select f.key from file f where f.revision_key=:revision_key and f.path=:path') file_insert_query = sa.text('insert into file (key, revision_key, path, old_path, status, inserted, deleted) ' ' values (NULL, :revision_key, :path, NULL, NULL, NULL, NULL)') conn = op.get_bind() countres = conn.execute('select count(*) from comment') comments = countres.fetchone()[0] comment_res = conn.execute('select p.name, c.number, c.status, r.key, r.number, m.file, m.key ' 'from project p, change c, revision r, comment m ' 'where m.revision_key=r.key and r.change_key=c.key and ' 'c.project_key=p.key order by p.name') count = 0 for (pname, cnumber, cstatus, rkey, rnumber, mfile, mkey) in comment_res.fetchall(): count += 1 sys.stdout.write('Comment %s / %s\r' % (count, comments)) sys.stdout.flush() file_res = conn.execute(file_query, revision_key=rkey, path=mfile) file_key = file_res.fetchone() if not file_key: conn.execute(file_insert_query, revision_key=rkey, path=mfile) file_res = conn.execute(file_query, revision_key=rkey, path=mfile) file_key = file_res.fetchone() fkey = file_key[0] file_res = conn.execute(update_query, file_key=fkey, key=mkey) sqlite_drop_columns('comment', ['revision_key', 'file']) print def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/2a11dd14665_fix_account_table.py0000664000175000017500000000117514667772533027000 0ustar00jamespagejamespage"""fix account table Revision ID: 2a11dd14665 Revises: 4cc9c46f9d8b Create Date: 2014-08-20 13:07:25.079603 """ # revision identifiers, used by Alembic. revision = '2a11dd14665' down_revision = '4cc9c46f9d8b' from alembic import op def upgrade(): op.drop_index('ix_account_name', 'account') op.drop_index('ix_account_username', 'account') op.drop_index('ix_account_email', 'account') op.create_index(op.f('ix_account_name'), 'account', ['name']) op.create_index(op.f('ix_account_username'), 'account', ['username']) op.create_index(op.f('ix_account_email'), 'account', ['email']) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/312cd5a9f878_add_can_submit_column.py0000664000175000017500000000137014667772533030023 0ustar00jamespagejamespage"""add can_submit column Revision ID: 312cd5a9f878 Revises: 46b175bfa277 Create Date: 2014-09-18 16:37:13.149729 """ # revision identifiers, used by Alembic. revision = '312cd5a9f878' down_revision = '46b175bfa277' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('revision', sa.Column('can_submit', sa.Boolean())) conn = op.get_bind() q = sa.text('update revision set can_submit=:submit') conn.execute(q, submit=False) sqlite_alter_columns('revision', [ sa.Column('can_submit', sa.Boolean(), nullable=False), ]) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/3610c2543e07_add_conflicts_table.py0000664000175000017500000000114314667772533027271 0ustar00jamespagejamespage"""add conflicts table Revision ID: 3610c2543e07 Revises: 4388de50824a Create Date: 2016-02-05 16:43:20.047238 """ # revision identifiers, used by Alembic. revision = '3610c2543e07' down_revision = '4388de50824a' from alembic import op import sqlalchemy as sa def upgrade(): op.create_table('change_conflict', sa.Column('key', sa.Integer(), nullable=False), sa.Column('change1_key', sa.Integer(), sa.ForeignKey('change.key'), index=True), sa.Column('change2_key', sa.Integer(), sa.ForeignKey('change.key'), index=True), sa.PrimaryKeyConstraint('key') ) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/37a702b7f58e_add_last_seen_column_to_change.py0000664000175000017500000000075614667772533031666 0ustar00jamespagejamespage"""add last_seen column to change Revision ID: 37a702b7f58e Revises: 3610c2543e07 Create Date: 2016-02-06 09:09:38.728225 """ # revision identifiers, used by Alembic. revision = '37a702b7f58e' down_revision = '3610c2543e07' import warnings from alembic import op import sqlalchemy as sa def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('change', sa.Column('last_seen', sa.DateTime, index=True)) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/38104b4c1b84_added_project_updated_column.py0000664000175000017500000000173014667772533031263 0ustar00jamespagejamespage"""Added project updated column Revision ID: 38104b4c1b84 Revises: 56e48a4a064a Create Date: 2014-05-31 06:52:12.452205 """ # revision identifiers, used by Alembic. revision = '38104b4c1b84' down_revision = '56e48a4a064a' from alembic import op import sqlalchemy as sa def upgrade(): op.add_column('project', sa.Column('updated', sa.DateTime)) conn = op.get_bind() res = conn.execute('select "key", name from project') for (key, name) in res.fetchall(): q = sa.text("select max(updated) from change where project_key=:key") res = conn.execute(q, key=key) for (updated,) in res.fetchall(): q = sa.text('update project set updated=:updated where "key"=:key') conn.execute(q, key=key, updated=updated) op.create_index(op.f('ix_project_updated'), 'project', ['updated'], unique=False) def downgrade(): op.drop_index(op.f('ix_project_updated'), table_name='project') op.drop_column('project', 'updated') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/alembic/versions/399c4b3dcc9a_add_hashtags.py0000664000175000017500000000217614667773353026266 0ustar00jamespagejamespage"""add_hashtags Revision ID: 399c4b3dcc9a Revises: 7ef7dfa2ca3a Create Date: 2019-08-24 15:54:05.934760 """ # revision identifiers, used by Alembic. revision = '399c4b3dcc9a' down_revision = '7ef7dfa2ca3a' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): op.create_table('hashtag', sa.Column('key', sa.Integer(), nullable=False), sa.Column('change_key', sa.Integer(), sa.ForeignKey('change.key'), index=True), sa.Column('name', sa.String(length=255), index=True, nullable=False), sa.PrimaryKeyConstraint('key') ) with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('change', sa.Column('pending_hashtags', sa.Boolean())) connection = op.get_bind() change = sa.sql.table('change', sa.sql.column('pending_hashtags', sa.Boolean())) connection.execute(change.update().values({'pending_hashtags':False})) sqlite_alter_columns('change', [ sa.Column('pending_hashtags', sa.Boolean(), index=True, nullable=False), ]) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/3cc7e3753dc3_add_hold.py0000664000175000017500000000146114667772533025321 0ustar00jamespagejamespage"""add held Revision ID: 3cc7e3753dc3 Revises: 1cdd4e2e74c Create Date: 2015-03-22 08:48:15.516289 """ # revision identifiers, used by Alembic. revision = '3cc7e3753dc3' down_revision = '1cdd4e2e74c' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('change', sa.Column('held', sa.Boolean())) connection = op.get_bind() change = sa.sql.table('change', sa.sql.column('held', sa.Boolean())) connection.execute(change.update().values({'held':False})) sqlite_alter_columns('change', [ sa.Column('held', sa.Boolean(), index=True, nullable=False), ]) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/3d429503a29a_add_draft_fields.py0000664000175000017500000000244314667772533026654 0ustar00jamespagejamespage"""add draft fields Revision ID: 3d429503a29a Revises: 2a11dd14665 Create Date: 2014-08-30 13:26:03.698902 """ # revision identifiers, used by Alembic. revision = '3d429503a29a' down_revision = '2a11dd14665' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns, sqlite_drop_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('message', sa.Column('draft', sa.Boolean())) op.add_column('comment', sa.Column('draft', sa.Boolean())) op.add_column('approval', sa.Column('draft', sa.Boolean())) conn = op.get_bind() conn.execute("update message set draft=pending") conn.execute("update comment set draft=pending") conn.execute("update approval set draft=pending") sqlite_alter_columns('message', [ sa.Column('draft', sa.Boolean(), index=True, nullable=False), ]) sqlite_alter_columns('comment', [ sa.Column('draft', sa.Boolean(), index=True, nullable=False), ]) sqlite_alter_columns('approval', [ sa.Column('draft', sa.Boolean(), index=True, nullable=False), ]) sqlite_drop_columns('comment', ['pending']) sqlite_drop_columns('approval', ['pending']) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/4388de50824a_add_topic_table.py0000664000175000017500000000177614667772533026534 0ustar00jamespagejamespage"""add topic table Revision ID: 4388de50824a Revises: 254ac5fc3941 Create Date: 2015-10-31 19:06:38.538948 """ # revision identifiers, used by Alembic. revision = '4388de50824a' down_revision = '254ac5fc3941' from alembic import op import sqlalchemy as sa def upgrade(): op.create_table('topic', sa.Column('key', sa.Integer(), nullable=False), sa.Column('name', sa.String(length=255), index=True, nullable=False), sa.Column('sequence', sa.Integer(), index=True, unique=True, nullable=False), sa.PrimaryKeyConstraint('key') ) op.create_table('project_topic', sa.Column('key', sa.Integer(), nullable=False), sa.Column('project_key', sa.Integer(), sa.ForeignKey('project.key'), index=True), sa.Column('topic_key', sa.Integer(), sa.ForeignKey('topic.key'), index=True), sa.Column('sequence', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('key'), sa.UniqueConstraint('topic_key', 'sequence', name='topic_key_sequence_const'), ) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/44402069e137_initial_schema.py0000664000175000017500000002235214667772533026232 0ustar00jamespagejamespage"""Initial schema Revision ID: 44402069e137 Revises: None Create Date: 2014-05-04 17:10:23.127702 """ # revision identifiers, used by Alembic. revision = '44402069e137' down_revision = None from alembic import op import sqlalchemy as sa def upgrade(): ### commands auto generated by Alembic - please adjust! ### op.create_table('project', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('name', sa.String(length=255), nullable=False), sa.Column('subscribed', sa.Boolean(), nullable=True), sa.Column('description', sa.Text(), nullable=False), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_project_name'), 'project', ['name'], unique=True) op.create_index(op.f('ix_project_subscribed'), 'project', ['subscribed'], unique=False) op.create_table('change', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('project_key', sa.Integer(), nullable=True), sa.Column('id', sa.String(length=255), nullable=False), sa.Column('number', sa.Integer(), nullable=False), sa.Column('branch', sa.String(length=255), nullable=False), sa.Column('change_id', sa.String(length=255), nullable=False), sa.Column('topic', sa.String(length=255), nullable=True), sa.Column('owner', sa.String(length=255), nullable=True), sa.Column('subject', sa.Text(), nullable=False), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('updated', sa.DateTime(), nullable=False), sa.Column('status', sa.String(length=8), nullable=False), sa.Column('hidden', sa.Boolean(), nullable=False), sa.Column('reviewed', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['project_key'], ['project.key'], ), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_change_branch'), 'change', ['branch'], unique=False) op.create_index(op.f('ix_change_change_id'), 'change', ['change_id'], unique=False) op.create_index(op.f('ix_change_created'), 'change', ['created'], unique=False) op.create_index(op.f('ix_change_hidden'), 'change', ['hidden'], unique=False) op.create_index(op.f('ix_change_id'), 'change', ['id'], unique=True) op.create_index(op.f('ix_change_number'), 'change', ['number'], unique=True) op.create_index(op.f('ix_change_owner'), 'change', ['owner'], unique=False) op.create_index(op.f('ix_change_project_key'), 'change', ['project_key'], unique=False) op.create_index(op.f('ix_change_reviewed'), 'change', ['reviewed'], unique=False) op.create_index(op.f('ix_change_status'), 'change', ['status'], unique=False) op.create_index(op.f('ix_change_topic'), 'change', ['topic'], unique=False) op.create_index(op.f('ix_change_updated'), 'change', ['updated'], unique=False) op.create_table('approval', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('change_key', sa.Integer(), nullable=True), sa.Column('name', sa.String(length=255), nullable=True), sa.Column('category', sa.String(length=255), nullable=False), sa.Column('value', sa.Integer(), nullable=False), sa.Column('pending', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['change_key'], ['change.key'], ), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_approval_change_key'), 'approval', ['change_key'], unique=False) op.create_index(op.f('ix_approval_pending'), 'approval', ['pending'], unique=False) op.create_table('revision', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('change_key', sa.Integer(), nullable=True), sa.Column('number', sa.Integer(), nullable=False), sa.Column('message', sa.Text(), nullable=False), sa.Column('commit', sa.String(length=255), nullable=False), sa.Column('parent', sa.String(length=255), nullable=False), sa.ForeignKeyConstraint(['change_key'], ['change.key'], ), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_revision_change_key'), 'revision', ['change_key'], unique=False) op.create_index(op.f('ix_revision_number'), 'revision', ['number'], unique=False) op.create_table('label', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('change_key', sa.Integer(), nullable=True), sa.Column('category', sa.String(length=255), nullable=False), sa.Column('value', sa.Integer(), nullable=False), sa.Column('description', sa.String(length=255), nullable=False), sa.ForeignKeyConstraint(['change_key'], ['change.key'], ), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_label_change_key'), 'label', ['change_key'], unique=False) op.create_table('permitted_label', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('change_key', sa.Integer(), nullable=True), sa.Column('category', sa.String(length=255), nullable=False), sa.Column('value', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['change_key'], ['change.key'], ), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_permitted_label_change_key'), 'permitted_label', ['change_key'], unique=False) op.create_table('comment', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('revision_key', sa.Integer(), nullable=True), sa.Column('id', sa.String(length=255), nullable=True), sa.Column('in_reply_to', sa.String(length=255), nullable=True), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('name', sa.String(length=255), nullable=True), sa.Column('file', sa.Text(), nullable=False), sa.Column('parent', sa.Boolean(), nullable=False), sa.Column('line', sa.Integer(), nullable=True), sa.Column('message', sa.Text(), nullable=False), sa.Column('pending', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['revision_key'], ['revision.key'], ), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_comment_created'), 'comment', ['created'], unique=False) op.create_index(op.f('ix_comment_id'), 'comment', ['id'], unique=False) op.create_index(op.f('ix_comment_pending'), 'comment', ['pending'], unique=False) op.create_index(op.f('ix_comment_revision_key'), 'comment', ['revision_key'], unique=False) op.create_table('message', sa.Column('key', sa.Integer(), nullable=False, quote=True), sa.Column('revision_key', sa.Integer(), nullable=True), sa.Column('id', sa.String(length=255), nullable=True), sa.Column('created', sa.DateTime(), nullable=False), sa.Column('name', sa.String(length=255), nullable=True), sa.Column('message', sa.Text(), nullable=False), sa.Column('pending', sa.Boolean(), nullable=False), sa.ForeignKeyConstraint(['revision_key'], ['revision.key'], ), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_message_created'), 'message', ['created'], unique=False) op.create_index(op.f('ix_message_id'), 'message', ['id'], unique=False) op.create_index(op.f('ix_message_pending'), 'message', ['pending'], unique=False) op.create_index(op.f('ix_message_revision_key'), 'message', ['revision_key'], unique=False) ### end Alembic commands ### def downgrade(): ### commands auto generated by Alembic - please adjust! ### op.drop_index(op.f('ix_message_revision_key'), table_name='message') op.drop_index(op.f('ix_message_pending'), table_name='message') op.drop_index(op.f('ix_message_id'), table_name='message') op.drop_index(op.f('ix_message_created'), table_name='message') op.drop_table('message') op.drop_index(op.f('ix_comment_revision_key'), table_name='comment') op.drop_index(op.f('ix_comment_pending'), table_name='comment') op.drop_index(op.f('ix_comment_id'), table_name='comment') op.drop_index(op.f('ix_comment_created'), table_name='comment') op.drop_table('comment') op.drop_index(op.f('ix_permitted_label_change_key'), table_name='permitted_label') op.drop_table('permitted_label') op.drop_index(op.f('ix_label_change_key'), table_name='label') op.drop_table('label') op.drop_index(op.f('ix_revision_number'), table_name='revision') op.drop_index(op.f('ix_revision_change_key'), table_name='revision') op.drop_table('revision') op.drop_index(op.f('ix_approval_pending'), table_name='approval') op.drop_index(op.f('ix_approval_change_key'), table_name='approval') op.drop_table('approval') op.drop_index(op.f('ix_change_updated'), table_name='change') op.drop_index(op.f('ix_change_topic'), table_name='change') op.drop_index(op.f('ix_change_status'), table_name='change') op.drop_index(op.f('ix_change_reviewed'), table_name='change') op.drop_index(op.f('ix_change_project_key'), table_name='change') op.drop_index(op.f('ix_change_owner'), table_name='change') op.drop_index(op.f('ix_change_number'), table_name='change') op.drop_index(op.f('ix_change_id'), table_name='change') op.drop_index(op.f('ix_change_hidden'), table_name='change') op.drop_index(op.f('ix_change_created'), table_name='change') op.drop_index(op.f('ix_change_change_id'), table_name='change') op.drop_index(op.f('ix_change_branch'), table_name='change') op.drop_table('change') op.drop_index(op.f('ix_project_subscribed'), table_name='project') op.drop_index(op.f('ix_project_name'), table_name='project') op.drop_table('project') ### end Alembic commands ### ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/alembic/versions/45d33eccc7a7_add_checks.py0000664000175000017500000000304214667773353025710 0ustar00jamespagejamespage"""add-checks Revision ID: 45d33eccc7a7 Revises: 6f6183367a8f Create Date: 2020-02-20 13:16:22.342039 """ # revision identifiers, used by Alembic. revision = '45d33eccc7a7' down_revision = '6f6183367a8f' from alembic import op import sqlalchemy as sa def upgrade(): op.create_table('checker', sa.Column('key', sa.Integer(), nullable=False), sa.Column('uuid', sa.String(255), index=True, unique=True, nullable=False), sa.Column('name', sa.String(255), nullable=False), sa.Column('status', sa.String(255), nullable=False), sa.Column('blocking', sa.String(255)), sa.Column('description', sa.Text()), sa.PrimaryKeyConstraint('key') ) op.create_table('check', sa.Column('key', sa.Integer(), nullable=False), sa.Column('revision_key', sa.Integer(), index=True), sa.Column('checker_key', sa.Integer(), index=True), sa.Column('state', sa.String(255), nullable=False), sa.Column('url', sa.Text()), sa.Column('message', sa.Text()), sa.Column('started', sa.DateTime()), sa.Column('finished', sa.DateTime()), sa.Column('created', sa.DateTime(), index=True, nullable=False), sa.Column('updated', sa.DateTime(), index=True, nullable=False), sa.PrimaryKeyConstraint('key') ) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/46b175bfa277_add_pending_actions.py0000664000175000017500000000503214667772533027456 0ustar00jamespagejamespage"""add pending actions Revision ID: 46b175bfa277 Revises: 3d429503a29a Create Date: 2014-08-31 09:20:11.789330 """ # revision identifiers, used by Alembic. revision = '46b175bfa277' down_revision = '3d429503a29a' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): op.create_table('branch', sa.Column('key', sa.Integer(), nullable=False), sa.Column('project_key', sa.Integer(), sa.ForeignKey('project.key'), index=True, nullable=False), sa.Column('name', sa.String(length=255), nullable=False), sa.PrimaryKeyConstraint('key') ) op.create_table('pending_cherry_pick', sa.Column('key', sa.Integer(), nullable=False), sa.Column('revision_key', sa.Integer(), sa.ForeignKey('revision.key'), index=True, nullable=False), sa.Column('branch', sa.String(length=255), nullable=False), sa.Column('message', sa.Text(), nullable=False), sa.PrimaryKeyConstraint('key') ) with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('change', sa.Column('pending_rebase', sa.Boolean())) op.add_column('change', sa.Column('pending_topic', sa.Boolean())) op.add_column('change', sa.Column('pending_status', sa.Boolean())) op.add_column('change', sa.Column('pending_status_message', sa.Text())) op.add_column('revision', sa.Column('pending_message', sa.Boolean())) connection = op.get_bind() change = sa.sql.table('change', sa.sql.column('pending_rebase', sa.Boolean()), sa.sql.column('pending_topic', sa.Boolean()), sa.sql.column('pending_status', sa.Boolean())) connection.execute(change.update().values({'pending_rebase':False, 'pending_topic':False, 'pending_status':False})) revision = sa.sql.table('revision', sa.sql.column('pending_message', sa.Boolean())) connection.execute(revision.update().values({'pending_message':False})) sqlite_alter_columns('change', [ sa.Column('pending_rebase', sa.Boolean(), index=True, nullable=False), sa.Column('pending_topic', sa.Boolean(), index=True, nullable=False), sa.Column('pending_status', sa.Boolean(), index=True, nullable=False), ]) sqlite_alter_columns('revision', [ sa.Column('pending_message', sa.Boolean(), index=True, nullable=False), ]) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/4a802b741d2f_add_starred.py0000664000175000017500000000216014667772533025744 0ustar00jamespagejamespage"""add starred Revision ID: 4a802b741d2f Revises: 312cd5a9f878 Create Date: 2015-02-12 18:10:19.187733 """ # revision identifiers, used by Alembic. revision = '4a802b741d2f' down_revision = '312cd5a9f878' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('change', sa.Column('starred', sa.Boolean())) op.add_column('change', sa.Column('pending_starred', sa.Boolean())) connection = op.get_bind() change = sa.sql.table('change', sa.sql.column('starred', sa.Boolean()), sa.sql.column('pending_starred', sa.Boolean())) connection.execute(change.update().values({'starred':False, 'pending_starred':False})) sqlite_alter_columns('change', [ sa.Column('starred', sa.Boolean(), index=True, nullable=False), sa.Column('pending_starred', sa.Boolean(), index=True, nullable=False), ]) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/4cc9c46f9d8b_add_account_table.py0000664000175000017500000000531114667772533027270 0ustar00jamespagejamespage"""add account table Revision ID: 4cc9c46f9d8b Revises: 725816dc500 Create Date: 2014-07-23 16:01:47.462597 """ # revision identifiers, used by Alembic. revision = '4cc9c46f9d8b' down_revision = '725816dc500' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns, sqlite_drop_columns def upgrade(): sqlite_drop_columns('message', ['name']) sqlite_drop_columns('comment', ['name']) sqlite_drop_columns('approval', ['name']) sqlite_drop_columns('change', ['owner']) op.create_table('account', sa.Column('key', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), index=True, unique=True, nullable=False), sa.Column('name', sa.String(length=255)), sa.Column('username', sa.String(length=255)), sa.Column('email', sa.String(length=255)), sa.PrimaryKeyConstraint('key') ) op.create_index(op.f('ix_account_name'), 'account', ['name'], unique=True) op.create_index(op.f('ix_account_username'), 'account', ['name'], unique=True) op.create_index(op.f('ix_account_email'), 'account', ['name'], unique=True) with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('message', sa.Column('account_key', sa.Integer())) op.add_column('comment', sa.Column('account_key', sa.Integer())) op.add_column('approval', sa.Column('account_key', sa.Integer())) op.add_column('change', sa.Column('account_key', sa.Integer())) sqlite_alter_columns('message', [ sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key')) ]) sqlite_alter_columns('comment', [ sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key')) ]) sqlite_alter_columns('approval', [ sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key')) ]) sqlite_alter_columns('change', [ sa.Column('account_key', sa.Integer(), sa.ForeignKey('account.key')) ]) op.create_index(op.f('ix_message_account_key'), 'message', ['account_key'], unique=False) op.create_index(op.f('ix_comment_account_key'), 'comment', ['account_key'], unique=False) op.create_index(op.f('ix_approval_account_key'), 'approval', ['account_key'], unique=False) op.create_index(op.f('ix_change_account_key'), 'change', ['account_key'], unique=False) connection = op.get_bind() project = sa.sql.table('project', sa.sql.column('updated', sa.DateTime)) connection.execute(project.update().values({'updated':None})) approval = sa.sql.table('approval', sa.sql.column('pending')) connection.execute(approval.delete().where(approval.c.pending==False)) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/50344aecd1c2_add_files_table.py0000664000175000017500000000655514667772533026637 0ustar00jamespagejamespage"""add files table Revision ID: 50344aecd1c2 Revises: 1bb187bcd401 Create Date: 2015-04-13 08:08:08.682803 """ # revision identifiers, used by Alembic. revision = '50344aecd1c2' down_revision = '1bb187bcd401' import re import sys from alembic import op, context import sqlalchemy as sa import git.exc import gertty.db import gertty.gitrepo def upgrade(): op.create_table('file', sa.Column('key', sa.Integer(), nullable=False), sa.Column('revision_key', sa.Integer(), nullable=False, index=True), sa.Column('path', sa.Text(), nullable=False, index=True), sa.Column('old_path', sa.Text(), index=True), sa.Column('status', sa.String(length=1)), sa.Column('inserted', sa.Integer()), sa.Column('deleted', sa.Integer()), sa.PrimaryKeyConstraint('key') ) pathre = re.compile('((.*?)\{|^)(.*?) => (.*?)(\}(.*)|$)') insert = sa.text('insert into file (key, revision_key, path, old_path, status, inserted, deleted) ' ' values (NULL, :revision_key, :path, :old_path, :status, :inserted, :deleted)') conn = op.get_bind() countres = conn.execute('select count(*) from revision') revisions = countres.fetchone()[0] if revisions > 50: print('') print('Adding support for searching for changes by file modified. ' 'This may take a while.') qres = conn.execute('select p.name, c.number, c.status, r.key, r.number, r."commit", r.parent from project p, change c, revision r ' 'where r.change_key=c.key and c.project_key=p.key order by p.name') count = 0 for (pname, cnumber, cstatus, rkey, rnumber, commit, parent) in qres.fetchall(): count += 1 sys.stdout.write('Diffstat revision %s / %s\r' % (count, revisions)) sys.stdout.flush() ires = conn.execute(insert, revision_key=rkey, path='/COMMIT_MSG', old_path=None, status=None, inserted=None, deleted=None) repo = gertty.gitrepo.get_repo(pname, context.config.gertty_app.config) try: stats = repo.diffstat(parent, commit) except git.exc.GitCommandError: # Probably a missing commit if cstatus not in ['MERGED', 'ABANDONED']: print("Unable to examine diff for %s %s change %s,%s" % (cstatus, pname, cnumber, rnumber)) continue for stat in stats: try: (added, removed, path) = stat except ValueError: if cstatus not in ['MERGED', 'ABANDONED']: print("Empty diffstat for %s %s change %s,%s" % (cstatus, pname, cnumber, rnumber)) m = pathre.match(path) status = gertty.db.File.STATUS_MODIFIED old_path = None if m: status = gertty.db.File.STATUS_RENAMED pre = m.group(2) or '' post = m.group(6) or '' old_path = pre+m.group(3)+post path = pre+m.group(4)+post try: added = int(added) except ValueError: added = None try: removed = int(removed) except ValueError: removed = None conn.execute(insert, revision_key=rkey, path=path, old_path=old_path, status=status, inserted=added, deleted=removed) print('') def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/56e48a4a064a_increase_status_field_width.py0000664000175000017500000000107414667772533031235 0ustar00jamespagejamespage"""Increase status field width Revision ID: 56e48a4a064a Revises: 44402069e137 Create Date: 2014-05-05 11:49:42.133569 """ # revision identifiers, used by Alembic. revision = '56e48a4a064a' down_revision = '44402069e137' import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): sqlite_alter_columns('change', [ sa.Column('status', sa.String(16), index=True, nullable=False) ]) def downgrade(): sqlite_alter_columns('change', [ sa.Column('status', sa.String(8), index=True, nullable=False) ]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/alembic/versions/6f6183367a8f_robot_comments.py0000664000175000017500000000122214667773353026465 0ustar00jamespagejamespage"""robot-comments Revision ID: 6f6183367a8f Revises: a18731009699 Create Date: 2020-02-20 10:11:56.409361 """ # revision identifiers, used by Alembic. revision = '6f6183367a8f' down_revision = 'a18731009699' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('comment', sa.Column('robot_id', sa.String(255))) op.add_column('comment', sa.Column('robot_run_id', sa.String(255))) op.add_column('comment', sa.Column('url', sa.Text())) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/725816dc500_add_fetch_ref_column.py0000664000175000017500000000241314667772533027363 0ustar00jamespagejamespage"""Add fetch ref column Revision ID: 725816dc500 Revises: 38104b4c1b84 Create Date: 2014-05-31 14:51:08.078616 """ # revision identifiers, used by Alembic. revision = '725816dc500' down_revision = '38104b4c1b84' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('revision', sa.Column('fetch_auth', sa.Boolean())) op.add_column('revision', sa.Column('fetch_ref', sa.String(length=255))) conn = op.get_bind() res = conn.execute('select r.key, r.number, c.number from revision r, "change" c where r.change_key=c.key') for (rkey, rnumber, cnumber) in res.fetchall(): q = sa.text('update revision set fetch_auth=:auth, fetch_ref=:ref where "key"=:key') ref = 'refs/changes/%s/%s/%s' % (str(cnumber)[-2:], cnumber, rnumber) res = conn.execute(q, key=rkey, ref=ref, auth=False) sqlite_alter_columns('revision', [ sa.Column('fetch_auth', sa.Boolean(), nullable=False), sa.Column('fetch_ref', sa.String(length=255), nullable=False) ]) def downgrade(): op.drop_column('revision', 'fetch_auth') op.drop_column('revision', 'fetch_ref') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic/versions/7ef7dfa2ca3a_add_change_outdated.py0000664000175000017500000000151614667772533027727 0ustar00jamespagejamespage"""add change.outdated Revision ID: 7ef7dfa2ca3a Revises: 37a702b7f58e Create Date: 2016-08-09 08:59:04.441926 """ # revision identifiers, used by Alembic. revision = '7ef7dfa2ca3a' down_revision = '37a702b7f58e' import warnings from alembic import op import sqlalchemy as sa from gertty.dbsupport import sqlite_alter_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('change', sa.Column('outdated', sa.Boolean())) connection = op.get_bind() change = sa.sql.table('change', sa.sql.column('outdated', sa.Boolean())) connection.execute(change.update().values({'outdated':False})) sqlite_alter_columns('change', [ sa.Column('outdated', sa.Boolean(), index=True, nullable=False), ]) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/alembic/versions/a18731009699_add_server_table.py0000664000175000017500000000101314667773353026547 0ustar00jamespagejamespage"""add_server_table Revision ID: a18731009699 Revises: 399c4b3dcc9a Create Date: 2019-08-28 14:12:22.657691 """ # revision identifiers, used by Alembic. revision = 'a18731009699' down_revision = '399c4b3dcc9a' from alembic import op import sqlalchemy as sa def upgrade(): op.create_table('server', sa.Column('key', sa.Integer(), nullable=False), sa.Column('own_account_key', sa.Integer(), sa.ForeignKey('own_account.key'), index=True), sa.PrimaryKeyConstraint('key') ) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/alembic/versions/ad440301e47f_wip.py0000664000175000017500000000222514667773353024270 0ustar00jamespagejamespage"""wip Revision ID: ad440301e47f Revises: 02ca927a2b55 Create Date: 2020-12-18 10:42:07.689266 """ # revision identifiers, used by Alembic. revision = 'ad440301e47f' down_revision = '02ca927a2b55' from alembic import op import sqlalchemy as sa import warnings from gertty.dbsupport import sqlite_alter_columns def upgrade(): with warnings.catch_warnings(): warnings.simplefilter("ignore") op.add_column('change', sa.Column('wip', sa.Boolean())) op.add_column('change', sa.Column('pending_wip', sa.Boolean())) op.add_column('change', sa.Column('pending_wip_message', sa.Text())) connection = op.get_bind() change = sa.sql.table('change', sa.sql.column('wip', sa.Boolean()), sa.sql.column('pending_wip', sa.Boolean())) connection.execute(change.update().values({'wip':False, 'pending_wip':False})) sqlite_alter_columns('change', [ sa.Column('wip', sa.Boolean(), index=True, nullable=False), sa.Column('pending_wip', sa.Boolean(), index=True, nullable=False), ]) def downgrade(): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/alembic.ini0000664000175000017500000000212214667772533020135 0ustar00jamespagejamespage# A generic, single database configuration. [alembic] # path to migration scripts script_location = alembic # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # max length of characters to apply to the # "slug" field #truncate_slug_length = 40 # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # set to 'true' to allow .pyc and .pyo files without # a source .py file to be detected as revisions in the # versions/ directory # sourceless = false sqlalchemy.url = sqlite:////tmp/gertty.db # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/app.py0000664000175000017500000011035014667773353017176 0ustar00jamespagejamespage# 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 argparse import datetime import dateutil import fcntl import functools import logging import os import re import socket import subprocess import sys import textwrap import threading import warnings import webbrowser import six from six.moves import queue from six.moves.urllib import parse as urlparse import sqlalchemy.exc import urwid # To aid debugging cursor positions # urwid.escape.HIDE_CURSOR='' 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 WELCOME_TEXT = """\ Welcome to Gertty! 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. Press the F1 key anywhere to get help. Your terminal emulator may require you to press function-F1 or alt-F1 instead. """ class StatusHeader(urwid.WidgetWrap): def __init__(self, app): super(StatusHeader, self).__init__(urwid.Columns([])) self.app = app self.title_widget = urwid.Text(u'Start') self.error_widget = urwid.Text('') self.offline_widget = urwid.Text('') self.sync_widget = urwid.Text(u'Sync: 0') self.held_widget = urwid.Text(u'') self._w.contents.append((self.title_widget, ('pack', None, False))) self._w.contents.append((urwid.Text(u''), ('weight', 1, False))) self._w.contents.append((self.held_widget, ('pack', None, False))) self._w.contents.append((self.error_widget, ('pack', None, False))) self._w.contents.append((self.offline_widget, ('pack', None, False))) self._w.contents.append((self.sync_widget, ('pack', None, False))) self.error = None self.offline = None self.title = None self.message = None self.sync = None self.held = None self._error = False self._offline = False self._title = '' self._message = '' self._sync = 0 self._held = 0 self.held_key = self.app.config.keymap.formatKeys(keymap.LIST_HELD) def update(self, title=None, message=None, error=None, offline=None, refresh=True, held=None): if title is not None: self.title = title if message is not None: self.message = message if error is not None: self.error = error if offline is not None: self.offline = offline if held is not None: self.held = held self.sync = self.app.sync.queue.qsize() if refresh: self.refresh() def refresh(self): if (self._title != self.title or self._message != self.message): self._title = self.title self._message = self.message t = self.message or self.title self.title_widget.set_text(t) if self._held != self.held: self._held = self.held if self._held: self.held_widget.set_text(('error', u'Held: %s (%s)' % (self._held, self.held_key))) else: self.held_widget.set_text(u'') if self._error != self.error: self._error = self.error if self._error: self.error_widget.set_text(('error', u' Error')) else: self.error_widget.set_text(u'') if self._offline != self.offline: self._offline = self.offline if self._offline: self.offline_widget.set_text(u' Offline') else: self.offline_widget.set_text(u'') if self._sync != self.sync: self._sync = self.sync self.sync_widget.set_text(u' Sync: %i' % self._sync) class BreadCrumbBar(urwid.WidgetWrap): BREADCRUMB_SYMBOL = u'\N{BLACK RIGHT-POINTING SMALL TRIANGLE}' BREADCRUMB_WIDTH = 25 def __init__(self): self.prefix_text = urwid.Text(u' \N{WATCH} ') self.breadcrumbs = urwid.Columns([], dividechars=3) self.display_widget = urwid.Columns( [('pack', self.prefix_text), self.breadcrumbs]) super(BreadCrumbBar, self).__init__(self.display_widget) def _get_breadcrumb_text(self, screen): title = getattr(screen, 'short_title', None) if not title: title = getattr(screen, 'title', str(screen)) text = "%s %s" % (BreadCrumbBar.BREADCRUMB_SYMBOL, title) if len(text) > 23: text = "%s..." % text[:20] return urwid.Text(text, wrap='clip') def _get_breadcrumb_column_options(self): return self.breadcrumbs.options("given", BreadCrumbBar.BREADCRUMB_WIDTH) def _update(self, screens): breadcrumb_contents = [] for screen in screens: breadcrumb_contents.append(( self._get_breadcrumb_text(screen), self._get_breadcrumb_column_options())) self.breadcrumbs.contents = breadcrumb_contents # Update focus so we always have the right end of the breadcrumb trail # in view. Urwid will gracefully handle clipping from the left when # there is overflow.as trail grows, shrinks, or screen is resized. if len(self.breadcrumbs.contents): self.breadcrumbs.focus_position = len(self.breadcrumbs.contents) - 1 class SearchDialog(mywid.ButtonDialog): signals = ['search', 'cancel'] def __init__(self, app, default): self.app = app search_button = mywid.FixedButton('Search') cancel_button = mywid.FixedButton('Cancel') urwid.connect_signal(search_button, 'click', lambda button:self._emit('search')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) super(SearchDialog, self).__init__("Search", "Enter a change number or search string.", entry_prompt="Search: ", entry_text=default, buttons=[search_button, cancel_button], ring=app.ring) def keypress(self, size, key): if not self.app.input_buffer: key = super(SearchDialog, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if keymap.ACTIVATE in commands: self._emit('search') return None return key # From: cpython/file/2.7/Lib/webbrowser.py with modification to # redirect stdin/out/err. class BackgroundBrowser(webbrowser.GenericBrowser): """Class for all browsers which are to be started in the background.""" def open(self, url, new=0, autoraise=True): cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] inout = open(os.devnull, "r+") try: if sys.platform[:3] == 'win': p = subprocess.Popen(cmdline) else: setsid = getattr(os, 'setsid', None) if not setsid: setsid = getattr(os, 'setpgrp', None) p = subprocess.Popen(cmdline, close_fds=True, stdin=inout, stdout=inout, stderr=inout, preexec_fn=setsid) return (p.poll() is None) except OSError: return False class ProjectCache(object): def __init__(self): self.projects = {} 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), ) return self.projects[project.key] def clear(self, project): if project.key in self.projects: del self.projects[project.key] class App(object): simple_change_search = re.compile('^(\d+|I[a-fA-F0-9]{40})$') def __init__(self, server=None, palette='default', keymap='default', debug=False, verbose=False, disable_sync=False, disable_background_sync=False, fetch_missing_refs=False, path=None): self.server = server self.config = config.Config(server, palette, keymap, path) if debug: level = logging.DEBUG elif verbose: level = logging.INFO else: level = logging.WARNING logging.basicConfig(filename=self.config.log_file, filemode='w', format='%(asctime)s %(message)s', level=level) # Python2.6 Logger.setLevel doesn't convert string name # to integer code. Here, we set the requests logger level to # be less verbose, since our logging output duplicates some # requests logging content in places. req_level_name = 'WARN' req_logger = logging.getLogger('requests') if sys.version_info < (2, 7): level = logging.getLevelName(req_level_name) req_logger.setLevel(level) else: req_logger.setLevel(req_level_name) self.log = logging.getLogger('gertty.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']) sys.exit(1) self.project_cache = ProjectCache() self.ring = mywid.KillRing() self.input_buffer = [] webbrowser.register('xdg-open', None, BackgroundBrowser("xdg-open")) self.fetch_missing_refs = fetch_missing_refs self.config.keymap.updateCommandMap() self.search = search.SearchCompiler(self.getOwnAccountId) self.db = db.Database(self, self.config.dburi, self.search) self.own_account_id = None with self.db.getSession() as session: account = session.getOwnAccount() if account: self.own_account_id = account.id self.sync = sync.Sync(self, disable_background_sync) self.status = StatusHeader(self) self.header = urwid.AttrMap(self.status, 'header') self.screens = urwid.MonitoredList() self.breadcrumbs = BreadCrumbBar() self.screens.set_modified_callback( functools.partial(self.breadcrumbs._update, self.screens)) if self.config.breadcrumbs: self.footer = urwid.AttrMap(self.breadcrumbs, 'footer') else: self.footer = None screen = view_project_list.ProjectListView(self) self.status.update(title=screen.title) self.updateStatusQueries() self.frame = urwid.Frame(body=screen, footer=self.footer) self.loop = urwid.MainLoop(self.frame, palette=self.config.palette.getPalette(), handle_mouse=self.config.handle_mouse, unhandled_input=self.unhandledInput, input_filter=self.inputFilter) self.sync_pipe = self.loop.watch_pipe(self.refresh) self.error_queue = queue.Queue() self.error_pipe = self.loop.watch_pipe(self._errorPipeInput) self.logged_warnings = set() self.command_pipe = self.loop.watch_pipe(self._commandPipeInput) self.command_queue = queue.Queue() warnings.showwarning = self._showWarning has_subscribed_projects = False with self.db.getSession() as session: if session.getProjects(subscribed=True): has_subscribed_projects = True if not has_subscribed_projects: self.welcome() self.loop.screen.tty_signal_keys(start='undefined', stop='undefined') #self.loop.screen.set_terminal_properties(colors=88) self.startSocketListener() if not disable_sync: self.sync_thread = threading.Thread(target=self.sync.run, args=(self.sync_pipe,)) self.sync_thread.daemon = True self.sync_thread.start() else: self.sync_thread = None self.sync.offline = True self.status.update(offline=True) def getOwnAccountId(self): return self.own_account_id def isOwnAccount(self, account): return account.id == self.own_account_id def run(self): try: self.loop.run() except KeyboardInterrupt: pass def _quit(self, widget=None): raise urwid.ExitMainLoop() def quit(self): dialog = mywid.YesNoDialog(u'Quit', u'Are you sure you want to quit?') urwid.connect_signal(dialog, 'no', self.backScreen) urwid.connect_signal(dialog, 'yes', self._quit) self.popup(dialog) def startSocketListener(self): if os.path.exists(self.config.socket_path): os.unlink(self.config.socket_path) self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.socket.bind(self.config.socket_path) self.socket.listen(1) self.socket_thread = threading.Thread(target=self._socketListener) self.socket_thread.daemon = True self.socket_thread.start() def _socketListener(self): while True: try: s, addr = self.socket.accept() self.log.debug("Accepted socket connection %s" % (s,)) buf = b'' while True: buf += s.recv(1) if buf[-1] == 10: break buf = buf.decode('utf8').strip() self.log.debug("Received %s from socket" % (buf,)) s.close() parts = buf.split() self.command_queue.put((parts[0], parts[1:])) os.write(self.command_pipe, six.b('command\n')) except Exception: self.log.exception("Exception in socket handler") def clearInputBuffer(self): if self.input_buffer: self.input_buffer = [] self.status.update(message='') def changeScreen(self, widget, push=True): self.log.debug("Changing screen to %s" % (widget,)) self.status.update(error=False, title=widget.title) if push: self.screens.append(self.frame.body) self.clearInputBuffer() self.frame.body = widget def getPreviousScreen(self): if not self.screens: return None return self.screens[-1] def backScreen(self, target_widget=None): if not self.screens: return while self.screens: widget = self.screens.pop() if (not target_widget) or (widget is target_widget): break self.log.debug("Popping screen to %s" % (widget,)) if hasattr(widget, 'title'): self.status.update(title=widget.title) self.clearInputBuffer() self.frame.body = widget self.refresh(force=True) def findChangeList(self): for widget in reversed(self.screens): if isinstance(widget, view_change_list.ChangeListView): return widget return None def clearHistory(self): self.log.debug("Clearing screen history") while self.screens: widget = self.screens.pop() self.clearInputBuffer() self.frame.body = widget def refresh(self, data=None, force=False): widget = self.frame.body while isinstance(widget, urwid.Overlay): widget = widget.contents[0][0] interested = force invalidate = False try: while True: event = self.sync.result_queue.get(0) if widget.interested(event): interested = True if hasattr(event, 'held_changed') and event.held_changed: invalidate = True except queue.Empty: pass if interested: widget.refresh() if invalidate: self.updateStatusQueries() self.status.refresh() def updateStatusQueries(self): with self.db.getSession() as session: held = len(session.getHeld()) self.status.update(held=held) def popup(self, widget, relative_width=50, relative_height=25, min_width=20, min_height=8, width=None, height=None): self.clearInputBuffer() if width is None: width = ('relative', relative_width) if height is None: height = ('relative', relative_height) overlay = urwid.Overlay(widget, self.frame.body, 'center', width, 'middle', height, min_width=min_width, min_height=min_height) if hasattr(widget, 'title'): overlay.title = widget.title self.log.debug("Overlaying %s on screen %s" % (widget, self.frame.body)) self.screens.append(self.frame.body) self.frame.body = overlay def getGlobalCommands(self): return list(mywid.GLOBAL_HELP) def getGlobalHelp(self): keys = [(k, self.config.keymap.formatKeys(k), t) for (k, t) in self.getGlobalCommands()] for d in self.config.dashboards.values(): keys.append(('', d['key'], d['name'])) return keys def help(self): if not hasattr(self.frame.body, 'help'): return global_help = self.getGlobalHelp() parts = [('Global Keys', global_help), ('This Screen', self.frame.body.help())] keylen = 0 for title, items in parts: for cmd, keys, text in items: keylen = max(len(keys), keylen) text = '' for title, items in parts: if text: text += '\n' text += title+'\n' text += '%s\n' % ('='*len(title),) for cmd, keys, cmdtext in items: text += '{keys:{width}} {text}\n'.format( keys=keys, width=keylen, text=cmdtext) dialog = mywid.MessageDialog('Help for %s' % version(), text) lines = text.split('\n') urwid.connect_signal(dialog, 'close', lambda button: self.backScreen()) self.popup(dialog, min_width=76, min_height=len(lines)+4) def welcome(self): text = WELCOME_TEXT dialog = mywid.MessageDialog('Welcome', text) lines = text.split('\n') urwid.connect_signal(dialog, 'close', lambda button: self.backScreen()) self.popup(dialog, min_width=76, min_height=len(lines)+4) def _syncOneChangeFromQuery(self, query): number = changeid = restid = None if query.startswith("change:"): number = query.split(':')[1].strip() try: number = int(number) except ValueError: number = None changeid = query.split(':')[1].strip() if not (number or changeid): return with self.db.getSession() as session: if number: changes = [session.getChangeByNumber(number)] elif changeid: changes = session.getChangesByChangeID(changeid) change_keys = [c.key for c in changes if c] restids = [c.id for c in changes if c] if not change_keys: if self.sync.offline: raise Exception('Can not sync change while offline.') dialog = mywid.SystemMessage("Syncing change...") self.popup(dialog, width=40, height=6) self.loop.draw_screen() try: task = sync.SyncChangeByNumberTask(number or changeid, sync.HIGH_PRIORITY) self.sync.submitTask(task) succeeded = task.wait(300) if not succeeded: raise Exception('Unable to find change.') for subtask in task.tasks: succeeded = subtask.wait(300) if not succeeded: raise Exception('Unable to sync change.') finally: # Remove "syncing..." popup self.backScreen() with self.db.getSession() as session: if number: changes = [session.getChangeByNumber(number)] elif changeid: changes = session.getChangesByChangeID(changeid) change_keys = [c.key for c in changes if c] elif restids: for restid in restids: task = sync.SyncChangeTask(restid, sync.HIGH_PRIORITY) self.sync.submitTask(task) if not change_keys: raise Exception('Change is not in local database.') def doSearch(self, query): self.log.debug("Search query: %s" % query) try: self._syncOneChangeFromQuery(query) except Exception as e: return self.error(str(e)) with self.db.getSession() as session: try: changes = session.getChanges(query) except gertty.search.SearchSyntaxError as e: return self.error(e.message) except sqlalchemy.exc.OperationalError as e: return self.error(e.message) except Exception as e: return self.error(str(e)) change_key = None if len(changes) == 1: change_key = changes[0].key try: if change_key: view = view_change.ChangeView(self, change_key) else: view = view_change_list.ChangeListView(self, query) self.changeScreen(view) except gertty.view.DisplayError as e: return self.error(e.message) def searchDialog(self, default): dialog = SearchDialog(self, default) urwid.connect_signal(dialog, 'cancel', lambda button: self.backScreen()) urwid.connect_signal(dialog, 'search', lambda button: self._searchDialog(dialog)) self.popup(dialog, min_width=76, min_height=8) def _searchDialog(self, dialog): self.backScreen() query = dialog.entry.edit_text.strip() if self.simple_change_search.match(query): query = 'change:%s' % query else: result = self.parseInternalURL(query) if result is not None: return self.openInternalURL(result) self.doSearch(query) trailing_filename_re = re.compile('.*(,[a-z]+)') def parseInternalURL(self, url): if not url.startswith(self.config.url): return None result = urlparse.urlparse(url) change = patchset = filename = None path = [x for x in result.path.split('/') if x] if path: if path[0] == 'c': while path: path.pop(0) if path[0] == '+': path.pop(0) change = path.pop(0) break else: change = path[0] else: path = [x for x in result.fragment.split('/') if x] if path[0] == 'c': path.pop(0) while path: if not change: change = path.pop(0) continue if not patchset: patchset = path.pop(0) continue if not filename: filename = '/'.join(path) m = self.trailing_filename_re.match(filename) if m: filename = filename[:0-len(m.group(1))] path = None return (change, patchset, filename) def openInternalURL(self, result): (change, patchset, filename) = result # TODO: support deep-linking to a filename self.doSearch('change:%s' % change) def error(self, message, title='Error'): dialog = mywid.MessageDialog(title, message) urwid.connect_signal(dialog, 'close', lambda button: self.backScreen()) cols, rows = self.loop.screen.get_cols_rows() cols = int(cols*.5) lines = textwrap.wrap(message, cols) min_height = max(4, len(lines)+4) self.popup(dialog, min_height=min_height) return None def inputFilter(self, keys, raw): if 'window resize' in keys: m = getattr(self.frame.body, 'onResize', None) if m: m() return keys def unhandledInput(self, key): # get commands from buffer keys = self.input_buffer + [key] commands = self.config.keymap.getCommands(keys) if keymap.PREV_SCREEN in commands: self.backScreen() elif keymap.TOP_SCREEN in commands: self.clearHistory() self.refresh(force=True) elif keymap.HELP in commands: self.help() elif keymap.QUIT in commands: self.quit() elif keymap.CHANGE_SEARCH in commands: self.searchDialog('') 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')) self.changeScreen(view) elif keymap.FURTHER_INPUT in commands: self.input_buffer.append(key) msg = ''.join(self.input_buffer) commands = dict(self.getGlobalCommands()) if hasattr(self.frame.body, 'getCommands'): commands.update(dict(self.frame.body.getCommands())) further_commands = self.config.keymap.getFurtherCommands(keys) completions = [] for (key, cmds) in further_commands: for cmd in cmds: if cmd in commands: completions.append(key) completions = ' '.join(completions) msg = '%s: %s' % (msg, completions) self.status.update(message=msg) return self.clearInputBuffer() def openURL(self, url): self.log.debug("Open URL %s" % url) webbrowser.open_new_tab(url) self.loop.screen.clear() def time(self, dt): utc = dt.replace(tzinfo=dateutil.tz.tzutc()) if self.config.utc: return utc local = utc.astimezone(dateutil.tz.tzlocal()) return local def _errorPipeInput(self, data=None): (title, message) = self.error_queue.get() self.error(message, title=title) def _showWarning(self, message, category, filename, lineno, file=None, line=None): # Don't display repeat warnings if str(message) in self.logged_warnings: return m = warnings.formatwarning(message, category, filename, lineno, line) self.log.warning(m) self.logged_warnings.add(str(message)) # Log this warning, but never display it to the user; it is # nearly un-actionable. if category == requestsexceptions.InsecurePlatformWarning: return if category == requestsexceptions.SNIMissingWarning: return # Disable InsecureRequestWarning when certificate validation is disabled if not self.config.verify_ssl: if category == requestsexceptions.InsecureRequestWarning: return self.error_queue.put(('Warning', m)) os.write(self.error_pipe, six.b('error\n')) def _commandPipeInput(self, data=None): (command, data) = self.command_queue.get() if command == 'open': url = data[0] self.log.debug("Opening URL %s" % (url,)) result = self.parseInternalURL(url) if result is not None: self.openInternalURL(result) else: self.log.error("Unable to parse command %s with data %s" % (command, data)) def toggleHeldChange(self, change_key): with self.db.getSession() as session: change = session.getChange(change_key) change.held = not change.held ret = change.held if not change.held: for r in change.revisions: for m in change.messages: if m.pending: self.sync.submitTask( sync.UploadReviewTask(m.key, sync.HIGH_PRIORITY)) 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 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 saveReviews(self, revision_keys, approvals, message, upload, submit): message_keys = [] with self.db.getSession() as session: account = session.getOwnAccount() for revision_key in revision_keys: k = self._saveReview(session, account, revision_key, approvals, message, upload, submit) if k: message_keys.append(k) return message_keys def _saveReview(self, session, account, revision_key, approvals, message, upload, submit): message_key = None revision = session.getRevision(revision_key) change = revision.change draft_approvals = {} for approval in change.draft_approvals: draft_approvals[approval.category] = approval categories = set() for label in change.permitted_labels: categories.add(label.category) for category in categories: value = approvals.get(category, 0) approval = draft_approvals.get(category) if not approval: approval = change.createApproval(account, category, 0, draft=True) draft_approvals[category] = approval approval.value = value draft_message = revision.getPendingMessage() if not draft_message: draft_message = revision.getDraftMessage() if not draft_message: if message or upload: draft_message = revision.createMessage(None, account, datetime.datetime.utcnow(), '', draft=True) if draft_message: draft_message.created = datetime.datetime.utcnow() draft_message.message = message draft_message.pending = upload message_key = draft_message.key if upload: change.reviewed = True self.project_cache.clear(change.project) if submit: change.status = 'SUBMITTED' change.pending_status = True change.pending_status_message = None return message_key def version(): return "Gertty version: %s" % gertty.version.version_info.release_string() class PrintKeymapAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): for cmd in sorted(keymap.DEFAULT_KEYMAP.keys()): print(cmd.replace(' ', '-')) sys.exit(0) class PrintPaletteAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): for attr in sorted(palette.DEFAULT_PALETTE.keys()): print(attr) sys.exit(0) class OpenChangeAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): cf = config.Config(namespace.server, namespace.palette, namespace.keymap, namespace.path) url = values[0] result = urlparse.urlparse(values[0]) if not url.startswith(cf.url): print('Supplied URL must start with %s' % (cf.url,)) sys.exit(1) s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(cf.socket_path) s.sendall(('open %s\n' % url).encode('utf8')) sys.exit(0) def main(): parser = argparse.ArgumentParser( description='Console client for Gerrit Code Review.') parser.add_argument('-c', dest='path', help='path to config file') parser.add_argument('-v', dest='verbose', action='store_true', help='enable more verbose logging') parser.add_argument('-d', dest='debug', action='store_true', help='enable debug logging') parser.add_argument('--no-sync', dest='no_sync', action='store_true', help='disable remote syncing') parser.add_argument('--debug-sync', dest='debug_sync', action='store_true', help='disable most background sync tasks for debugging') parser.add_argument('--fetch-missing-refs', dest='fetch_missing_refs', action='store_true', help='fetch any refs missing from local repos') parser.add_argument('--print-keymap', nargs=0, action=PrintKeymapAction, help='print the keymap command names to stdout') parser.add_argument('--print-palette', nargs=0, action=PrintPaletteAction, 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') parser.add_argument('--version', dest='version', action='version', version=version(), help='show Gertty\'s version') parser.add_argument('-p', dest='palette', default='default', help='color palette to use') parser.add_argument('-k', dest='keymap', default='default', help='keymap to use') parser.add_argument('server', nargs='?', help='the server to use (as specified in config file)') args = parser.parse_args() g = App(args.server, args.palette, args.keymap, args.debug, args.verbose, args.no_sync, args.debug_sync, args.fetch_missing_refs, args.path) g.run() if __name__ == '__main__': main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/auth.py0000664000175000017500000000456314667772533017366 0ustar00jamespagejamespage# Copyright 2015 Christoph Gysin # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import logging import requests from six.moves.urllib import parse as urlparse class FormAuth(requests.auth.AuthBase): def __init__(self, username, password): self.username = username self.password = password self.log = logging.getLogger('gertty.auth') def _retry_using_form_auth(self, response, args): adapter = requests.adapters.HTTPAdapter() request = _copy_request(response.request) u = urlparse.urlparse(response.url) url = urlparse.urlunparse([u.scheme, u.netloc, '/login', None, None, None]) auth = {'username': self.username, 'password': self.password} request2 = requests.Request('POST', url, data=auth).prepare() response2 = adapter.send(request2, **args) if response2.status_code == 401: self.log.error('Login failed: Invalid username or password?') return response cookie = response2.headers.get('set-cookie') if cookie is not None: request.headers['Cookie'] = cookie response3 = adapter.send(request, **args) return response3 def _response_hook(self, response, **kwargs): if response.status_code == 401: return self._retry_using_form_auth(response, kwargs) return response def __call__(self, request): request.headers["Connection"] = "Keep-Alive" request.register_hook('response', self._response_hook) return request def _copy_request(request): new_request = requests.PreparedRequest() new_request.method = request.method new_request.url = request.url new_request.body = request.body new_request.hooks = request.hooks new_request.headers = request.headers.copy() return new_request ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/commentlink.py0000664000175000017500000000773614667772533020752 0ustar00jamespagejamespage# Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import collections try: import ordereddict except: pass import re import six import urwid from gertty import mywid try: OrderedDict = collections.OrderedDict except AttributeError: OrderedDict = ordereddict.OrderedDict class TextReplacement(object): def __init__(self, config): if isinstance(config, six.string_types): self.color = None self.text = config else: self.color = config.get('color') self.text = config['text'] def replace(self, app, data): if self.color: return (self.color.format(**data), self.text.format(**data)) return (None, self.text.format(**data)) class LinkReplacement(object): def __init__(self, config): self.url = config['url'] self.text = config['text'] def replace(self, app, data): link = mywid.Link(self.text.format(**data), 'link', 'focused-link') urwid.connect_signal(link, 'selected', lambda link:self.activate(app, self.url.format(**data))) return link def activate(self, app, url): result = app.parseInternalURL(url) if result is not None: return app.openInternalURL(result) return app.openURL(url) class SearchReplacement(object): def __init__(self, config): self.query = config['query'] self.text = config['text'] def replace(self, app, data): link = mywid.Link(self.text.format(**data), 'link', 'focused-link') urwid.connect_signal(link, 'selected', lambda link:app.doSearch(self.query.format(**data))) return link class CommentLink(object): def __init__(self, config): self.match = re.compile(config['match'], re.M) self.test_result = config.get('test-result', None) self.replacements = [] for r in config['replacements']: if 'text' in r: self.replacements.append(TextReplacement(r['text'])) if 'link' in r: self.replacements.append(LinkReplacement(r['link'])) if 'search' in r: self.replacements.append(SearchReplacement(r['search'])) def getTestResults(self, app, text): if self.test_result is None: return {} ret = OrderedDict() for line in text.split('\n'): m = self.match.search(line) if m: repl = [r.replace(app, m.groupdict()) for r in self.replacements] job = self.test_result.format(**m.groupdict()) ret[job] = repl + ['\n'] return ret def run(self, app, chunks): ret = [] for chunk in chunks: if not isinstance(chunk, six.string_types): # We don't currently support nested commentlinks; if # we have something that isn't a string, just append # it to the output. ret.append(chunk) continue if not chunk: ret += [chunk] while chunk: m = self.match.search(chunk) if not m: ret.append(chunk) break before = chunk[:m.start()] after = chunk[m.end():] if before: ret.append(before) ret += [r.replace(app, m.groupdict()) for r in self.replacements] chunk = after return ret ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/config.py0000664000175000017500000002633514667773353017674 0ustar00jamespagejamespage# 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 collections import getpass import os import re import sys try: import ordereddict except: pass import yaml from six.moves.urllib import parse as urlparse import voluptuous as v import gertty.commentlink import gertty.palette import gertty.keymap try: OrderedDict = collections.OrderedDict except AttributeError: OrderedDict = ordereddict.OrderedDict DEFAULT_CONFIG_PATH = '~/.config/gertty/gertty.yaml' FALLBACK_CONFIG_PATH = '~/.gertty.yaml' class ConfigSchema(object): server = {v.Required('name'): str, v.Required('url'): str, v.Required('username'): str, 'password': str, 'verify-ssl': bool, 'ssl-ca-path': str, 'dburi': str, v.Required('git-root'): str, 'git-url': str, 'log-file': str, 'lock-file': str, 'socket': str, 'auth-type': v.Any('basic', 'digest', 'form'), } servers = [server] _sort_by = v.Any('number', 'updated', 'last-seen', 'project') sort_by = v.Any(_sort_by, [_sort_by]) text_replacement = {'text': v.Any(str, {'color': str, v.Required('text'): str})} link_replacement = {'link': {v.Required('url'): str, v.Required('text'): str}} search_replacement = {'search': {v.Required('query'): str, v.Required('text'): str}} replacement = v.Any(text_replacement, link_replacement, search_replacement) palette = {v.Required('name'): str, v.Match('(?!name)'): [str]} palettes = [palette] commentlink = {v.Required('match'): str, v.Required('replacements'): [replacement], 'test-result': str} commentlinks = [commentlink] dashboard = {v.Required('name'): str, v.Required('query'): str, v.Optional('sort-by'): sort_by, v.Optional('reverse'): bool, v.Required('key'): str} dashboards = [dashboard] reviewkey_approval = {v.Required('category'): str, v.Required('value'): int} reviewkey = {v.Required('approvals'): [reviewkey_approval], v.Optional('message'): str, 'submit': bool, v.Required('key'): str} reviewkeys = [reviewkey] hide_comment = {v.Required('author'): str} hide_comments = [hide_comment] change_list_options = {'sort-by': sort_by, 'reverse': bool} keymap = {v.Required('name'): str, v.Match('(?!name)'): v.Any([[str], str], [str], str)} keymaps = [keymap] thresholds = [int, int, int, int, int, int, int, int] size_column = {v.Required('type'): v.Any('graph', 'split-graph', 'number', 'disabled', None), v.Optional('thresholds'): thresholds} def getSchema(self, data): schema = v.Schema({v.Required('servers'): self.servers, 'palettes': self.palettes, 'palette': str, 'keymaps': self.keymaps, 'keymap': str, 'commentlinks': self.commentlinks, 'dashboards': self.dashboards, 'reviewkeys': self.reviewkeys, 'change-list-query': str, 'diff-view': str, 'hide-comments': self.hide_comments, 'thread-changes': bool, 'display-times-in-utc': bool, 'handle-mouse': bool, 'breadcrumbs': bool, 'close-change-on-review': bool, 'change-list-options': self.change_list_options, 'expire-age': str, 'size-column': self.size_column, }) return schema class Config(object): def __init__(self, server=None, palette='default', keymap='default', path=None): self.path = self.verifyConfigFile(path) self.config = yaml.safe_load(open(self.path)) schema = ConfigSchema().getSchema(self.config) schema(self.config) server = self.getServer(server) self.server = server url = server['url'] if not url.endswith('/'): url += '/' 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))) sys.exit(1) self.auth_type = server.get('auth-type', 'digest') self.verify_ssl = server.get('verify-ssl', True) if not self.verify_ssl: os.environ['GIT_SSL_NO_VERIFY']='true' 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 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) 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') self.socket_path = os.path.expanduser(socket_path) log_file = server.get('log-file', '~/.gertty.log') self.log_file = os.path.expanduser(log_file) lock_file = server.get('lock-file', '~/.gertty.%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), } for p in self.config.get('palettes', []): if p['name'] not in self.palettes: self.palettes[p['name']] = gertty.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)} for p in self.config.get('keymaps', []): if p['name'] not in self.keymaps: self.keymaps[p['name']] = gertty.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) for c in self.config.get('commentlinks', [])] self.commentlinks.append( gertty.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.diff_view = self.config.get('diff-view', 'side-by-side') self.dashboards = OrderedDict() for d in self.config.get('dashboards', []): self.dashboards[d['key']] = d self.dashboards[d['key']] self.reviewkeys = OrderedDict() for k in self.config.get('reviewkeys', []): self.reviewkeys[k['key']] = k self.hide_comments = [] 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.close_change_on_review = self.config.get('close-change-on-review', False) 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)} self.expire_age = self.config.get('expire-age', '2 months') self.size_column = self.config.get('size-column', {}) self.size_column['type'] = self.size_column.get('type', 'graph') if self.size_column['type'] == 'graph': self.size_column['thresholds'] = self.size_column.get('thresholds', [1, 10, 100, 1000]) else: self.size_column['thresholds'] = self.size_column.get('thresholds', [1, 10, 100, 200, 400, 600, 800, 1000]) def verifyConfigFile(self, path): for checkpath in [ path, DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH ]: if checkpath is not None: expandedpath = os.path.expanduser(checkpath) if os.path.exists(expandedpath): return expandedpath self.printSample() sys.exit(1) def getServer(self, name=None): for server in self.config['servers']: if name is None or name == server['name']: return server return None def printSample(self): filename = 'share/gertty/examples' print("""Gertty requires a configuration file at %s or %s If the file contains a password then permissions must be set to 0600. Several sample configuration files were installed with Gertty and are available in %s in the root of the installation. For more information, please see the README. """ % (DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH, filename,)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/db.py0000664000175000017500000013126014667773353017006 0ustar00jamespagejamespage# 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 re import time import logging import threading import alembic import alembic.config import alembic.migration 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 registry, sessionmaker, relationship, scoped_session, joinedload from sqlalchemy.orm.session import Session from sqlalchemy.sql import exists from sqlalchemy.sql.expression import and_ mapper = registry() metadata = MetaData() project_table = Table( 'project', metadata, Column('key', Integer, primary_key=True), Column('name', String(255), index=True, unique=True, nullable=False), Column('subscribed', Boolean, index=True, default=False), Column('description', Text, nullable=False, default=''), Column('updated', DateTime, index=True), ) branch_table = Table( 'branch', metadata, Column('key', Integer, primary_key=True), Column('project_key', Integer, ForeignKey("project.key"), index=True), Column('name', String(255), index=True, nullable=False), ) 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'), ) change_table = Table( 'change', metadata, Column('key', Integer, primary_key=True), Column('project_key', Integer, ForeignKey("project.key"), index=True), Column('id', String(255), index=True, unique=True, nullable=False), Column('number', Integer, index=True, unique=True, nullable=False), Column('branch', String(255), index=True, nullable=False), Column('change_id', String(255), index=True, nullable=False), Column('topic', String(255), index=True), Column('account_key', Integer, ForeignKey("account.key"), index=True), Column('subject', Text, nullable=False), Column('created', DateTime, index=True, nullable=False), Column('updated', DateTime, index=True, nullable=False), Column('status', String(16), index=True, nullable=False), Column('hidden', Boolean, index=True, nullable=False), Column('reviewed', Boolean, index=True, nullable=False), Column('starred', Boolean, index=True, nullable=False), Column('held', Boolean, index=True, nullable=False), Column('pending_rebase', Boolean, index=True, nullable=False), Column('pending_topic', Boolean, index=True, nullable=False), Column('pending_starred', Boolean, index=True, nullable=False), Column('pending_status', Boolean, index=True, nullable=False), Column('pending_hashtags', Boolean, index=True, nullable=False), Column('pending_status_message', Text), Column('last_seen', DateTime, index=True), Column('outdated', Boolean, index=True, nullable=False), Column('wip', Boolean, index=True, nullable=False), Column('pending_wip', Boolean, index=True, nullable=False), Column('pending_wip_message', Text), ) change_conflict_table = Table( 'change_conflict', metadata, Column('key', Integer, primary_key=True), Column('change1_key', Integer, ForeignKey("change.key"), index=True), Column('change2_key', Integer, ForeignKey("change.key"), index=True), ) hashtag_table = Table( 'hashtag', metadata, Column('key', Integer, primary_key=True), Column('change_key', Integer, ForeignKey("change.key"), index=True), Column('name', String(length=255), index=True, nullable=False), ) revision_table = Table( 'revision', metadata, Column('key', Integer, primary_key=True), Column('change_key', Integer, ForeignKey("change.key"), index=True), Column('number', Integer, index=True, nullable=False), Column('message', Text, nullable=False), Column('commit', String(255), index=True, nullable=False), Column('parent', String(255), index=True, nullable=False), # TODO: fetch_ref, fetch_auth are unused; remove Column('fetch_auth', Boolean, nullable=False), Column('fetch_ref', String(255), nullable=False), Column('pending_message', Boolean, index=True, nullable=False), Column('can_submit', Boolean, nullable=False), ) message_table = Table( 'message', metadata, Column('key', Integer, primary_key=True), Column('revision_key', Integer, ForeignKey("revision.key"), index=True), Column('account_key', Integer, ForeignKey("account.key"), index=True), Column('id', String(255), index=True), #, unique=True, nullable=False), Column('created', DateTime, index=True, nullable=False), Column('message', Text, nullable=False), Column('draft', Boolean, index=True, nullable=False), Column('pending', Boolean, index=True, nullable=False), ) comment_table = Table( 'comment', metadata, Column('key', Integer, primary_key=True), Column('file_key', Integer, ForeignKey("file.key"), index=True), Column('account_key', Integer, ForeignKey("account.key"), index=True), Column('id', String(255), index=True), #, unique=True, nullable=False), Column('in_reply_to', String(255)), Column('created', DateTime, index=True, nullable=False), Column('parent', Boolean, nullable=False), Column('line', Integer), Column('message', Text, nullable=False), Column('draft', Boolean, index=True, nullable=False), Column('robot_id', String(255)), Column('robot_run_id', String(255)), Column('url', Text()), ) label_table = Table( 'label', metadata, Column('key', Integer, primary_key=True), Column('change_key', Integer, ForeignKey("change.key"), index=True), Column('category', String(255), nullable=False), Column('value', Integer, nullable=False), Column('description', String(255), nullable=False), ) permitted_label_table = Table( 'permitted_label', metadata, Column('key', Integer, primary_key=True), Column('change_key', Integer, ForeignKey("change.key"), index=True), Column('category', String(255), nullable=False), Column('value', Integer, nullable=False), ) approval_table = Table( 'approval', metadata, Column('key', Integer, primary_key=True), Column('change_key', Integer, ForeignKey("change.key"), index=True), Column('account_key', Integer, ForeignKey("account.key"), index=True), Column('category', String(255), nullable=False), Column('value', Integer, nullable=False), Column('draft', Boolean, index=True, nullable=False), ) account_table = Table( 'account', metadata, Column('key', Integer, primary_key=True), Column('id', Integer, index=True, unique=True, nullable=False), Column('name', String(255), index=True), Column('username', String(255), index=True), Column('email', String(255), index=True), ) pending_cherry_pick_table = Table( 'pending_cherry_pick', metadata, Column('key', Integer, primary_key=True), Column('revision_key', Integer, ForeignKey("revision.key"), index=True), # Branch is a str here to avoid FK complications if the branch # entry is removed. Column('branch', String(255), nullable=False), Column('message', Text, nullable=False), ) 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), ) file_table = Table( 'file', metadata, Column('key', Integer, primary_key=True), Column('revision_key', Integer, ForeignKey("revision.key"), index=True), Column('path', Text, nullable=False, index=True), Column('old_path', Text, index=True), Column('inserted', Integer), Column('deleted', Integer), Column('status', String(1), nullable=False), ) server_table = Table( 'server', metadata, Column('key', Integer, primary_key=True), Column('own_account_key', Integer, ForeignKey("account.key"), index=True), ) checker_table = Table( 'checker', metadata, Column('key', Integer, primary_key=True), Column('uuid', String(255), index=True, unique=True, nullable=False), Column('name', String(255), nullable=False), Column('status', String(255), nullable=False), Column('blocking', String(255)), Column('description', Text), ) check_table = Table( 'check', metadata, Column('key', Integer, primary_key=True), Column('revision_key', Integer, ForeignKey("revision.key"), index=True), Column('checker_key', Integer, ForeignKey("checker.key"), index=True), Column('state', String(255), nullable=False), Column('url', Text), Column('message', Text), Column('started', DateTime), Column('finished', DateTime), Column('created', DateTime, index=True, nullable=False), Column('updated', DateTime, index=True, nullable=False), ) class Account(object): def __init__(self, id, name=None, username=None, email=None): self.id = id self.name = name self.username = username self.email = email class Project(object): def __init__(self, name, subscribed=False, description=''): self.name = name self.subscribed = subscribed self.description = description def delete(self): # This unusual delete method is to accomodate the # self-referential many-to-many relationship from # change_conflict. With the default cascade configuration, # the entry from the association table is deleted regardless # of whether the change being deleted is change1 or change2. # However, when both changes are deleted at once (only likely # to happen when the entire project is deleted), SQLAlchemy # cascades through both relationships and attempts to delete # the entry twice. Since we rarely delete projects, we add a # special case here to delete a project's changes one at a # time to avoid this situation. session = Session.object_session(self) for c in self.changes: session.delete(c) session.flush() session.expire_all() session.delete(self) 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 Hashtag(object): def __init__(self, change, name): self.change_key = change.key self.name = name class Branch(object): def __init__(self, project, name): self.project_key = project.key self.name = name 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() class Change(object): def __init__(self, project, id, owner, number, branch, change_id, subject, created, updated, status, topic=None, hidden=False, reviewed=False, starred=False, wip=False, held=False, pending_rebase=False, pending_topic=False, pending_starred=False, pending_status=False, pending_status_message=None, pending_hashtags=False, pending_wip=False, pending_wip_message=None, outdated=False): self.project_key = project.key self.account_key = owner.key self.id = id self.number = number self.branch = branch self.change_id = change_id self.topic = topic self.subject = subject self.created = created self.updated = updated self.status = status self.hidden = hidden self.reviewed = reviewed self.wip = wip self.starred = starred self.held = held self.pending_rebase = pending_rebase self.pending_topic = pending_topic self.pending_hashtags = pending_hashtags self.pending_starred = pending_starred self.pending_status = pending_status self.pending_status_message = pending_status_message self.pending_wip = pending_wip self.pending_wip_message = pending_wip_message self.outdated = outdated def getCategories(self): categories = set([label.category for label in self.labels]) return sorted(categories) def getMaxForCategory(self, category): if not hasattr(self, '_approval_cache'): self._updateApprovalCache() return self._approval_cache.get(category, 0) def _updateApprovalCache(self): cat_min = {} cat_max = {} cat_value = {} for approval in self.approvals: if approval.draft: continue cur_min = cat_min.get(approval.category, 0) cur_max = cat_max.get(approval.category, 0) cur_min = min(approval.value, cur_min) cur_max = max(approval.value, cur_max) cat_min[approval.category] = cur_min cat_max[approval.category] = cur_max cur_value = cat_value.get(approval.category, 0) if abs(cur_min) > abs(cur_value): cur_value = cur_min if abs(cur_max) > abs(cur_value): cur_value = cur_max cat_value[approval.category] = cur_value self._approval_cache = cat_value def getMinMaxPermittedForCategory(self, category): if not hasattr(self, '_permitted_cache'): self._updatePermittedCache() return self._permitted_cache.get(category, (0,0)) def _updatePermittedCache(self): cache = {} for label in self.labels: if label.category not in cache: cache[label.category] = [0, 0] if label.value > cache[label.category][1]: cache[label.category][1] = label.value if label.value < cache[label.category][0]: cache[label.category][0] = label.value self._permitted_cache = cache def createRevision(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) r = Revision(*args, **kw) self.revisions.append(r) session.add(r) session.flush() return r def createLabel(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) l = Label(*args, **kw) self.labels.append(l) session.add(l) session.flush() return l def createApproval(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) l = Approval(*args, **kw) self.approvals.append(l) session.add(l) session.flush() return l def createPermittedLabel(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) l = PermittedLabel(*args, **kw) self.permitted_labels.append(l) session.add(l) session.flush() return l def createHashtag(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) h = Hashtag(*args, **kw) self.hashtags.append(h) session.add(h) session.flush() return h def setHashtags(self, tags): session = Session.object_session(self) current_hashtags = [h.name for h in self.hashtags] for hashtag in self.hashtags: if hashtag.name not in tags: session.delete(hashtag) for hashtag in tags: if hashtag not in current_hashtags: self.createHashtag(hashtag) @property def owner_name(self): owner_name = 'Anonymous Coward' if self.owner: if self.owner.name: owner_name = self.owner.name elif self.owner.username: owner_name = self.owner.username elif self.owner.email: owner_name = self.owner.email return owner_name @property def conflicts(self): return tuple(set(self.conflicts1 + self.conflicts2)) def addConflict(self, other): session = Session.object_session(self) if other in self.conflicts1 or other in self.conflicts2: return if self in other.conflicts1 or self in other.conflicts2: return self.conflicts1.append(other) session.flush() session.expire(other, attribute_names=['conflicts2']) def delConflict(self, other): session = Session.object_session(self) if other in self.conflicts1: self.conflicts1.remove(other) session.flush() session.expire(other, attribute_names=['conflicts2']) if self in other.conflicts1: other.conflicts1.remove(self) session.flush() session.expire(self, attribute_names=['conflicts2']) class Revision(object): def __init__(self, change, number, message, commit, parent, fetch_auth, fetch_ref, pending_message=False, can_submit=False): self.change_key = change.key self.number = number self.message = message self.commit = commit self.parent = parent self.fetch_auth = fetch_auth self.fetch_ref = fetch_ref self.pending_message = pending_message self.can_submit = can_submit def createMessage(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) m = Message(*args, **kw) self.messages.append(m) session.add(m) session.flush() return m def createPendingCherryPick(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) c = PendingCherryPick(*args, **kw) self.pending_cherry_picks.append(c) session.add(c) session.flush() return c def createFile(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) f = File(*args, **kw) self.files.append(f) session.add(f) session.flush() if hasattr(self, '_file_cache'): self._file_cache[f.path] = f return f def createCheck(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) c = Check(*args, **kw) self.checks.append(c) session.add(c) session.flush() return c def getFile(self, path): if not hasattr(self, '_file_cache'): self._file_cache = {} for f in self.files: self._file_cache[f.path] = f return self._file_cache.get(path, None) def getPendingMessage(self): for m in self.messages: if m.pending: return m return None def getDraftMessage(self): for m in self.messages: if m.draft: return m return None class Message(object): def __init__(self, revision, id, author, created, message, draft=False, pending=False): self.revision_key = revision.key self.account_key = author.key self.id = id self.created = created self.message = message self.draft = draft self.pending = pending @property def author_name(self): author_name = 'Anonymous Coward' if self.author: if self.author.name: author_name = self.author.name elif self.author.username: author_name = self.author.username elif self.author.email: author_name = self.author.email return author_name class Comment(object): def __init__(self, file, id, author, in_reply_to, created, parent, line, message, draft=False, robot_id=None, robot_run_id=None, url=None): self.file_key = file.key self.account_key = author.key self.id = id self.in_reply_to = in_reply_to self.created = created self.parent = parent self.line = line self.message = message self.draft = draft self.robot_id = robot_id self.robot_run_id = robot_run_id self.url = url class Label(object): def __init__(self, change, category, value, description): self.change_key = change.key self.category = category self.value = value self.description = description class PermittedLabel(object): def __init__(self, change, category, value): self.change_key = change.key self.category = category self.value = value class Approval(object): def __init__(self, change, reviewer, category, value, draft=False): self.change_key = change.key self.account_key = reviewer.key self.category = category self.value = value self.draft = draft @property def reviewer_name(self): reviewer_name = 'Anonymous Coward' if self.reviewer: if self.reviewer.name: reviewer_name = self.reviewer.name elif self.reviewer.username: reviewer_name = self.reviewer.username elif self.reviewer.email: reviewer_name = self.reviewer.email return reviewer_name class PendingCherryPick(object): def __init__(self, revision, branch, message): self.revision_key = revision.key self.branch = branch self.message = message class SyncQuery(object): def __init__(self, name): self.name = name class File(object): STATUS_ADDED = 'A' STATUS_DELETED = 'D' STATUS_RENAMED = 'R' STATUS_COPIED = 'C' STATUS_REWRITTEN = 'W' STATUS_MODIFIED = 'M' def __init__(self, revision, path, status, old_path=None, inserted=None, deleted=None): self.revision_key = revision.key self.path = path self.status = status self.old_path = old_path self.inserted = inserted self.deleted = deleted @property def display_path(self): if not self.old_path: return self.path pre = [] post = [] for start in range(min(len(self.old_path), len(self.path))): if self.path[start] == self.old_path[start]: pre.append(self.old_path[start]) else: break pre = ''.join(pre) for end in range(1, min(len(self.old_path), len(self.path))-1): if self.path[0-end] == self.old_path[0-end]: post.insert(0, self.old_path[0-end]) else: break post = ''.join(post) mid = '{%s => %s}' % (self.old_path[start:0-end+1], self.path[start:0-end+1]) if pre and post: mid = '{%s => %s}' % (self.old_path[start:0-end+1], self.path[start:0-end+1]) return pre + mid + post else: return '%s => %s' % (self.old_path, self.path) def createComment(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) c = Comment(*args, **kw) self.comments.append(c) session.add(c) session.flush() return c class Server(object): def __init__(self): pass class Checker(object): def __init__(self, uuid, name, status): self.uuid = uuid self.name = name self.status = status class Check(object): def __init__(self, revision, checker, state, created, updated): self.revision_key = revision.key self.checker_key = checker.key self.state = state self.created = created self.updated = updated mapper.map_imperatively(Account, account_table) mapper.map_imperatively(Project, project_table, properties=dict( branches=relationship(Branch, backref='project', order_by=branch_table.c.name, cascade='all, delete-orphan'), changes=relationship(Change, backref='project', order_by=change_table.c.number, cascade='all, delete-orphan'), topics=relationship(Topic, secondary=project_topic_table, order_by=topic_table.c.name, viewonly=True), unreviewed_changes=relationship(Change, primaryjoin=and_(project_table.c.key==change_table.c.project_key, change_table.c.hidden==False, change_table.c.status!='MERGED', change_table.c.status!='ABANDONED', change_table.c.reviewed==False), order_by=change_table.c.number, ), open_changes=relationship(Change, primaryjoin=and_(project_table.c.key==change_table.c.project_key, change_table.c.status!='MERGED', change_table.c.status!='ABANDONED'), order_by=change_table.c.number, ), )) mapper.map_imperatively(Branch, branch_table) mapper.map_imperatively(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.map_imperatively(ProjectTopic, project_topic_table) mapper.map_imperatively(Change, change_table, properties=dict( owner=relationship(Account), conflicts1=relationship(Change, secondary=change_conflict_table, primaryjoin=change_table.c.key==change_conflict_table.c.change1_key, secondaryjoin=change_table.c.key==change_conflict_table.c.change2_key, ), conflicts2=relationship(Change, secondary=change_conflict_table, primaryjoin=change_table.c.key==change_conflict_table.c.change2_key, secondaryjoin=change_table.c.key==change_conflict_table.c.change1_key, ), hashtags=relationship(Hashtag, backref='change', cascade='all, delete-orphan'), revisions=relationship(Revision, backref='change', order_by=revision_table.c.number, cascade='all, delete-orphan'), messages=relationship(Message, secondary=revision_table, order_by=message_table.c.created, viewonly=True), labels=relationship(Label, backref='change', order_by=(label_table.c.category, label_table.c.value), cascade='all, delete-orphan'), permitted_labels=relationship(PermittedLabel, backref='change', order_by=(permitted_label_table.c.category, permitted_label_table.c.value), cascade='all, delete-orphan'), approvals=relationship(Approval, backref='change', order_by=(approval_table.c.category, approval_table.c.value), cascade='all, delete-orphan'), draft_approvals=relationship(Approval, primaryjoin=and_(change_table.c.key==approval_table.c.change_key, approval_table.c.draft==True), order_by=(approval_table.c.category, approval_table.c.value)) )) mapper.map_imperatively(Revision, revision_table, properties=dict( messages=relationship(Message, backref='revision', cascade='all, delete-orphan'), files=relationship(File, backref='revision', cascade='all, delete-orphan'), pending_cherry_picks=relationship(PendingCherryPick, backref='revision', cascade='all, delete-orphan'), checks=relationship(Check, backref='revision', cascade='all, delete-orphan'), )) mapper.map_imperatively(Message, message_table, properties=dict( author=relationship(Account))) mapper.map_imperatively(File, file_table, properties=dict( comments=relationship(Comment, backref='file', order_by=(comment_table.c.line, comment_table.c.created), cascade='all, delete-orphan'), draft_comments=relationship(Comment, primaryjoin=and_(file_table.c.key==comment_table.c.file_key, comment_table.c.draft==True), order_by=(comment_table.c.line, comment_table.c.created)), )) mapper.map_imperatively(Comment, comment_table, properties=dict( author=relationship(Account))) mapper.map_imperatively(Label, label_table) mapper.map_imperatively(PermittedLabel, permitted_label_table) mapper.map_imperatively(Approval, approval_table, properties=dict( reviewer=relationship(Account))) mapper.map_imperatively(PendingCherryPick, pending_cherry_pick_table) mapper.map_imperatively(SyncQuery, sync_query_table) mapper.map_imperatively(Hashtag, hashtag_table) mapper.map_imperatively(Server, server_table, properties=dict( own_account=relationship(Account) )) mapper.map_imperatively(Checker, checker_table) mapper.map_imperatively(Check, check_table, properties=dict( checker=relationship(Checker))) 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('gertty.db') self.own_account_key = None 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", "gertty:alembic") config.set_main_option("sqlalchemy.url", self.dburi) config.gertty_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, unreviewed=False, topicless=False): """Retrieve projects. :param subscribed: If True limit to only subscribed projects. :param unreviewed: If True limit to only projects with unreviewed changes. :param topicless: If True limit to only projects without topics. """ query = self.session().query(Project) if subscribed: query = query.filter_by(subscribed=subscribed) if unreviewed: query = query.filter(exists().where(Project.unreviewed_changes)) 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 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 getChange(self, key, lazy=True): query = self.session().query(Change).filter_by(key=key) if not lazy: query = query.options(joinedload(Change.revisions).joinedload(Revision.files).joinedload(File.comments)) try: return query.one() except sqlalchemy.orm.exc.NoResultFound: return None def getChangeByID(self, id): try: return self.session().query(Change).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: return None def getChangeIDs(self, ids): # Returns a set of IDs that exist in the local database matching # the set of supplied IDs. This is used when sync'ing the changesets # locally with the remote changes. if not ids: return set() query = self.session().query(Change.id) return set(ids).intersection(r[0] for r in query.all()) def getChangesByChangeID(self, change_id): try: return self.session().query(Change).filter_by(change_id=change_id) except sqlalchemy.orm.exc.NoResultFound: return None def getChangeByNumber(self, number): try: return self.session().query(Change).filter_by(number=number).one() except sqlalchemy.orm.exc.NoResultFound: return None def getPendingCherryPick(self, key): try: return self.session().query(PendingCherryPick).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getChanges(self, query, unreviewed=False, sort_by='number'): self.database.log.debug("Search query: %s sort: %s" % (query, sort_by)) q = self.session().query(Change).filter(self.search.parse(query)) if not isinstance(sort_by, (list, tuple)): sort_by = [sort_by] if unreviewed: q = q.filter(change_table.c.hidden==False, change_table.c.reviewed==False) for s in sort_by: if s == 'updated': q = q.order_by(change_table.c.updated) elif s == 'last-seen': q = q.order_by(change_table.c.last_seen) elif s == 'number': q = q.order_by(change_table.c.number) elif s == 'project': q = q.filter(project_table.c.key == change_table.c.project_key) q = q.order_by(project_table.c.name) self.database.log.debug("Search SQL: %s" % q) try: return q.all() except sqlalchemy.orm.exc.NoResultFound: return [] def getRevision(self, key): try: return self.session().query(Revision).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getRevisionByCommit(self, commit): try: return self.session().query(Revision).filter_by(commit=commit).one() except sqlalchemy.orm.exc.NoResultFound: return None def getRevisionsByParent(self, parent): if isinstance(parent, six.string_types): parent = (parent,) try: return self.session().query(Revision).filter(Revision.parent.in_(parent)).all() except sqlalchemy.orm.exc.NoResultFound: return [] def getRevisionByNumber(self, change, number): try: return self.session().query(Revision).filter_by(change_key=change.key, number=number).one() except sqlalchemy.orm.exc.NoResultFound: return None def getFile(self, key): try: return self.session().query(File).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getComment(self, key): try: return self.session().query(Comment).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getCommentByID(self, id): try: return self.session().query(Comment).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: return None def getMessage(self, key): try: return self.session().query(Message).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getMessageByID(self, id): try: return self.session().query(Message).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: return None def getHeld(self): return self.session().query(Change).filter_by(held=True).all() def getOutdated(self): return self.session().query(Change).filter_by(outdated=True).all() def getPendingMessages(self): return self.session().query(Message).filter_by(pending=True).all() def getPendingTopics(self): return self.session().query(Change).filter_by(pending_topic=True).all() def getPendingHashtags(self): return self.session().query(Change).filter_by(pending_hashtags=True).all() def getPendingRebases(self): return self.session().query(Change).filter_by(pending_rebase=True).all() def getPendingStarred(self): return self.session().query(Change).filter_by(pending_starred=True).all() def getPendingWIP(self): return self.session().query(Change).filter_by(pending_wip=True).all() def getPendingStatusChanges(self): return self.session().query(Change).filter_by(pending_status=True).all() def getPendingCherryPicks(self): return self.session().query(PendingCherryPick).all() def getPendingCommitMessages(self): return self.session().query(Revision).filter_by(pending_message=True).all() def getAccountByID(self, id, name=None, username=None, email=None): try: account = self.session().query(Account).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: account = self.createAccount(id) if name is not None and account.name != name: account.name = name if username is not None and account.username != username: account.username = username if email is not None and account.email != email: account.email = email return account def getAccountByUsername(self, username): try: return self.session().query(Account).filter_by(username=username).one() except sqlalchemy.orm.exc.NoResultFound: return None def getSystemAccount(self): return self.getAccountByID(0, 'Gerrit Code Review') def setOwnAccount(self, account): try: server = self.session().query(Server).one() except sqlalchemy.orm.exc.NoResultFound: server = Server() self.session().add(server) self.session().flush() server.own_account = account self.database.own_account_key = account.key def getOwnAccount(self): if self.database.own_account_key is None: try: server = self.session().query(Server).one() except sqlalchemy.orm.exc.NoResultFound: return None self.database.own_account_key = server.own_account.key try: return self.session().query(Account).filter_by(key=self.database.own_account_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 createAccount(self, *args, **kw): a = Account(*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 createChecker(self, *args, **kw): o = Checker(*args, **kw) self.session().add(o) self.session().flush() return o def getCheckerByUUID(self, uuid): try: return self.session().query(Checker).filter_by(uuid=uuid).one() except sqlalchemy.orm.exc.NoResultFound: return None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/dbsupport.py0000664000175000017500000001550214667772533020442 0ustar00jamespagejamespage# Copyright 2014 Mirantis Inc. # Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import six import uuid from alembic import op import sqlalchemy def sqlite_alter_columns(table_name, column_defs): """Implement alter columns for SQLite. The ALTER COLUMN command isn't supported by SQLite specification. Instead of calling ALTER COLUMN it uses the following workaround: * create temp table '{table_name}_{rand_uuid}', with some column defs replaced; * copy all data to the temp table; * drop old table; * rename temp table to the old table name. """ connection = op.get_bind() meta = sqlalchemy.MetaData(bind=connection) meta.reflect() changed_columns = {} indexes = [] for col in column_defs: # If we are to have an index on the column, don't create it # immediately, instead, add it to a list of indexes to create # after the table rename. if col.index: indexes.append(('ix_%s_%s' % (table_name, col.name), table_name, [col.name], col.unique)) col.unique = False col.index = False changed_columns[col.name] = col # construct lists of all columns and their names old_columns = [] new_columns = [] column_names = [] for column in meta.tables[table_name].columns: column_names.append(column.name) old_columns.append(column) if column.name in changed_columns.keys(): new_columns.append(changed_columns[column.name]) else: col_copy = column.copy() new_columns.append(col_copy) for key in meta.tables[table_name].foreign_keys: constraint = key.constraint con_copy = constraint.copy() new_columns.append(con_copy) for index in meta.tables[table_name].indexes: # If this is a single column index for a changed column, don't # copy it because we may already be creating a new version of # it (or removing it). idx_columns = [col.name for col in index.columns] if len(idx_columns)==1 and idx_columns[0] in changed_columns.keys(): continue # Otherwise, recreate the index. indexes.append((index.name, table_name, [col.name for col in index.columns], index.unique)) # create temp table tmp_table_name = "%s_%s" % (table_name, six.text_type(uuid.uuid4())) op.create_table(tmp_table_name, *new_columns) meta.reflect() try: # copy data from the old table to the temp one sql_select = sqlalchemy.sql.select(old_columns) connection.execute(sqlalchemy.sql.insert(meta.tables[tmp_table_name]) .from_select(column_names, sql_select)) except Exception: op.drop_table(tmp_table_name) raise # drop the old table and rename temp table to the old table name op.drop_table(table_name) op.rename_table(tmp_table_name, table_name) # (re-)create indexes for index in indexes: op.create_index(op.f(index[0]), index[1], index[2], unique=index[3]) def sqlite_drop_columns(table_name, drop_columns): """Implement drop columns for SQLite. The DROP COLUMN command isn't supported by SQLite specification. Instead of calling DROP COLUMN it uses the following workaround: * create temp table '{table_name}_{rand_uuid}', without dropped columns; * copy all data to the temp table; * drop old table; * rename temp table to the old table name. """ connection = op.get_bind() meta = sqlalchemy.MetaData(bind=connection) meta.reflect() # construct lists of all columns and their names old_columns = [] new_columns = [] column_names = [] indexes = [] for column in meta.tables[table_name].columns: if column.name not in drop_columns: old_columns.append(column) column_names.append(column.name) col_copy = column.copy() new_columns.append(col_copy) 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 if (len(columns)==1 and columns[0] in drop_columns): continue # Otherwise, recreate the constraint. constraint = key.constraint con_copy = constraint.copy() new_columns.append(con_copy) for index in meta.tables[table_name].indexes: # If this is a single column index for a dropped column, don't # copy it. idx_columns = [col.name for col in index.columns] if len(idx_columns)==1 and idx_columns[0] in drop_columns: continue # Otherwise, recreate the index. indexes.append((index.name, table_name, [col.name for col in index.columns], index.unique)) # create temp table tmp_table_name = "%s_%s" % (table_name, six.text_type(uuid.uuid4())) op.create_table(tmp_table_name, *new_columns) meta.reflect() try: # copy data from the old table to the temp one sql_select = sqlalchemy.sql.select(old_columns) connection.execute(sqlalchemy.sql.insert(meta.tables[tmp_table_name]) .from_select(column_names, sql_select)) except Exception: op.drop_table(tmp_table_name) raise # drop the old table and rename temp table to the old table name op.drop_table(table_name) op.rename_table(tmp_table_name, table_name) # (re-)create indexes for index in indexes: op.create_index(op.f(index[0]), index[1], index[2], unique=index[3]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/gitrepo.py0000664000175000017500000005152314667773353020075 0ustar00jamespagejamespage# -*- coding: utf-8 -*- # Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. # Test changes: # https://review.opendev.org/275862 # https://review.opendev.org/119302 # https://review.opendev.org/133550 import datetime import logging import difflib import itertools import os import re import git import gitdb import six OLD = 0 NEW = 1 START = 0 END = 1 LINENO = 0 LINE = 1 class GitTimeZone(datetime.tzinfo): """Because we can't have nice things.""" def __init__(self, offset_seconds): self._offset = offset_seconds def utcoffset(self, dt): return datetime.timedelta(seconds=self._offset) def dst(self, dt): return datetime.timedelta(0) def tzname(self, dt): return None class CommitBlob(object): def __init__(self): self.path = '/COMMIT_MSG' class CommitContext(object): """A git.diff.Diff for commit messages.""" def decorateGitTime(self, seconds, tz): dt = datetime.datetime.fromtimestamp(seconds, GitTimeZone(-tz)) return dt.strftime('%Y-%m-%d %H:%M:%S %Z%z') def decorateMessage(self, commit): """Put the Gerrit commit metadata at the front of the message. e.g.: Parent: cc8a51ca (Initial commit) 1 Author: Robert Collins 2 AuthorDate: 2014-05-27 14:05:47 +1200 3 Commit: Robert Collins 4 CommitDate: 2014-05-27 14:07:57 +1200 5 6 """ # NB: If folk report that commits have comments at the wrong place # Then this function, which reproduces gerrit behaviour, will need # to be fixed (e.g. by making the behaviour match more closely. if not commit: return [] if commit.parents: parentsha = commit.parents[0].hexsha[:8] else: parentsha = None author = commit.author committer = commit.committer author_date = self.decorateGitTime( commit.authored_date, commit.author_tz_offset) commit_date = self.decorateGitTime( commit.committed_date, commit.committer_tz_offset) if isinstance(author.email, six.text_type): author_email = author.email else: author_email = author.email.decode('utf8') if isinstance(committer.email, six.text_type): committer_email = committer.email else: committer_email = committer.email.decode('utf8') return [u"Parent: %s\n" % parentsha, u"Author: %s <%s>\n" % (author.name, author_email), u"AuthorDate: %s\n" % author_date, u"Commit: %s <%s>\n" % (committer.name, committer_email), u"CommitDate: %s\n" % commit_date, u"\n"] + commit.message.splitlines(True) def __init__(self, old, new): """Create a CommitContext. :param old: A git.objects.commit object or None. :param new: A git.objects.commit object. """ self.rename_from = self.rename_to = None if old is None: self.new_file = True else: self.new_file = False self.deleted_file = False self.a_blob = CommitBlob() self.b_blob = CommitBlob() self.a_path = self.a_blob.path self.b_path = self.b_blob.path self.diff = ''.join(difflib.unified_diff( self.decorateMessage(old), self.decorateMessage(new), fromfile="/a/COMMIT_MSG", tofile="/b/COMMIT_MSG")) class DiffChunk(object): def __init__(self): self.oldlines = [] self.newlines = [] self.first = False self.last = False self.lines = [] self.calcRange() def __repr__(self): return '<%s old lines %s-%s / new lines %s-%s>' % ( self.__class__.__name__, self.range[OLD][START], self.range[OLD][END], self.range[NEW][START], self.range[NEW][END]) def calcRange(self): self.range = [[0, 0], [0, 0]] for l in self.lines: if self.range[OLD][START] == 0 and l[OLD][LINENO] is not None: self.range[OLD][START] = l[OLD][LINENO] if self.range[NEW][START] == 0 and l[NEW][LINENO] is not None: self.range[NEW][START] = l[NEW][LINENO] if (self.range[OLD][START] != 0 and self.range[NEW][START] != 0): break for l in self.lines[::-1]: if self.range[OLD][END] == 0 and l[OLD][LINENO] is not None: self.range[OLD][END] = l[OLD][LINENO] if self.range[NEW][END] == 0 and l[NEW][LINENO] is not None: self.range[NEW][END] = l[NEW][LINENO] if (self.range[OLD][END] != 0 and self.range[NEW][END] != 0): break def indexOfLine(self, oldnew, lineno): for i, l in enumerate(self.lines): if l[oldnew][LINENO] == lineno: return i class DiffContextChunk(DiffChunk): context = True class DiffChangedChunk(DiffChunk): context = False class DiffFile(object): log = logging.getLogger('gertty.gitrepo') def __init__(self): self.newname = 'Unknown File' self.oldname = 'Unknown File' self.old_empty = False self.new_empty = False self.chunks = [] self.current_chunk = None self.old_lineno = 0 self.new_lineno = 0 self.offset = 0 def finalize(self): if not self.current_chunk: return oldlines = [(n, d, self.expand_tabs(l)) for (n, d, l) in self.current_chunk.oldlines] newlines = [(n, d, self.expand_tabs(l)) for (n, d, l) in self.current_chunk.newlines] self.current_chunk.lines = list( six.moves.zip(oldlines, newlines)) if not self.chunks: self.current_chunk.first = True else: self.chunks[-1].last = False self.current_chunk.last = True self.current_chunk.calcRange() self.chunks.append(self.current_chunk) self.current_chunk = None def expand_tabs(self, l, tabstop = 8): offset = { 'start': 0, 'prevstart': 0 } def replace(match): offset['start'] += match.start(0) - offset['prevstart'] offset['prevstart'] = match.start(0) cnt = tabstop - offset['start'] % tabstop - 1 offset['start'] += cnt return "»" + " " * cnt try: if isinstance(l, six.string_types): return re.sub(r'\t', replace, l) elif isinstance(l, list): return [self.expand_tabs(e) for e in l] else: (a, b) = l return (a, re.sub(r'\t', replace, b)) except Exception: self.log.exception("Error expanding tabs") return l def addDiffLines(self, old, new): if (self.current_chunk and not isinstance(self.current_chunk, DiffChangedChunk)): self.finalize() if not self.current_chunk: self.current_chunk = DiffChangedChunk() for l in old: self.current_chunk.oldlines.append((self.old_lineno, '-', l)) self.old_lineno += 1 self.offset -= 1 for l in new: self.current_chunk.newlines.append((self.new_lineno, '+', l)) self.new_lineno += 1 self.offset += 1 while self.offset > 0: self.current_chunk.oldlines.append((None, '', '')) self.offset -= 1 while self.offset < 0: self.current_chunk.newlines.append((None, '', '')) self.offset += 1 def addNewLine(self, line): if (self.current_chunk and not isinstance(self.current_chunk, DiffChangedChunk)): self.finalize() if not self.current_chunk: self.current_chunk = DiffChangedChunk() def addContextLine(self, line): if (self.current_chunk and not isinstance(self.current_chunk, DiffContextChunk)): self.finalize() if not self.current_chunk: self.current_chunk = DiffContextChunk() self.current_chunk.oldlines.append((self.old_lineno, ' ', line)) self.current_chunk.newlines.append((self.new_lineno, ' ', line)) self.old_lineno += 1 self.new_lineno += 1 class GitCheckoutError(Exception): def __init__(self, msg): super(GitCheckoutError, self).__init__(msg) self.msg = msg class GitCloneError(Exception): def __init__(self, msg): super(GitCloneError, self).__init__(msg) self.msg = msg class Repo(object): def __init__(self, url, path): self.log = logging.getLogger('gertty.gitrepo') self.url = url self.path = path self.differ = difflib.Differ() if not os.path.exists(path): if url is None: raise GitCloneError("No URL available for git clone") git.Repo.clone_from(self.url, self.path) def checkCommits(self, shas): invalid = set() repo = git.Repo(self.path) for sha in shas: try: repo.commit(sha) except gitdb.exc.BadObject: invalid.add(sha) except ValueError: invalid.add(sha) return invalid def fetch(self, url, refspec): repo = git.Repo(self.path) try: repo.git.fetch(url, refspec) except AssertionError: repo.git.fetch(url, refspec) def deleteRef(self, ref): repo = git.Repo(self.path) git.Reference.delete(repo, ref) def checkout(self, ref): repo = git.Repo(self.path) try: repo.git.checkout(ref) except git.exc.GitCommandError as e: raise GitCheckoutError(e.stderr.replace('\t', ' ')) def cherryPick(self, ref): repo = git.Repo(self.path) try: repo.git.cherry_pick(ref) except git.exc.GitCommandError as e: raise GitCheckoutError(e.stderr.replace('\t', ' ')) def diffstat(self, old, new): repo = git.Repo(self.path) diff = repo.git.diff('-M', '--color=never', '--numstat', old, new) ret = [] for x in diff.split('\n'): # Added, removed, filename ret.append(x.split('\t')) return ret trailing_ws_re = re.compile('\s+$') def _emph_trail_ws(self, style, line): result = (style, line) re_result = self.trailing_ws_re.search(line) if (re_result): span = re_result.span() if len(line[:span[0]]) == 0: ws_line = ('trailing-ws', line) else: ws_line = [(style, line[:span[0]]), ('trailing-ws', line[span[0]:span[1]])] result = ws_line return result def intralineDiff(self, old, new): # takes a list of old lines and a list of new lines prevline = None prevstyle = None output_old = [] output_new = [] #self.log.debug('startold' + repr(old)) #self.log.debug('startnew' + repr(new)) for line in self.differ.compare(old, new): #self.log.debug('diff output: ' + line) key = line[0] rest = line[2:] if key == '?': result = [] accumulator = '' emphasis = False rest = rest[:-1] # It has a newline. for i, c in enumerate(prevline): if i >= len(rest): indicator = ' ' else: indicator = rest[i] #self.log.debug('%s %s %s %s %s' % (i, c, indicator, emphasis, accumulator)) if indicator != ' ' and not emphasis: # changing from not emph to emph if accumulator: result.append((prevstyle+'-line', accumulator)) accumulator = '' emphasis = True elif indicator == ' ' and emphasis: # changing from emph to not emph if accumulator: result.append((prevstyle+'-word', accumulator)) accumulator = '' emphasis = False accumulator += c if accumulator: if emphasis: result.append(self._emph_trail_ws(prevstyle+'-word', accumulator)) else: result.append(self._emph_trail_ws(prevstyle+'-line', accumulator)) if prevstyle == 'added': output_new.append(result) elif prevstyle == 'removed': output_old.append(result) prevline = None continue if prevline is not None: if prevstyle == 'added' or prevstyle == 'context': output_new.append(self._emph_trail_ws(prevstyle+'-line', prevline)) if prevstyle == 'removed' or prevstyle == 'context': output_old.append((prevstyle+'-line', prevline)) if key == '+': prevstyle = 'added' elif key == '-': prevstyle = 'removed' elif key == ' ': prevstyle = 'context' prevline = rest #self.log.debug('prev'+repr(prevline)) if prevline is not None: if prevstyle == 'added': output_new.append(self._emph_trail_ws(prevstyle+'-line', prevline)) elif prevstyle == 'removed': output_old.append((prevstyle+'-line', prevline)) #self.log.debug(repr(output_old)) #self.log.debug(repr(output_new)) return output_old, output_new header_re = re.compile('@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@') def diff(self, old, new, context=10000, show_old_commit=False): """Create a diff from old to new. Note that the commit message is also diffed, and listed as /COMMIT_MSG. """ repo = git.Repo(self.path) #'-y', '-x', 'diff -C10', old, new, path).split('\n'): oldc = repo.commit(old) newc = repo.commit(new) files = [] extra_contexts = [] if show_old_commit: extra_contexts.append(CommitContext(oldc, newc)) else: extra_contexts.append(CommitContext(None, newc)) contexts = itertools.chain( extra_contexts, oldc.diff( newc, color='never', create_patch=True, unified=context)) for diff_context in contexts: # Each iteration of this is a file f = DiffFile() f.oldname = diff_context.a_path f.newname = diff_context.b_path if diff_context.new_file: f.oldname = 'Empty file' f.old_empty = True if diff_context.deleted_file: f.newname = 'Empty file' f.new_empty = True files.append(f) if diff_context.rename_from: f.oldname = diff_context.rename_from if diff_context.rename_to: f.newname = diff_context.rename_to oldchunk = [] newchunk = [] prev_key = '' if isinstance(diff_context.diff, six.string_types): diff_text = diff_context.diff else: diff_text = diff_context.diff.decode('utf-8') diff_lines = diff_text.split('\n') for i, line in enumerate(diff_lines): last_line = (i == len(diff_lines)-1) if line.startswith('---'): continue if line.startswith('+++'): continue if line.startswith('@@'): #socket.sendall(line) m = self.header_re.match(line) #socket.sendall(str(m.groups())) f.old_lineno = int(m.group(1)) f.new_lineno = int(m.group(3)) continue if not line: if prev_key != '\\': # Strangely, we get an extra newline in the # diff in the case that the last line is "\ No # newline at end of file". This is a # workaround for that. prev_key = '' line = 'X ' else: line = ' ' key = line[0] rest = line[1:] if key == '\\': # This is for "\ No newline at end of file" which # follows either a -, + or ' ' line to indicate # which file it's talking about (or both). For # now, treat it like normal text and let the user # infer from context that it's not actually in the # file. Potential TODO: highlight it to make that # more clear. if prev_key: key = prev_key else: key = ' ' prev_key = '\\' if key == '-': prev_key = '-' oldchunk.append(rest) if not last_line: continue if key == '+': prev_key = '+' newchunk.append(rest) if not last_line: continue prev_key = '' # end of chunk if oldchunk or newchunk: oldchunk, newchunk = self.intralineDiff(oldchunk, newchunk) f.addDiffLines(oldchunk, newchunk) oldchunk = [] newchunk = [] if key == ' ': f.addContextLine(rest) continue if line.startswith("similarity index"): continue if line.startswith("rename"): continue if line.startswith("index"): continue if line.startswith("Binary files"): continue if not last_line: raise Exception("Unhandled line: %s" % line) if not diff_context.diff: # There is no diff, possibly because this is simply a # rename. Include context lines so that comments may # appear. if not f.new_empty: blob = newc.tree[f.newname] else: blob = oldc.tree[f.oldname] f.old_lineno = 1 f.new_lineno = 1 for line in blob.data_stream.read().splitlines(): if isinstance(line, six.string_types): f.addContextLine(line) else: try: f.addContextLine(line.decode('utf8')) except: f.addContextLine("") f.finalize() return files def getFile(self, old, new, path): f = DiffFile() f.oldname = path f.newname = path f.old_lineno = 1 f.new_lineno = 1 repo = git.Repo(self.path) newc = repo.commit(new) try: blob = newc.tree[path] except KeyError: return None for line in blob.data_stream.read().splitlines(): if isinstance(line, six.string_types): f.addContextLine(line) else: f.addContextLine(line.decode('utf8')) f.finalize() return f def get_repo(project_name, config): local_path = os.path.join(config.git_root, project_name) local_root = os.path.abspath(config.git_root) assert os.path.commonprefix((local_root, local_path)) == local_root return Repo(config.git_url + project_name, local_path) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/keymap.py0000664000175000017500000002105714667773353017711 0ustar00jamespagejamespage# 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 re import string import logging import urwid # urwid command map: REDRAW_SCREEN = urwid.REDRAW_SCREEN CURSOR_UP = urwid.CURSOR_UP CURSOR_DOWN = urwid.CURSOR_DOWN CURSOR_LEFT = urwid.CURSOR_LEFT CURSOR_RIGHT = urwid.CURSOR_RIGHT CURSOR_PAGE_UP = urwid.CURSOR_PAGE_UP 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: KILL = 'kill' YANK = 'yank' YANK_POP = 'yank pop' 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' 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' SEARCH_RESULTS = 'search results' NEXT_CHANGE = 'next change' PREV_CHANGE = 'previous change' TOGGLE_HIDDEN_COMMENTS = 'toggle hidden comments' ABANDON_CHANGE = 'abandon change' RESTORE_CHANGE = 'restore change' WIP_CHANGE = 'mark change work in progress' READY_CHANGE = 'mark change ready for review' REBASE_CHANGE = 'rebase change' CHERRY_PICK_CHANGE = 'cherry pick change' REFRESH = 'refresh' EDIT_TOPIC = 'edit topic' EDIT_HASHTAGS = 'edit hashtags' EDIT_COMMIT_MESSAGE = 'edit commit message' SUBMIT_CHANGE = 'submit change' 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_SUBSCRIBED = 'toggle list subscribed' TOGGLE_SUBSCRIBED = 'toggle subscribed' NEW_PROJECT_TOPIC = 'new project topic' DELETE_PROJECT_TOPIC = 'delete project topic' 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' NEXT_SELECTABLE = 'next selectable' PREV_SELECTABLE = 'prev selectable' NEXT_PATCHSET = 'next patchset' PREV_PATCHSET = 'prev patchset' INTERACTIVE_SEARCH = 'interactive search' # Special: FURTHER_INPUT = 'further input' DEFAULT_KEYMAP = { REDRAW_SCREEN: 'ctrl l', CURSOR_UP: 'up', CURSOR_DOWN: 'down', CURSOR_LEFT: 'left', CURSOR_RIGHT: 'right', CURSOR_PAGE_UP: ['page up', 'meta v'], CURSOR_PAGE_DOWN: ['page down', 'ctrl v'], CURSOR_MAX_LEFT: ['home', 'ctrl a'], CURSOR_MAX_RIGHT: ['end', 'ctrl e'], ACTIVATE: 'enter', KILL: 'ctrl k', YANK: 'ctrl y', YANK_POP: 'meta y', PREV_SCREEN: 'esc', TOP_SCREEN: 'meta home', HELP: ['f1', '?'], QUIT: ['ctrl q'], CHANGE_SEARCH: 'ctrl o', REFINE_CHANGE_SEARCH: 'meta o', LIST_HELD: 'f12', TOGGLE_REVIEWED: 'v', TOGGLE_HIDDEN: 'k', TOGGLE_STARRED: '*', TOGGLE_HELD: '!', TOGGLE_MARK: '%', REVIEW: 'r', DIFF: 'd', LOCAL_CHECKOUT: 'c', LOCAL_CHERRY_PICK: 'x', SEARCH_RESULTS: 'u', NEXT_CHANGE: 'n', PREV_CHANGE: 'p', TOGGLE_HIDDEN_COMMENTS: 't', ABANDON_CHANGE: 'ctrl a', RESTORE_CHANGE: 'ctrl e', WIP_CHANGE: 'ctrl w', READY_CHANGE: 'ctrl y', REBASE_CHANGE: 'ctrl b', CHERRY_PICK_CHANGE: 'ctrl x', REFRESH: 'ctrl r', EDIT_TOPIC: 'ctrl t', EDIT_HASHTAGS: '#', EDIT_COMMIT_MESSAGE: 'ctrl d', SUBMIT_CHANGE: 'ctrl u', 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_SUBSCRIBED: 'L', TOGGLE_SUBSCRIBED: 's', NEW_PROJECT_TOPIC: [['T', 'n']], DELETE_PROJECT_TOPIC: [['T', 'delete']], MOVE_PROJECT_TOPIC: [['T', 'm']], COPY_PROJECT_TOPIC: [['T', 'c']], REMOVE_PROJECT_TOPIC: [['T', 'D']], RENAME_PROJECT_TOPIC: [['T', 'r']], SELECT_PATCHSETS: 'p', NEXT_SELECTABLE: 'tab', PREV_SELECTABLE: 'shift tab', NEXT_PATCHSET: '>', PREV_PATCHSET: '<', INTERACTIVE_SEARCH: 'ctrl s', } # Hi vi users! Add more things here! This overrides the default # keymap, so anything not defined here will just use what's defined # above. VI_KEYMAP = { QUIT: [[':', 'q']], CURSOR_LEFT: 'h', CURSOR_DOWN: 'j', CURSOR_UP: 'k', CURSOR_RIGHT: 'l', } URWID_COMMANDS = frozenset(( urwid.REDRAW_SCREEN, urwid.CURSOR_UP, urwid.CURSOR_DOWN, urwid.CURSOR_LEFT, urwid.CURSOR_RIGHT, urwid.CURSOR_PAGE_UP, urwid.CURSOR_PAGE_DOWN, urwid.CURSOR_MAX_LEFT, urwid.CURSOR_MAX_RIGHT, urwid.ACTIVATE, KILL, YANK, YANK_POP, )) FORMAT_SUBS = ( (re.compile('ctrl '), 'CTRL-'), (re.compile('meta '), 'META-'), (re.compile('f(\d+)'), 'F\\1'), (re.compile('([a-z][a-z]+)'), lambda x: x.group(1).upper()), ) def formatKey(key): if type(key) == type([]): return ''.join([formatKey(k) for k in key]) for subre, repl in FORMAT_SUBS: key = subre.sub(repl, key) return key class Key(object): def __init__(self, key): self.key = key self.keys = {} self.commands = [] def addKey(self, key): if key not in self.keys: self.keys[key] = Key(key) return self.keys[key] def __repr__(self): return '%s %s %s' % (self.__class__.__name__, self.key, self.keys.keys()) class KeyMap(object): def __init__(self, config): # key -> [commands] self.keytree = Key(None) self.commandmap = {} self.multikeys = '' self.update(DEFAULT_KEYMAP) self.update(config) def update(self, config): # command -> [keys] for command, keys in config.items(): if command == 'name': continue command = command.replace('-', ' ') if type(keys) != type([]): keys = [keys] self.commandmap[command] = keys self.keytree = Key(None) for command, keys in self.commandmap.items(): for key in keys: if isinstance(key, list): # This is a command series tree = self.keytree for i, innerkey in enumerate(key): tree = tree.addKey(innerkey) if i+1 == len(key): tree.commands.append(command) else: tree = self.keytree.addKey(key) tree.commands.append(command) def getCommands(self, keys): if not keys: return [] tree = self.keytree for key in keys: tree = tree.keys.get(key) if not tree: return [] ret = tree.commands[:] if tree.keys: ret.append(FURTHER_INPUT) return ret def getFurtherCommands(self, keys): if not keys: return [] tree = self.keytree for key in keys: tree = tree.keys.get(key) if not tree: return [] return self._getFurtherCommands('', tree) def _getFurtherCommands(self, keys, tree): if keys: ret = [(formatKey(keys), tree.commands[:])] else: ret = [] for subtree in tree.keys.values(): ret.extend(self._getFurtherCommands(keys + subtree.key, subtree)) return ret def getKeys(self, command): return self.commandmap.get(command, []) def updateCommandMap(self): "Update the urwid command map with this keymap" for key in self.keytree.keys.values(): for command in key.commands: if command in URWID_COMMANDS: urwid.command_map[key.key]=command def formatKeys(self, command): keys = self.getKeys(command) keys = [formatKey(k) for k in keys] return ' or '.join(keys) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/mywid.py0000664000175000017500000004467014667773353017562 0ustar00jamespagejamespage# Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import urwid from gertty import keymap from gertty.view import mouse_scroll_decorator GLOBAL_HELP = ( (keymap.HELP, "Display help"), (keymap.PREV_SCREEN, "Back to previous screen"), (keymap.TOP_SCREEN, "Back to project list"), (keymap.QUIT, "Quit Gertty"), (keymap.CHANGE_SEARCH, "Search for changes"), (keymap.LIST_HELD, "List held changes"), (keymap.KILL, "Kill to end of line (editing)"), (keymap.YANK, "Yank from kill ring (editing)"), (keymap.YANK_POP, "Replace previous yank from kill ring (editing)"), ) class TextButton(urwid.Button): def selectable(self): return True def __init__(self, text, on_press=None, user_data=None): super(TextButton, self).__init__('', on_press=on_press, user_data=user_data) self.text = urwid.SelectableIcon(text) self._w = urwid.AttrMap(self.text, None, focus_map='focused') class FixedButton(urwid.Button): def sizing(self): return frozenset([urwid.FIXED]) def pack(self, size, focus=False): return (len(self.get_label())+4, 1) class FixedRadioButton(urwid.RadioButton): def sizing(self): return frozenset([urwid.FIXED]) def pack(self, size, focus=False): return (len(self.get_label())+4, 1) class TableColumn(urwid.Pile): def sizing(self): """Explicit declare flow sizing due to the custom pack method.""" return frozenset((urwid.FLOW,)) def pack(self, size, focus=False): maxcol = size[0] mx = max([i[0].pack((maxcol,), focus)[0] for i in self.contents]) return (min(mx+2, maxcol), len(self.contents)) class Table(urwid.WidgetWrap): def __init__(self, headers=[], columns=None): if columns is None: cols = [('pack', TableColumn([('pack', w)])) for w in headers] else: cols = [('pack', TableColumn([])) for x in range(columns)] super(Table, self).__init__( urwid.Columns(cols)) def addRow(self, cells=[]): for i, widget in enumerate(cells): self._w.contents[i][0].contents.append((widget, ('pack', None))) class KillRing(object): def __init__(self): self.ring = [] def kill(self, text): self.ring.append(text) def yank(self, repeat=False): if not self.ring: return None if repeat: t = self.ring.pop() self.ring.insert(0, t) return self.ring[-1] class MyEdit(urwid.Edit): def __init__(self, *args, **kw): self.ring = kw.pop('ring', None) if not self.ring: self.ring = KillRing() self.last_yank = None super(MyEdit, self).__init__(*args, **kw) def keypress(self, size, key): if self._command_map[key] == keymap.YANK: text = self.ring.yank() if text: self.last_yank = (self.edit_pos, self.edit_pos+len(text)) self.insert_text(text) return if self._command_map[key] == keymap.YANK_POP: if not self.last_yank: return text = self.ring.yank(True) if text: self.edit_text = (self.edit_text[:self.last_yank[0]] + self.edit_text[self.last_yank[1]:]) self.last_yank = (self.edit_pos, self.edit_pos+len(text)) self.insert_text(text) return self.last_yank = None if self._command_map[key] == keymap.KILL: text = self.edit_text[self.edit_pos:] self.edit_text = self.edit_text[:self.edit_pos] self.ring.kill(text) return super(MyEdit, self).keypress(size, key) class LineBoxTitlePropertyMixin(object): @property def title(self): return self._w.title_widget.text.strip() @title.setter def title(self, text): return self._w.set_title(text) class SystemMessage(urwid.WidgetWrap, LineBoxTitlePropertyMixin): def __init__(self, message): w = urwid.Filler(urwid.Text(message, align='center')) super(SystemMessage, self).__init__(urwid.LineBox(w, u'System Message')) @mouse_scroll_decorator.ScrollByWheel class ButtonDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin): def __init__(self, title, message, entry_prompt=None, entry_text='', buttons=[], ring=None): button_widgets = [] for button in buttons: button_widgets.append(('pack', button)) button_columns = urwid.Columns(button_widgets, dividechars=2) rows = [] rows.append(urwid.Text(message)) if entry_prompt: self.entry = MyEdit(entry_prompt, edit_text=entry_text, ring=ring) rows.append(self.entry) else: self.entry = None rows.append(urwid.Divider()) rows.append(button_columns) listbox = urwid.ListBox(rows) super(ButtonDialog, self).__init__(urwid.LineBox(listbox, title)) class LineEditDialog(ButtonDialog): signals = ['save', 'cancel'] def __init__(self, app, title, message, entry_prompt=None, entry_text='', ring=None): self.app = app save_button = FixedButton('Save') cancel_button = FixedButton('Cancel') urwid.connect_signal(save_button, 'click', lambda button:self._emit('save')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) super(LineEditDialog, self).__init__(title, message, entry_prompt, entry_text, buttons=[save_button, cancel_button], ring=ring) def keypress(self, size, key): if not self.app.input_buffer: key = super(LineEditDialog, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if keymap.ACTIVATE in commands: self._emit('save') return None return key class TextEditDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin): signals = ['save', 'cancel'] def __init__(self, title, prompt, button, text, ring=None): save_button = FixedButton(button) cancel_button = FixedButton('Cancel') urwid.connect_signal(save_button, 'click', lambda button:self._emit('save')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) button_widgets = [('pack', save_button), ('pack', cancel_button)] button_columns = urwid.Columns(button_widgets, dividechars=2) rows = [] self.entry = MyEdit(edit_text=text, multiline=True, ring=ring) rows.append(urwid.Text(prompt)) rows.append(self.entry) rows.append(urwid.Divider()) rows.append(button_columns) pile = urwid.Pile(rows) fill = urwid.Filler(pile, valign='top') super(TextEditDialog, self).__init__(urwid.LineBox(fill, title)) class MessageDialog(ButtonDialog): signals = ['close'] def __init__(self, title, message): ok_button = FixedButton('OK') urwid.connect_signal(ok_button, 'click', lambda button:self._emit('close')) super(MessageDialog, self).__init__(title, message, buttons=[ok_button]) class YesNoDialog(ButtonDialog): signals = ['yes', 'no'] def __init__(self, title, message): yes_button = FixedButton('Yes') no_button = FixedButton('No') urwid.connect_signal(yes_button, 'click', lambda button:self._emit('yes')) urwid.connect_signal(no_button, 'click', lambda button:self._emit('no')) super(YesNoDialog, self).__init__(title, message, buttons=[yes_button, no_button]) def keypress(self, size, key): r = super(YesNoDialog, self).keypress(size, key) if r in ('Y', 'y'): self._emit('yes') return None if r in ('N', 'n'): self._emit('no') return None return r class SearchableText(urwid.Text): def set_text(self, markup): self._markup = markup super(SearchableText, self).set_text(markup) def search(self, search, attribute): if not search: self.set_text(self._markup) return (text, attrs) = urwid.util.decompose_tagmarkup(self._markup) last = 0 found = False while True: start = text.find(search, last) if start < 0: break found = True end = start + len(search) i = 0 newattrs = [] for attr, al in attrs: if i + al <= start: i += al newattrs.append((attr, al)) continue if i >= end: i += al newattrs.append((attr, al)) continue before = max(start - i, 0) after = max(i + al - end, 0) if before: newattrs.append((attr, before)) newattrs.append((attribute, len(search))) if after: newattrs.append((attr, after)) i += al if i < start: newattrs.append((None, start-i)) i += start-i if i < end: newattrs.append((attribute, len(search))) last = start + 1 attrs = newattrs self._text = text self._attrib = attrs self._invalidate() return found class Searchable(object): def searchInit(self): self.search = None self.results = [] self.current_result = 0 def searchValidChar(self, ch): return urwid.util.is_wide_char(ch, 0) or (len(ch) == 1 and ord(ch) >= 32) def searchKeypress(self, size, key): if self.search is not None: if self.searchValidChar(key) or key == 'backspace': if key == 'backspace': self.search = self.search[:-1] else: self.search += key self.interactiveSearch(self.search) return True else: commands = self.app.config.keymap.getCommands([key]) if keymap.INTERACTIVE_SEARCH in commands: self.nextSearchResult() return True else: self.app.status.update(title=self.title) if not self.search: self.interactiveSearch(None) self.search = None if key in ['enter', 'esc']: return True return False def searchStart(self): self.search = '' self.app.status.update(title=("Search: ")) def interactiveSearch(self, search): if search is not None: self.app.status.update(title=("Search: " + search)) self.results = [] self.current_result = 0 for i, line in enumerate(self.listbox.body): if hasattr(line, 'search'): if line.search(search, 'search-result'): self.results.append(i) def nextSearchResult(self): if not self.results: return dest = self.results[self.current_result] self.listbox.set_focus(dest) self.listbox._invalidate() self.current_result += 1 if self.current_result >= len(self.results): self.current_result = 0 class HyperText(urwid.Text): _selectable = True def sizing(self): """Explicit declare flow sizing due to the custom pack method. Normal Text can be rendered as FIXED. """ return frozenset((urwid.FLOW,)) def __init__(self, markup, align=urwid.LEFT, wrap=urwid.SPACE, layout=None): self._mouse_press_item = None self.selectable_items = [] self.focused_index = None self.last_focused_index = 0 super(HyperText, self).__init__(markup, align, wrap, layout) def focusFirstItem(self): if len(self.selectable_items) == 0: return False self.focusItem(0) return True def focusLastItem(self): if len(self.selectable_items) == 0: return False self.focusItem(len(self.selectable_items)-1) return True def focusPreviousItem(self): if len(self.selectable_items) == 0: return False if self.focused_index is None: self.focusItem(self.last_focused_index) item = max(0, self.focused_index-1) if item != self.focused_index: self.focusItem(item) return True return False def focusNextItem(self): if len(self.selectable_items) == 0: return False if self.focused_index is None: self.focusItem(self.last_focused_index) item = min(len(self.selectable_items)-1, self.focused_index+1) if item != self.focused_index: self.focusItem(item) return True return False def focusItem(self, item): self.last_focused_index = self.focused_index self.focused_index = item self.set_text(self._markup) self._invalidate() def select(self): if self.focused_index is not None: self.selectable_items[self.focused_index][0].select() def keypress(self, size, key): if self._command_map[key] == urwid.CURSOR_UP: if self.focusPreviousItem(): return False return key elif self._command_map[key] == urwid.CURSOR_DOWN: if self.focusNextItem(): return False return key elif self._command_map[key] == urwid.ACTIVATE: self.select() return False return key def getPosAtCoords(self, maxcol, col, row): trans = self.get_line_translation(maxcol) colpos = 0 line = None try: line = trans[row] except IndexError: return None for t in line: if len(t) == 2: width, pos = t if colpos <= col < colpos + width: return pos else: width, start, end = t if colpos <= col < colpos + width: return start + (col - colpos) colpos += width return None def getItemAtCoords(self, maxcol, col, row): pos = self.getPosAtCoords(maxcol, col, row) index = 0 for item, start, end in self.selectable_items: if pos is not None and start <= pos <= end: return index index += 1 return None def mouse_event(self, size, event, button, col, row, focus): if ((button not in [0, 1]) or (event not in ['mouse press', 'mouse release'])): return False item = self.getItemAtCoords(size[0], col, row) if item is None: if self.focused_index is None: self.focusFirstItem() return False if event == 'mouse press': self.focusItem(item) self._mouse_press_item = item if event == 'mouse release': if self._mouse_press_item == item: self.select() self._mouse_press_item = None return True def processLinks(self, markup, data=None): if data is None: data = dict(pos=0) if isinstance(markup, list): return [self.processLinks(i, data) for i in markup] if isinstance(markup, tuple): return (markup[0], self.processLinks(markup[1], data)) if isinstance(markup, Link): self.selectable_items.append((markup, data['pos'], data['pos']+len(markup.text))) data['pos'] += len(markup.text) focused = len(self.selectable_items)-1 == self.focused_index link_attr = markup.getAttr(focused) if link_attr: return (link_attr, markup.text) else: return markup.text data['pos'] += len(markup) return markup def get_cursor_coords(self, size): if self.focused_index is None: cursor_pos = 0 return None else: item, start, end = self.selectable_items[self.focused_index] cursor_pos = start (maxcol,) = size trans = self.get_line_translation(maxcol) x, y = urwid.text_layout.calc_coords(self.text, trans, cursor_pos) if maxcol <= x: return None return x, y def set_text(self, markup): self._markup = markup self.selectable_items = [] super(HyperText, self).set_text(self.processLinks(markup)) def move_cursor_to_coords(self, size, col, row): if self.focused_index is None: if row: self.focusLastItem() else: self.focusFirstItem() return True def render(self, size, focus=False): if (not focus) and (self.focused_index is not None): self.focusItem(None) ret = super(HyperText, self).render(size, focus) if focus: ret = urwid.canvas.CompositeCanvas(ret) ret.cursor = self.get_cursor_coords(size) return ret class Link(urwid.Widget): signals = ['selected'] def __init__(self, text, attr=None, focused_attr=None): self.text = text self.attr = attr self.focused_attr = focused_attr def select(self): self._emit('selected') def getAttr(self, focus): if focus: return self.focused_attr return self.attr ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/palette.py0000664000175000017500000001565614667773353020071 0ustar00jamespagejamespage# Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. DEFAULT_PALETTE={ 'focused': ['default,standout', ''], 'header': ['white,bold', 'dark blue'], 'error': ['light red', 'dark blue'], 'table-header': ['white,bold', ''], 'filename': ['light cyan', ''], 'filename-inline-comment': ['dark cyan', ''], 'focused-filename': ['light cyan,standout', ''], 'positive-label': ['dark green', ''], 'negative-label': ['dark red', ''], 'max-label': ['light green', ''], 'min-label': ['light red', ''], 'focused-positive-label': ['dark green,standout', ''], 'focused-negative-label': ['dark red,standout', ''], 'focused-max-label': ['light green,standout', ''], 'focused-min-label': ['light red,standout', ''], 'link': ['dark blue', ''], 'focused-link': ['light blue', ''], 'footer': ['light gray', 'dark gray'], # Diff 'context-button': ['dark magenta', ''], 'focused-context-button': ['light magenta', ''], 'removed-line': ['dark red', ''], 'removed-word': ['light red', ''], 'added-line': ['dark green', ''], 'added-word': ['light green', ''], 'nonexistent': ['default', ''], 'focused-removed-line': ['dark red,standout', ''], 'focused-removed-word': ['light red,standout', ''], 'focused-added-line': ['dark green,standout', ''], 'focused-added-word': ['light green,standout', ''], 'focused-nonexistent': ['default,standout', ''], 'draft-comment': ['default', 'dark gray'], 'comment': ['light gray', 'dark gray'], 'comment-name': ['white', 'dark gray'], 'line-number': ['dark gray', ''], 'focused-line-number': ['dark gray,standout', ''], 'search-result': ['default,standout', ''], 'trailing-ws': ['light red,standout', ''], # Change view 'change-data': ['dark cyan', ''], 'focused-change-data': ['light cyan', ''], 'change-header': ['light blue', ''], 'revision-name': ['light blue', ''], 'revision-commit': ['dark blue', ''], 'revision-comments': ['default', ''], 'revision-drafts': ['dark red', ''], 'focused-revision-name': ['light blue,standout', ''], 'focused-revision-commit': ['dark blue,standout', ''], 'focused-revision-comments': ['default,standout', ''], 'focused-revision-drafts': ['dark red,standout', ''], 'change-message-name': ['yellow', ''], 'change-message-own-name': ['light cyan', ''], 'change-message-header': ['brown', ''], 'change-message-own-header': ['dark cyan', ''], 'change-message-draft': ['dark red', ''], 'revision-button': ['dark magenta', ''], 'focused-revision-button': ['light magenta', ''], 'lines-added': ['light green', ''], 'lines-removed': ['light red', ''], 'reviewer-name': ['yellow', ''], 'reviewer-own-name': ['light cyan', ''], 'check-SUCCESSFUL': ['light green', ''], 'check-FAILED': ['light red', ''], 'check-NOT_STARTED': ['dark gray', ''], 'check-SCHEDULED': ['dark magenta', '', ''], 'check-RUNNING': ['dark magenta', ''], 'check-NOT_RELEVANT': ['dark gray', ''], 'state-wip': ['yellow', ''], # project list 'unreviewed-project': ['white', ''], 'subscribed-project': ['default', ''], 'unsubscribed-project': ['dark gray', ''], 'marked-project': ['light cyan', ''], 'focused-unreviewed-project': ['white,standout', ''], 'focused-subscribed-project': ['default,standout', ''], 'focused-unsubscribed-project': ['dark gray,standout', ''], 'focused-marked-project': ['light cyan,standout', ''], # change list 'unreviewed-change': ['default', ''], 'reviewed-change': ['dark gray', ''], 'focused-unreviewed-change': ['default,standout', ''], 'focused-reviewed-change': ['dark gray,standout', ''], 'starred-change': ['light cyan', ''], 'focused-starred-change': ['light cyan,standout', ''], 'held-change': ['light red', ''], 'focused-held-change': ['light red,standout', ''], 'marked-change': ['dark cyan', ''], 'focused-marked-change': ['dark cyan,standout', ''], 'added-graph': ['dark green', ''], 'removed-graph': ['dark red', ''], 'added-removed-graph': ['dark green', 'dark red'], 'focused-added-graph': ['default,standout', 'dark green'], 'focused-removed-graph': ['default,standout', 'dark red'], 'line-count-threshold-1': ['light green', ''], 'focused-line-count-threshold-1': ['light green,standout', ''], 'line-count-threshold-2': ['light cyan', ''], 'focused-line-count-threshold-2': ['light cyan,standout', ''], 'line-count-threshold-3': ['light blue', ''], 'focused-line-count-threshold-3': ['light blue,standout', ''], 'line-count-threshold-4': ['yellow', ''], 'focused-line-count-threshold-4': ['yellow,standout', ''], 'line-count-threshold-5': ['dark magenta', ''], 'focused-line-count-threshold-5': ['dark magenta,standout', ''], 'line-count-threshold-6': ['light magenta', ''], 'focused-line-count-threshold-6': ['light magenta,standout', ''], 'line-count-threshold-7': ['dark red', ''], 'focused-line-count-threshold-7': ['dark red,standout', ''], 'line-count-threshold-8': ['light red', ''], 'focused-line-count-threshold-8': ['light red,standout', ''], } # A delta from the default palette LIGHT_PALETTE = { 'table-header': ['black,bold', ''], 'unreviewed-project': ['black', ''], 'subscribed-project': ['dark gray', ''], 'unsubscribed-project': ['dark gray', ''], 'focused-unreviewed-project': ['black,standout', ''], 'focused-subscribed-project': ['dark gray,standout', ''], 'focused-unsubscribed-project': ['dark gray,standout', ''], 'change-data': ['dark blue,bold', ''], 'focused-change-data': ['dark blue,standout', ''], 'reviewer-name': ['brown', ''], 'reviewer-own-name': ['dark blue,bold', ''], 'change-message-name': ['brown', ''], 'change-message-own-name': ['dark blue,bold', ''], 'change-message-header': ['black', ''], 'change-message-own-header': ['black,bold', ''], 'focused-link': ['dark blue,bold', ''], 'filename': ['dark cyan', ''], } class Palette(object): def __init__(self, config): self.palette = {} self.palette.update(DEFAULT_PALETTE) self.update(config) def update(self, config): d = config.copy() if 'name' in d: del d['name'] self.palette.update(d) def getPalette(self): ret = [] for k,v in self.palette.items(): ret.append(tuple([k]+v)) return ret ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953371.0 gertty-1.6.1.dev56/gertty/requestsexceptions.py0000664000175000017500000000242314667772533022373 0ustar00jamespagejamespage# Copyright 2015 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. try: from requests.packages.urllib3.exceptions import InsecurePlatformWarning except ImportError: try: from urllib3.exceptions import InsecurePlatformWarning except ImportError: InsecurePlatformWarning = None try: from requests.packages.urllib3.exceptions import InsecureRequestWarning except ImportError: try: from urllib3.exceptions import InsecureRequestWarning except ImportError: InsecureRequestWarning = None try: from requests.packages.urllib3.exceptions import SNIMissingWarning except ImportError: try: from urllib3.exceptions import SNIMissingWarning except ImportError: SNIMissingWarning = None ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1725953980.1860216 gertty-1.6.1.dev56/gertty/search/0000775000175000017500000000000014667773674017317 5ustar00jamespagejamespage././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/search/__init__.py0000664000175000017500000000741614667773353021432 0ustar00jamespagejamespage# 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 sqlalchemy.sql.expression from sqlalchemy.sql.expression import and_, select, func from gertty.search import tokenizer, parser import gertty.db class SearchSyntaxError(Exception): def __init__(self, message): self.message = message class SearchCompiler(object): def __init__(self, get_account_id): self.get_account_id = get_account_id self.lexer = tokenizer.SearchTokenizer() self.parser = parser.SearchParser() self.parser.account_id = None def findTables(self, expression): tables = set() stack = [expression] while stack: x = stack.pop() if hasattr(x, 'table'): if (x.table != gertty.db.change_table and hasattr(x.table, 'name')): tables.add(x.table) for child in x.get_children(): if not isinstance(child, sqlalchemy.sql.selectable.Select): stack.append(child) return tables def parse(self, data): if self.parser.account_id is None: self.parser.account_id = self.get_account_id() if self.parser.account_id is None: raise Exception("Own account is unknown") 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, 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, result) tables.remove(gertty.db.account_table) if gertty.db.hashtag_table in tables: result = and_(gertty.db.hashtag_table.c.change_key == gertty.db.change_table.c.key, result) tables.remove(gertty.db.hashtag_table) if gertty.db.file_table in tables: # We only want to look at files for the most recent # revision. s = select([func.max(gertty.db.revision_table.c.number)], correlate=False).where( gertty.db.revision_table.c.change_key==gertty.db.change_table.c.key).correlate(gertty.db.change_table) 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, gertty.db.revision_table.c.number == s, result) tables.remove(gertty.db.file_table) if tables: raise Exception("Unknown table in search: %s" % tables) return result if __name__ == '__main__': class Dummy(object): pass query = 'recentlyseen:24 hours' lexer = tokenizer.SearchTokenizer() lexer.input(query) while True: token = lexer.token() if not token: break print(token) app = Dummy() app.config = Dummy() app.config.username = 'bob' search = SearchCompiler(app.config.username) x = search.parse(query) print(x) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1725953771.0 gertty-1.6.1.dev56/gertty/search/parser.py0000664000175000017500000003670314667773353021170 0ustar00jamespagejamespage# 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 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 def age_to_delta(delta, unit): if unit in ['seconds', 'second', 'sec', 's']: pass elif unit in ['minutes', 'minute', 'min', 'm']: delta = delta * 60 elif unit in ['hours', 'hour', 'hr', 'h']: delta = delta * 60 * 60 elif unit in ['days', 'day', 'd']: delta = delta * 60 * 60 * 24 elif unit in ['weeks', 'week', 'w']: delta = delta * 60 * 60 * 24 * 7 elif unit in ['months', 'month', 'mon']: delta = delta * 60 * 60 * 24 * 30 elif unit in ['years', 'year', 'y']: delta = delta * 60 * 60 * 24 * 365 return delta def SearchParser(): precedence = ( # NOQA ('left', 'NOT', 'NEG'), ) def p_terms(p): '''expression : list_expr | paren_expr | boolean_expr | negative_expr | term''' p[0] = p[1] def p_list_expr(p): '''list_expr : expression expression''' p[0] = and_(p[1], p[2]) def p_paren_expr(p): '''paren_expr : LPAREN expression RPAREN''' p[0] = p[2] def p_boolean_expr(p): '''boolean_expr : expression AND expression | expression OR expression''' if p[2].lower() == 'and': p[0] = and_(p[1], p[3]) elif p[2].lower() == 'or': p[0] = or_(p[1], p[3]) else: raise gertty.search.SearchSyntaxError("Boolean %s not recognized" % p[2]) def p_negative_expr(p): '''negative_expr : NOT expression | NEG expression''' p[0] = not_(p[2]) def p_term(p): '''term : age_term | recentlyseen_term | change_term | owner_term | reviewer_term | commit_term | project_term | projects_term | project_key_term | branch_term | topic_term | hashtag_term | ref_term | label_term | message_term | comment_term | has_term | is_term | status_term | file_term | path_term | limit_term | op_term''' p[0] = p[1] def p_string(p): '''string : SSTRING | DSTRING | USTRING''' p[0] = p[1] def p_age_term(p): '''age_term : OP_AGE NUMBER string''' now = datetime.datetime.utcnow() 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)) def p_recentlyseen_term(p): '''recentlyseen_term : OP_RECENTLYSEEN NUMBER string''' # A gertty 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)], correlate=False) p[0] = gertty.db.change_table.c.last_seen >= s def p_change_term(p): '''change_term : OP_CHANGE CHANGE_ID | OP_CHANGE NUMBER''' if type(p[2]) == int: p[0] = gertty.db.change_table.c.number == p[2] else: p[0] = gertty.db.change_table.c.change_id == p[2] def p_owner_term(p): '''owner_term : OP_OWNER string''' if p[2] == 'self': account_id = p.parser.account_id p[0] = gertty.db.account_table.c.id == account_id 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]) 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) try: number = int(p[2]) except: number = None if number is not None: filters.append(gertty.db.account_table.c.id == number) elif p[2] == 'self': account_id = p.parser.account_id filters.append(gertty.db.account_table.c.id == account_id) 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) 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) 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) else: p[0] = gertty.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]) def p_project_key_term(p): '''project_key_term : OP_PROJECT_KEY NUMBER''' p[0] = gertty.db.change_table.c.project_key == p[2] 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) else: p[0] = gertty.db.change_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) else: p[0] = and_(gertty.db.change_table.c.topic.isnot(None), gertty.db.change_table.c.topic == p[2]) def p_hashtag_term(p): '''hashtag_term : OP_HASHTAG string''' if p[2].startswith('^'): p[0] = func.matches(p[2], gertty.db.hashtag_table.c.name) else: p[0] = gertty.db.hashtag_table.c.name == 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) else: p[0] = gertty.db.change_table.c.branch == p[2][len('refs/heads/'):] label_re = re.compile(r'(?P