You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

github-merge.py 11KB

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