Browse Source

Initial commit

Change-Id: Ie79f257c46a2c50abdd7ce63bfeceaad976ca878
tags/0.9.0
James E. Blair 5 years ago
parent
commit
1d6b0fd881
16 changed files with 2607 additions and 0 deletions
  1. 1
    0
      .gitignore
  2. 202
    0
      LICENSE
  3. 111
    0
      README.rst
  4. 0
    0
      gertty/__init__.py
  5. 46
    0
      gertty/config.py
  6. 446
    0
      gertty/db.py
  7. 186
    0
      gertty/gertty.py
  8. 196
    0
      gertty/gitrepo.py
  9. 61
    0
      gertty/mywid.py
  10. 453
    0
      gertty/sync.py
  11. 0
    0
      gertty/view/__init__.py
  12. 372
    0
      gertty/view/change.py
  13. 140
    0
      gertty/view/change_list.py
  14. 261
    0
      gertty/view/diff.py
  15. 127
    0
      gertty/view/project_list.py
  16. 5
    0
      requirements.txt

+ 1
- 0
.gitignore View File

@@ -0,0 +1 @@
1
+*.pyc

+ 202
- 0
LICENSE View File

@@ -0,0 +1,202 @@
1
+
2
+                                 Apache License
3
+                           Version 2.0, January 2004
4
+                        http://www.apache.org/licenses/
5
+
6
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+   1. Definitions.
9
+
10
+      "License" shall mean the terms and conditions for use, reproduction,
11
+      and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+      "Licensor" shall mean the copyright owner or entity authorized by
14
+      the copyright owner that is granting the License.
15
+
16
+      "Legal Entity" shall mean the union of the acting entity and all
17
+      other entities that control, are controlled by, or are under common
18
+      control with that entity. For the purposes of this definition,
19
+      "control" means (i) the power, direct or indirect, to cause the
20
+      direction or management of such entity, whether by contract or
21
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+      outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+      "You" (or "Your") shall mean an individual or Legal Entity
25
+      exercising permissions granted by this License.
26
+
27
+      "Source" form shall mean the preferred form for making modifications,
28
+      including but not limited to software source code, documentation
29
+      source, and configuration files.
30
+
31
+      "Object" form shall mean any form resulting from mechanical
32
+      transformation or translation of a Source form, including but
33
+      not limited to compiled object code, generated documentation,
34
+      and conversions to other media types.
35
+
36
+      "Work" shall mean the work of authorship, whether in Source or
37
+      Object form, made available under the License, as indicated by a
38
+      copyright notice that is included in or attached to the work
39
+      (an example is provided in the Appendix below).
40
+
41
+      "Derivative Works" shall mean any work, whether in Source or Object
42
+      form, that is based on (or derived from) the Work and for which the
43
+      editorial revisions, annotations, elaborations, or other modifications
44
+      represent, as a whole, an original work of authorship. For the purposes
45
+      of this License, Derivative Works shall not include works that remain
46
+      separable from, or merely link (or bind by name) to the interfaces of,
47
+      the Work and Derivative Works thereof.
48
+
49
+      "Contribution" shall mean any work of authorship, including
50
+      the original version of the Work and any modifications or additions
51
+      to that Work or Derivative Works thereof, that is intentionally
52
+      submitted to Licensor for inclusion in the Work by the copyright owner
53
+      or by an individual or Legal Entity authorized to submit on behalf of
54
+      the copyright owner. For the purposes of this definition, "submitted"
55
+      means any form of electronic, verbal, or written communication sent
56
+      to the Licensor or its representatives, including but not limited to
57
+      communication on electronic mailing lists, source code control systems,
58
+      and issue tracking systems that are managed by, or on behalf of, the
59
+      Licensor for the purpose of discussing and improving the Work, but
60
+      excluding communication that is conspicuously marked or otherwise
61
+      designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+      "Contributor" shall mean Licensor and any individual or Legal Entity
64
+      on behalf of whom a Contribution has been received by Licensor and
65
+      subsequently incorporated within the Work.
66
+
67
+   2. Grant of Copyright License. Subject to the terms and conditions of
68
+      this License, each Contributor hereby grants to You a perpetual,
69
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+      copyright license to reproduce, prepare Derivative Works of,
71
+      publicly display, publicly perform, sublicense, and distribute the
72
+      Work and such Derivative Works in Source or Object form.
73
+
74
+   3. Grant of Patent License. Subject to the terms and conditions of
75
+      this License, each Contributor hereby grants to You a perpetual,
76
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+      (except as stated in this section) patent license to make, have made,
78
+      use, offer to sell, sell, import, and otherwise transfer the Work,
79
+      where such license applies only to those patent claims licensable
80
+      by such Contributor that are necessarily infringed by their
81
+      Contribution(s) alone or by combination of their Contribution(s)
82
+      with the Work to which such Contribution(s) was submitted. If You
83
+      institute patent litigation against any entity (including a
84
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+      or a Contribution incorporated within the Work constitutes direct
86
+      or contributory patent infringement, then any patent licenses
87
+      granted to You under this License for that Work shall terminate
88
+      as of the date such litigation is filed.
89
+
90
+   4. Redistribution. You may reproduce and distribute copies of the
91
+      Work or Derivative Works thereof in any medium, with or without
92
+      modifications, and in Source or Object form, provided that You
93
+      meet the following conditions:
94
+
95
+      (a) You must give any other recipients of the Work or
96
+          Derivative Works a copy of this License; and
97
+
98
+      (b) You must cause any modified files to carry prominent notices
99
+          stating that You changed the files; and
100
+
101
+      (c) You must retain, in the Source form of any Derivative Works
102
+          that You distribute, all copyright, patent, trademark, and
103
+          attribution notices from the Source form of the Work,
104
+          excluding those notices that do not pertain to any part of
105
+          the Derivative Works; and
106
+
107
+      (d) If the Work includes a "NOTICE" text file as part of its
108
+          distribution, then any Derivative Works that You distribute must
109
+          include a readable copy of the attribution notices contained
110
+          within such NOTICE file, excluding those notices that do not
111
+          pertain to any part of the Derivative Works, in at least one
112
+          of the following places: within a NOTICE text file distributed
113
+          as part of the Derivative Works; within the Source form or
114
+          documentation, if provided along with the Derivative Works; or,
115
+          within a display generated by the Derivative Works, if and
116
+          wherever such third-party notices normally appear. The contents
117
+          of the NOTICE file are for informational purposes only and
118
+          do not modify the License. You may add Your own attribution
119
+          notices within Derivative Works that You distribute, alongside
120
+          or as an addendum to the NOTICE text from the Work, provided
121
+          that such additional attribution notices cannot be construed
122
+          as modifying the License.
123
+
124
+      You may add Your own copyright statement to Your modifications and
125
+      may provide additional or different license terms and conditions
126
+      for use, reproduction, or distribution of Your modifications, or
127
+      for any such Derivative Works as a whole, provided Your use,
128
+      reproduction, and distribution of the Work otherwise complies with
129
+      the conditions stated in this License.
130
+
131
+   5. Submission of Contributions. Unless You explicitly state otherwise,
132
+      any Contribution intentionally submitted for inclusion in the Work
133
+      by You to the Licensor shall be under the terms and conditions of
134
+      this License, without any additional terms or conditions.
135
+      Notwithstanding the above, nothing herein shall supersede or modify
136
+      the terms of any separate license agreement you may have executed
137
+      with Licensor regarding such Contributions.
138
+
139
+   6. Trademarks. This License does not grant permission to use the trade
140
+      names, trademarks, service marks, or product names of the Licensor,
141
+      except as required for reasonable and customary use in describing the
142
+      origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+   7. Disclaimer of Warranty. Unless required by applicable law or
145
+      agreed to in writing, Licensor provides the Work (and each
146
+      Contributor provides its Contributions) on an "AS IS" BASIS,
147
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+      implied, including, without limitation, any warranties or conditions
149
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+      PARTICULAR PURPOSE. You are solely responsible for determining the
151
+      appropriateness of using or redistributing the Work and assume any
152
+      risks associated with Your exercise of permissions under this License.
153
+
154
+   8. Limitation of Liability. In no event and under no legal theory,
155
+      whether in tort (including negligence), contract, or otherwise,
156
+      unless required by applicable law (such as deliberate and grossly
157
+      negligent acts) or agreed to in writing, shall any Contributor be
158
+      liable to You for damages, including any direct, indirect, special,
159
+      incidental, or consequential damages of any character arising as a
160
+      result of this License or out of the use or inability to use the
161
+      Work (including but not limited to damages for loss of goodwill,
162
+      work stoppage, computer failure or malfunction, or any and all
163
+      other commercial damages or losses), even if such Contributor
164
+      has been advised of the possibility of such damages.
165
+
166
+   9. Accepting Warranty or Additional Liability. While redistributing
167
+      the Work or Derivative Works thereof, You may choose to offer,
168
+      and charge a fee for, acceptance of support, warranty, indemnity,
169
+      or other liability obligations and/or rights consistent with this
170
+      License. However, in accepting such obligations, You may act only
171
+      on Your own behalf and on Your sole responsibility, not on behalf
172
+      of any other Contributor, and only if You agree to indemnify,
173
+      defend, and hold each Contributor harmless for any liability
174
+      incurred by, or claims asserted against, such Contributor by reason
175
+      of your accepting any such warranty or additional liability.
176
+
177
+   END OF TERMS AND CONDITIONS
178
+
179
+   APPENDIX: How to apply the Apache License to your work.
180
+
181
+      To apply the Apache License to your work, attach the following
182
+      boilerplate notice, with the fields enclosed by brackets "[]"
183
+      replaced with your own identifying information. (Don't include
184
+      the brackets!)  The text should be enclosed in the appropriate
185
+      comment syntax for the file format. We also recommend that a
186
+      file or class name and description of purpose be included on the
187
+      same "printed page" as the copyright notice for easier
188
+      identification within third-party archives.
189
+
190
+   Copyright [yyyy] [name of copyright owner]
191
+
192
+   Licensed under the Apache License, Version 2.0 (the "License");
193
+   you may not use this file except in compliance with the License.
194
+   You may obtain a copy of the License at
195
+
196
+       http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+   Unless required by applicable law or agreed to in writing, software
199
+   distributed under the License is distributed on an "AS IS" BASIS,
200
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+   See the License for the specific language governing permissions and
202
+   limitations under the License.

+ 111
- 0
README.rst View File

@@ -0,0 +1,111 @@
1
+Gertty
2
+======
3
+
4
+Gertty is a console-based interface to the Gerrit Code Review system.
5
+
6
+As compared to the web interface, the main advantages are:
7
+
8
+ * Workflow -- the interface is designed to support a workflow similar
9
+   to reading network news or mail.  In particular, it is designed to
10
+   deal with a large number of review requests across a large number
11
+   of projects.
12
+
13
+ * Offline Use -- Gertty syncs information about changes in subscribed
14
+   projects to a local database and local git repos.  All review
15
+   operations are performed against that database and then synced back
16
+   to Gerrit.
17
+
18
+ * Speed -- user actions modify locally cached content and need not
19
+   wait for server interaction.
20
+
21
+ * Convenience -- because Gertty downloads all changes to local git
22
+   repos, a single command instructs it to checkout a change into that
23
+   repo for detailed examination or testing of larger changes.
24
+
25
+Usage
26
+-----
27
+
28
+Create a file at ``~/.gerttyrc`` with the following contents::
29
+
30
+  [gerrit]
31
+  url=https://review.example.org/
32
+  username=<gerrit username>
33
+  password=<gerrit password>
34
+  git_root=~/git/
35
+
36
+You can generate or retrieve your Gerrit password by navigating to
37
+Settings, then HTTP Password.  Set ``git_root`` to a directory where
38
+Gertty should find or clone git repositories for your projects.
39
+
40
+If your Gerrit uses a self-signed certificate, you can add::
41
+
42
+  verify_ssl=False
43
+
44
+To the section.
45
+
46
+The config file is designed to support multiple Gerrit instances, but
47
+currently, only the first one is used.
48
+
49
+After installing the requirements (listed in requirements.txt), you
50
+should be able to simply run Gertty.  You will need to start by
51
+subscribing to some projects.  Use 'l' to list all of the projects and
52
+then 's' to subscribe to them.
53
+
54
+In general, pressing the F1 key will show help text on any screen, and
55
+ESC will take you to the previous screen.
56
+
57
+To select text (e.g., to copy to the clipboard), hold Shift while
58
+selecting the text.
59
+
60
+Philosophy
61
+----------
62
+
63
+Gertty is based on the following precepts which should inform changes
64
+to the program:
65
+
66
+* Support large numbers of review requests across large numbers of
67
+  projects.  Help the user prioritize those reviews.
68
+
69
+* Adopt a news/mailreader-like workflow in support of the above.
70
+  Being able to subscribe to projects, mark reviews as "read" without
71
+  reviewing, etc, are all useful concepts to support a heavy review
72
+  load (they have worked extremely well in supporting people who
73
+  read/write a lot of mail/news).
74
+
75
+* Support off-line use.  Gertty should be completely usable off-line
76
+  with reliable syncing between local data and Gerrit when a
77
+  connection is available (just like git or mail or news).
78
+
79
+* Ample use of color.  Unlike a web interface, a good text interface
80
+  relies mostly on color and precise placement rather than whitespace
81
+  and decoration to indicate to the user the purpose of a given piece
82
+  of information.  Gertty should degrade well to 16 colors, but more
83
+  (88 or 256) may be used.
84
+
85
+* Keyboard navigation (with easy-to-remember commands) should be
86
+  considered the primary mode of interaction.  Mouse interaction
87
+  should also be supported.
88
+
89
+* The navigation philosophy is a stack of screens, where each
90
+  selection pushes a new screen onto the stack, and ESC pops the
91
+  screen off.  This makes sense when drilling down to a change from
92
+  lists, but also supports linking from change to change (via commit
93
+  messages or comments) and navigating back intuitive (it matches
94
+  expectations set by the web browsers).
95
+
96
+Contributing
97
+------------
98
+
99
+To browse the latest code, see: https://git.openstack.org/cgit/stackforge/gertty/tree/
100
+To clone the latest code, use `git clone git://git.openstack.org/stackforge/gertty`
101
+
102
+Bugs are handled at: https://storyboard.openstack.org/
103
+
104
+Code reviews are handled by gerrit at: https://review.openstack.org
105
+
106
+Use `git review` to submit patches (after creating a gerrit account
107
+that links to your launchpad account). Example::
108
+
109
+    # Do your commits
110
+    $ git review
111
+    # Enter your username if prompted

+ 0
- 0
gertty/__init__.py View File


+ 46
- 0
gertty/config.py View File

@@ -0,0 +1,46 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import os
16
+import ConfigParser
17
+
18
+
19
+DEFAULT_CONFIG_PATH='~/.gerttyrc'
20
+
21
+class Config(object):
22
+    def __init__(self, server=None, path=DEFAULT_CONFIG_PATH):
23
+        self.path = os.path.expanduser(path)
24
+        self.config = ConfigParser.RawConfigParser()
25
+        self.config.read(self.path)
26
+        if server is None:
27
+            server = self.config.sections()[0]
28
+        self.server = server
29
+        self.url = self.config.get(server, 'url')
30
+        self.username = self.config.get(server, 'username')
31
+        self.password = self.config.get(server, 'password')
32
+        if self.config.has_option(server, 'verify_ssl'):
33
+            self.verify_ssl = self.config.getboolean(server, 'verify_ssl')
34
+        else:
35
+            self.verify_ssl = True
36
+        if not self.verify_ssl:
37
+            os.environ['GIT_SSL_NO_VERIFY']='true'
38
+        self.git_root = os.path.expanduser(self.config.get(server, 'git_root'))
39
+        if self.config.has_option(server, 'dburi'):
40
+            self.dburi = self.config.get(server, 'dburi')
41
+        else:
42
+            self.dburi = 'sqlite:///' + os.path.expanduser('~/.gertty.db')
43
+        if self.config.has_option(server, 'log_file'):
44
+            self.log_file = os.path.expanduser(self.config.get(server, 'log_file'))
45
+        else:
46
+            self.log_file = os.path.expanduser('~/.gertty.log')

+ 446
- 0
gertty/db.py View File

@@ -0,0 +1,446 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import sqlalchemy
16
+from sqlalchemy import create_engine, MetaData, Table, Column, Integer, String, Boolean, DateTime, Text, select, func
17
+from sqlalchemy.schema import ForeignKey
18
+from sqlalchemy.orm import mapper, sessionmaker, relationship, column_property, scoped_session
19
+from sqlalchemy.orm.session import Session
20
+from sqlalchemy.sql.expression import and_
21
+
22
+metadata = MetaData()
23
+project_table = Table(
24
+    'project', metadata,
25
+    Column('key', Integer, primary_key=True),
26
+    Column('name', String(255), index=True, unique=True, nullable=False),
27
+    Column('subscribed', Boolean, index=True, default=False),
28
+    Column('description', Text, nullable=False, default=''),
29
+    )
30
+change_table = Table(
31
+    'change', metadata,
32
+    Column('key', Integer, primary_key=True),
33
+    Column('project_key', Integer, ForeignKey("project.key"), index=True),
34
+    Column('id', String(255), index=True, unique=True, nullable=False),
35
+    Column('number', Integer, index=True, unique=True, nullable=False),
36
+    Column('branch', String(255), index=True, nullable=False),
37
+    Column('change_id', String(255), index=True, nullable=False),
38
+    Column('topic', String(255), index=True),
39
+    Column('owner', String(255), index=True),
40
+    Column('subject', Text, nullable=False),
41
+    Column('created', DateTime, index=True, nullable=False),
42
+    Column('updated', DateTime, index=True, nullable=False),
43
+    Column('status', String(8), index=True, nullable=False),
44
+    Column('hidden', Boolean, index=True, nullable=False),
45
+    Column('reviewed', Boolean, index=True, nullable=False),
46
+    )
47
+revision_table = Table(
48
+    'revision', metadata,
49
+    Column('key', Integer, primary_key=True),
50
+    Column('change_key', Integer, ForeignKey("change.key"), index=True),
51
+    Column('number', Integer, index=True, nullable=False),
52
+    Column('message', Text, nullable=False),
53
+    Column('commit', String(255), nullable=False),
54
+    Column('parent', String(255), nullable=False),
55
+    )
56
+message_table = Table(
57
+    'message', metadata,
58
+    Column('key', Integer, primary_key=True),
59
+    Column('revision_key', Integer, ForeignKey("revision.key"), index=True),
60
+    Column('id', String(255), index=True), #, unique=True, nullable=False),
61
+    Column('created', DateTime, index=True, nullable=False),
62
+    Column('name', String(255)),
63
+    Column('message', Text, nullable=False),
64
+    Column('pending', Boolean, index=True, nullable=False),
65
+    )
66
+comment_table = Table(
67
+    'comment', metadata,
68
+    Column('key', Integer, primary_key=True),
69
+    Column('revision_key', Integer, ForeignKey("revision.key"), index=True),
70
+    Column('id', String(255), index=True), #, unique=True, nullable=False),
71
+    Column('in_reply_to', String(255)),
72
+    Column('created', DateTime, index=True, nullable=False),
73
+    Column('name', String(255)),
74
+    Column('file', Text, nullable=False),
75
+    Column('parent', Boolean, nullable=False),
76
+    Column('line', Integer),
77
+    Column('message', Text, nullable=False),
78
+    Column('pending', Boolean, index=True, nullable=False),
79
+    )
80
+label_table = Table(
81
+    'label', metadata,
82
+    Column('key', Integer, primary_key=True),
83
+    Column('change_key', Integer, ForeignKey("change.key"), index=True),
84
+    Column('category', String(255), nullable=False),
85
+    Column('value', Integer, nullable=False),
86
+    Column('description', String(255), nullable=False),
87
+    )
88
+permitted_label_table = Table(
89
+    'permitted_label', metadata,
90
+    Column('key', Integer, primary_key=True),
91
+    Column('change_key', Integer, ForeignKey("change.key"), index=True),
92
+    Column('category', String(255), nullable=False),
93
+    Column('value', Integer, nullable=False),
94
+    )
95
+approval_table = Table(
96
+    'approval', metadata,
97
+    Column('key', Integer, primary_key=True),
98
+    Column('change_key', Integer, ForeignKey("change.key"), index=True),
99
+    Column('name', String(255)),
100
+    Column('category', String(255), nullable=False),
101
+    Column('value', Integer, nullable=False),
102
+    Column('pending', Boolean, index=True, nullable=False),
103
+    )
104
+
105
+
106
+class Project(object):
107
+    def __init__(self, name, subscribed=False, description=''):
108
+        self.name = name
109
+        self.subscribed = subscribed
110
+        self.description = description
111
+
112
+    def createChange(self, *args, **kw):
113
+        session = Session.object_session(self)
114
+        args = [self] + list(args)
115
+        c = Change(*args, **kw)
116
+        self.changes.append(c)
117
+        session.add(c)
118
+        session.flush()
119
+        return c
120
+
121
+class Change(object):
122
+    def __init__(self, project, id, number, branch, change_id,
123
+                 owner, subject, created, updated, status,
124
+                 topic=False, hidden=False, reviewed=False):
125
+        self.project_key = project.key
126
+        self.id = id
127
+        self.number = number
128
+        self.branch = branch
129
+        self.change_id = change_id
130
+        self.topic = topic
131
+        self.owner = owner
132
+        self.subject = subject
133
+        self.created = created
134
+        self.updated = updated
135
+        self.status = status
136
+        self.hidden = hidden
137
+        self.reviewed = reviewed
138
+
139
+    def getCategories(self):
140
+        categories = []
141
+        for label in self.labels:
142
+            if label.category in categories:
143
+                continue
144
+            categories.append(label.category)
145
+        return categories
146
+
147
+    def getMaxForCategory(self, category):
148
+        if not hasattr(self, '_approval_cache'):
149
+            self._updateApprovalCache()
150
+        return self._approval_cache.get(category, 0)
151
+
152
+    def _updateApprovalCache(self):
153
+        cat_min = {}
154
+        cat_max = {}
155
+        cat_value = {}
156
+        for approval in self.approvals:
157
+            cur_min = cat_min.get(approval.category, 0)
158
+            cur_max = cat_max.get(approval.category, 0)
159
+            cur_min = min(approval.value, cur_min)
160
+            cur_max = max(approval.value, cur_max)
161
+            cat_min[approval.category] = cur_min
162
+            cat_max[approval.category] = cur_max
163
+            cur_value = cat_value.get(approval.category, 0)
164
+            if abs(cur_min) > abs(cur_value):
165
+                cur_value = cur_min
166
+            if abs(cur_max) > abs(cur_value):
167
+                cur_value = cur_max
168
+            cat_value[approval.category] = cur_value
169
+        self._approval_cache = cat_value
170
+
171
+    def createRevision(self, *args, **kw):
172
+        session = Session.object_session(self)
173
+        args = [self] + list(args)
174
+        r = Revision(*args, **kw)
175
+        self.revisions.append(r)
176
+        session.add(r)
177
+        session.flush()
178
+        return r
179
+
180
+    def createLabel(self, *args, **kw):
181
+        session = Session.object_session(self)
182
+        args = [self] + list(args)
183
+        l = Label(*args, **kw)
184
+        self.labels.append(l)
185
+        session.add(l)
186
+        session.flush()
187
+        return l
188
+
189
+    def createApproval(self, *args, **kw):
190
+        session = Session.object_session(self)
191
+        args = [self] + list(args)
192
+        l = Approval(*args, **kw)
193
+        self.approvals.append(l)
194
+        session.add(l)
195
+        session.flush()
196
+        return l
197
+
198
+    def createPermittedLabel(self, *args, **kw):
199
+        session = Session.object_session(self)
200
+        args = [self] + list(args)
201
+        l = PermittedLabel(*args, **kw)
202
+        self.permitted_labels.append(l)
203
+        session.add(l)
204
+        session.flush()
205
+        return l
206
+
207
+class Revision(object):
208
+    def __init__(self, change, number, message, commit, parent):
209
+        self.change_key = change.key
210
+        self.number = number
211
+        self.message = message
212
+        self.commit = commit
213
+        self.parent = parent
214
+
215
+    def createMessage(self, *args, **kw):
216
+        session = Session.object_session(self)
217
+        args = [self] + list(args)
218
+        m = Message(*args, **kw)
219
+        self.messages.append(m)
220
+        session.add(m)
221
+        session.flush()
222
+        return m
223
+
224
+    def createComment(self, *args, **kw):
225
+        session = Session.object_session(self)
226
+        args = [self] + list(args)
227
+        c = Comment(*args, **kw)
228
+        self.comments.append(c)
229
+        session.add(c)
230
+        session.flush()
231
+        return c
232
+
233
+class Message(object):
234
+    def __init__(self, revision, id, created, name, message, pending=False):
235
+        self.revision_key = revision.key
236
+        self.id = id
237
+        self.created = created
238
+        self.name = name
239
+        self.message = message
240
+        self.pending = pending
241
+
242
+class Comment(object):
243
+    def __init__(self, revision, id, in_reply_to, created, name, file, parent, line, message, pending=False):
244
+        self.revision_key = revision.key
245
+        self.id = id
246
+        self.in_reply_to = in_reply_to
247
+        self.created = created
248
+        self.name = name
249
+        self.file = file
250
+        self.parent = parent
251
+        self.line = line
252
+        self.message = message
253
+        self.pending = pending
254
+
255
+class Label(object):
256
+    def __init__(self, change, category, value, description):
257
+        self.change_key = change.key
258
+        self.category = category
259
+        self.value = value
260
+        self.description = description
261
+
262
+class PermittedLabel(object):
263
+    def __init__(self, change, category, value):
264
+        self.change_key = change.key
265
+        self.category = category
266
+        self.value = value
267
+
268
+class Approval(object):
269
+    def __init__(self, change, name, category, value, pending=False):
270
+        self.change_key = change.key
271
+        self.name = name
272
+        self.category = category
273
+        self.value = value
274
+        self.pending = pending
275
+
276
+mapper(Project, project_table, properties=dict(
277
+        changes=relationship(Change, backref='project',
278
+                             order_by=change_table.c.number),
279
+        unreviewed_changes=relationship(Change,
280
+                                        primaryjoin=and_(project_table.c.key==change_table.c.project_key,
281
+                                                         change_table.c.hidden==False,
282
+                                                         change_table.c.reviewed==False),
283
+                                        order_by=change_table.c.number,
284
+                                        ),
285
+        reviewed_changes=relationship(Change,
286
+                                      primaryjoin=and_(project_table.c.key==change_table.c.project_key,
287
+                                                       change_table.c.hidden==False,
288
+                                                       change_table.c.reviewed==True),
289
+                                        order_by=change_table.c.number,
290
+                                      ),
291
+        updated = column_property(
292
+            select([func.max(change_table.c.updated)]).where(
293
+                change_table.c.project_key==project_table.c.key)
294
+            ),
295
+        ))
296
+mapper(Change, change_table, properties=dict(
297
+        revisions=relationship(Revision, backref='change',
298
+                               order_by=revision_table.c.number),
299
+        messages=relationship(Message,
300
+                              secondary=revision_table,
301
+                              order_by=message_table.c.created),
302
+        labels=relationship(Label, backref='change', order_by=(label_table.c.category,
303
+                                                               label_table.c.value)),
304
+        permitted_labels=relationship(PermittedLabel, backref='change',
305
+                                      order_by=(permitted_label_table.c.category,
306
+                                                permitted_label_table.c.value)),
307
+        approvals=relationship(Approval, backref='change', order_by=(approval_table.c.category,
308
+                                                                     approval_table.c.value)),
309
+        pending_approvals=relationship(Approval,
310
+                                       primaryjoin=and_(change_table.c.key==approval_table.c.change_key,
311
+                                                        approval_table.c.pending==True),
312
+                                       order_by=(approval_table.c.category,
313
+                                                 approval_table.c.value))
314
+        ))
315
+mapper(Revision, revision_table, properties=dict(
316
+        messages=relationship(Message, backref='revision'),
317
+        comments=relationship(Comment, backref='revision',
318
+                              order_by=(comment_table.c.line,
319
+                                        comment_table.c.created)),
320
+        pending_comments=relationship(Comment,
321
+                                      primaryjoin=and_(revision_table.c.key==comment_table.c.revision_key,
322
+                                                       comment_table.c.pending==True),
323
+                                      order_by=(comment_table.c.line,
324
+                                                comment_table.c.created)),
325
+        ))
326
+mapper(Message, message_table)
327
+mapper(Comment, comment_table)
328
+mapper(Label, label_table)
329
+mapper(PermittedLabel, permitted_label_table)
330
+mapper(Approval, approval_table)
331
+
332
+class Database(object):
333
+    def __init__(self, app):
334
+        self.app = app
335
+        self.engine = create_engine(self.app.config.dburi)
336
+        metadata.create_all(self.engine)
337
+        self.session_factory = sessionmaker(bind=self.engine)
338
+        self.session = scoped_session(self.session_factory)
339
+
340
+    def getSession(self):
341
+        return DatabaseSession(self.session)
342
+
343
+class DatabaseSession(object):
344
+    def __init__(self, session):
345
+        self.session = session
346
+
347
+    def __enter__(self):
348
+        return self
349
+
350
+    def __exit__(self, etype, value, tb):
351
+        if etype:
352
+            self.session().rollback()
353
+        else:
354
+            self.session().commit()
355
+        self.session().close()
356
+        self.session = None
357
+
358
+    def abort(self):
359
+        self.session().rollback()
360
+
361
+    def commit(self):
362
+        self.session().commit()
363
+
364
+    def delete(self, obj):
365
+        self.session().delete(obj)
366
+
367
+    def getProjects(self, subscribed=False):
368
+        if subscribed:
369
+            return self.session().query(Project).filter_by(subscribed=subscribed).order_by(Project.name).all()
370
+        else:
371
+            return self.session().query(Project).order_by(Project.name).all()
372
+
373
+    def getProject(self, key):
374
+        try:
375
+            return self.session().query(Project).filter_by(key=key).one()
376
+        except sqlalchemy.orm.exc.NoResultFound:
377
+            return None
378
+
379
+    def getProjectByName(self, name):
380
+        try:
381
+            return self.session().query(Project).filter_by(name=name).one()
382
+        except sqlalchemy.orm.exc.NoResultFound:
383
+            return None
384
+
385
+    def getChange(self, key):
386
+        try:
387
+            return self.session().query(Change).filter_by(key=key).one()
388
+        except sqlalchemy.orm.exc.NoResultFound:
389
+            return None
390
+
391
+    def getChangeByID(self, id):
392
+        try:
393
+            return self.session().query(Change).filter_by(id=id).one()
394
+        except sqlalchemy.orm.exc.NoResultFound:
395
+            return None
396
+
397
+    def getRevision(self, key):
398
+        try:
399
+            return self.session().query(Revision).filter_by(key=key).one()
400
+        except sqlalchemy.orm.exc.NoResultFound:
401
+            return None
402
+
403
+    def getRevisionByCommit(self, commit):
404
+        try:
405
+            return self.session().query(Revision).filter_by(commit=commit).one()
406
+        except sqlalchemy.orm.exc.NoResultFound:
407
+            return None
408
+
409
+    def getRevisionByNumber(self, change, number):
410
+        try:
411
+            return self.session().query(Revision).filter_by(change_key=change.key, number=number).one()
412
+        except sqlalchemy.orm.exc.NoResultFound:
413
+            return None
414
+
415
+    def getComment(self, key):
416
+        try:
417
+            return self.session().query(Comment).filter_by(key=key).one()
418
+        except sqlalchemy.orm.exc.NoResultFound:
419
+            return None
420
+
421
+    def getCommentByID(self, id):
422
+        try:
423
+            return self.session().query(Comment).filter_by(id=id).one()
424
+        except sqlalchemy.orm.exc.NoResultFound:
425
+            return None
426
+
427
+    def getMessage(self, key):
428
+        try:
429
+            return self.session().query(Message).filter_by(key=key).one()
430
+        except sqlalchemy.orm.exc.NoResultFound:
431
+            return None
432
+
433
+    def getMessageByID(self, id):
434
+        try:
435
+            return self.session().query(Message).filter_by(id=id).one()
436
+        except sqlalchemy.orm.exc.NoResultFound:
437
+            return None
438
+
439
+    def getPendingMessages(self):
440
+        return self.session().query(Message).filter_by(pending=True).all()
441
+
442
+    def createProject(self, *args, **kw):
443
+        o = Project(*args, **kw)
444
+        self.session().add(o)
445
+        self.session().flush()
446
+        return o

+ 186
- 0
gertty/gertty.py View File

@@ -0,0 +1,186 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import argparse
16
+import logging
17
+import os
18
+import sys
19
+import threading
20
+
21
+import urwid
22
+
23
+import db
24
+import config
25
+import gitrepo
26
+import mywid
27
+import sync
28
+import view.project_list
29
+
30
+palette=[('reversed', 'default,standout', ''),
31
+         ('header', 'white,bold', 'dark blue'),
32
+         ('error', 'light red', 'dark blue'),
33
+         ('table-header', 'white,bold', ''),
34
+         # Diff
35
+         ('removed-line', 'dark red', ''),
36
+         ('removed-word', 'light red', ''),
37
+         ('added-line', 'dark green', ''),
38
+         ('added-word', 'light green', ''),
39
+         ('nonexistent', 'default', ''),
40
+         ('reversed-removed-line', 'dark red,standout', ''),
41
+         ('reversed-removed-word', 'light red,standout', ''),
42
+         ('reversed-added-line', 'dark green,standout', ''),
43
+         ('reversed-added-word', 'light green,standout', ''),
44
+         ('reversed-nonexistent', 'default,standout', ''),
45
+         ('draft-comment', 'default', 'dark gray'),
46
+         ('comment', 'white', 'dark gray'),
47
+         # Change view
48
+         ('change-data', 'light cyan', ''),
49
+         ('change-header', 'light blue', ''),
50
+         ('revision-name', 'light blue', ''),
51
+         ('revision-commit', 'dark blue', ''),
52
+         ('revision-drafts', 'dark red', ''),
53
+         ('reversed-revision-name', 'light blue,standout', ''),
54
+         ('reversed-revision-commit', 'dark blue,standout', ''),
55
+         ('reversed-revision-drafts', 'dark red,standout', ''),
56
+         ('change-message-name', 'light blue', ''),
57
+         ('change-message-header', 'dark blue', ''),
58
+         # project list
59
+         ('unreviewed-project', 'white', ''),
60
+         ('subscribed-project', 'default', ''),
61
+         ('unsubscribed-project', 'dark gray', ''),
62
+         ('reversed-unreviewed-project', 'white,standout', ''),
63
+         ('reversed-subscribed-project', 'default,standout', ''),
64
+         ('reversed-unsubscribed-project', 'dark gray,standout', ''),
65
+         # change list
66
+         ('unreviewed-change', 'default', ''),
67
+         ('reviewed-change', 'dark gray', ''),
68
+         ('reversed-unreviewed-change', 'default,standout', ''),
69
+         ('reversed-reviewed-change', 'dark gray,standout', ''),
70
+         ]
71
+
72
+class StatusHeader(urwid.WidgetWrap):
73
+    def __init__(self, app):
74
+        super(StatusHeader, self).__init__(urwid.Columns([]))
75
+        self.app = app
76
+        self.title = urwid.Text(u'Start')
77
+        self.error = urwid.Text('')
78
+        self.offline = urwid.Text('')
79
+        self.sync = urwid.Text(u'Sync: 0')
80
+        self._w.contents.append((self.title, ('pack', None, False)))
81
+        self._w.contents.append((urwid.Text(u''), ('weight', 1, False)))
82
+        self._w.contents.append((self.error, ('pack', None, False)))
83
+        self._w.contents.append((self.offline, ('pack', None, False)))
84
+        self._w.contents.append((self.sync, ('pack', None, False)))
85
+
86
+    def update(self, title=None, error=False, offline=None):
87
+        if title:
88
+            self.title.set_text(title)
89
+        if error:
90
+            self.error.set_text(('error', u'Error'))
91
+        if offline is not None:
92
+            if offline:
93
+                self.error.set_text(u'Offline')
94
+            else:
95
+                self.error.set_text(u'')
96
+        self.sync.set_text(u' Sync: %i' % self.app.sync.queue.qsize())
97
+
98
+class App(object):
99
+    def __init__(self, server=None, debug=False):
100
+        self.server = server
101
+        self.config = config.Config(server)
102
+        if debug:
103
+            level = logging.DEBUG
104
+        else:
105
+            level = logging.WARNING
106
+        logging.basicConfig(filename=self.config.log_file, filemode='w',
107
+                            format='%(asctime)s %(message)s',
108
+                            level=level)
109
+        self.log = logging.getLogger('gertty.App')
110
+        self.log.debug("Starting")
111
+        self.db = db.Database(self)
112
+        self.sync = sync.Sync(self)
113
+
114
+        self.screens = []
115
+        self.status = StatusHeader(self)
116
+        self.header = urwid.AttrMap(self.status, 'header')
117
+        screen = view.project_list.ProjectListView(self)
118
+        self.status.update(title=screen.title)
119
+        self.loop = urwid.MainLoop(screen, palette=palette,
120
+                                   unhandled_input=self.unhandledInput)
121
+        sync_pipe = self.loop.watch_pipe(self.refresh)
122
+        #self.loop.screen.set_terminal_properties(colors=88)
123
+        self.sync_thread = threading.Thread(target=self.sync.run, args=(sync_pipe,))
124
+        self.sync_thread.start()
125
+        self.loop.run()
126
+
127
+    def changeScreen(self, widget):
128
+        self.status.update(title=widget.title)
129
+        self.screens.append(self.loop.widget)
130
+        self.loop.widget = widget
131
+
132
+    def backScreen(self):
133
+        if not self.screens:
134
+            return
135
+        widget = self.screens.pop()
136
+        self.status.update(title=widget.title)
137
+        self.loop.widget = widget
138
+        self.refresh()
139
+
140
+    def refresh(self, data=None):
141
+        widget = self.loop.widget
142
+        while isinstance(widget, urwid.Overlay):
143
+            widget = widget.contents[0][0]
144
+        widget.refresh()
145
+
146
+    def popup(self, widget,
147
+              relative_width=50, relative_height=25,
148
+              min_width=20, min_height=8):
149
+        overlay = urwid.Overlay(widget, self.loop.widget,
150
+                                'center', ('relative', relative_width),
151
+                                'middle', ('relative', relative_height),
152
+                                min_width=min_width, min_height=min_height)
153
+        self.screens.append(self.loop.widget)
154
+        self.loop.widget = overlay
155
+
156
+    def help(self):
157
+        if not hasattr(self.loop.widget, 'help'):
158
+            return
159
+        dialog = mywid.MessageDialog('Help', self.loop.widget.help)
160
+        lines = self.loop.widget.help.split('\n')
161
+        urwid.connect_signal(dialog, 'close',
162
+            lambda button: self.backScreen())
163
+        self.popup(dialog, min_width=76, min_height=len(lines)+2)
164
+
165
+    def unhandledInput(self, key):
166
+        if key == 'esc':
167
+            self.backScreen()
168
+        elif key == 'f1':
169
+            self.help()
170
+
171
+    def getRepo(self, project_name):
172
+        local_path = os.path.join(self.config.git_root, project_name)
173
+        local_root = os.path.abspath(self.config.git_root)
174
+        assert os.path.commonprefix((local_root, local_path)) == local_root
175
+        return gitrepo.Repo(self.config.url+'p/'+project_name,
176
+                            local_path)
177
+
178
+if __name__ == '__main__':
179
+    parser = argparse.ArgumentParser(
180
+        description='Console client for Gerrit Code Review.')
181
+    parser.add_argument('-d', dest='debug', action='store_true',
182
+                        help='enable debug logging')
183
+    parser.add_argument('server', nargs='?',
184
+                        help='the server to use (as specified in config file)')
185
+    args = parser.parse_args()
186
+    g = App(args.server, args.debug)

+ 196
- 0
gertty/gitrepo.py View File

@@ -0,0 +1,196 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import difflib
16
+import os
17
+import re
18
+
19
+import git
20
+
21
+class DiffFile(object):
22
+    def __init__(self):
23
+        self.newname = None
24
+        self.oldname = None
25
+        self.oldlines = []
26
+        self.newlines = []
27
+
28
+class GitCheckoutError(Exception):
29
+    def __init__(self, msg):
30
+        super(GitCheckoutError, self).__init__(msg)
31
+        self.msg = msg
32
+
33
+class Repo(object):
34
+    def __init__(self, url, path):
35
+        self.url = url
36
+        self.path = path
37
+        self.differ = difflib.Differ()
38
+        if not os.path.exists(path):
39
+            git.Repo.clone_from(self.url, self.path)
40
+
41
+    def fetch(self, url, refspec):
42
+        repo = git.Repo(self.path)
43
+        try:
44
+            repo.git.fetch(url, refspec)
45
+        except AssertionError:
46
+            repo.git.fetch(url, refspec)
47
+
48
+    def checkout(self, ref):
49
+        repo = git.Repo(self.path)
50
+        try:
51
+            repo.git.checkout(ref)
52
+        except git.exc.GitCommandError as e:
53
+            raise GitCheckoutError(e.stderr.replace('\t', '    '))
54
+
55
+    def diffstat(self, old, new):
56
+        repo = git.Repo(self.path)
57
+        diff = repo.git.diff('-M', '--numstat', old, new)
58
+        ret = []
59
+        for x in diff.split('\n'):
60
+            # Added, removed, filename
61
+            ret.append(x.split('\t'))
62
+        return ret
63
+
64
+    def intraline_diff(self, old, new):
65
+        prevline = None
66
+        prevstyle = None
67
+        output_old = []
68
+        output_new = []
69
+        #socket.send('startold' + repr(old)+'\n')
70
+        #socket.send('startnew' + repr(new)+'\n')
71
+        for line in self.differ.compare(old, new):
72
+            #socket.sendall('diff output: ' + line+'\n')
73
+            key = line[0]
74
+            rest = line[2:]
75
+            if key == '?':
76
+                result = []
77
+                accumulator = ''
78
+                emphasis = False
79
+                rest = rest[:-1]  # It has a newline.
80
+                for i, c in enumerate(prevline):
81
+                    if i >= len(rest):
82
+                        indicator = ' '
83
+                    else:
84
+                        indicator = rest[i]
85
+                    #socket.sendall('%s %s %s %s %s\n' % (i, c, indicator, emphasis, accumulator))
86
+                    if indicator != ' ' and not emphasis:
87
+                        # changing from not emph to emph
88
+                        if accumulator:
89
+                            result.append((prevstyle+'-line', accumulator))
90
+                        accumulator = ''
91
+                        emphasis = True
92
+                    elif indicator == ' ' and emphasis:
93
+                        # changing from emph to not emph
94
+                        if accumulator:
95
+                            result.append((prevstyle+'-word', accumulator))
96
+                        accumulator = ''
97
+                        emphasis = False
98
+                    accumulator += c
99
+                if accumulator:
100
+                    if emphasis:
101
+                        result.append((prevstyle+'-word', accumulator))
102
+                    else:
103
+                        result.append((prevstyle+'-line', accumulator))
104
+                if prevstyle == 'added':
105
+                    output_new.append(result)
106
+                elif prevstyle == 'removed':
107
+                    output_old.append(result)
108
+                prevline = None
109
+                continue
110
+            if prevline is not None:
111
+                if prevstyle == 'added':
112
+                    output_new.append((prevstyle+'-line', prevline))
113
+                elif prevstyle == 'removed':
114
+                    output_old.append((prevstyle+'-line', prevline))
115
+            if key == '+':
116
+                prevstyle = 'added'
117
+            elif key == '-':
118
+                prevstyle = 'removed'
119
+            prevline = rest
120
+        #socket.sendall('prev'+repr(prevline)+'\n')
121
+        if prevline is not None:
122
+            if prevstyle == 'added':
123
+                output_new.append((prevstyle+'-line', prevline))
124
+            elif prevstyle == 'removed':
125
+                output_old.append((prevstyle+'-line', prevline))
126
+        #socket.sendall(repr(output_old)+'\n')
127
+        #socket.sendall(repr(output_new)+'\n')
128
+        #socket.sendall('\n')
129
+        return output_old, output_new
130
+
131
+    header_re = re.compile('@@ -(\d+)(,\d+)? \+(\d+)(,\d+)? @@')
132
+    def diff(self, old, new, context=20):
133
+        repo = git.Repo(self.path)
134
+        #'-y', '-x', 'diff -C10', old, new, path).split('\n'):
135
+        oldc = repo.commit(old)
136
+        newc = repo.commit(new)
137
+        files = []
138
+        for context in oldc.diff(newc, create_patch=True, U=context):
139
+            f = DiffFile()
140
+            files.append(f)
141
+            old_lineno = 0
142
+            new_lineno = 0
143
+            offset = 0
144
+            oldchunk = []
145
+            newchunk = []
146
+            for line in context.diff.split('\n'):
147
+                if line.startswith('---'):
148
+                    f.oldname = line[6:]
149
+                    continue
150
+                if line.startswith('+++'):
151
+                    f.newname = line[6:]
152
+                    continue
153
+                if line.startswith('@@'):
154
+                    #socket.sendall(line)
155
+                    m = self.header_re.match(line)
156
+                    #socket.sendall(str(m.groups()))
157
+                    old_lineno = int(m.group(1))
158
+                    new_lineno = int(m.group(3))
159
+                    continue
160
+                if not line:
161
+                    line = ' '
162
+                key = line[0]
163
+                rest = line[1:]
164
+                if key == '-':
165
+                    oldchunk.append(rest)
166
+                    continue
167
+                if key == '+':
168
+                    newchunk.append(rest)
169
+                    continue
170
+                # end of chunk
171
+                if oldchunk or newchunk:
172
+                    oldchunk, newchunk = self.intraline_diff(oldchunk, newchunk)
173
+                for l in oldchunk:
174
+                    f.oldlines.append((old_lineno, '-', l))
175
+                    old_lineno += 1
176
+                    offset -= 1
177
+                for l in newchunk:
178
+                    f.newlines.append((new_lineno, '+', l))
179
+                    new_lineno += 1
180
+                    offset += 1
181
+                oldchunk = []
182
+                newchunk = []
183
+                while offset > 0:
184
+                    f.oldlines.append((None, '', ''))
185
+                    offset -= 1
186
+                while offset < 0:
187
+                    f.newlines.append((None, '', ''))
188
+                    offset += 1
189
+                if key == ' ':
190
+                    f.oldlines.append((old_lineno, ' ', rest))
191
+                    f.newlines.append((new_lineno, ' ', rest))
192
+                    old_lineno += 1
193
+                    new_lineno += 1
194
+                    continue
195
+                raise Exception("Unhandled line: %s" % line)
196
+        return files

+ 61
- 0
gertty/mywid.py View File

@@ -0,0 +1,61 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import urwid
16
+
17
+class TextButton(urwid.Button):
18
+    def selectable(self):
19
+        return True
20
+
21
+    def __init__(self, text, on_press=None, user_data=None):
22
+        super(TextButton, self).__init__('', on_press=on_press, user_data=user_data)
23
+        text = urwid.Text(text)
24
+        self._w = urwid.AttrMap(text, None, focus_map='reversed')
25
+
26
+class FixedButton(urwid.Button):
27
+    def sizing(self):
28
+        return frozenset([urwid.FIXED])
29
+
30
+    def pack(self, size, focus=False):
31
+        return (len(self.get_label())+4, 1)
32
+
33
+class TableColumn(urwid.Pile):
34
+    def pack(self, size, focus=False):
35
+        mx = max([len(i[0].text) for i in self.contents])
36
+        return (mx+2, len(self.contents))
37
+
38
+class Table(urwid.WidgetWrap):
39
+    def __init__(self, headers=[]):
40
+        super(Table, self).__init__(
41
+            urwid.Columns([('pack', TableColumn([('pack', w)])) for w in headers]))
42
+
43
+    def addRow(self, cells=[]):
44
+        for i, widget in enumerate(cells):
45
+            self._w.contents[i][0].contents.append((widget, ('pack', None)))
46
+
47
+class MessageDialog(urwid.WidgetWrap):
48
+    signals = ['close']
49
+    def __init__(self, title, message):
50
+        ok_button = FixedButton(u'OK')
51
+        urwid.connect_signal(ok_button, 'click',
52
+            lambda button:self._emit('close'))
53
+        buttons = urwid.Columns([('pack', ok_button)],
54
+                                dividechars=2)
55
+        rows = []
56
+        rows.append(urwid.Text(message))
57
+        rows.append(urwid.Divider())
58
+        rows.append(buttons)
59
+        pile = urwid.Pile(rows)
60
+        fill = urwid.Filler(pile, valign='top')
61
+        super(MessageDialog, self).__init__(urwid.LineBox(fill, title))

+ 453
- 0
gertty/sync.py View File

@@ -0,0 +1,453 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import collections
16
+import logging
17
+import math
18
+import os
19
+import threading
20
+import urlparse
21
+import json
22
+import time
23
+import Queue
24
+import datetime
25
+
26
+import dateutil.parser
27
+import requests
28
+
29
+HIGH_PRIORITY=0
30
+NORMAL_PRIORITY=1
31
+LOW_PRIORITY=2
32
+
33
+class MultiQueue(object):
34
+    def __init__(self, priorities):
35
+        self.queues = collections.OrderedDict()
36
+        for key in priorities:
37
+            self.queues[key] = collections.deque()
38
+        self.condition = threading.Condition()
39
+
40
+    def qsize(self):
41
+        count = 0
42
+        for queue in self.queues.values():
43
+            count += len(queue)
44
+        return count
45
+
46
+    def put(self, item, priority):
47
+        self.condition.acquire()
48
+        try:
49
+            self.queues[priority].append(item)
50
+            self.condition.notify()
51
+        finally:
52
+            self.condition.release()
53
+
54
+    def get(self):
55
+        self.condition.acquire()
56
+        try:
57
+            while True:
58
+                for queue in self.queues.values():
59
+                    try:
60
+                        ret = queue.popleft()
61
+                        return ret
62
+                    except IndexError:
63
+                        pass
64
+                self.condition.wait()
65
+        finally:
66
+            self.condition.release()
67
+
68
+class Task(object):
69
+    def __init__(self, priority=NORMAL_PRIORITY):
70
+        self.log = logging.getLogger('gertty.sync')
71
+        self.priority = priority
72
+        self.succeeded = None
73
+        self.event = threading.Event()
74
+
75
+    def complete(self, success):
76
+        self.succeeded = success
77
+        self.event.set()
78
+
79
+    def wait(self):
80
+        self.event.wait()
81
+
82
+class SyncProjectListTask(Task):
83
+    def __repr__(self):
84
+        return '<SyncProjectListTask>'
85
+
86
+    def run(self, sync):
87
+        app = sync.app
88
+        with app.db.getSession() as session:
89
+            remote = sync.get('projects/?d')
90
+            remote_keys = set(remote.keys())
91
+
92
+            local = {}
93
+            for p in session.getProjects():
94
+                local[p.name] = p
95
+            local_keys = set(local.keys())
96
+
97
+            for name in local_keys-remote_keys:
98
+                session.delete(local[name])
99
+
100
+            for name in remote_keys-local_keys:
101
+                p = remote[name]
102
+                session.createProject(name, description=p.get('description', ''))
103
+
104
+class SyncSubscribedProjectsTask(Task):
105
+    def __repr__(self):
106
+        return '<SyncSubscribedProjectsTask>'
107
+
108
+    def run(self, sync):
109
+        app = sync.app
110
+        with app.db.getSession() as session:
111
+            for p in session.getProjects(subscribed=True):
112
+                sync.submitTask(SyncProjectTask(p.key, self.priority))
113
+
114
+class SyncProjectTask(Task):
115
+    _closed_statuses = ['MERGED', 'ABANDONED']
116
+
117
+    def __init__(self, project_key, priority=NORMAL_PRIORITY):
118
+        super(SyncProjectTask, self).__init__(priority)
119
+        self.project_key = project_key
120
+
121
+    def __repr__(self):
122
+        return '<SyncProjectTask %s>' % (self.project_key,)
123
+
124
+    def run(self, sync):
125
+        app = sync.app
126
+        with app.db.getSession() as session:
127
+            project = session.getProject(self.project_key)
128
+            query = 'project:%s' % project.name
129
+            if project.updated:
130
+                query += ' -age:%ss' % (int(math.ceil((datetime.datetime.utcnow()-project.updated).total_seconds())) + 0,)
131
+            changes = sync.get('changes/?q=%s' % query)
132
+            self.log.debug('Query: %s ' % (query,))
133
+            for c in reversed(changes):
134
+                # The list we get is newest to oldest; if we are
135
+                # interrupted, we will have already synced the newest
136
+                # change and a subsequent sync will not catch up the
137
+                # old ones.  So reverse the list before we process it
138
+                # so that the updated time is accurate.
139
+                # For now, just sync open changes or changes already
140
+                # in the db optionally we could sync all changes ever
141
+                change = session.getChangeByID(c['id'])
142
+                if change or (c['status'] not in self._closed_statuses):
143
+                    sync.submitTask(SyncChangeTask(c['id'], self.priority))
144
+                    self.log.debug("Change %s update %s" % (c['id'], c['updated']))
145
+
146
+class SyncChangeTask(Task):
147
+    def __init__(self, change_id, priority=NORMAL_PRIORITY):
148
+        super(SyncChangeTask, self).__init__(priority)
149
+        self.change_id = change_id
150
+
151
+    def __repr__(self):
152
+        return '<SyncChangeTask %s>' % (self.change_id,)
153
+
154
+    def run(self, sync):
155
+        app = sync.app
156
+        remote_change = sync.get('changes/%s?o=DETAILED_LABELS&o=ALL_REVISIONS&o=ALL_COMMITS&o=MESSAGES&o=DETAILED_ACCOUNTS' % self.change_id)
157
+        fetches = []
158
+        with app.db.getSession() as session:
159
+            change = session.getChangeByID(self.change_id)
160
+            if not change:
161
+                project = session.getProjectByName(remote_change['project'])
162
+                created = dateutil.parser.parse(remote_change['created'])
163
+                updated = dateutil.parser.parse(remote_change['updated'])
164
+                change = project.createChange(remote_change['id'], remote_change['_number'],
165
+                                              remote_change['branch'], remote_change['change_id'],
166
+                                              remote_change['owner']['name'],
167
+                                              remote_change['subject'], created,
168
+                                              updated, remote_change['status'],
169
+                                              topic=remote_change.get('topic'))
170
+            change.status = remote_change['status']
171
+            change.subject = remote_change['subject']
172
+            change.updated = dateutil.parser.parse(remote_change['updated'])
173
+            change.topic = remote_change.get('topic')
174
+            repo = app.getRepo(change.project.name)
175
+            new_revision = False
176
+            for remote_commit, remote_revision in remote_change.get('revisions', {}).items():
177
+                revision = session.getRevisionByCommit(remote_commit)
178
+                if not revision:
179
+                    # TODO: handle multiple parents
180
+                    url = sync.app.config.url + change.project.name
181
+                    if 'anonymous http' in remote_revision['fetch']:
182
+                        ref = remote_revision['fetch']['anonymous http']['ref']
183
+                    else:
184
+                        ref = remote_revision['fetch']['http']['ref']
185
+                        url = list(urlparse.urlsplit(url))
186
+                        url[1] = '%s:%s@%s' % (sync.app.config.username,
187
+                                               sync.app.config.password, url[1])
188
+                        url = urlparse.urlunsplit(url)
189
+                    fetches.append((url, ref))
190
+                    revision = change.createRevision(remote_revision['_number'],
191
+                                                     remote_revision['commit']['message'], remote_commit,
192
+                                                     remote_revision['commit']['parents'][0]['commit'])
193
+                    new_revision = True
194
+                remote_comments = sync.get('changes/%s/revisions/%s/comments' % (self.change_id, revision.commit))
195
+                for remote_file, remote_comments in remote_comments.items():
196
+                    for remote_comment in remote_comments:
197
+                        comment = session.getCommentByID(remote_comment['id'])
198
+                        if not comment:
199
+                            # Normalize updated -> created
200
+                            created = dateutil.parser.parse(remote_comment['updated'])
201
+                            parent = False
202
+                            if remote_comment.get('side', '') == 'PARENT':
203
+                                parent = True
204
+                            comment = revision.createComment(remote_comment['id'],
205
+                                                             remote_comment.get('in_reply_to'),
206
+                                                             created, remote_comment['author']['name'],
207
+                                                             remote_file, parent, remote_comment.get('line'),
208
+                                                             remote_comment['message'])
209
+            new_message = False
210
+            for remote_message in remote_change.get('messages', []):
211
+                message = session.getMessageByID(remote_message['id'])
212
+                if not message:
213
+                    revision = session.getRevisionByNumber(change, remote_message['_revision_number'])
214
+                    # Normalize date -> created
215
+                    created = dateutil.parser.parse(remote_message['date'])
216
+                    if 'author' in remote_message:
217
+                        author_name = remote_message['author']['name']
218
+                        if remote_message['author']['username'] != app.config.username:
219
+                            new_message = True
220
+                    else:
221
+                        author_name = 'Gerrit Code Review'
222
+                    message = revision.createMessage(remote_message['id'], created,
223
+                                                     author_name,
224
+                                                     remote_message['message'])
225
+            remote_approval_entries = {}
226
+            remote_label_entries = {}
227
+            user_voted = False
228
+            for remote_label_name, remote_label_dict in remote_change.get('labels', {}).items():
229
+                for remote_approval in remote_label_dict.get('all', []):
230
+                    if remote_approval.get('value') is None:
231
+                        continue
232
+                    remote_approval['category'] = remote_label_name
233
+                    key = '%s~%s' % (remote_approval['category'], remote_approval['name'])
234
+                    remote_approval_entries[key] = remote_approval
235
+                    if remote_approval.get('username', None) == app.config.username and int(remote_approval['value']) != 0:
236
+                        user_voted = True
237
+                for key, value in remote_label_dict.get('values', {}).items():
238
+                    # +1: "LGTM"
239
+                    label = dict(value=key,
240
+                                 description=value,
241
+                                 category=remote_label_name)
242
+                    key = '%s~%s~%s' % (label['category'], label['value'], label['description'])
243
+                    remote_label_entries[key] = label
244
+            remote_approval_keys = set(remote_approval_entries.keys())
245
+            remote_label_keys = set(remote_label_entries.keys())
246
+            local_approvals = {}
247
+            local_labels = {}
248
+            for approval in change.approvals:
249
+                key = '%s~%s' % (approval.category, approval.name)
250
+                local_approvals[key] = approval
251
+            local_approval_keys = set(local_approvals.keys())
252
+            for label in change.labels:
253
+                key = '%s~%s~%s' % (label.category, label.value, label.description)
254
+                local_labels[key] = label
255
+            local_label_keys = set(local_labels.keys())
256
+
257
+            for key in local_approval_keys-remote_approval_keys:
258
+                session.delete(local_approvals[key])
259
+
260
+            for key in local_label_keys-remote_label_keys:
261
+                session.delete(local_labels[key])
262
+
263
+            for key in remote_approval_keys-local_approval_keys:
264
+                remote_approval = remote_approval_entries[key]
265
+                change.createApproval(remote_approval['name'],
266
+                                      remote_approval['category'],
267
+                                      remote_approval['value'])
268
+
269
+            for key in remote_label_keys-local_label_keys:
270
+                remote_label = remote_label_entries[key]
271
+                change.createLabel(remote_label['category'],
272
+                                   remote_label['value'],
273
+                                   remote_label['description'])
274
+
275
+            for key in remote_approval_keys.intersection(local_approval_keys):
276
+                local_approval = local_approvals[key]
277
+                remote_approval = remote_approval_entries[key]
278
+                local_approval.value = remote_approval['value']
279
+
280
+            remote_permitted_entries = {}
281
+            for remote_label_name, remote_label_values in remote_change.get('permitted_labels', {}).items():
282
+                for remote_label_value in remote_label_values:
283
+                    remote_label = dict(category=remote_label_name,
284
+                                        value=remote_label_value)
285
+                    key = '%s~%s' % (remote_label['category'], remote_label['value'])
286
+                    remote_permitted_entries[key] = remote_label
287
+            remote_permitted_keys = set(remote_permitted_entries.keys())
288
+            local_permitted = {}
289
+            for permitted in change.permitted_labels:
290
+                key = '%s~%s' % (permitted.category, permitted.value)
291
+                local_permitted[key] = permitted
292
+            local_permitted_keys = set(local_permitted.keys())
293
+
294
+            for key in local_permitted_keys-remote_permitted_keys:
295
+                session.delete(local_permitted[key])
296
+
297
+            for key in remote_permitted_keys-local_permitted_keys:
298
+                remote_permitted = remote_permitted_entries[key]
299
+                change.createPermittedLabel(remote_permitted['category'],
300
+                                            remote_permitted['value'])
301
+
302
+            if not user_voted:
303
+                # Only consider changing the reviewed state if we don't have a vote
304
+                if new_revision or new_message:
305
+                    change.reviewed = False
306
+        for (url, ref) in fetches:
307
+            self.log.debug("git fetch %s %s" % (url, ref))
308
+            repo.fetch(url, ref)
309
+
310
+
311
+class UploadReviewsTask(Task):
312
+    def __repr__(self):
313
+        return '<UploadReviewsTask>'
314
+
315
+    def run(self, sync):
316
+        app = sync.app
317
+        with app.db.getSession() as session:
318
+            for m in session.getPendingMessages():
319
+                sync.submitTask(UploadReviewTask(m.key, self.priority))
320
+
321
+class UploadReviewTask(Task):
322
+    def __init__(self, message_key, priority=NORMAL_PRIORITY):
323
+        super(UploadReviewTask, self).__init__(priority)
324
+        self.message_key = message_key
325
+
326
+    def __repr__(self):
327
+        return '<UploadReviewTask %s>' % (self.message_key,)
328
+
329
+    def run(self, sync):
330
+        app = sync.app
331
+        with app.db.getSession() as session:
332
+            message = session.getMessage(self.message_key)
333
+            revision = message.revision
334
+            change = message.revision.change
335
+            current_revision = change.revisions[-1]
336
+            data = dict(message=message.message,
337
+                        strict_labels=False)
338
+            if revision == current_revision:
339
+                data['labels'] = {}
340
+                for approval in change.pending_approvals:
341
+                    data['labels'][approval.category] = approval.value
342
+                    session.delete(approval)
343
+            if revision.pending_comments:
344
+                data['comments'] = {}
345
+                last_file = None
346
+                comment_list = []
347
+                for comment in revision.pending_comments:
348
+                    if comment.file != last_file:
349
+                        last_file = comment.file
350
+                        comment_list = []
351
+                        data['comments'][comment.file] = comment_list
352
+                    d = dict(line=comment.line,
353
+                             message=comment.message)
354
+                    if comment.parent:
355
+                        d['side'] = 'PARENT'
356
+                    comment_list.append(d)
357
+                    session.delete(comment)
358
+            session.delete(message)
359
+            sync.post('changes/%s/revisions/%s/review' % (change.id, revision.commit),
360
+                      data)
361
+            sync.submitTask(SyncChangeTask(change.id, self.priority))
362
+
363
+class Sync(object):
364
+    def __init__(self, app):
365
+        self.offline = False
366
+        self.app = app
367
+        self.log = logging.getLogger('gertty.sync')
368
+        self.queue = MultiQueue([HIGH_PRIORITY, NORMAL_PRIORITY, LOW_PRIORITY])
369
+        self.submitTask(SyncProjectListTask(HIGH_PRIORITY))
370
+        self.submitTask(SyncSubscribedProjectsTask(HIGH_PRIORITY))
371
+        self.submitTask(UploadReviewsTask(HIGH_PRIORITY))
372
+        self.periodic_thread = threading.Thread(target=self.periodicSync)
373
+        self.periodic_thread.start()
374
+
375
+    def periodicSync(self):
376
+        while True:
377
+            try:
378
+                time.sleep(60)
379
+                self.syncSubscribedProjects()
380
+            except Exception:
381
+                self.log.exception('Exception in periodicSync')
382
+
383
+    def submitTask(self, task):
384
+        if not self.offline:
385
+            self.queue.put(task, task.priority)
386
+
387
+    def run(self, pipe):
388
+        task = None
389
+        while True:
390
+            task = self._run(pipe, task)
391
+
392
+    def _run(self, pipe, task=None):
393
+        if not task:
394
+            task = self.queue.get()
395
+        self.log.debug('Run: %s' % (task,))
396
+        try:
397
+            task.run(self)
398
+            task.complete(True)
399
+        except requests.ConnectionError, e:
400
+            self.log.warning("Offline due to: %s" % (e,))
401
+            if not self.offline:
402
+                self.submitTask(SyncSubscribedProjectsTask(HIGH_PRIORITY))
403
+                self.submitTask(UploadReviewsTask(HIGH_PRIORITY))
404
+            self.offline = True
405
+            self.app.status.update(offline=True)
406
+            os.write(pipe, 'refresh\n')
407
+            time.sleep(30)
408
+            return task
409
+        except Exception:
410
+            task.complete(False)
411
+            self.log.exception('Exception running task %s' % (task,))
412
+            self.app.status.update(error=True)
413
+        self.offline = False
414
+        self.app.status.update(offline=False)
415
+        os.write(pipe, 'refresh\n')
416
+        return None
417
+
418
+    def url(self, path):
419
+        return self.app.config.url + 'a/' + path
420
+
421
+    def get(self, path):
422
+        url = self.url(path)
423
+        self.log.debug('GET: %s' % (url,))
424
+        r = requests.get(url,
425
+                         verify=self.app.config.verify_ssl,
426
+                         auth=requests.auth.HTTPDigestAuth(self.app.config.username,
427
+                                                           self.app.config.password),
428
+                         headers = {'Accept': 'application/json',
429
+                                    'Accept-Encoding': 'gzip'})
430
+        self.log.debug('Received: %s' % (r.text,))
431
+        ret = json.loads(r.text[4:])
432
+        return ret
433
+
434
+    def post(self, path, data):
435
+        url = self.url(path)
436
+        self.log.debug('POST: %s' % (url,))
437
+        self.log.debug('data: %s' % (data,))
438
+        r = requests.post(url, data=json.dumps(data).encode('utf8'),
439
+                          verify=self.app.config.verify_ssl,
440
+                          auth=requests.auth.HTTPDigestAuth(self.app.config.username,
441
+                                                            self.app.config.password),
442
+                          headers = {'Content-Type': 'application/json;charset=UTF-8'})
443
+        self.log.debug('Received: %s' % (r.text,))
444
+
445
+    def syncSubscribedProjects(self):
446
+        keys = []
447
+        with self.app.db.getSession() as session:
448
+            for p in session.getProjects(subscribed=True):
449
+                keys.append(p.key)
450
+        for key in keys:
451
+            t = SyncProjectTask(key, LOW_PRIORITY)
452
+            self.submitTask(t)
453
+            t.wait()

+ 0
- 0
gertty/view/__init__.py View File


+ 372
- 0
gertty/view/change.py View File

@@ -0,0 +1,372 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import datetime
16
+
17
+import urwid
18
+
19
+import gitrepo
20
+import mywid
21
+import sync
22
+import view.diff
23
+
24
+class ReviewDialog(urwid.WidgetWrap):
25
+    signals = ['save', 'cancel']
26
+    def __init__(self, revision_row):
27
+        self.revision_row = revision_row
28
+        self.change_view = revision_row.change_view
29
+        self.app = self.change_view.app
30
+        save_button = mywid.FixedButton(u'Save')
31
+        cancel_button = mywid.FixedButton(u'Cancel')
32
+        urwid.connect_signal(save_button, 'click',
33
+            lambda button:self._emit('save'))
34
+        urwid.connect_signal(cancel_button, 'click',
35
+            lambda button:self._emit('cancel'))
36
+        buttons = urwid.Columns([('pack', save_button), ('pack', cancel_button)],
37
+                                dividechars=2)
38
+        rows = []
39
+        categories = []
40
+        values = {}
41
+        self.button_groups = {}
42
+        message = ''
43
+        with self.app.db.getSession() as session:
44
+            revision = session.getRevision(self.revision_row.revision_key)
45
+            change = revision.change
46
+            if revision == change.revisions[-1]:
47
+                for label in change.permitted_labels:
48
+                    if label.category not in categories:
49
+                        categories.append(label.category)
50
+                        values[label.category] = []
51
+                    values[label.category].append(label.value)
52
+                pending_approvals = {}
53
+                for approval in change.pending_approvals:
54
+                    pending_approvals[approval.category] = approval
55
+                for category in categories:
56
+                    rows.append(urwid.Text(category))
57
+                    group = []
58
+                    self.button_groups[category] = group
59
+                    current = pending_approvals.get(category)
60
+                    if current is None:
61
+                        current = 0
62
+                    else:
63
+                        current = current.value
64
+                    for value in values[category]:
65
+                        if value > 0:
66
+                            strvalue = '+%s' % value
67
+                        elif value == 0:
68
+                            strvalue = ' 0'
69
+                        else:
70
+                            strvalue = str(value)
71
+                        b = urwid.RadioButton(group, strvalue, state=(value == current))
72
+                        rows.append(b)
73
+                    rows.append(urwid.Divider())
74
+            for m in revision.messages:
75
+                if m.pending:
76
+                    message = m.message
77
+                    break
78
+        self.message = urwid.Edit("Message: \n", edit_text=message, multiline=True)
79
+        rows.append(self.message)
80
+        rows.append(urwid.Divider())
81
+        rows.append(buttons)
82
+        pile = urwid.Pile(rows)
83
+        fill = urwid.Filler(pile, valign='top')
84
+        super(ReviewDialog, self).__init__(urwid.LineBox(fill, 'Review'))
85
+
86
+    def save(self):
87
+        message_key = None
88
+        with self.app.db.getSession() as session:
89
+            revision = session.getRevision(self.revision_row.revision_key)
90
+            change = revision.change
91
+            pending_approvals = {}
92
+            for approval in change.pending_approvals:
93
+                pending_approvals[approval.category] = approval
94
+            for category, group in self.button_groups.items():
95
+                approval = pending_approvals.get(category)
96
+                if not approval:
97
+                    approval = change.createApproval(u'(draft)', category, 0, pending=True)
98
+                    pending_approvals[category] = approval
99
+                for button in group:
100
+                    if button.state:
101
+                        approval.value = int(button.get_label())
102
+            message = None
103
+            for m in revision.messages:
104
+                if m.pending:
105
+                    message = m
106
+                    break
107
+            if not message:
108
+                message = revision.createMessage(None,
109
+                                                 datetime.datetime.utcnow(),
110
+                                                 u'(draft)', '', pending=True)
111
+            message.message = self.message.edit_text.strip()
112
+            message_key = message.key
113
+            change.reviewed = True
114
+        return message_key
115
+
116
+    def keypress(self, size, key):
117
+        r = super(ReviewDialog, self).keypress(size, key)
118
+        if r=='esc':
119
+            self._emit('cancel')
120
+            return None
121
+        return r
122
+
123
+class ReviewButton(mywid.FixedButton):
124
+    def __init__(self, revision_row):
125
+        super(ReviewButton, self).__init__(u'Review')
126
+        self.revision_row = revision_row
127
+        self.change_view = revision_row.change_view
128
+        urwid.connect_signal(self, 'click',
129
+            lambda button: self.openReview())
130
+
131
+    def openReview(self):
132
+        self.dialog = ReviewDialog(self.revision_row)
133
+        urwid.connect_signal(self.dialog, 'save',
134
+            lambda button: self.closeReview(True))
135
+        urwid.connect_signal(self.dialog, 'cancel',
136
+            lambda button: self.closeReview(False))
137
+        self.change_view.app.popup(self.dialog,
138
+                                   relative_width=50, relative_height=75,
139
+                                   min_width=60, min_height=20)
140
+
141
+    def closeReview(self, save):
142
+        if save:
143
+            message_key = self.dialog.save()
144
+            self.change_view.app.sync.submitTask(
145
+                sync.UploadReviewTask(message_key, sync.HIGH_PRIORITY))
146
+            self.change_view.refresh()
147
+        self.change_view.app.backScreen()
148
+
149
+class RevisionRow(urwid.WidgetWrap):
150
+    revision_focus_map = {
151
+                          'revision-name': 'reversed-revision-name',
152
+                          'revision-commit': 'reversed-revision-commit',
153
+                          'revision-drafts': 'reversed-revision-drafts',
154
+                          }
155
+
156
+    def __init__(self, app, change_view, repo, revision, expanded=False):
157
+        super(RevisionRow, self).__init__(urwid.Pile([]))
158
+        self.app = app
159
+        self.change_view = change_view
160
+        self.revision_key = revision.key
161
+        self.project_name = revision.change.project.name
162
+        self.commit_sha = revision.commit
163
+        line = [('revision-name', 'Patch Set %s ' % revision.number),
164
+                ('revision-commit', revision.commit)]
165
+        if len(revision.pending_comments):
166
+            line.append(('revision-drafts', ' (%s drafts)' % len(revision.pending_comments)))
167
+        self.title = mywid.TextButton(line, on_press = self.expandContract)
168
+        stats = repo.diffstat(revision.parent, revision.commit)
169
+        rows = []
170
+        total_added = 0
171
+        total_removed = 0
172
+        for added, removed, filename in stats:
173
+            total_added += int(added)
174
+            total_removed += int(removed)
175
+            rows.append(urwid.Columns([urwid.Text(filename),
176
+                                       (10, urwid.Text('+%s, -%s' % (added, removed))),
177
+                                       ]))
178
+        rows.append(urwid.Columns([urwid.Text(''),
179
+                                   (10, urwid.Text('+%s, -%s' % (total_added, total_removed))),
180
+                                   ]))
181
+        table = urwid.Pile(rows)
182
+        buttons = urwid.Columns([('pack', ReviewButton(self)),
183
+                                 ('pack', mywid.FixedButton("Diff", on_press=self.diff)),
184
+                                 ('pack', mywid.FixedButton("Checkout", on_press=self.checkout)),
185
+                                 urwid.Text(''),
186
+                                 ], dividechars=2)
187
+        self.more = urwid.Pile([table, buttons])
188
+        self.pile = urwid.Pile([self.title])
189
+        self._w = urwid.AttrMap(self.pile, None, focus_map=self.revision_focus_map)
190
+        self.expanded = False
191
+        if expanded:
192
+            self.expandContract(None)
193
+
194
+    def expandContract(self, button):
195
+        if self.expanded:
196
+            self.pile.contents.pop()
197
+            self.expanded = False
198
+        else:
199
+            self.pile.contents.append((self.more, ('pack', None)))
200
+            self.expanded = True
201
+
202
+    def diff(self, button):
203
+        self.change_view.diff(self.revision_key)
204
+
205
+    def checkout(self, button):
206
+        repo = self.app.getRepo(self.project_name)
207
+        try:
208
+            repo.checkout(self.commit_sha)
209
+            dialog = mywid.MessageDialog('Checkout', 'Change checked out in %s' % repo.path)
210
+            min_height=8
211
+        except gitrepo.GitCheckoutError as e:
212
+            dialog = mywid.MessageDialog('Error', e.msg)
213
+            min_height=12
214
+        urwid.connect_signal(dialog, 'close',
215
+            lambda button: self.app.backScreen())
216
+        self.app.popup(dialog, min_height=min_height)
217
+
218
+class ChangeMessageBox(urwid.Text):
219
+    def __init__(self, message):
220
+        super(ChangeMessageBox, self).__init__(u'')
221
+        lines = message.message.split('\n')
222
+        text = [('change-message-name', message.name),
223
+                ('change-message-header', ': '+lines.pop(0))]
224
+        if lines and lines[-1]:
225
+            lines.append('')
226
+        text += '\n'.join(lines)
227
+        self.set_text(text)
228
+
229
+class ChangeView(urwid.WidgetWrap):
230
+    help = """
231
+<r>   Toggle the reviewed flag for the current change.
232
+<ESC> Go back to the previous screen.
233
+"""
234
+
235
+    def __init__(self, app, change_key):
236
+        super(ChangeView, self).__init__(urwid.Pile([]))
237
+        self.app = app
238
+        self.change_key = change_key
239
+        self.revision_rows = {}
240
+        self.message_rows = {}
241
+        self.change_id_label = urwid.Text(u'', wrap='clip')
242
+        self.owner_label = urwid.Text(u'', wrap='clip')
243
+        self.project_label = urwid.Text(u'', wrap='clip')
244
+        self.branch_label = urwid.Text(u'', wrap='clip')
245
+        self.topic_label = urwid.Text(u'', wrap='clip')
246
+        self.created_label = urwid.Text(u'', wrap='clip')
247
+        self.updated_label = urwid.Text(u'', wrap='clip')
248
+        self.status_label = urwid.Text(u'', wrap='clip')
249
+        change_info = []
250
+        for l, v in [("Change-Id", self.change_id_label),
251
+                     ("Owner", self.owner_label),
252
+                     ("Project", self.project_label),
253
+                     ("Branch", self.branch_label),
254
+                     ("Topic", self.topic_label),
255
+                     ("Created", self.created_label),
256
+                     ("Updated", self.updated_label),
257
+                     ("Status", self.status_label),
258
+                     ]:
259
+            row = urwid.Columns([(12, urwid.Text(('change-header', l), wrap='clip')), v])
260
+            change_info.append(row)
261
+        change_info = urwid.Pile(change_info)
262
+        self.commit_message = urwid.Text(u'')
263
+        top = urwid.Columns([change_info, ('weight', 1, self.commit_message)])
264
+        votes = mywid.Table([])
265
+
266
+        self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
267
+        self._w.contents.append((self.app.header, ('pack', 1)))
268
+        self._w.contents.append((urwid.Divider(), ('pack', 1)))
269
+        self._w.contents.append((top, ('pack', None)))
270
+        self._w.contents.append((urwid.Divider(), ('pack', 1)))
271
+        self._w.contents.append((votes, ('pack', None)))
272
+        self._w.contents.append((urwid.Divider(), ('pack', 1)))
273
+        self._w.contents.append((self.listbox, ('weight', 1)))
274
+        self._w.set_focus(6)
275
+
276
+        self.refresh()
277
+
278
+    def refresh(self):
279
+        change_info = []
280
+        with self.app.db.getSession() as session:
281
+            change = session.getChange(self.change_key)
282
+            if change.reviewed:
283
+                reviewed = ' (reviewed)'
284
+            else:
285
+                reviewed = ''
286
+            self.title = 'Change %s%s' % (change.number, reviewed)
287
+            self.app.status.update(title=self.title)
288
+            self.project_key = change.project.key
289
+
290
+            self.change_id_label.set_text(('change-data', change.change_id))
291
+            self.owner_label.set_text(('change-data', change.owner))
292
+            self.project_label.set_text(('change-data', change.project.name))
293
+            self.branch_label.set_text(('change-data', change.branch))
294
+            self.topic_label.set_text(('change-data', change.topic or ''))
295
+            self.created_label.set_text(('change-data', str(change.created)))
296
+            self.updated_label.set_text(('change-data', str(change.updated)))
297
+            self.status_label.set_text(('change-data', change.status))
298
+            self.commit_message.set_text(change.revisions[-1].message)
299
+
300
+            categories = []
301
+            approval_headers = [urwid.Text(('table-header', 'Name'))]
302
+            for label in change.labels:
303
+                if label.category in categories:
304
+                    continue
305
+                approval_headers.append(urwid.Text(('table-header', label.category)))
306
+                categories.append(label.category)
307
+            votes = mywid.Table(approval_headers)
308
+            approvals_for_name = {}
309
+            for approval in change.approvals:
310
+                approvals = approvals_for_name.get(approval.name)
311
+                if not approvals:
312
+                    approvals = {}
313
+                    row = []
314
+                    row.append(urwid.Text(approval.name))
315
+                    for i, category in enumerate(categories):
316
+                        w = urwid.Text(u'')
317
+                        approvals[category] = w
318
+                        row.append(w)
319
+                    approvals_for_name[approval.name] = approvals
320
+                    votes.addRow(row)
321
+                if str(approval.value) != '0':
322
+                    approvals[approval.category].set_text(str(approval.value))
323
+            votes = urwid.Padding(votes, width='pack')
324
+
325
+            # TODO: update the existing table rather than replacing it
326
+            # wholesale.  It will become more important if the table
327
+            # gets selectable items (like clickable names).
328
+            self._w.contents[4] = (votes, ('pack', None))
329
+
330
+            repo = self.app.getRepo(change.project.name)
331
+            # The listbox has both revisions and messages in it (and
332
+            # may later contain the vote table and change header), so
333
+            # keep track of the index separate from the loop.
334
+            listbox_index = 0
335
+            for revno, revision in enumerate(change.revisions):
336
+                row = self.revision_rows.get(revision.key)
337
+                if not row:
338
+                    row = RevisionRow(self.app, self, repo, revision,
339
+                                      expanded=(revno==len(change.revisions)-1))
340
+                    self.listbox.body.insert(listbox_index, row)
341
+                    self.revision_rows[revision.key] = row
342
+                # Revisions are extremely unlikely to be deleted, skip
343
+                # that case.
344
+                listbox_index += 1
345
+            if len(self.listbox.body) == listbox_index:
346
+                self.listbox.body.insert(listbox_index, urwid.Divider())
347
+                listbox_index += 1
348
+            for message in change.messages:
349
+                row = self.message_rows.get(message.key)
350
+                if not row:
351
+                    row = ChangeMessageBox(message)
352
+                    self.listbox.body.insert(listbox_index, row)
353
+                    self.message_rows[message.key] = row
354
+                # Messages are extremely unlikely to be deleted, skip
355
+                # that case.
356
+                listbox_index += 1
357
+
358
+    def toggleReviewed(self):
359
+        with self.app.db.getSession() as session:
360
+            change = session.getChange(self.change_key)
361
+            change.reviewed = not change.reviewed
362
+
363
+    def keypress(self, size, key):
364
+        r = super(ChangeView, self).keypress(size, key)
365
+        if r=='r':
366
+            self.toggleReviewed()
367
+            self.refresh()
368
+            return None
369
+        return r
370
+
371
+    def diff(self, revision_key):
372
+        self.app.changeScreen(view.diff.DiffView(self.app, revision_key))

+ 140
- 0
gertty/view/change_list.py View File

@@ -0,0 +1,140 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import urwid
16
+
17
+import view.change
18
+
19
+class ChangeRow(urwid.Button):
20
+    change_focus_map = {None: 'reversed',
21
+                        'unreviewed-change': 'reversed-unreviewed-change',
22
+                        'reviewed-change': 'reversed-reviewed-change',
23
+                        }
24
+
25
+    def selectable(self):
26
+        return True
27
+
28
+    def __init__(self, change, callback=None):
29
+        super(ChangeRow, self).__init__('', on_press=callback, user_data=change.key)
30
+        self.change_key = change.key
31
+        self.subject = urwid.Text(u'', wrap='clip')
32
+        self.number = urwid.Text(u'')
33
+        cols = [(8, self.number), self.subject]
34
+        self.columns = urwid.Columns(cols)
35
+        self.row_style = urwid.AttrMap(self.columns, '')
36
+        self._w = urwid.AttrMap(self.row_style, None, focus_map=self.change_focus_map)
37
+        self.update(change)
38
+
39
+    def update(self, change):
40
+        if change.reviewed:
41
+            style = 'reviewed-change'
42
+        else:
43
+            style = 'unreviewed-change'
44
+        self.row_style.set_attr_map({None: style})
45
+        self.subject.set_text(change.subject)
46
+        self.number.set_text(str(change.number))
47
+        del self.columns.contents[2:]
48
+        for category in change.getCategories():
49
+            v = change.getMaxForCategory(category)
50
+            if v == 0:
51
+                v = ''
52
+            else:
53
+                v = '%2i' % v
54
+            self.columns.contents.append((urwid.Text(v), self.columns.options('given', 3)))
55
+
56
+class ChangeListHeader(urwid.WidgetWrap):
57
+    def __init__(self):
58
+        cols = [(8, urwid.Text(u'Number')), urwid.Text(u'Subject')]
59
+        super(ChangeListHeader, self).__init__(urwid.Columns(cols))
60
+
61
+    def update(self, change):
62
+        del self._w.contents[2:]
63
+        for category in change.getCategories():
64
+            self._w.contents.append((urwid.Text(' %s' % category[0]), self._w.options('given', 3)))
65
+
66
+class ChangeListView(urwid.WidgetWrap):
67
+    help = """
68
+<l>   Toggle whether only unreviewed or all changes are displayed.
69
+<r>   Toggle the reviewed flag for the currently selected change.
70
+<ESC> Go back to the previous screen.
71
+"""
72
+
73
+    def __init__(self, app, project_key):
74
+        super(ChangeListView, self).__init__(urwid.Pile([]))
75
+        self.app = app
76
+        self.project_key = project_key
77
+        self.unreviewed = True
78
+        self.change_rows = {}
79
+        self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
80
+        self.header = ChangeListHeader()
81
+        self.refresh()
82
+        self._w.contents.append((app.header, ('pack', 1)))
83
+        self._w.contents.append((urwid.Divider(), ('pack', 1)))
84
+        self._w.contents.append((urwid.AttrWrap(self.header, 'table-header'), ('pack', 1)))
85
+        self._w.contents.append((self.listbox, ('weight', 1)))
86
+        self._w.set_focus(3)
87
+
88
+    def refresh(self):
89
+        unseen_keys = set(self.change_rows.keys())
90
+        with self.app.db.getSession() as session:
91
+            project = session.getProject(self.project_key)
92
+            self.project_name = project.name
93
+            if self.unreviewed:
94
+                self.title = u'Unreviewed changes in %s' % project.name
95
+                lst = project.unreviewed_changes
96
+            else:
97
+                self.title = u'Open changes in %s' % project.name
98
+                lst = project.changes
99
+            self.app.status.update(title=self.title)
100
+            i = 0
101
+            for change in lst:
102
+                row = self.change_rows.get(change.key)
103
+                if not row:
104
+                    row = ChangeRow(change, self.onSelect)
105
+                    self.listbox.body.insert(i, row)
106
+                    self.change_rows[change.key] = row
107
+                else:
108
+                    row.update(change)
109
+                    unseen_keys.remove(change.key)
110
+                i += 1
111
+            if project.changes:
112
+                self.header.update(project.changes[0])
113
+        for key in unseen_keys:
114
+            row = self.change_rows[key]
115
+            self.listbox.body.remove(row)
116
+            del self.change_rows[key]
117
+
118
+    def toggleReviewed(self, change_key):
119
+        with self.app.db.getSession() as session:
120
+            change = session.getChange(change_key)
121
+            change.reviewed = not change.reviewed
122
+            ret = change.reviewed
123
+        return ret
124
+
125
+    def keypress(self, size, key):
126
+        if key=='l':
127
+            self.unreviewed = not self.unreviewed
128
+            self.refresh()
129
+            return None
130
+        if key=='r':
131
+            if not len(self.listbox.body):
132
+                return None
133
+            pos = self.listbox.focus_position
134
+            reviewed = self.toggleReviewed(self.listbox.body[pos].change_key)
135
+            self.refresh()
136
+            return None
137
+        return super(ChangeListView, self).keypress(size, key)
138
+
139
+    def onSelect(self, button, change_key):
140
+        self.app.changeScreen(view.change.ChangeView(self.app, change_key))

+ 261
- 0
gertty/view/diff.py View File

@@ -0,0 +1,261 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import datetime
16
+
17
+import urwid
18
+
19
+class LineContext(object):
20
+    def __init__(self, old_revision_key, new_revision_key,
21
+                 old_revision_num, new_revision_num,
22
+                 old_fn, new_fn, old_ln, new_ln):
23
+        self.old_revision_key = old_revision_key
24
+        self.new_revision_key = new_revision_key
25
+        self.old_revision_num = old_revision_num
26
+        self.new_revision_num = new_revision_num
27
+        self.old_fn = old_fn
28
+        self.new_fn = new_fn
29
+        self.old_ln = old_ln
30
+        self.new_ln = new_ln
31
+
32
+class DiffCommentEdit(urwid.Columns):
33
+    def __init__(self, context, old_key=None, new_key=None, old=u'', new=u''):
34
+        super(DiffCommentEdit, self).__init__([])
35
+        self.context = context
36
+        # If we save a comment, the resulting key will be stored here
37
+        self.old_key = old_key
38
+        self.new_key = new_key
39
+        self.old = urwid.Edit(edit_text=old, multiline=True)
40
+        self.new = urwid.Edit(edit_text=new, multiline=True)
41
+        self.contents.append((urwid.Text(u''), ('given', 4, False)))
42
+        self.contents.append((urwid.AttrMap(self.old, 'draft-comment'), ('weight', 1, False)))
43
+        self.contents.append((urwid.Text(u''), ('given', 4, False)))
44
+        self.contents.append((urwid.AttrMap(self.new, 'draft-comment'), ('weight', 1, False)))
45
+        self.focus_position = 3
46
+
47
+    def keypress(self, size, key):
48
+        r = super(DiffCommentEdit, self).keypress(size, key)
49
+        if r in ['tab', 'shift tab']:
50
+            if self.focus_position == 3:
51
+                self.focus_position = 1
52
+            else:
53
+                self.focus_position = 3
54
+            return None
55
+        return r
56
+
57
+class DiffComment(urwid.Columns):
58
+    def __init__(self, context, old, new):
59
+        super(DiffComment, self).__init__([])
60
+        self.context = context
61
+        self.old = urwid.AttrMap(urwid.Text(old), 'comment')
62
+        self.new = urwid.AttrMap(urwid.Text(new), 'comment')
63
+        self.contents.append((urwid.Text(u''), ('given', 4, False)))
64
+        self.contents.append((self.old, ('weight', 1, False)))
65
+        self.contents.append((urwid.Text(u''), ('given', 4, False)))
66
+        self.contents.append((self.new, ('weight', 1, False)))
67
+
68
+class DiffLine(urwid.Button):
69
+    def selectable(self):
70
+        return True
71
+
72
+    def __init__(self, app, context, old, new, callback=None):
73
+        super(DiffLine, self).__init__('', on_press=callback)
74
+        self.context = context
75
+        columns = []
76
+        for (ln, action, line) in (old, new):
77
+            if ln is None:
78
+                ln = ''
79
+            else:
80
+                ln = str(ln)
81
+            ln_col = urwid.Text(ln)
82
+            ln_col.set_wrap_mode('clip')
83
+            line_col = urwid.Text(line)
84
+            line_col.set_wrap_mode('clip')
85
+            if action == '':
86
+                line_col = urwid.AttrMap(line_col, 'nonexistent')
87
+            columns += [(4, ln_col), line_col]
88
+        col = urwid.Columns(columns)
89
+        map = {None: 'reversed',
90
+               'added-line': 'reversed-added-line',
91
+               'added-word': 'reversed-added-word',
92
+               'removed-line': 'reversed-removed-line',
93
+               'removed-word': 'reversed-removed-word',
94
+               'nonexistent': 'reversed-nonexistent',
95
+               }
96
+        self._w = urwid.AttrMap(col, None, focus_map=map)
97
+
98
+class DiffView(urwid.WidgetWrap):
99
+    help = """
100
+<Enter> Add an inline comment.
101
+<ESC>   Go back to the previous screen.
102
+"""
103
+
104
+    def __init__(self, app, new_revision_key):
105
+        super(DiffView, self).__init__(urwid.Pile([]))
106
+        self.app = app
107
+        self.new_revision_key = new_revision_key
108
+        with self.app.db.getSession() as session:
109
+            revision = session.getRevision(new_revision_key)
110
+            self.title = u'Diff of %s change %s patchset %s' % (
111
+                revision.change.project.name,
112
+                revision.change.number,
113
+                revision.number)
114
+            self.new_revision_num = revision.number
115
+            self.change_key = revision.change.key
116
+            self.project_name = revision.change.project.name
117
+            self.parent = revision.parent
118
+            self.commit = revision.commit
119
+            comment_lists = {}
120
+            for comment in revision.comments:
121
+                if comment.parent:
122
+                    key = 'old'
123
+                else:
124
+                    key = 'new'
125
+                if comment.pending:
126
+                    key += 'draft'
127
+                key += '-' + str(comment.line)
128
+                key += '-' + str(comment.file)
129
+                comment_list = comment_lists.get(key, [])
130
+                comment_list.append((comment.key, comment.message))
131
+                comment_lists[key] = comment_list
132
+        repo = self.app.getRepo(self.project_name)
133
+        self._w.contents.append((app.header, ('pack', 1)))
134
+        self._w.contents.append((urwid.Divider(), ('pack', 1)))
135
+        lines = []
136
+        # this is a list of files:
137
+        for i, diff in enumerate(repo.diff(self.parent, self.commit)):
138
+            if i > 0:
139
+                lines.append(urwid.Text(''))
140
+            lines.append(urwid.Columns([
141
+                        urwid.Text(diff.oldname),
142
+                        urwid.Text(diff.newname)]))
143
+            for i, old in enumerate(diff.oldlines):
144
+                new = diff.newlines[i]
145
+                context = LineContext(
146
+                    None, self.new_revision_key,
147
+                    None, self.new_revision_num,
148
+                    diff.oldname, diff.newname,
149
+                    old[0], new[0])
150
+                lines.append(DiffLine(self.app, context, old, new,
151
+                                      callback=self.onSelect))
152
+                # see if there are any comments for this line
153
+                key = 'old-%s-%s' % (old[0], diff.oldname)
154
+                old_list = comment_lists.get(key, [])
155
+                key = 'new-%s-%s' % (old[0], diff.oldname)
156
+                new_list = comment_lists.get(key, [])
157
+                while old_list or new_list:
158
+                    old_comment_key = new_comment_key = None
159
+                    old_comment = new_comment = u''
160
+                    if old_list:
161
+                        (old_comment_key, old_comment) = old_list.pop(0)
162
+                    if new_list:
163
+                        (new_comment_key, new_comment) = new_list.pop(0)
164
+                    lines.append(DiffComment(context, old_comment, new_comment))
165
+                # see if there are any draft comments for this line
166
+                key = 'olddraft-%s-%s' % (old[0], diff.oldname)
167
+                old_list = comment_lists.get(key, [])
168
+                key = 'newdraft-%s-%s' % (old[0], diff.oldname)
169
+                new_list = comment_lists.get(key, [])
170
+                while old_list or new_list:
171
+                    old_comment_key = new_comment_key = None
172
+                    old_comment = new_comment = u''
173
+                    if old_list:
174
+                        (old_comment_key, old_comment) = old_list.pop(0)
175
+                    if new_list:
176
+                        (new_comment_key, new_comment) = new_list.pop(0)
177
+                    lines.append(DiffCommentEdit(context,
178
+                                                 old_comment_key,
179
+                                                 new_comment_key,
180
+                                                 old_comment, new_comment))
181
+        listwalker = urwid.SimpleFocusListWalker(lines)
182
+        self.listbox = urwid.ListBox(listwalker)
183
+        self._w.contents.append((self.listbox, ('weight', 1)))
184
+        self.old_focus = 2
185
+        self.draft_comments = []
186
+        self._w.set_focus(self.old_focus)
187
+
188
+    def refresh(self):
189
+        #TODO
190
+        pass
191
+
192
+    def keypress(self, size, key):
193
+        old_focus = self.listbox.focus
194
+        r = super(DiffView, self).keypress(size, key)
195
+        new_focus = self.listbox.focus
196
+        if old_focus != new_focus and isinstance(old_focus, DiffCommentEdit):
197
+            self.cleanupEdit(old_focus)
198
+        return r
199
+
200
+    def mouse_event(self, size, event, button, x, y, focus):
201
+        old_focus = self.listbox.focus
202
+        r = super(DiffView, self).mouse_event(size, event, button, x, y, focus)
203
+        new_focus = self.listbox.focus
204
+        if old_focus != new_focus and isinstance(old_focus, DiffCommentEdit):
205
+            self.cleanupEdit(old_focus)
206
+        return r
207
+
208
+    def onSelect(self, button):
209
+        pos = self.listbox.focus_position
210
+        e = DiffCommentEdit(self.listbox.body[pos].context)
211
+        self.listbox.body.insert(pos+1, e)
212
+        self.listbox.focus_position = pos+1
213
+
214
+    def cleanupEdit(self, edit):
215
+        if edit.old_key:
216
+            self.deleteComment(edit.old_key)
217
+            edit.old_key = None
218
+        if edit.new_key:
219
+            self.deleteComment(edit.new_key)
220
+            edit.new_key = None
221
+        old = edit.old.edit_text.strip()
222
+        new = edit.new.edit_text.strip()
223
+        if old or new:
224
+            if old:
225
+                edit.old_key = self.saveComment(
226
+                    edit.context, old, new=False)
227
+            if new:
228
+                edit.new_key = self.saveComment(
229
+                    edit.context, new, new=True)
230
+        else:
231
+            self.listbox.body.remove(edit)
232
+
233
+    def deleteComment(self, comment_key):
234
+        with self.app.db.getSession() as session:
235
+            comment = session.getComment(comment_key)
236
+            session.delete(comment)
237
+
238
+    def saveComment(self, context, text, new=True):
239
+        if (not new) and (not context.old_revision_num):
240
+            parent = True
241
+            revision_key = context.new_revision_key
242
+        else:
243
+            parent = False
244
+            if new:
245
+                revision_key = context.new_revision_key
246
+            else:
247
+                revision_key = context.old_revision_key
248
+        if new:
249
+            line_num = context.new_ln
250
+            filename = context.new_fn
251
+        else:
252
+            line_num = context.old_ln
253
+            filename = context.old_fn
254
+        with self.app.db.getSession() as session:
255
+            revision = session.getRevision(revision_key)
256
+            comment = revision.createComment(None, None,
257
+                                             datetime.datetime.utcnow(),
258
+                                             None, filename, parent,
259
+                                             line_num, text, pending=True)
260
+            key = comment.key
261
+        return key

+ 127
- 0
gertty/view/project_list.py View File

@@ -0,0 +1,127 @@
1
+# Copyright 2014 OpenStack Foundation
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+# not use this file except in compliance with the License. You may obtain
5
+# a copy of the License at
6
+#
7
+#      http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+# License for the specific language governing permissions and limitations
13
+# under the License.
14
+
15
+import urwid
16
+
17
+import sync
18
+import view.change_list
19
+
20
+class ProjectRow(urwid.Button):
21
+    project_focus_map = {None: 'reversed',
22
+                         'unreviewed-project': 'reversed-unreviewed-project',
23
+                         'subscribed-project': 'reversed-subscribed-project',
24
+                         'unsubscribed-project': 'reversed-unsubscribed-project',
25
+                         }
26
+
27
+    def selectable(self):
28
+        return True
29
+
30
+    def __init__(self, project, callback=None):
31
+        super(ProjectRow, self).__init__('', on_press=callback, user_data=project.key)
32
+        self.project_key = project.key
33
+        name = urwid.Text(u' '+project.name)
34
+        name.set_wrap_mode('clip')
35
+        self.unreviewed_changes = urwid.Text(u'')
36
+        self.reviewed_changes = urwid.Text(u'')
37
+        col = urwid.Columns([
38
+                name,
39
+                ('fixed', 4, self.unreviewed_changes),
40
+                ('fixed', 4, self.reviewed_changes),
41
+                ])
42
+        self.row_style = urwid.AttrMap(col, '')
43
+        self._w = urwid.AttrMap(self.row_style, None, focus_map=self.project_focus_map)
44
+        self.update(project)
45
+
46
+    def update(self, project):
47
+        if project.subscribed:
48
+            if len(project.unreviewed_changes) > 0:
49
+                style = 'unreviewed-project'
50
+            else:
51
+                style = 'subscribed-project'
52
+        else:
53
+            style = 'unsubscribed-project'
54
+        self.row_style.set_attr_map({None: style})
55
+        self.unreviewed_changes.set_text(str(len(project.unreviewed_changes)))
56
+        self.reviewed_changes.set_text(str(len(project.reviewed_changes)))
57
+
58
+class ProjectListView(urwid.WidgetWrap):
59
+    help = """
60
+<l>   Toggle whether only subscribed projects or all projects are listed.
61
+<s>   Toggle the subscription flag for the currently selected project.
62
+"""
63
+
64
+    def __init__(self, app):
65
+        super(ProjectListView, self).__init__(urwid.Pile([]))
66
+        self.app = app
67
+        self.subscribed = True
68
+        self.project_rows = {}
69
+        self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
70
+        self.refresh()
71
+        self._w.contents.append((app.header, ('pack', 1)))
72
+        self._w.contents.append((urwid.Divider(),('pack', 1)))
73
+        self._w.contents.append((self