new_proposal.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. #!/usr/bin/env python3
  2. """Prepares a new proposal file and PR."""
  3. __copyright__ = """
  4. Part of the Carbon Language project, under the Apache License v2.0 with LLVM
  5. Exceptions. See /LICENSE for license information.
  6. SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
  7. """
  8. import argparse
  9. import os
  10. import re
  11. import shlex
  12. import shutil
  13. import subprocess
  14. import sys
  15. _PROMPT = """This will:
  16. - Create and switch to a new branch named '%s'.
  17. - Create a new proposal titled '%s'.
  18. - Create a PR for the proposal.
  19. Continue? (Y/n) """
  20. _LINK_TEMPLATE = """Proposal links (add links as proposal evolves):
  21. - Evolution links:
  22. - [Proposal PR](https://github.com/carbon-language/carbon-lang/pull/%s)
  23. - `[RFC topic](TODO)`
  24. - `[Decision topic](TODO)`
  25. - `[Decision PR](TODO)`
  26. - `[Announcement](TODO)`
  27. - Related links (optional):
  28. - `[Idea topic](TODO)`
  29. - `[TODO](TODO)`
  30. """
  31. def _exit(error):
  32. """Wraps sys.exit for testing."""
  33. sys.exit(error)
  34. def _parse_args(args=None):
  35. """Parses command-line arguments and flags."""
  36. parser = argparse.ArgumentParser(
  37. description="Generates a branch and PR for a new proposal with the "
  38. "specified title."
  39. )
  40. parser.add_argument(
  41. "title",
  42. metavar="TITLE",
  43. help="The title of the proposal.",
  44. )
  45. parser.add_argument(
  46. "--branch",
  47. metavar="BRANCH",
  48. help="The name of the branch. Automatically generated from the title "
  49. "by default.",
  50. )
  51. parser.add_argument(
  52. "--proposals-dir",
  53. metavar="PROPOSALS_DIR",
  54. help="The proposals directory, mainly for testing cross-repository. "
  55. "Automatically found by default.",
  56. )
  57. parser.add_argument(
  58. "--branch-start-point",
  59. metavar="BRANCH_START_POINT",
  60. default="trunk",
  61. type=str,
  62. help="The starting point for the new branch.",
  63. )
  64. return parser.parse_args(args=args)
  65. def _calculate_branch(parsed_args):
  66. """Returns the branch name."""
  67. if parsed_args.branch:
  68. return parsed_args.branch
  69. # Only use the first 20 chars of the title for branch names.
  70. return "proposal-%s" % (parsed_args.title.lower().replace(" ", "-")[0:20])
  71. def _find_tool(tool):
  72. """Checks if a tool is present."""
  73. tool_path = shutil.which(tool)
  74. if not tool_path:
  75. _exit("ERROR: Missing the '%s' command-line tool." % tool)
  76. return tool_path
  77. def _fill_template(template_path, title, pr_num):
  78. """Fills out template TODO fields."""
  79. with open(template_path) as template_file:
  80. content = template_file.read()
  81. content = re.sub(r"^# TODO\n", "# %s\n" % title, content)
  82. content = re.sub(
  83. r"(https://github.com/[^/]+/[^/]+/pull/)####",
  84. r"\g<1>%d" % pr_num,
  85. content,
  86. )
  87. content = re.sub(r"## TODO(?:.|\n)*(## Problem)", r"\1", content)
  88. return content
  89. def _get_proposals_dir(parsed_args):
  90. """Returns the path to the proposals directory."""
  91. if parsed_args.proposals_dir:
  92. return parsed_args.proposals_dir
  93. return os.path.realpath(
  94. os.path.join(os.path.dirname(__file__), "../../proposals")
  95. )
  96. def _run(argv, check=True, get_stdout=False):
  97. """Runs a command."""
  98. cmd = " ".join([shlex.quote(x) for x in argv])
  99. print("\n+ RUNNING: %s" % cmd, file=sys.stderr)
  100. stdout_pipe = None
  101. if get_stdout:
  102. stdout_pipe = subprocess.PIPE
  103. p = subprocess.Popen(argv, stdout=stdout_pipe)
  104. stdout, _ = p.communicate()
  105. if get_stdout:
  106. out = stdout.decode("utf-8")
  107. print(out, end="")
  108. if check and p.returncode != 0:
  109. _exit("ERROR: Command failed: %s" % cmd)
  110. if get_stdout:
  111. return out
  112. def _run_pr_create(argv):
  113. """Runs a command and returns the PR#."""
  114. out = _run(argv, get_stdout=True)
  115. match = re.search(
  116. r"^https://github.com/[^/]+/[^/]+/pull/(\d+)$", out, re.MULTILINE
  117. )
  118. if not match:
  119. _exit("ERROR: Failed to find PR# in output.")
  120. return int(match[1])
  121. def main():
  122. parsed_args = _parse_args()
  123. title = parsed_args.title
  124. branch = _calculate_branch(parsed_args)
  125. # Verify tools are available.
  126. gh_bin = _find_tool("gh")
  127. git_bin = _find_tool("git")
  128. precommit_bin = _find_tool("pre-commit")
  129. # Ensure a good working directory.
  130. proposals_dir = _get_proposals_dir(parsed_args)
  131. os.chdir(proposals_dir)
  132. # Verify there are no uncommitted changes.
  133. p = subprocess.run([git_bin, "diff-index", "--quiet", "HEAD", "--"])
  134. if p.returncode != 0:
  135. _exit("ERROR: There are uncommitted changes in your git repo.")
  136. # Prompt before proceeding.
  137. response = "?"
  138. while response not in ("y", "n", ""):
  139. response = input(_PROMPT % (branch, title)).lower()
  140. if response == "n":
  141. _exit("ERROR: Cancelled")
  142. # Create a proposal branch.
  143. _run(
  144. [git_bin, "switch", "--create", branch, parsed_args.branch_start_point]
  145. )
  146. _run([git_bin, "push", "-u", "origin", branch])
  147. # Copy template.md to a temp file.
  148. template_path = os.path.join(proposals_dir, "template.md")
  149. temp_path = os.path.join(proposals_dir, "new-proposal.tmp.md")
  150. shutil.copyfile(template_path, temp_path)
  151. _run([git_bin, "add", temp_path])
  152. _run([git_bin, "commit", "-m", "Creating new proposal: %s" % title])
  153. # Create a PR with WIP+proposal labels.
  154. _run([git_bin, "push"])
  155. pr_num = _run_pr_create(
  156. [
  157. gh_bin,
  158. "pr",
  159. "create",
  160. "--draft",
  161. "--label",
  162. "proposal",
  163. "--project",
  164. "Proposals",
  165. "--reviewer",
  166. "carbon-language/carbon-leads",
  167. "--title",
  168. title,
  169. "--body",
  170. "TODO: add summary and links here",
  171. ]
  172. )
  173. # Remove the temp file, create p####.md, and fill in PR information.
  174. os.remove(temp_path)
  175. final_path = os.path.join(proposals_dir, "p%04d.md" % pr_num)
  176. content = _fill_template(template_path, title, pr_num)
  177. with open(final_path, "w") as final_file:
  178. final_file.write(content)
  179. _run([git_bin, "add", temp_path, final_path])
  180. _run([precommit_bin, "run"], check=False) # Needs a ToC update.
  181. _run([git_bin, "add", final_path, os.path.join(proposals_dir, "README.md")])
  182. _run(
  183. [
  184. git_bin,
  185. "commit",
  186. "--amend",
  187. "-m",
  188. "Filling out template with PR %d" % pr_num,
  189. ]
  190. )
  191. # Push the PR update.
  192. _run([git_bin, "push", "--force-with-lease"])
  193. print(
  194. "\nCreated PR %d for %s. Make changes to:\n %s"
  195. % (pr_num, title, final_path)
  196. )
  197. if __name__ == "__main__":
  198. main()