Created
October 26, 2012 18:02
-
-
Save ojacobson/3960339 to your computer and use it in GitHub Desktop.
git-issue -- a tool for correlating Redmine tickets to branches
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
import sys | |
import subprocess as s | |
import optparse as o | |
import urllib2 as u | |
import json as j | |
import re | |
import os | |
import errno | |
REDMINE = 'https://redmine.example.com%s' | |
REMOTE = 'origin' | |
UPSTREAM = '%s/master' % (REMOTE,) | |
SUBJECT_MANGLES = [ | |
(re.compile(r"'"), ''), | |
(re.compile(r'[^a-zA-Z0-9]+'), '-'), | |
] | |
PREPARE_COMMITMSG_HOOK=[ | |
'#!/bin/bash -e\n', | |
'### git-issue hook\n', | |
'{self} --prepare-commit-msg "$@"\n', | |
'{chain}\n', | |
] | |
def parse_args(): | |
p = o.OptionParser( | |
usage="%prog [options] NUMBER [DESCRIPTION]", | |
description="streamline management of local issue branches" | |
) | |
p.add_option( | |
'--install', | |
action='store_true', | |
default=False, | |
help='Install hooks.', | |
) | |
p.add_option( | |
'--prepare-commit-msg', | |
action='store_true', | |
default=False, | |
help='Act as a prepare-commit-msg hook and insert "See #1234: " at the start of the commit message.', | |
) | |
options, args = p.parse_args() | |
if options.install: | |
return options, None, None | |
elif len(args) < 1: | |
p.print_help() | |
p.exit(1) | |
elif len(args) == 1: | |
return options, args[0], None | |
return options, args[0], ' '.join(args[1:]) | |
def git(*args): | |
return s.check_output(('git',) + args) | |
def current_branch(): | |
try: | |
return git('symbolic-ref', 'HEAD') | |
except s.CalledProcessError, e: | |
# "Not on a branch" (and also possibly "not in a git repo" :-\ ) | |
if e.returncode == 128: | |
return None | |
def fetch_issue(issue): | |
issue_path = '/issues/%s.json' % (issue,) | |
response = u.urlopen(REDMINE % issue_path) | |
return j.load(response) | |
def name_issue_branch(issue, description): | |
summary = description | |
for pattern, replacement in SUBJECT_MANGLES: | |
summary = re.sub(pattern, replacement, summary) | |
assert ' ' not in summary | |
summary = summary.strip('-') | |
summary = summary.lower() | |
return 'issue-{issue}-{summary}'.format(summary=summary, issue=issue) | |
def is_issue_branch(branch, issue): | |
return branch_issue(branch) == issue | |
def branch_issue(branch): | |
if branch is None: | |
return None | |
if branch.startswith('refs/heads/'): | |
branch = branch[len('refs/heads/'):] | |
if branch.startswith('issue-'): | |
issue = '' | |
for char in branch[len('issue-'):]: | |
if char == '-': | |
break | |
issue += char | |
return issue | |
else: | |
return None | |
def branches(): | |
branch_list = git('branch', '--list') | |
assert branch_list[-1] == "\n" | |
lines = branch_list[:-1].split("\n") | |
return [line[2:] for line in lines] # strip leading ' ' or '* ' | |
def summarize(**issue_json): | |
return "Working on [{issue[project][name]}] #{issue[id]}: {issue[subject]}".format(**issue_json) | |
def install_hooks(): | |
git_dir = git('rev-parse', '--git-dir')[:-1] | |
hooks = os.path.join(git_dir, 'hooks') | |
prepare_commit_msg = os.path.join(hooks, 'prepare-commit-msg') | |
try: | |
with open(prepare_commit_msg, 'r') as input: | |
lines = input.readlines() | |
if lines[:2] == PREPARE_COMMITMSG_HOOK[:2]: | |
return # hook already installed | |
os.rename(prepare_commit_msg, prepare_commit_msg + ".git-issue-chain") | |
chain = prepare_commit_msg + '.git-issue-chain "$@"' | |
except IOError, e: | |
# Not found is fine, we're about to create it. | |
if e.errno != errno.ENOENT: | |
raise | |
else: | |
chain = "" | |
with open(prepare_commit_msg, 'w') as output: | |
for line in PREPARE_COMMITMSG_HOOK: | |
self = os.path.abspath(sys.argv[0]) | |
output.write(line.format(self=self, chain=chain)) | |
os.chmod(prepare_commit_msg, 0755) | |
def become_issue_branch(issue, description): | |
if git('status', '--porcelain') != '': | |
print >>sys.stderr, "Uncommitted changes found in working tree" | |
return 2 | |
branch = current_branch() | |
if is_issue_branch(branch, issue): | |
# Already on the correct issue branch. Do nothing; don't even validate | |
# that the issue exists in Redmine. | |
return 0 | |
# Retrieve issue metadata from Redmine | |
issue_json = fetch_issue(issue) | |
for branch in branches(): | |
if is_issue_branch(branch, issue): | |
git('checkout', branch) | |
print summarize(**issue_json) | |
return 0 | |
if description is None: | |
name = name_issue_branch(issue_json['issue']['id'], issue_json['issue']['subject']) | |
else: | |
name = name_issue_branch(issue, description) | |
git('fetch', REMOTE) | |
git('checkout', UPSTREAM, '-b', name) | |
print summarize(**issue_json) | |
return 0 | |
def prepare_commit_msg_hook(filename): | |
branch = current_branch() | |
issue = branch_issue(branch) | |
if issue is None: | |
return 0 | |
temp = filename + '.git-issue-tmp' | |
with open(filename, 'r') as input: | |
with open(temp, 'w') as output: | |
print >>output, "See #%s: " % (issue,) | |
for line in input: | |
output.write(line) | |
os.rename(temp, filename) | |
return 0 | |
def main(): | |
options, issue, description = parse_args() | |
if options.install: | |
return install_hooks() | |
if options.prepare_commit_msg: | |
return prepare_commit_msg_hook(issue) | |
return become_issue_branch(issue, description) | |
if __name__ == '__main__': | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment