Merge #10069: [QA] Fix typo in fundrawtransaction test
[bitcoinplatinum.git] / contrib / devtools / github-merge.py
blob3fee39143dad7541b8319ef304426df0107b2e47
1 #!/usr/bin/env python3
2 # Copyright (c) 2016 The 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.
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.
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)
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
19 from sys import stdin,stdout,stderr
20 import argparse
21 import hashlib
22 import subprocess
23 import json,codecs
24 try:
25 from urllib.request import Request,urlopen
26 except:
27 from urllib2 import Request,urlopen
29 # External tools (can be overridden using environment)
30 GIT = os.getenv('GIT','git')
31 BASH = os.getenv('BASH','bash')
33 # OS specific configuration for terminal attributes
34 ATTR_RESET = ''
35 ATTR_PR = ''
36 COMMIT_FORMAT = '%h %s (%an)%d'
37 if os.name == 'posix': # if posix, assume we can use basic terminal escapes
38 ATTR_RESET = '\033[0m'
39 ATTR_PR = '\033[1;36m'
40 COMMIT_FORMAT = '%C(bold blue)%h%Creset %s %C(cyan)(%an)%Creset%C(green)%d%Creset'
42 def git_config_get(option, default=None):
43 '''
44 Get named configuration option from git repository.
45 '''
46 try:
47 return subprocess.check_output([GIT,'config','--get',option]).rstrip().decode('utf-8')
48 except subprocess.CalledProcessError as e:
49 return default
51 def retrieve_pr_info(repo,pull):
52 '''
53 Retrieve pull request information from github.
54 Return None if no title can be found, or an error happens.
55 '''
56 try:
57 req = Request("https://api.github.com/repos/"+repo+"/pulls/"+pull)
58 result = urlopen(req)
59 reader = codecs.getreader('utf-8')
60 obj = json.load(reader(result))
61 return obj
62 except Exception as e:
63 print('Warning: unable to retrieve pull information from github: %s' % e)
64 return None
66 def ask_prompt(text):
67 print(text,end=" ",file=stderr)
68 stderr.flush()
69 reply = stdin.readline().rstrip()
70 print("",file=stderr)
71 return reply
73 def get_symlink_files():
74 files = sorted(subprocess.check_output([GIT, 'ls-tree', '--full-tree', '-r', 'HEAD']).splitlines())
75 ret = []
76 for f in files:
77 if (int(f.decode('utf-8').split(" ")[0], 8) & 0o170000) == 0o120000:
78 ret.append(f.decode('utf-8').split("\t")[1])
79 return ret
81 def tree_sha512sum(commit='HEAD'):
82 # request metadata for entire tree, recursively
83 files = []
84 blob_by_name = {}
85 for line in subprocess.check_output([GIT, 'ls-tree', '--full-tree', '-r', commit]).splitlines():
86 name_sep = line.index(b'\t')
87 metadata = line[:name_sep].split() # perms, 'blob', blobid
88 assert(metadata[1] == b'blob')
89 name = line[name_sep+1:]
90 files.append(name)
91 blob_by_name[name] = metadata[2]
93 files.sort()
94 # open connection to git-cat-file in batch mode to request data for all blobs
95 # this is much faster than launching it per file
96 p = subprocess.Popen([GIT, 'cat-file', '--batch'], stdout=subprocess.PIPE, stdin=subprocess.PIPE)
97 overall = hashlib.sha512()
98 for f in files:
99 blob = blob_by_name[f]
100 # request blob
101 p.stdin.write(blob + b'\n')
102 p.stdin.flush()
103 # read header: blob, "blob", size
104 reply = p.stdout.readline().split()
105 assert(reply[0] == blob and reply[1] == b'blob')
106 size = int(reply[2])
107 # hash the blob data
108 intern = hashlib.sha512()
109 ptr = 0
110 while ptr < size:
111 bs = min(65536, size - ptr)
112 piece = p.stdout.read(bs)
113 if len(piece) == bs:
114 intern.update(piece)
115 else:
116 raise IOError('Premature EOF reading git cat-file output')
117 ptr += bs
118 dig = intern.hexdigest()
119 assert(p.stdout.read(1) == b'\n') # ignore LF that follows blob data
120 # update overall hash with file hash
121 overall.update(dig.encode("utf-8"))
122 overall.update(" ".encode("utf-8"))
123 overall.update(f)
124 overall.update("\n".encode("utf-8"))
125 p.stdin.close()
126 if p.wait():
127 raise IOError('Non-zero return value executing git cat-file')
128 return overall.hexdigest()
131 def parse_arguments():
132 epilog = '''
133 In addition, you can set the following git configuration variables:
134 githubmerge.repository (mandatory),
135 user.signingkey (mandatory),
136 githubmerge.host (default: git@github.com),
137 githubmerge.branch (no default),
138 githubmerge.testcmd (default: none).
140 parser = argparse.ArgumentParser(description='Utility to merge, sign and push github pull requests',
141 epilog=epilog)
142 parser.add_argument('pull', metavar='PULL', type=int, nargs=1,
143 help='Pull request ID to merge')
144 parser.add_argument('branch', metavar='BRANCH', type=str, nargs='?',
145 default=None, help='Branch to merge against (default: githubmerge.branch setting, or base branch for pull, or \'master\')')
146 return parser.parse_args()
148 def main():
149 # Extract settings from git repo
150 repo = git_config_get('githubmerge.repository')
151 host = git_config_get('githubmerge.host','git@github.com')
152 opt_branch = git_config_get('githubmerge.branch',None)
153 testcmd = git_config_get('githubmerge.testcmd')
154 signingkey = git_config_get('user.signingkey')
155 if repo is None:
156 print("ERROR: No repository configured. Use this command to set:", file=stderr)
157 print("git config githubmerge.repository <owner>/<repo>", file=stderr)
158 exit(1)
159 if signingkey is None:
160 print("ERROR: No GPG signing key set. Set one using:",file=stderr)
161 print("git config --global user.signingkey <key>",file=stderr)
162 exit(1)
164 host_repo = host+":"+repo # shortcut for push/pull target
166 # Extract settings from command line
167 args = parse_arguments()
168 pull = str(args.pull[0])
170 # Receive pull information from github
171 info = retrieve_pr_info(repo,pull)
172 if info is None:
173 exit(1)
174 title = info['title']
175 # precedence order for destination branch argument:
176 # - command line argument
177 # - githubmerge.branch setting
178 # - base branch for pull (as retrieved from github)
179 # - 'master'
180 branch = args.branch or opt_branch or info['base']['ref'] or 'master'
182 # Initialize source branches
183 head_branch = 'pull/'+pull+'/head'
184 base_branch = 'pull/'+pull+'/base'
185 merge_branch = 'pull/'+pull+'/merge'
186 local_merge_branch = 'pull/'+pull+'/local-merge'
188 devnull = open(os.devnull,'w')
189 try:
190 subprocess.check_call([GIT,'checkout','-q',branch])
191 except subprocess.CalledProcessError as e:
192 print("ERROR: Cannot check out branch %s." % (branch), file=stderr)
193 exit(3)
194 try:
195 subprocess.check_call([GIT,'fetch','-q',host_repo,'+refs/pull/'+pull+'/*:refs/heads/pull/'+pull+'/*'])
196 except subprocess.CalledProcessError as e:
197 print("ERROR: Cannot find pull request #%s on %s." % (pull,host_repo), file=stderr)
198 exit(3)
199 try:
200 subprocess.check_call([GIT,'log','-q','-1','refs/heads/'+head_branch], stdout=devnull, stderr=stdout)
201 except subprocess.CalledProcessError as e:
202 print("ERROR: Cannot find head of pull request #%s on %s." % (pull,host_repo), file=stderr)
203 exit(3)
204 try:
205 subprocess.check_call([GIT,'log','-q','-1','refs/heads/'+merge_branch], stdout=devnull, stderr=stdout)
206 except subprocess.CalledProcessError as e:
207 print("ERROR: Cannot find merge of pull request #%s on %s." % (pull,host_repo), file=stderr)
208 exit(3)
209 try:
210 subprocess.check_call([GIT,'fetch','-q',host_repo,'+refs/heads/'+branch+':refs/heads/'+base_branch])
211 except subprocess.CalledProcessError as e:
212 print("ERROR: Cannot find branch %s on %s." % (branch,host_repo), file=stderr)
213 exit(3)
214 subprocess.check_call([GIT,'checkout','-q',base_branch])
215 subprocess.call([GIT,'branch','-q','-D',local_merge_branch], stderr=devnull)
216 subprocess.check_call([GIT,'checkout','-q','-b',local_merge_branch])
218 try:
219 # Go up to the repository's root.
220 toplevel = subprocess.check_output([GIT,'rev-parse','--show-toplevel']).strip()
221 os.chdir(toplevel)
222 # Create unsigned merge commit.
223 if title:
224 firstline = 'Merge #%s: %s' % (pull,title)
225 else:
226 firstline = 'Merge #%s' % (pull,)
227 message = firstline + '\n\n'
228 message += subprocess.check_output([GIT,'log','--no-merges','--topo-order','--pretty=format:%h %s (%an)',base_branch+'..'+head_branch]).decode('utf-8')
229 try:
230 subprocess.check_call([GIT,'merge','-q','--commit','--no-edit','--no-ff','-m',message.encode('utf-8'),head_branch])
231 except subprocess.CalledProcessError as e:
232 print("ERROR: Cannot be merged cleanly.",file=stderr)
233 subprocess.check_call([GIT,'merge','--abort'])
234 exit(4)
235 logmsg = subprocess.check_output([GIT,'log','--pretty=format:%s','-n','1']).decode('utf-8')
236 if logmsg.rstrip() != firstline.rstrip():
237 print("ERROR: Creating merge failed (already merged?).",file=stderr)
238 exit(4)
240 symlink_files = get_symlink_files()
241 for f in symlink_files:
242 print("ERROR: File %s was a symlink" % f)
243 if len(symlink_files) > 0:
244 exit(4)
246 # Put tree SHA512 into the message
247 try:
248 first_sha512 = tree_sha512sum()
249 message += '\n\nTree-SHA512: ' + first_sha512
250 except subprocess.CalledProcessError as e:
251 printf("ERROR: Unable to compute tree hash")
252 exit(4)
253 try:
254 subprocess.check_call([GIT,'commit','--amend','-m',message.encode('utf-8')])
255 except subprocess.CalledProcessError as e:
256 printf("ERROR: Cannot update message.",file=stderr)
257 exit(4)
259 print('%s#%s%s %s %sinto %s%s' % (ATTR_RESET+ATTR_PR,pull,ATTR_RESET,title,ATTR_RESET+ATTR_PR,branch,ATTR_RESET))
260 subprocess.check_call([GIT,'log','--graph','--topo-order','--pretty=format:'+COMMIT_FORMAT,base_branch+'..'+head_branch])
261 print()
263 # Run test command if configured.
264 if testcmd:
265 if subprocess.call(testcmd,shell=True):
266 print("ERROR: Running %s failed." % testcmd,file=stderr)
267 exit(5)
269 # Show the created merge.
270 diff = subprocess.check_output([GIT,'diff',merge_branch+'..'+local_merge_branch])
271 subprocess.check_call([GIT,'diff',base_branch+'..'+local_merge_branch])
272 if diff:
273 print("WARNING: merge differs from github!",file=stderr)
274 reply = ask_prompt("Type 'ignore' to continue.")
275 if reply.lower() == 'ignore':
276 print("Difference with github ignored.",file=stderr)
277 else:
278 exit(6)
279 reply = ask_prompt("Press 'd' to accept the diff.")
280 if reply.lower() == 'd':
281 print("Diff accepted.",file=stderr)
282 else:
283 print("ERROR: Diff rejected.",file=stderr)
284 exit(6)
285 else:
286 # Verify the result manually.
287 print("Dropping you on a shell so you can try building/testing the merged source.",file=stderr)
288 print("Run 'git diff HEAD~' to show the changes being merged.",file=stderr)
289 print("Type 'exit' when done.",file=stderr)
290 if os.path.isfile('/etc/debian_version'): # Show pull number on Debian default prompt
291 os.putenv('debian_chroot',pull)
292 subprocess.call([BASH,'-i'])
293 reply = ask_prompt("Type 'm' to accept the merge.")
294 if reply.lower() == 'm':
295 print("Merge accepted.",file=stderr)
296 else:
297 print("ERROR: Merge rejected.",file=stderr)
298 exit(7)
300 second_sha512 = tree_sha512sum()
301 if first_sha512 != second_sha512:
302 print("ERROR: Tree hash changed unexpectedly",file=stderr)
303 exit(8)
305 # Sign the merge commit.
306 reply = ask_prompt("Type 's' to sign off on the merge.")
307 if reply == 's':
308 try:
309 subprocess.check_call([GIT,'commit','-q','--gpg-sign','--amend','--no-edit'])
310 except subprocess.CalledProcessError as e:
311 print("Error signing, exiting.",file=stderr)
312 exit(1)
313 else:
314 print("Not signing off on merge, exiting.",file=stderr)
315 exit(1)
317 # Put the result in branch.
318 subprocess.check_call([GIT,'checkout','-q',branch])
319 subprocess.check_call([GIT,'reset','-q','--hard',local_merge_branch])
320 finally:
321 # Clean up temporary branches.
322 subprocess.call([GIT,'checkout','-q',branch])
323 subprocess.call([GIT,'branch','-q','-D',head_branch],stderr=devnull)
324 subprocess.call([GIT,'branch','-q','-D',base_branch],stderr=devnull)
325 subprocess.call([GIT,'branch','-q','-D',merge_branch],stderr=devnull)
326 subprocess.call([GIT,'branch','-q','-D',local_merge_branch],stderr=devnull)
328 # Push the result.
329 reply = ask_prompt("Type 'push' to push the result to %s, branch %s." % (host_repo,branch))
330 if reply.lower() == 'push':
331 subprocess.check_call([GIT,'push',host_repo,'refs/heads/'+branch])
333 if __name__ == '__main__':
334 main()