pr_comments.py 13 KB

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