Console interface to Gerrit Code Review
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1335 lines
58 KiB

# Copyright 2014 OpenStack Foundation
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import collections
import datetime
import logging
try:
import ordereddict
except:
pass
import textwrap
from six.moves.urllib import parse as urlparse
import urwid
from gertty import gitrepo
from gertty import keymap
from gertty import mywid
from gertty import sync
from gertty.view import side_diff as view_side_diff
from gertty.view import unified_diff as view_unified_diff
from gertty.view import mouse_scroll_decorator
import gertty.view
try:
OrderedDict = collections.OrderedDict
except AttributeError:
OrderedDict = ordereddict.OrderedDict
class EditTopicDialog(mywid.ButtonDialog):
signals = ['save', 'cancel']
def __init__(self, app, topic):
self.app = app
save_button = mywid.FixedButton('Save')
cancel_button = mywid.FixedButton('Cancel')
urwid.connect_signal(save_button, 'click',
lambda button:self._emit('save'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
super(EditTopicDialog, self).__init__("Edit Topic",
"Edit the change topic.",
entry_prompt="Topic: ",
entry_text=topic,
buttons=[save_button,
cancel_button],
ring=app.ring)
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(EditTopicDialog, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.ACTIVATE in commands:
self._emit('save')
return None
return key
class EditHashtagsDialog(mywid.ButtonDialog):
signals = ['save', 'cancel']
def __init__(self, app, hashtags):
self.app = app
save_button = mywid.FixedButton('Save')
cancel_button = mywid.FixedButton('Cancel')
urwid.connect_signal(save_button, 'click',
lambda button:self._emit('save'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
super(EditHashtagsDialog, self).__init__("Edit Hashtags",
"Edit the change hashtags.",
entry_prompt="Hashtags: ",
entry_text=hashtags,
buttons=[save_button,
cancel_button],
ring=app.ring)
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(EditHashtagsDialog, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.ACTIVATE in commands:
self._emit('save')
return None
return key
class CherryPickDialog(urwid.WidgetWrap, mywid.LineBoxTitlePropertyMixin):
signals = ['save', 'cancel']
def __init__(self, app, change):
save_button = mywid.FixedButton('Propose Change')
cancel_button = mywid.FixedButton('Cancel')
urwid.connect_signal(save_button, 'click',
lambda button:self._emit('save'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
button_widgets = [('pack', save_button),
('pack', cancel_button)]
button_columns = urwid.Columns(button_widgets, dividechars=2)
rows = []
self.entry = mywid.MyEdit(edit_text=change.revisions[-1].message,
multiline=True, ring=app.ring)
self.branch_buttons = []
rows.append(urwid.Text(u"Branch:"))
for branch in change.project.branches:
b = mywid.FixedRadioButton(self.branch_buttons, branch.name,
state=(branch.name == change.branch))
rows.append(b)
rows.append(urwid.Divider())
rows.append(urwid.Text(u"Commit message:"))
rows.append(self.entry)
rows.append(urwid.Divider())
rows.append(button_columns)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
super(CherryPickDialog, self).__init__(urwid.LineBox(fill,
'Propose Change to Branch'))
class ReviewDialog(urwid.WidgetWrap, mywid.LineBoxTitlePropertyMixin):
signals = ['submit', 'save', 'cancel']
def __init__(self, app, revision_key, message=''):
self.revision_key = revision_key
self.app = app
save_button = mywid.FixedButton(u'Save')
submit_button = mywid.FixedButton(u'Save and Submit')
cancel_button = mywid.FixedButton(u'Cancel')
urwid.connect_signal(save_button, 'click',
lambda button:self._emit('save'))
urwid.connect_signal(submit_button, 'click',
lambda button:self._emit('submit'))
urwid.connect_signal(cancel_button, 'click',
lambda button:self._emit('cancel'))
rows = []
categories = []
values = {}
descriptions = {}
self.button_groups = {}
with self.app.db.getSession() as session:
revision = session.getRevision(self.revision_key)
change = revision.change
buttons = [('pack', save_button)]
if revision.can_submit:
buttons.append(('pack', submit_button))
buttons.append(('pack', cancel_button))
buttons = urwid.Columns(buttons, dividechars=2)
if revision == change.revisions[-1]:
for label in change.labels:
d = descriptions.setdefault(label.category, {})
d[label.value] = label.description
vmin = d.setdefault('min', label.value)
if label.value < vmin:
d['min'] = label.value
vmax = d.setdefault('max', label.value)
if label.value > vmax:
d['max'] = label.value
for label in change.permitted_labels:
if label.category not in categories:
categories.append(label.category)
values[label.category] = []
values[label.category].append(label.value)
draft_approvals = {}
prior_approvals = {}
for approval in change.approvals:
if self.app.isOwnAccount(approval.reviewer):
if approval.draft:
draft_approvals[approval.category] = approval
else:
prior_approvals[approval.category] = approval
for category in categories:
rows.append(urwid.Text(category))
group = []
self.button_groups[category] = group
current = draft_approvals.get(category)
if current is None:
current = prior_approvals.get(category)
if current is None:
current = 0
else:
current = current.value
for value in sorted(values[category], reverse=True):
if value > 0:
strvalue = '+%s' % value
elif value == 0:
strvalue = ' 0'
else:
strvalue = str(value)
strvalue += ' ' + descriptions[category][value]
b = urwid.RadioButton(group, strvalue, state=(value == current))
b._value = value
if value > 0:
if value == descriptions[category]['max']:
b = urwid.AttrMap(b, 'max-label')
else:
b = urwid.AttrMap(b, 'positive-label')
elif value < 0:
if value == descriptions[category]['min']:
b = urwid.AttrMap(b, 'min-label')
else:
b = urwid.AttrMap(b, 'negative-label')
rows.append(b)
rows.append(urwid.Divider())
m = revision.getPendingMessage()
if not m:
m = revision.getDraftMessage()
if m:
message = m.message
self.message = mywid.MyEdit(u"Message: \n", edit_text=message,
multiline=True, ring=app.ring)
rows.append(self.message)
rows.append(urwid.Divider())
rows.append(buttons)
pile = urwid.Pile(rows)
fill = urwid.Filler(pile, valign='top')
super(ReviewDialog, self).__init__(urwid.LineBox(fill, 'Review'))
def getValues(self):
approvals = {}
for category, group in self.button_groups.items():
for button in group:
if button.state:
approvals[category] = button._value
message = self.message.edit_text.strip()
return (approvals, message)
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(ReviewDialog, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.PREV_SCREEN in commands:
self._emit('cancel')
return None
return key
class ReviewButton(mywid.FixedButton):
def __init__(self, revision_row):
super(ReviewButton, self).__init__(('revision-button', u'Review'))
self.revision_row = revision_row
self.change_view = revision_row.change_view
urwid.connect_signal(self, 'click',
lambda button: self.openReview())
def openReview(self, message=''):
self.dialog = ReviewDialog(self.change_view.app,
self.revision_row.revision_key,
message=message)
urwid.connect_signal(self.dialog, 'save',
lambda button: self.closeReview(True, False))
urwid.connect_signal(self.dialog, 'submit',
lambda button: self.closeReview(True, True))
urwid.connect_signal(self.dialog, 'cancel',
lambda button: self.closeReview(False, False))
self.change_view.app.popup(self.dialog,
relative_width=50, relative_height=75,
min_width=60, min_height=20)
def closeReview(self, upload, submit):
approvals, message = self.dialog.getValues()
self.change_view.saveReview(self.revision_row.revision_key, approvals,
message, upload, submit)
self.change_view.app.backScreen()
class RevisionRow(urwid.WidgetWrap):
revision_focus_map = {
'revision-name': 'focused-revision-name',
'revision-commit': 'focused-revision-commit',
'revision-comments': 'focused-revision-comments',
'revision-drafts': 'focused-revision-drafts',
}
def __init__(self, app, change_view, repo, revision, expanded=False):
super(RevisionRow, self).__init__(urwid.Pile([]))
self.app = app
self.change_view = change_view
self.revision_key = revision.key
self.project_name = revision.change.project.name
self.commit_sha = revision.commit
self.can_submit = revision.can_submit
self.title = mywid.TextButton(u'', on_press = self.expandContract)
table = mywid.Table(columns=3)
total_added = 0
total_removed = 0
for rfile in revision.files:
if rfile.status is None:
continue
added = rfile.inserted or 0
removed = rfile.deleted or 0
total_added += added
total_removed += removed
table.addRow([urwid.Text(('filename', rfile.display_path), wrap='clip'),
urwid.Text([('lines-added', '+%i' % (added,)), ', '],
align=urwid.RIGHT),
urwid.Text(('lines-removed', '-%i' % (removed,)))])
table.addRow([urwid.Text(''),
urwid.Text([('lines-added', '+%i' % (total_added,)), ', '],
align=urwid.RIGHT),
urwid.Text(('lines-removed', '-%i' % (total_removed,)))])
table = urwid.Padding(table, width='pack')
focus_map={'revision-button': 'focused-revision-button'}
self.review_button = ReviewButton(self)
buttons = [self.review_button,
mywid.FixedButton(('revision-button', "Diff"),
on_press=self.diff),
mywid.FixedButton(('revision-button', "Local Checkout"),
on_press=self.checkout),
mywid.FixedButton(('revision-button', "Local Cherry-Pick"),
on_press=self.cherryPick)]
if self.can_submit:
buttons.append(mywid.FixedButton(('revision-button', "Submit"),
on_press=lambda x: self.change_view.doSubmitChange()))
buttons = [('pack', urwid.AttrMap(b, None, focus_map=focus_map)) for b in buttons]
buttons = urwid.Columns(buttons + [urwid.Text('')], dividechars=2)
buttons = urwid.AttrMap(buttons, 'revision-button')
self.more = urwid.Pile([table, buttons])
padded_title = urwid.Padding(self.title, width='pack')
self.pile = urwid.Pile([padded_title])
self._w = urwid.AttrMap(self.pile, None, focus_map=self.revision_focus_map)
self.expanded = False
self.update(revision)
if expanded:
self.expandContract(None)
def update(self, revision):
line = [('revision-name', 'Patch Set %s ' % revision.number),
('revision-commit', revision.commit)]
num_drafts = sum([len(f.draft_comments) for f in revision.files])
if num_drafts:
pending_message = revision.getPendingMessage()
if not pending_message:
line.append(('revision-drafts', ' (%s draft%s)' % (
num_drafts, num_drafts>1 and 's' or '')))
num_comments = sum([len(f.comments) for f in revision.files]) - num_drafts
if num_comments:
line.append(('revision-comments', ' (%s inline comment%s)' % (
num_comments, num_comments>1 and 's' or '')))
self.title.text.set_text(line)
def expandContract(self, button):
if self.expanded:
self.pile.contents.pop()
self.expanded = False
else:
self.pile.contents.append((self.more, ('pack', None)))
self.expanded = True
def diff(self, button):
self.change_view.diff(self.revision_key)
def checkout(self, button):
self.app.localCheckoutCommit(self.project_name, self.commit_sha)
def cherryPick(self, button):
self.app.localCherryPickCommit(self.project_name, self.commit_sha)
class ChangeButton(urwid.Button):
button_left = urwid.Text(u' ')
button_right = urwid.Text(u' ')
def __init__(self, change_view, change_key, text):
super(ChangeButton, self).__init__('')
self.set_label(text)
self.change_view = change_view
self.change_key = change_key
urwid.connect_signal(self, 'click',
lambda button: self.openChange())
def set_label(self, text):
super(ChangeButton, self).set_label(text)
def openChange(self):
try:
self.change_view.app.changeScreen(ChangeView(self.change_view.app, self.change_key))
except gertty.view.DisplayError as e:
self.change_view.app.error(e.message)
class ChangeMessageBox(mywid.HyperText):
def __init__(self, change_view, change, message):
super(ChangeMessageBox, self).__init__(u'')
self.change_view = change_view
self.app = change_view.app
self.refresh(change, message)
def formatReply(self):
text = self.message_text
pgraphs = []
pgraph_accumulator = []
wrap = True
for line in text.split('\n')[2:]:
if line.startswith('> '):
wrap = False
line = '> ' + line
if not line:
if pgraph_accumulator:
pgraphs.append((wrap, '\n'.join(pgraph_accumulator)))
pgraph_accumulator = []
wrap = True
continue
pgraph_accumulator.append(line)
if pgraph_accumulator:
pgraphs.append((wrap, '\n'.join(pgraph_accumulator)))
pgraph_accumulator = []
wrap = True
wrapper = textwrap.TextWrapper(initial_indent='> ',
subsequent_indent='> ')
wrapped_pgraphs = []
for wrap, pgraph in pgraphs:
if wrap:
wrapped_pgraphs.append('\n'.join(wrapper.wrap(pgraph)))
else:
wrapped_pgraphs.append(pgraph)
return '\n>\n'.join(wrapped_pgraphs)
def reply(self):
reply_text = self.formatReply()
if reply_text:
reply_text = self.message_author + ' wrote:\n\n' + reply_text + '\n'
row = self.change_view.revision_rows[self.revision_key]
row.review_button.openReview(reply_text)
def refresh(self, change, message):
self.revision_key = message.revision.key
self.message_created = message.created
self.message_author = message.author_name
self.message_text = message.message
created = self.app.time(message.created)
lines = message.message.split('\n')
if message.draft:
lines.insert(0, '')
lines.insert(0, 'Patch Set %s:' % (message.revision.number,))
if self.app.isOwnAccount(message.author):
name_style = 'change-message-own-name'
header_style = 'change-message-own-header'
reviewer_string = message.author_name
else:
name_style = 'change-message-name'
header_style = 'change-message-header'
if message.author.email:
reviewer_string = "%s <%s>" % (
message.author_name,
message.author.email)
else:
reviewer_string = message.author_name
text = [(name_style, reviewer_string),
(header_style, ': '+lines.pop(0)),
(header_style,
created.strftime(' (%Y-%m-%d %H:%M:%S%z)'))]
if message.draft and not message.pending:
text.append(('change-message-draft', ' (draft)'))
else:
link = mywid.Link('< Reply >',
'revision-button',
'focused-revision-button')
urwid.connect_signal(link, 'selected',
lambda link:self.reply())
text.append(' ')
text.append(link)
if lines and lines[-1]:
lines.append('')
comment_text = ['\n'.join(lines)]
for commentlink in self.app.config.commentlinks:
comment_text = commentlink.run(self.app, comment_text)
inline_comments = {}
for revno, revision in enumerate(change.revisions):
for file in revision.files:
comments = [c for c in file.comments
if c.author.id == message.author.id
and c.created == message.created]
for comment in comments:
path = comment.file.path
inline_comments.setdefault(path, [])
comment_ps = revno + 1
if comment_ps == message.revision.number:
comment_ps = None
inline_comments[path].append((comment_ps or 0, comment.line or 0, comment.message))
for v in inline_comments.values():
v.sort()
if inline_comments:
comment_text.append(u'\n')
for key, value in inline_comments.items():
comment_text.append(('filename-inline-comment', u'%s' % key))
for patchset, line, comment in value:
location_str = ''
if patchset:
location_str += "PS%i" % patchset
if line: location_str += ", "
if line:
location_str += str(line)
if location_str:
location_str += ": "
comment_text.append(u'\n %s%s\n' % (location_str, comment))
self.set_text(text+comment_text)
class CommitMessageBox(mywid.HyperText):
def __init__(self, app, message):
self.app = app
super(CommitMessageBox, self).__init__(message)
def set_text(self, text):
text = [text]
for commentlink in self.app.config.commentlinks:
text = commentlink.run(self.app, text)
super(CommitMessageBox, self).set_text(text)
@mouse_scroll_decorator.ScrollByWheel
class ChangeView(urwid.WidgetWrap):
def getCommands(self):
return [
(keymap.LOCAL_CHECKOUT,
"Checkout the most recent revision into the local repo"),
(keymap.DIFF,
"Show the diff of the most recent revision"),
(keymap.TOGGLE_HIDDEN,
"Toggle the hidden flag for the current change"),
(keymap.NEXT_CHANGE,
"Go to the next change in the list"),
(keymap.PREV_CHANGE,
"Go to the previous change in the list"),
(keymap.REVIEW,
"Leave a review for the most recent revision"),
(keymap.TOGGLE_HELD,
"Toggle the held flag for the current change"),
(keymap.TOGGLE_HIDDEN_COMMENTS,
"Toggle display of hidden comments"),
(keymap.SEARCH_RESULTS,
"Back to the list of changes"),
(keymap.TOGGLE_REVIEWED,
"Toggle the reviewed flag for the current change"),
(keymap.TOGGLE_STARRED,
"Toggle the starred flag for the current change"),
(keymap.LOCAL_CHERRY_PICK,
"Cherry-pick the most recent revision onto the local repo"),
(keymap.ABANDON_CHANGE,
"Abandon this change"),
(keymap.EDIT_COMMIT_MESSAGE,
"Edit the commit message of this change"),
(keymap.REBASE_CHANGE,
"Rebase this change (remotely)"),
(keymap.RESTORE_CHANGE,
"Restore this change"),
(keymap.READY_CHANGE,
"Mark this change ready for review"),
(keymap.REFRESH,
"Refresh this change"),
(keymap.EDIT_TOPIC,
"Edit the topic of this change"),
(keymap.EDIT_HASHTAGS,
"Edit the hashtags of this change"),
(keymap.SUBMIT_CHANGE,
"Submit this change"),
(keymap.CHERRY_PICK_CHANGE,
"Propose this change to another branch"),
(keymap.WIP_CHANGE,
"Mark this change work in progress"),
]
def help(self):
key = self.app.config.keymap.formatKeys
commands = self.getCommands()
ret = [(c[0], key(c[0]), c[1]) for c in commands]
for k in self.app.config.reviewkeys.values():
action = ', '.join(['{category}:{value}'.format(**a) for a in k['approvals']])
ret.append(('', keymap.formatKey(k['key']), action))
return ret
def __init__(self, app, change_key):
super(ChangeView, self).__init__(urwid.Pile([]))
self.log = logging.getLogger('gertty.view.change')
self.app = app
self.change_key = change_key
self.revision_rows = {}
self.message_rows = {}
self.last_revision_key = None
self.hide_comments = True
self.marked_seen = False
self.change_id_label = mywid.TextButton(u'', on_press=self.searchChangeId)
self.owner_label = mywid.TextButton(u'', on_press=self.searchOwner)
self.project_label = mywid.TextButton(u'', on_press=self.searchProject)
self.branch_label = urwid.Text(u'', wrap='clip')
self.topic_label = mywid.TextButton(u'', on_press=self.searchTopic)
self.hashtags_label = mywid.HyperText(u'')
self.created_label = urwid.Text(u'', wrap='clip')
self.updated_label = urwid.Text(u'', wrap='clip')
self.status_label = urwid.Text(u'', wrap='clip')
self.permalink_label = mywid.TextButton(u'', on_press=self.openPermalink)
change_info = []
change_info_map={'change-data': 'focused-change-data'}
for l, v in [("Change-Id", urwid.Padding(urwid.AttrMap(self.change_id_label, None,
focus_map=change_info_map),
width='pack')),
("Owner", urwid.Padding(urwid.AttrMap(self.owner_label, None,
focus_map=change_info_map),
width='pack')),
("Project", urwid.Padding(urwid.AttrMap(self.project_label, None,
focus_map=change_info_map),
width='pack')),
("Branch", self.branch_label),
("Topic", urwid.Padding(urwid.AttrMap(self.topic_label, None,
focus_map=change_info_map),
width='pack')),
("Hashtags", self.hashtags_label),
("Created", self.created_label),
("Updated", self.updated_label),
("Status", self.status_label),
("Permalink", urwid.Padding(urwid.AttrMap(self.permalink_label, None,
focus_map=change_info_map),
width='pack')),
]:
row = urwid.Columns([(12, urwid.Text(('change-header', l), wrap='clip')), v])
change_info.append(row)
change_info = urwid.Pile(change_info)
self.commit_message = CommitMessageBox(app, u'')
votes = mywid.Table([])
self.depends_on = urwid.Pile([])
self.depends_on_rows = {}
self.needed_by = urwid.Pile([])
self.needed_by_rows = {}
self.conflicts_with = urwid.Pile([])
self.conflicts_with_rows = {}
self.related_changes = urwid.Pile([self.depends_on, self.needed_by, self.conflicts_with])
self.results = mywid.HyperText(u'') # because it scrolls better than a table
self.grid = mywid.MyGridFlow([change_info, self.commit_message, votes, self.results],
cell_width=80, h_sep=2, v_sep=1, align='left')
self.listbox = urwid.ListBox(urwid.SimpleFocusListWalker([]))
self._w.contents.append((self.app.header, ('pack', 1)))
self._w.contents.append((urwid.Divider(), ('pack', 1)))
self._w.contents.append((self.listbox, ('weight', 1)))
self._w.set_focus(2)
self.listbox.body.append(self.grid)
self.listbox.body.append(urwid.Divider())
self.listbox.body.append(self.related_changes)
self.listbox.body.append(urwid.Divider())
self.listbox_patchset_start = len(self.listbox.body)
self.checkGitRepo()
self.refresh()
self.listbox.set_focus(0)
self.grid.set_focus(1)
def checkGitRepo(self):
missing_revisions = set()
change_number = None
change_id = None
shas = set()
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change_project_name = change.project.name
change_number = change.number
change_id = change.id
for revision in change.revisions:
shas.add(revision.parent)
shas.add(revision.commit)
repo = gitrepo.get_repo(change_project_name, self.app.config)
missing_revisions = repo.checkCommits(shas)
if missing_revisions:
if self.app.sync.offline:
raise gertty.view.DisplayError("Git commits not present in local repository")
self.app.log.warning("Missing some commits for change %s %s",
change_number, missing_revisions)
task = sync.SyncChangeTask(change_id, force_fetch=True,
priority=sync.HIGH_PRIORITY)
self.app.sync.submitTask(task)
succeeded = task.wait(300)
if not succeeded:
raise gertty.view.DisplayError("Git commits not present in local repository")
def interested(self, event):
if not ((isinstance(event, sync.ChangeAddedEvent) and
self.change_key in event.related_change_keys)
or
(isinstance(event, sync.ChangeUpdatedEvent) and
self.change_key in event.related_change_keys)):
self.log.debug("Ignoring refresh change due to event %s" % (event,))
return False
self.log.debug("Refreshing change due to event %s" % (event,))
return True
def refresh(self):
with self.app.db.getSession() as session:
change = session.getChange(self.change_key, lazy=False)
# When we first open the change, update its last_seen
# time.
if not self.marked_seen:
change.last_seen = datetime.datetime.utcnow()
self.marked_seen = True
self.topic = change.topic or ''
self.hashtags = ', '.join([h.name for h in change.hashtags])
self.pending_status_message = change.pending_status_message or ''
reviewed = hidden = starred = held = ''
if change.reviewed:
reviewed = ' (reviewed)'
if change.hidden:
hidden = ' (hidden)'
if change.starred:
starred = '* '
if change.held:
held = ' (held)'
self.title = '%sChange %s%s%s%s' % (starred, change.number, reviewed,
hidden, held)
self.app.status.update(title=self.title)
self.project_key = change.project.key
self.project_name = change.project.name
self.change_rest_id = change.id
self.change_id = change.change_id
if change.owner:
self.owner_email = change.owner.email
else:
self.owner_email = None
self.change_id_label.text.set_text(('change-data', change.change_id))
if change.owner.email:
owner_string = '%s <%s>' % (change.owner_name,
change.owner.email)
else:
owner_string = change.owner_name
self.owner_label.text.set_text(('change-data', owner_string))
self.project_label.text.set_text(('change-data', change.project.name))
self.branch_label.set_text(('change-data', change.branch))
self.topic_label.text.set_text(('change-data', self.topic))
hashtag_buttons = []
for x in change.hashtags:
if hashtag_buttons:
hashtag_buttons.append(' ')
link = mywid.Link(x.name, 'change-data', 'focused-change-data')
urwid.connect_signal(
link, 'selected',
lambda link, x=x: self.searchHashtags(x.name))
hashtag_buttons.append(link)
self.hashtags_label.set_text(('change-data', hashtag_buttons or u''))
self.created_label.set_text(('change-data', str(self.app.time(change.created))))
self.updated_label.set_text(('change-data', str(self.app.time(change.updated))))
stat = change.wip and ('state-wip', 'WIP') or change.status
self.status_label.set_text(('change-data', stat))
self.permalink_url = urlparse.urljoin(self.app.config.url, str(change.number))
self.permalink_label.text.set_text(('change-data', self.permalink_url))
self.commit_message.set_text(change.revisions[-1].message)
categories = []
approval_headers = [urwid.Text(('table-header', 'Name'))]
for label in change.labels:
if label.category in categories:
continue
approval_headers.append(urwid.Text(('table-header', label.category)))
categories.append(label.category)
votes = mywid.Table(approval_headers)
approvals_for_account = {}
pending_message = change.revisions[-1].getPendingMessage()
for approval in change.approvals:
# Don't display draft approvals unless they are pending-upload
if approval.draft and not pending_message:
continue
approvals = approvals_for_account.get(approval.reviewer.id)
if not approvals:
approvals = {}
row = []
if self.app.isOwnAccount(approval.reviewer):
style = 'reviewer-own-name'
else:
style = 'reviewer-name'
row.append(urwid.Text((style, approval.reviewer_name)))
for i, category in enumerate(categories):
w = urwid.Text(u'', align=urwid.CENTER)
approvals[category] = w
row.append(w)
approvals_for_account[approval.reviewer.id] = approvals
votes.addRow(row)
if str(approval.value) != '0':
cat_min, cat_max = change.getMinMaxPermittedForCategory(approval.category)
if approval.value > 0:
val = '+%i' % approval.value
if approval.value == cat_max:
val = ('max-label', val)
else:
val = ('positive-label', val)
else:
val = '%i' % approval.value
if approval.value == cat_min:
val = ('min-label', val)
else:
val = ('negative-label', val)
approvals[approval.category].set_text(val)
votes = urwid.Padding(votes, width='pack')
# TODO: update the existing table rather than replacing it
# wholesale. It will become more important if the table
# gets selectable items (like clickable names).
self.grid.contents[2] = (votes, ('given', 80))
self.refreshDependencies(session, change)
repo = gitrepo.get_repo(change.project.name, self.app.config)
# The listbox has both revisions and messages in it (and
# may later contain the vote table and change header), so
# keep track of the index separate from the loop.
listbox_index = self.listbox_patchset_start
for revno, revision in enumerate(change.revisions):
self.last_revision_key = revision.key
row = self.revision_rows.get(revision.key)
if not row:
row = RevisionRow(self.app, self, repo, revision,
expanded=(revno==len(change.revisions)-1))
self.listbox.body.insert(listbox_index, row)
self.revision_rows[revision.key] = row
row.update(revision)
# Revisions are extremely unlikely to be deleted, skip
# that case.
listbox_index += 1
if len(self.listbox.body) == listbox_index:
self.listbox.body.insert(listbox_index, urwid.Divider())
listbox_index += 1
# Get the set of messages that should be displayed
display_messages = []
result_systems = {}
for message in change.messages:
if (message.revision == change.revisions[-1] and
message.author and message.author.name):
for commentlink in self.app.config.commentlinks:
results = commentlink.getTestResults(self.app, message.message)
if results:
result_system = result_systems.get(message.author.name,
OrderedDict())
result_systems[message.author.name] = result_system
result_system.update(results)
skip = False
if self.hide_comments and message.author and message.author.name:
for regex in self.app.config.hide_comments:
if regex.match(message.author.name):
skip = True
break
if not skip:
display_messages.append(message)
# The set of message keys currently displayed
unseen_keys = set(self.message_rows.keys())
# Make sure all of the messages that should be displayed are
for message in display_messages:
row = self.message_rows.get(message.key)
if not row:
box = ChangeMessageBox(self, change, message)
row = urwid.Padding(box, width=80)
self.listbox.body.insert(listbox_index, row)
self.message_rows[message.key] = row
else:
unseen_keys.remove(message.key)
if message.created != row.original_widget.message_created:
row.original_widget.refresh(change, message)
listbox_index += 1
# Remove any messages that should not be displayed
for key in unseen_keys:
row = self.message_rows.get(key)
self.listbox.body.remove(row)
del self.message_rows[key]
listbox_index -= 1
self._updateTestResults(change, result_systems)
def _updateTestResults(self, change, result_systems):
text = []
for system, results in result_systems.items():
for job, result in results.items():
text.append(result)
# Add check results
revision = change.revisions[-1]
for check in revision.checks:
# link checker name/url, color result, in time
link = mywid.Link('{:<42}'.format(check.checker.name), 'link', 'focused-link')
urwid.connect_signal(link, 'selected', lambda link:self.app.openURL(check.url))
color = 'check-%s' % check.state
result = (color, check.state)
line = [link, result]
if check.finished and check.started:
line.append(' in %s' % (check.finished-check.started))
line.append('\n')
text.append(line)
if text:
self.results.set_text(text)
else:
self.results.set_text('')
def _updateDependenciesWidget(self, changes, widget, widget_rows, header):
if not changes:
if len(widget.contents) > 0:
widget.contents[:] = []
return
if len(widget.contents) == 0:
widget.contents.append((urwid.Text(('table-header', header)),
widget.options()))
unseen_keys = set(widget_rows.keys())
i = 1
for key, subject in changes.items():
row = widget_rows.get(key)
if not row:
row = urwid.AttrMap(urwid.Padding(ChangeButton(self, key, subject), width='pack'),
'link', focus_map={None: 'focused-link'})
row = (row, widget.options('pack'))
widget.contents.insert(i, row)
if not widget.selectable():
widget.set_focus(i)
if not self.related_changes.selectable():
self.related_changes.set_focus(widget)
widget_rows[key] = row
else:
row[0].original_widget.original_widget.set_label(subject)
unseen_keys.remove(key)
i += 1
for key in unseen_keys:
row = widget_rows[key]
widget.contents.remove(row)
del widget_rows[key]
def refreshDependencies(self, session, change):
revision = change.revisions[-1]
# Handle depends-on
parents = {}
parent = session.getRevisionByCommit(revision.parent)
if parent:
subject = parent.change.subject
show_merged = False
if parent != parent.change.revisions[-1]:
subject += ' [OUTDATED]'
show_merged = True
if parent.change.status == 'ABANDONED':
subject += ' [ABANDONED]'
if show_merged or parent.change.status != 'MERGED':
parents[parent.change.key] = subject
self._updateDependenciesWidget(parents,
self.depends_on, self.depends_on_rows,
header='Depends on:')
# Handle needed-by
children = {}
children.update((r.change.key, r.change.subject)
for r in session.getRevisionsByParent([revision.commit for revision in change.revisions])
if (r.change.status != 'MERGED' and
r.change.status != 'ABANDONED' and
r == r.change.revisions[-1]))
self._updateDependenciesWidget(children,
self.needed_by, self.needed_by_rows,
header='Needed by:')
# Handle conflicts_with
conflicts = {}
conflicts.update((c.key, c.subject)
for c in change.conflicts
if (c.status != 'MERGED' and
c.status != 'ABANDONED'))
self._updateDependenciesWidget(conflicts,
self.conflicts_with, self.conflicts_with_rows,
header='Conflicts with:')
def toggleReviewed(self):
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.reviewed = not change.reviewed
self.app.project_cache.clear(change.project)
def toggleHidden(self):
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.hidden = not change.hidden
self.app.project_cache.clear(change.project)
def toggleStarred(self):
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.starred = not change.starred
change.pending_starred = True
self.app.sync.submitTask(
sync.ChangeStarredTask(self.change_key, sync.HIGH_PRIORITY))
def toggleHeld(self):
return self.app.toggleHeldChange(self.change_key)
def keypress(self, size, key):
if not self.app.input_buffer:
key = super(ChangeView, self).keypress(size, key)
keys = self.app.input_buffer + [key]
commands = self.app.config.keymap.getCommands(keys)
if keymap.TOGGLE_REVIEWED in commands:
self.toggleReviewed()
self.refresh()
return None
if keymap.TOGGLE_HIDDEN in commands:
self.toggleHidden()
self.refresh()
return None
if keymap.TOGGLE_STARRED in commands:
self.toggleStarred()
self.refresh()
return None
if keymap.TOGGLE_HELD in commands:
self.toggleHeld()
self.refresh()
return None
if keymap.REVIEW in commands:
row = self.revision_rows[self.last_revision_key]
row.review_button.openReview()
return None
if keymap.DIFF in commands:
row = self.revision_rows[self.last_revision_key]
row.diff(None)
return None
if keymap.LOCAL_CHECKOUT in commands:
row = self.revision_rows[self.last_revision_key]
row.checkout(None)
return None
if keymap.LOCAL_CHERRY_PICK in commands:
row = self.revision_rows[self.last_revision_key]
row.cherryPick(None)
return None
if keymap.SEARCH_RESULTS in commands:
widget = self.app.findChangeList()
if widget:
self.app.backScreen(widget)
return None
if ((keymap.NEXT_CHANGE in commands) or
(keymap.PREV_CHANGE in commands)):
widget = self.app.findChangeList()
if widget:
if keymap.NEXT_CHANGE in commands:
new_change_key = widget.getNextChangeKey(self.change_key)
else:
new_change_key = widget.getPrevChangeKey(self.change_key)
if new_change_key:
try:
view = ChangeView(self.app, new_change_key)
self.app.changeScreen(view, push=False)
except gertty.view.DisplayError as e:
self.app.error(e.message)
return None
if keymap.TOGGLE_HIDDEN_COMMENTS in commands:
self.hide_comments = not self.hide_comments
self.refresh()
return None
if keymap.ABANDON_CHANGE in commands:
self.abandonChange()
return None
if keymap.WIP_CHANGE in commands:
self.wipChange()
return None
if keymap.READY_CHANGE in commands:
self.readyChange()
return None
if keymap.EDIT_COMMIT_MESSAGE in commands:
self.editCommitMessage()
return None
if keymap.REBASE_CHANGE in commands:
self.rebaseChange()
return None
if keymap.RESTORE_CHANGE in commands:
self.restoreChange()
return None
if keymap.REFRESH in commands:
self.app.sync.submitTask(
sync.SyncChangeTask(self.change_rest_id, priority=sync.HIGH_PRIORITY))
self.app.status.update()
return None
if keymap.SUBMIT_CHANGE in commands:
self.doSubmitChange()
return None
if keymap.EDIT_TOPIC in commands:
self.editTopic()
return None
if keymap.EDIT_HASHTAGS in commands:
self.editHashtags()
return None
if keymap.CHERRY_PICK_CHANGE in commands:
self.cherryPickChange()
return None
if key in self.app.config.reviewkeys:
self.reviewKey(self.app.config.reviewkeys[key])
return None
return key
def diff(self, revision_key):
if self.app.config.diff_view == 'unified':
screen = view_unified_diff.UnifiedDiffView(self.app, revision_key)
else:
screen = view_side_diff.SideDiffView(self.app, revision_key)
self.app.changeScreen(screen)
def abandonChange(self):
dialog = mywid.TextEditDialog(u'Abandon Change', u'Abandon message:',
u'Abandon Change',
self.pending_status_message)
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doAbandonRestoreChange(dialog, 'ABANDONED'))
self.app.popup(dialog)
def restoreChange(self):
dialog = mywid.TextEditDialog(u'Restore Change', u'Restore message:',
u'Restore Change',
self.pending_status_message)
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doAbandonRestoreChange(dialog, 'NEW'))
self.app.popup(dialog)
def doAbandonRestoreChange(self, dialog, state):
change_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.status = state
change.pending_status = True
change.pending_status_message = dialog.entry.edit_text
change_key = change.key
self.app.sync.submitTask(
sync.ChangeStatusTask(change_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def wipChange(self):
dialog = mywid.TextEditDialog(u'Mark Change Work In Progress',
u'WIP message:',
u'WIP Change',
self.pending_status_message)
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doWip(dialog, True))
self.app.popup(dialog)
def readyChange(self):
dialog = mywid.TextEditDialog(u'Mark Change Ready For Review',
u'Ready message:',
u'Ready Change',
self.pending_status_message)
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doWip(dialog, False))
self.app.popup(dialog)
def doWip(self, dialog, state):
change_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.wip = state
change.pending_wip = True
change.pending_wip_message = dialog.entry.edit_text
change_key = change.key
self.app.sync.submitTask(
sync.ChangeWIPTask(change_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def editCommitMessage(self):
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
dialog = mywid.TextEditDialog(u'Edit Commit Message', u'Commit message:',
u'Save', change.revisions[-1].message)
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doEditCommitMessage(dialog))
self.app.popup(dialog,
relative_width=50, relative_height=75,
min_width=60, min_height=20)
def doEditCommitMessage(self, dialog):
revision_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
revision = change.revisions[-1]
revision.message = dialog.entry.edit_text
revision.pending_message = True
revision_key = revision.key
self.app.sync.submitTask(
sync.ChangeCommitMessageTask(revision_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def rebaseChange(self):
dialog = mywid.YesNoDialog(u'Rebase Change',
u'Perform a remote rebase of this change?')
urwid.connect_signal(dialog, 'no', self.app.backScreen)
urwid.connect_signal(dialog, 'yes', self.doRebaseChange)
self.app.popup(dialog)
def doRebaseChange(self, button=None):
change_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.pending_rebase = True
change_key = change.key
self.app.sync.submitTask(
sync.RebaseChangeTask(change_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def cherryPickChange(self):
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
dialog = CherryPickDialog(self.app, change)
urwid.connect_signal(dialog, 'cancel', self.app.backScreen)
urwid.connect_signal(dialog, 'save', lambda button:
self.doCherryPickChange(dialog))
self.app.popup(dialog,
relative_width=50, relative_height=75,
min_width=60, min_height=20)
def doCherryPickChange(self, dialog):
cp_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
branch = None
for button in dialog.branch_buttons:
if button.state:
branch = button.get_label()
message = dialog.entry.edit_text
self.app.log.debug("Creating pending cherry-pick of %s to %s" %
(change.revisions[-1].commit, branch))
cp = change.revisions[-1].createPendingCherryPick(branch, message)
cp_key = cp.key
self.app.sync.submitTask(
sync.SendCherryPickTask(cp_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def doSubmitChange(self):
change_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.status = 'SUBMITTED'
change.pending_status = True
change.pending_status_message = None
change_key = change.key
self.app.sync.submitTask(
sync.ChangeStatusTask(change_key, sync.HIGH_PRIORITY))
self.refresh()
def editTopic(self):
dialog = EditTopicDialog(self.app, self.topic)
urwid.connect_signal(dialog, 'save',
lambda button: self.closeEditTopic(dialog, True))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.closeEditTopic(dialog, False))
self.app.popup(dialog)
def closeEditTopic(self, dialog, save):
if save:
change_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.topic = dialog.entry.edit_text
change.pending_topic = True
change_key = change.key
self.app.sync.submitTask(
sync.SetTopicTask(change_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def editHashtags(self):
dialog = EditHashtagsDialog(self.app, self.hashtags)
urwid.connect_signal(dialog, 'save',
lambda button: self.closeEditHashtags(dialog, True))
urwid.connect_signal(dialog, 'cancel',
lambda button: self.closeEditHashtags(dialog, False))
self.app.popup(dialog)
def closeEditHashtags(self, dialog, save):
if save:
change_key = None
with self.app.db.getSession() as session:
change = session.getChange(self.change_key)
change.setHashtags([x.strip() for x in dialog.entry.edit_text.split(',')])
change.pending_hashtags = True
change_key = change.key
self.app.sync.submitTask(
sync.SetHashtagsTask(change_key, sync.HIGH_PRIORITY))
self.app.backScreen()
self.refresh()
def openPermalink(self, widget):
self.app.openURL(self.permalink_url)
def searchChangeId(self, widget):
self.app.doSearch("status:open change:%s" % (self.change_id,))
def searchOwner(self, widget):
if self.owner_email:
self.app.doSearch("status:open owner:%s" % (self.owner_email,))
def searchProject(self, widget):
self.app.doSearch("status:open project:%s" % (self.project_name,))
def searchTopic(self, widget):
if self.topic:
self.app.doSearch("status:open topic:%s" % (self.topic,))
def searchHashtags(self, name):
self.app.doSearch("status:open hashtag:%s" % (name,))
def reviewKey(self, reviewkey):
approvals = {}
for a in reviewkey['approvals']:
approvals[a['category']] = a['value']
self.app.log.debug("Reviewkey %s with approvals %s" %
(reviewkey['key'], approvals))
row = self.revision_rows[self.last_revision_key]
message = reviewkey.get('message', '')
submit = reviewkey.get('submit', False)
self.saveReview(row.revision_key, approvals, message, True, submit)
def saveReview(self, revision_key, approvals, message, upload, submit):
message_keys = self.app.saveReviews([revision_key], approvals,
message, upload, submit)
if upload:
for message_key in message_keys:
self.app.sync.submitTask(
sync.UploadReviewTask(message_key, sync.HIGH_PRIORITY))
self.refresh()
if self.app.config.close_change_on_review:
self.app.backScreen()