Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle PR reviews. #134

Merged
merged 2 commits into from
Jul 31, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 107 additions & 57 deletions src/scripts/pr_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
author {
login
}
createdAt
title

%(comments)s
%(reviews)s
%(review_threads)s
}
}
Expand All @@ -47,6 +49,23 @@
}
"""

_QUERY_REVIEWS = """
reviews(first: 100%s) {
nodes {
author {
login
}
body
createdAt
url
}
pageInfo {
endCursor
hasNextPage
}
}
"""

_QUERY_REVIEW_THREADS = """
reviewThreads(first: 100%s) {
nodes {
Expand Down Expand Up @@ -98,8 +117,8 @@ def from_raw_comment(raw_comment):
)

@staticmethod
def _rewrap(content, indent):
"""Rewraps a comment to fit in 80 columns with an optional indent."""
def _rewrap(content):
"""Rewraps a comment to fit in 80 columns with an indent."""
lines = []
for line in content.split("\n"):
lines.extend(
Expand All @@ -108,31 +127,49 @@ def _rewrap(content, indent):
for x in textwrap.wrap(
line,
width=80,
initial_indent=" " * indent,
subsequent_indent=" " * indent,
initial_indent=" " * 4,
subsequent_indent=" " * 4,
)
]
)
return "\n".join(lines)

def format(self, long, indent):
def format(self, long):
"""Formats the comment."""
if long:
return "%s%s at %s:\n%s" % (
" " * indent,
" " * 2,
self.author,
self.timestamp.strftime("%Y-%m-%d %H:%M"),
self._rewrap(self.body, indent + 2),
self._rewrap(self.body),
)
else:
# Compact newlines down into pilcrows, leaving a space after.
body = self.body.replace("\r", "").replace("\n", "¶ ")
while "¶ ¶" in body:
body = body.replace("¶ ¶", "¶¶")
line = "%s%s: %s" % (" " * indent, self.author, body)
line = "%s%s: %s" % (" " * 2, self.author, body)
return line if len(line) <= 80 else line[:77] + "..."


class _PRComment(_Comment):
"""A comment on the top-level PR."""

def __init__(self, raw_comment):
super().__init__(
raw_comment["author"]["login"],
raw_comment["createdAt"],
raw_comment["body"],
)
self.url = raw_comment["url"]

def __lt__(self, other):
return self.timestamp < other.timestamp

def format(self, long):
return "%s\n%s" % (self.url, super().format(long))


class _Thread(object):
"""A review thread on a line of code."""

Expand All @@ -144,36 +181,29 @@ def __init__(self, parsed_args, thread):
self.line = first_comment["originalPosition"]
self.path = first_comment["path"]

# Link to the comment in the commit if possible; GitHub features work
# better there than in the conversation view. The diff_url allows
# viewing changes since the comment, although the comment won't be
# visible there.
if "originalCommit" in first_comment:
template = (
"https://github.com/carbon-language/%(repo)s/pull/%(pr_num)s/"
"files/%(oid)s%(head)s#diff-%(path_md5)s%(line_side)s%(line)s"
)
# GitHub uses an md5 of the file's path for the link.
path_md5 = hashlib.md5()
path_md5.update(bytearray(self.path, "utf-8"))
format_dict = {
"head": "",
"line_side": "R",
"line": self.line,
"oid": first_comment["originalCommit"]["abbreviatedOid"],
"path_md5": path_md5.hexdigest(),
"pr_num": parsed_args.pr_num,
"repo": parsed_args.repo,
}
self.comment_url = template % format_dict
format_dict["head"] = "..HEAD"
format_dict["line_side"] = "L"
self.diff_url = template % format_dict
self.conversation_url = None
else:
self.conversation_url = first_comment["url"]
self.comment_url = None
self.diff_url = None
# Link to the comment in the commit; GitHub features work better there
# than in the conversation view. The diff_url allows viewing changes
# since the comment, although the comment won't be visible there.
template = (
"https://github.com/carbon-language/%(repo)s/pull/%(pr_num)s/"
"files/%(oid)s%(head)s#diff-%(path_md5)s%(line_side)s%(line)s"
)
# GitHub uses an md5 of the file's path for the link.
path_md5 = hashlib.md5()
path_md5.update(bytearray(self.path, "utf-8"))
format_dict = {
"head": "",
"line_side": "R",
"line": self.line,
"oid": first_comment["originalCommit"]["abbreviatedOid"],
"path_md5": path_md5.hexdigest(),
"pr_num": parsed_args.pr_num,
"repo": parsed_args.repo,
}
self.url = template % format_dict
format_dict["head"] = "..HEAD"
format_dict["line_side"] = "L"
self.diff_url = template % format_dict

self.comments = [
_Comment.from_raw_comment(comment)
Expand All @@ -198,16 +228,17 @@ def format(self, long):
"""Formats the review thread with comments."""
lines = []
lines.append(
"line %d; %s"
% (self.line, ("resolved" if self.is_resolved else "unresolved"),)
"%s\n - line %d; %s"
% (
self.url,
self.line,
("resolved" if self.is_resolved else "unresolved"),
)
)
if self.diff_url:
lines.append(" COMMENT: %s" % self.comment_url)
lines.append(" CHANGES: %s" % self.diff_url)
else:
lines.append(" %s" % self.conversation_url)
lines.append(" - diff: %s" % self.diff_url)
for comment in self.comments:
lines.append(comment.format(long, 2))
lines.append(comment.format(long))
return "\n".join(lines)

def has_comment_from(self, comments_from):
Expand Down Expand Up @@ -285,6 +316,7 @@ def _query(parsed_args, client, field_name=None, field=None):
"repo": parsed_args.repo,
"comments": "",
"review_threads": "",
"reviews": "",
}
if field:
# Use a cursor for pagination of the field.
Expand All @@ -293,19 +325,25 @@ def _query(parsed_args, client, field_name=None, field=None):
format_inputs["comments"] = _QUERY_COMMENTS % cursor
elif field_name == "reviewThreads":
format_inputs["review_threads"] = _QUERY_REVIEW_THREADS % cursor
elif field_name == "reviews":
format_inputs["reviews"] = _QUERY_REVIEWS % cursor
else:
raise ValueError("Unexpected field_name: %s" % field_name)
else:
# Fetch the first page of both fields.
# Fetch the first page of all fields.
format_inputs["comments"] = _QUERY_COMMENTS % ""
format_inputs["review_threads"] = _QUERY_REVIEW_THREADS % ""
format_inputs["reviews"] = _QUERY_REVIEWS % ""
return client.execute(gql.gql(_QUERY % format_inputs))


def _accumulate_comments(parsed_args, comments, raw_comments):
"""Collects top-level comments."""
def _accumulate_pr_comments(parsed_args, comments, raw_comments):
"""Collects top-level comments and reviews."""
for raw_comment in raw_comments:
comments.append(_Comment.from_raw_comment(raw_comment))
# Elide reviews that have no top-level comment body.
if not raw_comment["body"]:
continue
comments.append(_PRComment(raw_comment))


def _accumulate_threads(parsed_args, threads_by_path, raw_threads):
Expand Down Expand Up @@ -373,11 +411,20 @@ def _fetch_comments(parsed_args):
threads_result = _query(parsed_args, client)
pull_request = threads_result["repository"]["pullRequest"]

# Paginate comments and review threads.
# Paginate comments, reviews, and review threads.
comments = []
_paginate(
"comments",
_accumulate_comments,
_accumulate_pr_comments,
parsed_args,
client,
pull_request,
comments,
)
# Combine reviews into comments for interleaving.
_paginate(
"reviews",
_accumulate_pr_comments,
parsed_args,
client,
pull_request,
Expand All @@ -394,20 +441,23 @@ def _fetch_comments(parsed_args):
)

# Now that loading is done (no more progress indicators), print the header.
print(
"\n '%s' by %s"
% (pull_request["title"], pull_request["author"]["login"])
print()
pr_desc = _Comment(
pull_request["author"]["login"],
pull_request["createdAt"],
pull_request["title"],
)
print(pr_desc.format(parsed_args.long))
return comments, threads_by_path


def main():
parsed_args = _parse_args()
comments, threads_by_path = _fetch_comments(parsed_args)

print()
for comment in comments:
print(comment.format(parsed_args.long, 0))
for comment in sorted(comments):
print()
print(comment.format(parsed_args.long))

for path, threads in sorted(threads_by_path.items()):
# Print a header for each path.
Expand Down
Loading