gertty-1.5.0/0000775000175000017500000000000013137372663014174 5ustar jenkinsjenkins00000000000000gertty-1.5.0/AUTHORS0000664000175000017500000000435713137372663015255 0ustar jenkinsjenkins00000000000000Adam Spiers Alex Schultz Andrew Ruthven Anita Kuno Antoine Musso Bradley Jones Cedric Brandily Christian Berendt Christoph Gysin Clark Boylan Clint Adams Cody A.W. Somerville Cody A.W. Somerville Craige McWhirter David Pursehouse David Shrewsbury David Stanek Dolph Mathews Doug Hellmann Doug Wiegley Ian Cordasco James E. Blair James E. Blair James E. Blair James E. Blair James E. Blair James Polley Jan Kundrát Jan Kundrát Jay Pipes Jeremy Stanley Jim Rollenhagen John L. Villalovos Joshua Harlow Joshua Harlow K Jonathan Harker K Jonathan Harker Kevin Benton Khai Do Mark McClain Mark McLoughlin Martin André Masayuki Igawa Masayuki Igawa Matthew Oliver Matthew Thode Matthew Treinish Matthias Runge Miguel Grinberg Monty Taylor Paul Belanger Paul Bourke Pádraig Brady Robbie Harwood (frozencemetery) Robert Collins Russell Bryant Sean M. Collins Sirushti Murugesan Wouter van Kesteren Zane Bitter gertty-1.5.0/README.rst0000664000175000017500000001611313137372464015664 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 and is currently available in: * unstable * testing * stable You can install it with:: apt-get install gertty Fedora ~~~~~~ Gertty is packaged starting in Fedora 21. You can install it with:: yum install python-gertty openSUSE ~~~~~~~~ Gertty is packaged for openSUSE 13.1 onwards. You can install it via `1-click install from the Open Build Service `_. Gentoo ~~~~~~ Gertty is available in the main Gentoo repository. You can install it with:: emerge gertty Arch Linux ~~~~~~~~~~ Gertty packages are available in the Arch User Repository packages. You can get the package from:: https://aur.archlinux.org/packages/python2-gertty/ Source ~~~~~~ When installing from source, it is recommended (but not required) to install Gertty in a virtualenv. To set one up:: 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). If Gertty is offline, it will so indicate in the status bar. It will retry requests if needed, and will switch between offline and online mode automatically. If you review a change while offline with a positive vote, and someone else leaves a negative vote on that change in the same category before Gertty is able to upload your review, Gertty will detect the situation and mark the change as "held" so that you may re-inspect the change and any new comments before uploading the review. The status bar will alert you to any held changes and direct you to a list of them (the `F12` key by default). When viewing a change, the "held" flag may be toggled with the exclamation key (`!`). Once held, a change must be explicitly un-held in this manner for your review to be uploaded. If Gertty encounters an error, this will also be indicated in the status bar. You may wish to examine ~/.gertty.log to see what the error was. In many cases, Gertty can continue after encountering an error. The error flag will be cleared when you leave the current screen. To select text (e.g., to copy to the clipboard), hold Shift while selecting the text. Terminal Integration -------------------- If you use rxvt-unicode, you can add something like the following to ``.Xresources`` to make Gerrit URLs that are displayed in your terminal (perhaps in an email or irc client) clickable links that open in Gertty:: URxvt.perl-ext: default,matcher URxvt.url-launcher: sensible-browser URxvt.keysym.C-Delete: perl:matcher:last URxvt.keysym.M-Delete: perl:matcher:list URxvt.matcher.button: 1 URxvt.matcher.pattern.1: https:\/\/review.example.org/(\\#\/c\/)?(\\d+)[\w]* URxvt.matcher.launcher.1: gertty --open $0 You will want to adjust the pattern to match the review site you are interested in; multiple patterns may be added as needed. 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.5.0/setup.py0000775000175000017500000000121113137372464015703 0ustar jenkinsjenkins00000000000000#!/usr/bin/env python # # 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 setuptools setuptools.setup( setup_requires=['pbr'], pbr=True) gertty-1.5.0/PKG-INFO0000664000175000017500000002270013137372663015272 0ustar jenkinsjenkins00000000000000Metadata-Version: 1.1 Name: gertty Version: 1.5.0 Summary: Gertty is a console-based interface to the Gerrit Code Review system. Home-page: http://www.openstack.org/ Author: OpenStack Author-email: openstack-dev@lists.openstack.org License: UNKNOWN Description: Gertty ====== Gertty is a console-based interface to the Gerrit Code Review system. As compared to the web interface, the main advantages are: * Workflow -- the interface is designed to support a workflow similar to reading network news or mail. In particular, it is designed to deal with a large number of review requests across a large number of projects. * Offline Use -- Gertty syncs information about changes in subscribed projects to a local database and local git repos. All review operations are performed against that database and then synced back to Gerrit. * Speed -- user actions modify locally cached content and need not wait for server interaction. * Convenience -- because Gertty downloads all changes to local git repos, a single command instructs it to checkout a change into that repo for detailed examination or testing of larger changes. Installation ------------ Debian ~~~~~~ Gertty is packaged in Debian and is currently available in: * unstable * testing * stable You can install it with:: apt-get install gertty Fedora ~~~~~~ Gertty is packaged starting in Fedora 21. You can install it with:: yum install python-gertty openSUSE ~~~~~~~~ Gertty is packaged for openSUSE 13.1 onwards. You can install it via `1-click install from the Open Build Service `_. Gentoo ~~~~~~ Gertty is available in the main Gentoo repository. You can install it with:: emerge gertty Arch Linux ~~~~~~~~~~ Gertty packages are available in the Arch User Repository packages. You can get the package from:: https://aur.archlinux.org/packages/python2-gertty/ Source ~~~~~~ When installing from source, it is recommended (but not required) to install Gertty in a virtualenv. To set one up:: 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). If Gertty is offline, it will so indicate in the status bar. It will retry requests if needed, and will switch between offline and online mode automatically. If you review a change while offline with a positive vote, and someone else leaves a negative vote on that change in the same category before Gertty is able to upload your review, Gertty will detect the situation and mark the change as "held" so that you may re-inspect the change and any new comments before uploading the review. The status bar will alert you to any held changes and direct you to a list of them (the `F12` key by default). When viewing a change, the "held" flag may be toggled with the exclamation key (`!`). Once held, a change must be explicitly un-held in this manner for your review to be uploaded. If Gertty encounters an error, this will also be indicated in the status bar. You may wish to examine ~/.gertty.log to see what the error was. In many cases, Gertty can continue after encountering an error. The error flag will be cleared when you leave the current screen. To select text (e.g., to copy to the clipboard), hold Shift while selecting the text. Terminal Integration -------------------- If you use rxvt-unicode, you can add something like the following to ``.Xresources`` to make Gerrit URLs that are displayed in your terminal (perhaps in an email or irc client) clickable links that open in Gertty:: URxvt.perl-ext: default,matcher URxvt.url-launcher: sensible-browser URxvt.keysym.C-Delete: perl:matcher:last URxvt.keysym.M-Delete: perl:matcher:list URxvt.matcher.button: 1 URxvt.matcher.pattern.1: https:\/\/review.example.org/(\\#\/c\/)?(\\d+)[\w]* URxvt.matcher.launcher.1: gertty --open $0 You will want to adjust the pattern to match the review site you are interested in; multiple patterns may be added as needed. 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 Keywords: gerrit console urwid review Platform: UNKNOWN Classifier: Topic :: Utilities Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Information Technology Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 gertty-1.5.0/examples/0000775000175000017500000000000013137372663016012 5ustar jenkinsjenkins00000000000000gertty-1.5.0/examples/openstack-gertty.yaml0000664000175000017500000001204113137372464022176 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. # This file does not list all of the available options. For a full # list with explanations, see the 'reference-gertty.yaml' file. servers: - name: openstack url: https://review.openstack.org/ # Your gerrit username. username: CHANGEME # Set password at https://review.openstack.org/#/settings/http-password # Note this is not your launchpad password. password: CHANGEME git-root: ~/git/ # This section adds the colors that we will reference later in the # commentlinks section for test results. You can also change other # colors here. palettes: - name: default test-SUCCESS: ['light green', ''] test-FAILURE: ['light red', ''] test-UNSTABLE: ['yellow', ''] # Uncomment the next line if your terminal has a white background # palette: light # Commentlinks are regexes that are applied to commit and review # messages. They can be replaced with internal or external links, or # have colors applied. commentlinks: # This matches the job results left by Zuul. - match: "^- (?P.*?) (?P.*?) : (?P[^ ]+) ?(?P.*)$" # This indicates that this is a test result, and should be indexed # using the "job" match group from the commentlink regex. Gertty # displays test results in their own area of the screen. test-result: "{job}" replacements: # Replace the matching text with a hyperlink to the "url" match # group whose text is the "job" match group. - link: text: "{job:<42}" url: "{url}" # Follow that with the plain text of the "result" match group # with the color "test-{result}" applied. See the palette # section above. - text: color: "test-{result}" text: "{result} " # And then follow that with the plain text of the "comment" # match group. - text: "{comment}" # Match Gerrit change ids, and replace them with a link to an # internal Gertty search for that change id. - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" # Match external references to bugs on Launchpad - match: "(?P(?:[Cc]loses|[Pp]artial|[Rr]elated)-[Bb]ug *: *#?(?P\\d+))" replacements: - link: text: "{bug_str}" url: "https://launchpad.net/bugs/{bug_id}" # Match external references to blueprints on Launchpad - match: "blueprint +(?P[\\w\\-.]+)" replacements: - link: text: "blueprint {blueprint}" url: "https://blueprints.launchpad.net/openstack/?searchtext={blueprint}" # This is the query used for the list of changes when a project is # selected. The default is "status:open". If you don't want to see # changes which are WIP or have verification failures, use a query like this: # change-list-query: "status:open not label:Workflow=-1" # If you also want to exclude reviews with failed tests, the query is slightly # more complex: # "status:open not (label:Workflow=-1 or label:Verified=-1)" # Uncomment the following line to use a unified diff view instead of the # default side-by-side: # diff-view: unified # Hide comments by default that match the following criteria. # You can toggle their display with 't'. hide-comments: - author: "^(.*CI|Jenkins)$" # This section defines customized dashboards. You can supply any # Gertty search string and bind them to any key. They will appear in # the global help text, and pressing the key anywhere in Gertty will # discard the current display stack and replace it with the results of # the query. # # NB: "recentlyseen:24 hours" does not just return changes seen in the # last 24 hours -- it returns changes seen within 24 hours of the most # recently seen change. So you can take the weekend off and pick up # where you were. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" - name: "Starred changes" query: "is:starred" key: "f4" - name: "Recently seen changes" query: "recentlyseen:24 hours" sort-by: "last-seen" reverse: True key: "f5" # Reviewkeys are hotkeys that perform immediate reviews within the # change screen. Any pending comments or review messages will be # attached to the review; otherwise an empty review will be left. The # approvals list is exhaustive, so if you specify an empty list, # Gertty will submit a review that clears any previous approvals. # They will appear in the help text for the change screen. reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: - category: 'Code-Review' value: 2 - key: 'meta 3' approvals: - category: 'Code-Review' value: 2 - category: 'Workflow' value: 1 gertty-1.5.0/examples/googlesource-gertty.yaml0000664000175000017500000000567113137372464022717 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. # This file does not list all of the available options. For a full # list with explanations, see the 'reference-gertty.yaml' file. servers: - name: CHANGEME-review url: https://CHANGEME-review.googlesource.com/ # Your gerrit username. username: CHANGEME # Set password at https://{name}-review.googlesource.com/#/settings/http-password # Note this is not your Google password. password: CHANGEME auth-type: basic git-root: ~/git/ # Uncomment the next line if your terminal has a white background # palette: light # Commentlinks are regexes that are applied to commit and review # messages. They can be replaced with internal or external links, or # have colors applied. commentlinks: # Match Gerrit change ids, and replace them with a link to an # internal Gertty search for that change id. - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" # Uncomment the following line to use a unified diff view instead # of the default side-by-side: # diff-view: unified # This section defines customized dashboards. You can supply any # Gertty search string and bind them to any key. They will appear in # the global help text, and pressing the key anywhere in Gertty will # discard the current display stack and replace it with the results of # the query. # # NB: "recentlyseen:24 hours" does not just return changes seen in the # last 24 hours -- it returns changes seen within 24 hours of the most # recently seen change. So you can take the weekend off and pick up # where you were. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" - name: "Starred changes" query: "is:starred" key: "f4" - name: "Recently seen changes" query: "recentlyseen:24 hours" sort-by: "last-seen" reverse: True key: "f5" # Reviewkeys are hotkeys that perform immediate reviews within the # change screen. Any pending comments or review messages will be # attached to the review; otherwise an empty review will be left. The # approvals list is exhaustive, so if you specify an empty list, # Gertty will submit a review that clears any previous approvals. To # submit the change with the review, include 'submit: True' with the # reviewkey. Reviewkeys appear in the help text for the change # screen. reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: - category: 'Code-Review' value: 2 - key: 'meta 3' approvals: - category: 'Code-Review' value: 2 submit: True gertty-1.5.0/examples/minimal-gertty.yaml0000664000175000017500000000062113137372464021636 0ustar jenkinsjenkins00000000000000# This is an example ~/.gertty.yaml file with only the required # settings. # This file does not list all of the available options. For a full # list with explanations, see the 'reference-gertty.yaml' file. servers: - name: CHANGEME url: https://CHANGEME.example.org/ username: CHANGEME # Set corresponding HTTP password in gerrit settings password: CHANGEME git-root: ~/git/ gertty-1.5.0/examples/reference-gertty.yaml0000664000175000017500000002302413137372464022150 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/ lock-file: ~/.gertty.CHANGEME.lock # Each server section can have the following fields: # A name that describes the server, to reference on the command line. [required] # - name: sample # The URL of the Gerrit server. HTTPS should be preferred. [required] # url: https://example.org/ # Your username in Gerrit. [required] # username: CHANGEME # Your password in Gerrit (Settings -> HTTP Password). [required] # password: CHANGEME # Authentication type required by the Gerrit server. Can be 'basic', # 'digest', or 'form'. Defaults to 'digest'. # auth-type: digest # A location where Gertty should store its git repositories. These # can be the same git repositories where you do your own work -- # Gertty will not modify them unless you tell it to, and even then the # normal git protections against losing work remain in place. [required] # git-root: ~/git/ # The URL to clone git repos. By default, /p/ is used. For a list # of valid URLs, see: # https://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS # git-url: ssh://user@example.org:29418 # The location of Gertty's sqlite database. If you have more than one # server, you should specify a dburi for any additional servers. # By default a SQLite database called ~/.gertty.db is used. # dburi: sqlite:////home/user/.gertty.db # If your Gerrit server uses a non-standard certificate chain (e.g. on a test # server), you can pass a full path to a bundle of CA certificates here: # ssl-ca-path: ~/.pki/ca-chain.pem # In case you do not care about security and want to use a sledgehammer # approach to SSL, you can set this value to false to turn off certificate # validation. # verify-ssl: true # By default Gertty logs errors to a file and truncates that file each # time it starts (so that it does not grow without bound). If you # would like to log to a different location, you may specify it here. # log-file: ~/.gertty.log # Gertty listens on a unix domain socket for remote commands at # ~/.gertty.sock. You may change the path here: # socket: ~/.gertty.sock # Gertty uses a lock file per server to prevent multiple processes # from running at the same time. Example: # lock-file: /run/lockme.lock # Gertty comes with two palettes defined internally. The default # palette is suitable for use on a terminal with a dark background. # The "light" palette is for a terminal with a white or light # background. You may customize the colors in either of those # palettes, or define your own palette. # The following alters two colors in the default palette, one color in # the light palette, and one color in a custom palette. If any color # is not defined in a palette, the value from the default palette is # used. The values are a list of at least two elements describing the # colors to be use for the foreground and background colors. # Additional elements can specify (in order) the color to use for # monochrome terminals, the foreground, and background colors to use # in high-color terminals. # For a reference of possible color names, see: # http://urwid.org/manual/displayattributes.html#foreground-and-background-settings # To see the list of possible palette entries, run "gertty --print-palette". palettes: - name: default added-line: ['dark green', ''] added-word: ['light green', ''] - name: light filename: ['dark cyan', ''] - name: custom filename: ['light yellow', ''] # Palettes may be selected at runtime with the "-p PALETTE" command # line option, or you may set the default palette here: # palette: light # Keymaps work the same way as palettes. Two keymaps are defined # internally, the 'default' keymap and the 'vi' keymap. Individual # keys may be overridden and custom keymaps defined and selected in # the config file or the command line. # Each keymap contains a mapping of command -> key(s). If a command # is not specified, Gertty will use the keybinding specified in the # default map. More than one key can be bound to a command. # Run "gertty --print-keymap" for a list of commands that can be # bound. keymaps: - name: default diff: 'd' - name: custom review: ['r', 'R'] - name: osx # OS X blocks ctrl+o change-search: 'ctrl s' # To specify a sequence of keys, they must be a list of keystrokes # within a list of key series. For example: - name: vi quit: [[':', 'q']] # The default keymap may be selected with the '-k KEYMAP' command line # option, or with the following line: # keymap: custom # Commentlinks are regular expressions that are applied to commit and # review messages. They can be replaced with internal or external # links, or have colors applied. commentlinks: # Match Gerrit change ids, and replace them with a link to an internal # Gertty search for that change id. - match: "(?PI[0-9a-fA-F]{40})" replacements: - search: text: "{id}" query: "change:{id}" # Any number of commentlink entries may be specified. Start each with # a "match" key and regex. Named match groups within the regex may be # used in the replacements section. Any number of replacements may be # specified. The types of replacement available are: # # Text: Plain text whose color may be specified. The color references # a palette entry. # - text: # color: "" # text: "" # Link: A hyperlink with the indicated text that when activated will # open the user's browser with the supplied URL # - link: # text: "" # url: "" # Search: A hyperlink that will perform a Gertty search when # activated. # - search: # text: "{id}" # query: "change:{id}" # This is the query used for the list of changes when a project is # selected. The default is "status:open". # change-list-query: "status:open" # This section defines default sorting options for the change # list. The "sort-by" key specifies the sort order, which can be # 'number', 'updated', or 'last-seen'. The 'reverse' key specifies # ascending (true) or descending (false) order. # change-list-options: # sort-by: 'number' # reverse: false # Uncomment the following line to disable the navigation breadcrumbs # at the bottom of the screen: # breadcrumbs: false # Uncomment the following line to use a unified diff view instead # of the default side-by-side: # diff-view: unified # Dependent changes are displayed as "threads" in the change list by # default. To disable this behavior, uncomment the following line: # thread-changes: false # Times are displayed in the local timezone by default. To display # them in UTC instead, uncomment the following line: # display-times-in-utc: true # Gertty handles mouse input by default. If you don't want it messing # with your terminal's mouse handling, uncomment the following line: # handle-mouse: false # Closed changes that are older than two months are removed from the # local database (and their refs are removed from the local git repos # so that git may garbage collect them). If you would like to change # the expiration delay or disable it, uncomment the following line. # The time interval is specified in the same way as the "age:" term in # Gerrit's search syntax. To disable it altogether, set the value to # the empty string. # expire-age: '2 months' # Uncomment the following lines to Hide comments by default that match # certain criteria. You can toggle their display with 't'. Currently # the only supported criterion is "author". # hide-comments: # - author: "^(.*CI|Jenkins)$" # This section defines customized dashboards. You can supply any # Gertty search string and bind them to any key. They will appear in # the global help text, and pressing the key anywhere in Gertty will # run the query and display the results. # # NB: "recentlyseen:24 hours" does not just return changes seen in the # last 24 hours -- it returns changes seen within 24 hours of the most # recently seen change. So you can take the weekend off and pick up # where you were. dashboards: - name: "My changes" query: "owner:self status:open" key: "f2" - name: "Incoming reviews" query: "is:open is:reviewer" key: "f3" - name: "Starred changes" query: "is:starred" key: "f4" - name: "Recently seen changes" query: "recentlyseen:24 hours" sort-by: "last-seen" reverse: True key: "f5" # Reviewkeys are hotkeys that perform immediate reviews within the # change screen. Any pending comments or review messages will be # attached to the review; otherwise an empty review will be left. The # approvals list is exhaustive, so if you specify an empty list, # Gertty will submit a review that clears any previous approvals. To # submit the change with the review, include 'submit: True' with the # reviewkey. Reviewkeys appear in the help text for the change # screen. reviewkeys: - key: 'meta 0' approvals: [] - key: 'meta 1' approvals: - category: 'Code-Review' value: 1 - key: 'meta 2' approvals: - category: 'Code-Review' value: 2 - key: 'meta 3' approvals: - category: 'Code-Review' value: 2 submit: True gertty-1.5.0/CONTRIBUTING.rst0000664000175000017500000000462013137372464016636 0ustar jenkinsjenkins00000000000000Contributing ============ To browse the latest code, see: https://git.openstack.org/cgit/stackforge/gertty/tree/ To clone the latest code, use `git clone git://git.openstack.org/stackforge/gertty` Bugs are handled at: https://storyboard.openstack.org/#!/project/698 Code reviews are handled by gerrit at: https://review.openstack.org Use `git review` to submit patches (after creating a gerrit account that links to your launchpad account). Example:: # Do your commits $ git review # Enter your username if prompted Philosophy ---------- Gertty is based on the following precepts which should inform changes to the program: * Support large numbers of review requests across large numbers of projects. Help the user prioritize those reviews. * Adopt a news/mailreader-like workflow in support of the above. Being able to subscribe to projects, mark reviews as "read" without reviewing, etc, are all useful concepts to support a heavy review load (they have worked extremely well in supporting people who read/write a lot of mail/news). * Support off-line use. Gertty should be completely usable off-line with reliable syncing between local data and Gerrit when a connection is available (just like git or mail or news). * Ample use of color. Unlike a web interface, a good text interface relies mostly on color and precise placement rather than whitespace and decoration to indicate to the user the purpose of a given piece of information. Gertty should degrade well to 16 colors, but more (88 or 256) may be used. * Keyboard navigation (with easy-to-remember commands) should be considered the primary mode of interaction. Mouse interaction should also be supported. * The navigation philosophy is a stack of screens, where each selection pushes a new screen onto the stack, and ESC pops the screen off. This makes sense when drilling down to a change from lists, but also supports linking from change to change (via commit messages or comments) and navigating back intuitive (it matches expectations set by the web browsers). * Support a wide variety of Gerrit installations. The initial development of Gertty is against the OpenStack project's Gerrit, and many of the features are intended to help its developers with their workflow, however, those features should be implemented in a generic way so that the system does not require a specific Gerrit configuration. gertty-1.5.0/LICENSE0000664000175000017500000002645013137372464015207 0ustar jenkinsjenkins00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. gertty-1.5.0/gertty/0000775000175000017500000000000013137372663015512 5ustar jenkinsjenkins00000000000000gertty-1.5.0/gertty/dbsupport.py0000664000175000017500000001550213137372464020110 0ustar jenkinsjenkins00000000000000# Copyright 2014 Mirantis Inc. # Copyright 2014 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import six import uuid from alembic import op import sqlalchemy def sqlite_alter_columns(table_name, column_defs): """Implement alter columns for SQLite. The ALTER COLUMN command isn't supported by SQLite specification. Instead of calling ALTER COLUMN it uses the following workaround: * create temp table '{table_name}_{rand_uuid}', with some column defs replaced; * copy all data to the temp table; * drop old table; * rename temp table to the old table name. """ connection = op.get_bind() meta = sqlalchemy.MetaData(bind=connection) meta.reflect() changed_columns = {} indexes = [] for col in column_defs: # If we are to have an index on the column, don't create it # immediately, instead, add it to a list of indexes to create # after the table rename. if col.index: indexes.append(('ix_%s_%s' % (table_name, col.name), table_name, [col.name], col.unique)) col.unique = False col.index = False changed_columns[col.name] = col # construct lists of all columns and their names old_columns = [] new_columns = [] column_names = [] for column in meta.tables[table_name].columns: column_names.append(column.name) old_columns.append(column) if column.name in changed_columns.keys(): new_columns.append(changed_columns[column.name]) else: col_copy = column.copy() new_columns.append(col_copy) for key in meta.tables[table_name].foreign_keys: constraint = key.constraint con_copy = constraint.copy() new_columns.append(con_copy) for index in meta.tables[table_name].indexes: # If this is a single column index for a changed column, don't # copy it because we may already be creating a new version of # it (or removing it). idx_columns = [col.name for col in index.columns] if len(idx_columns)==1 and idx_columns[0] in changed_columns.keys(): continue # Otherwise, recreate the index. indexes.append((index.name, table_name, [col.name for col in index.columns], index.unique)) # create temp table tmp_table_name = "%s_%s" % (table_name, six.text_type(uuid.uuid4())) op.create_table(tmp_table_name, *new_columns) meta.reflect() try: # copy data from the old table to the temp one sql_select = sqlalchemy.sql.select(old_columns) connection.execute(sqlalchemy.sql.insert(meta.tables[tmp_table_name]) .from_select(column_names, sql_select)) except Exception: op.drop_table(tmp_table_name) raise # drop the old table and rename temp table to the old table name op.drop_table(table_name) op.rename_table(tmp_table_name, table_name) # (re-)create indexes for index in indexes: op.create_index(op.f(index[0]), index[1], index[2], unique=index[3]) def sqlite_drop_columns(table_name, drop_columns): """Implement drop columns for SQLite. The DROP COLUMN command isn't supported by SQLite specification. Instead of calling DROP COLUMN it uses the following workaround: * create temp table '{table_name}_{rand_uuid}', without dropped columns; * copy all data to the temp table; * drop old table; * rename temp table to the old table name. """ connection = op.get_bind() meta = sqlalchemy.MetaData(bind=connection) meta.reflect() # construct lists of all columns and their names old_columns = [] new_columns = [] column_names = [] indexes = [] for column in meta.tables[table_name].columns: if column.name not in drop_columns: old_columns.append(column) column_names.append(column.name) col_copy = column.copy() new_columns.append(col_copy) for key in meta.tables[table_name].foreign_keys: # If this is a single column constraint for a dropped column, # don't copy it. if isinstance(key.constraint.columns, sqlalchemy.sql.base.ColumnCollection): # This is needed for SQLAlchemy >= 1.0.4 columns = [c.name for c in key.constraint.columns] else: # This is needed for SQLAlchemy <= 0.9.9. This is # backwards compat code just in case someone updates # Gertty without updating SQLAlchemy. This is simple # enough to check and will hopefully avoid leaving the # user's db in an inconsistent state. Remove this after # Gertty 1.2.0. columns = key.constraint.columns if (len(columns)==1 and columns[0] in drop_columns): continue # Otherwise, recreate the constraint. constraint = key.constraint con_copy = constraint.copy() new_columns.append(con_copy) for index in meta.tables[table_name].indexes: # If this is a single column index for a dropped column, don't # copy it. idx_columns = [col.name for col in index.columns] if len(idx_columns)==1 and idx_columns[0] in drop_columns: continue # Otherwise, recreate the index. indexes.append((index.name, table_name, [col.name for col in index.columns], index.unique)) # create temp table tmp_table_name = "%s_%s" % (table_name, six.text_type(uuid.uuid4())) op.create_table(tmp_table_name, *new_columns) meta.reflect() try: # copy data from the old table to the temp one sql_select = sqlalchemy.sql.select(old_columns) connection.execute(sqlalchemy.sql.insert(meta.tables[tmp_table_name]) .from_select(column_names, sql_select)) except Exception: op.drop_table(tmp_table_name) raise # drop the old table and rename temp table to the old table name op.drop_table(table_name) op.rename_table(tmp_table_name, table_name) # (re-)create indexes for index in indexes: op.create_index(op.f(index[0]), index[1], index[2], unique=index[3]) gertty-1.5.0/gertty/mywid.py0000664000175000017500000004435713137372464017231 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) class LineBoxTitlePropertyMixin(object): @property def title(self): return self._w.title_widget.text.strip() @title.setter def title(self, text): return self._w.set_title(text) class SystemMessage(urwid.WidgetWrap, LineBoxTitlePropertyMixin): def __init__(self, message): w = urwid.Filler(urwid.Text(message, align='center')) super(SystemMessage, self).__init__(urwid.LineBox(w, u'System Message')) @mouse_scroll_decorator.ScrollByWheel class ButtonDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin): def __init__(self, title, message, entry_prompt=None, entry_text='', buttons=[], ring=None): button_widgets = [] for button in buttons: button_widgets.append(('pack', button)) button_columns = urwid.Columns(button_widgets, dividechars=2) rows = [] rows.append(urwid.Text(message)) if entry_prompt: self.entry = MyEdit(entry_prompt, edit_text=entry_text, ring=ring) rows.append(self.entry) else: self.entry = None rows.append(urwid.Divider()) rows.append(button_columns) listbox = urwid.ListBox(rows) super(ButtonDialog, self).__init__(urwid.LineBox(listbox, title)) class LineEditDialog(ButtonDialog): signals = ['save', 'cancel'] def __init__(self, app, title, message, entry_prompt=None, entry_text='', ring=None): self.app = app save_button = FixedButton('Save') cancel_button = FixedButton('Cancel') urwid.connect_signal(save_button, 'click', lambda button:self._emit('save')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) super(LineEditDialog, self).__init__(title, message, entry_prompt, entry_text, buttons=[save_button, cancel_button], ring=ring) def keypress(self, size, key): if not self.app.input_buffer: key = super(LineEditDialog, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if keymap.ACTIVATE in commands: self._emit('save') return None return key class TextEditDialog(urwid.WidgetWrap, LineBoxTitlePropertyMixin): signals = ['save', 'cancel'] def __init__(self, title, prompt, button, text, ring=None): save_button = FixedButton(button) cancel_button = FixedButton('Cancel') urwid.connect_signal(save_button, 'click', lambda button:self._emit('save')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) button_widgets = [('pack', save_button), ('pack', cancel_button)] button_columns = urwid.Columns(button_widgets, dividechars=2) rows = [] self.entry = MyEdit(edit_text=text, multiline=True, ring=ring) rows.append(urwid.Text(prompt)) rows.append(self.entry) rows.append(urwid.Divider()) rows.append(button_columns) pile = urwid.Pile(rows) fill = urwid.Filler(pile, valign='top') super(TextEditDialog, self).__init__(urwid.LineBox(fill, title)) class MessageDialog(ButtonDialog): signals = ['close'] def __init__(self, title, message): ok_button = FixedButton('OK') urwid.connect_signal(ok_button, 'click', lambda button:self._emit('close')) super(MessageDialog, self).__init__(title, message, buttons=[ok_button]) class YesNoDialog(ButtonDialog): signals = ['yes', 'no'] def __init__(self, title, message): yes_button = FixedButton('Yes') no_button = FixedButton('No') urwid.connect_signal(yes_button, 'click', lambda button:self._emit('yes')) urwid.connect_signal(no_button, 'click', lambda button:self._emit('no')) super(YesNoDialog, self).__init__(title, message, buttons=[yes_button, no_button]) def keypress(self, size, key): r = super(YesNoDialog, self).keypress(size, key) if r in ('Y', 'y'): self._emit('yes') return None if r in ('N', 'n'): self._emit('no') return None return r class SearchableText(urwid.Text): def set_text(self, markup): self._markup = markup super(SearchableText, self).set_text(markup) def search(self, search, attribute): if not search: self.set_text(self._markup) return (text, attrs) = urwid.util.decompose_tagmarkup(self._markup) last = 0 found = False while True: start = text.find(search, last) if start < 0: break found = True end = start + len(search) i = 0 newattrs = [] for attr, al in attrs: if i + al <= start: i += al newattrs.append((attr, al)) continue if i >= end: i += al newattrs.append((attr, al)) continue before = max(start - i, 0) after = max(i + al - end, 0) if before: newattrs.append((attr, before)) newattrs.append((attribute, len(search))) if after: newattrs.append((attr, after)) i += al if i < start: newattrs.append((None, start-i)) i += start-i if i < end: newattrs.append((attribute, len(search))) last = start + 1 attrs = newattrs self._text = text self._attrib = attrs self._invalidate() return found class Searchable(object): def searchInit(self): self.search = None self.results = [] self.current_result = 0 def searchValidChar(self, ch): return urwid.util.is_wide_char(ch, 0) or (len(ch) == 1 and ord(ch) >= 32) def searchKeypress(self, size, key): if self.search is not None: if self.searchValidChar(key) or key == 'backspace': if key == 'backspace': self.search = self.search[:-1] else: self.search += key self.interactiveSearch(self.search) return True else: commands = self.app.config.keymap.getCommands([key]) if keymap.INTERACTIVE_SEARCH in commands: self.nextSearchResult() return True else: self.app.status.update(title=self.title) if not self.search: self.interactiveSearch(None) self.search = None if key in ['enter', 'esc']: return True return False def searchStart(self): self.search = '' self.app.status.update(title=("Search: ")) def interactiveSearch(self, search): if search is not None: self.app.status.update(title=("Search: " + search)) self.results = [] self.current_result = 0 for i, line in enumerate(self.listbox.body): if hasattr(line, 'search'): if line.search(search, 'search-result'): self.results.append(i) def nextSearchResult(self): if not self.results: return dest = self.results[self.current_result] self.listbox.set_focus(dest) self.listbox._invalidate() self.current_result += 1 if self.current_result >= len(self.results): self.current_result = 0 class HyperText(urwid.Text): _selectable = True def __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.5.0/gertty/alembic.ini0000664000175000017500000000212213137372464017603 0ustar jenkinsjenkins00000000000000# A generic, single database configuration. [alembic] # path to migration scripts script_location = alembic # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # max length of characters to apply to the # "slug" field #truncate_slug_length = 40 # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # set to 'true' to allow .pyc and .pyo files without # a source .py file to be detected as revisions in the # versions/ directory # sourceless = false sqlalchemy.url = sqlite:////tmp/gertty.db # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S gertty-1.5.0/gertty/db.py0000664000175000017500000011507613137372464016462 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 six import sqlalchemy from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, DateTime, Text, UniqueConstraint from sqlalchemy.schema import ForeignKey from sqlalchemy.orm import mapper, sessionmaker, relationship, scoped_session, joinedload from sqlalchemy.orm.session import Session from sqlalchemy.sql import exists from sqlalchemy.sql.expression import and_ metadata = MetaData() project_table = Table( 'project', metadata, Column('key', Integer, primary_key=True), Column('name', String(255), index=True, unique=True, nullable=False), Column('subscribed', Boolean, index=True, default=False), Column('description', Text, nullable=False, default=''), Column('updated', DateTime, index=True), ) branch_table = Table( 'branch', metadata, Column('key', Integer, primary_key=True), Column('project_key', Integer, ForeignKey("project.key"), index=True), Column('name', String(255), index=True, nullable=False), ) topic_table = Table( 'topic', metadata, Column('key', Integer, primary_key=True), Column('name', String(255), index=True, nullable=False), Column('sequence', Integer, index=True, unique=True, nullable=False), ) project_topic_table = Table( 'project_topic', metadata, Column('key', Integer, primary_key=True), Column('project_key', Integer, ForeignKey("project.key"), index=True), Column('topic_key', Integer, ForeignKey("topic.key"), index=True), Column('sequence', Integer, nullable=False), UniqueConstraint('topic_key', 'sequence', name='topic_key_sequence_const'), ) change_table = Table( 'change', metadata, Column('key', Integer, primary_key=True), Column('project_key', Integer, ForeignKey("project.key"), index=True), Column('id', String(255), index=True, unique=True, nullable=False), Column('number', Integer, index=True, unique=True, nullable=False), Column('branch', String(255), index=True, nullable=False), Column('change_id', String(255), index=True, nullable=False), Column('topic', String(255), index=True), Column('account_key', Integer, ForeignKey("account.key"), index=True), Column('subject', Text, nullable=False), Column('created', DateTime, index=True, nullable=False), Column('updated', DateTime, index=True, nullable=False), Column('status', String(16), index=True, nullable=False), Column('hidden', Boolean, index=True, nullable=False), Column('reviewed', Boolean, index=True, nullable=False), Column('starred', Boolean, index=True, nullable=False), Column('held', Boolean, index=True, nullable=False), Column('pending_rebase', Boolean, index=True, nullable=False), Column('pending_topic', Boolean, index=True, nullable=False), Column('pending_starred', Boolean, index=True, nullable=False), Column('pending_status', Boolean, index=True, nullable=False), Column('pending_status_message', Text), Column('last_seen', DateTime, index=True), Column('outdated', Boolean, index=True, nullable=False), ) change_conflict_table = Table( 'change_conflict', metadata, Column('key', Integer, primary_key=True), Column('change1_key', Integer, ForeignKey("change.key"), index=True), Column('change2_key', Integer, ForeignKey("change.key"), index=True), ) 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 delete(self): # This unusual delete method is to accomodate the # self-referential many-to-many relationship from # change_conflict. With the default cascade configuration, # the entry from the association table is deleted regardless # of whether the change being deleted is change1 or change2. # However, when both changes are deleted at once (only likely # to happen when the entire project is deleted), SQLAlchemy # cascades through both relationships and attempts to delete # the entry twice. Since we rarely delete projects, we add a # special case here to delete a project's changes one at a # time to avoid this situation. session = Session.object_session(self) for c in self.changes: session.delete(c) session.flush() session.expire_all() session.delete(self) def createChange(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) c = Change(*args, **kw) self.changes.append(c) session.add(c) session.flush() return c def createBranch(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) b = Branch(*args, **kw) self.branches.append(b) session.add(b) session.flush() return b class Branch(object): def __init__(self, project, name): self.project_key = project.key self.name = name class ProjectTopic(object): def __init__(self, project, topic, sequence): self.project_key = project.key self.topic_key = topic.key self.sequence = sequence class Topic(object): def __init__(self, name, sequence): self.name = name self.sequence = sequence def addProject(self, project): session = Session.object_session(self) seq = max([x.sequence for x in self.project_topics] + [0]) pt = ProjectTopic(project, self, seq+1) self.project_topics.append(pt) self.projects.append(project) session.add(pt) session.flush() def removeProject(self, project): session = Session.object_session(self) for pt in self.project_topics: if pt.project_key == project.key: self.project_topics.remove(pt) session.delete(pt) self.projects.remove(project) session.flush() class Change(object): def __init__(self, project, id, owner, number, branch, change_id, subject, created, updated, status, topic=None, hidden=False, reviewed=False, starred=False, held=False, pending_rebase=False, pending_topic=False, pending_starred=False, pending_status=False, pending_status_message=None, outdated=False): self.project_key = project.key self.account_key = owner.key self.id = id self.number = number self.branch = branch self.change_id = change_id self.topic = topic self.subject = subject self.created = created self.updated = updated self.status = status self.hidden = hidden self.reviewed = reviewed self.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 self.outdated = outdated def getCategories(self): categories = set([label.category for label in self.labels]) return sorted(categories) def getMaxForCategory(self, category): if not hasattr(self, '_approval_cache'): self._updateApprovalCache() return self._approval_cache.get(category, 0) def _updateApprovalCache(self): cat_min = {} cat_max = {} cat_value = {} for approval in self.approvals: if approval.draft: continue cur_min = cat_min.get(approval.category, 0) cur_max = cat_max.get(approval.category, 0) cur_min = min(approval.value, cur_min) cur_max = max(approval.value, cur_max) cat_min[approval.category] = cur_min cat_max[approval.category] = cur_max cur_value = cat_value.get(approval.category, 0) if abs(cur_min) > abs(cur_value): cur_value = cur_min if abs(cur_max) > abs(cur_value): cur_value = cur_max cat_value[approval.category] = cur_value self._approval_cache = cat_value def getMinMaxPermittedForCategory(self, category): if not hasattr(self, '_permitted_cache'): self._updatePermittedCache() return self._permitted_cache.get(category, (0,0)) def _updatePermittedCache(self): cache = {} for label in self.labels: if label.category not in cache: cache[label.category] = [0, 0] if label.value > cache[label.category][1]: cache[label.category][1] = label.value if label.value < cache[label.category][0]: cache[label.category][0] = label.value self._permitted_cache = cache def createRevision(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) r = Revision(*args, **kw) self.revisions.append(r) session.add(r) session.flush() return r def createLabel(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) l = Label(*args, **kw) self.labels.append(l) session.add(l) session.flush() return l def createApproval(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) l = Approval(*args, **kw) self.approvals.append(l) session.add(l) session.flush() return l def createPermittedLabel(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) l = PermittedLabel(*args, **kw) self.permitted_labels.append(l) session.add(l) session.flush() return l @property def owner_name(self): owner_name = 'Anonymous Coward' if self.owner: if self.owner.name: owner_name = self.owner.name elif self.owner.username: owner_name = self.owner.username elif self.owner.email: owner_name = self.owner.email return owner_name @property def conflicts(self): return tuple(set(self.conflicts1 + self.conflicts2)) def addConflict(self, other): session = Session.object_session(self) if other in self.conflicts1 or other in self.conflicts2: return if self in other.conflicts1 or self in other.conflicts2: return self.conflicts1.append(other) session.flush() session.expire(other, attribute_names=['conflicts2']) def delConflict(self, other): session = Session.object_session(self) if other in self.conflicts1: self.conflicts1.remove(other) session.flush() session.expire(other, attribute_names=['conflicts2']) if self in other.conflicts1: other.conflicts1.remove(self) session.flush() session.expire(self, attribute_names=['conflicts2']) class Revision(object): def __init__(self, change, number, message, commit, parent, fetch_auth, fetch_ref, pending_message=False, can_submit=False): self.change_key = change.key self.number = number self.message = message self.commit = commit self.parent = parent self.fetch_auth = fetch_auth self.fetch_ref = fetch_ref self.pending_message = pending_message self.can_submit = can_submit def createMessage(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) m = Message(*args, **kw) self.messages.append(m) session.add(m) session.flush() return m def createPendingCherryPick(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) c = PendingCherryPick(*args, **kw) self.pending_cherry_picks.append(c) session.add(c) session.flush() return c def createFile(self, *args, **kw): session = Session.object_session(self) args = [self] + list(args) f = File(*args, **kw) self.files.append(f) session.add(f) session.flush() if hasattr(self, '_file_cache'): self._file_cache[f.path] = f return f def 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'), topics=relationship(Topic, secondary=project_topic_table, order_by=topic_table.c.name, viewonly=True), unreviewed_changes=relationship(Change, primaryjoin=and_(project_table.c.key==change_table.c.project_key, change_table.c.hidden==False, change_table.c.status!='MERGED', change_table.c.status!='ABANDONED', change_table.c.reviewed==False), order_by=change_table.c.number, ), open_changes=relationship(Change, primaryjoin=and_(project_table.c.key==change_table.c.project_key, change_table.c.status!='MERGED', change_table.c.status!='ABANDONED'), order_by=change_table.c.number, ), )) mapper(Branch, branch_table) mapper(Topic, topic_table, properties=dict( projects=relationship(Project, secondary=project_topic_table, order_by=project_table.c.name, viewonly=True), project_topics=relationship(ProjectTopic), )) mapper(ProjectTopic, project_topic_table) mapper(Change, change_table, properties=dict( owner=relationship(Account), conflicts1=relationship(Change, secondary=change_conflict_table, primaryjoin=change_table.c.key==change_conflict_table.c.change1_key, secondaryjoin=change_table.c.key==change_conflict_table.c.change2_key, ), conflicts2=relationship(Change, secondary=change_conflict_table, primaryjoin=change_table.c.key==change_conflict_table.c.change2_key, secondaryjoin=change_table.c.key==change_conflict_table.c.change1_key, ), 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, topicless=False): """Retrieve projects. :param subscribed: If True limit to only subscribed projects. :param unreviewed: If True limit to only projects with unreviewed changes. :param topicless: If True limit to only projects without topics. """ query = self.session().query(Project) if subscribed: query = query.filter_by(subscribed=subscribed) if unreviewed: query = query.filter(exists().where(Project.unreviewed_changes)) if topicless: query = query.filter_by(topics=None) return query.order_by(Project.name).all() def getTopics(self): return self.session().query(Topic).order_by(Topic.sequence).all() def getProject(self, key): try: return self.session().query(Project).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getProjectByName(self, name): try: return self.session().query(Project).filter_by(name=name).one() except sqlalchemy.orm.exc.NoResultFound: return None def getTopic(self, key): try: return self.session().query(Topic).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getTopicByName(self, name): try: return self.session().query(Topic).filter_by(name=name).one() except sqlalchemy.orm.exc.NoResultFound: return None def getSyncQueryByName(self, name): try: return self.session().query(SyncQuery).filter_by(name=name).one() except sqlalchemy.orm.exc.NoResultFound: return self.createSyncQuery(name) def getChange(self, key, lazy=True): query = self.session().query(Change).filter_by(key=key) if not lazy: query = query.options(joinedload(Change.revisions).joinedload(Revision.files).joinedload(File.comments)) try: return query.one() except sqlalchemy.orm.exc.NoResultFound: return None def getChangeByID(self, id): try: return self.session().query(Change).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: return None def getChangeIDs(self, ids): # Returns a set of IDs that exist in the local database matching # the set of supplied IDs. This is used when sync'ing the changesets # locally with the remote changes. if not ids: return set() query = self.session().query(Change.id) return set(ids).intersection(r[0] for r in query.all()) def getChangesByChangeID(self, change_id): try: return self.session().query(Change).filter_by(change_id=change_id) except sqlalchemy.orm.exc.NoResultFound: return None def getChangeByNumber(self, number): try: return self.session().query(Change).filter_by(number=number).one() except sqlalchemy.orm.exc.NoResultFound: return None def getPendingCherryPick(self, key): try: return self.session().query(PendingCherryPick).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getChanges(self, query, unreviewed=False, sort_by='number'): self.database.log.debug("Search query: %s sort: %s" % (query, sort_by)) q = self.session().query(Change).filter(self.search.parse(query)) if not isinstance(sort_by, (list, tuple)): sort_by = [sort_by] if unreviewed: q = q.filter(change_table.c.hidden==False, change_table.c.reviewed==False) for s in sort_by: if s == 'updated': q = q.order_by(change_table.c.updated) elif s == 'last-seen': q = q.order_by(change_table.c.last_seen) elif s == 'number': q = q.order_by(change_table.c.number) elif s == 'project': q = q.filter(project_table.c.key == change_table.c.project_key) q = q.order_by(project_table.c.name) self.database.log.debug("Search SQL: %s" % q) try: return q.all() except sqlalchemy.orm.exc.NoResultFound: return [] def getRevision(self, key): try: return self.session().query(Revision).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getRevisionByCommit(self, commit): try: return self.session().query(Revision).filter_by(commit=commit).one() except sqlalchemy.orm.exc.NoResultFound: return None def getRevisionsByParent(self, parent): if isinstance(parent, six.string_types): parent = (parent,) try: return self.session().query(Revision).filter(Revision.parent.in_(parent)).all() except sqlalchemy.orm.exc.NoResultFound: return [] def getRevisionByNumber(self, change, number): try: return self.session().query(Revision).filter_by(change_key=change.key, number=number).one() except sqlalchemy.orm.exc.NoResultFound: return None def getFile(self, key): try: return self.session().query(File).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getComment(self, key): try: return self.session().query(Comment).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getCommentByID(self, id): try: return self.session().query(Comment).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: return None def getMessage(self, key): try: return self.session().query(Message).filter_by(key=key).one() except sqlalchemy.orm.exc.NoResultFound: return None def getMessageByID(self, id): try: return self.session().query(Message).filter_by(id=id).one() except sqlalchemy.orm.exc.NoResultFound: return None def getHeld(self): return self.session().query(Change).filter_by(held=True).all() def getOutdated(self): return self.session().query(Change).filter_by(outdated=True).all() def getPendingMessages(self): return self.session().query(Message).filter_by(pending=True).all() def getPendingTopics(self): return self.session().query(Change).filter_by(pending_topic=True).all() def 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 def createTopic(self, *args, **kw): o = Topic(*args, **kw) self.session().add(o) self.session().flush() return o gertty-1.5.0/gertty/view/0000775000175000017500000000000013137372663016464 5ustar jenkinsjenkins00000000000000gertty-1.5.0/gertty/view/__init__.py0000664000175000017500000000116213137372464020574 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.5.0/gertty/view/diff.py0000664000175000017500000005165113137372464017755 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, mywid.LineBoxTitlePropertyMixin): signals = ['ok', 'cancel'] def __init__(self, patchsets, old, new): button_widgets = [] ok_button = mywid.FixedButton('OK') cancel_button = mywid.FixedButton('Cancel') urwid.connect_signal(ok_button, 'click', lambda button:self._emit('ok')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) button_widgets.append(('pack', ok_button)) button_widgets.append(('pack', cancel_button)) button_columns = urwid.Columns(button_widgets, dividechars=2) left = [] right = [] left.append(urwid.Text('Old')) right.append(urwid.Text('New')) self.old_buttons = [] self.new_buttons = [] self.patchset_keys = {} oldb = mywid.FixedRadioButton(self.old_buttons, 'Base', state=(old==None)) left.append(oldb) right.append(urwid.Text('')) self.patchset_keys[oldb] = None for key, num in patchsets: oldb = mywid.FixedRadioButton(self.old_buttons, 'Patchset %d' % num, state=(old==key)) newb = mywid.FixedRadioButton(self.new_buttons, 'Patchset %d' % num, state=(new==key)) left.append(oldb) right.append(newb) self.patchset_keys[oldb] = key self.patchset_keys[newb] = key left = urwid.Pile(left) right = urwid.Pile(right) table = urwid.Columns([left, right]) rows = [] rows.append(table) rows.append(urwid.Divider()) rows.append(button_columns) pile = urwid.Pile(rows) fill = urwid.Filler(pile, valign='top') title = 'Patchsets' super(PatchsetDialog, self).__init__(urwid.LineBox(fill, title)) def getSelected(self): old = new = None for b in self.old_buttons: if b.state: old = self.patchset_keys[b] break for b in self.new_buttons: if b.state: new = self.patchset_keys[b] break return old, new class LineContext(object): def __init__(self, old_file_key, new_file_key, old_fn, new_fn, old_ln, new_ln, header=False): self.old_file_key = old_file_key self.new_file_key = new_file_key self.old_fn = old_fn self.new_fn = new_fn self.old_ln = old_ln self.new_ln = new_ln self.header = header class BaseDiffCommentEdit(urwid.Columns): pass class BaseDiffComment(urwid.Columns): pass class BaseDiffLine(urwid.Button): def selectable(self): return True def search(self, search, attribute): pass class BaseFileHeader(urwid.Button): def selectable(self): return True def search(self, search, attribute): pass class BaseFileReminder(urwid.WidgetWrap): pass class DiffContextButton(urwid.WidgetWrap): def selectable(self): return True def __init__(self, view, diff, chunk): focus_map={'context-button':'focused-context-button'} buttons = [mywid.FixedButton(('context-button', "Expand previous 10"), on_press=self.prev), mywid.FixedButton(('context-button', "Expand"), on_press=self.all), mywid.FixedButton(('context-button', "Expand next 10"), on_press=self.next)] self._buttons = buttons buttons = [('pack', urwid.AttrMap(b, None, focus_map=focus_map)) for b in buttons] buttons = urwid.Columns([urwid.Text('')] + buttons + [urwid.Text('')], dividechars=4) buttons = urwid.AttrMap(buttons, 'context-button') super(DiffContextButton, self).__init__(buttons) self.view = view self.diff = diff self.chunk = chunk self.update() def update(self): self._buttons[1].set_label("Expand %s lines of context" % (len(self.chunk.lines)),) def prev(self, button): self.view.expandChunk(self.diff, self.chunk, from_start=10) def all(self, button): self.view.expandChunk(self.diff, self.chunk, expand_all=True) def next(self, button): self.view.expandChunk(self.diff, self.chunk, from_end=-10) @mouse_scroll_decorator.ScrollByWheel class BaseDiffView(urwid.WidgetWrap, mywid.Searchable): def getCommands(self): return [ (keymap.ACTIVATE, "Add an inline comment"), (keymap.SELECT_PATCHSETS, "Select old/new patchsets to diff"), (keymap.INTERACTIVE_SEARCH, "Interactive search"), ] def help(self): key = self.app.config.keymap.formatKeys commands = self.getCommands() return [(c[0], key(c[0]), c[1]) for c in commands] def __init__(self, app, new_revision_key): super(BaseDiffView, self).__init__(urwid.Pile([])) self.log = logging.getLogger('gertty.view.diff') self.app = app self.old_revision_key = None # Base self.new_revision_key = new_revision_key self._init() def _init(self): del self._w.contents[:] self.searchInit() with self.app.db.getSession() as session: new_revision = session.getRevision(self.new_revision_key) old_comments = [] new_comments = [] self.old_file_keys = {} self.new_file_keys = {} if self.old_revision_key is not None: old_revision = session.getRevision(self.old_revision_key) self.old_revision_num = old_revision.number old_str = 'patchset %s' % self.old_revision_num self.base_commit = old_revision.commit for f in old_revision.files: old_comments += f.comments self.old_file_keys[f.path] = f.key show_old_commit = True else: old_revision = None self.old_revision_num = None old_str = 'base' self.base_commit = new_revision.parent show_old_commit = False # The old files are the same as the new files since we # are diffing from base -> change, however, we should # use the old file names for file lookup. for f in new_revision.files: if f.old_path: self.old_file_keys[f.old_path] = f.key else: self.old_file_keys[f.path] = f.key self.title = u'Diff of %s change %s from %s to patchset %s' % ( new_revision.change.project.name, new_revision.change.number, old_str, new_revision.number) self.short_title = u'Diff of %s' % (new_revision.change.number,) self.new_revision_num = new_revision.number self.change_key = new_revision.change.key self.project_name = new_revision.change.project.name self.commit = new_revision.commit for f in new_revision.files: new_comments += f.comments self.new_file_keys[f.path] = f.key comment_lists = {} comment_filenames = set() for comment in new_comments: path = comment.file.path if comment.parent: if old_revision: # we're not looking at the base continue key = 'old' if comment.file.old_path: path = comment.file.old_path else: key = 'new' if comment.draft: key += 'draft' key += '-' + str(comment.line) key += '-' + path comment_list = comment_lists.get(key, []) if comment.draft: message = comment.message else: message = [('comment-name', comment.author.name), ('comment', u': '+comment.message)] comment_list.append((comment.key, message)) comment_lists[key] = comment_list comment_filenames.add(path) for comment in old_comments: if comment.parent: continue path = comment.file.path key = 'old' if comment.draft: key += 'draft' key += '-' + str(comment.line) key += '-' + path comment_list = comment_lists.get(key, []) if comment.draft: message = comment.message else: message = [('comment-name', comment.author.name), ('comment', u': '+comment.message)] comment_list.append((comment.key, message)) comment_lists[key] = comment_list comment_filenames.add(path) repo = gitrepo.get_repo(self.project_name, self.app.config) self._w.contents.append((self.app.header, ('pack', 1))) self.file_reminder = self.makeFileReminder() self._w.contents.append((self.file_reminder, ('pack', 1))) lines = [] # The initial set of lines to display self.file_diffs = [{}, {}] # Mapping of fn -> DiffFile object (old, new) # this is a list of files: diffs = repo.diff(self.base_commit, self.commit, show_old_commit=show_old_commit) for diff in diffs: comment_filenames.discard(diff.oldname) comment_filenames.discard(diff.newname) # There are comments referring to these files which do not # appear in the diff so we should create fake diff objects # that contain the full text. for filename in comment_filenames: diff = repo.getFile(self.base_commit, self.commit, filename) if diff: diffs.append(diff) else: self.log.debug("Unable to find file %s in commit %s" % (filename, self.commit)) for i, diff in enumerate(diffs): if i > 0: lines.append(urwid.Text('')) self.file_diffs[gitrepo.OLD][diff.oldname] = diff self.file_diffs[gitrepo.NEW][diff.newname] = diff lines.extend(self.makeFileHeader(diff, comment_lists)) for chunk in diff.chunks: if chunk.context: if not chunk.first: lines += self.makeLines(diff, chunk.lines[:10], comment_lists) del chunk.lines[:10] button = DiffContextButton(self, diff, chunk) chunk.button = button lines.append(button) if not chunk.last: lines += self.makeLines(diff, chunk.lines[-10:], comment_lists) del chunk.lines[-10:] chunk.calcRange() chunk.button.update() if not chunk.lines: lines.remove(button) else: lines += self.makeLines(diff, chunk.lines, comment_lists) listwalker = urwid.SimpleFocusListWalker(lines) self.listbox = urwid.ListBox(listwalker) self._w.contents.append((self.listbox, ('weight', 1))) self.old_focus = 2 self.draft_comments = [] self._w.set_focus(self.old_focus) self.handleUndisplayedComments(comment_lists) self.app.status.update(title=self.title) def handleUndisplayedComments(self, comment_lists): # Handle comments that landed outside our default diff context lastlen = 0 while comment_lists: if len(comment_lists.keys()) == lastlen: self.log.error("Unable to display all comments: %s" % comment_lists) return lastlen = len(comment_lists.keys()) key = comment_lists.keys()[0] kind, lineno, path = key.split('-', 2) lineno = int(lineno) if kind.startswith('old'): oldnew = gitrepo.OLD else: oldnew = gitrepo.NEW file_diffs = self.file_diffs[oldnew] if path not in file_diffs: self.log.error("Unable to display comment: %s" % key) del comment_lists[key] continue diff = self.file_diffs[oldnew][path] for chunk in diff.chunks: if (chunk.range[oldnew][gitrepo.START] <= lineno and chunk.range[oldnew][gitrepo.END] >= lineno): i = chunk.indexOfLine(oldnew, lineno) if i < (len(chunk.lines) / 2): from_start = True else: from_start = False if chunk.first and from_start: from_start = False if chunk.last and (not from_start): from_start = True if from_start: self.expandChunk(diff, chunk, comment_lists, from_start=i+10) else: self.expandChunk(diff, chunk, comment_lists, from_end=0-(len(chunk.lines)-i)-10) break def expandChunk(self, diff, chunk, comment_lists={}, from_start=None, from_end=None, expand_all=None): self.log.debug("Expand chunk %s %s %s" % (chunk, from_start, from_end)) add_lines = [] if from_start is not None: index = self.listbox.body.index(chunk.button) add_lines = chunk.lines[:from_start] del chunk.lines[:from_start] if from_end is not None: index = self.listbox.body.index(chunk.button)+1 add_lines = chunk.lines[from_end:] del chunk.lines[from_end:] if expand_all: index = self.listbox.body.index(chunk.button) add_lines = chunk.lines[:] del chunk.lines[:] if add_lines: lines = self.makeLines(diff, add_lines, comment_lists) self.listbox.body[index:index] = lines chunk.calcRange() if not chunk.lines: self.listbox.body.remove(chunk.button) else: chunk.button.update() def makeContext(self, diff, old_ln, new_ln, header=False): old_key = None new_key = None if not diff.old_empty: if diff.oldname in self.old_file_keys: old_key = self.old_file_keys[diff.oldname] elif diff.newname in self.old_file_keys: old_key = self.old_file_keys[diff.newname] if not diff.new_empty: new_key = self.new_file_keys.get(diff.newname) return LineContext( old_key, new_key, diff.oldname, diff.newname, old_ln, new_ln, header) def makeLines(self, diff, lines_to_add, comment_lists): raise NotImplementedError def makeFileHeader(self, diff, comment_lists): raise NotImplementedError def makeFileReminder(self): raise NotImplementedError def interested(self, event): if not ((isinstance(event, sync.ChangeAddedEvent) and self.change_key in event.related_change_keys) or (isinstance(event, sync.ChangeUpdatedEvent) and self.change_key in event.related_change_keys)): #self.log.debug("Ignoring refresh diff due to event %s" % (event,)) return False #self.log.debug("Refreshing diff due to event %s" % (event,)) return True def refresh(self, event=None): #TODO pass def getContextAtTop(self, size): middle, top, bottom = self.listbox.calculate_visible(size, True) if top and top[1]: (widget, pos, rows) = top[1][-1] elif middle: pos = middle[2] # Make sure the first header shows up as soon as it scrolls up if pos > 1: pos -= 1 context = None while True: item = self.listbox.body[pos] if hasattr(item, 'context'): break pos -= 1 if pos > 0: context = item.context return context def keypress(self, size, key): if self.searchKeypress(size, key): return None old_focus = self.listbox.focus if not self.app.input_buffer: key = super(BaseDiffView, self).keypress(size, key) new_focus = self.listbox.focus keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) context = self.getContextAtTop(size) if context: self.file_reminder.set(context.old_fn, context.new_fn) else: self.file_reminder.set('', '') if (isinstance(old_focus, BaseDiffCommentEdit) and (old_focus != new_focus or (keymap.PREV_SCREEN in commands))): self.cleanupEdit(old_focus) if keymap.SELECT_PATCHSETS in commands: self.openPatchsetDialog() return None if keymap.INTERACTIVE_SEARCH in commands: self.searchStart() return None return key def mouse_event(self, size, event, button, x, y, focus): old_focus = self.listbox.focus r = super(BaseDiffView, self).mouse_event(size, event, button, x, y, focus) new_focus = self.listbox.focus if old_focus != new_focus and isinstance(old_focus, BaseDiffCommentEdit): self.cleanupEdit(old_focus) return r def makeCommentEdit(self, edit): raise NotImplementedError def onSelect(self, button): pos = self.listbox.focus_position e = self.makeCommentEdit(self.listbox.body[pos]) self.listbox.body.insert(pos+1, e) self.listbox.focus_position = pos+1 def cleanupEdit(self, edit): raise NotImplementedError def deleteComment(self, comment_key): with self.app.db.getSession() as session: comment = session.getComment(comment_key) session.delete(comment) def saveComment(self, context, text, new=True): if (not new) and (not self.old_revision_num): parent = True else: parent = False if new: line_num = context.new_ln file_key = context.new_file_key else: line_num = context.old_ln file_key = context.old_file_key if file_key is None: raise Exception("Comment is not associated with a file") with self.app.db.getSession() as session: fileojb = session.getFile(file_key) account = session.getAccountByUsername(self.app.config.username) comment = fileojb.createComment(None, account, None, datetime.datetime.utcnow(), parent, line_num, text, draft=True) key = comment.key return key def openPatchsetDialog(self): revisions = [] with self.app.db.getSession() as session: change = session.getChange(self.change_key) for r in change.revisions: revisions.append((r.key, r.number)) dialog = PatchsetDialog(revisions, self.old_revision_key, self.new_revision_key) urwid.connect_signal(dialog, 'cancel', lambda button: self.app.backScreen()) urwid.connect_signal(dialog, 'ok', lambda button: self._openPatchsetDialog(dialog)) self.app.popup(dialog, min_width=30, min_height=8) def _openPatchsetDialog(self, dialog): self.app.backScreen() self.old_revision_key, self.new_revision_key = dialog.getSelected() self._init() gertty-1.5.0/gertty/view/side_diff.py0000664000175000017500000002420513137372464020754 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.5.0/gertty/view/project_list.py0000664000175000017500000005427313137372464021551 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 mywid from gertty import sync from gertty.view import change_list as view_change_list from gertty.view import mouse_scroll_decorator class TopicSelectDialog(urwid.WidgetWrap): signals = ['ok', 'cancel'] def __init__(self, title, topics): 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) self.topic_buttons = [] self.topic_keys = {} rows = [] for key, name in topics: button = mywid.FixedRadioButton(self.topic_buttons, name) self.topic_keys[button] = key rows.append(button) rows.append(urwid.Divider()) rows.append(button_columns) pile = urwid.Pile(rows) fill = urwid.Filler(pile, valign='top') super(TopicSelectDialog, self).__init__(urwid.LineBox(fill, title)) def getSelected(self): for b in self.topic_buttons: if b.state: return self.topic_keys[b] return None class ProjectRow(urwid.Button): project_focus_map = {None: 'focused', 'unreviewed-project': 'focused-unreviewed-project', 'subscribed-project': 'focused-subscribed-project', 'unsubscribed-project': 'focused-unsubscribed-project', 'marked-project': 'focused-marked-project', } def selectable(self): return True def _setName(self, name, indent): self.project_name = name name = indent+name if self.mark: name = '%'+name else: name = ' '+name self.name.set_text(name) def __init__(self, app, project, topic, callback=None): super(ProjectRow, self).__init__('', on_press=callback, user_data=(project.key, project.name)) self.app = app self.mark = False self._style = None self.project_key = project.key if topic: self.topic_key = topic.key self.indent = ' ' else: self.topic_key = None self.indent = '' self.project_name = project.name self.name = mywid.SearchableText('') self._setName(project.name, self.indent) self.name.set_wrap_mode('clip') self.unreviewed_changes = urwid.Text(u'', align=urwid.RIGHT) self.open_changes = urwid.Text(u'', align=urwid.RIGHT) col = urwid.Columns([ self.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 search(self, search, attribute): return self.name.search(search, attribute) def update(self, project): cache = self.app.project_cache.get(project) if project.subscribed: if cache['unreviewed_changes'] > 0: style = 'unreviewed-project' else: style = 'subscribed-project' else: style = 'unsubscribed-project' self._style = style if self.mark: style = 'marked-project' self.row_style.set_attr_map({None: style}) self.unreviewed_changes.set_text('%i ' % cache['unreviewed_changes']) self.open_changes.set_text('%i ' % cache['open_changes']) def toggleMark(self): self.mark = not self.mark if self.mark: style = 'marked-project' else: style = self._style self.row_style.set_attr_map({None: style}) self._setName(self.project_name, self.indent) class TopicRow(urwid.Button): project_focus_map = {None: 'focused', 'subscribed-project': 'focused-subscribed-project', 'marked-project': 'focused-marked-project', } def selectable(self): return True def _setName(self, name): self.topic_name = name name = '[[ '+name+' ]]' if self.mark: name = '%'+name else: name = ' '+name self.name.set_text(name) def __init__(self, topic, callback=None): super(TopicRow, self).__init__('', on_press=callback, user_data=(topic.key, topic.name)) self.mark = False self._style = None self.topic_key = topic.key self.name = urwid.Text('') self._setName(topic.name) self.name.set_wrap_mode('clip') self.unreviewed_changes = urwid.Text(u'', align=urwid.RIGHT) self.open_changes = urwid.Text(u'', align=urwid.RIGHT) col = urwid.Columns([ self.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._style = 'subscribed-project' self.row_style.set_attr_map({None: self._style}) self.update(topic) def update(self, topic, unreviewed_changes=None, open_changes=None): self._setName(topic.name) if unreviewed_changes is None: self.unreviewed_changes.set_text('') else: self.unreviewed_changes.set_text('%i ' % unreviewed_changes) if open_changes is None: self.open_changes.set_text('') else: self.open_changes.set_text('%i ' % open_changes) def toggleMark(self): self.mark = not self.mark if self.mark: style = 'marked-project' else: style = self._style self.row_style.set_attr_map({None: style}) self._setName(self.topic_name) 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, mywid.Searchable): def getCommands(self): return [ (keymap.TOGGLE_LIST_SUBSCRIBED, "Toggle whether only subscribed projects or all projects are listed"), (keymap.TOGGLE_LIST_REVIEWED, "Toggle listing of projects with unreviewed changes"), (keymap.TOGGLE_SUBSCRIBED, "Toggle the subscription flag for the selected project"), (keymap.REFRESH, "Sync subscribed projects"), (keymap.TOGGLE_MARK, "Toggle the process mark for the selected project"), (keymap.NEW_PROJECT_TOPIC, "Create project topic"), (keymap.DELETE_PROJECT_TOPIC, "Delete selected project topic"), (keymap.MOVE_PROJECT_TOPIC, "Move selected project to topic"), (keymap.COPY_PROJECT_TOPIC, "Copy selected project to topic"), (keymap.REMOVE_PROJECT_TOPIC, "Remove selected project from topic"), (keymap.RENAME_PROJECT_TOPIC, "Rename selected project topic"), (keymap.INTERACTIVE_SEARCH, "Interactive search"), ] def help(self): key = self.app.config.keymap.formatKeys commands = self.getCommands() return [(c[0], key(c[0]), c[1]) for c in commands] def __init__(self, app): super(ProjectListView, self).__init__(urwid.Pile([])) self.log = logging.getLogger('gertty.view.project_list') self.searchInit() self.app = app self.unreviewed = True self.subscribed = True self.project_rows = {} self.topic_rows = {} self.open_topics = set() 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 advance(self): pos = self.listbox.focus_position if pos < len(self.listbox.body)-1: pos += 1 self.listbox.focus_position = pos def _deleteRow(self, row): if row in self.listbox.body: self.listbox.body.remove(row) if isinstance(row, ProjectRow): del self.project_rows[(row.topic_key, row.project_key)] else: del self.topic_rows[row.topic_key] def _projectRow(self, i, project, topic): # Ensure that the row at i is the given project. If the row # already exists somewhere in the list, delete all rows # between i and the row and then update the row. If the row # does not exist, insert the row at position i. topic_key = topic and topic.key or None key = (topic_key, project.key) row = self.project_rows.get(key) while row: # This is "if row: while True:". if i >= len(self.listbox.body): break current_row = self.listbox.body[i] if (isinstance(current_row, ProjectRow) and current_row.project_key == project.key): break self._deleteRow(current_row) if not row: row = ProjectRow(self.app, project, topic, self.onSelect) self.listbox.body.insert(i, row) self.project_rows[key] = row else: row.update(project) return i+1 def _topicRow(self, i, topic): row = self.topic_rows.get(topic.key) while row: # This is "if row: while True:". if i >= len(self.listbox.body): break current_row = self.listbox.body[i] if (isinstance(current_row, TopicRow) and current_row.topic_key == topic.key): break self._deleteRow(current_row) if not row: row = TopicRow(topic, self.onSelectTopic) self.listbox.body.insert(i, row) self.topic_rows[topic.key] = row else: row.update(topic) return i + 1 def refresh(self): if self.subscribed: self.title = u'Subscribed projects' self.short_title = self.title[:] if self.unreviewed: self.title += u' with unreviewed changes' else: self.title = u'All projects' self.short_title = self.title[:] self.app.status.update(title=self.title) with self.app.db.getSession() as session: i = 0 for project in session.getProjects(topicless=True, subscribed=self.subscribed, unreviewed=self.unreviewed): #self.log.debug("project: %s" % project.name) i = self._projectRow(i, project, None) for topic in session.getTopics(): #self.log.debug("topic: %s" % topic.name) i = self._topicRow(i, topic) topic_unreviewed = 0 topic_open = 0 for project in topic.projects: #self.log.debug(" project: %s" % project.name) cache = self.app.project_cache.get(project) topic_unreviewed += cache['unreviewed_changes'] topic_open += cache['open_changes'] if self.subscribed: if not project.subscribed: continue if self.unreviewed and not cache['unreviewed_changes']: continue if topic.key in self.open_topics: i = self._projectRow(i, project, topic) topic_row = self.topic_rows.get(topic.key) topic_row.update(topic, topic_unreviewed, topic_open) while i < len(self.listbox.body): current_row = self.listbox.body[i] self._deleteRow(current_row) 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 onSelectTopic(self, button, data): topic_key = data[0] self.open_topics ^= set([topic_key]) self.refresh() def toggleMark(self): if not len(self.listbox.body): return pos = self.listbox.focus_position row = self.listbox.body[pos] row.toggleMark() self.advance() def createTopic(self): dialog = mywid.LineEditDialog(self.app, 'Topic', 'Create a new topic.', 'Topic: ', '', self.app.ring) urwid.connect_signal(dialog, 'save', lambda button: self.closeCreateTopic(dialog, True)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeCreateTopic(dialog, False)) self.app.popup(dialog) def closeCreateTopic(self, dialog, save): if save: last_topic_key = None for row in self.listbox.body: if isinstance(row, TopicRow): last_topic_key = row.topic_key with self.app.db.getSession() as session: if last_topic_key: last_topic = session.getTopic(last_topic_key) seq = last_topic.sequence + 1 else: seq = 0 t = session.createTopic(dialog.entry.edit_text, seq) self.app.backScreen() def deleteTopic(self): rows = self.getSelectedRows(TopicRow) if not rows: return with self.app.db.getSession() as session: for row in rows: topic = session.getTopic(row.topic_key) session.delete(topic) self.refresh() def renameTopic(self): pos = self.listbox.focus_position row = self.listbox.body[pos] if not isinstance(row, TopicRow): return with self.app.db.getSession() as session: topic = session.getTopic(row.topic_key) name = topic.name key = topic.key dialog = mywid.LineEditDialog(self.app, 'Topic', 'Rename a new topic.', 'Topic: ', name, self.app.ring) urwid.connect_signal(dialog, 'save', lambda button: self.closeRenameTopic(dialog, True, key)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeRenameTopic(dialog, False, key)) self.app.popup(dialog) def closeRenameTopic(self, dialog, save, key): if save: with self.app.db.getSession() as session: topic = session.getTopic(key) topic.name = dialog.entry.edit_text self.app.backScreen() def getSelectedRows(self, cls): ret = [] for row in self.listbox.body: if isinstance(row, cls) and row.mark: ret.append(row) if ret: return ret pos = self.listbox.focus_position row = self.listbox.body[pos] if isinstance(row, cls): return [row] return [] def copyMoveToTopic(self, move): if move: verb = 'Move' else: verb = 'Copy' rows = self.getSelectedRows(ProjectRow) if not rows: return with self.app.db.getSession() as session: topics = [(t.key, t.name) for t in session.getTopics()] dialog = TopicSelectDialog('%s to Topic' % verb, topics) urwid.connect_signal(dialog, 'ok', lambda button: self.closeCopyMoveToTopic(dialog, True, rows, move)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeCopyMoveToTopic(dialog, False, rows, move)) self.app.popup(dialog) def closeCopyMoveToTopic(self, dialog, save, rows, move): error = None if save: with self.app.db.getSession() as session: key = dialog.getSelected() new_topic = session.getTopic(key) if not new_topic: error = "Unable to find topic %s" % topic_name else: for row in rows: project = session.getProject(row.project_key) if move and row.topic_key: old_topic = session.getTopic(row.topic_key) self.log.debug("Remove %s from %s" % (project, old_topic)) old_topic.removeProject(project) self.log.debug("Add %s to %s" % (project, new_topic)) new_topic.addProject(project) self.app.backScreen() if error: self.app.error(error) def moveToTopic(self): self.copyMoveToTopic(True) def copyToTopic(self): self.copyMoveToTopic(False) def removeFromTopic(self): rows = self.getSelectedRows(ProjectRow) rows = [r for r in rows if r.topic_key] if not rows: return with self.app.db.getSession() as session: for row in rows: project = session.getProject(row.project_key) topic = session.getTopic(row.topic_key) self.log.debug("Remove %s from %s" % (project, topic)) topic.removeProject(project) self.refresh() def toggleSubscribed(self): rows = self.getSelectedRows(ProjectRow) if not rows: return keys = [row.project_key for row in rows] subscribed_keys = [] with self.app.db.getSession() as session: for key in keys: project = session.getProject(key) project.subscribed = not project.subscribed if project.subscribed: subscribed_keys.append(key) for row in rows: if row.mark: row.toggleMark() for key in subscribed_keys: self.app.sync.submitTask(sync.SyncProjectTask(key)) self.refresh() def keypress(self, size, key): if self.searchKeypress(size, key): return None 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: self.toggleSubscribed() return True if keymap.TOGGLE_MARK in commands: self.toggleMark() return True if keymap.NEW_PROJECT_TOPIC in commands: self.createTopic() return True if keymap.DELETE_PROJECT_TOPIC in commands: self.deleteTopic() return True if keymap.COPY_PROJECT_TOPIC in commands: self.copyToTopic() return True if keymap.MOVE_PROJECT_TOPIC in commands: self.moveToTopic() return True if keymap.REMOVE_PROJECT_TOPIC in commands: self.removeFromTopic() return True if keymap.RENAME_PROJECT_TOPIC in commands: self.renameTopic() return True if keymap.REFRESH in commands: self.app.sync.submitTask( sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY)) self.app.status.update() self.refresh() return True if keymap.INTERACTIVE_SEARCH in commands: self.searchStart() return True return False gertty-1.5.0/gertty/view/mouse_scroll_decorator.py0000664000175000017500000000235613137372464023613 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.5.0/gertty/view/unified_diff.py0000664000175000017500000002717413137372464021463 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 = [] old_cache = [] new_cache = [] for old, new in lines_to_add: context = self.makeContext(diff, old[0], new[0]) if context.old_ln is not None: old_cache.append(UnifiedDiffLine(self.app, context, gitrepo.OLD, old, new, callback=self.onSelect)) else: lines.extend(old_cache) lines.extend(new_cache) old_cache = [] new_cache = [] # see if there are any comments for this line key = 'old-%s-%s' % (old[0], diff.oldname) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) old_cache.append(UnifiedDiffComment(context, gitrepo.OLD, old_comment)) # see if there are any draft comments for this line key = 'olddraft-%s-%s' % (old[0], diff.oldname) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) old_cache.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.OLD, old_comment_key, old_comment)) # new line if context.new_ln is not None and new[1] != ' ': if old_cache: new_cache.append(UnifiedDiffLine(self.app, context, gitrepo.NEW, old, new, callback=self.onSelect)) else: lines.append(UnifiedDiffLine(self.app, context, gitrepo.NEW, old, new, callback=self.onSelect)) # see if there are any comments for this line key = 'new-%s-%s' % (new[0], diff.newname) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) if old_cache: new_cache.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment)) else: lines.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment)) # see if there are any draft comments for this line key = 'newdraft-%s-%s' % (new[0], diff.newname) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) if old_cache: new_cache.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.NEW, new_comment_key, new_comment)) else: lines.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.NEW, new_comment_key, new_comment)) else: if old_cache: lines.extend(old_cache) if new_cache: lines.extend(new_cache) return lines def makeFileReminder(self): return UnifiedFileReminder() def makeFileHeader(self, diff, comment_lists): context = self.makeContext(diff, None, None, header=True) lines = [] lines.append(UnifiedFileHeader(self.app, context, gitrepo.OLD, diff.oldname, diff.newname, callback=self.onSelect)) # see if there are any comments for this file key = 'old-None-%s' % (diff.oldname,) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) lines.append(UnifiedDiffComment(context, gitrepo.OLD, old_comment)) # see if there are any draft comments for this file key = 'olddraft-None-%s' % (diff.oldname,) old_list = comment_lists.pop(key, []) while old_list: (old_comment_key, old_comment) = old_list.pop(0) lines.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.OLD, old_comment_key, old_comment)) # new line lines.append(UnifiedFileHeader(self.app, context, gitrepo.NEW, diff.oldname, diff.newname, callback=self.onSelect)) # see if there are any comments for this file key = 'new-None-%s' % (diff.newname,) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(UnifiedDiffComment(context, gitrepo.NEW, new_comment)) # see if there are any draft comments for this file key = 'newdraft-None-%s' % (diff.newname,) new_list = comment_lists.pop(key, []) while new_list: (new_comment_key, new_comment) = new_list.pop(0) lines.append(UnifiedDiffCommentEdit(self.app, context, gitrepo.NEW, new_comment_key, new_comment)) return lines def makeCommentEdit(self, edit): return UnifiedDiffCommentEdit(self.app, edit.context, edit.oldnew) def cleanupEdit(self, edit): if edit.key: self.deleteComment(edit.key) edit.key = None comment = edit.comment.edit_text.strip() if comment: new = False if edit.oldnew == gitrepo.NEW: new = True edit.key = self.saveComment( edit.context, comment, new=new) else: self.listbox.body.remove(edit) gertty-1.5.0/gertty/view/change.py0000664000175000017500000014572613137372464020301 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 datetime import logging try: import ordereddict except: pass import textwrap from six.moves.urllib import parse as 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 try: OrderedDict = collections.OrderedDict except AttributeError: OrderedDict = ordereddict.OrderedDict 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, mywid.LineBoxTitlePropertyMixin): 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, mywid.LineBoxTitlePropertyMixin): signals = ['submit', 'save', 'cancel'] def __init__(self, app, revision_key, message=''): 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 = {} 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 = {} prior_approvals = {} for approval in change.approvals: if approval.reviewer.username == self.app.config.username: if approval.draft: draft_approvals[approval.category] = approval else: prior_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 = prior_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(u"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, message=''): self.dialog = ReviewDialog(self.change_view.app, self.revision_row.revision_key, message=message) 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(urwid.Button): 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, change_view, message): super(ChangeMessageBox, self).__init__(u'') self.change_view = change_view self.app = change_view.app self.refresh(message) def formatReply(self): text = self.message_text pgraphs = [] pgraph_accumulator = [] wrap = True for line in text.split('\n')[2:]: if line.startswith('> '): wrap = False line = '> ' + line if not line: if pgraph_accumulator: pgraphs.append((wrap, '\n'.join(pgraph_accumulator))) pgraph_accumulator = [] wrap = True continue pgraph_accumulator.append(line) if pgraph_accumulator: pgraphs.append((wrap, '\n'.join(pgraph_accumulator))) pgraph_accumulator = [] wrap = True wrapper = textwrap.TextWrapper(initial_indent='> ', subsequent_indent='> ') wrapped_pgraphs = [] for wrap, pgraph in pgraphs: if wrap: wrapped_pgraphs.append('\n'.join(wrapper.wrap(pgraph))) else: wrapped_pgraphs.append(pgraph) return '\n>\n'.join(wrapped_pgraphs) def reply(self): reply_text = self.formatReply() if reply_text: reply_text = self.message_author + ' wrote:\n\n' + reply_text + '\n' row = self.change_view.revision_rows[self.revision_key] row.review_button.openReview(reply_text) def refresh(self, message): self.revision_key = message.revision.key self.message_created = message.created self.message_author = message.author_name self.message_text = message.message 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' reviewer_string = message.author.name else: name_style = 'change-message-name' header_style = 'change-message-header' if message.author.email: reviewer_string = "%s <%s>" % ( message.author.name, message.author.email) else: reviewer_string = message.author.name text = [(name_style, reviewer_string), (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)')) else: link = mywid.Link('< Reply >', 'revision-button', 'focused-revision-button') urwid.connect_signal(link, 'selected', lambda link:self.reply()) text.append(' ') text.append(link) 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 getCommands(self): return [ (keymap.LOCAL_CHECKOUT, "Checkout the most recent revision into the local repo"), (keymap.DIFF, "Show the diff of the most recent revision"), (keymap.TOGGLE_HIDDEN, "Toggle the hidden flag for the current change"), (keymap.NEXT_CHANGE, "Go to the next change in the list"), (keymap.PREV_CHANGE, "Go to the previous change in the list"), (keymap.REVIEW, "Leave a review for the most recent revision"), (keymap.TOGGLE_HELD, "Toggle the held flag for the current change"), (keymap.TOGGLE_HIDDEN_COMMENTS, "Toggle display of hidden comments"), (keymap.SEARCH_RESULTS, "Back to the list of changes"), (keymap.TOGGLE_REVIEWED, "Toggle the reviewed flag for the current change"), (keymap.TOGGLE_STARRED, "Toggle the starred flag for the current change"), (keymap.LOCAL_CHERRY_PICK, "Cherry-pick the most recent revision onto the local repo"), (keymap.ABANDON_CHANGE, "Abandon this change"), (keymap.EDIT_COMMIT_MESSAGE, "Edit the commit message of this change"), (keymap.REBASE_CHANGE, "Rebase this change (remotely)"), (keymap.RESTORE_CHANGE, "Restore this change"), (keymap.REFRESH, "Refresh this change"), (keymap.EDIT_TOPIC, "Edit the topic of this change"), (keymap.SUBMIT_CHANGE, "Submit this change"), (keymap.CHERRY_PICK_CHANGE, "Propose this change to another branch"), ] def help(self): key = self.app.config.keymap.formatKeys commands = self.getCommands() ret = [(c[0], key(c[0]), c[1]) for c in commands] for k in self.app.config.reviewkeys.values(): action = ', '.join(['{category}:{value}'.format(**a) for a in k['approvals']]) ret.append(('', keymap.formatKey(k['key']), action)) return ret def __init__(self, app, 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.marked_seen = False 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.conflicts_with = urwid.Pile([]) self.conflicts_with_rows = {} self.related_changes = urwid.Pile([self.depends_on, self.needed_by, self.conflicts_with]) 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(0) 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, lazy=False) # When we first open the change, update its last_seen # time. if not self.marked_seen: change.last_seen = datetime.datetime.utcnow() self.marked_seen = True 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)) if change.owner.email: owner_string = '%s <%s>' % (change.owner_name, change.owner.email) else: owner_string = change.owner_name self.owner_label.text.set_text(('change-data', owner_string)) 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, OrderedDict()) 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, 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]' if parent.change.status == 'ABANDONED': subject += ' [ABANDONED]' 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.change.status != 'ABANDONED' and r == r.change.revisions[-1])) self._updateDependenciesWidget(children, self.needed_by, self.needed_by_rows, header='Needed by:') # Handle conflicts_with conflicts = {} conflicts.update((c.key, c.subject) for c in change.conflicts if (c.status != 'MERGED' and c.status != 'ABANDONED')) self._updateDependenciesWidget(conflicts, self.conflicts_with, self.conflicts_with_rows, header='Conflicts with:') def toggleReviewed(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.reviewed = not change.reviewed self.app.project_cache.clear(change.project) def toggleHidden(self): with self.app.db.getSession() as session: change = session.getChange(self.change_key) change.hidden = not change.hidden self.app.project_cache.clear(change.project) 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.5.0/gertty/view/change_list.py0000664000175000017500000010235113137372464021317 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 six import urwid from gertty import keymap from gertty import mywid from gertty import sync from gertty.view import change as view_change from gertty.view import mouse_scroll_decorator import gertty.view class ColumnInfo(object): def __init__(self, name, packing, value): self.name = name self.packing = packing self.value = value self.options = (packing, value) if packing == 'given': self.spacing = value + 1 else: self.spacing = (value * 8) + 1 COLUMNS = [ ColumnInfo('Number', 'given', 6), ColumnInfo('Subject', 'weight', 4), ColumnInfo('Project', 'weight', 1), ColumnInfo('Branch', 'weight', 1), ColumnInfo('Topic', 'weight', 1), ColumnInfo('Owner', 'weight', 1), ColumnInfo('Updated', 'given', 10), ColumnInfo('Size', 'given', 4), ] class ThreadStack(object): def __init__(self): self.stack = [] def push(self, change, children): self.stack.append([change, children]) def pop(self): while self.stack: if self.stack[-1][1]: # handle children at the tip return self.stack[-1][1].pop(0) else: # current tip has no children, walk up self.stack.pop() continue return None def countChildren(self): return [len(x[1]) for x in self.stack] class ChangeListColumns(object): def updateColumns(self): del self.columns.contents[:] cols = self.columns.contents options = self.columns.options for colinfo in COLUMNS: if colinfo.name in self.enabled_columns: attr = colinfo.name.lower().replace(' ', '_') cols.append((getattr(self, attr), options(*colinfo.options))) for c in self.category_columns: cols.append(c) class ChangeRow(urwid.Button, ChangeListColumns): change_focus_map = {None: 'focused', 'unreviewed-change': 'focused-unreviewed-change', 'reviewed-change': 'focused-reviewed-change', 'starred-change': 'focused-starred-change', 'held-change': 'focused-held-change', 'marked-change': 'focused-marked-change', 'positive-label': 'focused-positive-label', 'negative-label': 'focused-negative-label', 'min-label': 'focused-min-label', 'max-label': 'focused-max-label', 'added-graph': 'focused-added-graph', 'removed-graph': 'focused-removed-graph', } def selectable(self): return True def __init__(self, app, change, prefix, categories, enabled_columns, callback=None): super(ChangeRow, self).__init__('', on_press=callback, user_data=change.key) self.app = app self.change_key = change.key self.prefix = prefix self.enabled_columns = enabled_columns self.subject = mywid.SearchableText(u'', wrap='clip') self.number = mywid.SearchableText(u'') self.updated = mywid.SearchableText(u'') self.size = mywid.SearchableText(u'', align='right') self.project = mywid.SearchableText(u'', wrap='clip') self.owner = mywid.SearchableText(u'', wrap='clip') self.branch = mywid.SearchableText(u'', wrap='clip') self.topic = mywid.SearchableText(u'', wrap='clip') self.mark = False self.columns = urwid.Columns([], dividechars=1) self.row_style = urwid.AttrMap(self.columns, '') self._w = urwid.AttrMap(self.row_style, None, focus_map=self.change_focus_map) self.category_columns = [] self.update(change, categories) def search(self, search, attribute): if self.subject.search(search, attribute): return True if self.number.search(search, attribute): return True if self.project.search(search, attribute): return True if self.branch.search(search, attribute): return True if self.owner.search(search, attribute): return True if self.topic.search(search, attribute): return True if self.updated.search(search, attribute): return True return False def _makeSize(self, added, removed): # Removed is a red graph on top, added is a green graph on bottom. # # The graph is 4 cells wide. If both the red and green graphs # are in the cell, we set the bg to red, fg to green, and set # a box in the bottom half of the cell. # # If only one of the graphs is in the cell, we set a box in # either the top or bottom of the cell, and set the fg color # appropriately. This is so that the reverse-video which # operates on the line when focused works as expected. lower_box = u'\u2584' upper_box = u'\u2580' ret = [] # The graph is logarithmic -- one cell for each order of # magnitude. for threshold in [1, 10, 100, 1000]: color = [] if (added > threshold and removed > threshold): ret.append(('added-removed-graph', lower_box)) elif (added > threshold): ret.append(('added-graph', lower_box)) elif (removed > threshold): ret.append(('removed-graph', upper_box)) else: ret.append(' ') return ret def update(self, change, categories): if change.reviewed or change.hidden: style = 'reviewed-change' else: style = 'unreviewed-change' subject = '%s%s' % (self.prefix, change.subject) flag = ' ' if change.starred: flag = '*' style = 'starred-change' if change.held: flag = '!' style = 'held-change' if self.mark: flag = '%' style = 'marked-change' subject = flag + subject self.row_style.set_attr_map({None: style}) self.subject.set_text(subject) self.number.set_text(str(change.number)) self.project.set_text(change.project.name.split('/')[-1]) self.owner.set_text(change.owner_name) self.branch.set_text(change.branch or '') self.topic.set_text(change.topic or '') self.project_name = change.project.name self.commit_sha = change.revisions[-1].commit self.current_revision_key = change.revisions[-1].key today = self.app.time(datetime.datetime.utcnow()).date() updated_time = self.app.time(change.updated) if today == updated_time.date(): self.updated.set_text(updated_time.strftime("%I:%M %p").upper()) else: self.updated.set_text(updated_time.strftime("%Y-%m-%d")) total_added = 0 total_removed = 0 for rfile in change.revisions[-1].files: if rfile.status is None: continue total_added += rfile.inserted or 0 total_removed += rfile.deleted or 0 self.size.set_text(self._makeSize(total_added, total_removed)) self.category_columns = [] for category in categories: v = change.getMaxForCategory(category) cat_min, cat_max = change.getMinMaxPermittedForCategory(category) if v == 0: val = '' elif v > 0: val = '%2i' % v if v == cat_max: val = ('max-label', val) else: val = ('positive-label', val) else: val = '%i' % v if v == cat_min: val = ('min-label', val) else: val = ('negative-label', val) self.category_columns.append((urwid.Text(val), self.columns.options('given', 2))) self.updateColumns() class ChangeListHeader(urwid.WidgetWrap, ChangeListColumns): def __init__(self, enabled_columns): self.enabled_columns = enabled_columns self.subject = urwid.Text(u'Subject', wrap='clip') self.number = urwid.Text(u'Number') self.updated = urwid.Text(u'Updated') self.size = urwid.Text(u'Size') self.project = urwid.Text(u'Project', wrap='clip') self.owner = urwid.Text(u'Owner', wrap='clip') self.branch = urwid.Text(u'Branch', wrap='clip') self.topic = urwid.Text(u'Topic', wrap='clip') self.columns = urwid.Columns([], dividechars=1) self.category_columns = [] super(ChangeListHeader, self).__init__(self.columns) def update(self, categories): self.category_columns = [] for category in categories: self.category_columns.append((urwid.Text(' %s' % category[0]), self._w.options('given', 2))) self.updateColumns() @mouse_scroll_decorator.ScrollByWheel class ChangeListView(urwid.WidgetWrap, mywid.Searchable): required_columns = set(['Number', 'Subject', 'Updated']) optional_columns = set(['Topic', 'Branch', 'Size']) def getCommands(self): if self.project_key: refresh_help = "Sync current project" else: refresh_help = "Sync subscribed projects" return [ (keymap.TOGGLE_HELD, "Toggle the held flag for the currently selected change"), (keymap.LOCAL_CHECKOUT, "Checkout the most recent revision of the selected change into the local repo"), (keymap.TOGGLE_HIDDEN, "Toggle the hidden flag for the currently selected change"), (keymap.TOGGLE_LIST_REVIEWED, "Toggle whether only unreviewed or all changes are displayed"), (keymap.TOGGLE_REVIEWED, "Toggle the reviewed flag for the currently selected change"), (keymap.TOGGLE_STARRED, "Toggle the starred flag for the currently selected change"), (keymap.TOGGLE_MARK, "Toggle the process mark for the currently selected change"), (keymap.REFINE_CHANGE_SEARCH, "Refine the current search query"), (keymap.ABANDON_CHANGE, "Abandon the marked changes"), (keymap.EDIT_TOPIC, "Set the topic of the marked changes"), (keymap.RESTORE_CHANGE, "Restore the marked changes"), (keymap.REFRESH, refresh_help), (keymap.REVIEW, "Leave reviews for the marked changes"), (keymap.SORT_BY_NUMBER, "Sort changes by number"), (keymap.SORT_BY_UPDATED, "Sort changes by how recently the change was updated"), (keymap.SORT_BY_REVERSE, "Reverse the sort"), (keymap.LOCAL_CHERRY_PICK, "Cherry-pick the most recent revision of the selected change onto the local repo"), (keymap.INTERACTIVE_SEARCH, "Interactive search"), ] def help(self): key = self.app.config.keymap.formatKeys commands = self.getCommands() return [(c[0], key(c[0]), c[1]) for c in commands] def __init__(self, app, query, query_desc=None, project_key=None, unreviewed=False, sort_by=None, reverse=None): super(ChangeListView, self).__init__(urwid.Pile([])) self.log = logging.getLogger('gertty.view.change_list') self.searchInit() self.app = app self.query = query self.query_desc = query_desc or query self.unreviewed = unreviewed self.change_rows = {} self.enabled_columns = set() for colinfo in COLUMNS: if (colinfo.name in self.required_columns or colinfo.name not in self.optional_columns): self.enabled_columns.add(colinfo.name) self.disabled_columns = set() self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([])) self.project_key = project_key if 'Project' not in self.required_columns and project_key is not None: self.enabled_columns.discard('Project') self.disabled_columns.add('Project') if 'Owner' not in self.required_columns and 'owner:' in query: # This could be or'd with something else, but probably # not. self.enabled_columns.discard('Owner') self.disabled_columns.add('Owner') self.sort_by = sort_by or app.config.change_list_options['sort-by'] if reverse is not None: self.reverse = reverse else: self.reverse = app.config.change_list_options['reverse'] self.header = ChangeListHeader(self.enabled_columns) self.categories = [] self.refresh() self._w.contents.append((app.header, ('pack', 1))) self._w.contents.append((urwid.Divider(), ('pack', 1))) self._w.contents.append((urwid.AttrWrap(self.header, 'table-header'), ('pack', 1))) self._w.contents.append((self.listbox, ('weight', 1))) self._w.set_focus(3) def interested(self, event): if not ((self.project_key is not None and isinstance(event, sync.ChangeAddedEvent) and self.project_key == event.project_key) or (self.project_key is None and isinstance(event, sync.ChangeAddedEvent)) or (isinstance(event, sync.ChangeUpdatedEvent) and event.change_key in self.change_rows.keys())): self.log.debug("Ignoring refresh change list due to event %s" % (event,)) return False self.log.debug("Refreshing change list due to event %s" % (event,)) return True def refresh(self): unseen_keys = set(self.change_rows.keys()) with self.app.db.getSession() as session: change_list = session.getChanges(self.query, self.unreviewed, sort_by=self.sort_by) if self.unreviewed: self.title = (u'Unreviewed %d changes in %s' % (len(change_list), self.query_desc)) else: self.title = (u'All %d changes in %s' % (len(change_list), self.query_desc)) self.short_title = self.query_desc if '/' in self.short_title and ' ' not in self.short_title: i = self.short_title.rfind('/') self.short_title = self.short_title[i+1:] self.app.status.update(title=self.title) categories = set() for change in change_list: categories |= set(change.getCategories()) self.categories = sorted(categories) self.chooseColumns() self.header.update(self.categories) i = 0 if self.reverse: change_list.reverse() if self.app.config.thread_changes: change_list, prefixes = self._threadChanges(change_list) else: prefixes = {} new_rows = [] if len(self.listbox.body): focus_pos = self.listbox.focus_position focus_row = self.listbox.body[focus_pos] else: focus_pos = 0 focus_row = None for change in change_list: row = self.change_rows.get(change.key) if not row: row = ChangeRow(self.app, change, prefixes.get(change.key), self.categories, self.enabled_columns, callback=self.onSelect) self.listbox.body.insert(i, row) self.change_rows[change.key] = row else: row.update(change, self.categories) unseen_keys.remove(change.key) new_rows.append(row) i += 1 self.listbox.body[:] = new_rows if focus_row in self.listbox.body: pos = self.listbox.body.index(focus_row) else: pos = min(focus_pos, len(self.listbox.body)-1) self.listbox.body.set_focus(pos) for key in unseen_keys: row = self.change_rows[key] del self.change_rows[key] def chooseColumns(self): currently_enabled_columns = self.enabled_columns.copy() size = self.app.loop.screen.get_cols_rows() cols = size[0] for colinfo in COLUMNS: if (colinfo.name not in self.disabled_columns): cols -= colinfo.spacing cols -= 3 * len(self.categories) for colinfo in COLUMNS: if colinfo.name in self.optional_columns: if cols >= colinfo.spacing: self.enabled_columns.add(colinfo.name) cols -= colinfo.spacing else: self.enabled_columns.discard(colinfo.name) if currently_enabled_columns != self.enabled_columns: self.header.updateColumns() for key, value in six.iteritems(self.change_rows): value.updateColumns() def getQueryString(self): if self.project_key is not None: return "project:%s %s" % (self.query_desc, self.app.config.project_change_list_query) return self.query def _threadChanges(self, changes): ret = [] prefixes = {} stack = ThreadStack() children = {} commits = {} orphans = changes[:] for change in changes: for revision in change.revisions: commits[revision.commit] = change for change in changes: revision = change.revisions[-1] parent = commits.get(revision.parent, None) if parent: if parent.revisions[-1].commit != revision.parent: # Our parent is an outdated revision. This could # cause a cycle, so skip. This change will not # appear in the thread, but will still appear in # the list. TODO: use color to indicate it # depends on an outdated change. continue if change in orphans: orphans.remove(change) v = children.get(parent, []) v.append(change) children[parent] = v if orphans: change = orphans.pop(0) else: change = None while change: prefix = '' stack_children = stack.countChildren() for i, nchildren in enumerate(stack_children): if nchildren: if i+1 == len(stack_children): prefix += u'\u251c' else: prefix += u'\u2502' else: if i+1 == len(stack_children): prefix += u'\u2514' else: prefix += u' ' if i+1 == len(stack_children): prefix += u'\u2500' else: prefix += u' ' subject = '%s%s' % (prefix, change.subject) change._subject = subject prefixes[change.key] = prefix ret.append(change) if change in children: stack.push(change, children[change]) change = stack.pop() if (not change) and orphans: change = orphans.pop(0) assert len(ret) == len(changes) return (ret, prefixes) def clearChangeList(self): for key, value in six.iteritems(self.change_rows): self.listbox.body.remove(value) self.change_rows = {} def getNextChangeKey(self, change_key): row = self.change_rows.get(change_key) try: i = self.listbox.body.index(row) except ValueError: return None if i+1 >= len(self.listbox.body): return None row = self.listbox.body[i+1] return row.change_key def getPrevChangeKey(self, change_key): row = self.change_rows.get(change_key) try: i = self.listbox.body.index(row) except ValueError: return None if i <= 0: return None row = self.listbox.body[i-1] return row.change_key def toggleReviewed(self, change_key): with self.app.db.getSession() as session: change = session.getChange(change_key) change.reviewed = not change.reviewed self.app.project_cache.clear(change.project) ret = change.reviewed reviewed_str = 'reviewed' if change.reviewed else 'unreviewed' self.log.debug("Set change %s to %s", change_key, reviewed_str) return ret def toggleStarred(self, change_key): with self.app.db.getSession() as session: change = session.getChange(change_key) change.starred = not change.starred ret = change.starred change.pending_starred = True self.app.sync.submitTask( sync.ChangeStarredTask(change_key, sync.HIGH_PRIORITY)) return ret def toggleHeld(self, change_key): return self.app.toggleHeldChange(change_key) def toggleHidden(self, change_key): with self.app.db.getSession() as session: change = session.getChange(change_key) change.hidden = not change.hidden ret = change.hidden hidden_str = 'hidden' if change.hidden else 'visible' self.log.debug("Set change %s to %s", change_key, hidden_str) return ret def advance(self): pos = self.listbox.focus_position if pos < len(self.listbox.body)-1: pos += 1 self.listbox.focus_position = pos def keypress(self, size, key): if self.searchKeypress(size, key): return None if not self.app.input_buffer: key = super(ChangeListView, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) ret = self.handleCommands(commands) if ret is True: if keymap.FURTHER_INPUT not in commands: self.app.clearInputBuffer() return None return key def onResize(self): self.chooseColumns() def handleCommands(self, commands): if keymap.TOGGLE_LIST_REVIEWED in commands: self.unreviewed = not self.unreviewed self.refresh() return True if keymap.TOGGLE_REVIEWED in commands: if not len(self.listbox.body): return True pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key reviewed = self.toggleReviewed(change_key) if self.unreviewed and reviewed: # Here we can avoid a full refresh by just removing the particular # row from the change list if the view is for the unreviewed changes # only. row = self.change_rows[change_key] self.listbox.body.remove(row) del self.change_rows[change_key] else: # Just fall back on doing a full refresh if we're in a situation # where we're not just popping a row from the list of unreviewed # changes. self.refresh() self.advance() return True if keymap.TOGGLE_HIDDEN in commands: if not len(self.listbox.body): return True pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key hidden = self.toggleHidden(change_key) if hidden: # Here we can avoid a full refresh by just removing the particular # row from the change list row = self.change_rows[change_key] self.listbox.body.remove(row) del self.change_rows[change_key] else: # Just fall back on doing a full refresh if we're in a situation # where we're not just popping a row from the list of changes. self.refresh() self.advance() return True if keymap.TOGGLE_HELD in commands: if not len(self.listbox.body): return True pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key self.toggleHeld(change_key) row = self.change_rows[change_key] with self.app.db.getSession() as session: change = session.getChange(change_key) row.update(change, self.categories) self.advance() return True if keymap.TOGGLE_STARRED in commands: if not len(self.listbox.body): return True pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key self.toggleStarred(change_key) row = self.change_rows[change_key] with self.app.db.getSession() as session: change = session.getChange(change_key) row.update(change, self.categories) self.advance() return True if keymap.TOGGLE_MARK in commands: if not len(self.listbox.body): return True pos = self.listbox.focus_position change_key = self.listbox.body[pos].change_key row = self.change_rows[change_key] row.mark = not row.mark with self.app.db.getSession() as session: change = session.getChange(change_key) row.update(change, self.categories) self.advance() return True if keymap.EDIT_TOPIC in commands: self.editTopic() return True if keymap.REFRESH in commands: if self.project_key: self.app.sync.submitTask( sync.SyncProjectTask(self.project_key, sync.HIGH_PRIORITY)) else: self.app.sync.submitTask( sync.SyncSubscribedProjectsTask(sync.HIGH_PRIORITY)) self.app.status.update() return True if keymap.REVIEW in commands: rows = [row for row in self.change_rows.values() if row.mark] if not rows: pos = self.listbox.focus_position rows = [self.listbox.body[pos]] self.openReview(rows) return True if keymap.SORT_BY_NUMBER in commands: if not len(self.listbox.body): return True self.sort_by = 'number' self.clearChangeList() self.refresh() return True if keymap.SORT_BY_UPDATED in commands: if not len(self.listbox.body): return True self.sort_by = 'updated' self.clearChangeList() self.refresh() return True if keymap.SORT_BY_REVERSE in commands: if not len(self.listbox.body): return True if self.reverse: self.reverse = False else: self.reverse = True self.clearChangeList() self.refresh() return True if keymap.LOCAL_CHECKOUT in commands: if not len(self.listbox.body): return True pos = self.listbox.focus_position row = self.listbox.body[pos] self.app.localCheckoutCommit(row.project_name, row.commit_sha) return True if keymap.LOCAL_CHERRY_PICK in commands: if not len(self.listbox.body): return True pos = self.listbox.focus_position row = self.listbox.body[pos] self.app.localCherryPickCommit(row.project_name, row.commit_sha) return True if keymap.REFINE_CHANGE_SEARCH in commands: default = self.getQueryString() self.app.searchDialog(default) return True if keymap.ABANDON_CHANGE in commands: self.abandonChange() return True if keymap.RESTORE_CHANGE in commands: self.restoreChange() return True if keymap.INTERACTIVE_SEARCH in commands: self.searchStart() return True return False def onSelect(self, button, change_key): try: view = view_change.ChangeView(self.app, change_key) self.app.changeScreen(view) except gertty.view.DisplayError as e: self.app.error(str(e)) def openReview(self, rows): dialog = view_change.ReviewDialog(self.app, rows[0].current_revision_key) urwid.connect_signal(dialog, 'save', lambda button: self.closeReview(dialog, rows, True, False)) urwid.connect_signal(dialog, 'submit', lambda button: self.closeReview(dialog, rows, True, True)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeReview(dialog, rows, False, False)) self.app.popup(dialog, relative_width=50, relative_height=75, min_width=60, min_height=20) def closeReview(self, dialog, rows, upload, submit): approvals, message = dialog.getValues() revision_keys = [row.current_revision_key for row in rows] message_keys = self.app.saveReviews(revision_keys, approvals, message, upload, submit) if upload: for message_key in message_keys: self.app.sync.submitTask( sync.UploadReviewTask(message_key, sync.HIGH_PRIORITY)) self.refresh() self.app.backScreen() def editTopic(self): dialog = view_change.EditTopicDialog(self.app, '') urwid.connect_signal(dialog, 'save', lambda button: self.closeEditTopic(dialog, True)) urwid.connect_signal(dialog, 'cancel', lambda button: self.closeEditTopic(dialog, False)) self.app.popup(dialog) def closeEditTopic(self, dialog, save): if save: rows = [row for row in self.change_rows.values() if row.mark] if not rows: pos = self.listbox.focus_position rows = [self.listbox.body[pos]] change_keys = [row.change_key for row in rows] with self.app.db.getSession() as session: for change_key in change_keys: change = session.getChange(change_key) change.topic = dialog.entry.edit_text change.pending_topic = True self.app.sync.submitTask( sync.SetTopicTask(change_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() def abandonChange(self): dialog = mywid.TextEditDialog(u'Abandon Change', u'Abandon message:', u'Abandon Change', u'') urwid.connect_signal(dialog, 'cancel', self.app.backScreen) urwid.connect_signal(dialog, 'save', lambda button: self.doAbandonRestoreChange(dialog, 'ABANDONED')) self.app.popup(dialog) def restoreChange(self): dialog = mywid.TextEditDialog(u'Restore Change', u'Restore message:', u'Restore Change', u'') urwid.connect_signal(dialog, 'cancel', self.app.backScreen) urwid.connect_signal(dialog, 'save', lambda button: self.doAbandonRestoreChange(dialog, 'NEW')) self.app.popup(dialog) def doAbandonRestoreChange(self, dialog, state): rows = [row for row in self.change_rows.values() if row.mark] if not rows: pos = self.listbox.focus_position rows = [self.listbox.body[pos]] change_keys = [row.change_key for row in rows] with self.app.db.getSession() as session: for change_key in change_keys: change = session.getChange(change_key) change.status = state change.pending_status = True change.pending_status_message = dialog.entry.edit_text self.app.sync.submitTask( sync.ChangeStatusTask(change_key, sync.HIGH_PRIORITY)) self.app.backScreen() self.refresh() gertty-1.5.0/gertty/keymap.py0000664000175000017500000002033713137372464017356 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_LAST_SEEN = 'sort by last seen' SORT_BY_REVERSE = 'reverse the sort' # Project list screen: TOGGLE_LIST_REVIEWED = 'toggle list reviewed' TOGGLE_LIST_SUBSCRIBED = 'toggle list subscribed' TOGGLE_SUBSCRIBED = 'toggle subscribed' NEW_PROJECT_TOPIC = 'new project topic' DELETE_PROJECT_TOPIC = 'delete project topic' MOVE_PROJECT_TOPIC = 'move to project topic' COPY_PROJECT_TOPIC = 'copy to project topic' REMOVE_PROJECT_TOPIC = 'remove from project topic' RENAME_PROJECT_TOPIC = 'rename project topic' # Diff screens: SELECT_PATCHSETS = 'select patchsets' NEXT_SELECTABLE = 'next selectable' PREV_SELECTABLE = 'prev selectable' 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: [['S', 'n']], SORT_BY_UPDATED: [['S', 'u']], SORT_BY_LAST_SEEN: [['S', 's']], SORT_BY_REVERSE: [['S', 'r']], TOGGLE_LIST_REVIEWED: 'l', TOGGLE_LIST_SUBSCRIBED: 'L', TOGGLE_SUBSCRIBED: 's', NEW_PROJECT_TOPIC: [['T', 'n']], DELETE_PROJECT_TOPIC: [['T', 'delete']], MOVE_PROJECT_TOPIC: [['T', 'm']], COPY_PROJECT_TOPIC: [['T', 'c']], REMOVE_PROJECT_TOPIC: [['T', 'D']], RENAME_PROJECT_TOPIC: [['T', 'r']], SELECT_PATCHSETS: 'p', NEXT_SELECTABLE: 'tab', PREV_SELECTABLE: 'shift tab', INTERACTIVE_SEARCH: 'ctrl s', } # Hi vi users! Add more things here! This overrides the default # keymap, so anything not defined here will just use what's defined # above. VI_KEYMAP = { QUIT: [[':', 'q']], CURSOR_LEFT: 'h', CURSOR_DOWN: 'j', CURSOR_UP: 'k', CURSOR_RIGHT: 'l', } URWID_COMMANDS = frozenset(( urwid.REDRAW_SCREEN, urwid.CURSOR_UP, urwid.CURSOR_DOWN, urwid.CURSOR_LEFT, urwid.CURSOR_RIGHT, urwid.CURSOR_PAGE_UP, urwid.CURSOR_PAGE_DOWN, urwid.CURSOR_MAX_LEFT, urwid.CURSOR_MAX_RIGHT, urwid.ACTIVATE, KILL, YANK, YANK_POP, )) FORMAT_SUBS = ( (re.compile('ctrl '), 'CTRL-'), (re.compile('meta '), 'META-'), (re.compile('f(\d+)'), 'F\\1'), (re.compile('([a-z][a-z]+)'), lambda x: x.group(1).upper()), ) def formatKey(key): if type(key) == type([]): return ''.join([formatKey(k) for k in key]) for subre, repl in FORMAT_SUBS: key = subre.sub(repl, key) return key class Key(object): def __init__(self, key): self.key = key self.keys = {} self.commands = [] def addKey(self, key): if key not in self.keys: self.keys[key] = Key(key) return self.keys[key] def __repr__(self): return '%s %s %s' % (self.__class__.__name__, self.key, self.keys.keys()) class KeyMap(object): def __init__(self, config): # key -> [commands] self.keytree = Key(None) self.commandmap = {} self.multikeys = '' self.update(DEFAULT_KEYMAP) self.update(config) def update(self, config): # command -> [keys] for command, keys in config.items(): if command == 'name': continue command = command.replace('-', ' ') if type(keys) != type([]): keys = [keys] self.commandmap[command] = keys self.keytree = Key(None) for command, keys in self.commandmap.items(): for key in keys: if isinstance(key, list): # This is a command series tree = self.keytree for i, innerkey in enumerate(key): tree = tree.addKey(innerkey) if i+1 == len(key): tree.commands.append(command) else: tree = self.keytree.addKey(key) tree.commands.append(command) def getCommands(self, keys): if not keys: return [] tree = self.keytree for key in keys: tree = tree.keys.get(key) if not tree: return [] ret = tree.commands[:] if tree.keys: ret.append(FURTHER_INPUT) return ret def getFurtherCommands(self, keys): if not keys: return [] tree = self.keytree for key in keys: tree = tree.keys.get(key) if not tree: return [] return self._getFurtherCommands('', tree) def _getFurtherCommands(self, keys, tree): if keys: ret = [(formatKey(keys), tree.commands[:])] else: ret = [] for subtree in tree.keys.values(): ret.extend(self._getFurtherCommands(keys + subtree.key, subtree)) return ret def getKeys(self, command): return self.commandmap.get(command, []) def updateCommandMap(self): "Update the urwid command map with this keymap" for key in self.keytree.keys.values(): for command in key.commands: if command in URWID_COMMANDS: urwid.command_map[key.key]=command def formatKeys(self, command): keys = self.getKeys(command) keys = [formatKey(k) for k in keys] return ' or '.join(keys) gertty-1.5.0/gertty/__init__.py0000664000175000017500000000000013137372464017610 0ustar jenkinsjenkins00000000000000gertty-1.5.0/gertty/app.py0000664000175000017500000010702513137372465016651 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 fcntl import functools import logging import os import re import socket import subprocess import sys import textwrap import threading import warnings import webbrowser import six from six.moves import queue from six.moves.urllib import parse as urlparse import sqlalchemy.exc import urwid from gertty import db from gertty import config from gertty import gitrepo from gertty import keymap from gertty import mywid from gertty import palette from gertty import sync from gertty import search from gertty import requestsexceptions from gertty.view import change_list as view_change_list from gertty.view import project_list as view_project_list from gertty.view import change as view_change import gertty.view import gertty.version WELCOME_TEXT = """\ Welcome to Gertty! To get started, you should subscribe to some projects. Press the "L" key (shift-L) to list all the projects, navigate to the ones you are interested in, and then press "s" to subscribe to them. Gertty will automatically sync changes in your subscribed projects. Press the F1 key anywhere to get help. Your terminal emulator may require you to press function-F1 or alt-F1 instead. """ class StatusHeader(urwid.WidgetWrap): def __init__(self, app): super(StatusHeader, self).__init__(urwid.Columns([])) self.app = app self.title_widget = urwid.Text(u'Start') self.error_widget = urwid.Text('') self.offline_widget = urwid.Text('') self.sync_widget = urwid.Text(u'Sync: 0') self.held_widget = urwid.Text(u'') self._w.contents.append((self.title_widget, ('pack', None, False))) self._w.contents.append((urwid.Text(u''), ('weight', 1, False))) self._w.contents.append((self.held_widget, ('pack', None, False))) self._w.contents.append((self.error_widget, ('pack', None, False))) self._w.contents.append((self.offline_widget, ('pack', None, False))) self._w.contents.append((self.sync_widget, ('pack', None, False))) self.error = None self.offline = None self.title = None self.message = None self.sync = None self.held = None self._error = False self._offline = False self._title = '' self._message = '' self._sync = 0 self._held = 0 self.held_key = self.app.config.keymap.formatKeys(keymap.LIST_HELD) def update(self, title=None, message=None, error=None, offline=None, refresh=True, held=None): if title is not None: self.title = title if message is not None: self.message = message if error is not None: self.error = error if offline is not None: self.offline = offline if held is not None: self.held = held self.sync = self.app.sync.queue.qsize() if refresh: self.refresh() def refresh(self): if (self._title != self.title or self._message != self.message): self._title = self.title self._message = self.message t = self.message or self.title self.title_widget.set_text(t) if self._held != self.held: self._held = self.held if self._held: self.held_widget.set_text(('error', u'Held: %s (%s)' % (self._held, self.held_key))) else: self.held_widget.set_text(u'') if self._error != self.error: self._error = self.error if self._error: self.error_widget.set_text(('error', u' Error')) else: self.error_widget.set_text(u'') if self._offline != self.offline: self._offline = self.offline if self._offline: self.offline_widget.set_text(u' Offline') else: self.offline_widget.set_text(u'') if self._sync != self.sync: self._sync = self.sync self.sync_widget.set_text(u' Sync: %i' % self._sync) class BreadCrumbBar(urwid.WidgetWrap): BREADCRUMB_SYMBOL = u'\N{BLACK RIGHT-POINTING SMALL TRIANGLE}' BREADCRUMB_WIDTH = 25 def __init__(self): self.prefix_text = urwid.Text(u' \N{WATCH} ') self.breadcrumbs = urwid.Columns([], dividechars=3) self.display_widget = urwid.Columns( [('pack', self.prefix_text), self.breadcrumbs]) super(BreadCrumbBar, self).__init__(self.display_widget) def _get_breadcrumb_text(self, screen): title = getattr(screen, 'short_title', None) if not title: title = getattr(screen, 'title', str(screen)) text = "%s %s" % (BreadCrumbBar.BREADCRUMB_SYMBOL, title) if len(text) > 23: text = "%s..." % text[:20] return urwid.Text(text, wrap='clip') def _get_breadcrumb_column_options(self): return self.breadcrumbs.options("given", BreadCrumbBar.BREADCRUMB_WIDTH) def _update(self, screens): breadcrumb_contents = [] for screen in screens: breadcrumb_contents.append(( self._get_breadcrumb_text(screen), self._get_breadcrumb_column_options())) self.breadcrumbs.contents = breadcrumb_contents # Update focus so we always have the right end of the breadcrumb trail # in view. Urwid will gracefully handle clipping from the left when # there is overflow.as trail grows, shrinks, or screen is resized. if len(self.breadcrumbs.contents): self.breadcrumbs.focus_position = len(self.breadcrumbs.contents) - 1 class SearchDialog(mywid.ButtonDialog): signals = ['search', 'cancel'] def __init__(self, app, default): self.app = app search_button = mywid.FixedButton('Search') cancel_button = mywid.FixedButton('Cancel') urwid.connect_signal(search_button, 'click', lambda button:self._emit('search')) urwid.connect_signal(cancel_button, 'click', lambda button:self._emit('cancel')) super(SearchDialog, self).__init__("Search", "Enter a change number or search string.", entry_prompt="Search: ", entry_text=default, buttons=[search_button, cancel_button], ring=app.ring) def keypress(self, size, key): if not self.app.input_buffer: key = super(SearchDialog, self).keypress(size, key) keys = self.app.input_buffer + [key] commands = self.app.config.keymap.getCommands(keys) if keymap.ACTIVATE in commands: self._emit('search') return None return key # From: cpython/file/2.7/Lib/webbrowser.py with modification to # redirect stdin/out/err. class BackgroundBrowser(webbrowser.GenericBrowser): """Class for all browsers which are to be started in the background.""" def open(self, url, new=0, autoraise=True): cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] inout = open(os.devnull, "r+") try: if sys.platform[:3] == 'win': p = subprocess.Popen(cmdline) else: setsid = getattr(os, 'setsid', None) if not setsid: setsid = getattr(os, 'setpgrp', None) p = subprocess.Popen(cmdline, close_fds=True, stdin=inout, stdout=inout, stderr=inout, preexec_fn=setsid) return (p.poll() is None) except OSError: return False class ProjectCache(object): def __init__(self): self.projects = {} def get(self, project): if project.key not in self.projects: self.projects[project.key] = dict( unreviewed_changes = len(project.unreviewed_changes), open_changes = len(project.open_changes), ) return self.projects[project.key] def clear(self, project): if project.key in self.projects: del self.projects[project.key] class App(object): simple_change_search = re.compile('^(\d+|I[a-fA-F0-9]{40})$') def __init__(self, server=None, palette='default', keymap='default', debug=False, verbose=False, disable_sync=False, disable_background_sync=False, fetch_missing_refs=False, path=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.lock_fd = open(self.config.lock_file, 'w') try: fcntl.lockf(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except IOError: print("error: another instance of gertty is running for: %s" % self.config.server['name']) sys.exit(1) self.project_cache = ProjectCache() self.ring = mywid.KillRing() self.input_buffer = [] webbrowser.register('xdg-open', None, BackgroundBrowser("xdg-open")) self.fetch_missing_refs = fetch_missing_refs self.config.keymap.updateCommandMap() self.search = search.SearchCompiler(self.config.username) self.db = db.Database(self, self.config.dburi, self.search) self.sync = sync.Sync(self, disable_background_sync) self.status = StatusHeader(self) self.header = urwid.AttrMap(self.status, 'header') self.screens = urwid.MonitoredList() self.breadcrumbs = BreadCrumbBar() self.screens.set_modified_callback( functools.partial(self.breadcrumbs._update, self.screens)) if self.config.breadcrumbs: self.footer = urwid.AttrMap(self.breadcrumbs, 'footer') else: self.footer = None screen = view_project_list.ProjectListView(self) self.status.update(title=screen.title) self.updateStatusQueries() self.frame = urwid.Frame(body=screen, footer=self.footer) self.loop = urwid.MainLoop(self.frame, palette=self.config.palette.getPalette(), handle_mouse=self.config.handle_mouse, unhandled_input=self.unhandledInput, input_filter=self.inputFilter) self.sync_pipe = self.loop.watch_pipe(self.refresh) self.error_queue = queue.Queue() self.error_pipe = self.loop.watch_pipe(self._errorPipeInput) self.logged_warnings = set() self.command_pipe = self.loop.watch_pipe(self._commandPipeInput) self.command_queue = queue.Queue() warnings.showwarning = self._showWarning has_subscribed_projects = False with self.db.getSession() as session: if session.getProjects(subscribed=True): has_subscribed_projects = True if not has_subscribed_projects: self.welcome() self.loop.screen.tty_signal_keys(start='undefined', stop='undefined') #self.loop.screen.set_terminal_properties(colors=88) self.startSocketListener() if not disable_sync: self.sync_thread = threading.Thread(target=self.sync.run, args=(self.sync_pipe,)) self.sync_thread.daemon = True self.sync_thread.start() else: self.sync_thread = None self.sync.offline = True self.status.update(offline=True) def run(self): try: self.loop.run() except KeyboardInterrupt: pass def _quit(self, widget=None): raise urwid.ExitMainLoop() def quit(self): dialog = mywid.YesNoDialog(u'Quit', u'Are you sure you want to quit?') urwid.connect_signal(dialog, 'no', self.backScreen) urwid.connect_signal(dialog, 'yes', self._quit) self.popup(dialog) def startSocketListener(self): if os.path.exists(self.config.socket_path): os.unlink(self.config.socket_path) self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.socket.bind(self.config.socket_path) self.socket.listen(1) self.socket_thread = threading.Thread(target=self._socketListener) self.socket_thread.daemon = True self.socket_thread.start() def _socketListener(self): while True: try: s, addr = self.socket.accept() self.log.debug("Accepted socket connection %s" % (s,)) buf = '' while True: buf += s.recv(1) if buf[-1] == '\n': break buf = buf.strip() self.log.debug("Received %s from socket" % (buf,)) s.close() parts = buf.split() self.command_queue.put((parts[0], parts[1:])) os.write(self.command_pipe, six.b('command\n')) except Exception: self.log.exception("Exception in socket handler") def clearInputBuffer(self): if self.input_buffer: self.input_buffer = [] self.status.update(message='') def changeScreen(self, widget, push=True): self.log.debug("Changing screen to %s" % (widget,)) self.status.update(error=False, title=widget.title) if push: self.screens.append(self.frame.body) self.clearInputBuffer() self.frame.body = widget def backScreen(self, target_widget=None): if not self.screens: return while self.screens: widget = self.screens.pop() if (not target_widget) or (widget is target_widget): break self.log.debug("Popping screen to %s" % (widget,)) if hasattr(widget, 'title'): self.status.update(title=widget.title) self.clearInputBuffer() self.frame.body = widget self.refresh(force=True) def findChangeList(self): for widget in reversed(self.screens): if isinstance(widget, view_change_list.ChangeListView): return widget return None def clearHistory(self): self.log.debug("Clearing screen history") while self.screens: widget = self.screens.pop() self.clearInputBuffer() self.frame.body = widget def refresh(self, data=None, force=False): widget = self.frame.body while isinstance(widget, urwid.Overlay): widget = widget.contents[0][0] interested = force invalidate = False try: while True: event = self.sync.result_queue.get(0) if widget.interested(event): interested = True if hasattr(event, 'held_changed') and event.held_changed: invalidate = True except queue.Empty: pass if interested: widget.refresh() if invalidate: self.updateStatusQueries() self.status.refresh() def updateStatusQueries(self): with self.db.getSession() as session: held = len(session.getHeld()) self.status.update(held=held) def popup(self, widget, relative_width=50, relative_height=25, min_width=20, min_height=8, width=None, height=None): self.clearInputBuffer() if width is None: width = ('relative', relative_width) if height is None: height = ('relative', relative_height) overlay = urwid.Overlay(widget, self.frame.body, 'center', width, 'middle', height, min_width=min_width, min_height=min_height) if hasattr(widget, 'title'): overlay.title = widget.title self.log.debug("Overlaying %s on screen %s" % (widget, self.frame.body)) self.screens.append(self.frame.body) self.frame.body = overlay def getGlobalCommands(self): return list(mywid.GLOBAL_HELP) def getGlobalHelp(self): keys = [(k, self.config.keymap.formatKeys(k), t) for (k, t) in self.getGlobalCommands()] for d in self.config.dashboards.values(): keys.append(('', d['key'], d['name'])) return keys def help(self): if not hasattr(self.frame.body, 'help'): return global_help = self.getGlobalHelp() parts = [('Global Keys', global_help), ('This Screen', self.frame.body.help())] keylen = 0 for title, items in parts: for cmd, keys, text in items: keylen = max(len(keys), keylen) text = '' for title, items in parts: if text: text += '\n' text += title+'\n' text += '%s\n' % ('='*len(title),) for cmd, keys, cmdtext in items: text += '{keys:{width}} {text}\n'.format( keys=keys, width=keylen, text=cmdtext) dialog = mywid.MessageDialog('Help for %s' % version(), text) lines = text.split('\n') urwid.connect_signal(dialog, 'close', lambda button: self.backScreen()) self.popup(dialog, min_width=76, min_height=len(lines)+4) def welcome(self): text = WELCOME_TEXT dialog = mywid.MessageDialog('Welcome', text) lines = text.split('\n') urwid.connect_signal(dialog, 'close', lambda button: self.backScreen()) self.popup(dialog, min_width=76, min_height=len(lines)+4) def _syncOneChangeFromQuery(self, query): number = changeid = restid = None if query.startswith("change:"): number = query.split(':')[1].strip() try: number = int(number) except ValueError: number = None changeid = query.split(':')[1].strip() if not (number or changeid): return with self.db.getSession() as session: if number: changes = [session.getChangeByNumber(number)] elif changeid: changes = session.getChangesByChangeID(changeid) change_keys = [c.key for c in changes if c] restids = [c.id for c in changes if c] if not change_keys: if self.sync.offline: raise Exception('Can not sync change while offline.') dialog = mywid.SystemMessage("Syncing change...") self.popup(dialog, width=40, height=6) self.loop.draw_screen() try: task = sync.SyncChangeByNumberTask(number or changeid, sync.HIGH_PRIORITY) self.sync.submitTask(task) succeeded = task.wait(300) if not succeeded: raise Exception('Unable to find change.') for subtask in task.tasks: succeeded = subtask.wait(300) if not succeeded: raise Exception('Unable to sync change.') finally: # Remove "syncing..." popup self.backScreen() with self.db.getSession() as session: if number: changes = [session.getChangeByNumber(number)] elif changeid: changes = session.getChangesByChangeID(changeid) change_keys = [c.key for c in changes if c] elif restids: for restid in restids: task = sync.SyncChangeTask(restid, sync.HIGH_PRIORITY) self.sync.submitTask(task) if not change_keys: raise Exception('Change is not in local database.') def doSearch(self, query): self.log.debug("Search query: %s" % query) try: self._syncOneChangeFromQuery(query) except Exception as e: return self.error(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 inputFilter(self, keys, raw): if 'window resize' in keys: m = getattr(self.frame.body, 'onResize', None) if m: m() return keys def unhandledInput(self, key): # get commands from buffer keys = self.input_buffer + [key] commands = self.config.keymap.getCommands(keys) if keymap.PREV_SCREEN in commands: self.backScreen() elif keymap.TOP_SCREEN in commands: self.clearHistory() self.refresh(force=True) elif keymap.HELP in commands: self.help() elif keymap.QUIT in commands: self.quit() elif keymap.CHANGE_SEARCH in commands: self.searchDialog('') elif keymap.LIST_HELD in commands: self.doSearch("is:held") elif key in self.config.dashboards: d = self.config.dashboards[key] view = view_change_list.ChangeListView(self, d['query'], d['name'], sort_by=d.get('sort-by'), reverse=d.get('reverse')) self.changeScreen(view) elif keymap.FURTHER_INPUT in commands: self.input_buffer.append(key) msg = ''.join(self.input_buffer) commands = dict(self.getGlobalCommands()) if hasattr(self.frame.body, 'getCommands'): commands.update(dict(self.frame.body.getCommands())) further_commands = self.config.keymap.getFurtherCommands(keys) completions = [] for (key, cmds) in further_commands: for cmd in cmds: if cmd in commands: completions.append(key) completions = ' '.join(completions) msg = '%s: %s' % (msg, completions) self.status.update(message=msg) return self.clearInputBuffer() def openURL(self, url): self.log.debug("Open URL %s" % url) webbrowser.open_new_tab(url) self.loop.screen.clear() def time(self, dt): utc = dt.replace(tzinfo=dateutil.tz.tzutc()) if self.config.utc: return utc local = utc.astimezone(dateutil.tz.tzlocal()) return local def _errorPipeInput(self, data=None): (title, message) = self.error_queue.get() self.error(message, title=title) def _showWarning(self, message, category, filename, lineno, file=None, line=None): # Don't display repeat warnings if str(message) in self.logged_warnings: return m = warnings.formatwarning(message, category, filename, lineno, line) self.log.warning(m) self.logged_warnings.add(str(message)) # Log this warning, but never display it to the user; it is # nearly un-actionable. if category == requestsexceptions.InsecurePlatformWarning: return if category == requestsexceptions.SNIMissingWarning: return # Disable InsecureRequestWarning when certificate validation is disabled if not self.config.verify_ssl: if category == requestsexceptions.InsecureRequestWarning: return self.error_queue.put(('Warning', m)) os.write(self.error_pipe, six.b('error\n')) def _commandPipeInput(self, data=None): (command, data) = self.command_queue.get() if command == 'open': url = data[0] self.log.debug("Opening URL %s" % (url,)) result = self.parseInternalURL(url) if result is not None: self.openInternalURL(result) else: self.log.error("Unable to parse command %s with data %s" % (command, data)) def toggleHeldChange(self, change_key): with self.db.getSession() as session: change = session.getChange(change_key) change.held = not change.held ret = change.held if not change.held: for r in change.revisions: for m in change.messages: if m.pending: self.sync.submitTask( sync.UploadReviewTask(m.key, sync.HIGH_PRIORITY)) self.updateStatusQueries() return ret def localCheckoutCommit(self, project_name, commit_sha): repo = gitrepo.get_repo(project_name, self.config) try: repo.checkout(commit_sha) dialog = mywid.MessageDialog('Checkout', 'Change checked out in %s' % repo.path) min_height=8 except gitrepo.GitCheckoutError as e: dialog = mywid.MessageDialog('Error', e.msg) min_height=12 urwid.connect_signal(dialog, 'close', lambda button: self.backScreen()) self.popup(dialog, min_height=min_height) def localCherryPickCommit(self, project_name, commit_sha): repo = gitrepo.get_repo(project_name, self.config) try: repo.cherryPick(commit_sha) dialog = mywid.MessageDialog('Cherry-Pick', 'Change cherry-picked in %s' % repo.path) min_height=8 except gitrepo.GitCheckoutError as e: dialog = mywid.MessageDialog('Error', e.msg) min_height=12 urwid.connect_signal(dialog, 'close', lambda button: self.backScreen()) self.popup(dialog, min_height=min_height) def saveReviews(self, revision_keys, approvals, message, upload, submit): message_keys = [] with self.db.getSession() as session: account = session.getAccountByUsername(self.config.username) for revision_key in revision_keys: k = self._saveReview(session, account, revision_key, approvals, message, upload, submit) if k: message_keys.append(k) return message_keys def _saveReview(self, session, account, revision_key, approvals, message, upload, submit): message_key = None revision = session.getRevision(revision_key) change = revision.change draft_approvals = {} for approval in change.draft_approvals: draft_approvals[approval.category] = approval categories = set() for label in change.permitted_labels: categories.add(label.category) for category in categories: value = approvals.get(category, 0) approval = draft_approvals.get(category) if not approval: approval = change.createApproval(account, category, 0, draft=True) draft_approvals[category] = approval approval.value = value draft_message = revision.getPendingMessage() if not draft_message: draft_message = revision.getDraftMessage() if not draft_message: if message or upload: draft_message = revision.createMessage(None, account, datetime.datetime.utcnow(), '', draft=True) if draft_message: draft_message.created = datetime.datetime.utcnow() draft_message.message = message draft_message.pending = upload message_key = draft_message.key if upload: change.reviewed = True self.project_cache.clear(change.project) if submit: change.status = 'SUBMITTED' change.pending_status = True change.pending_status_message = None return message_key def version(): return "Gertty version: %s" % gertty.version.version_info.release_string() class PrintKeymapAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): for cmd in sorted(keymap.DEFAULT_KEYMAP.keys()): print(cmd.replace(' ', '-')) sys.exit(0) class PrintPaletteAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): for attr in sorted(palette.DEFAULT_PALETTE.keys()): print(attr) sys.exit(0) class OpenChangeAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): cf = config.Config(namespace.server, namespace.palette, namespace.keymap, namespace.path) url = values[0] result = urlparse.urlparse(values[0]) if not url.startswith(cf.url): print('Supplied URL must start with %s' % (cf.url,)) sys.exit(1) s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect(cf.socket_path) s.sendall('open %s\n' % url) 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('--debug-sync', dest='debug_sync', action='store_true', help='disable most background sync tasks for debugging') parser.add_argument('--fetch-missing-refs', dest='fetch_missing_refs', action='store_true', help='fetch any refs missing from local repos') parser.add_argument('--print-keymap', nargs=0, action=PrintKeymapAction, help='print the keymap command names to stdout') parser.add_argument('--print-palette', nargs=0, action=PrintPaletteAction, help='print the palette attribute names to stdout') parser.add_argument('--open', nargs=1, action=OpenChangeAction, metavar='URL', help='open the given URL in a running Gertty') parser.add_argument('--version', dest='version', action='version', version=version(), help='show Gertty\'s version') parser.add_argument('-p', dest='palette', default='default', help='color palette to use') parser.add_argument('-k', dest='keymap', default='default', help='keymap to use') parser.add_argument('server', nargs='?', help='the server to use (as specified in config file)') args = parser.parse_args() g = App(args.server, args.palette, args.keymap, args.debug, args.verbose, args.no_sync, args.debug_sync, args.fetch_missing_refs, args.path) g.run() if __name__ == '__main__': main() gertty-1.5.0/gertty/requestsexceptions.py0000664000175000017500000000242313137372464022041 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 try: from requests.packages.urllib3.exceptions import SNIMissingWarning except ImportError: try: from urllib3.exceptions import SNIMissingWarning except ImportError: SNIMissingWarning = None gertty-1.5.0/gertty/sync.py0000664000175000017500000020366313137372464017051 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 json import time import datetime import warnings import dateutil.parser try: import ordereddict except: pass import requests import requests.utils import six from six.moves import queue from six.moves.urllib import parse as urlparse import gertty.version from gertty import gitrepo from gertty.auth import FormAuth HIGH_PRIORITY=0 NORMAL_PRIORITY=1 LOW_PRIORITY=2 TIMEOUT=30 CLOSED_STATUSES = ['MERGED', 'ABANDONED'] class OfflineError(Exception): pass 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() self.incomplete = [] def qsize(self): count = 0 self.condition.acquire() try: for queue in self.queues.values(): count += len(queue) return count + len(self.incomplete) finally: self.condition.release() 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() self.incomplete.append(ret) 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 def complete(self, item): self.condition.acquire() try: if item in self.incomplete: self.incomplete.remove(item) finally: self.condition.release() 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: self.log.info("Deleted project %s", name) local[name].delete() 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/' % urlparse.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 = sync.query(queries) 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 offset = 0 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]: done = False if '_sortkey' in batch[-1]: sortkey = '&N=%s' % (batch[-1]['_sortkey'],) else: offset += len(batch) sortkey = '&start=%s' % (offset,) 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 SyncOutdatedChangesTask(Task): def __init__(self, priority=NORMAL_PRIORITY): super(SyncOutdatedChangesTask, self).__init__(priority) def __eq__(self, other): if other.__class__ == self.__class__: return True return False def __repr__(self): return '' def run(self, sync): with sync.app.db.getSession() as session: for change in session.getOutdated(): self.log.debug("Sync outdated change %s" % (change.id,)) sync.submitTask(SyncChangeTask(change.id, priority=self.priority)) 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() try: self._syncChange(sync) 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) except Exception: try: self.log.error("Marking change %s outdated" % (self.change_id,)) with sync.app.db.getSession() as session: change = session.getChangeByID(self.change_id) if change: change.outdated = True except Exception: self.log.exception("Error while marking change %s as outdated" % (self.change_id,)) raise def _syncChange(self, sync): 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 try: remote_conflicts = sync.query(['q=status:open+is:mergeable+conflicts:%s' % remote_change['_number']]) except Exception: self.log.exception("Unable to sync conflicts for change %s" % self.change_id) warnings.warn("Unable to sync conflicts for change %s" % self.change_id) remote_conflicts = [] 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']) if not project: self.log.debug("Project %s unknown while syncing change" % ( remote_change['project'],)) remote_project = sync.get('projects/%s' % (urlparse.quote_plus(remote_change['project']),)) if remote_project: project = session.createProject( remote_project['name'], description=remote_project.get('description', '')) self.log.info("Created project %s", project.name) self.results.append(ProjectAddedEvent(project)) sync.submitTask(SyncProjectBranchesTask(project.name, self.priority)) 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) app.project_cache.clear(change.project) 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') unseen_conflicts = [x.id for x in change.conflicts] for remote_conflict in remote_conflicts: conflict_id = remote_conflict['id'] conflict = session.getChangeByID(conflict_id) if not conflict: self.log.info("Need to sync conflicting change %s for change %s.", conflict_id, change.number) sync.submitTask(SyncChangeTask(conflict_id, priority=self.priority)) else: if conflict not in change.conflicts: self.log.info("Added conflict %s for change %s in local DB.", conflict.number, change.number) change.addConflict(conflict) self.results.append(ChangeUpdatedEvent(conflict)) if conflict_id in unseen_conflicts: unseen_conflicts.remove(conflict_id) for conflict_id in unseen_conflicts: conflict = session.getChangeByID(conflict_id) self.log.info("Deleted conflict %s for change %s in local DB.", conflict.number, change.number) change.delConflict(conflict) self.results.append(ChangeUpdatedEvent(conflict)) 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 url = sync.app.config.git_url + change.project.name 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' % ( urlparse.quote_plus(sync.app.config.username), urlparse.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 app.project_cache.clear(change.project) change.outdated = False 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) 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, force_fetch=app.fetch_missing_refs, priority=LOW_PRIORITY) ) except Exception: self.log.exception("Exception checking repo %s" % (project.name,)) class CheckRevisionsTask(Task): def __init__(self, project_key, force_fetch=False, priority=NORMAL_PRIORITY): super(CheckRevisionsTask, self).__init__(priority) self.project_key = project_key self.force_fetch = force_fetch 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, force_fetch=self.force_fetch, 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 as 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, disable_background_sync): 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 elif self.app.config.auth_type == 'form': authclass = FormAuth 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)) if not disable_background_sync: 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(SyncOutdatedChangesTask(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() self.syncOutdatedChanges() 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) self.queue.complete(task) except (requests.ConnectionError, OfflineError, requests.exceptions.ChunkedEncodingError, requests.exceptions.ReadTimeout ) as 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, six.b('refresh\n')) time.sleep(30) return task except Exception: task.complete(False) self.queue.complete(task) 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, six.b('refresh\n')) return None def url(self, path): return self.app.config.url + 'a/' + path def checkResponse(self, response): self.log.debug('HTTP status code: %d', response.status_code) if response.status_code == 503: raise OfflineError("Received 503 status code") 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}) self.checkResponse(r) 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 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.checkResponse(r) 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.checkResponse(r) 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.checkResponse(r) 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 syncOutdatedChanges(self): task = SyncOutdatedChangesTask(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)) def query(self, queries): changes = [] sortkey = '' done = False offset = 0 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 = self.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: offset += len(batch) sortkey = '&start=%s' % (offset,) return changes gertty-1.5.0/gertty/config.py0000664000175000017500000002363513137372464017341 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 getpass import os import re try: import ordereddict except: pass import yaml from six.moves.urllib import parse as urlparse import voluptuous as v import gertty.commentlink import gertty.palette import gertty.keymap try: OrderedDict = collections.OrderedDict except AttributeError: OrderedDict = ordereddict.OrderedDict DEFAULT_CONFIG_PATH='~/.gertty.yaml' class ConfigSchema(object): server = {v.Required('name'): str, v.Required('url'): str, v.Required('username'): str, 'password': str, 'verify-ssl': bool, 'ssl-ca-path': str, 'dburi': str, v.Required('git-root'): str, 'git-url': str, 'log-file': str, 'socket': str, 'auth-type': v.Any('basic', 'digest', 'form'), } servers = [server] _sort_by = v.Any('number', 'updated', 'last-seen', 'project') sort_by = v.Any(_sort_by, [_sort_by]) text_replacement = {'text': v.Any(str, {'color': str, v.Required('text'): str})} link_replacement = {'link': {v.Required('url'): str, v.Required('text'): str}} search_replacement = {'search': {v.Required('query'): str, v.Required('text'): str}} replacement = v.Any(text_replacement, link_replacement, search_replacement) palette = {v.Required('name'): str, v.Match('(?!name)'): [str]} palettes = [palette] commentlink = {v.Required('match'): str, v.Required('replacements'): [replacement], 'test-result': str} commentlinks = [commentlink] dashboard = {v.Required('name'): str, v.Required('query'): str, v.Optional('sort-by'): sort_by, v.Optional('reverse'): bool, v.Required('key'): str} dashboards = [dashboard] reviewkey_approval = {v.Required('category'): str, v.Required('value'): int} reviewkey = {v.Required('approvals'): [reviewkey_approval], 'submit': bool, v.Required('key'): str} reviewkeys = [reviewkey] hide_comment = {v.Required('author'): str} hide_comments = [hide_comment] change_list_options = {'sort-by': sort_by, 'reverse': bool} keymap = {v.Required('name'): str, v.Match('(?!name)'): v.Any([[str], str], [str], str)} keymaps = [keymap] def getSchema(self, data): schema = v.Schema({v.Required('servers'): self.servers, 'palettes': self.palettes, 'palette': str, 'keymaps': self.keymaps, 'keymap': str, 'commentlinks': self.commentlinks, 'dashboards': self.dashboards, 'reviewkeys': self.reviewkeys, 'change-list-query': str, 'diff-view': str, 'hide-comments': self.hide_comments, 'thread-changes': bool, 'display-times-in-utc': bool, 'handle-mouse': bool, 'breadcrumbs': bool, 'change-list-options': self.change_list_options, 'expire-age': str, }) return schema class Config(object): def __init__(self, server=None, palette='default', keymap='default', path=DEFAULT_CONFIG_PATH): self.path = os.path.expanduser(path) if not os.path.exists(self.path): self.printSample() exit(1) self.config = yaml.load(open(self.path)) schema = ConfigSchema().getSchema(self.config) schema(self.config) server = self.getServer(server) self.server = server url = server['url'] if not url.endswith('/'): url += '/' self.url = url result = urlparse.urlparse(url) self.hostname = result.netloc self.username = server['username'] self.password = server.get('password') if self.password is None: self.password = getpass.getpass("Password for %s (%s): " % (self.url, self.username)) else: # Ensure file is only readable by user as password is stored in # file. mode = os.stat(self.path).st_mode & 0o0777 if not mode == 0o600: print ( "Error: Config file '{}' contains a password and does " "not have permissions set to 0600.\n" "Permissions are: {}".format(self.path, oct(mode))) exit(1) self.auth_type = server.get('auth-type', 'digest') self.verify_ssl = server.get('verify-ssl', True) if not self.verify_ssl: os.environ['GIT_SSL_NO_VERIFY']='true' self.ssl_ca_path = server.get('ssl-ca-path', None) if self.ssl_ca_path is not None: self.ssl_ca_path = os.path.expanduser(self.ssl_ca_path) # Gertty itself uses the Requests library os.environ['REQUESTS_CA_BUNDLE'] = self.ssl_ca_path # And this is to allow Git callouts os.environ['GIT_SSL_CAINFO'] = self.ssl_ca_path self.git_root = os.path.expanduser(server['git-root']) git_url = server.get('git-url', self.url + 'p/') if not git_url.endswith('/'): git_url += '/' self.git_url = git_url self.dburi = server.get('dburi', 'sqlite:///' + os.path.expanduser('~/.gertty.db')) socket_path = server.get('socket', '~/.gertty.sock') self.socket_path = os.path.expanduser(socket_path) log_file = server.get('log-file', '~/.gertty.log') self.log_file = os.path.expanduser(log_file) lock_file = server.get('lock-file', '~/.gertty.%s.lock' % server['name']) self.lock_file = os.path.expanduser(lock_file) self.palettes = {'default': gertty.palette.Palette({}), 'light': gertty.palette.Palette(gertty.palette.LIGHT_PALETTE), } for p in self.config.get('palettes', []): if p['name'] not in self.palettes: self.palettes[p['name']] = gertty.palette.Palette(p) else: self.palettes[p['name']].update(p) self.palette = self.palettes[self.config.get('palette', palette)] self.keymaps = {'default': gertty.keymap.KeyMap({}), 'vi': gertty.keymap.KeyMap(gertty.keymap.VI_KEYMAP)} for p in self.config.get('keymaps', []): if p['name'] not in self.keymaps: self.keymaps[p['name']] = gertty.keymap.KeyMap(p) else: self.keymaps[p['name']].update(p) self.keymap = self.keymaps[self.config.get('keymap', keymap)] self.commentlinks = [gertty.commentlink.CommentLink(c) for c in self.config.get('commentlinks', [])] self.commentlinks.append( gertty.commentlink.CommentLink(dict( match="(?Phttps?://\\S*)", replacements=[ dict(link=dict( text="{url}", url="{url}"))]))) self.project_change_list_query = self.config.get('change-list-query', 'status:open') self.diff_view = self.config.get('diff-view', 'side-by-side') self.dashboards = OrderedDict() for d in self.config.get('dashboards', []): self.dashboards[d['key']] = d self.dashboards[d['key']] self.reviewkeys = OrderedDict() for k in self.config.get('reviewkeys', []): self.reviewkeys[k['key']] = k self.hide_comments = [] for h in self.config.get('hide-comments', []): self.hide_comments.append(re.compile(h['author'])) self.thread_changes = self.config.get('thread-changes', True) self.utc = self.config.get('display-times-in-utc', False) self.breadcrumbs = self.config.get('breadcrumbs', True) self.handle_mouse = self.config.get('handle-mouse', True) change_list_options = self.config.get('change-list-options', {}) self.change_list_options = { 'sort-by': change_list_options.get('sort-by', 'number'), 'reverse': change_list_options.get('reverse', False)} self.expire_age = self.config.get('expire-age', '2 months') def getServer(self, name=None): for server in self.config['servers']: if name is None or name == server['name']: return server return None def printSample(self): filename = 'share/gertty/examples' print("""Gertty requires a configuration file at ~/.gertty.yaml If the file contains a password then permissions must be set to 0600. Several sample configuration files were installed with Gertty and are available in %s in the root of the installation. For more information, please see the README. """ % (filename,)) gertty-1.5.0/gertty/auth.py0000664000175000017500000000456313137372464017034 0ustar jenkinsjenkins00000000000000# Copyright 2015 Christoph Gysin # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import logging import requests from six.moves.urllib import parse as urlparse class FormAuth(requests.auth.AuthBase): def __init__(self, username, password): self.username = username self.password = password self.log = logging.getLogger('gertty.auth') def _retry_using_form_auth(self, response, args): adapter = requests.adapters.HTTPAdapter() request = _copy_request(response.request) u = urlparse.urlparse(response.url) url = urlparse.urlunparse([u.scheme, u.netloc, '/login', None, None, None]) auth = {'username': self.username, 'password': self.password} request2 = requests.Request('POST', url, data=auth).prepare() response2 = adapter.send(request2, **args) if response2.status_code == 401: self.log.error('Login failed: Invalid username or password?') return response cookie = response2.headers.get('set-cookie') if cookie is not None: request.headers['Cookie'] = cookie response3 = adapter.send(request, **args) return response3 def _response_hook(self, response, **kwargs): if response.status_code == 401: return self._retry_using_form_auth(response, kwargs) return response def __call__(self, request): request.headers["Connection"] = "Keep-Alive" request.register_hook('response', self._response_hook) return request def _copy_request(request): new_request = requests.PreparedRequest() new_request.method = request.method new_request.url = request.url new_request.body = request.body new_request.hooks = request.hooks new_request.headers = request.headers.copy() return new_request gertty-1.5.0/gertty/version.py0000664000175000017500000000124413137372464017551 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 pbr.version version_info = pbr.version.VersionInfo('gertty') gertty-1.5.0/gertty/palette.py0000664000175000017500000001325413137372464017526 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. DEFAULT_PALETTE={ 'focused': ['default,standout', ''], 'header': ['white,bold', 'dark blue'], 'error': ['light red', 'dark blue'], 'table-header': ['white,bold', ''], 'filename': ['light cyan', ''], 'focused-filename': ['light cyan,standout', ''], 'positive-label': ['dark green', ''], 'negative-label': ['dark red', ''], 'max-label': ['light green', ''], 'min-label': ['light red', ''], 'focused-positive-label': ['dark green,standout', ''], 'focused-negative-label': ['dark red,standout', ''], 'focused-max-label': ['light green,standout', ''], 'focused-min-label': ['light red,standout', ''], 'link': ['dark blue', ''], 'focused-link': ['light blue', ''], 'footer': ['light gray', 'dark gray'], # Diff 'context-button': ['dark magenta', ''], 'focused-context-button': ['light magenta', ''], 'removed-line': ['dark red', ''], 'removed-word': ['light red', ''], 'added-line': ['dark green', ''], 'added-word': ['light green', ''], 'nonexistent': ['default', ''], 'focused-removed-line': ['dark red,standout', ''], 'focused-removed-word': ['light red,standout', ''], 'focused-added-line': ['dark green,standout', ''], 'focused-added-word': ['light green,standout', ''], 'focused-nonexistent': ['default,standout', ''], 'draft-comment': ['default', 'dark gray'], 'comment': ['light gray', 'dark gray'], 'comment-name': ['white', 'dark gray'], 'line-number': ['dark gray', ''], 'focused-line-number': ['dark gray,standout', ''], 'search-result': ['default,standout', ''], 'trailing-ws': ['light red,standout', ''], # Change view 'change-data': ['dark cyan', ''], 'focused-change-data': ['light cyan', ''], 'change-header': ['light blue', ''], 'revision-name': ['light blue', ''], 'revision-commit': ['dark blue', ''], 'revision-comments': ['default', ''], 'revision-drafts': ['dark red', ''], 'focused-revision-name': ['light blue,standout', ''], 'focused-revision-commit': ['dark blue,standout', ''], 'focused-revision-comments': ['default,standout', ''], 'focused-revision-drafts': ['dark red,standout', ''], 'change-message-name': ['yellow', ''], 'change-message-own-name': ['light cyan', ''], 'change-message-header': ['brown', ''], 'change-message-own-header': ['dark cyan', ''], 'change-message-draft': ['dark red', ''], 'revision-button': ['dark magenta', ''], 'focused-revision-button': ['light magenta', ''], 'lines-added': ['light green', ''], 'lines-removed': ['light red', ''], 'reviewer-name': ['yellow', ''], 'reviewer-own-name': ['light cyan', ''], # project list 'unreviewed-project': ['white', ''], 'subscribed-project': ['default', ''], 'unsubscribed-project': ['dark gray', ''], 'marked-project': ['light cyan', ''], 'focused-unreviewed-project': ['white,standout', ''], 'focused-subscribed-project': ['default,standout', ''], 'focused-unsubscribed-project': ['dark gray,standout', ''], 'focused-marked-project': ['light cyan,standout', ''], # change list 'unreviewed-change': ['default', ''], 'reviewed-change': ['dark gray', ''], 'focused-unreviewed-change': ['default,standout', ''], 'focused-reviewed-change': ['dark gray,standout', ''], 'starred-change': ['light cyan', ''], 'focused-starred-change': ['light cyan,standout', ''], 'held-change': ['light red', ''], 'focused-held-change': ['light red,standout', ''], 'marked-change': ['dark cyan', ''], 'focused-marked-change': ['dark cyan,standout', ''], 'added-graph': ['dark green', ''], 'removed-graph': ['dark red', ''], 'added-removed-graph': ['dark green', 'dark red'], 'focused-added-graph': ['default,standout', 'dark green'], 'focused-removed-graph': ['default,standout', 'dark red'], } # A delta from the default palette LIGHT_PALETTE = { 'table-header': ['black,bold', ''], 'unreviewed-project': ['black', ''], 'subscribed-project': ['dark gray', ''], 'unsubscribed-project': ['dark gray', ''], 'focused-unreviewed-project': ['black,standout', ''], 'focused-subscribed-project': ['dark gray,standout', ''], 'focused-unsubscribed-project': ['dark gray,standout', ''], 'change-data': ['dark blue,bold', ''], 'focused-change-data': ['dark blue,standout', ''], 'reviewer-name': ['brown', ''], 'reviewer-own-name': ['dark blue,bold', ''], 'change-message-name': ['brown', ''], 'change-message-own-name': ['dark blue,bold', ''], 'change-message-header': ['black', ''], 'change-message-own-header': ['black,bold', ''], 'focused-link': ['dark blue,bold', ''], 'filename': ['dark cyan', ''], } class Palette(object): def __init__(self, config): self.palette = {} self.palette.update(DEFAULT_PALETTE) self.update(config) def update(self, config): d = config.copy() if 'name' in d: del d['name'] self.palette.update(d) def getPalette(self): ret = [] for k,v in self.palette.items(): ret.append(tuple([k]+v)) return ret gertty-1.5.0/gertty/search/0000775000175000017500000000000013137372663016757 5ustar jenkinsjenkins00000000000000gertty-1.5.0/gertty/search/tokenizer.py0000664000175000017500000000572313137372464021351 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 ply.lex as lex import six operators = { 'age': 'OP_AGE', 'recentlyseen': 'OP_RECENTLYSEEN', # Gertty extension 'change': 'OP_CHANGE', 'owner': 'OP_OWNER', #'OP_OWNERIN', # needs local group membership 'reviewer': 'OP_REVIEWER', #'OP_REVIEWERIN', # needs local group membership 'commit': 'OP_COMMIT', 'project': 'OP_PROJECT', 'projects': 'OP_PROJECTS', '_project_key': 'OP_PROJECT_KEY', # internal gertty use only 'branch': 'OP_BRANCH', 'topic': 'OP_TOPIC', 'ref': 'OP_REF', #'tr': 'OP_TR', # needs trackingids #'bug': 'OP_BUG', # needs trackingids 'label': 'OP_LABEL', 'message': 'OP_MESSAGE', 'comment': 'OP_COMMENT', 'file': 'OP_FILE', 'has': 'OP_HAS', 'is': 'OP_IS', 'status': 'OP_STATUS', 'limit': 'OP_LIMIT', } reserved = { 'or|OR': 'OR', 'not|NOT': 'NOT', } tokens = [ 'OP', 'AND', 'OR', 'NOT', 'NEG', 'LPAREN', 'RPAREN', 'NUMBER', 'CHANGE_ID', 'SSTRING', 'DSTRING', 'USTRING', #'REGEX', #'SHA', ] + list(operators.values()) def SearchTokenizer(): t_LPAREN = r'\(' # NOQA t_RPAREN = r'\)' # NOQA t_NEG = r'[-!]' # NOQA t_ignore = ' \t' # NOQA (and intentionally not using r'' due to tab char) def t_OP(t): r'[a-zA-Z_][a-zA-Z_]*:' t.type = operators.get(t.value[:-1], 'OP') return t def t_CHANGE_ID(t): r'I[a-fA-F0-9]{7,40}' return t def t_SSTRING(t): r"'([^\\']+|\\'|\\\\)*'" t.value=t.value[1:-1].decode("string-escape") return t def t_DSTRING(t): r'"([^\\"]+|\\"|\\\\)*"' t.value=t.value[1:-1].decode("string-escape") return t def t_AND(t): r'and|AND' return t def t_OR(t): r'or|OR' return t def t_NOT(t): r'not|NOT' return t def t_INTEGER(t): r'[+-]\d+' t.value = int(t.value) return t def t_NUMBER(t): r'\d+' t.value = int(t.value) return t def t_USTRING(t): r'([^\s\(\)!-][^\s\(\)!]*)' t.value = six.b(t.value).decode("unicode_escape") return t def t_newline(t): r'\n+' t.lexer.lineno += len(t.value) def t_error(t): print("Illegal character '%s'" % t.value[0]) t.lexer.skip(1) return lex.lex() gertty-1.5.0/gertty/search/__init__.py0000664000175000017500000000564513137372464021101 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 sqlalchemy.sql.expression from sqlalchemy.sql.expression import and_ from gertty.search import tokenizer, parser import gertty.db class SearchSyntaxError(Exception): pass class SearchCompiler(object): def __init__(self, username): self.username = username self.lexer = tokenizer.SearchTokenizer() self.parser = parser.SearchParser() def findTables(self, expression): tables = set() stack = [expression] while stack: x = stack.pop() if hasattr(x, 'table'): if (x.table != gertty.db.change_table and hasattr(x.table, 'name')): tables.add(x.table) for child in x.get_children(): if not isinstance(child, sqlalchemy.sql.selectable.Select): stack.append(child) return tables def parse(self, data): self.parser.username = self.username result = self.parser.parse(data, lexer=self.lexer) tables = self.findTables(result) if gertty.db.project_table in tables: result = and_(gertty.db.change_table.c.project_key == gertty.db.project_table.c.key, result) tables.remove(gertty.db.project_table) if gertty.db.account_table in tables: result = and_(gertty.db.change_table.c.account_key == gertty.db.account_table.c.key, result) tables.remove(gertty.db.account_table) if gertty.db.file_table in tables: result = and_(gertty.db.file_table.c.revision_key == gertty.db.revision_table.c.key, gertty.db.revision_table.c.change_key == gertty.db.change_table.c.key, result) tables.remove(gertty.db.file_table) if tables: raise Exception("Unknown table in search: %s" % tables) return result if __name__ == '__main__': class Dummy(object): pass query = 'recentlyseen:24 hours' lexer = tokenizer.SearchTokenizer() lexer.input(query) while True: token = lexer.token() if not token: break print(token) app = Dummy() app.config = Dummy() app.config.username = 'bob' search = SearchCompiler(app.config.username) x = search.parse(query) print(x) gertty-1.5.0/gertty/search/parser.py0000664000175000017500000003504013137372464020626 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 gertty.search.tokenizer import tokens # NOQA def age_to_delta(delta, unit): if unit in ['seconds', 'second', 'sec', 's']: pass elif unit in ['minutes', 'minute', 'min', 'm']: delta = delta * 60 elif unit in ['hours', 'hour', 'hr', 'h']: delta = delta * 60 * 60 elif unit in ['days', 'day', 'd']: delta = delta * 60 * 60 * 24 elif unit in ['weeks', 'week', 'w']: delta = delta * 60 * 60 * 24 * 7 elif unit in ['months', 'month', 'mon']: delta = delta * 60 * 60 * 24 * 30 elif unit in ['years', 'year', 'y']: delta = delta * 60 * 60 * 24 * 365 return delta def SearchParser(): precedence = ( # NOQA ('left', 'NOT', 'NEG'), ) def p_terms(p): '''expression : list_expr | paren_expr | boolean_expr | negative_expr | term''' p[0] = p[1] def p_list_expr(p): '''list_expr : expression expression''' p[0] = and_(p[1], p[2]) def p_paren_expr(p): '''paren_expr : LPAREN expression RPAREN''' p[0] = p[2] def p_boolean_expr(p): '''boolean_expr : expression AND expression | expression OR expression''' if p[2].lower() == 'and': p[0] = and_(p[1], p[3]) elif p[2].lower() == 'or': p[0] = or_(p[1], p[3]) else: raise gertty.search.SearchSyntaxError("Boolean %s not recognized" % p[2]) def p_negative_expr(p): '''negative_expr : NOT expression | NEG expression''' p[0] = not_(p[2]) def p_term(p): '''term : age_term | recentlyseen_term | change_term | owner_term | reviewer_term | commit_term | project_term | projects_term | project_key_term | branch_term | topic_term | 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] delta = age_to_delta(delta, unit) p[0] = gertty.db.change_table.c.updated < (now-datetime.timedelta(seconds=delta)) def p_recentlyseen_term(p): '''recentlyseen_term : OP_RECENTLYSEEN NUMBER string''' # A gertty extension now = datetime.datetime.utcnow() delta = p[2] unit = p[3] delta = age_to_delta(delta, unit) s = select([func.datetime(func.max(gertty.db.change_table.c.last_seen), '-%s seconds' % delta)], correlate=False) p[0] = gertty.db.change_table.c.last_seen >= s def p_change_term(p): '''change_term : OP_CHANGE CHANGE_ID | OP_CHANGE NUMBER''' if type(p[2]) == int: p[0] = gertty.db.change_table.c.number == p[2] else: p[0] = gertty.db.change_table.c.change_id == p[2] def p_owner_term(p): '''owner_term : OP_OWNER string''' if p[2] == 'self': 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_projects_term(p): '''projects_term : OP_PROJECTS string''' p[0] = gertty.db.project_table.c.name.like('%s%%' % p[2]) def p_project_key_term(p): '''project_key_term : OP_PROJECT_KEY NUMBER''' p[0] = gertty.db.change_table.c.project_key == p[2] def p_branch_term(p): '''branch_term : OP_BRANCH string''' if p[2].startswith('^'): p[0] = func.matches(p[2], gertty.db.change_table.c.branch) else: p[0] = gertty.db.change_table.c.branch == p[2] def p_topic_term(p): '''topic_term : OP_TOPIC string''' if p[2].startswith('^'): p[0] = func.matches(p[2], gertty.db.change_table.c.topic) else: p[0] = and_(gertty.db.change_table.c.topic.isnot(None), gertty.db.change_table.c.topic == p[2]) def p_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