Skip to content

Commit cc174d0

Browse files
committed
Merge #117: Add github-merge.py
784d878 Adjust readme for gitian-builder (MarcoFalke) ee6dee4 devtools: Fix utf-8 support in messages for github-merge (Wladimir J. van der Laan) 7d99dee [devtools] github-merge get toplevel dir without extra whitespace (Andrew C) 0ae5a18 devtools: show pull and commit information in github-merge (Wladimir J. van der Laan) 992efcf devtools: replace github-merge with python version (Wladimir J. van der Laan)
2 parents bb4f92f + 784d878 commit cc174d0

File tree

2 files changed

+272
-0
lines changed

2 files changed

+272
-0
lines changed

contrib/devtools/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Contents
2+
========
3+
This directory contains tools for developers working on this repository.
4+
5+
github-merge.py
6+
===============
7+
8+
A small script to automate merging pull-requests securely and sign them with GPG.
9+
10+
For example:
11+
12+
./github-merge.py 3077
13+
14+
(in any git repository) will help you merge pull request #3077 for the
15+
devrandom/gitian-builder repository.
16+
17+
What it does:
18+
* Fetch master and the pull request.
19+
* Locally construct a merge commit.
20+
* Show the diff that merge results in.
21+
* Ask you to verify the resulting source tree (so you can do a make
22+
check or whatever).
23+
* Ask you whether to GPG sign the merge commit.
24+
* Ask you whether to push the result upstream.
25+
26+
This means that there are no potential race conditions (where a
27+
pullreq gets updated while you're reviewing it, but before you click
28+
merge), and when using GPG signatures, that even a compromised github
29+
couldn't mess with the sources.
30+
31+
Setup
32+
---------
33+
Configuring the github-merge tool for this repository is done in the following way:
34+
35+
git config githubmerge.repository devrandom/gitian-builder
36+
git config githubmerge.testcmd "make -j4 check" (adapt to whatever you want to use for testing)
37+
git config --global user.signingkey mykeyid (if you want to GPG sign)

contrib/devtools/github-merge.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#!/usr/bin/env python2
2+
# Copyright (c) 2016 Bitcoin Core Developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
6+
# This script will locally construct a merge commit for a pull request on a
7+
# github repository, inspect it, sign it and optionally push it.
8+
9+
# The following temporary branches are created/overwritten and deleted:
10+
# * pull/$PULL/base (the current master we're merging onto)
11+
# * pull/$PULL/head (the current state of the remote pull request)
12+
# * pull/$PULL/merge (github's merge)
13+
# * pull/$PULL/local-merge (our merge)
14+
15+
# In case of a clean merge that is accepted by the user, the local branch with
16+
# name $BRANCH is overwritten with the merged result, and optionally pushed.
17+
from __future__ import division,print_function,unicode_literals
18+
import os,sys
19+
from sys import stdin,stdout,stderr
20+
import argparse
21+
import subprocess
22+
23+
# External tools (can be overridden using environment)
24+
GIT = os.getenv('GIT','git')
25+
BASH = os.getenv('BASH','bash')
26+
27+
# OS specific configuration for terminal attributes
28+
ATTR_RESET = ''
29+
ATTR_PR = ''
30+
COMMIT_FORMAT = '%h %s (%an)%d'
31+
if os.name == 'posix': # if posix, assume we can use basic terminal escapes
32+
ATTR_RESET = '\033[0m'
33+
ATTR_PR = '\033[1;36m'
34+
COMMIT_FORMAT = '%C(bold blue)%h%Creset %s %C(cyan)(%an)%Creset%C(green)%d%Creset'
35+
36+
def git_config_get(option, default=None):
37+
'''
38+
Get named configuration option from git repository.
39+
'''
40+
try:
41+
return subprocess.check_output([GIT,'config','--get',option]).rstrip()
42+
except subprocess.CalledProcessError as e:
43+
return default
44+
45+
def retrieve_pr_title(repo,pull):
46+
'''
47+
Retrieve pull request title from github.
48+
Return None if no title can be found, or an error happens.
49+
'''
50+
import urllib2,json
51+
try:
52+
req = urllib2.Request("https://api.github.com/repos/"+repo+"/pulls/"+pull)
53+
result = urllib2.urlopen(req)
54+
result = json.load(result)
55+
return result['title']
56+
except Exception as e:
57+
print('Warning: unable to retrieve pull title from github: %s' % e)
58+
return None
59+
60+
def ask_prompt(text):
61+
print(text,end=" ",file=stderr)
62+
reply = stdin.readline().rstrip()
63+
print("",file=stderr)
64+
return reply
65+
66+
def parse_arguments(branch):
67+
epilog = '''
68+
In addition, you can set the following git configuration variables:
69+
githubmerge.repository (mandatory),
70+
user.signingkey (mandatory),
71+
githubmerge.host (default: [email protected]),
72+
githubmerge.branch (default: master),
73+
githubmerge.testcmd (default: none).
74+
'''
75+
parser = argparse.ArgumentParser(description='Utility to merge, sign and push github pull requests',
76+
epilog=epilog)
77+
parser.add_argument('pull', metavar='PULL', type=int, nargs=1,
78+
help='Pull request ID to merge')
79+
parser.add_argument('branch', metavar='BRANCH', type=str, nargs='?',
80+
default=branch, help='Branch to merge against (default: '+branch+')')
81+
return parser.parse_args()
82+
83+
def main():
84+
# Extract settings from git repo
85+
repo = git_config_get('githubmerge.repository')
86+
host = git_config_get('githubmerge.host','[email protected]')
87+
branch = git_config_get('githubmerge.branch','master')
88+
testcmd = git_config_get('githubmerge.testcmd')
89+
signingkey = git_config_get('user.signingkey')
90+
if repo is None:
91+
print("ERROR: No repository configured. Use this command to set:", file=stderr)
92+
print("git config githubmerge.repository <owner>/<repo>", file=stderr)
93+
exit(1)
94+
if signingkey is None:
95+
print("ERROR: No GPG signing key set. Set one using:",file=stderr)
96+
print("git config --global user.signingkey <key>",file=stderr)
97+
exit(1)
98+
99+
host_repo = host+":"+repo # shortcut for push/pull target
100+
101+
# Extract settings from command line
102+
args = parse_arguments(branch)
103+
pull = str(args.pull[0])
104+
branch = args.branch
105+
106+
# Initialize source branches
107+
head_branch = 'pull/'+pull+'/head'
108+
base_branch = 'pull/'+pull+'/base'
109+
merge_branch = 'pull/'+pull+'/merge'
110+
local_merge_branch = 'pull/'+pull+'/local-merge'
111+
112+
devnull = open(os.devnull,'w')
113+
try:
114+
subprocess.check_call([GIT,'checkout','-q',branch])
115+
except subprocess.CalledProcessError as e:
116+
print("ERROR: Cannot check out branch %s." % (branch), file=stderr)
117+
exit(3)
118+
try:
119+
subprocess.check_call([GIT,'fetch','-q',host_repo,'+refs/pull/'+pull+'/*:refs/heads/pull/'+pull+'/*'])
120+
except subprocess.CalledProcessError as e:
121+
print("ERROR: Cannot find pull request #%s on %s." % (pull,host_repo), file=stderr)
122+
exit(3)
123+
try:
124+
subprocess.check_call([GIT,'log','-q','-1','refs/heads/'+head_branch], stdout=devnull, stderr=stdout)
125+
except subprocess.CalledProcessError as e:
126+
print("ERROR: Cannot find head of pull request #%s on %s." % (pull,host_repo), file=stderr)
127+
exit(3)
128+
try:
129+
subprocess.check_call([GIT,'log','-q','-1','refs/heads/'+merge_branch], stdout=devnull, stderr=stdout)
130+
except subprocess.CalledProcessError as e:
131+
print("ERROR: Cannot find merge of pull request #%s on %s." % (pull,host_repo), file=stderr)
132+
exit(3)
133+
try:
134+
subprocess.check_call([GIT,'fetch','-q',host_repo,'+refs/heads/'+branch+':refs/heads/'+base_branch])
135+
except subprocess.CalledProcessError as e:
136+
print("ERROR: Cannot find branch %s on %s." % (branch,host_repo), file=stderr)
137+
exit(3)
138+
subprocess.check_call([GIT,'checkout','-q',base_branch])
139+
subprocess.call([GIT,'branch','-q','-D',local_merge_branch], stderr=devnull)
140+
subprocess.check_call([GIT,'checkout','-q','-b',local_merge_branch])
141+
142+
try:
143+
# Create unsigned merge commit.
144+
title = retrieve_pr_title(repo,pull)
145+
if title:
146+
firstline = 'Merge #%s: %s' % (pull,title)
147+
else:
148+
firstline = 'Merge #%s' % (pull,)
149+
message = firstline + '\n\n'
150+
message += subprocess.check_output([GIT,'log','--no-merges','--topo-order','--pretty=format:%h %s (%an)',base_branch+'..'+head_branch]).decode('utf-8')
151+
try:
152+
subprocess.check_call([GIT,'merge','-q','--commit','--no-edit','--no-ff','-m',message.encode('utf-8'),head_branch])
153+
except subprocess.CalledProcessError as e:
154+
print("ERROR: Cannot be merged cleanly.",file=stderr)
155+
subprocess.check_call([GIT,'merge','--abort'])
156+
exit(4)
157+
logmsg = subprocess.check_output([GIT,'log','--pretty=format:%s','-n','1']).decode('utf-8')
158+
if logmsg.rstrip() != firstline.rstrip():
159+
print("ERROR: Creating merge failed (already merged?).",file=stderr)
160+
exit(4)
161+
162+
print('%s#%s%s %s' % (ATTR_RESET+ATTR_PR,pull,ATTR_RESET,title))
163+
subprocess.check_call([GIT,'log','--graph','--topo-order','--pretty=format:'+COMMIT_FORMAT,base_branch+'..'+head_branch])
164+
print()
165+
# Run test command if configured.
166+
if testcmd:
167+
# Go up to the repository's root.
168+
toplevel = subprocess.check_output([GIT,'rev-parse','--show-toplevel']).strip()
169+
os.chdir(toplevel)
170+
if subprocess.call(testcmd,shell=True):
171+
print("ERROR: Running %s failed." % testcmd,file=stderr)
172+
exit(5)
173+
174+
# Show the created merge.
175+
diff = subprocess.check_output([GIT,'diff',merge_branch+'..'+local_merge_branch])
176+
subprocess.check_call([GIT,'diff',base_branch+'..'+local_merge_branch])
177+
if diff:
178+
print("WARNING: merge differs from github!",file=stderr)
179+
reply = ask_prompt("Type 'ignore' to continue.")
180+
if reply.lower() == 'ignore':
181+
print("Difference with github ignored.",file=stderr)
182+
else:
183+
exit(6)
184+
reply = ask_prompt("Press 'd' to accept the diff.")
185+
if reply.lower() == 'd':
186+
print("Diff accepted.",file=stderr)
187+
else:
188+
print("ERROR: Diff rejected.",file=stderr)
189+
exit(6)
190+
else:
191+
# Verify the result manually.
192+
print("Dropping you on a shell so you can try building/testing the merged source.",file=stderr)
193+
print("Run 'git diff HEAD~' to show the changes being merged.",file=stderr)
194+
print("Type 'exit' when done.",file=stderr)
195+
if os.path.isfile('/etc/debian_version'): # Show pull number on Debian default prompt
196+
os.putenv('debian_chroot',pull)
197+
subprocess.call([BASH,'-i'])
198+
reply = ask_prompt("Type 'm' to accept the merge.")
199+
if reply.lower() == 'm':
200+
print("Merge accepted.",file=stderr)
201+
else:
202+
print("ERROR: Merge rejected.",file=stderr)
203+
exit(7)
204+
205+
# Sign the merge commit.
206+
reply = ask_prompt("Type 's' to sign off on the merge.")
207+
if reply == 's':
208+
try:
209+
subprocess.check_call([GIT,'commit','-q','--gpg-sign','--amend','--no-edit'])
210+
except subprocess.CalledProcessError as e:
211+
print("Error signing, exiting.",file=stderr)
212+
exit(1)
213+
else:
214+
print("Not signing off on merge, exiting.",file=stderr)
215+
exit(1)
216+
217+
# Put the result in branch.
218+
subprocess.check_call([GIT,'checkout','-q',branch])
219+
subprocess.check_call([GIT,'reset','-q','--hard',local_merge_branch])
220+
finally:
221+
# Clean up temporary branches.
222+
subprocess.call([GIT,'checkout','-q',branch])
223+
subprocess.call([GIT,'branch','-q','-D',head_branch],stderr=devnull)
224+
subprocess.call([GIT,'branch','-q','-D',base_branch],stderr=devnull)
225+
subprocess.call([GIT,'branch','-q','-D',merge_branch],stderr=devnull)
226+
subprocess.call([GIT,'branch','-q','-D',local_merge_branch],stderr=devnull)
227+
228+
# Push the result.
229+
reply = ask_prompt("Type 'push' to push the result to %s, branch %s." % (host_repo,branch))
230+
if reply.lower() == 'push':
231+
subprocess.check_call([GIT,'push',host_repo,'refs/heads/'+branch])
232+
233+
if __name__ == '__main__':
234+
main()
235+

0 commit comments

Comments
 (0)