Add held changes

This feature detects when Gertty is about to upload a review with
a positive vote after someone else has left a negative vote while
Gertty was offline.  This prevents a situation where it appears
a user is ignoring negative feedback from others.  The local user
is alerted and has the option to re-evaluate their review before
proceeding.

Change-Id: I838acaae6d12a2f8557bfd5a16837784c97c031a
This commit is contained in:
James E. Blair 2015-03-25 16:22:33 -07:00
parent 7e85ed3745
commit 35f5af52e7
11 changed files with 187 additions and 13 deletions

View File

@ -131,6 +131,16 @@ If Gertty is offline, it will so indicate in the status bar. It will
retry requests if needed, and will switch between offline and online retry requests if needed, and will switch between offline and online
mode automatically. mode automatically.
If you review a change while offline with a positive vote, and someone
else leaves a negative vote on that change in the same category before
Gertty is able to upload your review, Gertty will detect the situation
and mark the change as "held" so that you may re-inspect the change
and any new comments before uploading the review. The status bar will
alert you to any held changes and direct you to a list of them (the
`F12` key by default). When viewing a change, the "held" flag may be
toggeled with the exclamation key (`!`). Once held, a change must be
explicitly un-held in this manner for your review to be uploaded.
If Gertty encounters an error, this will also be indicated in the If Gertty encounters an error, this will also be indicated in the
status bar. You may wish to examine ~/.gertty.log to see what the status bar. You may wish to examine ~/.gertty.log to see what the
error was. In many cases, Gertty can continue after encountering an error was. In many cases, Gertty can continue after encountering an

View File

@ -0,0 +1,37 @@
"""add held
Revision ID: 3cc7e3753dc3
Revises: 1cdd4e2e74c
Create Date: 2015-03-22 08:48:15.516289
"""
# revision identifiers, used by Alembic.
revision = '3cc7e3753dc3'
down_revision = '1cdd4e2e74c'
import warnings
from alembic import op
import sqlalchemy as sa
from gertty.dbsupport import sqlite_alter_columns
def upgrade():
with warnings.catch_warnings():
warnings.simplefilter("ignore")
op.add_column('change', sa.Column('held', sa.Boolean()))
connection = op.get_bind()
change = sa.sql.table('change',
sa.sql.column('held', sa.Boolean()))
connection.execute(change.update().values({'held':False}))
sqlite_alter_columns('change', [
sa.Column('held', sa.Boolean(), index=True, nullable=False),
])
def downgrade():
pass

View File

@ -64,8 +64,10 @@ class StatusHeader(urwid.WidgetWrap):
self.error_widget = urwid.Text('') self.error_widget = urwid.Text('')
self.offline_widget = urwid.Text('') self.offline_widget = urwid.Text('')
self.sync_widget = urwid.Text(u'Sync: 0') self.sync_widget = urwid.Text(u'Sync: 0')
self.held_widget = urwid.Text(u'')
self._w.contents.append((self.title_widget, ('pack', None, False))) self._w.contents.append((self.title_widget, ('pack', None, False)))
self._w.contents.append((urwid.Text(u''), ('weight', 1, False))) self._w.contents.append((urwid.Text(u''), ('weight', 1, False)))
self._w.contents.append((self.held_widget, ('pack', None, False)))
self._w.contents.append((self.error_widget, ('pack', None, False))) self._w.contents.append((self.error_widget, ('pack', None, False)))
self._w.contents.append((self.offline_widget, ('pack', None, False))) self._w.contents.append((self.offline_widget, ('pack', None, False)))
self._w.contents.append((self.sync_widget, ('pack', None, False))) self._w.contents.append((self.sync_widget, ('pack', None, False)))
@ -73,18 +75,23 @@ class StatusHeader(urwid.WidgetWrap):
self.offline = None self.offline = None
self.title = None self.title = None
self.sync = None self.sync = None
self.held = None
self._error = False self._error = False
self._offline = False self._offline = False
self._title = '' self._title = ''
self._sync = 0 self._sync = 0
self._held = 0
self.held_key = self.app.config.keymap.formatKeys(keymap.LIST_HELD)
def update(self, title=None, error=None, offline=None, refresh=True): def update(self, title=None, error=None, offline=None, refresh=True, held=None):
if title is not None: if title is not None:
self.title = title self.title = title
if error is not None: if error is not None:
self.error = error self.error = error
if offline is not None: if offline is not None:
self.offline = offline self.offline = offline
if held is not None:
self.held = held
self.sync = self.app.sync.queue.qsize() self.sync = self.app.sync.queue.qsize()
if refresh: if refresh:
self.refresh() self.refresh()
@ -93,6 +100,12 @@ class StatusHeader(urwid.WidgetWrap):
if self._title != self.title: if self._title != self.title:
self._title = self.title self._title = self.title
self.title_widget.set_text(self._title) self.title_widget.set_text(self._title)
if self._held != self.held:
self._held = self.held
if self._held:
self.held_widget.set_text(('error', u'Held: %s (%s)' % (self._held, self.held_key)))
else:
self.held_widget.set_text(u'')
if self._error != self.error: if self._error != self.error:
self._error = self.error self._error = self.error
if self._error: if self._error:
@ -202,6 +215,7 @@ class App(object):
self.header = urwid.AttrMap(self.status, 'header') self.header = urwid.AttrMap(self.status, 'header')
screen = view_project_list.ProjectListView(self) screen = view_project_list.ProjectListView(self)
self.status.update(title=screen.title) self.status.update(title=screen.title)
self.updateStatusQueries()
self.loop = urwid.MainLoop(screen, palette=self.config.palette.getPalette(), self.loop = urwid.MainLoop(screen, palette=self.config.palette.getPalette(),
unhandled_input=self.unhandledInput) unhandled_input=self.unhandledInput)
@ -280,20 +294,30 @@ class App(object):
self.loop.widget = widget self.loop.widget = widget
def refresh(self, data=None, force=False): def refresh(self, data=None, force=False):
self.status.refresh()
widget = self.loop.widget widget = self.loop.widget
while isinstance(widget, urwid.Overlay): while isinstance(widget, urwid.Overlay):
widget = widget.contents[0][0] widget = widget.contents[0][0]
interested = force interested = force
invalidate = False
try: try:
while True: while True:
event = self.sync.result_queue.get(0) event = self.sync.result_queue.get(0)
if widget.interested(event): if widget.interested(event):
interested = True interested = True
if hasattr(event, 'held_changed') and event.held_changed:
invalidate = True
except Queue.Empty: except Queue.Empty:
pass pass
if interested: if interested:
widget.refresh() widget.refresh()
if invalidate:
self.updateStatusQueries()
self.status.refresh()
def updateStatusQueries(self):
with self.db.getSession() as session:
held = len(session.getHeld())
self.status.update(held=held)
def popup(self, widget, def popup(self, widget,
relative_width=50, relative_height=25, relative_width=50, relative_height=25,
@ -441,6 +465,8 @@ class App(object):
self.quit() self.quit()
elif keymap.CHANGE_SEARCH in commands: elif keymap.CHANGE_SEARCH in commands:
self.searchDialog() self.searchDialog()
elif keymap.LIST_HELD in commands:
self.doSearch("status:open is:held")
elif key in self.config.dashboards: elif key in self.config.dashboards:
d = self.config.dashboards[key] d = self.config.dashboards[key]
self.clearHistory() self.clearHistory()
@ -488,6 +514,21 @@ class App(object):
self.error_queue.put(('Warning', m)) self.error_queue.put(('Warning', m))
os.write(self.error_pipe, 'error\n') os.write(self.error_pipe, 'error\n')
def toggleHeldChange(self, change_key):
with self.db.getSession() as session:
change = session.getChange(change_key)
change.held = not change.held
ret = change.held
if not change.held:
for r in change.revisions:
for m in change.messages:
if m.pending:
self.sync.submitTask(
sync.UploadReviewTask(m.key, sync.HIGH_PRIORITY))
self.updateStatusQueries()
return ret
def version(): def version():
return "Gertty version: %s" % gertty.version.version_info.version_string() return "Gertty version: %s" % gertty.version.version_info.version_string()

View File

@ -59,6 +59,7 @@ change_table = Table(
Column('hidden', Boolean, index=True, nullable=False), Column('hidden', Boolean, index=True, nullable=False),
Column('reviewed', Boolean, index=True, nullable=False), Column('reviewed', Boolean, index=True, nullable=False),
Column('starred', Boolean, index=True, nullable=False), Column('starred', Boolean, index=True, nullable=False),
Column('held', Boolean, index=True, nullable=False),
Column('pending_rebase', Boolean, index=True, nullable=False), Column('pending_rebase', Boolean, index=True, nullable=False),
Column('pending_topic', Boolean, index=True, nullable=False), Column('pending_topic', Boolean, index=True, nullable=False),
Column('pending_starred', Boolean, index=True, nullable=False), Column('pending_starred', Boolean, index=True, nullable=False),
@ -184,7 +185,7 @@ class Branch(object):
class Change(object): class Change(object):
def __init__(self, project, id, owner, number, branch, change_id, def __init__(self, project, id, owner, number, branch, change_id,
subject, created, updated, status, topic=None, subject, created, updated, status, topic=None,
hidden=False, reviewed=False, starred=False, hidden=False, reviewed=False, starred=False, held=False,
pending_rebase=False, pending_topic=False, pending_rebase=False, pending_topic=False,
pending_starred=False, pending_status=False, pending_starred=False, pending_status=False,
pending_status_message=None): pending_status_message=None):
@ -202,6 +203,7 @@ class Change(object):
self.hidden = hidden self.hidden = hidden
self.reviewed = reviewed self.reviewed = reviewed
self.starred = starred self.starred = starred
self.held = held
self.pending_rebase = pending_rebase self.pending_rebase = pending_rebase
self.pending_topic = pending_topic self.pending_topic = pending_topic
self.pending_starred = pending_starred self.pending_starred = pending_starred
@ -679,6 +681,9 @@ class DatabaseSession(object):
except sqlalchemy.orm.exc.NoResultFound: except sqlalchemy.orm.exc.NoResultFound:
return None return None
def getHeld(self):
return self.session().query(Change).filter_by(held=True).all()
def getPendingMessages(self): def getPendingMessages(self):
return self.session().query(Message).filter_by(pending=True).all() return self.session().query(Message).filter_by(pending=True).all()

View File

@ -34,10 +34,12 @@ PREV_SCREEN = 'previous screen'
HELP = 'help' HELP = 'help'
QUIT = 'quit' QUIT = 'quit'
CHANGE_SEARCH = 'change search' CHANGE_SEARCH = 'change search'
LIST_HELD = 'list held changes'
# Change screen: # Change screen:
TOGGLE_REVIEWED = 'toggle reviewed' TOGGLE_REVIEWED = 'toggle reviewed'
TOGGLE_HIDDEN = 'toggle hidden' TOGGLE_HIDDEN = 'toggle hidden'
TOGGLE_STARRED = 'toggle starred' TOGGLE_STARRED = 'toggle starred'
TOGGLE_HELD = 'toggle held'
REVIEW = 'review' REVIEW = 'review'
DIFF = 'diff' DIFF = 'diff'
LOCAL_CHECKOUT = 'local checkout' LOCAL_CHECKOUT = 'local checkout'
@ -82,10 +84,12 @@ DEFAULT_KEYMAP = {
HELP: ['f1', '?'], HELP: ['f1', '?'],
QUIT: 'ctrl q', QUIT: 'ctrl q',
CHANGE_SEARCH: 'ctrl o', CHANGE_SEARCH: 'ctrl o',
LIST_HELD: 'f12',
TOGGLE_REVIEWED: 'v', TOGGLE_REVIEWED: 'v',
TOGGLE_HIDDEN: 'k', TOGGLE_HIDDEN: 'k',
TOGGLE_STARRED: '*', TOGGLE_STARRED: '*',
TOGGLE_HELD: '!',
REVIEW: 'r', REVIEW: 'r',
DIFF: 'd', DIFF: 'd',
LOCAL_CHECKOUT: 'c', LOCAL_CHECKOUT: 'c',

View File

@ -25,6 +25,8 @@ GLOBAL_HELP = (
"Quit Gertty"), "Quit Gertty"),
(keymap.CHANGE_SEARCH, (keymap.CHANGE_SEARCH,
"Search for changes"), "Search for changes"),
(keymap.LIST_HELD,
"List held changes"),
) )
class TextButton(urwid.Button): class TextButton(urwid.Button):

View File

@ -81,6 +81,8 @@ DEFAULT_PALETTE={
'focused-reviewed-change': ['dark gray,standout', ''], 'focused-reviewed-change': ['dark gray,standout', ''],
'starred-change': ['light cyan', ''], 'starred-change': ['light cyan', ''],
'focused-starred-change': ['light cyan,standout', ''], 'focused-starred-change': ['light cyan,standout', ''],
'held-change': ['light red', ''],
'focused-held-change': ['light red,standout', ''],
} }
# A delta from the default palette # A delta from the default palette

View File

@ -271,6 +271,9 @@ def SearchParser():
p[0] = gertty.db.account_table.c.username == username p[0] = gertty.db.account_table.c.username == username
elif p[2] == 'starred': elif p[2] == 'starred':
p[0] = gertty.db.change_table.c.starred == True p[0] = gertty.db.change_table.c.starred == True
elif p[2] == 'held':
# A gertty extension
p[0] = gertty.db.change_table.c.held == True
elif p[2] == 'reviewer': elif p[2] == 'reviewer':
filters = [] filters = []
filters.append(gertty.db.approval_table.c.change_key == gertty.db.change_table.c.key) filters.append(gertty.db.approval_table.c.change_key == gertty.db.change_table.c.key)

View File

@ -111,6 +111,7 @@ class ChangeAddedEvent(UpdateEvent):
self.related_change_keys = set() self.related_change_keys = set()
self.review_flag_changed = True self.review_flag_changed = True
self.status_changed = True self.status_changed = True
self.held_changed = False
class ChangeUpdatedEvent(UpdateEvent): class ChangeUpdatedEvent(UpdateEvent):
def __repr__(self): def __repr__(self):
@ -123,6 +124,7 @@ class ChangeUpdatedEvent(UpdateEvent):
self.related_change_keys = set() self.related_change_keys = set()
self.review_flag_changed = False self.review_flag_changed = False
self.status_changed = False self.status_changed = False
self.held_changed = False
class Task(object): class Task(object):
def __init__(self, priority=NORMAL_PRIORITY): def __init__(self, priority=NORMAL_PRIORITY):
@ -420,7 +422,8 @@ class SyncChangeTask(Task):
remote_revision['commit']['message'], remote_commit, remote_revision['commit']['message'], remote_commit,
remote_revision['commit']['parents'][0]['commit'], remote_revision['commit']['parents'][0]['commit'],
auth, ref) auth, ref)
self.log.info("Created new revision %s for change %s revision %s in local DB.", revision.key, self.change_id, remote_revision['_number']) self.log.info("Created new revision %s for change %s revision %s in local DB.",
revision.key, self.change_id, remote_revision['_number'])
new_revision = True new_revision = True
revision.message = remote_revision['commit']['message'] revision.message = remote_revision['commit']['message']
actions = remote_revision.get('actions', {}) actions = remote_revision.get('actions', {})
@ -454,7 +457,8 @@ class SyncChangeTask(Task):
created, created,
remote_file, parent, remote_comment.get('line'), remote_file, parent, remote_comment.get('line'),
remote_comment['message']) remote_comment['message'])
self.log.info("Created new comment %s for revision %s in local DB.", comment.key, revision.key) self.log.info("Created new comment %s for revision %s in local DB.",
comment.key, revision.key)
else: else:
if comment.author != account: if comment.author != account:
comment.author = account comment.author = account
@ -506,9 +510,26 @@ class SyncChangeTask(Task):
remote_label_keys = set(remote_label_entries.keys()) remote_label_keys = set(remote_label_entries.keys())
local_approvals = {} local_approvals = {}
local_labels = {} local_labels = {}
user_votes = {}
for approval in change.approvals: for approval in change.approvals:
if approval.draft and not new_revision:
# If we have a new revision, we need to delete
# draft local approvals because they can no longer
# be uploaded. Otherwise, keep them because we
# may be about to upload a review. Ignoring an
# approval here means it will not be deleted.
# Also keep track of these approvals so we can
# determine whether we should hold the change
# later.
user_votes[approval.category] = approval.value
# Count draft votes as having voted for the
# purposes of deciding whether to clear the
# reviewed flag later.
user_voted = True
continue
key = '%s~%s' % (approval.category, approval.reviewer.id) key = '%s~%s' % (approval.category, approval.reviewer.id)
if key in local_approvals: if key in local_approvals:
# Delete duplicate approvals.
session.delete(approval) session.delete(approval)
else: else:
local_approvals[key] = approval local_approvals[key] = approval
@ -534,6 +555,16 @@ class SyncChangeTask(Task):
remote_approval['category'], remote_approval['category'],
remote_approval['value']) remote_approval['value'])
self.log.info("Created approval for change %s in local DB.", change.id) self.log.info("Created approval for change %s in local DB.", change.id)
user_value = user_votes.get(remote_approval['category'], 0)
if user_value > 0 and remote_approval['value'] < 0:
# Someone left a negative vote after the local
# user created a draft positive vote. Hold the
# change so that it doesn't look like the local
# user is ignoring negative feedback.
if not change.held:
change.held = True
result.held_changed = True
self.log.info("Setting change %s to held due to negative review after positive", change.id)
for key in remote_label_keys-local_label_keys: for key in remote_label_keys-local_label_keys:
remote_label = remote_label_entries[key] remote_label = remote_label_entries[key]
@ -831,12 +862,25 @@ class UploadReviewTask(Task):
def run(self, sync): def run(self, sync):
app = sync.app app = sync.app
with app.db.getSession() as session:
message = session.getMessage(self.message_key)
change = message.revision.change
if not change.held:
self.log.debug("Syncing %s to find out if it should be held" % (change.id,))
t = SyncChangeTask(change.id)
t.run(sync)
self.results += t.results
submit = False submit = False
change_id = None change_id = None
with app.db.getSession() as session: with app.db.getSession() as session:
message = session.getMessage(self.message_key) message = session.getMessage(self.message_key)
revision = message.revision revision = message.revision
change = message.revision.change change = message.revision.change
if change.held:
self.log.debug("Not uploading review to %s because it is held" %
(change.id,))
return
change_id = change.id change_id = change.id
current_revision = change.revisions[-1] current_revision = change.revisions[-1]
if change.pending_status and change.status == 'SUBMITTED': if change.pending_status and change.status == 'SUBMITTED':

View File

@ -407,6 +407,8 @@ class ChangeView(urwid.WidgetWrap):
"Go to the previous change in the list"), "Go to the previous change in the list"),
(key(keymap.REVIEW), (key(keymap.REVIEW),
"Leave a review for the most recent revision"), "Leave a review for the most recent revision"),
(key(keymap.TOGGLE_HELD),
"Toggle the held flag for the current change"),
(key(keymap.TOGGLE_HIDDEN_COMMENTS), (key(keymap.TOGGLE_HIDDEN_COMMENTS),
"Toggle display of hidden comments"), "Toggle display of hidden comments"),
(key(keymap.SEARCH_RESULTS), (key(keymap.SEARCH_RESULTS),
@ -546,20 +548,17 @@ class ChangeView(urwid.WidgetWrap):
change = session.getChange(self.change_key) change = session.getChange(self.change_key)
self.topic = change.topic or '' self.topic = change.topic or ''
self.pending_status_message = change.pending_status_message or '' self.pending_status_message = change.pending_status_message or ''
reviewed = hidden = starred = held = ''
if change.reviewed: if change.reviewed:
reviewed = ' (reviewed)' reviewed = ' (reviewed)'
else:
reviewed = ''
if change.hidden: if change.hidden:
hidden = ' (hidden)' hidden = ' (hidden)'
else:
hidden = ''
if change.starred: if change.starred:
starred = '* ' starred = '* '
else: if change.held:
starred = '' held = ' (held)'
self.title = '%sChange %s%s%s' % (starred, change.number, self.title = '%sChange %s%s%s%s' % (starred, change.number, reviewed,
reviewed, hidden) hidden, held)
self.app.status.update(title=self.title) self.app.status.update(title=self.title)
self.project_key = change.project.key self.project_key = change.project.key
self.change_rest_id = change.id self.change_rest_id = change.id
@ -777,6 +776,9 @@ class ChangeView(urwid.WidgetWrap):
self.app.sync.submitTask( self.app.sync.submitTask(
sync.ChangeStarredTask(self.change_key, sync.HIGH_PRIORITY)) sync.ChangeStarredTask(self.change_key, sync.HIGH_PRIORITY))
def toggleHeld(self):
return self.app.toggleHeldChange(self.change_key)
def keypress(self, size, key): def keypress(self, size, key):
r = super(ChangeView, self).keypress(size, key) r = super(ChangeView, self).keypress(size, key)
commands = self.app.config.keymap.getCommands(r) commands = self.app.config.keymap.getCommands(r)
@ -792,6 +794,10 @@ class ChangeView(urwid.WidgetWrap):
self.toggleStarred() self.toggleStarred()
self.refresh() self.refresh()
return None return None
if keymap.TOGGLE_HELD in commands:
self.toggleHeld()
self.refresh()
return None
if keymap.REVIEW in commands: if keymap.REVIEW in commands:
row = self.revision_rows[self.last_revision_key] row = self.revision_rows[self.last_revision_key]
row.review_button.openReview() row.review_button.openReview()

View File

@ -52,6 +52,7 @@ class ChangeRow(urwid.Button):
'unreviewed-change': 'focused-unreviewed-change', 'unreviewed-change': 'focused-unreviewed-change',
'reviewed-change': 'focused-reviewed-change', 'reviewed-change': 'focused-reviewed-change',
'starred-change': 'focused-starred-change', 'starred-change': 'focused-starred-change',
'held-change': 'focused-held-change',
'positive-label': 'focused-positive-label', 'positive-label': 'focused-positive-label',
'negative-label': 'focused-negative-label', 'negative-label': 'focused-negative-label',
'min-label': 'focused-min-label', 'min-label': 'focused-min-label',
@ -98,6 +99,9 @@ class ChangeRow(urwid.Button):
if change.starred: if change.starred:
flag = '*' flag = '*'
style = 'starred-change' style = 'starred-change'
if change.held:
flag = '!'
style = 'held-change'
subject = flag + subject subject = flag + subject
self.row_style.set_attr_map({None: style}) self.row_style.set_attr_map({None: style})
self.subject.set_text(subject) self.subject.set_text(subject)
@ -152,6 +156,8 @@ class ChangeListView(urwid.WidgetWrap):
def help(self): def help(self):
key = self.app.config.keymap.formatKeys key = self.app.config.keymap.formatKeys
return [ return [
(key(keymap.TOGGLE_HELD),
"Toggle the held flag for the currently selected change"),
(key(keymap.TOGGLE_HIDDEN), (key(keymap.TOGGLE_HIDDEN),
"Toggle the hidden flag for the currently selected change"), "Toggle the hidden flag for the currently selected change"),
(key(keymap.TOGGLE_LIST_REVIEWED), (key(keymap.TOGGLE_LIST_REVIEWED),
@ -368,6 +374,9 @@ class ChangeListView(urwid.WidgetWrap):
sync.ChangeStarredTask(change_key, sync.HIGH_PRIORITY)) sync.ChangeStarredTask(change_key, sync.HIGH_PRIORITY))
return ret return ret
def toggleHeld(self, change_key):
return self.app.toggleHeldChange(change_key)
def toggleHidden(self, change_key): def toggleHidden(self, change_key):
with self.app.db.getSession() as session: with self.app.db.getSession() as session:
change = session.getChange(change_key) change = session.getChange(change_key)
@ -420,6 +429,17 @@ class ChangeListView(urwid.WidgetWrap):
# where we're not just popping a row from the list of changes. # where we're not just popping a row from the list of changes.
self.refresh() self.refresh()
return None return None
if keymap.TOGGLE_HELD in commands:
if not len(self.listbox.body):
return None
pos = self.listbox.focus_position
change_key = self.listbox.body[pos].change_key
held = self.toggleHeld(change_key)
row = self.change_rows[change_key]
with self.app.db.getSession() as session:
change = session.getChange(change_key)
row.update(change, self.categories)
return None
if keymap.TOGGLE_STARRED in commands: if keymap.TOGGLE_STARRED in commands:
if not len(self.listbox.body): if not len(self.listbox.body):
return None return None