new_proposal.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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. def _exit(error):
  21. """Wraps sys.exit for testing."""
  22. sys.exit(error)
  23. def _parse_args(args=None):
  24. """Parses command-line arguments and flags."""
  25. parser = argparse.ArgumentParser(
  26. description="Generates a branch and PR for a new proposal with the "
  27. "specified title."
  28. )
  29. parser.add_argument(
  30. "title",
  31. metavar="TITLE",
  32. help="The title of the proposal.",
  33. )
  34. parser.add_argument(
  35. "--branch",
  36. metavar="BRANCH",
  37. help="The name of the branch. Automatically generated from the title "
  38. "by default.",
  39. )
  40. return parser.parse_args(args=args)
  41. def _calculate_branch(parsed_args):
  42. """Returns the branch name."""
  43. if parsed_args.branch:
  44. return parsed_args.branch
  45. # Only use the first 20 chars of the title for branch names.
  46. return "proposal-%s" % (parsed_args.title.lower().replace(" ", "-")[0:20])
  47. def _find_tool(tool):
  48. """Checks if a tool is present."""
  49. tool_path = shutil.which(tool)
  50. if not tool_path:
  51. _exit("ERROR: Missing the '%s' command-line tool." % tool)
  52. return tool_path
  53. def _fill_template(template_path, title, pr_num):
  54. """Fills out template TODO fields."""
  55. with open(template_path) as template_file:
  56. content = template_file.read()
  57. content = re.sub(r"^# TODO\n", "# %s\n" % title, content)
  58. content = re.sub(
  59. r"(https://github.com/[^/]+/[^/]+/pull/)####",
  60. r"\g<1>%d" % pr_num,
  61. content,
  62. )
  63. content = re.sub(r"## TODO(?:.|\n)*(## Problem)", r"\1", content)
  64. return content
  65. def _get_proposals_dir():
  66. """Returns the path to the proposals directory."""
  67. return os.path.realpath(
  68. os.path.join(os.path.dirname(__file__), "../../proposals")
  69. )
  70. def _run(argv, check=True):
  71. """Runs a command."""
  72. cmd = " ".join([shlex.quote(x) for x in argv])
  73. print("\n+ RUNNING: %s" % cmd, file=sys.stderr)
  74. p = subprocess.run(argv)
  75. if check and p.returncode != 0:
  76. _exit("ERROR: Command failed: %s" % cmd)
  77. def _run_pr_create(argv):
  78. """Runs a command and returns the PR#."""
  79. cmd = " ".join([shlex.quote(x) for x in argv])
  80. print("\n+ RUNNING: %s" % cmd, file=sys.stderr)
  81. p = subprocess.Popen(argv, stdout=subprocess.PIPE)
  82. out, _ = p.communicate()
  83. out = out.decode("utf-8")
  84. print(out, end="")
  85. if p.returncode != 0:
  86. _exit("ERROR: Command failed: %s" % cmd)
  87. match = re.search(
  88. r"^https://github.com/[^/]+/[^/]+/pull/(\d+)$", out, re.MULTILINE
  89. )
  90. if not match:
  91. _exit("ERROR: Failed to find PR# in output.")
  92. return int(match[1])
  93. def main():
  94. parsed_args = _parse_args()
  95. title = parsed_args.title
  96. branch = _calculate_branch(parsed_args)
  97. # Verify tools are available.
  98. git_bin = _find_tool("git")
  99. gh_bin = _find_tool("gh")
  100. precommit_bin = _find_tool("pre-commit")
  101. # Ensure a good working directory.
  102. proposals_dir = _get_proposals_dir()
  103. os.chdir(proposals_dir)
  104. # Verify there are no uncommitted changes.
  105. p = subprocess.run([git_bin, "diff-index", "--quiet", "HEAD", "--"])
  106. if p.returncode != 0:
  107. _exit("ERROR: There are uncommitted changes in your git repo.")
  108. # Prompt before proceeding.
  109. response = "?"
  110. while response not in ("y", "n", ""):
  111. response = input(_PROMPT % (branch, title)).lower()
  112. if response == "n":
  113. _exit("ERROR: Cancelled")
  114. # Create a proposal branch.
  115. _run([git_bin, "checkout", "-b", branch, "trunk"])
  116. _run([git_bin, "push", "-u", "origin", branch])
  117. # Copy template.md to a temp file.
  118. template_path = os.path.join(proposals_dir, "template.md")
  119. temp_path = os.path.join(proposals_dir, "new-proposal.tmp.md")
  120. shutil.copyfile(template_path, temp_path)
  121. _run([git_bin, "add", temp_path])
  122. _run([git_bin, "commit", "-m", "Creating new proposal: %s" % title])
  123. # Create a PR with WIP+proposal labels.
  124. _run([git_bin, "push"])
  125. pr_num = _run_pr_create(
  126. [
  127. gh_bin,
  128. "pr",
  129. "create",
  130. "--label",
  131. "WIP,proposal",
  132. "--title",
  133. title,
  134. "--body",
  135. "",
  136. ]
  137. )
  138. # Remove the temp file, create p####.md, and fill in PR information.
  139. os.remove(temp_path)
  140. final_path = os.path.join(proposals_dir, "p%04d.md" % pr_num)
  141. content = _fill_template(template_path, title, pr_num)
  142. with open(final_path, "w") as final_file:
  143. final_file.write(content)
  144. _run([git_bin, "add", temp_path, final_path])
  145. _run([precommit_bin, "run"], check=False) # Needs a ToC update.
  146. _run([git_bin, "add", final_path, os.path.join(proposals_dir, "README.md")])
  147. _run(
  148. [
  149. git_bin,
  150. "commit",
  151. "--amend",
  152. "-m",
  153. "Filling out template with PR %d" % pr_num,
  154. ]
  155. )
  156. # Push the PR update.
  157. _run([git_bin, "push", "--force-with-lease"])
  158. print(
  159. "\nCreated PR %d for %s. Make changes to:\n %s"
  160. % (pr_num, title, final_path)
  161. )
  162. if __name__ == "__main__":
  163. main()