pr_comments.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. #!/usr/bin/env python3
  2. """Figure out comments on a GitHub 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 datetime
  10. import hashlib
  11. import os
  12. import importlib.util
  13. import textwrap
  14. # Do some extra work to support direct runs.
  15. try:
  16. from carbon.github_tools import github_helpers
  17. except ImportError:
  18. github_helpers_spec = importlib.util.spec_from_file_location(
  19. "github_helpers",
  20. os.path.join(os.path.dirname(__file__), "github_helpers.py"),
  21. )
  22. github_helpers = importlib.util.module_from_spec(github_helpers_spec)
  23. github_helpers_spec.loader.exec_module(github_helpers)
  24. # The main query, into which other queries are composed.
  25. _QUERY = """
  26. {
  27. repository(owner: "carbon-language", name: "%(repo)s") {
  28. pullRequest(number: %(pr_num)d) {
  29. author {
  30. login
  31. }
  32. createdAt
  33. title
  34. %(comments)s
  35. %(reviews)s
  36. %(review_threads)s
  37. }
  38. }
  39. }
  40. """
  41. # Queries for comments on the PR. These are direct, non-review comments on the
  42. # PR.
  43. _QUERY_COMMENTS = """
  44. comments(first: 100%(cursor)s) {
  45. nodes {
  46. author {
  47. login
  48. }
  49. body
  50. createdAt
  51. url
  52. }
  53. %(pagination)s
  54. }
  55. """
  56. # Queries for reviews on the PR, which have a non-empty body if a review has
  57. # a summary comment.
  58. _QUERY_REVIEWS = """
  59. reviews(first: 100%(cursor)s) {
  60. nodes {
  61. author {
  62. login
  63. }
  64. body
  65. createdAt
  66. url
  67. }
  68. %(pagination)s
  69. }
  70. """
  71. # Queries for review threads on the PR.
  72. _QUERY_REVIEW_THREADS = """
  73. reviewThreads(first: 100%(cursor)s) {
  74. nodes {
  75. comments(first: 100) {
  76. nodes {
  77. author {
  78. login
  79. }
  80. body
  81. createdAt
  82. originalPosition
  83. originalCommit {
  84. abbreviatedOid
  85. }
  86. path
  87. }
  88. }
  89. isResolved
  90. resolvedBy {
  91. createdAt
  92. login
  93. }
  94. }
  95. %(pagination)s
  96. }
  97. """
  98. class _Comment(object):
  99. """A comment, either on a review thread or top-level on the PR."""
  100. def __init__(self, author, timestamp, body):
  101. self.author = author
  102. self.timestamp = datetime.datetime.strptime(
  103. timestamp, "%Y-%m-%dT%H:%M:%SZ"
  104. )
  105. self.body = body
  106. @staticmethod
  107. def from_raw_comment(raw_comment):
  108. """Creates the comment from a raw comment dict."""
  109. return _Comment(
  110. raw_comment["author"]["login"],
  111. raw_comment["createdAt"],
  112. raw_comment["body"],
  113. )
  114. @staticmethod
  115. def _rewrap(content):
  116. """Rewraps a comment to fit in 80 columns with an indent."""
  117. lines = []
  118. for line in content.split("\n"):
  119. lines.extend(
  120. [
  121. x
  122. for x in textwrap.wrap(
  123. line,
  124. width=80,
  125. initial_indent=" " * 4,
  126. subsequent_indent=" " * 4,
  127. )
  128. ]
  129. )
  130. return "\n".join(lines)
  131. def format(self, long):
  132. """Formats the comment."""
  133. if long:
  134. return "%s%s at %s:\n%s" % (
  135. " " * 2,
  136. self.author,
  137. self.timestamp.strftime("%Y-%m-%d %H:%M"),
  138. self._rewrap(self.body),
  139. )
  140. else:
  141. # Compact newlines down into pilcrows, leaving a space after.
  142. body = self.body.replace("\r", "").replace("\n", "¶ ")
  143. while "¶ ¶" in body:
  144. body = body.replace("¶ ¶", "¶¶")
  145. line = "%s%s: %s" % (" " * 2, self.author, body)
  146. return line if len(line) <= 80 else line[:77] + "..."
  147. class _PRComment(_Comment):
  148. """A comment on the top-level PR."""
  149. def __init__(self, raw_comment):
  150. super().__init__(
  151. raw_comment["author"]["login"],
  152. raw_comment["createdAt"],
  153. raw_comment["body"],
  154. )
  155. self.url = raw_comment["url"]
  156. def __lt__(self, other):
  157. return self.timestamp < other.timestamp
  158. def format(self, long):
  159. return "%s\n%s" % (self.url, super().format(long))
  160. class _Thread(object):
  161. """A review thread on a line of code."""
  162. def __init__(self, parsed_args, thread):
  163. self.is_resolved = thread["isResolved"]
  164. comments = thread["comments"]["nodes"]
  165. first_comment = comments[0]
  166. self.line = first_comment["originalPosition"]
  167. self.path = first_comment["path"]
  168. # Link to the comment in the commit; GitHub features work better there
  169. # than in the conversation view. The diff_url allows viewing changes
  170. # since the comment, although the comment won't be visible there.
  171. template = (
  172. "https://github.com/carbon-language/%(repo)s/pull/%(pr_num)s/"
  173. "files/%(oid)s%(head)s#diff-%(path_md5)s%(line_side)s%(line)s"
  174. )
  175. # GitHub uses an md5 of the file's path for the link.
  176. path_md5 = hashlib.md5()
  177. path_md5.update(bytearray(self.path, "utf-8"))
  178. format_dict = {
  179. "head": "",
  180. "line_side": "R",
  181. "line": self.line,
  182. "oid": first_comment["originalCommit"]["abbreviatedOid"],
  183. "path_md5": path_md5.hexdigest(),
  184. "pr_num": parsed_args.pr_num,
  185. "repo": parsed_args.repo,
  186. }
  187. self.url = template % format_dict
  188. format_dict["head"] = "..HEAD"
  189. format_dict["line_side"] = "L"
  190. self.diff_url = template % format_dict
  191. self.comments = [
  192. _Comment.from_raw_comment(comment)
  193. for comment in thread["comments"]["nodes"]
  194. ]
  195. if self.is_resolved:
  196. self.comments.append(
  197. _Comment(
  198. thread["resolvedBy"]["login"],
  199. thread["resolvedBy"]["createdAt"],
  200. "<resolved>",
  201. )
  202. )
  203. def __lt__(self, other):
  204. """Sort threads by line then timestamp."""
  205. if self.line != other.line:
  206. return self.line < other.line
  207. return self.comments[0].timestamp < other.comments[0].timestamp
  208. def format(self, long):
  209. """Formats the review thread with comments."""
  210. lines = []
  211. lines.append(
  212. "%s\n - line %d; %s"
  213. % (
  214. self.url,
  215. self.line,
  216. ("resolved" if self.is_resolved else "unresolved"),
  217. )
  218. )
  219. if self.diff_url:
  220. lines.append(" - diff: %s" % self.diff_url)
  221. for comment in self.comments:
  222. lines.append(comment.format(long))
  223. return "\n".join(lines)
  224. def has_comment_from(self, comments_from):
  225. """Returns true if comments has a comment from comments_from."""
  226. for comment in self.comments:
  227. if comment.author == comments_from:
  228. return True
  229. return False
  230. def _parse_args(args=None):
  231. """Parses command-line arguments and flags."""
  232. parser = argparse.ArgumentParser(description="Lists comments on a PR.")
  233. parser.add_argument(
  234. "pr_num",
  235. metavar="PR#",
  236. type=int,
  237. help="The pull request to fetch comments from.",
  238. )
  239. github_helpers.add_access_token_arg(parser, "repo")
  240. parser.add_argument(
  241. "--comments-after",
  242. metavar="LOGIN",
  243. help="Only print threads where the final comment is not from the given "
  244. "user. For example, use when looking for threads that you still need "
  245. "to respond to.",
  246. )
  247. parser.add_argument(
  248. "--comments-from",
  249. metavar="LOGIN",
  250. help="Only print threads with comments from the given user. For "
  251. "example, use when looking for threads that you've commented on.",
  252. )
  253. parser.add_argument(
  254. "--include-resolved",
  255. action="store_true",
  256. help="Whether to include resolved review threads. By default, only "
  257. "unresolved threads will be shown.",
  258. )
  259. parser.add_argument(
  260. "--repo",
  261. choices=["carbon-lang"],
  262. default="carbon-lang",
  263. help="The Carbon repo to query. Defaults to %(default)s.",
  264. )
  265. parser.add_argument(
  266. "--long",
  267. action="store_true",
  268. help="Prints long output, with the full comment.",
  269. )
  270. return parser.parse_args(args=args)
  271. def _query(parsed_args, field_name=None):
  272. """Returns a query for the passed field_name, or all by default."""
  273. print(".", end="", flush=True)
  274. format = {
  275. "pr_num": parsed_args.pr_num,
  276. "repo": parsed_args.repo,
  277. "comments": "",
  278. "review_threads": "",
  279. "reviews": "",
  280. }
  281. if field_name:
  282. # Use a cursor for pagination of the field.
  283. if field_name == "comments":
  284. format["comments"] = _QUERY_COMMENTS
  285. elif field_name == "reviewThreads":
  286. format["review_threads"] = _QUERY_REVIEW_THREADS
  287. elif field_name == "reviews":
  288. format["reviews"] = _QUERY_REVIEWS
  289. else:
  290. raise ValueError("Unexpected field_name: %s" % field_name)
  291. else:
  292. # Fetch the first page of all fields.
  293. subformat = {"cursor": "", "pagination": github_helpers.PAGINATION}
  294. format["comments"] = _QUERY_COMMENTS % subformat
  295. format["review_threads"] = _QUERY_REVIEW_THREADS % subformat
  296. format["reviews"] = _QUERY_REVIEWS % subformat
  297. return _QUERY % format
  298. def _accumulate_pr_comment(parsed_args, comments, raw_comment):
  299. """Collects top-level comments and reviews."""
  300. # Elide reviews that have no top-level comment body.
  301. if raw_comment["body"]:
  302. comments.append(_PRComment(raw_comment))
  303. def _accumulate_thread(parsed_args, threads_by_path, raw_thread):
  304. """Adds threads to threads_by_path for later sorting."""
  305. thread = _Thread(parsed_args, raw_thread)
  306. # Optionally skip resolved threads.
  307. if not parsed_args.include_resolved and thread.is_resolved:
  308. return
  309. # Optionally skip threads where the given user isn't the last commenter.
  310. if (
  311. parsed_args.comments_after
  312. and thread.comments[-1].author == parsed_args.comments_after
  313. ):
  314. return
  315. # Optionally skip threads where the given user hasn't commented.
  316. if parsed_args.comments_from and not thread.has_comment_from(
  317. parsed_args.comments_from
  318. ):
  319. return
  320. if thread.path not in threads_by_path:
  321. threads_by_path[thread.path] = []
  322. threads_by_path[thread.path].append(thread)
  323. def _paginate(
  324. field_name, accumulator, parsed_args, client, main_result, output
  325. ):
  326. """Paginates through the given field_name, accumulating results."""
  327. query = _query(parsed_args, field_name=field_name)
  328. path = ("repository", "pullRequest", field_name)
  329. for node in client.execute_and_paginate(
  330. query, path, first_page=main_result
  331. ):
  332. accumulator(parsed_args, output, node)
  333. def _fetch_comments(parsed_args):
  334. """Fetches comments and review threads from GitHub."""
  335. # Each _query call will print a '.' for progress.
  336. print(
  337. "Loading https://github.com/carbon-language/%s/pull/%d ..."
  338. % (parsed_args.repo, parsed_args.pr_num),
  339. end="",
  340. flush=True,
  341. )
  342. client = github_helpers.Client(parsed_args)
  343. # Get the initial set of review threads, and print the PR summary.
  344. main_result = client.execute(_query(parsed_args))
  345. pull_request = main_result["repository"]["pullRequest"]
  346. # Paginate comments, reviews, and review threads.
  347. comments = []
  348. _paginate(
  349. "comments",
  350. _accumulate_pr_comment,
  351. parsed_args,
  352. client,
  353. main_result,
  354. comments,
  355. )
  356. # Combine reviews into comments for interleaving.
  357. _paginate(
  358. "reviews",
  359. _accumulate_pr_comment,
  360. parsed_args,
  361. client,
  362. main_result,
  363. comments,
  364. )
  365. threads_by_path = {}
  366. _paginate(
  367. "reviewThreads",
  368. _accumulate_thread,
  369. parsed_args,
  370. client,
  371. main_result,
  372. threads_by_path,
  373. )
  374. # Now that loading is done (no more progress indicators), print the header.
  375. print()
  376. pr_desc = _Comment(
  377. pull_request["author"]["login"],
  378. pull_request["createdAt"],
  379. pull_request["title"],
  380. )
  381. print(pr_desc.format(parsed_args.long))
  382. return comments, threads_by_path
  383. def main():
  384. parsed_args = _parse_args()
  385. comments, threads_by_path = _fetch_comments(parsed_args)
  386. for comment in sorted(comments):
  387. print()
  388. print(comment.format(parsed_args.long))
  389. for path, threads in sorted(threads_by_path.items()):
  390. # Print a header for each path.
  391. print()
  392. print("=" * 80)
  393. print(path)
  394. print("=" * 80)
  395. for thread in sorted(threads):
  396. print()
  397. print(thread.format(parsed_args.long))
  398. if __name__ == "__main__":
  399. main()