gertty-1.3.1/0000775000567000056700000000000012636066311014176 5ustar jenkinsjenkins00000000000000gertty-1.3.1/examples/0000775000567000056700000000000012636066311016014 5ustar jenkinsjenkins00000000000000gertty-1.3.1/examples/googlesource-gertty.yaml0000664000567000056700000000457012636066265022727 0ustar jenkinsjenkins00000000000000# This is an example ~/.gertty.yaml file for use with installations of # Gerrit running on googlesource.com. Most of these options are not # required, rather, they customize Gertty to better deal with the # particulars of Google's Gerrit configuration. # See the reference-gertty.yaml file for more options. servers: - name: CHANGEME-review url: https://CHANGEME-review.googlesource.com/ # Your gerrit username. username: CHANGEME # Set password at https://{name}-review.googlesource.com/#/settings/http-password # Note this is not your Google password. password: CHANGEME auth-type: basic git-root: ~/git/ # Uncomment the next line if your terminal has a white background # palette: light # Commentlinks are regexes that are applied to commit and review # messages. They can be replaced with internal or external links, or # have colors applied. commentlinks: # Match Gerrit change ids, and replace them with a link to an # internal Gertty search for that change id. - match: "(?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. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" # 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 gertty-1.3.1/examples/openstack-gertty.yaml0000664000567000056700000000775612636066265022232 0ustar jenkinsjenkins00000000000000# This is an example ~/.gertty.yaml file for use with OpenStack's # Gerrit. Most of these options are not required, rather, they # customize Gertty to better deal with the particulars of OpenStack's # Gerrit configuration. servers: - name: openstack url: https://review.openstack.org/ # Your gerrit username. username: CHANGEME # Set password at https://review.openstack.org/#/settings/http-password # Note this is not your launchpad password. password: CHANGEME git-root: ~/git/ # This section adds the colors that we will reference later in the # commentlinks section for test results. You can also change other # colors here. palettes: - name: default test-SUCCESS: ['light green', ''] test-FAILURE: ['light red', ''] test-UNSTABLE: ['yellow', ''] # Uncomment the next line if your terminal has a white background # palette: light # Commentlinks are regexes that are applied to commit and review # messages. They can be replaced with internal or external links, or # have colors applied. commentlinks: # This matches the job results left by Zuul. - match: "^- (?P.*?) (?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}" # 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. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" # 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 gertty-1.3.1/examples/reference-gertty.yaml0000664000567000056700000002042212636066265022162 0ustar jenkinsjenkins00000000000000# This is an example ~/.gertty.yaml with an exhaustive listing of # options with commentary. # This section lists the servers that Gertty can talk to. Multiple # servers may be listed; by default, Gertty will use the first one # listed. To select another, simply specify its name on the command # line. servers: - name: CHANGEME url: https://CHANGEME.example.org/ username: CHANGEME # Your HTTP Password for gerrit. Go to the "HTTP Password" section in your # account settings to generate/retrieve this password. password: CHANGEME git-root: ~/git/ # 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' or # 'digest'. Defaults to 'digest' if not set or set to an unexpected # value. # 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 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 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. Only one keymap is defined # internally, the default keymap. Individual keys may be overridden # and custom keymaps defined and selected in the config file or the # command line. # Each keymap contains a mapping of command -> key(s). If a command # is not specified, Gertty will use the keybinding specified in the # default map. More than one key can be bound to a command. # Run "gertty --print-keymap" for a list of commands that can be # bound. keymaps: - name: default diff: 'd' - name: custom review: ['r', 'R'] - name: osx #OS X blocks ctrl+o change-search: 'ctrl s' # To specify a sequence of keys, they must be a list of keystrokes # within a list of key series. For example: - name: vi quit: [[':', 'q']] # The default keymap may be selected with the '-k KEYMAP' command line # option, or with the following line: # keymap: custom # Commentlinks are regular expressions that are applied to commit and # review messages. They can be replaced with internal or external # links, or have colors applied. commentlinks: # Match Gerrit change ids, and replace them with a link to an internal # Gertty search for that change id. - match: "(?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' or 'updated'. # The 'reverse' key specifies ascending (true) or descending (false) order. # change-list-options: # sort-by: 'number' # reverse: false # Uncomment the following line to use a unified diff view instead # of the default side-by-side: # diff-view: unified # Dependent changes are displayed as "threads" in the change list by # default. To disable this behavior, uncomment the following line: # thread-changes: false # Times are displayed in the local timezone by default. To display # them in UTC instead, uncomment the following line: # display-times-in-utc: true # 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 # discard the current display stack and replace it with the results of # the query. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" # 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 gertty-1.3.1/examples/minimal-gertty.yaml0000664000567000056700000000041412636066265021651 0ustar jenkinsjenkins00000000000000# This is an example ~/.gertty.yaml file with only the required # settings. servers: - name: CHANGEME url: https://CHANGEME.example.org/ username: CHANGEME # Set corresponding HTTP password in gerrit settings password: CHANGEME git-root: ~/git/ gertty-1.3.1/ChangeLog0000664000567000056700000002657212636066311015764 0ustar jenkinsjenkins00000000000000CHANGES ======= 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 gertty-1.3.1/tox.ini0000664000567000056700000000170112636066265015520 0ustar jenkinsjenkins00000000000000# The official style guide for Gertty is this: # # Try to match the existing code style and don't worry about it # too much. # # Please don't submit changes to enable pep8 style checks or change # the code to match pep8 guidelines. Gertty should be fun to work on # and it shouldn't be hard to go with the flow and not worry too much # about whitespace. # # Pyflakes on the other hand is a useful system that often catches # real bugs and errors. Flake8 is used to invoke pyflakes because it # supports the "NOQA" flag. [tox] minversion = 1.6 skipsdist = True envlist = pyflakes [testenv] setenv = VIRTUAL_ENV={envdir} usedevelop = True install_command = pip install {opts} {packages} deps = -r{toxinidir}/requirements.txt [testenv:pyflakes] commands = flake8 deps = flake8 [testenv:venv] commands = {posargs} [flake8] # Please do not fix these. See comment at top of file. ignore = W,E show-source = True exclude = .venv,.tox,dist,doc,build,*.egg gertty-1.3.1/setup.cfg0000664000567000056700000000155112636066311016021 0ustar jenkinsjenkins00000000000000[metadata] name = gertty summary = Gertty is a console-based interface to the Gerrit Code Review system. description-file = README.rst author = OpenStack author-email = openstack-dev@lists.openstack.org home-page = http://www.openstack.org/ classifier = Topic :: Utilities Intended Audience :: Developers Intended Audience :: Information Technology Intended Audience :: System Administrators License :: OSI Approved :: Apache Software License Operating System :: POSIX :: Linux Programming Language :: Python Programming Language :: Python :: 2 Programming Language :: Python :: 2.7 Programming Language :: Python :: 2.6 keywords = gerrit console urwid review [files] packages = gertty data_files = share/gertty/examples = examples/* [entry_points] console_scripts = gertty = gertty.app:main [egg_info] tag_build = tag_date = 0 tag_svn_revision = 0 gertty-1.3.1/README.rst0000664000567000056700000001447112636066265015704 0ustar jenkinsjenkins00000000000000Gertty ====== 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 sid/testing. 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 . Gertty uses a YAML based configuration file that it looks for at ``~/.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 ~/.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. **openstack-gertty.yaml** A configuration designed for use with OpenStack's installation of Gerrit. **googlesource-gertty.yaml** A configuration designed for use with installations of Gerrit running on googlesource.com. You will need your Gerrit password which you can generate or retrieve by navigating to ``Settings``, then ``HTTP Password``. Gertty uses local git repositories to perform much of its work. These can be the same git repositories that you use when developing a project. Gertty will not alter the working directory or index unless you request it to (and even then, the usual git safeguards against accidentally losing work remain in place). You will need to supply the name of a directory where Gertty will find or clone git repositories for your projects as the ``git-root`` parameter. 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). This will explain why the number of tasks displayed in the status bar sometimes changes rapidly. 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 toggeled 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. 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/698 gertty-1.3.1/gertty/0000775000567000056700000000000012636066311015514 5ustar jenkinsjenkins00000000000000gertty-1.3.1/gertty/sync.py0000664000567000056700000016756112636066266017073 0ustar jenkinsjenkins00000000000000# 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 errno import logging import math import os import re import threading import urllib import urlparse import json import time import Queue import datetime import dateutil.parser try: import ordereddict except: pass import requests import requests.utils import gertty.version from gertty import gitrepo HIGH_PRIORITY=0 NORMAL_PRIORITY=1 LOW_PRIORITY=2 TIMEOUT=30 CLOSED_STATUSES = ['MERGED', 'ABANDONED'] class MultiQueue(object): def __init__(self, priorities): try: self.queues = collections.OrderedDict() except AttributeError: self.queues = ordereddict.OrderedDict() for key in priorities: self.queues[key] = collections.deque() self.condition = threading.Condition() def qsize(self): count = 0 for queue in self.queues.values(): count += len(queue) return count def put(self, item, priority): added = False self.condition.acquire() try: if item not in self.queues[priority]: self.queues[priority].append(item) added = True self.condition.notify() finally: self.condition.release() return added def get(self): self.condition.acquire() try: while True: for queue in self.queues.values(): try: ret = queue.popleft() return ret except IndexError: pass self.condition.wait() finally: self.condition.release() def find(self, klass, priority): results = [] self.condition.acquire() try: for item in self.queues[priority]: if isinstance(item, klass): results.append(item) finally: self.condition.release() return results class UpdateEvent(object): def updateRelatedChanges(self, session, change): related_change_keys = set() related_change_keys.add(change.key) for revision in change.revisions: parent = session.getRevisionByCommit(revision.parent) if parent: related_change_keys.add(parent.change.key) for child in session.getRevisionsByParent(revision.commit): related_change_keys.add(child.change.key) self.related_change_keys = related_change_keys class ProjectAddedEvent(UpdateEvent): def __repr__(self): return '' % ( self.project_key,) def __init__(self, project): self.project_key = project.key class ChangeAddedEvent(UpdateEvent): def __repr__(self): return '' % ( self.project_key, self.change_key) def __init__(self, change): self.project_key = change.project.key self.change_key = change.key self.related_change_keys = set() self.review_flag_changed = True self.status_changed = True self.held_changed = False class ChangeUpdatedEvent(UpdateEvent): def __repr__(self): return '' % ( self.project_key, self.change_key, self.review_flag_changed, self.status_changed) def __init__(self, change): self.project_key = change.project.key self.change_key = change.key self.related_change_keys = set() self.review_flag_changed = False self.status_changed = False self.held_changed = False class Task(object): def __init__(self, priority=NORMAL_PRIORITY): self.log = logging.getLogger('gertty.sync') self.priority = priority self.succeeded = None self.event = threading.Event() self.tasks = [] self.results = [] def complete(self, success): self.succeeded = success self.event.set() def wait(self, timeout=None): self.event.wait(timeout) return self.succeeded def __eq__(self, other): raise NotImplementedError() class SyncOwnAccountTask(Task): def __repr__(self): return '' def __eq__(self, other): if other.__class__ == self.__class__: return True return False def run(self, sync): app = sync.app remote = sync.get('accounts/self') sync.account_id = remote['_account_id'] with app.db.getSession() as session: session.getAccountByID(remote['_account_id'], remote.get('name'), remote.get('username'), remote.get('email')) class GetVersionTask(Task): def __repr__(self): return '' def __eq__(self, other): if other.__class__ == self.__class__: return True return False def run(self, sync): version = sync.get('config/server/version') sync.setRemoteVersion(version) class SyncProjectListTask(Task): def __repr__(self): return '' def __eq__(self, other): if other.__class__ == self.__class__: return True return False def run(self, sync): app = sync.app remote = sync.get('projects/?d') remote_keys = set(remote.keys()) with app.db.getSession() as session: local = {} for p in session.getProjects(): local[p.name] = p local_keys = set(local.keys()) for name in local_keys-remote_keys: session.delete(local[name]) for name in remote_keys-local_keys: p = remote[name] project = session.createProject(name, description=p.get('description', '')) self.log.info("Created project %s", project.name) self.results.append(ProjectAddedEvent(project)) class SyncSubscribedProjectBranchesTask(Task): def __repr__(self): return '' def __eq__(self, other): if other.__class__ == self.__class__: return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: projects = session.getProjects(subscribed=True) for p in projects: sync.submitTask(SyncProjectBranchesTask(p.name, self.priority)) class SyncProjectBranchesTask(Task): branch_re = re.compile(r'refs/heads/(.*)') def __init__(self, project_name, priority=NORMAL_PRIORITY): super(SyncProjectBranchesTask, self).__init__(priority) self.project_name = project_name def __repr__(self): return '' % (self.project_name,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.project_name == self.project_name): return True return False def run(self, sync): app = sync.app remote = sync.get('projects/%s/branches/' % urllib.quote_plus(self.project_name)) remote_branches = set() for x in remote: m = self.branch_re.match(x['ref']) if m: remote_branches.add(m.group(1)) with app.db.getSession() as session: local = {} project = session.getProjectByName(self.project_name) for branch in project.branches: local[branch.name] = branch local_branches = set(local.keys()) for name in local_branches-remote_branches: session.delete(local[name]) self.log.info("Deleted branch %s from project %s in local DB.", name, project.name) for name in remote_branches-local_branches: project.createBranch(name) self.log.info("Added branch %s to project %s in local DB.", name, project.name) class SyncSubscribedProjectsTask(Task): def __repr__(self): return '' def __eq__(self, other): if (other.__class__ == self.__class__): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: keys = [p.key for p in session.getProjects(subscribed=True)] for i in range(0, len(keys), 10): t = SyncProjectTask(keys[i:i+10], self.priority) self.tasks.append(t) sync.submitTask(t) t = SyncQueriedChangesTask('owner', 'is:owner', self.priority) self.tasks.append(t) sync.submitTask(t) t = SyncQueriedChangesTask('starred', 'is:starred', self.priority) self.tasks.append(t) sync.submitTask(t) class SyncProjectTask(Task): def __init__(self, project_keys, priority=NORMAL_PRIORITY): super(SyncProjectTask, self).__init__(priority) if type(project_keys) == int: project_keys = [project_keys] self.project_keys = project_keys def __repr__(self): return '' % (self.project_keys,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.project_keys == self.project_keys): return True return False def run(self, sync): app = sync.app now = datetime.datetime.utcnow() queries = [] with app.db.getSession() as session: for project_key in self.project_keys: project = session.getProject(project_key) query = 'q=project:%s' % project.name if project.updated: # Allow 4 seconds for request time, etc. query += ' -age:%ss' % (int(math.ceil((now-project.updated).total_seconds())) + 4,) else: query += ' status:open' queries.append(query) changes = [] sortkey = '' done = False while not done: query = '&'.join(queries) # We don't actually want to limit to 500, but that's the server-side default, and # if we don't specify this, we won't get a _more_changes flag. q = 'changes/?n=500%s&%s' % (sortkey, query) self.log.debug('Query: %s ' % (q,)) responses = sync.get(q) if len(queries) == 1: responses = [responses] done = True for batch in responses: changes += batch if batch and '_more_changes' in batch[-1]: done = False if '_sortkey' in batch[-1]: sortkey = '&N=%s' % (batch[-1]['_sortkey'],) else: sortkey = '&start=%s' % (len(batch)) change_ids = [c['id'] for c in changes] with app.db.getSession() as session: # Winnow the list of IDs to only the ones in the local DB. change_ids = session.getChangeIDs(change_ids) for c in changes: # For now, just sync open changes or changes already # in the db optionally we could sync all changes ever if c['id'] in change_ids or (c['status'] not in CLOSED_STATUSES): sync.submitTask(SyncChangeTask(c['id'], priority=self.priority)) for key in self.project_keys: sync.submitTask(SetProjectUpdatedTask(key, now, priority=self.priority)) class SetProjectUpdatedTask(Task): def __init__(self, project_key, updated, priority=NORMAL_PRIORITY): super(SetProjectUpdatedTask, self).__init__(priority) self.project_key = project_key self.updated = updated def __repr__(self): return '' % (self.project_key, self.updated) def __eq__(self, other): if (other.__class__ == self.__class__ and other.project_key == self.project_key and other.updated == self.updated): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: project = session.getProject(self.project_key) project.updated = self.updated class SyncQueriedChangesTask(Task): def __init__(self, query_name, query, priority=NORMAL_PRIORITY): super(SyncQueriedChangesTask, self).__init__(priority) self.query_name = query_name self.query = query def __repr__(self): return '' % self.query_name def __eq__(self, other): if (other.__class__ == self.__class__ and other.query_name == self.query_name and other.query == self.query): return True return False def run(self, sync): app = sync.app now = datetime.datetime.utcnow() with app.db.getSession() as session: sync_query = session.getSyncQueryByName(self.query_name) query = 'q=%s' % self.query if sync_query.updated: # Allow 4 seconds for request time, etc. query += ' -age:%ss' % (int(math.ceil((now-sync_query.updated).total_seconds())) + 4,) else: query += ' status:open' for project in session.getProjects(subscribed=True): query += ' -project:%s' % project.name changes = [] sortkey = '' done = False while not done: # We don't actually want to limit to 500, but that's the server-side default, and # if we don't specify this, we won't get a _more_changes flag. q = 'changes/?n=500%s&%s' % (sortkey, query) self.log.debug('Query: %s ' % (q,)) batch = sync.get(q) done = True if batch: changes += batch if '_more_changes' in batch[-1]: sortkey = '&N=%s' % (batch[-1]['_sortkey'],) done = False change_ids = [c['id'] for c in changes] with app.db.getSession() as session: # Winnow the list of IDs to only the ones in the local DB. change_ids = session.getChangeIDs(change_ids) for c in changes: # For now, just sync open changes or changes already # in the db optionally we could sync all changes ever if c['id'] in change_ids or (c['status'] not in CLOSED_STATUSES): sync.submitTask(SyncChangeTask(c['id'], priority=self.priority)) sync.submitTask(SetSyncQueryUpdatedTask(self.query_name, now, priority=self.priority)) class SetSyncQueryUpdatedTask(Task): def __init__(self, query_name, updated, priority=NORMAL_PRIORITY): super(SetSyncQueryUpdatedTask, self).__init__(priority) self.query_name = query_name self.updated = updated def __repr__(self): return '' % (self.query_name, self.updated) def __eq__(self, other): if (other.__class__ == self.__class__ and other.query_name == self.query_name and other.updated == self.updated): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: sync_query = session.getSyncQueryByName(self.query_name) sync_query.updated = self.updated class SyncChangesByCommitsTask(Task): def __init__(self, commits, priority=NORMAL_PRIORITY): super(SyncChangesByCommitsTask, self).__init__(priority) self.commits = commits def __repr__(self): return '' % (self.commits,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.commits == self.commits): return True return False def run(self, sync): query = ' OR '.join(['commit:%s' % x for x in self.commits]) changes = sync.get('changes/?q=%s' % query) self.log.debug('Query: %s ' % (query,)) for c in changes: sync.submitTask(SyncChangeTask(c['id'], priority=self.priority)) self.log.debug("Sync change %s for its commit" % (c['id'],)) def addCommit(self, commit): if commit in self.commits: return True # 100 should be under the URL length limit if len(self.commits) >= 100: return False self.commits.append(commit) return True class SyncChangeByNumberTask(Task): def __init__(self, number, priority=NORMAL_PRIORITY): super(SyncChangeByNumberTask, self).__init__(priority) self.number = number def __repr__(self): return '' % (self.number,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.number == self.number): return True return False def run(self, sync): query = '%s' % self.number changes = sync.get('changes/?q=%s' % query) self.log.debug('Query: %s ' % (query,)) for c in changes: task = SyncChangeTask(c['id'], priority=self.priority) self.tasks.append(task) sync.submitTask(task) self.log.debug("Sync change %s because it is number %s" % (c['id'], self.number)) class SyncChangeTask(Task): def __init__(self, change_id, force_fetch=False, priority=NORMAL_PRIORITY): super(SyncChangeTask, self).__init__(priority) self.change_id = change_id self.force_fetch = force_fetch def __repr__(self): return '' % (self.change_id,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.change_id == self.change_id and other.force_fetch == self.force_fetch): return True return False def run(self, sync): start_time = time.time() app = sync.app remote_change = sync.get('changes/%s?o=DETAILED_LABELS&o=ALL_REVISIONS&o=ALL_COMMITS&o=MESSAGES&o=DETAILED_ACCOUNTS&o=CURRENT_ACTIONS&o=ALL_FILES' % self.change_id) # Perform subqueries this task will need outside of the db session for remote_commit, remote_revision in remote_change.get('revisions', {}).items(): remote_comments_data = sync.get('changes/%s/revisions/%s/comments' % (self.change_id, remote_commit)) remote_revision['_gertty_remote_comments_data'] = remote_comments_data fetches = collections.defaultdict(list) parent_commits = set() with app.db.getSession() as session: change = session.getChangeByID(self.change_id) account = session.getAccountByID(remote_change['owner']['_account_id'], name=remote_change['owner'].get('name'), username=remote_change['owner'].get('username'), email=remote_change['owner'].get('email')) if not change: project = session.getProjectByName(remote_change['project']) created = dateutil.parser.parse(remote_change['created']) updated = dateutil.parser.parse(remote_change['updated']) change = project.createChange(remote_change['id'], account, remote_change['_number'], remote_change['branch'], remote_change['change_id'], remote_change['subject'], created, updated, remote_change['status'], topic=remote_change.get('topic')) self.log.info("Created new change %s in local DB.", change.id) result = ChangeAddedEvent(change) else: result = ChangeUpdatedEvent(change) self.results.append(result) change.owner = account if change.status != remote_change['status']: change.status = remote_change['status'] result.status_changed = True if remote_change.get('starred'): change.starred = True else: change.starred = False change.subject = remote_change['subject'] change.updated = dateutil.parser.parse(remote_change['updated']) change.topic = remote_change.get('topic') repo = gitrepo.get_repo(change.project.name, app.config) new_revision = False for remote_commit, remote_revision in remote_change.get('revisions', {}).items(): revision = session.getRevisionByCommit(remote_commit) # TODO: handle multiple parents if 'anonymous http' in remote_revision['fetch']: ref = remote_revision['fetch']['anonymous http']['ref'] url = remote_revision['fetch']['anonymous http']['url'] auth = False elif 'http' in remote_revision['fetch']: auth = True ref = remote_revision['fetch']['http']['ref'] url = list(urlparse.urlsplit(sync.app.config.url + change.project.name)) url[1] = '%s:%s@%s' % ( urllib.quote_plus(sync.app.config.username), urllib.quote_plus(sync.app.config.password), url[1]) url = urlparse.urlunsplit(url) elif 'ssh' in remote_revision['fetch']: ref = remote_revision['fetch']['ssh']['ref'] url = remote_revision['fetch']['ssh']['url'] auth = False elif 'git' in remote_revision['fetch']: ref = remote_revision['fetch']['git']['ref'] url = remote_revision['fetch']['git']['url'] auth = False else: if len(remote_revision['fetch']): errMessage = "No supported fetch method found. Server offers: %s" % ( ', '.join(remote_revision['fetch'].keys())) else: errMessage = "The server is missing the download-commands plugin." raise Exception(errMessage) if (not revision) or self.force_fetch: fetches[url].append('+%(ref)s:%(ref)s' % dict(ref=ref)) if not revision: revision = change.createRevision(remote_revision['_number'], remote_revision['commit']['message'], remote_commit, remote_revision['commit']['parents'][0]['commit'], auth, ref) self.log.info("Created new revision %s for change %s revision %s in local DB.", revision.key, self.change_id, remote_revision['_number']) new_revision = True revision.message = remote_revision['commit']['message'] actions = remote_revision.get('actions', {}) revision.can_submit = 'submit' in actions # TODO: handle multiple parents if revision.parent not in parent_commits: parent_revision = session.getRevisionByCommit(revision.parent) if not parent_revision and change.status not in CLOSED_STATUSES: sync._syncChangeByCommit(revision.parent, self.priority) self.log.debug("Change %s revision %s needs parent commit %s synced" % (change.id, remote_revision['_number'], revision.parent)) parent_commits.add(revision.parent) result.updateRelatedChanges(session, change) f = revision.getFile('/COMMIT_MSG') if f is None: f = revision.createFile('/COMMIT_MSG', None, None, None, None) for remote_path, remote_file in remote_revision['files'].items(): f = revision.getFile(remote_path) if f is None: if remote_file.get('binary'): inserted = deleted = None else: inserted = remote_file.get('lines_inserted', 0) deleted = remote_file.get('lines_deleted', 0) f = revision.createFile(remote_path, remote_file.get('status', 'M'), remote_file.get('old_path'), inserted, deleted) remote_comments_data = remote_revision['_gertty_remote_comments_data'] for remote_file, remote_comments in remote_comments_data.items(): for remote_comment in remote_comments: account = session.getAccountByID(remote_comment['author']['_account_id'], name=remote_comment['author'].get('name'), username=remote_comment['author'].get('username'), email=remote_comment['author'].get('email')) comment = session.getCommentByID(remote_comment['id']) if not comment: # Normalize updated -> created created = dateutil.parser.parse(remote_comment['updated']) parent = False if remote_comment.get('side', '') == 'PARENT': parent = True fileobj = revision.getFile(remote_file) if fileobj is None: fileobj = revision.createFile(remote_file, 'M') comment = fileobj.createComment(remote_comment['id'], account, remote_comment.get('in_reply_to'), created, parent, remote_comment.get('line'), remote_comment['message']) self.log.info("Created new comment %s for revision %s in local DB.", comment.key, revision.key) else: if comment.author != account: comment.author = account new_message = False for remote_message in remote_change.get('messages', []): if 'author' in remote_message: account = session.getAccountByID(remote_message['author']['_account_id'], name=remote_message['author'].get('name'), username=remote_message['author'].get('username'), email=remote_message['author'].get('email')) if account.username != app.config.username: new_message = True else: account = session.getSystemAccount() message = session.getMessageByID(remote_message['id']) if not message: revision = session.getRevisionByNumber(change, remote_message.get('_revision_number', 1)) if revision: # Normalize date -> created created = dateutil.parser.parse(remote_message['date']) message = revision.createMessage(remote_message['id'], account, created, remote_message['message']) self.log.info("Created new review message %s for revision %s in local DB.", message.key, revision.key) else: self.log.info("Unable to create new review message for revision %s because it is not in local DB (draft?).", remote_message.get('_revision_number')) else: if message.author != account: message.author = account remote_approval_entries = {} remote_label_entries = {} user_voted = False for remote_label_name, remote_label_dict in remote_change.get('labels', {}).items(): for remote_approval in remote_label_dict.get('all', []): if remote_approval.get('value') is None: continue remote_approval['category'] = remote_label_name key = '%s~%s' % (remote_approval['category'], remote_approval['_account_id']) remote_approval_entries[key] = remote_approval if remote_approval['_account_id'] == sync.account_id and int(remote_approval['value']) != 0: user_voted = True for key, value in remote_label_dict.get('values', {}).items(): # +1: "LGTM" label = dict(value=key, description=value, category=remote_label_name) key = '%s~%s~%s' % (label['category'], label['value'], label['description']) remote_label_entries[key] = label remote_approval_keys = set(remote_approval_entries.keys()) remote_label_keys = set(remote_label_entries.keys()) local_approvals = {} local_labels = {} user_votes = {} for approval in change.approvals: if approval.draft and not new_revision: # If we have a new revision, we need to delete # draft local approvals because they can no longer # be uploaded. Otherwise, keep them because we # may be about to upload a review. Ignoring an # approval here means it will not be deleted. # Also keep track of these approvals so we can # determine whether we should hold the change # later. user_votes[approval.category] = approval.value # Count draft votes as having voted for the # purposes of deciding whether to clear the # reviewed flag later. user_voted = True continue key = '%s~%s' % (approval.category, approval.reviewer.id) if key in local_approvals: # Delete duplicate approvals. session.delete(approval) else: local_approvals[key] = approval local_approval_keys = set(local_approvals.keys()) for label in change.labels: key = '%s~%s~%s' % (label.category, label.value, label.description) local_labels[key] = label local_label_keys = set(local_labels.keys()) for key in local_approval_keys-remote_approval_keys: session.delete(local_approvals[key]) for key in local_label_keys-remote_label_keys: session.delete(local_labels[key]) for key in remote_approval_keys-local_approval_keys: remote_approval = remote_approval_entries[key] account = session.getAccountByID(remote_approval['_account_id'], name=remote_approval.get('name'), username=remote_approval.get('username'), email=remote_approval.get('email')) change.createApproval(account, remote_approval['category'], remote_approval['value']) self.log.info("Created approval for change %s in local DB.", change.id) user_value = user_votes.get(remote_approval['category'], 0) if user_value > 0 and remote_approval['value'] < 0: # Someone left a negative vote after the local # user created a draft positive vote. Hold the # change so that it doesn't look like the local # user is ignoring negative feedback. if not change.held: change.held = True result.held_changed = True self.log.info("Setting change %s to held due to negative review after positive", change.id) for key in remote_label_keys-local_label_keys: remote_label = remote_label_entries[key] change.createLabel(remote_label['category'], remote_label['value'], remote_label['description']) for key in remote_approval_keys.intersection(local_approval_keys): local_approval = local_approvals[key] remote_approval = remote_approval_entries[key] local_approval.value = remote_approval['value'] # For the side effect of updating account info: account = session.getAccountByID(remote_approval['_account_id'], name=remote_approval.get('name'), username=remote_approval.get('username'), email=remote_approval.get('email')) remote_permitted_entries = {} for remote_label_name, remote_label_values in remote_change.get('permitted_labels', {}).items(): for remote_label_value in remote_label_values: remote_label = dict(category=remote_label_name, value=remote_label_value) key = '%s~%s' % (remote_label['category'], remote_label['value']) remote_permitted_entries[key] = remote_label remote_permitted_keys = set(remote_permitted_entries.keys()) local_permitted = {} for permitted in change.permitted_labels: key = '%s~%s' % (permitted.category, permitted.value) local_permitted[key] = permitted local_permitted_keys = set(local_permitted.keys()) for key in local_permitted_keys-remote_permitted_keys: session.delete(local_permitted[key]) for key in remote_permitted_keys-local_permitted_keys: remote_permitted = remote_permitted_entries[key] change.createPermittedLabel(remote_permitted['category'], remote_permitted['value']) if not user_voted: # Only consider changing the reviewed state if we don't have a vote if new_revision or new_message: if change.reviewed: change.reviewed = False result.review_flag_changed = True for url, refs in fetches.items(): self.log.debug("Fetching from %s with refs %s", url, refs) try: repo.fetch(url, refs) except Exception: # Backwards compat with GitPython before the multi-ref fetch # patch. # (https://github.com/gitpython-developers/GitPython/pull/170) for ref in refs: self.log.debug("git fetch %s %s" % (url, ref)) repo.fetch(url, ref) end_time = time.time() total_time = end_time - start_time self.log.info("Synced change %s in %0.5f seconds.", self.change_id, total_time) class CheckReposTask(Task): # on startup, check all projects # for any subscribed project withot a local repo or if # --fetch-missing-refs is supplied, check all local changes for # missing refs, and sync the associated changes def __repr__(self): return '' def __eq__(self, other): if (other.__class__ == self.__class__): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: projects = session.getProjects(subscribed=True) for project in projects: try: missing = False try: repo = gitrepo.get_repo(project.name, app.config) except gitrepo.GitCloneError: missing = True if missing or app.fetch_missing_refs: sync.submitTask(CheckRevisionsTask(project.key, priority=LOW_PRIORITY)) except Exception: self.log.exception("Exception checking repo %s" % (project.name,)) class CheckRevisionsTask(Task): def __init__(self, project_key, priority=NORMAL_PRIORITY): super(CheckRevisionsTask, self).__init__(priority) self.project_key = project_key def __repr__(self): return '' % (self.project_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.project_key == self.project_key): return True return False def run(self, sync): app = sync.app to_sync = set() with app.db.getSession() as session: project = session.getProject(self.project_key) repo = None try: repo = gitrepo.get_repo(project.name, app.config) except gitrepo.GitCloneError: pass for change in project.open_changes: if repo: for revision in change.revisions: if not (repo.hasCommit(revision.parent) and repo.hasCommit(revision.commit)): to_sync.add(change.id) else: to_sync.add(change.id) for change_id in to_sync: sync.submitTask(SyncChangeTask(change_id, priority=self.priority)) class UploadReviewsTask(Task): def __repr__(self): return '' def __eq__(self, other): if (other.__class__ == self.__class__): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: for c in session.getPendingTopics(): sync.submitTask(SetTopicTask(c.key, self.priority)) for c in session.getPendingRebases(): sync.submitTask(RebaseChangeTask(c.key, self.priority)) for c in session.getPendingStatusChanges(): sync.submitTask(ChangeStatusTask(c.key, self.priority)) for c in session.getPendingStarred(): sync.submitTask(ChangeStarredTask(c.key, self.priority)) for c in session.getPendingCherryPicks(): sync.submitTask(SendCherryPickTask(c.key, self.priority)) for r in session.getPendingCommitMessages(): sync.submitTask(ChangeCommitMessageTask(r.key, self.priority)) for m in session.getPendingMessages(): sync.submitTask(UploadReviewTask(m.key, self.priority)) class SetTopicTask(Task): def __init__(self, change_key, priority=NORMAL_PRIORITY): super(SetTopicTask, self).__init__(priority) self.change_key = change_key def __repr__(self): return '' % (self.change_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.change_key == self.change_key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: change = session.getChange(self.change_key) data = dict(topic=change.topic) change.pending_topic = False # Inside db session for rollback sync.put('changes/%s/topic' % (change.id,), data) sync.submitTask(SyncChangeTask(change.id, priority=self.priority)) class RebaseChangeTask(Task): def __init__(self, change_key, priority=NORMAL_PRIORITY): super(RebaseChangeTask, self).__init__(priority) self.change_key = change_key def __repr__(self): return '' % (self.change_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.change_key == self.change_key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: change = session.getChange(self.change_key) change.pending_rebase = False # Inside db session for rollback sync.post('changes/%s/rebase' % (change.id,), {}) sync.submitTask(SyncChangeTask(change.id, priority=self.priority)) class ChangeStarredTask(Task): def __init__(self, change_key, priority=NORMAL_PRIORITY): super(ChangeStarredTask, self).__init__(priority) self.change_key = change_key def __repr__(self): return '' % (self.change_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.change_key == self.change_key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: change = session.getChange(self.change_key) if change.starred: sync.put('accounts/self/starred.changes/%s' % (change.id,), data={}) else: sync.delete('accounts/self/starred.changes/%s' % (change.id,), data={}) change.pending_starred = False sync.submitTask(SyncChangeTask(change.id, priority=self.priority)) class ChangeStatusTask(Task): def __init__(self, change_key, priority=NORMAL_PRIORITY): super(ChangeStatusTask, self).__init__(priority) self.change_key = change_key def __repr__(self): return '' % (self.change_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.change_key == self.change_key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: change = session.getChange(self.change_key) if change.pending_status_message: data = dict(message=change.pending_status_message) else: data = {} change.pending_status = False change.pending_status_message = None # Inside db session for rollback if change.status == 'ABANDONED': sync.post('changes/%s/abandon' % (change.id,), data) elif change.status == 'NEW': sync.post('changes/%s/restore' % (change.id,), data) elif change.status == 'SUBMITTED': sync.post('changes/%s/submit' % (change.id,), {}) sync.submitTask(SyncChangeTask(change.id, priority=self.priority)) class SendCherryPickTask(Task): def __init__(self, cp_key, priority=NORMAL_PRIORITY): super(SendCherryPickTask, self).__init__(priority) self.cp_key = cp_key def __repr__(self): return '' % (self.cp_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.cp_key == self.cp_key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: cp = session.getPendingCherryPick(self.cp_key) data = dict(message=cp.message, destination=cp.branch) session.delete(cp) # Inside db session for rollback ret = sync.post('changes/%s/revisions/%s/cherrypick' % (cp.revision.change.id, cp.revision.commit), data) if ret and 'id' in ret: sync.submitTask(SyncChangeTask(ret['id'], priority=self.priority)) class ChangeCommitMessageTask(Task): def __init__(self, revision_key, priority=NORMAL_PRIORITY): super(ChangeCommitMessageTask, self).__init__(priority) self.revision_key = revision_key def __repr__(self): return '' % (self.revision_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.revision_key == self.revision_key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: revision = session.getRevision(self.revision_key) revision.pending_message = False data = dict(message=revision.message) # Inside db session for rollback if sync.version < (2,11,0): sync.post('changes/%s/revisions/%s/message' % (revision.change.id, revision.commit), data) else: edit = sync.get('changes/%s/edit' % revision.change.id) if edit is not None: raise Exception("Edit already in progress on change %s" % (revision.change.number,)) sync.put('changes/%s/edit:message' % (revision.change.id,), data) sync.post('changes/%s/edit:publish' % (revision.change.id,), {}) change_id = revision.change.id sync.submitTask(SyncChangeTask(change_id, priority=self.priority)) class UploadReviewTask(Task): def __init__(self, message_key, priority=NORMAL_PRIORITY): super(UploadReviewTask, self).__init__(priority) self.message_key = message_key def __repr__(self): return '' % (self.message_key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.message_key == self.message_key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: message = session.getMessage(self.message_key) if message is None: self.log.debug("Message %s has already been uploaded" % ( self.message_key)) return change = message.revision.change if not change.held: self.log.debug("Syncing %s to find out if it should be held" % (change.id,)) t = SyncChangeTask(change.id) t.run(sync) self.results += t.results submit = False change_id = None with app.db.getSession() as session: message = session.getMessage(self.message_key) revision = message.revision change = message.revision.change if change.held: self.log.debug("Not uploading review to %s because it is held" % (change.id,)) return change_id = change.id current_revision = change.revisions[-1] if change.pending_status and change.status == 'SUBMITTED': submit = True data = dict(message=message.message, strict_labels=False) if revision == current_revision: data['labels'] = {} for approval in change.draft_approvals: data['labels'][approval.category] = approval.value session.delete(approval) comments = {} for file in revision.files: if file.draft_comments: comment_list = [] for comment in file.draft_comments: d = dict(line=comment.line, message=comment.message) if comment.parent: d['side'] = 'PARENT' comment_list.append(d) session.delete(comment) comments[file.path] = comment_list if comments: data['comments'] = comments session.delete(message) # Inside db session for rollback sync.post('changes/%s/revisions/%s/review' % (change.id, revision.commit), data) if submit: # In another db session in case submit fails after posting # the message succeeds with app.db.getSession() as session: change = session.getChangeByID(change_id) change.pending_status = False change.pending_status_message = None sync.post('changes/%s/submit' % (change_id,), {}) sync.submitTask(SyncChangeTask(change_id, priority=self.priority)) class PruneDatabaseTask(Task): def __init__(self, age, priority=NORMAL_PRIORITY): super(PruneDatabaseTask, self).__init__(priority) self.age = age def __repr__(self): return '' % (self.age,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.age == self.age): return True return False def run(self, sync): if not self.age: return app = sync.app with app.db.getSession() as session: for change in session.getChanges('status:closed age:%s' % self.age): t = PruneChangeTask(change.key, priority=self.priority) self.tasks.append(t) sync.submitTask(t) t = VacuumDatabaseTask(priority=self.priority) self.tasks.append(t) sync.submitTask(t) class PruneChangeTask(Task): def __init__(self, key, priority=NORMAL_PRIORITY): super(PruneChangeTask, self).__init__(priority) self.key = key def __repr__(self): return '' % (self.key,) def __eq__(self, other): if (other.__class__ == self.__class__ and other.key == self.key): return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: change = session.getChange(self.key) if not change: return repo = gitrepo.get_repo(change.project.name, app.config) self.log.info("Pruning %s change %s status:%s updated:%s" % ( change.project.name, change.number, change.status, change.updated)) change_ref = None for revision in change.revisions: if change_ref is None: change_ref = '/'.join(revision.fetch_ref.split('/')[:-1]) self.log.info("Deleting %s ref %s" % ( change.project.name, revision.fetch_ref)) repo.deleteRef(revision.fetch_ref) self.log.info("Deleting %s ref %s" % ( change.project.name, change_ref)) try: repo.deleteRef(change_ref) except OSError, e: if e.errno not in [errno.EISDIR, errno.EPERM]: raise session.delete(change) class VacuumDatabaseTask(Task): def __init__(self, priority=NORMAL_PRIORITY): super(VacuumDatabaseTask, self).__init__(priority) def __repr__(self): return '' def __eq__(self, other): if other.__class__ == self.__class__: return True return False def run(self, sync): app = sync.app with app.db.getSession() as session: session.vacuum() class Sync(object): def __init__(self, app): self.user_agent = 'Gertty/%s %s' % (gertty.version.version_info.release_string(), requests.utils.default_user_agent()) self.version = (0, 0, 0) self.offline = False self.account_id = None self.app = app self.log = logging.getLogger('gertty.sync') self.queue = MultiQueue([HIGH_PRIORITY, NORMAL_PRIORITY, LOW_PRIORITY]) self.result_queue = Queue.Queue() self.session = requests.Session() if self.app.config.auth_type == 'basic': authclass = requests.auth.HTTPBasicAuth else: authclass = requests.auth.HTTPDigestAuth self.auth = authclass( self.app.config.username, self.app.config.password) self.submitTask(GetVersionTask(HIGH_PRIORITY)) self.submitTask(SyncOwnAccountTask(HIGH_PRIORITY)) self.submitTask(CheckReposTask(HIGH_PRIORITY)) self.submitTask(UploadReviewsTask(HIGH_PRIORITY)) self.submitTask(SyncProjectListTask(HIGH_PRIORITY)) self.submitTask(SyncSubscribedProjectsTask(NORMAL_PRIORITY)) self.submitTask(SyncSubscribedProjectBranchesTask(LOW_PRIORITY)) self.submitTask(PruneDatabaseTask(self.app.config.expire_age, LOW_PRIORITY)) self.periodic_thread = threading.Thread(target=self.periodicSync) self.periodic_thread.daemon = True self.periodic_thread.start() def periodicSync(self): hourly = time.time() while True: try: time.sleep(60) self.syncSubscribedProjects() now = time.time() if now-hourly > 3600: hourly = now self.pruneDatabase() except Exception: self.log.exception('Exception in periodicSync') def submitTask(self, task): if not self.offline: if not self.queue.put(task, task.priority): task.complete(False) else: task.complete(False) def run(self, pipe): task = None while True: task = self._run(pipe, task) def _run(self, pipe, task=None): if not task: task = self.queue.get() self.log.debug('Run: %s' % (task,)) try: task.run(self) task.complete(True) except requests.ConnectionError, e: self.log.warning("Offline due to: %s" % (e,)) if not self.offline: self.submitTask(GetVersionTask(HIGH_PRIORITY)) self.submitTask(UploadReviewsTask(HIGH_PRIORITY)) self.offline = True self.app.status.update(offline=True, refresh=False) os.write(pipe, 'refresh\n') time.sleep(30) return task except Exception: task.complete(False) self.log.exception('Exception running task %s' % (task,)) self.app.status.update(error=True, refresh=False) self.offline = False self.app.status.update(offline=False, refresh=False) for r in task.results: self.result_queue.put(r) os.write(pipe, 'refresh\n') return None def url(self, path): return self.app.config.url + 'a/' + path def get(self, path): url = self.url(path) self.log.debug('GET: %s' % (url,)) r = self.session.get(url, verify=self.app.config.verify_ssl, auth=self.auth, timeout=TIMEOUT, headers = {'Accept': 'application/json', 'Accept-Encoding': 'gzip', 'User-Agent': self.user_agent}) if r.status_code == 200: ret = json.loads(r.text[4:]) if len(ret): self.log.debug('200 OK, Received: %s' % (ret,)) else: self.log.debug('200 OK, No body.') return ret else: self.log.warn('HTTP response: %d', r.status_code) def post(self, path, data): url = self.url(path) self.log.debug('POST: %s' % (url,)) self.log.debug('data: %s' % (data,)) r = self.session.post(url, data=json.dumps(data).encode('utf8'), verify=self.app.config.verify_ssl, auth=self.auth, timeout=TIMEOUT, headers = {'Content-Type': 'application/json;charset=UTF-8', 'User-Agent': self.user_agent}) self.log.debug('Received: %s' % (r.text,)) ret = None if r.text and len(r.text)>4: try: ret = json.loads(r.text[4:]) except Exception: self.log.exception("Unable to parse result %s from post to %s" % (r.text, url)) return ret def put(self, path, data): url = self.url(path) self.log.debug('PUT: %s' % (url,)) self.log.debug('data: %s' % (data,)) r = self.session.put(url, data=json.dumps(data).encode('utf8'), verify=self.app.config.verify_ssl, auth=self.auth, timeout=TIMEOUT, headers = {'Content-Type': 'application/json;charset=UTF-8', 'User-Agent': self.user_agent}) self.log.debug('Received: %s' % (r.text,)) def delete(self, path, data): url = self.url(path) self.log.debug('DELETE: %s' % (url,)) self.log.debug('data: %s' % (data,)) r = self.session.delete(url, data=json.dumps(data).encode('utf8'), verify=self.app.config.verify_ssl, auth=self.auth, timeout=TIMEOUT, headers = {'Content-Type': 'application/json;charset=UTF-8', 'User-Agent': self.user_agent}) self.log.debug('Received: %s' % (r.text,)) def syncSubscribedProjects(self): task = SyncSubscribedProjectsTask(LOW_PRIORITY) self.submitTask(task) if task.wait(): for subtask in task.tasks: subtask.wait() def pruneDatabase(self): task = PruneDatabaseTask(self.app.config.expire_age, LOW_PRIORITY) self.submitTask(task) if task.wait(): for subtask in task.tasks: subtask.wait() def _syncChangeByCommit(self, commit, priority): # Accumulate sync change by commit tasks because they often # come in batches. This method assumes it is being called # from within the run queue already and therefore does not # need to worry about locking the queue. task = None for task in self.queue.find(SyncChangesByCommitsTask, priority): if task.addCommit(commit): return task = SyncChangesByCommitsTask([commit], priority) self.submitTask(task) def setRemoteVersion(self, version): base = version.split('-')[0] parts = base.split('.') major = minor = micro = 0 if len(parts) > 0: major = int(parts[0]) if len(parts) > 1: minor = int(parts[1]) if len(parts) > 2: micro = int(parts[2]) self.version = (major, minor, micro) self.log.info("Remote version is: %s (parsed as %s)" % (version, self.version)) gertty-1.3.1/gertty/requestsexceptions.py0000664000567000056700000000206712636066265022060 0ustar jenkinsjenkins00000000000000# 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 gertty-1.3.1/gertty/view/0000775000567000056700000000000012636066311016466 5ustar jenkinsjenkins00000000000000gertty-1.3.1/gertty/view/change_list.py0000664000567000056700000006226712636066265021345 0ustar jenkinsjenkins00000000000000# Copyright 2014 OpenStack Foundation # Copyright 2014 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import datetime import logging import urwid from gertty import keymap from gertty import sync from gertty.view import change as view_change from gertty.view import mouse_scroll_decorator import gertty.view class ThreadStack(object): def __init__(self): self.stack = [] def push(self, change, children): self.stack.append([change, children]) def pop(self): while self.stack: if self.stack[-1][1]: # handle children at the tip return self.stack[-1][1].pop(0) else: # current tip has no children, walk up self.stack.pop() continue return None def countChildren(self): return [len(x[1]) for x in self.stack] class ChangeRow(urwid.Button): change_focus_map = {None: 'focused', 'unreviewed-change': 'focused-unreviewed-change', 'reviewed-change': 'focused-reviewed-change', 'starred-change': 'focused-starred-change', 'held-change': 'focused-held-change', 'marked-change': 'focused-marked-change', 'positive-label': 'focused-positive-label', 'negative-label': 'focused-negative-label', 'min-label': 'focused-min-label', 'max-label': 'focused-max-label', } def selectable(self): return True def __init__(self, app, change, prefix, categories, project=False, owner=False, updated=False, callback=None): super(ChangeRow, self).__init__('', on_press=callback, user_data=change.key) self.app = app self.change_key = change.key self.prefix = prefix self.subject = urwid.Text(u'', wrap='clip') self.number = urwid.Text(u'') self.updated = urwid.Text(u'') self.project = urwid.Text(u'', wrap='clip') self.owner = urwid.Text(u'', wrap='clip') self.mark = False cols = [(6, self.number), ('weight', 4, self.subject)] if project: cols.append(('weight', 1, self.project)) if owner: cols.append(('weight', 2, self.owner)) if updated: cols.append(('fixed', 10, self.updated)) self.num_columns = len(cols) self.columns = urwid.Columns(cols, dividechars=1) self.row_style = urwid.AttrMap(self.columns, '') self._w = urwid.AttrMap(self.row_style, None, focus_map=self.change_focus_map) self.update(change, categories) def update(self, change, categories): if change.reviewed or change.hidden: style = 'reviewed-change' else: style = 'unreviewed-change' subject = '%s%s' % (self.prefix, change.subject) flag = ' ' if change.starred: flag = '*' style = 'starred-change' if change.held: flag = '!' style = 'held-change' if self.mark: flag = '%' style = 'marked-change' subject = flag + subject self.row_style.set_attr_map({None: style}) self.subject.set_text(subject) self.number.set_text(str(change.number)) self.project.set_text(change.project.name.split('/')[-1]) self.owner.set_text(change.owner_name) self.project_name = change.project.name self.commit_sha = change.revisions[-1].commit self.current_revision_key = change.revisions[-1].key today = self.app.time(datetime.datetime.utcnow()).date() updated_time = self.app.time(change.updated) if today == updated_time.date(): self.updated.set_text(updated_time.strftime("%I:%M %p").upper()) else: self.updated.set_text(updated_time.strftime("%Y-%m-%d")) del self.columns.contents[self.num_columns:] for category in categories: v = change.getMaxForCategory(category) cat_min, cat_max = change.getMinMaxPermittedForCategory(category) if v == 0: val = '' elif v > 0: val = '%2i' % v if v == cat_max: val = ('max-label', val) else: val = ('positive-label', val) else: val = '%i' % v if v == cat_min: val = ('min-label', val) else: val = ('negative-label', val) self.columns.contents.append((urwid.Text(val), self.columns.options('given', 2))) class ChangeListHeader(urwid.WidgetWrap): def __init__(self, project=False, owner=False, updated=False): cols = [(6, urwid.Text(u'Number')), ('weight', 4, urwid.Text(u' Subject'))] if project: cols.append(('weight', 1, urwid.Text(u'Project'))) if owner: cols.append(('weight', 2, urwid.Text(u'Owner'))) if updated: cols.append(('fixed', 10, urwid.Text(u'Updated'))) self.num_columns = len(cols) super(ChangeListHeader, self).__init__(urwid.Columns(cols, dividechars=1)) def update(self, categories): del self._w.contents[self.num_columns:] for category in categories: self._w.contents.append((urwid.Text(' %s' % category[0]), self._w.options('given', 2))) @mouse_scroll_decorator.ScrollByWheel class ChangeListView(urwid.WidgetWrap): def help(self): key = self.app.config.keymap.formatKeys if self.project_key: refresh_help = "Sync current project" else: refresh_help = "Sync subscribed projects" return [ (key(keymap.TOGGLE_HELD), "Toggle the held flag for the currently selected change"), (key(keymap.LOCAL_CHECKOUT), "Checkout the most recent revision of the selected change into the local repo"), (key(keymap.TOGGLE_HIDDEN), "Toggle the hidden flag for the currently selected change"), (key(keymap.TOGGLE_LIST_REVIEWED), "Toggle whether only unreviewed or all changes are displayed"), (key(keymap.TOGGLE_REVIEWED), "Toggle the reviewed flag for the currently selected change"), (key(keymap.TOGGLE_STARRED), "Toggle the starred flag for the currently selected change"), (key(keymap.TOGGLE_MARK), "Toggle the process mark for the currently selected change"), (key(keymap.REFINE_CHANGE_SEARCH), "Refine the current search query"), (key(keymap.EDIT_TOPIC), "Set the topic of the marked changes"), (key(keymap.REFRESH), refresh_help), (key(keymap.REVIEW), "Leave reviews for the marked changes"), (key(keymap.SORT_BY_NUMBER), "Sort changes by number"), (key(keymap.SORT_BY_UPDATED), "Sort changes by how recently the change was updated"), (key(keymap.SORT_BY_REVERSE), "Reverse the sort"), (key(keymap.LOCAL_CHERRY_PICK), "Cherry-pick the most recent revision of the selected change onto the local repo"), ] def __init__(self, app, query, query_desc=None, project_key=None, unreviewed=False): super(ChangeListView, self).__init__(urwid.Pile([])) self.log = logging.getLogger('gertty.view.change_list') self.app = app self.query = query self.query_desc = query_desc or query self.unreviewed = unreviewed self.change_rows = {} self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) self.display_owner = self.display_project = self.display_updated = True self.project_key = project_key if project_key is not None: self.display_project = False self.sort_by = app.config.change_list_options['sort-by'] self.reverse = app.config.change_list_options['reverse'] self.header = ChangeListHeader(self.display_project, self.display_owner, self.display_updated) self.categories = [] self.refresh() self._w.contents.append((app.header, ('pack', 1))) self._w.contents.append((urwid.Divider(), ('pack', 1))) self._w.contents.append((urwid.AttrWrap(self.header, 'table-header'), ('pack', 1))) self._w.contents.append((self.listbox, ('weight', 1))) self._w.set_focus(3) def interested(self, event): if not ((self.project_key is not None and isinstance(event, sync.ChangeAddedEvent) and self.project_key == event.project_key) or (self.project_key is None and isinstance(event, sync.ChangeAddedEvent)) or (isinstance(event, sync.ChangeUpdatedEvent) and event.change_key in self.change_rows.keys())): self.log.debug("Ignoring refresh change list due to event %s" % (event,)) return False self.log.debug("Refreshing change list due to event %s" % (event,)) return True def refresh(self): unseen_keys = set(self.change_rows.keys()) with self.app.db.getSession() as session: change_list = session.getChanges(self.query, self.unreviewed, sort_by=self.sort_by) if self.unreviewed: self.title = u'Unreviewed changes in %s' % self.query_desc else: self.title = u'All changes in %s' % self.query_desc self.app.status.update(title=self.title) categories = set() for change in change_list: categories |= set(change.getCategories()) self.categories = sorted(categories) i = 0 if self.reverse: change_list.reverse() if self.app.config.thread_changes: change_list, prefixes = self._threadChanges(change_list) else: prefixes = {} new_rows = [] if len(self.listbox.body): focus_pos = self.listbox.focus_position focus_row = self.listbox.body[focus_pos] else: focus_pos = 0 focus_row = None for change in change_list: row = self.change_rows.get(change.key) if not row: row = ChangeRow(self.app, change, prefixes.get(change.key), self.categories, self.display_project, self.display_owner, self.display_updated, callback=self.onSelect) self.listbox.body.insert(i, row) self.change_rows[change.key] = row else: row.update(change, self.categories) unseen_keys.remove(change.key) new_rows.append(row) i += 1 self.listbox.body[:] = new_rows if focus_row in self.listbox.body: pos = self.listbox.body.index(focus_row) else: pos = min(focus_pos, len(self.listbox.body)-1) self.listbox.body.set_focus(pos) if change_list: self.header.update(self.categories) for key in unseen_keys: row = self.change_rows[key] del self.change_rows[key] def getQueryString(self): if self.project_key is not None: return "project:%s %s" % (self.query_desc, self.app.config.project_change_list_query) return self.query def _threadChanges(self, changes): ret = [] prefixes = {} stack = ThreadStack() children = {} commits = {} orphans = changes[:] for change in changes: for revision in change.revisions: commits[revision.commit] = change for change in changes: revision = change.revisions[-1] parent = commits.get(revision.parent, None) if parent: if parent.revisions[-1].commit != revision.parent: # Our parent is an outdated revision. This could # cause a cycle, so skip. This change will not # appear in the thread, but will still appear in # the list. TODO: use color to indicate it # depends on an outdated change. continue if change in orphans: orphans.remove(change) v = children.get(parent, []) v.append(change) children[parent] = v if orphans: change = orphans.pop(0) else: change = None while change: prefix = '' stack_children = stack.countChildren() for i, nchildren in enumerate(stack_children): if nchildren: if i+1 == len(stack_children): prefix += u'\u251c' else: prefix += u'\u2502' else: if i+1 == len(stack_children): prefix += u'\u2514' else: prefix += u' ' if i+1 == len(stack_children): prefix += u'\u2500' else: prefix += u' ' subject = '%s%s' % (prefix, change.subject) change._subject = subject prefixes[change.key] = prefix ret.append(change) if change in children: stack.push(change, children[change]) change = stack.pop() if (not change) and orphans: change = orphans.pop(0) assert len(ret) == len(changes) return (ret, prefixes) def clearChangeList(self): for key, value in self.change_rows.iteritems(): self.listbox.body.remove(value) self.change_rows = {} def getNextChangeKey(self, change_key): row = self.change_rows.get(change_key) try: i = self.listbox.body.index(row) except ValueError: return None if i+1 >= len(self.listbox.body): return None row = self.listbox.body[i+1] return row.change_key def getPrevChangeKey(self, change_key): row = self.change_rows.get(change_key) try: i = self.listbox.body.index(row) except ValueError: return None if i <= 0: return None row = self.listbox.body[i-1] return row.change_key def toggleReviewed(self, change_key): with self.app.db.getSession() as session: change = session.getChange(change_key) change.reviewed = not change.reviewed ret = change.reviewed reviewed_str = 'reviewed' if change.reviewed else 'unreviewed' self.log.debug("Set change %s to %s", change_key, reviewed_str) return ret def toggleStarred(self, change_key): with self.app.db.getSession() as session: change = session.getChange(change_key) change.starred = not change.starred ret = change.starred change.pending_starred = True self.app.sync.submitTask( sync.ChangeStarredTask(change_key, sync.HIGH_PRIORITY)) return ret def toggleHeld(self, change_key): return self.app.toggleHeldChange(change_key) def toggleHidden(self, change_key): with self.app.db.getSession() as session: change = session.getChange(change_key) change.hidden = not change.hidden ret = change.hidden hidden_str = 'hidden' if change.hidden else 'visible' self.log.debug("Set change %s to %s", change_key, hidden_str) return ret def advance(self): pos = self.listbox.focus_position if pos < len(self.listbox.body)-1: pos += 1 self.listbox.focus_position = pos def keypress(self, size, key): if not self.app.input_buffer: key = super(ChangeListView, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if keymap.TOGGLE_LIST_REVIEWED in commands: self.unreviewed = not self.unreviewed self.refresh() return None if keymap.TOGGLE_REVIEWED in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key reviewed = self.toggleReviewed(change_key) if self.unreviewed and reviewed: # Here we can avoid a full refresh by just removing the particular # row from the change list if the view is for the unreviewed changes # only. row = self.change_rows[change_key] self.listbox.body.remove(row) del self.change_rows[change_key] else: # Just fall back on doing a full refresh if we're in a situation # where we're not just popping a row from the list of unreviewed # changes. self.refresh() self.advance() return None if keymap.TOGGLE_HIDDEN in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key hidden = self.toggleHidden(change_key) if hidden: # Here we can avoid a full refresh by just removing the particular # row from the change list row = self.change_rows[change_key] self.listbox.body.remove(row) del self.change_rows[change_key] else: # Just fall back on doing a full refresh if we're in a situation # where we're not just popping a row from the list of changes. self.refresh() self.advance() return None if keymap.TOGGLE_HELD in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key self.toggleHeld(change_key) row = self.change_rows[change_key] with self.app.db.getSession() as session: change = session.getChange(change_key) row.update(change, self.categories) self.advance() return None if keymap.TOGGLE_STARRED in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key self.toggleStarred(change_key) row = self.change_rows[change_key] with self.app.db.getSession() as session: change = session.getChange(change_key) row.update(change, self.categories) self.advance() return None if keymap.TOGGLE_MARK in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key row = self.change_rows[change_key] row.mark = not row.mark with self.app.db.getSession() as session: change = session.getChange(change_key) row.update(change, self.categories) self.advance() return None if keymap.EDIT_TOPIC in commands: self.editTopic() return None if keymap.REFRESH in commands: if self.project_key: self.app.sync.submitTask( sync.SyncProjectTask(self.project_key, sync.HIGH_PRIORITY)) else: self.app.sync.submitTask( sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY)) self.app.status.update() return None if keymap.REVIEW in commands: rows = [row for row in self.change_rows.values() if row.mark] if not rows: pos = self.listbox.focus_position rows = [self.listbox.body[pos]] self.openReview(rows) return None if keymap.SORT_BY_NUMBER in commands: if not len(self.listbox.body): return None self.sort_by = 'number' self.clearChangeList() self.refresh() return None if keymap.SORT_BY_UPDATED in commands: if not len(self.listbox.body): return None self.sort_by = 'updated' self.clearChangeList() self.refresh() return None if keymap.SORT_BY_REVERSE in commands: if not len(self.listbox.body): return None if self.reverse: self.reverse = False else: self.reverse = True self.clearChangeList() self.refresh() return None if keymap.LOCAL_CHECKOUT in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position row = self.listbox.body[pos] self.app.localCheckoutCommit(row.project_name, row.commit_sha) return None if keymap.LOCAL_CHERRY_PICK in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position row = self.listbox.body[pos] self.app.localCherryPickCommit(row.project_name, row.commit_sha) return None if keymap.REFINE_CHANGE_SEARCH in commands: default = self.getQueryString() self.app.searchDialog(default) return None return key def onSelect(self, button, change_key): try: view = view_change.ChangeView(self.app, change_key) self.app.changeScreen(view) except gertty.view.DisplayError as e: self.app.error(e.message) def openReview(self, rows): dialog = view_change.ReviewDialog(self.app, rows[0].current_revision_key) urwid.connect_signal(dialog, 'save', lambda button: self.closeReview(dialog, rows, True, False)) urwid.connect_signal(dialog, 'submit', lambda button: self.closeReview(dialog, rows, True, True)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeReview(dialog, rows, False, False)) self.app.popup(dialog, relative_width=50, relative_height=75, min_width=60, min_height=20) def closeReview(self, dialog, rows, upload, submit): approvals, message = dialog.getValues() revision_keys = [row.current_revision_key for row in rows] message_keys = self.app.saveReviews(revision_keys, approvals, message, upload, submit) if upload: for message_key in message_keys: self.app.sync.submitTask( sync.UploadReviewTask(message_key, sync.HIGH_PRIORITY)) self.refresh() self.app.backScreen() def editTopic(self): dialog = view_change.EditTopicDialog(self.app, '') urwid.connect_signal(dialog, 'save', lambda button: self.closeEditTopic(dialog, True)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeEditTopic(dialog, False)) self.app.popup(dialog) def closeEditTopic(self, dialog, save): if save: rows = [row for row in self.change_rows.values() if row.mark] if not rows: pos = self.listbox.focus_position rows = [self.listbox.body[pos]] change_keys = [row.change_key for row in rows] with self.app.db.getSession() as session: for change_key in change_keys: change = session.getChange(change_key) change.topic = dialog.entry.edit_text change.pending_topic = True self.app.sync.submitTask( sync.SetTopicTask(change_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() gertty-1.3.1/gertty/view/change.py0000664000567000056700000013533512636066265020307 0ustar jenkinsjenkins00000000000000# Copyright 2014 OpenStack Foundation # Copyright 2014 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import datetime import logging import urlparse import urwid from gertty import gitrepo from gertty import keymap from gertty import mywid from gertty import sync from gertty.view import side_diff as view_side_diff from gertty.view import unified_diff as view_unified_diff from gertty.view import mouse_scroll_decorator import gertty.view class EditTopicDialog(mywid.ButtonDialog): signals = ['save', 'cancel'] def __init__(self, app, topic): self.app = app save_button = mywid.FixedButton('Save') cancel_button = mywid.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(EditTopicDialog, self).__init__("Edit Topic", "Edit the change topic.", entry_prompt="Topic: ", entry_text=topic, buttons=[save_button, cancel_button], ring=app.ring) def keypress(self, size, key): if not self.app.input_buffer: key = super(EditTopicDialog, 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 CherryPickDialog(urwid.WidgetWrap): signals = ['save', 'cancel'] def __init__(self, app, change): save_button = mywid.FixedButton('Propose Change') cancel_button = mywid.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 = mywid.MyEdit(edit_text=change.revisions[-1].message, multiline=True, ring=app.ring) self.branch_buttons = [] rows.append(urwid.Text(u"Branch:")) for branch in change.project.branches: b = mywid.FixedRadioButton(self.branch_buttons, branch.name, state=(branch.name == change.branch)) rows.append(b) rows.append(urwid.Divider()) rows.append(urwid.Text(u"Commit message:")) rows.append(self.entry) rows.append(urwid.Divider()) rows.append(button_columns) pile = urwid.Pile(rows) fill = urwid.Filler(pile, valign='top') super(CherryPickDialog, self).__init__(urwid.LineBox(fill, 'Propose Change to Branch')) class ReviewDialog(urwid.WidgetWrap): signals = ['submit', 'save', 'cancel'] def __init__(self, app, revision_key): self.revision_key = revision_key self.app = app save_button = mywid.FixedButton(u'Save') submit_button = mywid.FixedButton(u'Save and Submit') cancel_button = mywid.FixedButton(u'Cancel') urwid.connect_signal(save_button, 'click', lambda button:self._emit('save')) urwid.connect_signal(submit_button, 'click', lambda button:self._emit('submit')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) rows = [] categories = [] values = {} descriptions = {} self.button_groups = {} message = '' with self.app.db.getSession() as session: revision = session.getRevision(self.revision_key) change = revision.change buttons = [('pack', save_button)] if revision.can_submit: buttons.append(('pack', submit_button)) buttons.append(('pack', cancel_button)) buttons = urwid.Columns(buttons, dividechars=2) if revision == change.revisions[-1]: for label in change.labels: d = descriptions.setdefault(label.category, {}) d[label.value] = label.description vmin = d.setdefault('min', label.value) if label.value < vmin: d['min'] = label.value vmax = d.setdefault('max', label.value) if label.value > vmax: d['max'] = label.value for label in change.permitted_labels: if label.category not in categories: categories.append(label.category) values[label.category] = [] values[label.category].append(label.value) draft_approvals = {} for approval in change.draft_approvals: draft_approvals[approval.category] = approval for category in categories: rows.append(urwid.Text(category)) group = [] self.button_groups[category] = group current = draft_approvals.get(category) if current is None: current = 0 else: current = current.value for value in sorted(values[category], reverse=True): if value > 0: strvalue = '+%s' % value elif value == 0: strvalue = ' 0' else: strvalue = str(value) strvalue += ' ' + descriptions[category][value] b = urwid.RadioButton(group, strvalue, state=(value == current)) b._value = value if value > 0: if value == descriptions[category]['max']: b = urwid.AttrMap(b, 'max-label') else: b = urwid.AttrMap(b, 'positive-label') elif value < 0: if value == descriptions[category]['min']: b = urwid.AttrMap(b, 'min-label') else: b = urwid.AttrMap(b, 'negative-label') rows.append(b) rows.append(urwid.Divider()) m = revision.getPendingMessage() if not m: m = revision.getDraftMessage() if m: message = m.message self.message = mywid.MyEdit("Message: \n", edit_text=message, multiline=True, ring=app.ring) rows.append(self.message) rows.append(urwid.Divider()) rows.append(buttons) pile = urwid.Pile(rows) fill = urwid.Filler(pile, valign='top') super(ReviewDialog, self).__init__(urwid.LineBox(fill, 'Review')) def getValues(self): approvals = {} for category, group in self.button_groups.items(): for button in group: if button.state: approvals[category] = button._value message = self.message.edit_text.strip() return (approvals, message) def keypress(self, size, key): if not self.app.input_buffer: key = super(ReviewDialog, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if keymap.PREV_SCREEN in commands: self._emit('cancel') return None return key class ReviewButton(mywid.FixedButton): def __init__(self, revision_row): super(ReviewButton, self).__init__(('revision-button', u'Review')) self.revision_row = revision_row self.change_view = revision_row.change_view urwid.connect_signal(self, 'click', lambda button: self.openReview()) def openReview(self): self.dialog = ReviewDialog(self.change_view.app, self.revision_row.revision_key) urwid.connect_signal(self.dialog, 'save', lambda button: self.closeReview(True, False)) urwid.connect_signal(self.dialog, 'submit', lambda button: self.closeReview(True, True)) urwid.connect_signal(self.dialog, 'cancel', lambda button: self.closeReview(False, False)) self.change_view.app.popup(self.dialog, relative_width=50, relative_height=75, min_width=60, min_height=20) def closeReview(self, upload, submit): approvals, message = self.dialog.getValues() self.change_view.saveReview(self.revision_row.revision_key, approvals, message, upload, submit) self.change_view.app.backScreen() class RevisionRow(urwid.WidgetWrap): revision_focus_map = { 'revision-name': 'focused-revision-name', 'revision-commit': 'focused-revision-commit', 'revision-comments': 'focused-revision-comments', 'revision-drafts': 'focused-revision-drafts', } def __init__(self, app, change_view, repo, revision, expanded=False): super(RevisionRow, self).__init__(urwid.Pile([])) self.app = app self.change_view = change_view self.revision_key = revision.key self.project_name = revision.change.project.name self.commit_sha = revision.commit self.can_submit = revision.can_submit self.title = mywid.TextButton(u'', on_press = self.expandContract) table = mywid.Table(columns=3) total_added = 0 total_removed = 0 for rfile in revision.files: if rfile.status is None: continue added = rfile.inserted or 0 removed = rfile.deleted or 0 total_added += added total_removed += removed table.addRow([urwid.Text(('filename', rfile.display_path), wrap='clip'), urwid.Text([('lines-added', '+%i' % (added,)), ', '], align=urwid.RIGHT), urwid.Text(('lines-removed', '-%i' % (removed,)))]) table.addRow([urwid.Text(''), urwid.Text([('lines-added', '+%i' % (total_added,)), ', '], align=urwid.RIGHT), urwid.Text(('lines-removed', '-%i' % (total_removed,)))]) table = urwid.Padding(table, width='pack') focus_map={'revision-button': 'focused-revision-button'} self.review_button = ReviewButton(self) buttons = [self.review_button, mywid.FixedButton(('revision-button', "Diff"), on_press=self.diff), mywid.FixedButton(('revision-button', "Local Checkout"), on_press=self.checkout), mywid.FixedButton(('revision-button', "Local Cherry-Pick"), on_press=self.cherryPick)] if self.can_submit: buttons.append(mywid.FixedButton(('revision-button', "Submit"), on_press=lambda x: self.change_view.doSubmitChange())) buttons = [('pack', urwid.AttrMap(b, None, focus_map=focus_map)) for b in buttons] buttons = urwid.Columns(buttons + [urwid.Text('')], dividechars=2) buttons = urwid.AttrMap(buttons, 'revision-button') self.more = urwid.Pile([table, buttons]) padded_title = urwid.Padding(self.title, width='pack') self.pile = urwid.Pile([padded_title]) self._w = urwid.AttrMap(self.pile, None, focus_map=self.revision_focus_map) self.expanded = False self.update(revision) if expanded: self.expandContract(None) def update(self, revision): line = [('revision-name', 'Patch Set %s ' % revision.number), ('revision-commit', revision.commit)] num_drafts = sum([len(f.draft_comments) for f in revision.files]) if num_drafts: pending_message = revision.getPendingMessage() if not pending_message: line.append(('revision-drafts', ' (%s draft%s)' % ( num_drafts, num_drafts>1 and 's' or ''))) num_comments = sum([len(f.comments) for f in revision.files]) - num_drafts if num_comments: line.append(('revision-comments', ' (%s inline comment%s)' % ( num_comments, num_comments>1 and 's' or ''))) self.title.text.set_text(line) def expandContract(self, button): if self.expanded: self.pile.contents.pop() self.expanded = False else: self.pile.contents.append((self.more, ('pack', None))) self.expanded = True def diff(self, button): self.change_view.diff(self.revision_key) def checkout(self, button): self.app.localCheckoutCommit(self.project_name, self.commit_sha) def cherryPick(self, button): self.app.localCherryPickCommit(self.project_name, self.commit_sha) class ChangeButton(mywid.FixedButton): button_left = urwid.Text(u' ') button_right = urwid.Text(u' ') def __init__(self, change_view, change_key, text): super(ChangeButton, self).__init__('') self.set_label(text) self.change_view = change_view self.change_key = change_key urwid.connect_signal(self, 'click', lambda button: self.openChange()) def set_label(self, text): super(ChangeButton, self).set_label(text) def openChange(self): try: self.change_view.app.changeScreen(ChangeView(self.change_view.app, self.change_key)) except gertty.view.DisplayError as e: self.change_view.app.error(e.message) class ChangeMessageBox(mywid.HyperText): def __init__(self, app, message): super(ChangeMessageBox, self).__init__(u'') self.app = app self.refresh(message) def refresh(self, message): self.message_created = message.created created = self.app.time(message.created) lines = message.message.split('\n') if message.draft: lines.insert(0, '') lines.insert(0, 'Patch Set %s:' % (message.revision.number,)) if message.author.username == self.app.config.username: name_style = 'change-message-own-name' header_style = 'change-message-own-header' else: name_style = 'change-message-name' header_style = 'change-message-header' text = [(name_style, message.author_name), (header_style, ': '+lines.pop(0)), (header_style, created.strftime(' (%Y-%m-%d %H:%M:%S%z)'))] if message.draft and not message.pending: text.append(('change-message-draft', ' (draft)')) if lines and lines[-1]: lines.append('') comment_text = ['\n'.join(lines)] for commentlink in self.app.config.commentlinks: comment_text = commentlink.run(self.app, comment_text) self.set_text(text+comment_text) class CommitMessageBox(mywid.HyperText): def __init__(self, app, message): self.app = app super(CommitMessageBox, self).__init__(message) def set_text(self, text): text = [text] for commentlink in self.app.config.commentlinks: text = commentlink.run(self.app, text) super(CommitMessageBox, self).set_text(text) @mouse_scroll_decorator.ScrollByWheel class ChangeView(urwid.WidgetWrap): def help(self): key = self.app.config.keymap.formatKeys ret = [ (key(keymap.LOCAL_CHECKOUT), "Checkout the most recent revision into the local repo"), (key(keymap.DIFF), "Show the diff of the most recent revision"), (key(keymap.TOGGLE_HIDDEN), "Toggle the hidden flag for the current change"), (key(keymap.NEXT_CHANGE), "Go to the next change in the list"), (key(keymap.PREV_CHANGE), "Go to the previous change in the list"), (key(keymap.REVIEW), "Leave a review for the most recent revision"), (key(keymap.TOGGLE_HELD), "Toggle the held flag for the current change"), (key(keymap.TOGGLE_HIDDEN_COMMENTS), "Toggle display of hidden comments"), (key(keymap.SEARCH_RESULTS), "Back to the list of changes"), (key(keymap.TOGGLE_REVIEWED), "Toggle the reviewed flag for the current change"), (key(keymap.TOGGLE_STARRED), "Toggle the starred flag for the current change"), (key(keymap.LOCAL_CHERRY_PICK), "Cherry-pick the most recent revision onto the local repo"), (key(keymap.ABANDON_CHANGE), "Abandon this change"), (key(keymap.EDIT_COMMIT_MESSAGE), "Edit the commit message of this change"), (key(keymap.REBASE_CHANGE), "Rebase this change (remotely)"), (key(keymap.RESTORE_CHANGE), "Restore this change"), (key(keymap.REFRESH), "Refresh this change"), (key(keymap.EDIT_TOPIC), "Edit the topic of this change"), (key(keymap.SUBMIT_CHANGE), "Submit this change"), (key(keymap.CHERRY_PICK_CHANGE), "Propose this change to another branch"), ] for k in self.app.config.reviewkeys.values(): action = ', '.join(['{category}:{value}'.format(**a) for a in k['approvals']]) ret.append((keymap.formatKey(k['key']), action)) return ret def __init__(self, app, change_key): super(ChangeView, self).__init__(urwid.Pile([])) self.log = logging.getLogger('gertty.view.change') self.app = app self.change_key = change_key self.revision_rows = {} self.message_rows = {} self.last_revision_key = None self.hide_comments = True self.change_id_label = mywid.TextButton(u'', on_press=self.searchChangeId) self.owner_label = mywid.TextButton(u'', on_press=self.searchOwner) self.project_label = mywid.TextButton(u'', on_press=self.searchProject) self.branch_label = urwid.Text(u'', wrap='clip') self.topic_label = mywid.TextButton(u'', on_press=self.searchTopic) self.created_label = urwid.Text(u'', wrap='clip') self.updated_label = urwid.Text(u'', wrap='clip') self.status_label = urwid.Text(u'', wrap='clip') self.permalink_label = mywid.TextButton(u'', on_press=self.openPermalink) change_info = [] change_info_map={'change-data': 'focused-change-data'} for l, v in [("Change-Id", urwid.Padding(urwid.AttrMap(self.change_id_label, None, focus_map=change_info_map), width='pack')), ("Owner", urwid.Padding(urwid.AttrMap(self.owner_label, None, focus_map=change_info_map), width='pack')), ("Project", urwid.Padding(urwid.AttrMap(self.project_label, None, focus_map=change_info_map), width='pack')), ("Branch", self.branch_label), ("Topic", urwid.Padding(urwid.AttrMap(self.topic_label, None, focus_map=change_info_map), width='pack')), ("Created", self.created_label), ("Updated", self.updated_label), ("Status", self.status_label), ("Permalink", urwid.Padding(urwid.AttrMap(self.permalink_label, None, focus_map=change_info_map), width='pack')), ]: row = urwid.Columns([(12, urwid.Text(('change-header', l), wrap='clip')), v]) change_info.append(row) change_info = urwid.Pile(change_info) self.commit_message = CommitMessageBox(app, u'') votes = mywid.Table([]) self.depends_on = urwid.Pile([]) self.depends_on_rows = {} self.needed_by = urwid.Pile([]) self.needed_by_rows = {} self.related_changes = urwid.Pile([self.depends_on, self.needed_by]) self.results = mywid.HyperText(u'') # because it scrolls better than a table self.grid = mywid.MyGridFlow([change_info, self.commit_message, votes, self.results], cell_width=80, h_sep=2, v_sep=1, align='left') self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) self._w.contents.append((self.app.header, ('pack', 1))) self._w.contents.append((urwid.Divider(), ('pack', 1))) self._w.contents.append((self.listbox, ('weight', 1))) self._w.set_focus(2) self.listbox.body.append(self.grid) self.listbox.body.append(urwid.Divider()) self.listbox.body.append(self.related_changes) self.listbox.body.append(urwid.Divider()) self.listbox_patchset_start = len(self.listbox.body) self.checkGitRepo() self.refresh() self.listbox.set_focus(3) self.grid.set_focus(1) def checkGitRepo(self): missing_revisions = set() change_number = None change_id = None with self.app.db.getSession() as session: change = session.getChange(self.change_key) change_number = change.number change_id = change.id repo = gitrepo.get_repo(change.project.name, self.app.config) for revision in change.revisions: if not repo.hasCommit(revision.parent): missing_revisions.add(revision.parent) if not repo.hasCommit(revision.commit): missing_revisions.add(revision.commit) if missing_revisions: break if missing_revisions: if self.app.sync.offline: raise gertty.view.DisplayError("Git commits not present in local repository") self.app.log.warning("Missing some commits for change %s %s", change_number, missing_revisions) task = sync.SyncChangeTask(change_id, force_fetch=True, priority=sync.HIGH_PRIORITY) self.app.sync.submitTask(task) succeeded = task.wait(300) if not succeeded: raise gertty.view.DisplayError("Git commits not present in local repository") def interested(self, event): if not ((isinstance(event, sync.ChangeAddedEvent) and self.change_key in event.related_change_keys) or (isinstance(event, sync.ChangeUpdatedEvent) and self.change_key in event.related_change_keys)): self.log.debug("Ignoring refresh change due to event %s" % (event,)) return False self.log.debug("Refreshing change due to event %s" % (event,)) return True def refresh(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) self.topic = change.topic or '' self.pending_status_message = change.pending_status_message or '' reviewed = hidden = starred = held = '' if change.reviewed: reviewed = ' (reviewed)' if change.hidden: hidden = ' (hidden)' if change.starred: starred = '* ' if change.held: held = ' (held)' self.title = '%sChange %s%s%s%s' % (starred, change.number, reviewed, hidden, held) self.app.status.update(title=self.title) self.project_key = change.project.key self.project_name = change.project.name self.change_rest_id = change.id self.change_id = change.change_id if change.owner: self.owner_email = change.owner.email else: self.owner_email = None self.change_id_label.text.set_text(('change-data', change.change_id)) self.owner_label.text.set_text(('change-data', change.owner_name)) self.project_label.text.set_text(('change-data', change.project.name)) self.branch_label.set_text(('change-data', change.branch)) self.topic_label.text.set_text(('change-data', self.topic)) self.created_label.set_text(('change-data', str(self.app.time(change.created)))) self.updated_label.set_text(('change-data', str(self.app.time(change.updated)))) self.status_label.set_text(('change-data', change.status)) self.permalink_url = urlparse.urljoin(self.app.config.url, str(change.number)) self.permalink_label.text.set_text(('change-data', self.permalink_url)) self.commit_message.set_text(change.revisions[-1].message) categories = [] approval_headers = [urwid.Text(('table-header', 'Name'))] for label in change.labels: if label.category in categories: continue approval_headers.append(urwid.Text(('table-header', label.category))) categories.append(label.category) votes = mywid.Table(approval_headers) approvals_for_name = {} pending_message = change.revisions[-1].getPendingMessage() for approval in change.approvals: # Don't display draft approvals unless they are pending-upload if approval.draft and not pending_message: continue approvals = approvals_for_name.get(approval.reviewer.name) if not approvals: approvals = {} row = [] if approval.reviewer.username == self.app.config.username: style = 'reviewer-own-name' else: style = 'reviewer-name' row.append(urwid.Text((style, approval.reviewer.name))) for i, category in enumerate(categories): w = urwid.Text(u'', align=urwid.CENTER) approvals[category] = w row.append(w) approvals_for_name[approval.reviewer.name] = approvals votes.addRow(row) if str(approval.value) != '0': cat_min, cat_max = change.getMinMaxPermittedForCategory(approval.category) if approval.value > 0: val = '+%i' % approval.value if approval.value == cat_max: val = ('max-label', val) else: val = ('positive-label', val) else: val = '%i' % approval.value if approval.value == cat_min: val = ('min-label', val) else: val = ('negative-label', val) approvals[approval.category].set_text(val) votes = urwid.Padding(votes, width='pack') # TODO: update the existing table rather than replacing it # wholesale. It will become more important if the table # gets selectable items (like clickable names). self.grid.contents[2] = (votes, ('given', 80)) self.refreshDependencies(session, change) repo = gitrepo.get_repo(change.project.name, self.app.config) # The listbox has both revisions and messages in it (and # may later contain the vote table and change header), so # keep track of the index separate from the loop. listbox_index = self.listbox_patchset_start for revno, revision in enumerate(change.revisions): self.last_revision_key = revision.key row = self.revision_rows.get(revision.key) if not row: row = RevisionRow(self.app, self, repo, revision, expanded=(revno==len(change.revisions)-1)) self.listbox.body.insert(listbox_index, row) self.revision_rows[revision.key] = row row.update(revision) # Revisions are extremely unlikely to be deleted, skip # that case. listbox_index += 1 if len(self.listbox.body) == listbox_index: self.listbox.body.insert(listbox_index, urwid.Divider()) listbox_index += 1 # Get the set of messages that should be displayed display_messages = [] result_systems = {} for message in change.messages: if (message.revision == change.revisions[-1] and message.author and message.author.name): for commentlink in self.app.config.commentlinks: results = commentlink.getTestResults(self.app, message.message) if results: result_system = result_systems.get(message.author.name, {}) result_systems[message.author.name] = result_system result_system.update(results) skip = False if self.hide_comments and message.author and message.author.name: for regex in self.app.config.hide_comments: if regex.match(message.author.name): skip = True break if not skip: display_messages.append(message) # The set of message keys currently displayed unseen_keys = set(self.message_rows.keys()) # Make sure all of the messages that should be displayed are for message in display_messages: row = self.message_rows.get(message.key) if not row: box = ChangeMessageBox(self.app, message) row = urwid.Padding(box, width=80) self.listbox.body.insert(listbox_index, row) self.message_rows[message.key] = row else: unseen_keys.remove(message.key) if message.created != row.original_widget.message_created: row.original_widget.refresh(message) listbox_index += 1 # Remove any messages that should not be displayed for key in unseen_keys: row = self.message_rows.get(key) self.listbox.body.remove(row) del self.message_rows[key] listbox_index -= 1 self._updateTestResults(result_systems) def _updateTestResults(self, result_systems): text = [] for system, results in result_systems.items(): for job, result in results.items(): text.append(result) if text: self.results.set_text(text) else: self.results.set_text('') def _updateDependenciesWidget(self, changes, widget, widget_rows, header): if not changes: if len(widget.contents) > 0: widget.contents[:] = [] return if len(widget.contents) == 0: widget.contents.append((urwid.Text(('table-header', header)), widget.options())) unseen_keys = set(widget_rows.keys()) i = 1 for key, subject in changes.items(): row = widget_rows.get(key) if not row: row = urwid.AttrMap(urwid.Padding(ChangeButton(self, key, subject), width='pack'), 'link', focus_map={None: 'focused-link'}) row = (row, widget.options('pack')) widget.contents.insert(i, row) if not widget.selectable(): widget.set_focus(i) if not self.related_changes.selectable(): self.related_changes.set_focus(widget) widget_rows[key] = row else: row[0].original_widget.original_widget.set_label(subject) unseen_keys.remove(key) i += 1 for key in unseen_keys: row = widget_rows[key] widget.contents.remove(row) del widget_rows[key] def refreshDependencies(self, session, change): revision = change.revisions[-1] # Handle depends-on parents = {} parent = session.getRevisionByCommit(revision.parent) if parent and parent.change.status != 'MERGED': subject = parent.change.subject if parent != parent.change.revisions[-1]: subject += ' [OUTDATED]' parents[parent.change.key] = subject self._updateDependenciesWidget(parents, self.depends_on, self.depends_on_rows, header='Depends on:') # Handle needed-by children = {} children.update((r.change.key, r.change.subject) for r in session.getRevisionsByParent([revision.commit for revision in change.revisions]) if (r.change.status != 'MERGED' and r == r.change.revisions[-1])) self._updateDependenciesWidget(children, self.needed_by, self.needed_by_rows, header='Needed by:') def toggleReviewed(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.reviewed = not change.reviewed def toggleHidden(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.hidden = not change.hidden def toggleStarred(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.starred = not change.starred change.pending_starred = True self.app.sync.submitTask( sync.ChangeStarredTask(self.change_key, sync.HIGH_PRIORITY)) def toggleHeld(self): return self.app.toggleHeldChange(self.change_key) def keypress(self, size, key): if not self.app.input_buffer: key = super(ChangeView, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if keymap.TOGGLE_REVIEWED in commands: self.toggleReviewed() self.refresh() return None if keymap.TOGGLE_HIDDEN in commands: self.toggleHidden() self.refresh() return None if keymap.TOGGLE_STARRED in commands: self.toggleStarred() self.refresh() return None if keymap.TOGGLE_HELD in commands: self.toggleHeld() self.refresh() return None if keymap.REVIEW in commands: row = self.revision_rows[self.last_revision_key] row.review_button.openReview() return None if keymap.DIFF in commands: row = self.revision_rows[self.last_revision_key] row.diff(None) return None if keymap.LOCAL_CHECKOUT in commands: row = self.revision_rows[self.last_revision_key] row.checkout(None) return None if keymap.LOCAL_CHERRY_PICK in commands: row = self.revision_rows[self.last_revision_key] row.cherryPick(None) return None if keymap.SEARCH_RESULTS in commands: widget = self.app.findChangeList() if widget: self.app.backScreen(widget) return None if ((keymap.NEXT_CHANGE in commands) or (keymap.PREV_CHANGE in commands)): widget = self.app.findChangeList() if widget: if keymap.NEXT_CHANGE in commands: new_change_key = widget.getNextChangeKey(self.change_key) else: new_change_key = widget.getPrevChangeKey(self.change_key) if new_change_key: try: view = ChangeView(self.app, new_change_key) self.app.changeScreen(view, push=False) except gertty.view.DisplayError as e: self.app.error(e.message) return None if keymap.TOGGLE_HIDDEN_COMMENTS in commands: self.hide_comments = not self.hide_comments self.refresh() return None if keymap.ABANDON_CHANGE in commands: self.abandonChange() return None if keymap.EDIT_COMMIT_MESSAGE in commands: self.editCommitMessage() return None if keymap.REBASE_CHANGE in commands: self.rebaseChange() return None if keymap.RESTORE_CHANGE in commands: self.restoreChange() return None if keymap.REFRESH in commands: self.app.sync.submitTask( sync.SyncChangeTask(self.change_rest_id, priority=sync.HIGH_PRIORITY)) self.app.status.update() return None if keymap.SUBMIT_CHANGE in commands: self.doSubmitChange() return None if keymap.EDIT_TOPIC in commands: self.editTopic() return None if keymap.CHERRY_PICK_CHANGE in commands: self.cherryPickChange() return None if key in self.app.config.reviewkeys: self.reviewKey(self.app.config.reviewkeys[key]) return None return key def diff(self, revision_key): if self.app.config.diff_view == 'unified': screen = view_unified_diff.UnifiedDiffView(self.app, revision_key) else: screen = view_side_diff.SideDiffView(self.app, revision_key) self.app.changeScreen(screen) def abandonChange(self): dialog = mywid.TextEditDialog(u'Abandon Change', u'Abandon message:', u'Abandon Change', self.pending_status_message) urwid.connect_signal(dialog, 'cancel', self.app.backScreen) urwid.connect_signal(dialog, 'save', lambda button: self.doAbandonRestoreChange(dialog, 'ABANDONED')) self.app.popup(dialog) def restoreChange(self): dialog = mywid.TextEditDialog(u'Restore Change', u'Restore message:', u'Restore Change', self.pending_status_message) urwid.connect_signal(dialog, 'cancel', self.app.backScreen) urwid.connect_signal(dialog, 'save', lambda button: self.doAbandonRestoreChange(dialog, 'NEW')) self.app.popup(dialog) def doAbandonRestoreChange(self, dialog, state): change_key = None with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.status = state change.pending_status = True change.pending_status_message = dialog.entry.edit_text change_key = change.key self.app.sync.submitTask( sync.ChangeStatusTask(change_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() def editCommitMessage(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) dialog = mywid.TextEditDialog(u'Edit Commit Message', u'Commit message:', u'Save', change.revisions[-1].message) urwid.connect_signal(dialog, 'cancel', self.app.backScreen) urwid.connect_signal(dialog, 'save', lambda button: self.doEditCommitMessage(dialog)) self.app.popup(dialog, relative_width=50, relative_height=75, min_width=60, min_height=20) def doEditCommitMessage(self, dialog): revision_key = None with self.app.db.getSession() as session: change = session.getChange(self.change_key) revision = change.revisions[-1] revision.message = dialog.entry.edit_text revision.pending_message = True revision_key = revision.key self.app.sync.submitTask( sync.ChangeCommitMessageTask(revision_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() def rebaseChange(self): dialog = mywid.YesNoDialog(u'Rebase Change', u'Perform a remote rebase of this change?') urwid.connect_signal(dialog, 'no', self.app.backScreen) urwid.connect_signal(dialog, 'yes', self.doRebaseChange) self.app.popup(dialog) def doRebaseChange(self, button=None): change_key = None with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.pending_rebase = True change_key = change.key self.app.sync.submitTask( sync.RebaseChangeTask(change_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() def cherryPickChange(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) dialog = CherryPickDialog(self.app, change) urwid.connect_signal(dialog, 'cancel', self.app.backScreen) urwid.connect_signal(dialog, 'save', lambda button: self.doCherryPickChange(dialog)) self.app.popup(dialog, relative_width=50, relative_height=75, min_width=60, min_height=20) def doCherryPickChange(self, dialog): cp_key = None with self.app.db.getSession() as session: change = session.getChange(self.change_key) branch = None for button in dialog.branch_buttons: if button.state: branch = button.get_label() message = dialog.entry.edit_text self.app.log.debug("Creating pending cherry-pick of %s to %s" % (change.revisions[-1].commit, branch)) cp = change.revisions[-1].createPendingCherryPick(branch, message) cp_key = cp.key self.app.sync.submitTask( sync.SendCherryPickTask(cp_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() def doSubmitChange(self): change_key = None with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.status = 'SUBMITTED' change.pending_status = True change.pending_status_message = None change_key = change.key self.app.sync.submitTask( sync.ChangeStatusTask(change_key, sync.HIGH_PRIORITY)) self.refresh() def editTopic(self): dialog = EditTopicDialog(self.app, self.topic) urwid.connect_signal(dialog, 'save', lambda button: self.closeEditTopic(dialog, True)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeEditTopic(dialog, False)) self.app.popup(dialog) def closeEditTopic(self, dialog, save): if save: change_key = None with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.topic = dialog.entry.edit_text change.pending_topic = True change_key = change.key self.app.sync.submitTask( sync.SetTopicTask(change_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() def openPermalink(self, widget): self.app.openURL(self.permalink_url) def searchChangeId(self, widget): self.app.doSearch("status:open change:%s" % (self.change_id,)) def searchOwner(self, widget): if self.owner_email: self.app.doSearch("status:open owner:%s" % (self.owner_email,)) def searchProject(self, widget): self.app.doSearch("status:open project:%s" % (self.project_name,)) def searchTopic(self, widget): if self.topic: self.app.doSearch("status:open topic:%s" % (self.topic,)) def reviewKey(self, reviewkey): approvals = {} for a in reviewkey['approvals']: approvals[a['category']] = a['value'] self.app.log.debug("Reviewkey %s with approvals %s" % (reviewkey['key'], approvals)) row = self.revision_rows[self.last_revision_key] submit = reviewkey.get('submit', False) self.saveReview(row.revision_key, approvals, '', True, submit) def saveReview(self, revision_key, approvals, message, upload, submit): message_keys = self.app.saveReviews([revision_key], approvals, message, upload, submit) if upload: for message_key in message_keys: self.app.sync.submitTask( sync.UploadReviewTask(message_key, sync.HIGH_PRIORITY)) self.refresh() gertty-1.3.1/gertty/view/mouse_scroll_decorator.py0000664000567000056700000000235612636066265023626 0ustar jenkinsjenkins00000000000000# coding=utf8 # # Copyright 2014 Jan Kundrát # # 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. def mouse_event_scrolling(class_type): def mouse_event_scrolling(self, size, event, button, col, row, focus): if event == 'mouse press': if button == 4: self.keypress(size, 'up') return True if button == 5: self.keypress(size, 'down') return True return super(class_type, self).mouse_event(size, event, button, col, row, focus) return mouse_event_scrolling def ScrollByWheel(original_class): original_class.mouse_event = mouse_event_scrolling(original_class) return original_class gertty-1.3.1/gertty/view/project_list.py0000664000567000056700000001717112636066266021561 0ustar jenkinsjenkins00000000000000# 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 logging import urwid from gertty import keymap from gertty import sync from gertty.view import change_list as view_change_list from gertty.view import mouse_scroll_decorator class ProjectRow(urwid.Button): project_focus_map = {None: 'focused', 'unreviewed-project': 'focused-unreviewed-project', 'subscribed-project': 'focused-subscribed-project', 'unsubscribed-project': 'focused-unsubscribed-project', } def selectable(self): return True def __init__(self, project, callback=None): super(ProjectRow, self).__init__('', on_press=callback, user_data=(project.key, project.name)) self.project_key = project.key name = urwid.Text(project.name) name.set_wrap_mode('clip') self.unreviewed_changes = urwid.Text(u'', align=urwid.RIGHT) self.open_changes = urwid.Text(u'', align=urwid.RIGHT) col = urwid.Columns([ name, ('fixed', 11, self.unreviewed_changes), ('fixed', 5, self.open_changes), ]) self.row_style = urwid.AttrMap(col, '') self._w = urwid.AttrMap(self.row_style, None, focus_map=self.project_focus_map) self.update(project) def update(self, project): if project.subscribed: if len(project.unreviewed_changes) > 0: style = 'unreviewed-project' else: style = 'subscribed-project' else: style = 'unsubscribed-project' self.row_style.set_attr_map({None: style}) self.unreviewed_changes.set_text('%i ' % len(project.unreviewed_changes)) self.open_changes.set_text('%i ' % len(project.open_changes)) class ProjectListHeader(urwid.WidgetWrap): def __init__(self): cols = [urwid.Text(u'Project'), (11, urwid.Text(u'Unreviewed')), (5, urwid.Text(u'Open'))] super(ProjectListHeader, self).__init__(urwid.Columns(cols)) @mouse_scroll_decorator.ScrollByWheel class ProjectListView(urwid.WidgetWrap): def help(self): key = self.app.config.keymap.formatKeys return [ (key(keymap.TOGGLE_LIST_SUBSCRIBED), "Toggle whether only subscribed projects or all projects are listed"), (key(keymap.TOGGLE_LIST_REVIEWED), "Toggle listing of projects with unreviewed changes"), (key(keymap.TOGGLE_SUBSCRIBED), "Toggle the subscription flag for the currently selected project"), (key(keymap.REFRESH), "Sync subscribed projects") ] def __init__(self, app): super(ProjectListView, self).__init__(urwid.Pile([])) self.log = logging.getLogger('gertty.view.project_list') self.app = app self.unreviewed = True self.subscribed = True self.project_rows = {} self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) self.header = ProjectListHeader() self.refresh() self._w.contents.append((app.header, ('pack', 1))) self._w.contents.append((urwid.Divider(),('pack', 1))) self._w.contents.append((urwid.AttrWrap(self.header, 'table-header'), ('pack', 1))) self._w.contents.append((self.listbox, ('weight', 1))) self._w.set_focus(3) def interested(self, event): if not (isinstance(event, sync.ProjectAddedEvent) or isinstance(event, sync.ChangeAddedEvent) or (isinstance(event, sync.ChangeUpdatedEvent) and (event.status_changed or event.review_flag_changed))): self.log.debug("Ignoring refresh project list due to event %s" % (event,)) return False self.log.debug("Refreshing project list due to event %s" % (event,)) return True def refresh(self): if self.subscribed: self.title = u'Subscribed projects' if self.unreviewed: self.title += u' with unreviewed changes' else: self.title = u'All projects' self.app.status.update(title=self.title) unseen_keys = set(self.project_rows.keys()) with self.app.db.getSession() as session: i = 0 for project in session.getProjects( subscribed=self.subscribed, unreviewed=self.unreviewed): row = self.project_rows.get(project.key) if not row: row = ProjectRow(project, self.onSelect) self.listbox.body.insert(i, row) self.project_rows[project.key] = row else: row.update(project) unseen_keys.remove(project.key) i += 1 for key in unseen_keys: row = self.project_rows[key] self.listbox.body.remove(row) del self.project_rows[key] def toggleSubscribed(self, project_key): with self.app.db.getSession() as session: project = session.getProject(project_key) project.subscribed = not project.subscribed ret = project.subscribed return ret def onSelect(self, button, data): project_key, project_name = data self.app.changeScreen(view_change_list.ChangeListView( self.app, "_project_key:%s %s" % (project_key, self.app.config.project_change_list_query), project_name, project_key=project_key, unreviewed=True)) def keypress(self, size, key): if not self.app.input_buffer: key = super(ProjectListView, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) ret = self.handleCommands(commands) if ret is True: if keymap.FURTHER_INPUT not in commands: self.app.clearInputBuffer() return None return key def handleCommands(self, commands): if keymap.TOGGLE_LIST_REVIEWED in commands: self.unreviewed = not self.unreviewed self.refresh() return True if keymap.TOGGLE_LIST_SUBSCRIBED in commands: self.subscribed = not self.subscribed self.refresh() return True if keymap.TOGGLE_SUBSCRIBED in commands: if not len(self.listbox.body): return None pos = self.listbox.focus_position project_key = self.listbox.body[pos].project_key subscribed = self.toggleSubscribed(project_key) self.refresh() if subscribed: self.app.sync.submitTask(sync.SyncProjectTask(project_key)) return None if keymap.REFRESH in commands: self.app.sync.submitTask( sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY)) self.app.status.update() self.refresh() return True return False gertty-1.3.1/gertty/view/diff.py0000664000567000056700000005450112636066266017766 0ustar jenkinsjenkins00000000000000# Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import datetime import logging import urwid from gertty import gitrepo from gertty import keymap from gertty import mywid from gertty import gitrepo from gertty import sync from gertty.view import mouse_scroll_decorator class PatchsetDialog(urwid.WidgetWrap): signals = ['ok', 'cancel'] def __init__(self, patchsets, old, new): button_widgets = [] ok_button = mywid.FixedButton('OK') cancel_button = mywid.FixedButton('Cancel') urwid.connect_signal(ok_button, 'click', lambda button:self._emit('ok')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) button_widgets.append(('pack', ok_button)) button_widgets.append(('pack', cancel_button)) button_columns = urwid.Columns(button_widgets, dividechars=2) left = [] right = [] left.append(urwid.Text('Old')) right.append(urwid.Text('New')) self.old_buttons = [] self.new_buttons = [] self.patchset_keys = {} oldb = mywid.FixedRadioButton(self.old_buttons, 'Base', state=(old==None)) left.append(oldb) right.append(urwid.Text('')) self.patchset_keys[oldb] = None for key, num in patchsets: oldb = mywid.FixedRadioButton(self.old_buttons, 'Patchset %d' % num, state=(old==key)) newb = mywid.FixedRadioButton(self.new_buttons, 'Patchset %d' % num, state=(new==key)) left.append(oldb) right.append(newb) self.patchset_keys[oldb] = key self.patchset_keys[newb] = key left = urwid.Pile(left) right = urwid.Pile(right) table = urwid.Columns([left, right]) rows = [] rows.append(table) rows.append(urwid.Divider()) rows.append(button_columns) pile = urwid.Pile(rows) fill = urwid.Filler(pile, valign='top') title = 'Patchsets' super(PatchsetDialog, self).__init__(urwid.LineBox(fill, title)) def getSelected(self): old = new = None for b in self.old_buttons: if b.state: old = self.patchset_keys[b] break for b in self.new_buttons: if b.state: new = self.patchset_keys[b] break return old, new class LineContext(object): def __init__(self, old_file_key, new_file_key, old_fn, new_fn, old_ln, new_ln, header=False): self.old_file_key = old_file_key self.new_file_key = new_file_key self.old_fn = old_fn self.new_fn = new_fn self.old_ln = old_ln self.new_ln = new_ln self.header = header class BaseDiffCommentEdit(urwid.Columns): pass class BaseDiffComment(urwid.Columns): pass class BaseDiffLine(urwid.Button): def selectable(self): return True def search(self, search, attribute): pass class BaseFileHeader(urwid.Button): def selectable(self): return True def search(self, search, attribute): pass class BaseFileReminder(urwid.WidgetWrap): pass class DiffContextButton(urwid.WidgetWrap): def selectable(self): return True def __init__(self, view, diff, chunk): focus_map={'context-button':'focused-context-button'} buttons = [mywid.FixedButton(('context-button', "Expand previous 10"), on_press=self.prev), mywid.FixedButton(('context-button', "Expand"), on_press=self.all), mywid.FixedButton(('context-button', "Expand next 10"), on_press=self.next)] self._buttons = buttons buttons = [('pack', urwid.AttrMap(b, None, focus_map=focus_map)) for b in buttons] buttons = urwid.Columns([urwid.Text('')] + buttons + [urwid.Text('')], dividechars=4) buttons = urwid.AttrMap(buttons, 'context-button') super(DiffContextButton, self).__init__(buttons) self.view = view self.diff = diff self.chunk = chunk self.update() def update(self): self._buttons[1].set_label("Expand %s lines of context" % (len(self.chunk.lines)),) def prev(self, button): self.view.expandChunk(self.diff, self.chunk, from_start=10) def all(self, button): self.view.expandChunk(self.diff, self.chunk, expand_all=True) def next(self, button): self.view.expandChunk(self.diff, self.chunk, from_end=-10) @mouse_scroll_decorator.ScrollByWheel class BaseDiffView(urwid.WidgetWrap): def help(self): key = self.app.config.keymap.formatKeys return [ (key(keymap.ACTIVATE), "Add an inline comment"), (key(keymap.SELECT_PATCHSETS), "Select old/new patchsets to diff"), (key(keymap.INTERACTIVE_SEARCH), "Interactive search"), ] def __init__(self, app, new_revision_key): super(BaseDiffView, self).__init__(urwid.Pile([])) self.log = logging.getLogger('gertty.view.diff') self.app = app self.old_revision_key = None # Base self.new_revision_key = new_revision_key self._init() def _init(self): del self._w.contents[:] self.search = None self.results = [] self.current_result = None with self.app.db.getSession() as session: new_revision = session.getRevision(self.new_revision_key) old_comments = [] new_comments = [] self.old_file_keys = {} self.new_file_keys = {} if self.old_revision_key is not None: old_revision = session.getRevision(self.old_revision_key) self.old_revision_num = old_revision.number old_str = 'patchset %s' % self.old_revision_num self.base_commit = old_revision.commit for f in old_revision.files: old_comments += f.comments self.old_file_keys[f.path] = f.key show_old_commit = True else: old_revision = None self.old_revision_num = None old_str = 'base' self.base_commit = new_revision.parent show_old_commit = False # The old files are the same as the new files since we # are diffing from base -> change, however, we should # use the old file names for file lookup. for f in new_revision.files: if f.old_path: self.old_file_keys[f.old_path] = f.key else: self.old_file_keys[f.path] = f.key self.title = u'Diff of %s change %s from %s to patchset %s' % ( new_revision.change.project.name, new_revision.change.number, old_str, new_revision.number) self.new_revision_num = new_revision.number self.change_key = new_revision.change.key self.project_name = new_revision.change.project.name self.commit = new_revision.commit for f in new_revision.files: new_comments += f.comments self.new_file_keys[f.path] = f.key comment_lists = {} comment_filenames = set() for comment in new_comments: path = comment.file.path if comment.parent: if old_revision: # we're not looking at the base continue key = 'old' if comment.file.old_path: path = comment.file.old_path else: key = 'new' if comment.draft: key += 'draft' key += '-' + str(comment.line) key += '-' + path comment_list = comment_lists.get(key, []) if comment.draft: message = comment.message else: message = [('comment-name', comment.author.name), ('comment', u': '+comment.message)] comment_list.append((comment.key, message)) comment_lists[key] = comment_list comment_filenames.add(path) for comment in old_comments: if comment.parent: continue path = comment.file.path key = 'old' if comment.draft: key += 'draft' key += '-' + str(comment.line) key += '-' + path comment_list = comment_lists.get(key, []) if comment.draft: message = comment.message else: message = [('comment-name', comment.author.name), ('comment', u': '+comment.message)] comment_list.append((comment.key, message)) comment_lists[key] = comment_list comment_filenames.add(path) repo = gitrepo.get_repo(self.project_name, self.app.config) self._w.contents.append((self.app.header, ('pack', 1))) self.file_reminder = self.makeFileReminder() self._w.contents.append((self.file_reminder, ('pack', 1))) lines = [] # The initial set of lines to display self.file_diffs = [{}, {}] # Mapping of fn -> DiffFile object (old, new) # this is a list of files: diffs = repo.diff(self.base_commit, self.commit, show_old_commit=show_old_commit) for diff in diffs: comment_filenames.discard(diff.oldname) comment_filenames.discard(diff.newname) # There are comments referring to these files which do not # appear in the diff so we should create fake diff objects # that contain the full text. for filename in comment_filenames: diff = repo.getFile(self.base_commit, self.commit, filename) if diff: diffs.append(diff) else: self.log.debug("Unable to find file %s in commit %s" % (filename, self.commit)) for i, diff in enumerate(diffs): if i > 0: lines.append(urwid.Text('')) self.file_diffs[gitrepo.OLD][diff.oldname] = diff self.file_diffs[gitrepo.NEW][diff.newname] = diff lines.extend(self.makeFileHeader(diff, comment_lists)) for chunk in diff.chunks: if chunk.context: if not chunk.first: lines += self.makeLines(diff, chunk.lines[:10], comment_lists) del chunk.lines[:10] button = DiffContextButton(self, diff, chunk) chunk.button = button lines.append(button) if not chunk.last: lines += self.makeLines(diff, chunk.lines[-10:], comment_lists) del chunk.lines[-10:] chunk.calcRange() if not chunk.lines: lines.remove(button) else: lines += self.makeLines(diff, chunk.lines, comment_lists) listwalker = urwid.SimpleFocusListWalker(lines) self.listbox = urwid.ListBox(listwalker) self._w.contents.append((self.listbox, ('weight', 1))) self.old_focus = 2 self.draft_comments = [] self._w.set_focus(self.old_focus) self.handleUndisplayedComments(comment_lists) self.app.status.update(title=self.title) def handleUndisplayedComments(self, comment_lists): # Handle comments that landed outside our default diff context lastlen = 0 while comment_lists: if len(comment_lists.keys()) == lastlen: self.log.error("Unable to display all comments: %s" % comment_lists) return lastlen = len(comment_lists.keys()) key = comment_lists.keys()[0] kind, lineno, path = key.split('-', 2) lineno = int(lineno) if kind.startswith('old'): oldnew = gitrepo.OLD else: oldnew = gitrepo.NEW file_diffs = self.file_diffs[oldnew] if path not in file_diffs: self.log.error("Unable to display comment: %s" % key) del comment_lists[key] continue diff = self.file_diffs[oldnew][path] for chunk in diff.chunks: if (chunk.range[oldnew][gitrepo.START] <= lineno and chunk.range[oldnew][gitrepo.END] >= lineno): i = chunk.indexOfLine(oldnew, lineno) if i < (len(chunk.lines) / 2): from_start = True else: from_start = False if chunk.first and from_start: from_start = False if chunk.last and (not from_start): from_start = True if from_start: self.expandChunk(diff, chunk, comment_lists, from_start=i+10) else: self.expandChunk(diff, chunk, comment_lists, from_end=i-10) break def expandChunk(self, diff, chunk, comment_lists={}, from_start=None, from_end=None, expand_all=None): self.log.debug("Expand chunk %s %s %s" % (chunk, from_start, from_end)) add_lines = [] if from_start is not None: index = self.listbox.body.index(chunk.button) add_lines = chunk.lines[:from_start] del chunk.lines[:from_start] if from_end is not None: index = self.listbox.body.index(chunk.button)+1 add_lines = chunk.lines[from_end:] del chunk.lines[from_end:] if expand_all: index = self.listbox.body.index(chunk.button) add_lines = chunk.lines[:] del chunk.lines[:] if add_lines: lines = self.makeLines(diff, add_lines, comment_lists) self.listbox.body[index:index] = lines chunk.calcRange() if not chunk.lines: self.listbox.body.remove(chunk.button) else: chunk.button.update() def makeContext(self, diff, old_ln, new_ln, header=False): old_key = None new_key = None if not diff.old_empty: if diff.oldname in self.old_file_keys: old_key = self.old_file_keys[diff.oldname] elif diff.newname in self.old_file_keys: old_key = self.old_file_keys[diff.newname] if not diff.new_empty: new_key = self.new_file_keys.get(diff.newname) return LineContext( old_key, new_key, diff.oldname, diff.newname, old_ln, new_ln, header) def makeLines(self, diff, lines_to_add, comment_lists): raise NotImplementedError def makeFileHeader(self, diff, comment_lists): raise NotImplementedError def makeFileReminder(self): raise NotImplementedError def interested(self, event): if not ((isinstance(event, sync.ChangeAddedEvent) and self.change_key in event.related_change_keys) or (isinstance(event, sync.ChangeUpdatedEvent) and self.change_key in event.related_change_keys)): #self.log.debug("Ignoring refresh diff due to event %s" % (event,)) return False #self.log.debug("Refreshing diff due to event %s" % (event,)) return True def refresh(self, event=None): #TODO pass def getContextAtTop(self, size): middle, top, bottom = self.listbox.calculate_visible(size, True) if top and top[1]: (widget, pos, rows) = top[1][-1] elif middle: pos = middle[2] # Make sure the first header shows up as soon as it scrolls up if pos > 1: pos -= 1 context = None while True: item = self.listbox.body[pos] if hasattr(item, 'context'): break pos -= 1 if pos > 0: context = item.context return context def search_valid_char(self, ch): return urwid.util.is_wide_char(ch, 0) or (len(ch) == 1 and ord(ch) >= 32) def keypress(self, size, key): if self.search is not None: if self.search_valid_char(key) or key == 'backspace': if key == 'backspace': self.search = self.search[:-1] else: self.search += key self.interactiveSearch(self.search) return None else: commands = self.app.config.keymap.getCommands([key]) if keymap.INTERACTIVE_SEARCH in commands: self.nextSearchResult() return None else: self.app.status.update(title=self.title) if not self.search: self.interactiveSearch(None) self.search = None if key in ['enter', 'esc']: return None old_focus = self.listbox.focus if not self.app.input_buffer: key = super(BaseDiffView, self).keypress(size, key) new_focus = self.listbox.focus keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) context = self.getContextAtTop(size) if context: self.file_reminder.set(context.old_fn, context.new_fn) else: self.file_reminder.set('', '') if (isinstance(old_focus, BaseDiffCommentEdit) and (old_focus != new_focus or (keymap.PREV_SCREEN in commands))): self.cleanupEdit(old_focus) if keymap.SELECT_PATCHSETS in commands: self.openPatchsetDialog() return None if keymap.INTERACTIVE_SEARCH in commands: self.search = '' self.app.status.update(title=("Search: ")) return None return key def mouse_event(self, size, event, button, x, y, focus): old_focus = self.listbox.focus r = super(BaseDiffView, self).mouse_event(size, event, button, x, y, focus) new_focus = self.listbox.focus if old_focus != new_focus and isinstance(old_focus, BaseDiffCommentEdit): self.cleanupEdit(old_focus) return r def makeCommentEdit(self, edit): raise NotImplementedError def onSelect(self, button): pos = self.listbox.focus_position e = self.makeCommentEdit(self.listbox.body[pos]) self.listbox.body.insert(pos+1, e) self.listbox.focus_position = pos+1 def cleanupEdit(self, edit): raise NotImplementedError def deleteComment(self, comment_key): with self.app.db.getSession() as session: comment = session.getComment(comment_key) session.delete(comment) def saveComment(self, context, text, new=True): if (not new) and (not self.old_revision_num): parent = True else: parent = False if new: line_num = context.new_ln file_key = context.new_file_key else: line_num = context.old_ln file_key = context.old_file_key if file_key is None: raise Exception("Comment is not associated with a file") with self.app.db.getSession() as session: fileojb = session.getFile(file_key) account = session.getAccountByUsername(self.app.config.username) comment = fileojb.createComment(None, account, None, datetime.datetime.utcnow(), parent, line_num, text, draft=True) key = comment.key return key def openPatchsetDialog(self): revisions = [] with self.app.db.getSession() as session: change = session.getChange(self.change_key) for r in change.revisions: revisions.append((r.key, r.number)) dialog = PatchsetDialog(revisions, self.old_revision_key, self.new_revision_key) urwid.connect_signal(dialog, 'cancel', lambda button: self.app.backScreen()) urwid.connect_signal(dialog, 'ok', lambda button: self._openPatchsetDialog(dialog)) self.app.popup(dialog, min_width=30, min_height=8) def _openPatchsetDialog(self, dialog): self.app.backScreen() self.old_revision_key, self.new_revision_key = dialog.getSelected() self._init() 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 gertty-1.3.1/gertty/view/__init__.py0000664000567000056700000000116212636066265020607 0ustar jenkinsjenkins00000000000000# 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. class DisplayError(Exception): pass gertty-1.3.1/gertty/view/side_diff.py0000664000567000056700000002420512636066265020767 0ustar jenkinsjenkins00000000000000# Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import urwid from gertty import keymap from gertty import mywid from gertty.view.diff import BaseDiffComment, BaseDiffCommentEdit, BaseDiffLine from gertty.view.diff import BaseFileHeader, BaseFileReminder, BaseDiffView LN_COL_WIDTH = 5 class SideDiffCommentEdit(BaseDiffCommentEdit): def __init__(self, app, context, old_key=None, new_key=None, old=u'', new=u''): super(SideDiffCommentEdit, self).__init__([]) self.app = app self.context = context # If we save a comment, the resulting key will be stored here self.old_key = old_key self.new_key = new_key self.old = mywid.MyEdit(edit_text=old, multiline=True, ring=app.ring) self.new = mywid.MyEdit(edit_text=new, multiline=True, ring=app.ring) self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False))) if context.old_file_key and (context.old_ln is not None or context.header): self.contents.append((urwid.AttrMap(self.old, 'draft-comment'), ('weight', 1, False))) else: self.contents.append((urwid.Text(u''), ('weight', 1, False))) self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False))) if context.new_file_key and (context.new_ln is not None or context.header): self.contents.append((urwid.AttrMap(self.new, 'draft-comment'), ('weight', 1, False))) new_editable = True else: self.contents.append((urwid.Text(u''), ('weight', 1, False))) new_editable = False if new_editable: self.focus_position = 3 else: self.focus_position = 1 def keypress(self, size, key): if not self.app.input_buffer: key = super(SideDiffCommentEdit, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if ((keymap.NEXT_SELECTABLE in commands) or (keymap.PREV_SELECTABLE in commands)): if ((self.context.old_ln is not None and self.context.new_ln is not None) or self.context.header): if self.focus_position == 3: self.focus_position = 1 else: self.focus_position = 3 return None return key class SideDiffComment(BaseDiffComment): def __init__(self, context, old, new): super(SideDiffComment, self).__init__([]) self.context = context oldt = urwid.Text(old) newt = urwid.Text(new) if old: oldt = urwid.AttrMap(oldt, 'comment') if new: newt = urwid.AttrMap(newt, 'comment') self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False))) self.contents.append((oldt, ('weight', 1, False))) self.contents.append((urwid.Text(u''), ('given', LN_COL_WIDTH, False))) self.contents.append((newt, ('weight', 1, False))) class SideDiffLine(BaseDiffLine): def __init__(self, app, context, old, new, callback=None): super(SideDiffLine, self).__init__('', on_press=callback) self.context = context self.text_widgets = [] columns = [] for (ln, action, line) in (old, new): if ln is None: ln = '' else: ln = '%*i' % (LN_COL_WIDTH-1, ln) ln_col = urwid.Text(('line-number', ln)) ln_col.set_wrap_mode('clip') line_col = mywid.SearchableText(line) self.text_widgets.append(line_col) if action == '': line_col = urwid.AttrMap(line_col, 'nonexistent') columns += [(LN_COL_WIDTH, ln_col), line_col] col = urwid.Columns(columns) map = {None: 'focused', 'added-line': 'focused-added-line', 'added-word': 'focused-added-word', 'removed-line': 'focused-removed-line', 'removed-word': 'focused-removed-word', 'nonexistent': 'focused-nonexistent', 'line-number': 'focused-line-number', } self._w = urwid.AttrMap(col, None, focus_map=map) def search(self, search, attribute): ret = False for w in self.text_widgets: if w.search(search, attribute): ret = True return ret class SideFileHeader(BaseFileHeader): def __init__(self, app, context, old, new, callback=None): super(SideFileHeader, self).__init__('', on_press=callback) self.context = context col = urwid.Columns([ urwid.Text(('filename', old)), urwid.Text(('filename', new))]) map = {None: 'focused-filename', 'filename': 'focused-filename'} self._w = urwid.AttrMap(col, None, focus_map=map) class SideFileReminder(BaseFileReminder): def __init__(self): self.old_text = urwid.Text(('filename', '')) self.new_text = urwid.Text(('filename', '')) col = urwid.Columns([self.old_text, self.new_text]) super(SideFileReminder, self).__init__(col) def set(self, old, new): self.old_text.set_text(('filename', old)) self.new_text.set_text(('filename', new)) class SideDiffView(BaseDiffView): def makeLines(self, diff, lines_to_add, comment_lists): lines = [] for old, new in lines_to_add: context = self.makeContext(diff, old[0], new[0]) lines.append(SideDiffLine(self.app, context, old, new, callback=self.onSelect)) # see if there are any comments for this line key = 'old-%s-%s' % (old[0], diff.oldname) old_list = comment_lists.pop(key, []) key = 'new-%s-%s' % (new[0], diff.newname) new_list = comment_lists.pop(key, []) while old_list or new_list: old_comment_key = new_comment_key = None old_comment = new_comment = u'' if old_list: (old_comment_key, old_comment) = old_list.pop(0) if new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(SideDiffComment(context, old_comment, new_comment)) # see if there are any draft comments for this line key = 'olddraft-%s-%s' % (old[0], diff.oldname) old_list = comment_lists.pop(key, []) key = 'newdraft-%s-%s' % (new[0], diff.newname) new_list = comment_lists.pop(key, []) while old_list or new_list: old_comment_key = new_comment_key = None old_comment = new_comment = u'' if old_list: (old_comment_key, old_comment) = old_list.pop(0) if new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(SideDiffCommentEdit(self.app, context, old_comment_key, new_comment_key, old_comment, new_comment)) return lines def makeFileReminder(self): return SideFileReminder() def makeFileHeader(self, diff, comment_lists): context = self.makeContext(diff, None, None, header=True) lines = [] lines.append(SideFileHeader(self.app, context, diff.oldname, diff.newname, callback=self.onSelect)) # see if there are any comments for this file key = 'old-None-%s' % (diff.oldname,) old_list = comment_lists.pop(key, []) key = 'new-None-%s' % (diff.newname,) new_list = comment_lists.pop(key, []) while old_list or new_list: old_comment_key = new_comment_key = None old_comment = new_comment = u'' if old_list: (old_comment_key, old_comment) = old_list.pop(0) if new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(SideDiffComment(context, old_comment, new_comment)) # see if there are any draft comments for this file key = 'olddraft-None-%s' % (diff.oldname,) old_list = comment_lists.pop(key, []) key = 'newdraft-None-%s' % (diff.newname,) new_list = comment_lists.pop(key, []) while old_list or new_list: old_comment_key = new_comment_key = None old_comment = new_comment = u'' if old_list: (old_comment_key, old_comment) = old_list.pop(0) if new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(SideDiffCommentEdit(self.app, context, old_comment_key, new_comment_key, old_comment, new_comment)) return lines def makeCommentEdit(self, edit): return SideDiffCommentEdit(self.app, edit.context) def cleanupEdit(self, edit): if edit.old_key: self.deleteComment(edit.old_key) edit.old_key = None if edit.new_key: self.deleteComment(edit.new_key) edit.new_key = None old = edit.old.edit_text.strip() new = edit.new.edit_text.strip() if old or new: if old: edit.old_key = self.saveComment( edit.context, old, new=False) if new: edit.new_key = self.saveComment( edit.context, new, new=True) else: self.listbox.body.remove(edit) gertty-1.3.1/gertty/view/unified_diff.py0000664000567000056700000002473412636066265021475 0ustar jenkinsjenkins00000000000000# Copyright 2014 OpenStack Foundation # Copyright 2014 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import urwid from gertty import gitrepo from gertty import mywid from gertty.view.diff import BaseDiffCommentEdit, BaseDiffComment, BaseDiffLine from gertty.view.diff import BaseFileHeader, BaseFileReminder, BaseDiffView LN_COL_WIDTH = 5 class UnifiedDiffCommentEdit(BaseDiffCommentEdit): def __init__(self, app, context, oldnew, key=None, comment=u''): super(UnifiedDiffCommentEdit, self).__init__([]) self.context = context self.oldnew = oldnew # If we save a comment, the resulting key will be stored here self.key = key self.comment = mywid.MyEdit(edit_text=comment, multiline=True, ring=app.ring) self.contents.append((urwid.Text(u''), ('given', 8, False))) self.contents.append((urwid.AttrMap(self.comment, 'draft-comment'), ('weight', 1, False))) self.focus_position = 1 class UnifiedDiffComment(BaseDiffComment): def __init__(self, context, oldnew, comment): super(UnifiedDiffComment, self).__init__([]) self.context = context text = urwid.AttrMap(urwid.Text(comment), 'comment') self.contents.append((urwid.Text(u''), ('given', 8, False))) self.contents.append((text, ('weight', 1, False))) class UnifiedDiffLine(BaseDiffLine): def __init__(self, app, context, oldnew, old, new, callback=None): super(UnifiedDiffLine, self).__init__('', on_press=callback) self.context = context self.oldnew = oldnew (old_ln, old_action, old_line) = old (new_ln, new_action, new_line) = new if old_ln is None: old_ln = '' else: old_ln = '%*i' % (LN_COL_WIDTH-1, old_ln) if new_ln is None: new_ln = '' else: new_ln = '%*i' % (LN_COL_WIDTH-1, new_ln) old_ln_col = urwid.Text(('line-number', old_ln)) old_ln_col.set_wrap_mode('clip') new_ln_col = urwid.Text(('line-number', new_ln)) new_ln_col.set_wrap_mode('clip') if oldnew == gitrepo.OLD: action = old_action line = old_line columns = [(LN_COL_WIDTH, old_ln_col), (LN_COL_WIDTH, urwid.Text(u''))] elif oldnew == gitrepo.NEW: action = new_action line = new_line columns = [(LN_COL_WIDTH, urwid.Text(u'')), (LN_COL_WIDTH, new_ln_col)] if new_action == ' ': columns = [(LN_COL_WIDTH, old_ln_col), (LN_COL_WIDTH, new_ln_col)] line_col = mywid.SearchableText(line) self.text_widget = line_col if action == '': line_col = urwid.AttrMap(line_col, 'nonexistent') columns += [line_col] col = urwid.Columns(columns) map = {None: 'focused', 'added-line': 'focused-added-line', 'added-word': 'focused-added-word', 'removed-line': 'focused-removed-line', 'removed-word': 'focused-removed-word', 'nonexistent': 'focused-nonexistent', 'line-number': 'focused-line-number', } self._w = urwid.AttrMap(col, None, focus_map=map) def search(self, search, attribute): return self.text_widget.search(search, attribute) class UnifiedFileHeader(BaseFileHeader): def __init__(self, app, context, oldnew, old, new, callback=None): super(UnifiedFileHeader, self).__init__('', on_press=callback) self.context = context self.oldnew = oldnew if oldnew == gitrepo.OLD: col = urwid.Columns([ urwid.Text(('filename', old))]) elif oldnew == gitrepo.NEW: col = urwid.Columns([ (LN_COL_WIDTH, urwid.Text(u'')), urwid.Text(('filename', new))]) map = {None: 'focused-filename', 'filename': 'focused-filename'} self._w = urwid.AttrMap(col, None, focus_map=map) class UnifiedFileReminder(BaseFileReminder): def __init__(self): self.old_text = urwid.Text(('filename', '')) self.new_text = urwid.Text(('filename', '')) self.col = urwid.Columns([('pack', self.old_text), ('pack', self.new_text), urwid.Text(u'')], dividechars=2) super(UnifiedFileReminder, self).__init__(self.col) def set(self, old, new): self.old_text.set_text(('filename', old)) self.new_text.set_text(('filename', new)) self.col._invalidate() class UnifiedDiffView(BaseDiffView): def makeLines(self, diff, lines_to_add, comment_lists): lines = [] for old, new in lines_to_add: context = self.makeContext(diff, old[0], new[0]) if context.old_ln is not None: lines.append(UnifiedDiffLine(self.app, context, gitrepo.OLD, old, new, callback=self.onSelect)) # see if there are any comments for this line key = 'old-%s-%s' % (old[0], diff.oldname) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) lines.append(UnifiedDiffComment(context, gitrepo.OLD, old_comment)) # see if there are any draft comments for this line key = 'olddraft-%s-%s' % (old[0], diff.oldname) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) lines.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.OLD, old_comment_key, old_comment)) # new line if context.new_ln is not None and new[1] != ' ': lines.append(UnifiedDiffLine(self.app, context, gitrepo.NEW, old, new, callback=self.onSelect)) # see if there are any comments for this line key = 'new-%s-%s' % (new[0], diff.newname) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment)) # see if there are any draft comments for this line key = 'newdraft-%s-%s' % (new[0], diff.newname) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.NEW, new_comment_key, new_comment)) return lines def makeFileReminder(self): return UnifiedFileReminder() def makeFileHeader(self, diff, comment_lists): context = self.makeContext(diff, None, None, header=True) lines = [] lines.append(UnifiedFileHeader(self.app, context, gitrepo.OLD, diff.oldname, diff.newname, callback=self.onSelect)) # see if there are any comments for this file key = 'old-None-%s' % (diff.oldname,) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) lines.append(UnifiedDiffComment(context, gitrepo.OLD, old_comment)) # see if there are any draft comments for this file key = 'olddraft-None-%s' % (diff.oldname,) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) lines.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.OLD, old_comment_key, old_comment)) # new line lines.append(UnifiedFileHeader(self.app, context, gitrepo.NEW, diff.oldname, diff.newname, callback=self.onSelect)) # see if there are any comments for this file key = 'new-None-%s' % (diff.newname,) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment)) # see if there are any draft comments for this file key = 'newdraft-None-%s' % (diff.newname,) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.NEW, new_comment_key, new_comment)) return lines def makeCommentEdit(self, edit): return UnifiedDiffCommentEdit(self.app, edit.context, edit.oldnew) def cleanupEdit(self, edit): if edit.key: self.deleteComment(edit.key) edit.key = None comment = edit.comment.edit_text.strip() if comment: new = False if edit.oldnew == gitrepo.NEW: new = True edit.key = self.saveComment( edit.context, comment, new=new) else: self.listbox.body.remove(edit) gertty-1.3.1/gertty/mywid.py0000664000567000056700000003517412636066266017242 0ustar jenkinsjenkins00000000000000# 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.Text(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 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): (maxcol,) = size 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) @mouse_scroll_decorator.ScrollByWheel class ButtonDialog(urwid.WidgetWrap): 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 TextEditDialog(urwid.WidgetWrap): 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 HyperText(urwid.Text): _selectable = True 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 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 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) return super(HyperText, self).render(size, focus) 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 # A workaround for the issue fixed in # https://github.com/wardi/urwid/pull/74 # included here until thi fix is released class MyGridFlow(urwid.GridFlow): def generate_display_widget(self, size): p = super(MyGridFlow, self).generate_display_widget(size) for item in p.contents: if isinstance(item[0], urwid.Padding): c = item[0].original_widget if isinstance(c, urwid.Columns): if c.focus_position == 0 and not c.contents[0][0].selectable(): for i, w in enumerate(c.contents): if w[0].selectable(): c.focus_position = i break return p gertty-1.3.1/gertty/keymap.py0000664000567000056700000001546312636066265017375 0ustar jenkinsjenkins00000000000000# 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' REBASE_CHANGE = 'rebase change' CHERRY_PICK_CHANGE = 'cherry pick change' REFRESH = 'refresh' EDIT_TOPIC = 'edit topic' EDIT_COMMIT_MESSAGE = 'edit commit message' SUBMIT_CHANGE = 'submit change' SORT_BY_NUMBER = 'sort by number' SORT_BY_UPDATED = 'sort by updated' 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' # Diff screens: SELECT_PATCHSETS = 'select patchsets' NEXT_SELECTABLE = 'next selectable' PREV_SELECTABLE = 'prev selectable' 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', CURSOR_PAGE_DOWN: 'page down', 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', REBASE_CHANGE: 'ctrl b', CHERRY_PICK_CHANGE: 'ctrl x', REFRESH: 'ctrl r', EDIT_TOPIC: 'ctrl t', EDIT_COMMIT_MESSAGE: 'ctrl d', SUBMIT_CHANGE: 'ctrl u', SORT_BY_NUMBER: 'n', SORT_BY_UPDATED: 'u', SORT_BY_REVERSE: 'R', TOGGLE_LIST_REVIEWED: 'l', TOGGLE_LIST_SUBSCRIBED: 'L', TOGGLE_SUBSCRIBED: 's', SELECT_PATCHSETS: 'p', NEXT_SELECTABLE: 'tab', PREV_SELECTABLE: 'shift tab', INTERACTIVE_SEARCH: 'ctrl s', } 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: string.upper(x.group(1))), ) 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 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) gertty-1.3.1/gertty/db.py0000664000567000056700000010042512636066265016465 0ustar jenkinsjenkins00000000000000# 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 sqlalchemy from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, DateTime, Text from sqlalchemy.schema import ForeignKey from sqlalchemy.orm import mapper, sessionmaker, relationship, scoped_session from sqlalchemy.orm.session import Session from sqlalchemy.sql import exists from sqlalchemy.sql.expression import and_ metadata = MetaData() project_table = Table( 'project', metadata, Column('key', Integer, primary_key=True), Column('name', String(255), index=True, unique=True, nullable=False), Column('subscribed', Boolean, index=True, default=False), Column('description', Text, nullable=False, default=''), 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), ) 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_status_message', Text), ) 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), ) 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), ) 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 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 Branch(object): def __init__(self, project, name): self.project_key = project.key self.name = name 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, held=False, pending_rebase=False, pending_topic=False, pending_starred=False, pending_status=False, pending_status_message=None): 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.starred = starred self.held = held self.pending_rebase = pending_rebase self.pending_topic = pending_topic self.pending_starred = pending_starred self.pending_status = pending_status self.pending_status_message = pending_status_message 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 @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 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 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): 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 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 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 mapper(Account, account_table) mapper(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'), 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(Branch, branch_table) mapper(Change, change_table, properties=dict( owner=relationship(Account), 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(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'), )) mapper(Message, message_table, properties=dict( author=relationship(Account))) mapper(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(Comment, comment_table, properties=dict( author=relationship(Account))) mapper(Label, label_table) mapper(PermittedLabel, permitted_label_table) mapper(Approval, approval_table, properties=dict( reviewer=relationship(Account))) mapper(PendingCherryPick, pending_cherry_pick_table) mapper(SyncQuery, sync_query_table) def match(expr, item): if item is None: return False return re.match(expr, item) is not None @sqlalchemy.event.listens_for(sqlalchemy.engine.Engine, "connect") def add_sqlite_match(dbapi_connection, connection_record): dbapi_connection.create_function("matches", 2, match) class Database(object): def __init__(self, app, dburi, search): self.log = logging.getLogger('gertty.db') self.dburi = dburi self.search = search self.engine = create_engine(self.dburi) #metadata.create_all(self.engine) self.migrate(app) # If we want the objects returned from query() to be usable # outside of the session, we need to expunge them from the session, # and since the DatabaseSession always calls commit() on the session # when the context manager exits, we need to inform the session to # expire objects when it does so. self.session_factory = sessionmaker(bind=self.engine, expire_on_commit=False, autoflush=False) self.session = scoped_session(self.session_factory) self.lock = threading.Lock() def getSession(self): return DatabaseSession(self) def migrate(self, app): conn = self.engine.connect() context = alembic.migration.MigrationContext.configure(conn) current_rev = context.get_current_revision() self.log.debug('Current migration revision: %s' % current_rev) has_table = self.engine.dialect.has_table(conn, "project") config = alembic.config.Config() config.set_main_option("script_location", "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): """Retrieve projects. :param subscribed: If True limit to only subscribed projects. :param unreviewed: If True limit to only projects with unreviewed changes. """ query = self.session().query(Project) if subscribed: query = query.filter_by(subscribed=subscribed) if unreviewed: query = query.filter(exists().where(Project.unreviewed_changes)) return query.order_by(Project.name).all() def getProject(self, key): try: return self.session().query(Project).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getProjectByName(self, name): try: return self.session().query(Project).filter_by(name=name).one() except sqlalchemy.orm.exc.NoResultFound: return None def 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): try: return self.session().query(Change).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getChangeByID(self, id): try: return self.session().query(Change).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: return None def 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([]) return set([r[0] for r in self.session().query(Change.id).filter(Change.id.in_(ids)).all()]) def getChangeByChangeID(self, change_id): try: return self.session().query(Change).filter_by(change_id=change_id).one() 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" % query) q = self.session().query(Change).filter(self.search.parse(query)) if unreviewed: q = q.filter(change_table.c.hidden==False, change_table.c.reviewed==False) if sort_by == 'updated': q = q.order_by(change_table.c.updated) else: q = q.order_by(change_table.c.number) 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, basestring): 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 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 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 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 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 gertty-1.3.1/gertty/app.py0000664000567000056700000006764312636066265016676 0ustar jenkinsjenkins00000000000000# 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 logging import os import Queue import re import subprocess import sys import textwrap import threading import urlparse import warnings import webbrowser import sqlalchemy.exc import urwid from gertty import db from gertty import config from gertty import gitrepo from gertty import keymap from gertty import mywid from gertty import palette from gertty import sync from gertty import search from gertty import requestsexceptions from gertty.view import change_list as view_change_list from gertty.view import project_list as view_project_list from gertty.view import change as view_change import gertty.view import gertty.version 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 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 = file(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 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, fetch_missing_refs=False, path=config.DEFAULT_CONFIG_PATH): 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.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.config.username) self.db = db.Database(self, self.config.dburi, self.search) self.sync = sync.Sync(self) self.screens = [] self.status = StatusHeader(self) self.header = urwid.AttrMap(self.status, 'header') screen = view_project_list.ProjectListView(self) self.status.update(title=screen.title) self.updateStatusQueries() self.loop = urwid.MainLoop(screen, palette=self.config.palette.getPalette(), unhandled_input=self.unhandledInput) 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() 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) 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 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 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.loop.widget) self.clearInputBuffer() self.loop.widget = widget 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.loop.widget = 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.loop.widget = widget def refresh(self, data=None, force=False): widget = self.loop.widget 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): self.clearInputBuffer() overlay = urwid.Overlay(widget, self.loop.widget, 'center', ('relative', relative_width), 'middle', ('relative', relative_height), min_width=min_width, min_height=min_height) self.log.debug("Overlaying %s on screen %s" % (widget, self.loop.widget)) self.screens.append(self.loop.widget) self.loop.widget = overlay def help(self): if not hasattr(self.loop.widget, 'help'): return global_help = [(self.config.keymap.formatKeys(k), t) for (k, t) in mywid.GLOBAL_HELP] for d in self.config.dashboards.values(): global_help.append((keymap.formatKey(d['key']), d['name'])) parts = [('Global Keys', global_help), ('This Screen', self.loop.widget.help())] keylen = 0 for title, items in parts: for 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 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 = 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: change = session.getChangeByNumber(number) elif changeid: change = session.getChangeByChangeID(changeid) change_key = change and change.key or None if change_key is None: if self.sync.offline: raise Exception('Can not sync change while offline.') 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.') with self.db.getSession() as session: if number: change = session.getChangeByNumber(number) elif changeid: change = session.getChangeByChangeID(changeid) change_key = change and change.key or None if change_key is None: 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(e.message) 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: 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 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']) self.changeScreen(view) elif keymap.FURTHER_INPUT in commands: self.input_buffer.append(key) self.status.update(message=''.join(self.input_buffer)) 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 # 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, 'error\n') 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.getAccountByUsername(self.config.username) for revision_key in revision_keys: k = self._saveReview(session, account, revision_key, approvals, message, upload, submit) if k: message_keys.append(k) return message_keys def _saveReview(self, session, account, revision_key, approvals, message, upload, submit): message_key = None revision = session.getRevision(revision_key) change = revision.change draft_approvals = {} for approval in change.draft_approvals: draft_approvals[approval.category] = approval categories = set() for label in change.permitted_labels: categories.add(label.category) for category in categories: value = approvals.get(category, 0) approval = draft_approvals.get(category) if not approval: approval = change.createApproval(account, category, 0, draft=True) draft_approvals[category] = approval approval.value = value draft_message = revision.getPendingMessage() if not draft_message: draft_message = revision.getDraftMessage() if not draft_message: if message or upload: draft_message = revision.createMessage(None, account, datetime.datetime.utcnow(), '', draft=True) if draft_message: draft_message.created = datetime.datetime.utcnow() draft_message.message = message draft_message.pending = upload message_key = draft_message.key if upload: change.reviewed = True 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) def main(): parser = argparse.ArgumentParser( description='Console client for Gerrit Code Review.') parser.add_argument('-c', dest='path', default=config.DEFAULT_CONFIG_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('--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('--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.fetch_missing_refs, args.path) g.run() if __name__ == '__main__': main() gertty-1.3.1/gertty/__init__.py0000664000567000056700000000000012636066265017623 0ustar jenkinsjenkins00000000000000gertty-1.3.1/gertty/commentlink.py0000664000567000056700000000767312636066265020433 0ustar jenkinsjenkins00000000000000# 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 urwid import mywid try: OrderedDict = collections.OrderedDict except AttributeError: OrderedDict = ordereddict.OrderedDict class TextReplacement(object): def __init__(self, config): if isinstance(config, basestring): 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, basestring): # 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 gertty-1.3.1/gertty/search/0000775000567000056700000000000012636066311016761 5ustar jenkinsjenkins00000000000000gertty-1.3.1/gertty/search/parser.py0000664000567000056700000003342412636066265020645 0ustar jenkinsjenkins00000000000000# 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 tokenizer import tokens # NOQA 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 | change_term | owner_term | reviewer_term | commit_term | project_term | project_key_term | branch_term | topic_term | ref_term | label_term | message_term | comment_term | has_term | is_term | status_term | file_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] 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 p[0] = gertty.db.change_table.c.updated < (now-datetime.timedelta(seconds=delta)) 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': username = p.parser.username p[0] = gertty.db.account_table.c.username == username else: p[0] = or_(gertty.db.account_table.c.username == p[2], gertty.db.account_table.c.email == p[2], gertty.db.account_table.c.name == p[2]) 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': username = p.parser.username filters.append(gertty.db.account_table.c.username == username) else: filters.append(or_(gertty.db.account_table.c.username == p[2], gertty.db.account_table.c.email == p[2], gertty.db.account_table.c.name == p[2])) s = select([gertty.db.change_table.c.key], correlate=False).where(and_(*filters)) p[0] = gertty.db.change_table.c.key.in_(s) 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_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] = gertty.db.change_table.c.topic == p[2] def p_ref_term(p): '''ref_term : OP_REF string''' if p[2].startswith('^'): p[0] = func.matches(p[2], 'refs/heads/'+gertty.db.change_table.c.branch) else: p[0] = gertty.db.change_table.c.branch == p[2][len('refs/heads/'):] label_re = re.compile(r'(?P