Improving Gerrit + commit-log automation.

I've reworked a few items here, so I will describe them in as much a
linear fashion as possible. Keep in mind that one of the primary goals
for these changes is to allow us to "trigger more magic from Gerrit".
And it is to that end that I've implemented these changes.

In find_bugs(), I've tightened-up the regular expression being used so
that it will parse-out any prefixes associated with the bug reference.
I've tested the regular expression against the most common bug
references that I've seen in commit logs, as well as against the
styles described in our documentation. The sources that I've drawn
from are:

    https://etherpad.openstack.org/drive-automation-from-commitmsg
    https://wiki.openstack.org/wiki/GitCommitMessages
    https://wiki.openstack.org/wiki/Gerrit_Workflow

Moreover, I'm using re.finditer() which allows for more direct
access to the text that was matched. Lastly, I've tried to keep the
expression as flexible as possible so that it will match even if the
developer references the bug in a funky way.

In order to keep the prefix and lp_task associated with each other,
I've created a class called "Task" which is simply an interface to
determine what sort of automation needs to take place for the given
bugtask. This being the case, I've taken the liberty of renaming a few
variables to make this more clear.

In "Task", a basic level of processing is performed on the prefix to
determine what changes need to be made on launchpad. A method called
needs_change() returns a boolean indicating if the supplied argument
is a change which needs to be made.

Lastly, yet most importantly for this bug fix, process_bugtsk() is
utilizing needs_change(), as mentioned above, to ensure that the
bugtask's status is not erroneously changed in the case of a bug fix
which spans multiple commits.

Closes-Bug: 1018013
Change-Id: Ibd84d3c6edcf104afe3211fb55ea531efa92d20e
diff --git a/jeepyb/cmd/update_bug.py b/jeepyb/cmd/update_bug.py
index bc433ae..be486a7 100644
--- a/jeepyb/cmd/update_bug.py
+++ b/jeepyb/cmd/update_bug.py
@@ -230,29 +230,76 @@
     ]
 
 
-def process_bugtask(launchpad, bugtask, git_log, args):
-    """Apply changes to bugtask, based on hook / branch..."""
+class Task:
+    def __init__(self, lp_task, prefix):
+        '''Prefixes associated with bug references will allow for certain
+        changes to be made to the bug's launchpad (lp) page. The following
+        tokens represent the automation currently taking place.
+
+        ::
+        add_comment       -> Adds a comment to the bug's lp page.
+        set_in_progress   -> Sets the bug's lp status to 'In Progress'.
+        set_fix_released  -> Sets the bug's lp status to 'Fix Released'.
+        set_fix_committed -> Sets the bug's lp status to 'Fix Committed'.
+        ::
+
+        changes_needed, when populated, simply indicates the actions that are
+        available to be taken based on the value of 'prefix'.
+        '''
+        self.lp_task = lp_task
+        self.changes_needed = []
+
+        # If no prefix was matched, default to 'closes'.
+        prefix = prefix.split('-')[0].lower() if prefix else 'closes'
+
+        if prefix in ('closes', 'fixes', 'resolves'):
+            self.changes_needed.extend(('add_comment',
+                                        'set_in_progress',
+                                        'set_fix_committed',
+                                        'set_fix_released'))
+        elif prefix in ('partial',):
+            self.changes_needed.extend(('add_comment', 'set_in_progress'))
+        elif prefix in ('related', 'impacts', 'affects'):
+            self.changes_needed.extend(('add_comment',))
+        else:
+            # prefix is not recognized.
+            self.changes_needed.extend(('add_comment',))
+
+    def needs_change(self, change):
+        '''Return a boolean indicating if given 'change' needs to be made.'''
+        if change in self.changes_needed:
+            return True
+        else:
+            return False
+
+
+def process_bugtask(launchpad, task, git_log, args):
+    """Apply changes to lp bug tasks, based on hook / branch."""
+
+    bugtask = task.lp_task
 
     if args.hook == "change-merged":
         if args.branch == 'master':
-            if is_direct_release(args.project):
+            if (is_direct_release(args.project) and
+                    task.needs_change('set_fix_released')):
                 set_fix_released(bugtask)
             else:
-                if bugtask.status != u'Fix Released':
+                if (bugtask.status != u'Fix Released' and
+                        task.needs_change('set_fix_committed')):
                     set_fix_committed(bugtask)
         elif args.branch == 'milestone-proposed':
             release_fixcommitted(bugtask)
         elif args.branch.startswith('stable/'):
             series = args.branch[7:]
-            # Look for a related task matching the series
+            # Look for a related task matching the series.
             for reltask in bugtask.related_tasks:
                 if (reltask.bug_target_name.endswith("/" + series) and
-                        reltask.status != u'Fix Released'):
-                    # Use fixcommitted if there is any
+                        reltask.status != u'Fix Released' and
+                        task.needs_change('set_fix_committed')):
                     set_fix_committed(reltask)
                     break
             else:
-                # Use tagging if there isn't any
+                # Use tag_in_branchname if there isn't any.
                 tag_in_branchname(bugtask, args.branch)
 
         add_change_merged_message(bugtask, args.change_url, args.project,
@@ -261,13 +308,15 @@
 
     if args.hook == "patchset-created":
         if args.branch == 'master':
-            if bugtask.status not in [u'Fix Committed', u'Fix Released']:
-                set_in_progress(bugtask, launchpad, args.uploader,
-                                args.change_url)
+            if (bugtask.status not in [u'Fix Committed', u'Fix Released'] and
+                    task.needs_change('set_in_progress')):
+                set_in_progress(bugtask, launchpad,
+                                args.uploader, args.change_url)
         elif args.branch.startswith('stable/'):
             series = args.branch[7:]
             for reltask in bugtask.related_tasks:
                 if (reltask.bug_target_name.endswith("/" + series) and
+                        task.needs_change('set_in_progress') and
                         reltask.status not in [u'Fix Committed',
                                                u'Fix Released']):
                     set_in_progress(reltask, launchpad,
@@ -280,23 +329,44 @@
 
 
 def find_bugs(launchpad, git_log, args):
-    """Find bugs referenced in the git log and return related bugtasks."""
+    '''Find bugs referenced in the git log and return related tasks.
 
-    bug_regexp = r'([Bb]ug|[Ll][Pp])[\s#:]*(\d+)'
-    tokens = re.split(bug_regexp, git_log)
+    Our regular expression is composed of three major parts:
+    part1: Matches only at start-of-line (required). Optionally matches any
+           word or hyphen separated words.
+    part2: Matches the words 'bug' or 'lp' on a word boundry (required).
+    part3: Matches a whole number (required).
 
-    # Extract unique bug tasks
+    The following patterns will be matched properly:
+    bug # 555555
+    Closes-Bug: 555555
+    Fixes: bug # 555555
+    Resolves: bug 555555
+    Partial-Bug: lp bug # 555555
+
+    :returns: an iterable containing Task objects.
+    '''
+
+    part1 = r'^[\t ]*(?P<prefix>[-\w]+)?[\s:]*'
+    part2 = r'(?:\b(?:bug|lp)\b[\s#:]*)+'
+    part3 = r'(?P<bug_number>\d+)\s*?$'
+    regexp = part1 + part2 + part3
+    matches = re.finditer(regexp, git_log, flags=re.I | re.M)
+
+    # Extract unique bug tasks and associated prefixes.
     bugtasks = {}
-    for token in tokens:
-        if re.match('^\d+$', token) and (token not in bugtasks):
+    for match in matches:
+        prefix = match.group('prefix')
+        bug_num = match.group('bug_number')
+        if bug_num not in bugtasks:
             try:
-                lp_bug = launchpad.bugs[token]
+                lp_bug = launchpad.bugs[bug_num]
                 for lp_task in lp_bug.bug_tasks:
                     if lp_task.bug_target_name == git2lp(args.project):
-                        bugtasks[token] = lp_task
+                        bugtasks[bug_num] = Task(lp_task, prefix)
                         break
             except KeyError:
-                # Unknown bug
+                # Unknown bug.
                 pass
 
     return bugtasks.values()
@@ -313,31 +383,31 @@
 def main():
     parser = argparse.ArgumentParser()
     parser.add_argument('hook')
-    #common
+    # common
     parser.add_argument('--change', default=None)
     parser.add_argument('--change-url', default=None)
     parser.add_argument('--project', default=None)
     parser.add_argument('--branch', default=None)
     parser.add_argument('--commit', default=None)
-    #change-merged
+    # change-merged
     parser.add_argument('--submitter', default=None)
-    #patchset-created
+    # patchset-created
     parser.add_argument('--uploader', default=None)
     parser.add_argument('--patchset', default=None)
 
     args = parser.parse_args()
 
-    # Connect to Launchpad
+    # Connect to Launchpad.
     lpconn = launchpad.Launchpad.login_with(
         'Gerrit User Sync', uris.LPNET_SERVICE_ROOT, GERRIT_CACHE_DIR,
         credentials_file=GERRIT_CREDENTIALS, version='devel')
 
-    # Get git log
+    # Get git log.
     git_log = extract_git_log(args)
 
-    # Process bugtasks found in git log
-    for bugtask in find_bugs(lpconn, git_log, args):
-        process_bugtask(lpconn, bugtask, git_log, args)
+    # Process tasks found in git log.
+    for task in find_bugs(lpconn, git_log, args):
+        process_bugtask(lpconn, task, git_log, args)
 
 if __name__ == "__main__":
     main()