pr_comments.py 13 KB

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