From de17a501e7b2eb0d1721a00dca4c4ae5b4289bc0 Mon Sep 17 00:00:00 2001
From: "James E. Blair" <jeblair@hp.com>
Date: Sun, 31 Aug 2014 14:57:25 -0700
Subject: [PATCH] Add support for cherry-picking to a branch

Change-Id: I50fc591226f15505daf1bb5d954be4a9e00a39b5
---
 gertty/db.py          | 24 +++++++++++--
 gertty/keymap.py      |  2 ++
 gertty/sync.py        | 78 +++++++++++++++++++++++++++++++++++++++++++
 gertty/view/change.py | 67 ++++++++++++++++++++++++++++++++++++-
 4 files changed, 167 insertions(+), 4 deletions(-)

diff --git a/gertty/db.py b/gertty/db.py
index 4b230b3..23cfccf 100644
--- a/gertty/db.py
+++ b/gertty/db.py
@@ -300,6 +300,15 @@ class Revision(object):
         session.flush()
         return c
 
+    def createPendingCherryPick(self, *args, **kw):
+        session = Session.object_session(self)
+        args = [self] + list(args)
+        c = PendingCherryPick(*args, **kw)
+        self.pending_cherry_picks.append(c)
+        session.add(c)
+        session.flush()
+        return c
+
     def getPendingMessage(self):
         for m in self.messages:
             if m.pending:
@@ -366,7 +375,7 @@ class PendingCherryPick(object):
 mapper(Account, account_table)
 mapper(Project, project_table, properties=dict(
         branches=relationship(Branch, backref='project',
-                              order_by=branch_table.c.key),
+                              order_by=branch_table.c.name),
         changes=relationship(Change, backref='project',
                              order_by=change_table.c.number),
         unreviewed_changes=relationship(Change,
@@ -415,6 +424,7 @@ mapper(Revision, revision_table, properties=dict(
                                                      comment_table.c.draft==True),
                                     order_by=(comment_table.c.line,
                                               comment_table.c.created)),
+        pending_cherry_picks=relationship(PendingCherryPick, backref='revision'),
         ))
 mapper(Message, message_table, properties=dict(
         author=relationship(Account)))
@@ -424,8 +434,7 @@ mapper(Label, label_table)
 mapper(PermittedLabel, permitted_label_table)
 mapper(Approval, approval_table, properties=dict(
         reviewer=relationship(Account)))
-mapper(PendingCherryPick, pending_cherry_pick_table, properties=dict(
-        revision=relationship(Revision)))
+mapper(PendingCherryPick, pending_cherry_pick_table)
 
 class Database(object):
     def __init__(self, app):
@@ -539,6 +548,12 @@ class DatabaseSession(object):
         except sqlalchemy.orm.exc.NoResultFound:
             return None
 
+    def getPendingCherryPick(self, key):
+        try:
+            return self.session().query(PendingCherryPick).filter_by(key=key).one()
+        except sqlalchemy.orm.exc.NoResultFound:
+            return None
+
     def getChanges(self, query, unreviewed=False):
         self.database.log.debug("Search query: %s" % query)
         q = self.session().query(Change).filter(self.search.parse(query))
@@ -611,6 +626,9 @@ class DatabaseSession(object):
     def getPendingStatusChanges(self):
         return self.session().query(Change).filter_by(pending_status=True).all()
 
+    def getPendingCherryPicks(self):
+        return self.session().query(PendingCherryPick).all()
+
     def getAccountByID(self, id, name=None, username=None, email=None):
         try:
             account = self.session().query(Account).filter_by(id=id).one()
diff --git a/gertty/keymap.py b/gertty/keymap.py
index 070ba84..4904742 100644
--- a/gertty/keymap.py
+++ b/gertty/keymap.py
@@ -48,6 +48,7 @@ TOGGLE_HIDDEN_COMMENTS = 'toggle hidden comments'
 ABANDON_CHANGE = 'abandon change'
 RESTORE_CHANGE = 'restore change'
 REBASE_CHANGE = 'rebase change'
+CHERRY_PICK_CHANGE = 'cherry-pick change'
 REFRESH = 'refresh'
 EDIT_TOPIC = 'edit topic'
 # Project list screen:
@@ -89,6 +90,7 @@ DEFAULT_KEYMAP = {
     ABANDON_CHANGE: 'ctrl a',
     RESTORE_CHANGE: 'ctrl e',
     REBASE_CHANGE: 'ctrl b',
+    CHERRY_PICK_CHANGE: 'ctrl x',
     REFRESH: 'ctrl r',
     EDIT_TOPIC: 'ctrl t',
 
diff --git a/gertty/sync.py b/gertty/sync.py
index d5e1b70..98970ab 100644
--- a/gertty/sync.py
+++ b/gertty/sync.py
@@ -17,7 +17,9 @@ import collections
 import logging
 import math
 import os
+import re
 import threading
+import urllib
 import urlparse
 import json
 import time
@@ -125,6 +127,49 @@ class SyncProjectListTask(Task):
                 p = remote[name]
                 session.createProject(name, description=p.get('description', ''))
 
+class SyncSubscribedProjectBranchesTask(Task):
+    def __repr__(self):
+        return '<SyncSubscribedProjectBranchesTask>'
+
+    def run(self, sync):
+        app = sync.app
+        with app.db.getSession() as session:
+            for p in session.getProjects(subscribed=True):
+                sync.submitTask(SyncProjectBranchesTask(p.name, self.priority))
+
+class SyncProjectBranchesTask(Task):
+    branch_re = re.compile(r'refs/heads/(.*)')
+
+    def __init__(self, project_name, priority=NORMAL_PRIORITY):
+        super(SyncProjectBranchesTask, self).__init__(priority)
+        self.project_name = project_name
+
+    def __repr__(self):
+        return '<SyncProjectBranchesTask %s>' % (self.project_name,)
+
+    def run(self, sync):
+        app = sync.app
+        remote = sync.get('projects/%s/branches/' % urllib.quote_plus(self.project_name))
+        remote_branches = set()
+        for x in remote:
+            m = self.branch_re.match(x['ref'])
+            if m:
+                remote_branches.add(m.group(1))
+        with app.db.getSession() as session:
+            local = {}
+            project = session.getProjectByName(self.project_name)
+            for branch in project.branches:
+                local[branch.name] = branch
+            local_branches = set(local.keys())
+
+            for name in local_branches-remote_branches:
+                self.log.debug("Delete branch %s from project %s" % (name, project.name))
+                session.delete(local[name])
+
+            for name in remote_branches-local_branches:
+                self.log.debug("Add branch %s to project %s" % (name, project.name))
+                project.createBranch(name)
+
 class SyncSubscribedProjectsTask(Task):
     def __repr__(self):
         return '<SyncSubscribedProjectsTask>'
@@ -537,6 +582,8 @@ class UploadReviewsTask(Task):
                 sync.submitTask(RebaseChangeTask(c.key, self.priority))
             for c in session.getPendingStatusChanges():
                 sync.submitTask(ChangeStatusTask(c.key, self.priority))
+            for c in session.getPendingCherryPicks():
+                sync.submitTask(SendCherryPickTask(c.key, self.priority))
             for m in session.getPendingMessages():
                 sync.submitTask(UploadReviewTask(m.key, self.priority))
 
@@ -603,6 +650,28 @@ class ChangeStatusTask(Task):
                           data)
             sync.submitTask(SyncChangeTask(change.id, priority=self.priority))
 
+class SendCherryPickTask(Task):
+    def __init__(self, cp_key, priority=NORMAL_PRIORITY):
+        super(SendCherryPickTask, self).__init__(priority)
+        self.cp_key = cp_key
+
+    def __repr__(self):
+        return '<SendCherryPickTask %s>' % (self.cp_key,)
+
+    def run(self, sync):
+        app = sync.app
+        with app.db.getSession() as session:
+            cp = session.getPendingCherryPick(self.cp_key)
+            data = dict(message=cp.message,
+                        destination=cp.branch)
+            session.delete(cp)
+            # Inside db session for rollback
+            ret = sync.post('changes/%s/revisions/%s/cherrypick' %
+                            (cp.revision.change.id, cp.revision.commit),
+                            data)
+        if ret and 'id' in ret:
+            sync.submitTask(SyncChangeTask(ret['id'], priority=self.priority))
+
 class UploadReviewTask(Task):
     def __init__(self, message_key, priority=NORMAL_PRIORITY):
         super(UploadReviewTask, self).__init__(priority)
@@ -662,6 +731,7 @@ class Sync(object):
         self.submitTask(UploadReviewsTask(HIGH_PRIORITY))
         self.submitTask(SyncProjectListTask(HIGH_PRIORITY))
         self.submitTask(SyncSubscribedProjectsTask(HIGH_PRIORITY))
+        self.submitTask(SyncSubscribedProjectBranchesTask(LOW_PRIORITY))
         self.periodic_thread = threading.Thread(target=self.periodicSync)
         self.periodic_thread.daemon = True
         self.periodic_thread.start()
@@ -735,6 +805,14 @@ class Sync(object):
                           headers = {'Content-Type': 'application/json;charset=UTF-8',
                                      'User-Agent': self.user_agent})
         self.log.debug('Received: %s' % (r.text,))
+        ret = None
+        if r.text and len(r.text)>4:
+            try:
+                ret = json.loads(r.text[4:])
+            except Exception:
+                self.log.exception("Unable to parse result %s from post to %s" %
+                                   (r.text, url))
+        return ret
 
     def put(self, path, data):
         url = self.url(path)
diff --git a/gertty/view/change.py b/gertty/view/change.py
index 3e0b1cf..4be7ad5 100644
--- a/gertty/view/change.py
+++ b/gertty/view/change.py
@@ -65,7 +65,7 @@ class AbandonRestoreDialog(urwid.WidgetWrap):
         button_columns = urwid.Columns(button_widgets, dividechars=2)
         rows = []
         self.entry = urwid.Edit(edit_text=text, multiline=True)
-        rows.append(urwid.Text(u"%s message: " % action))
+        rows.append(urwid.Text(u"%s message:" % action))
         rows.append(self.entry)
         rows.append(urwid.Divider())
         rows.append(button_columns)
@@ -73,6 +73,36 @@ class AbandonRestoreDialog(urwid.WidgetWrap):
         fill = urwid.Filler(pile, valign='top')
         super(AbandonRestoreDialog, self).__init__(urwid.LineBox(fill, '%s Change' % (action,)))
 
+class CherryPickDialog(urwid.WidgetWrap):
+    signals = ['save', 'cancel']
+    def __init__(self, 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 = urwid.Edit(edit_text=change.revisions[-1].message, multiline=True)
+        self.branch_buttons = []
+        rows.append(urwid.Text(u"Branch:"))
+        for branch in change.project.branches:
+            b = mywid.FixedRadioButton(self.branch_buttons, branch.name,
+                                       state=(branch.name == change.branch))
+            rows.append(b)
+        rows.append(urwid.Divider())
+        rows.append(urwid.Text(u"Commit message:"))
+        rows.append(self.entry)
+        rows.append(urwid.Divider())
+        rows.append(button_columns)
+        pile = urwid.Pile(rows)
+        fill = urwid.Filler(pile, valign='top')
+        super(CherryPickDialog, self).__init__(urwid.LineBox(fill,
+                                                             'Propose Change to Branch'))
+
 class ReviewDialog(urwid.WidgetWrap):
     signals = ['save', 'cancel']
     def __init__(self, revision_row):
@@ -378,6 +408,8 @@ class ChangeView(urwid.WidgetWrap):
              "Refresh this change"),
             (key(keymap.EDIT_TOPIC),
              "Edit the topic of this change"),
+            (key(keymap.CHERRY_PICK_CHANGE),
+             "Propose this change to another branch"),
             ]
 
         for k in self.app.config.reviewkeys.values():
@@ -762,6 +794,9 @@ class ChangeView(urwid.WidgetWrap):
         if keymap.EDIT_TOPIC in commands:
             self.editTopic()
             return None
+        if keymap.CHERRY_PICK_CHANGE in commands:
+            self.cherryPickChange()
+            return None
         if r in self.app.config.reviewkeys:
             self.reviewKey(self.app.config.reviewkeys[r])
             return None
@@ -819,6 +854,36 @@ class ChangeView(urwid.WidgetWrap):
         self.app.backScreen()
         self.refresh()
 
+    def cherryPickChange(self):
+        with self.app.db.getSession() as session:
+            change = session.getChange(self.change_key)
+            dialog = CherryPickDialog(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 editTopic(self):
         dialog = EditTopicDialog(self.app, self.topic)
         urwid.connect_signal(dialog, 'save',