Patch can be pulled from the tracker on demand
authorFrederic Massart <fmcell@gmail.com>
Tue, 1 Apr 2014 16:19:37 +0000 (00:19 +0800)
committerFrederic Massart <fmcell@gmail.com>
Tue, 1 Apr 2014 16:19:37 +0000 (00:19 +0800)
This also includes a massive rewrite of the pull command which was
getting too distusting to be true... Now the logic is abstracted
in a class, which style does not entirely convince me, but that
is still a lot neater than before!

extra/bash_completion
lib/commands/pull.py
lib/fetch.py [new file with mode: 0644]
lib/git.py

index 6a56a2e..7e03045 100644 (file)
@@ -167,7 +167,11 @@ function _mdk() {
                 fi
                 ;;
             pull)
-                OPTS="--fetch-only --integration --no-merge --testing"
+                if [[ "${PREV}" == "-m" ]] || "${PREV}" == "--mode"; then
+                    OPTS="checkout fetch integration pull testing"
+                else
+                    OPTS="--fetch-only --integration --no-merge --testing --mode --prompt"
+                fi
                 ;;
             push)
                 if [[ "$PREV" != "--branch" && "$PREV" != "-b" && "$PREV" != "--remote" && "$PREV" != "-r" ]]; then
index 12d8cab..2432b80 100644 (file)
@@ -25,8 +25,7 @@ http://github.com/FMCorz/mdk
 import re
 import os
 import logging
-from datetime import datetime
-from lib import tools, jira
+from lib import tools, jira, fetch
 from lib.command import Command
 from lib.tools import question
 
@@ -38,7 +37,7 @@ class PullCommand(Command):
             ['-i', '--integration'],
             {
                 'action': 'store_true',
-                'help': 'checkout the stable branch before proceeding to the pull (Integration mode)'
+                'help': 'checkout the stable branch before proceeding to the pull. Short for --mode integration.'
             }
         ),
         (
@@ -46,7 +45,7 @@ class PullCommand(Command):
             {
                 'action': 'store_true',
                 'dest': 'nomerge',
-                'help': 'checkout the remote branch without merging. Also this does not work with patch files. (No merge mode)'
+                'help': 'checkout the remote branch without merging. Short for --mode checkout.'
             }
         ),
         (
@@ -54,14 +53,30 @@ class PullCommand(Command):
             {
                 'action': 'store_true',
                 'dest': 'fetchonly',
-                'help': 'only fetches the remote branch, you can then use FETCH_HEAD. Does not work with patch files. (Fetch mode)'
+                'help': 'only fetches the remote branch, you can then use FETCH_HEAD. Short for --mode fetch.'
             }
         ),
         (
             ['-t', '--testing'],
             {
                 'action': 'store_true',
-                'help': 'checkout a testing branch before proceeding to the pull (Testing mode)'
+                'help': 'checkout a testing branch before proceeding to the pull. Short for --mode testing.'
+            }
+        ),
+        (
+            ['-m', '--mode'],
+            {
+                'action': 'store',
+                'choices': ['checkout', 'fetch', 'integration', 'pull', 'testing'],
+                'default': 'pull',
+                'help': 'define the mode to use'
+            }
+        ),
+        (
+            ['-p', '--prompt'],
+            {
+                'action': 'store_true',
+                'help': 'prompts the user to choose the patch to download.'
             }
         ),
         (
@@ -82,8 +97,19 @@ class PullCommand(Command):
         if not M:
             raise Exception('This is not a Moodle instance')
 
-        if (args.testing and args.integration) or (args.testing and args.nomerge) or (args.integration and args.nomerge):
-            raise Exception('You cannot combine --integration, --testing or --no-merge')
+        # Get the mode.
+        mode = args.mode
+        if args.fetchonly:
+            mode = 'fetch'
+        elif args.nomerge:
+            mode = 'checkout'
+        elif args.testing:
+            mode = 'testing'
+        elif args.integration:
+            mode = 'integration'
+
+        # Prompt?
+        prompt = args.prompt
 
         # Tracker issue number.
         issuenb = args.issue
@@ -101,69 +127,29 @@ class PullCommand(Command):
 
         # Get information from Tracker
         logging.info('Retrieving information about %s from Moodle Tracker' % (mdl))
-        J = jira.Jira()
-        issueInfo = J.getIssue(mdl)
-
-        mode = 'pull' if not args.fetchonly else 'fetchonly'
-        remoteUrl = issueInfo.get('named').get(self.C.get('tracker.fieldnames.repositoryurl'))
-        remoteBranch = issueInfo.get('named').get(self.C.get('tracker.fieldnames.%s.branch' % (branch)))
-        patchesToApply = []
-
-        if (args.nomerge or args.fetchonly) and (not remoteUrl or not remoteBranch):
-            # No merge and Fetch only require valid URL and branch
-            mode = None
-
-        elif (not args.nomerge and not args.fetchonly) and (not remoteUrl or not remoteBranch):
-            # Attempting to find a patch
-            mode = None
-            attachments = issueInfo.get('fields').get('attachment')
-            patches = {}
-            for attachment in attachments:
-                if attachment['filename'].endswith('.patch'):
-                    patches[attachment['filename']] = attachment
+        fetcher = fetch.FetchTracker(M)
 
-            if len(patches) > 0:
-                mapping = {}
-                i = 1
-                for key in sorted(patches.keys()):
-                    patch = patches[key]
-                    mapping[i] = patch
-                    date = jira.Jira.parseDate(patch['created'])
-                    print '{0:<2}: {1:<60} {2}'.format(i, patch['filename'][:60], datetime.strftime(date, '%Y-%m-%d %H:%M'))
-                    i += 1
+        try:
+            if not prompt:
+                fetcher.setFromTracker(mdl, branch)
+        except (fetch.FetchTrackerRepoException, fetch.FetchTrackerBranchException) as e:
+            prompt = True
 
-                ids = question('What patches would you like to apply?')
-                if ids:
-                    ids = re.split(r'\s*[, ]\s*', ids)
-                    for i in ids:
-                        i = int(i)
-                        if not i in mapping.keys():
-                            continue
-                        j = 0
-                        while True:
-                            mapping[i]['mdk-filename'] = mapping[i]['filename'] + (('.' + str(j)) if j > 0 else '')
-                            j += 1
-                            if not os.path.isfile(mapping[i]['mdk-filename']):
-                                break
-                        patchesToApply.append(mapping[i])
-                    mode = 'patch'
-            else:
-                mode = False
-
-        if not mode:
-            raise Exception('Did not find enough information to pull a patch.')
+        if prompt:
+            patches = self.pickPatches(mdl)
+            if not patches:
+                raise Exception('Could not find any relevant information for a successful pull')
+            fetcher.usePatches(patches)
 
-        # Stash
-        stash = None
-        if mode != 'fetchonly':
-            stash = M.git().stash(untracked=True)
-            if stash[0] != 0:
-                raise Exception('Error while trying to stash your changes. Exiting...')
-            elif not stash[1].startswith('No local changes'):
-                logging.info('Stashed your local changes')
-
-        # Create a testing branch
-        if args.testing:
+        if mode == 'pull':
+            fetcher.pull()
+        elif mode == 'checkout':
+            fetcher.checkout()
+        elif mode == 'fetch':
+            fetcher.fetch()
+        elif mode == 'integration':
+            fetcher.pull(into=M.get('stablebranch'))
+        elif mode == 'testing':
             i = 0
             while True:
                 i += 1
@@ -171,100 +157,40 @@ class PullCommand(Command):
                 newBranch = M.generateBranchName(issue, suffix=suffix, version=branch)
                 if not M.git().hasBranch(newBranch):
                     break
-            track = '%s/%s' % (self.C.get('upstreamRemote'), M.get('stablebranch'))
-            if not M.git().createBranch(newBranch, track=track):
-                raise Exception('Could not create branch %s tracking %s' % (newBranch, track))
-            if not M.git().checkout(newBranch):
-                raise Exception('Could not checkout branch %s' % (newBranch))
-            logging.info('Checked out branch %s' % (newBranch))
+            fetcher.pull(into=newBranch)
 
-        # Checkout the stable branch
-        elif args.integration:
-            if not M.git().checkout(M.get('stablebranch')):
-                logging.error('Could not checkout branch %s' % (M.get('stablebranch')))
-            logging.info('Checked out branch %s' % (M.get('stablebranch')))
+    def pickPatches(self, mdl):
+        """Prompts the user to pick a patch"""
 
-        # Create a no-merge branch
-        elif args.nomerge:
-            i = 0
-            while True:
-                i += 1
-                suffix = 'nomerge' if i <= 1 else 'nomerge' + str(i)
-                newBranch = M.generateBranchName(issue, suffix=suffix, version=branch)
-                if not M.git().hasBranch(newBranch):
-                    break
-            track = '%s/%s' % (self.C.get('upstreamRemote'), M.get('stablebranch'))
-            if not M.git().createBranch(newBranch, track=track):
-                raise Exception('Could not create branch %s tracking %s' % (newBranch, track))
-            if not M.git().checkout(newBranch):
-                raise Exception('Could not checkout branch %s' % (newBranch))
-            logging.info('Checked out branch %s' % (newBranch))
-            mode = 'nomerge'
-
-        # Let's pretend everything was fine at the start.
-        result = True
-        unstash = True
-
-        if mode == 'pull':
-            # Pull branch from tracker
-            logging.info('Pulling branch %s from %s into %s' % (remoteBranch, remoteUrl, M.currentBranch()))
-            result = M.git().pull(remote=remoteUrl, ref=remoteBranch)
-            if result[0] != 0:
-                logging.warning('Merge failed, please solve the conflicts and commit')
-                result = False
-                unstash = False
-
-        elif mode == 'fetchonly':
-            # Only fetches the branch from the remote
-            logging.info('Fetching branch %s from %s' % (remoteBranch, remoteUrl))
-            fetch = M.git().fetch(remote=remoteUrl, ref=remoteBranch)
-            if fetch[0] != 0:
-                logging.warning('Failed to fetch the remote branch')
-            else:
-                logging.info('Fetch successful, you can now use FETCH_HEAD')
-
-        elif mode == 'patch':
-            # Apply a patch from tracker
-            files = []
-            for patch in patchesToApply:
-                dest = patch['mdk-filename']
-                logging.info('Downloading %s' % (patch['filename']))
-                if not J.download(patch['content'], dest):
-                    logging.error('Failed to download. Aborting...')
-                    result = False
-                    files = []
-                    break
-                files.append(dest)
-
-            if len(files) > 0:
-                logging.info('Applying patch(es)...')
-                if not M.git().apply(files):
-                    logging.warning('Could not apply the patch(es), please apply manually')
-                    result = False
-                else:
-                    for f in files:
-                        os.remove(f)
-
-        elif mode == 'nomerge':
-            # Checking out the patch without merging it.
-            logging.info('Fetching %s %s' % (remoteUrl, remoteBranch))
-            M.git().fetch(remote=remoteUrl, ref=remoteBranch)
-            logging.info('Hard reset to FETCH_HEAD')
-            M.git().reset('FETCH_HEAD', hard=True)
-
-        # Stash pop
-        if unstash and stash and not stash[1].startswith('No local changes'):
-            pop = M.git().stash(command='pop')
-            if pop[0] != 0:
-                logging.error('An error ocured while unstashing your changes')
-            else:
-                logging.info('Popped the stash')
-        elif not unstash and stash:
-            logging.warning('Note that some files have been left in your stash')
-
-        if result:
-            logging.info('Done.')
+        J = jira.Jira()
+        patches = J.getAttachments(mdl)
+        patches = {k: v for k, v in patches.items() if v.get('filename').endswith('.patch')}
+        toApply = []
+
+        if len(patches) < 1:
+            return False
+
+        mapping = {}
+        i = 1
+        for key in sorted(patches.keys()):
+            patch = patches[key]
+            mapping[i] = patch
+            print '{0:<2}: {1:<60} {2}'.format(i, key[:60], datetime.strftime(patch.get('date'), '%Y-%m-%d %H:%M'))
+            i += 1
+
+        while True:
+            try:
+                ids = question('What patches would you like to apply?')
+                if ids.lower() == 'ankit':
+                    logging.warning('Sorry, I am unable to punch a child at the moment...')
+                    continue
+                elif ids:
+                    ids = re.split(r'\s*[, ]\s*', ids)
+                    toApply = [mapping[int(i)] for i in ids if int(i) in mapping.keys()]
+            except ValueError:
+                logging.warning('Error while parsing the list of patches, try a little harder.')
+                continue
+            break
+        
+        return toApply
 
-        # TODO Tidy up the messy logic above!
-        # TODO Really, this needs some good tidy up!
-        # TODO I'm being serious... this needs to crazy clean up!!
diff --git a/lib/fetch.py b/lib/fetch.py
new file mode 100644 (file)
index 0000000..b253ec0
--- /dev/null
@@ -0,0 +1,257 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Moodle Development Kit
+
+Copyright (c) 2014 Frédéric Massart - FMCorz.net
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+http://github.com/FMCorz/mdk
+"""
+
+import os
+import jira
+import logging
+
+
+class Fetch(object):
+    """Holds the logic and processing to fetch a remote and following actions into an instance"""
+
+    _M = None
+    _ref = None
+    _repo = None
+
+    _canCreateBranch = True
+    _hasstashed = False
+
+    def __init__(self, M, repo=None, ref=None):
+        self._M = M
+        self._repo = repo
+        self._ref = ref
+
+    def checkout(self):
+        """Fetch and checkout the fetched branch"""
+        self.fetch()
+        logging.info('Checking out branch as FETCH_HEAD')
+        if not self.M.git().checkout('FETCH_HEAD'):
+            raise FetchException('Could not checkout FETCH_HEAD')
+
+    def fetch(self):
+        """Perform the fetch"""
+        if not self.repo:
+            raise FetchException('The repository to fetch from is unknown')
+        elif not self.ref:
+            raise FetchException('The ref to fetch is unknown')
+
+        git = self.M.git()
+        logging.info('Fetching %s from %s' % (self.ref, self.repo))
+        result = git.fetch(remote=self.repo, ref=self.ref)
+        if result[0] != 0:
+            raise FetchException('Error while fetching %s from %s' % (self.ref, self.repo))
+
+    def _merge(self):
+        """Protected method to merge FETCH_HEAD into the current branch"""
+        logging.info('Merging into current branch')
+        if not self.M.git().merge('FETCH_HEAD'):
+            raise FetchException('Merge failed, resolve the conflicts and commit')
+
+    def pull(self, into=None):
+        """Fetch and merge the fetched branch into a branch passed as param"""
+        self._stash()
+
+        try:
+            self.fetch()
+            git = self.M.git()
+
+            if into:
+                logging.info('Switching to branch %s' % (into))
+
+                if not git.hasBranch(into):
+                    if self.canCreateBranch:
+                        if not git.createBranch(into):
+                            raise FetchException('Could not create the branch %s' % (into))
+                    else:
+                        raise FetchException('Branch %s does not exist and create branch is forbidden' % (into))
+
+                if not git.checkout(into):
+                    raise FetchException('Could not checkout branch %s' % (into))
+
+            self._merge()
+
+        except FetchException as e:
+            if self._hasstashed:
+                logging.warning('An error occured. Some files may have been left in your stash.')
+            raise e
+
+        self._unstash()
+
+    def setRef(self, ref):
+        """Set the reference to fetch"""
+        self._ref = ref
+
+    def setRepo(self, repo):
+        """Set the repository to fetch from"""
+        self._repo = repo
+
+    def _stash(self):
+        """Protected method to stash"""
+        stash = self.M.git().stash(untracked=True)
+        if stash[0] != 0:
+            raise FetchException('Error while trying to stash your changes')
+        elif not stash[1].startswith('No local changes'):
+            logging.info('Stashed your local changes')
+            self._hasstashed = True
+
+    def _unstash(self):
+        """Protected method to unstash"""
+        if self._hasstashed:
+            pop = self.M.git().stash(command='pop')
+            if pop[0] != 0:
+                logging.error('An error ocured while unstashing your changes')
+            else:
+                logging.info('Popped the stash')
+        self._hasstashed = False
+
+    @property
+    def canCreateBranch(self):
+        return self._canCreateBranch
+
+    @property
+    def into(self):
+        return self._into
+
+    @property
+    def M(self):
+        return self._M
+
+    @property
+    def ref(self):
+        return self._ref
+
+    @property
+    def repo(self):
+        return self._repo
+
+
+class FetchTracker(Fetch):
+    """Pretty dodgy implementation of Fetch to work with the tracker.
+
+        If a list of patches is set, we override the git methods to fetch from a remote
+        to use the patches instead. I am not super convinced by this design, but at
+        least the logic to fetch/pull/merge is more or less self contained.
+    """
+
+    _J = None
+    _cache = None
+    _patches = None
+
+    def __init__(self, *args, **kwargs):
+        super(FetchTracker, self).__init__(*args, **kwargs)
+        self._J = jira.Jira()
+        self._cache = {}
+
+    def checkout(self):
+        if not self.patches:
+            return super(FetchTracker, self).checkout()
+
+        self.fetch()
+
+    def fetch(self):
+        if not self.patches:
+            return super(FetchTracker, self).fetch()
+
+        for patch in self.patches:
+            j = 0
+            dest = None
+            while True:
+                downloadedTo = patch.get('filename') + (('.' + str(j)) if j > 0 else '')
+                dest = os.path.join(self.M.get('path'), downloadedTo)
+                j += 1
+                if not os.path.isfile(dest):
+                    patch['downloadedTo'] = downloadedTo
+                    break
+            
+            logging.info('Downloading patch as %s' % (patch.get('downloadedTo')))
+            if not dest or not self.J.download(patch.get('url'), dest):
+                raise FetchTrackerException('Failed to download the patch to %s' % (dest))
+
+
+    def _merge(self):
+        if not self.patches:
+            return super(FetchTracker, self)._merge()
+
+        patchList = [patch.get('downloadedTo') for patch in self.patches]
+        git = self.M.git()
+        if not git.apply(patchList):
+            raise FetchTrackerException('Could not apply the patch(es), please apply manually')
+        else:
+            for f in patchList:
+                os.remove(f)
+        logging.info('Patches applied successfully')
+
+    def getPullInfo(self, mdl):
+        """Return the pull information
+
+            This implements its own local cache because we could potentially
+            call it multiple times during the same request. This is bad though.
+        """
+        if not self._cache.has_key(mdl):
+            issueInfo = self.J.getPullInfo(mdl)
+            self._cache[mdl] = issueInfo
+        return self._cache[mdl]
+
+    def setFromTracker(self, mdl, branch):
+        """Sets the repo and ref according to the tracker information"""
+        issueInfo = self.getPullInfo(mdl)
+
+        repo = issueInfo.get('repo', None)
+        if not repo:
+            raise FetchTrackerRepoException('Missing information about the repository to pull from on %s' % (mdl))
+
+        ref = issueInfo.get('branches').get(str(branch), None)
+        if not ref:
+            raise FetchTrackerBranchException('Could not find branch info on %s' % (str(branch), mdl))
+
+        self.setRepo(repo)
+        self.setRef(ref.get('branch'))
+
+    def usePatches(self, patches):
+        """List of patches (returned by jira.Jira.getAttachments) to work with instead of the standard repo and ref"""
+        self._patches = patches
+
+    @property
+    def J(self):
+        return self._J
+
+    @property
+    def patches(self):
+        return self._patches
+
+
+class FetchException(Exception):
+    pass
+
+
+class FetchTrackerException(FetchException):
+    pass
+
+
+class FetchTrackerBranchException(FetchTrackerException):
+    pass
+
+
+class FetchTrackerRepoException(FetchTrackerException):
+    pass
\ No newline at end of file
index f6add12..da7ee3b 100644 (file)
@@ -211,6 +211,12 @@ class Git(object):
 
         return stdout
 
+    def merge(self, ref):
+        """Wrapper for the merge command"""
+        cmd = 'merge %s' % (ref)
+        (returncode, stdout, stderr) = self.execute(cmd)
+        return returncode == 0
+
     def messages(self, count=10, since=None, path=None):
         """Return the latest titles of the commit messages"""
         messages = self.log(count=count, since=since, path=path, format='%s')