Merge carbon-project-tools back into carbon-lang (#203)

As proposed at https://forums.carbon-lang.dev/t/merged-repo-structure/174:

- carbon-lang:/src/jekyll -> carbon-lang:/website/jekyll
- carbon-lang:/src/scripts -> carbon-lang:/proposals/scripts
- carbon-project-tools:/firebase -> carbon-lang:/website/firebase
- carbon-project-tools:/github -> carbon-lang:/github
This commit is contained in:
Jon Meow
2020-12-02 09:53:14 -08:00
committed by GitHub
parent 1a85ea2225
commit cc4211442f
18 changed files with 1277 additions and 18 deletions
+17
View File
@@ -0,0 +1,17 @@
# GitHub
<!--
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
-->
Scripts for use with GitHub.
See individual scripts for more details.
Please use `pytest` for testing:
```
$ pip install pytest
```
+102
View File
@@ -0,0 +1,102 @@
"""GitHub GraphQL helpers.
https://developer.github.com/v4/explorer/ is very useful for building queries.
"""
__copyright__ = """
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
import os
# https://pypi.org/project/gql/
import gql
import gql.transport.requests
_ENV_TOKEN = "GITHUB_ACCESS_TOKEN"
# Query elements for pagination.
PAGINATION = """pageInfo {
hasNextPage
endCursor
}
totalCount"""
def add_access_token_arg(parser, permissions):
"""Adds a flag to set the access token."""
access_token = os.environ.get(_ENV_TOKEN, default=None)
parser.add_argument(
"--access-token",
metavar="ACCESS_TOKEN",
default=access_token,
required=not access_token,
help="The access token for use with GitHub. May also be specified in "
"the environment as %s. The access token should have permissions: %s"
% (_ENV_TOKEN, permissions),
)
class Client(object):
"""A GitHub GraphQL client."""
def __init__(self, parsed_args):
"""Connects to GitHub."""
transport = gql.transport.requests.RequestsHTTPTransport(
url="https://api.github.com/graphql",
headers={"Authorization": "bearer %s" % parsed_args.access_token},
)
self._client = gql.Client(
transport=transport, fetch_schema_from_transport=True
)
def execute(self, query):
"""Runs a query."""
return self._client.execute(gql.gql(query))
def execute_and_paginate(self, query, path, first_page=None):
"""Runs a query with pagination.
Arguments:
query: The GraphQL query template, which must have both 'cursor' and
'pagination' fields to fill in. The cursor should be part of the
location query (with 'first'), and the pagination should be at the
same level as nodes.
path: A list of strings indicating the path to the nodes in the
result.
first_page: An optional object for the first page of results, which
will otherwise automatically be collected. This exists for callers
to optimize by collecting other data with the first page.
"""
format = {"cursor": "", "pagination": PAGINATION}
count = 0
exp_count = None
while True:
if first_page:
result = first_page
first_page = None
else:
result = self.execute(query % format)
# Follow the path to the nodes being paginated.
node_parent = result
for entry in path:
node_parent = node_parent[entry]
# Store the total count of responses.
if not exp_count:
exp_count = node_parent["totalCount"]
# Yield each node individually.
for node in node_parent["nodes"]:
yield node
count += 1
# Check for pagination, verifying the total count on exit.
page_info = node_parent["pageInfo"]
if not page_info["hasNextPage"]:
assert exp_count == count, "exp %d != actual %d at path %s" % (
exp_count,
count,
path,
)
return
format["cursor"] = ' after: "%s"' % page_info["endCursor"]
+181
View File
@@ -0,0 +1,181 @@
"""Tests for github_helpers.py."""
__copyright__ = """
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
import unittest
from unittest import mock
import github_helpers
_TEST_QUERY = """
query {
top(login: "foo") {
child(first: 100%(cursor)s) {
nodes {
login
}
%(pagination)s
}
}
}
"""
_TEST_QUERY_PATH = ("top", "child")
_EXP_QUERY_FIRST_PAGE = """
query {
top(login: "foo") {
child(first: 100) {
nodes {
login
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
"""
_EXP_QUERY_SECOND_PAGE = """
query {
top(login: "foo") {
child(first: 100 after: "CURSOR") {
nodes {
login
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
}
"""
class TestGithubHelpers(unittest.TestCase):
def setUp(self):
patcher = mock.patch.object(
github_helpers.Client, "__init__", lambda self, parsed_args: None
)
self.addCleanup(patcher.stop)
patcher.start()
self.client = github_helpers.Client(None)
@staticmethod
def mock_result(nodes, total_count=None, has_next_page=False):
if total_count is None:
total_count = len(nodes)
end_cursor = None
if has_next_page:
end_cursor = "CURSOR"
return {
"top": {
"child": {
"nodes": nodes,
"pageInfo": {
"hasNextPage": has_next_page,
"endCursor": end_cursor,
},
"totalCount": total_count,
}
}
}
def test_execute_and_paginate_empty(self):
self.client.execute = mock.MagicMock(return_value=self.mock_result([]))
self.assertEqual(
list(
self.client.execute_and_paginate(_TEST_QUERY, _TEST_QUERY_PATH)
),
[],
)
self.client.execute.assert_called_once_with(_EXP_QUERY_FIRST_PAGE)
def test_execute_and_paginate_one_page(self):
self.client.execute = mock.MagicMock(
return_value=self.mock_result(["foo", "bar", "baz"])
)
self.assertEqual(
list(
self.client.execute_and_paginate(_TEST_QUERY, _TEST_QUERY_PATH)
),
["foo", "bar", "baz"],
)
self.client.execute.assert_called_once_with(_EXP_QUERY_FIRST_PAGE)
def test_execute_and_paginate_one_page_count_mismatch(self):
self.client.execute = mock.MagicMock(
return_value=self.mock_result(["foo"], total_count=2),
)
self.assertRaises(
AssertionError,
list,
self.client.execute_and_paginate(_TEST_QUERY, _TEST_QUERY_PATH),
)
self.client.execute.assert_called_once_with(_EXP_QUERY_FIRST_PAGE)
def test_execute_and_paginate_two_page(self):
def paging(query):
if query == _EXP_QUERY_FIRST_PAGE:
return self.mock_result(
["foo", "bar"], total_count=3, has_next_page=True
)
elif query == _EXP_QUERY_SECOND_PAGE:
return self.mock_result(["baz"], total_count="unused")
else:
raise ValueError("Bad query: %s" % query)
self.client.execute = mock.MagicMock(side_effect=paging)
self.assertEqual(
list(
self.client.execute_and_paginate(_TEST_QUERY, _TEST_QUERY_PATH)
),
["foo", "bar", "baz"],
)
self.assertEqual(self.client.execute.call_count, 2)
def test_execute_and_paginate_first_page_done(self):
self.client.execute = mock.MagicMock()
self.assertEqual(
list(
self.client.execute_and_paginate(
_TEST_QUERY,
_TEST_QUERY_PATH,
first_page=self.mock_result(["foo"]),
)
),
["foo"],
)
self.assertEqual(self.client.execute.call_count, 0)
def test_execute_and_paginate_first_page_continue(self):
self.client.execute = mock.MagicMock(
return_value=self.mock_result(["bar"], total_count="unused")
)
self.assertEqual(
list(
self.client.execute_and_paginate(
_TEST_QUERY,
_TEST_QUERY_PATH,
first_page=self.mock_result(
["foo"], total_count=2, has_next_page=True
),
)
),
["foo", "bar"],
)
self.client.execute.assert_called_once_with(_EXP_QUERY_SECOND_PAGE)
if __name__ == "__main__":
unittest.main()
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""Figure out comments on a GitHub PR."""
__copyright__ = """
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
import argparse
import datetime
import hashlib
import textwrap
import github_helpers
# The main query, into which other queries are composed.
_QUERY = """
{
repository(owner: "carbon-language", name: "%(repo)s") {
pullRequest(number: %(pr_num)d) {
author {
login
}
createdAt
title
%(comments)s
%(reviews)s
%(review_threads)s
}
}
}
"""
# Queries for comments on the PR. These are direct, non-review comments on the
# PR.
_QUERY_COMMENTS = """
comments(first: 100%(cursor)s) {
nodes {
author {
login
}
body
createdAt
url
}
%(pagination)s
}
"""
# Queries for reviews on the PR, which have a non-empty body if a review has
# a summary comment.
_QUERY_REVIEWS = """
reviews(first: 100%(cursor)s) {
nodes {
author {
login
}
body
createdAt
url
}
%(pagination)s
}
"""
# Queries for review threads on the PR.
_QUERY_REVIEW_THREADS = """
reviewThreads(first: 100%(cursor)s) {
nodes {
comments(first: 100) {
nodes {
author {
login
}
body
createdAt
originalPosition
originalCommit {
abbreviatedOid
}
path
}
}
isResolved
resolvedBy {
createdAt
login
}
}
%(pagination)s
}
"""
class _Comment(object):
"""A comment, either on a review thread or top-level on the PR."""
def __init__(self, author, timestamp, body):
self.author = author
self.timestamp = datetime.datetime.strptime(
timestamp, "%Y-%m-%dT%H:%M:%SZ"
)
self.body = body
@staticmethod
def from_raw_comment(raw_comment):
"""Creates the comment from a raw comment dict."""
return _Comment(
raw_comment["author"]["login"],
raw_comment["createdAt"],
raw_comment["body"],
)
@staticmethod
def _rewrap(content):
"""Rewraps a comment to fit in 80 columns with an indent."""
lines = []
for line in content.split("\n"):
lines.extend(
[
x
for x in textwrap.wrap(
line,
width=80,
initial_indent=" " * 4,
subsequent_indent=" " * 4,
)
]
)
return "\n".join(lines)
def format(self, long):
"""Formats the comment."""
if long:
return "%s%s at %s:\n%s" % (
" " * 2,
self.author,
self.timestamp.strftime("%Y-%m-%d %H:%M"),
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" % (" " * 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."""
def __init__(self, parsed_args, thread):
self.is_resolved = thread["isResolved"]
comments = thread["comments"]["nodes"]
first_comment = comments[0]
self.line = first_comment["originalPosition"]
self.path = first_comment["path"]
# 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)
for comment in thread["comments"]["nodes"]
]
if self.is_resolved:
self.comments.append(
_Comment(
thread["resolvedBy"]["login"],
thread["resolvedBy"]["createdAt"],
"<resolved>",
)
)
def __lt__(self, other):
"""Sort threads by line then timestamp."""
if self.line != other.line:
return self.line < other.line
return self.comments[0].timestamp < other.comments[0].timestamp
def format(self, long):
"""Formats the review thread with comments."""
lines = []
lines.append(
"%s\n - line %d; %s"
% (
self.url,
self.line,
("resolved" if self.is_resolved else "unresolved"),
)
)
if self.diff_url:
lines.append(" - diff: %s" % self.diff_url)
for comment in self.comments:
lines.append(comment.format(long))
return "\n".join(lines)
def has_comment_from(self, comments_from):
"""Returns true if comments has a comment from comments_from."""
for comment in self.comments:
if comment.author == comments_from:
return True
return False
def _parse_args(args=None):
"""Parses command-line arguments and flags."""
parser = argparse.ArgumentParser(description="Lists comments on a PR.")
parser.add_argument(
"pr_num",
metavar="PR#",
type=int,
help="The pull request to fetch comments from.",
)
github_helpers.add_access_token_arg(parser, "repo")
parser.add_argument(
"--comments-after",
metavar="LOGIN",
help="Only print threads where the final comment is not from the given "
"user. For example, use when looking for threads that you still need "
"to respond to.",
)
parser.add_argument(
"--comments-from",
metavar="LOGIN",
help="Only print threads with comments from the given user. For "
"example, use when looking for threads that you've commented on.",
)
parser.add_argument(
"--include-resolved",
action="store_true",
help="Whether to include resolved review threads. By default, only "
"unresolved threads will be shown.",
)
parser.add_argument(
"--repo",
choices=["carbon-lang", "carbon-toolchain"],
default="carbon-lang",
help="The Carbon repo to query. Defaults to %(default)s.",
)
parser.add_argument(
"--long",
action="store_true",
help="Prints long output, with the full comment.",
)
return parser.parse_args(args=args)
def _query(parsed_args, field_name=None):
"""Returns a query for the passed field_name, or all by default."""
print(".", end="", flush=True)
format = {
"pr_num": parsed_args.pr_num,
"repo": parsed_args.repo,
"comments": "",
"review_threads": "",
"reviews": "",
}
if field_name:
# Use a cursor for pagination of the field.
if field_name == "comments":
format["comments"] = _QUERY_COMMENTS
elif field_name == "reviewThreads":
format["review_threads"] = _QUERY_REVIEW_THREADS
elif field_name == "reviews":
format["reviews"] = _QUERY_REVIEWS
else:
raise ValueError("Unexpected field_name: %s" % field_name)
else:
# Fetch the first page of all fields.
subformat = {"cursor": "", "pagination": github_helpers.PAGINATION}
format["comments"] = _QUERY_COMMENTS % subformat
format["review_threads"] = _QUERY_REVIEW_THREADS % subformat
format["reviews"] = _QUERY_REVIEWS % subformat
return _QUERY % format
def _accumulate_pr_comment(parsed_args, comments, raw_comment):
"""Collects top-level comments and reviews."""
# Elide reviews that have no top-level comment body.
if raw_comment["body"]:
comments.append(_PRComment(raw_comment))
def _accumulate_thread(parsed_args, threads_by_path, raw_thread):
"""Adds threads to threads_by_path for later sorting."""
thread = _Thread(parsed_args, raw_thread)
# Optionally skip resolved threads.
if not parsed_args.include_resolved and thread.is_resolved:
return
# Optionally skip threads where the given user isn't the last commenter.
if (
parsed_args.comments_after
and thread.comments[-1].author == parsed_args.comments_after
):
return
# Optionally skip threads where the given user hasn't commented.
if parsed_args.comments_from and not thread.has_comment_from(
parsed_args.comments_from
):
return
if thread.path not in threads_by_path:
threads_by_path[thread.path] = []
threads_by_path[thread.path].append(thread)
def _paginate(
field_name, accumulator, parsed_args, client, main_result, output
):
"""Paginates through the given field_name, accumulating results."""
query = _query(parsed_args, field_name=field_name)
path = ("repository", "pullRequest", field_name)
for node in client.execute_and_paginate(
query, path, first_page=main_result
):
accumulator(parsed_args, output, node)
def _fetch_comments(parsed_args):
"""Fetches comments and review threads from GitHub."""
# Each _query call will print a '.' for progress.
print(
"Loading https://github.com/carbon-language/%s/pull/%d ..."
% (parsed_args.repo, parsed_args.pr_num),
end="",
flush=True,
)
client = github_helpers.Client(parsed_args)
# Get the initial set of review threads, and print the PR summary.
main_result = client.execute(_query(parsed_args))
pull_request = main_result["repository"]["pullRequest"]
# Paginate comments, reviews, and review threads.
comments = []
_paginate(
"comments",
_accumulate_pr_comment,
parsed_args,
client,
main_result,
comments,
)
# Combine reviews into comments for interleaving.
_paginate(
"reviews",
_accumulate_pr_comment,
parsed_args,
client,
main_result,
comments,
)
threads_by_path = {}
_paginate(
"reviewThreads",
_accumulate_thread,
parsed_args,
client,
main_result,
threads_by_path,
)
# Now that loading is done (no more progress indicators), print the header.
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)
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.
print()
print("=" * 80)
print(path)
print("=" * 80)
for thread in sorted(threads):
print()
print(thread.format(parsed_args.long))
if __name__ == "__main__":
main()
+278
View File
@@ -0,0 +1,278 @@
"""Tests for pr_comments.py."""
__copyright__ = """
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
import os
import unittest
from unittest import mock
import pr_comments
class TestPRComments(unittest.TestCase):
def test_format_comment_short(self):
created_at = "2001-02-03T04:05:06Z"
self.assertEqual(
pr_comments._Comment("author", created_at, "brief").format(False),
" author: brief",
)
self.assertEqual(
pr_comments._Comment("author", created_at, "brief\nwrap").format(
False
),
" author: brief¶ wrap",
)
self.assertEqual(
pr_comments._Comment(
"author", created_at, "brief\n\n\nwrap"
).format(False),
" author: brief¶¶¶ wrap",
)
self.assertEqual(
pr_comments._Comment(
"author",
created_at,
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed "
"do eiusmo",
).format(False),
" author: Lorem ipsum dolor sit amet, consectetur adipiscing "
"elit, sed do eiusmo",
)
self.assertEqual(
pr_comments._Comment(
"author",
created_at,
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed "
"do eiusmod",
).format(False),
" author: Lorem ipsum dolor sit amet, consectetur adipiscing "
"elit, sed do eiu...",
)
def test_format_comment_long(self):
created_at = "2001-02-03T04:05:06Z"
self.assertEqual(
pr_comments._Comment("author", created_at, "brief").format(True),
" author at 2001-02-03 04:05:\n brief",
)
self.assertEqual(
pr_comments._Comment("author", created_at, "brief\nwrap").format(
True
),
" author at 2001-02-03 04:05:\n brief\n wrap",
)
body = (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed "
"do eiusmod tempor incididunt ut labore et dolore magna "
"aliqua.\n"
"Ut enim ad minim veniam,"
)
self.assertEqual(
pr_comments._Comment("author", created_at, body).format(True),
" author at 2001-02-03 04:05:\n"
" Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed "
"do eiusmod\n"
" tempor incididunt ut labore et dolore magna aliqua.\n"
" Ut enim ad minim veniam,",
)
@staticmethod
def fake_thread(**kwargs):
with mock.patch.dict(os.environ, {}):
parsed_args = pr_comments._parse_args(["83"])
return pr_comments._Thread(
parsed_args, TestPRComments.fake_thread_dict(**kwargs)
)
@staticmethod
def fake_thread_dict(
is_resolved=False,
path="foo.md",
line=3,
created_at="2001-02-03T04:05:06Z",
):
thread_dict = {
"isResolved": is_resolved,
"comments": {
"nodes": [
{
"author": {"login": "author"},
"body": "comment",
"createdAt": created_at,
"originalCommit": {"abbreviatedOid": "abcdef"},
"originalPosition": line,
"path": path,
"url": "http://xyz",
},
{
"author": {"login": "other"},
"body": "reply",
"createdAt": "2001-02-03T04:15:16Z",
},
],
},
}
if is_resolved:
thread_dict["resolvedBy"] = {
"login": "resolver",
"createdAt": "2001-02-03T04:25:26Z",
}
return thread_dict
def test_thread_format(self):
self.assertEqual(
self.fake_thread().format(False),
"https://github.com/carbon-language/carbon-lang/pull/83/"
"files/abcdef#diff-d8ca3b3d314d8209367af0eea2373b6fR3\n"
" - line 3; unresolved\n"
" - diff: https://github.com/carbon-language/carbon-lang/pull/83/"
"files/abcdef..HEAD#diff-d8ca3b3d314d8209367af0eea2373b6fL3\n"
" author: comment\n"
" other: reply",
)
self.assertEqual(
self.fake_thread().format(True),
"https://github.com/carbon-language/carbon-lang/pull/83/files/"
"abcdef#diff-d8ca3b3d314d8209367af0eea2373b6fR3\n"
" - line 3; unresolved\n"
" - diff: https://github.com/carbon-language/carbon-lang/pull/83/"
"files/abcdef..HEAD#diff-d8ca3b3d314d8209367af0eea2373b6fL3\n"
" author at 2001-02-03 04:05:\n"
" comment\n"
" other at 2001-02-03 04:15:\n"
" reply",
)
self.assertEqual(
self.fake_thread(is_resolved=True).format(False),
"https://github.com/carbon-language/carbon-lang/pull/83/"
"files/abcdef#diff-d8ca3b3d314d8209367af0eea2373b6fR3\n"
" - line 3; resolved\n"
" - diff: https://github.com/carbon-language/carbon-lang/pull/83/"
"files/abcdef..HEAD#diff-d8ca3b3d314d8209367af0eea2373b6fL3\n"
" author: comment\n"
" other: reply\n"
" resolver: <resolved>",
)
self.assertEqual(
self.fake_thread(is_resolved=True).format(True),
"https://github.com/carbon-language/carbon-lang/pull/83/"
"files/abcdef#diff-d8ca3b3d314d8209367af0eea2373b6fR3\n"
" - line 3; resolved\n"
" - diff: https://github.com/carbon-language/carbon-lang/pull/83/"
"files/abcdef..HEAD#diff-d8ca3b3d314d8209367af0eea2373b6fL3\n"
" author at 2001-02-03 04:05:\n"
" comment\n"
" other at 2001-02-03 04:15:\n"
" reply\n"
" resolver at 2001-02-03 04:25:\n"
" <resolved>",
)
def test_thread_lt(self):
thread1 = self.fake_thread(line=2)
thread2 = self.fake_thread()
thread3 = self.fake_thread(created_at="2002-02-03T04:05:06Z")
self.assertTrue(thread1 < thread2)
self.assertFalse(thread2 < thread1)
self.assertFalse(thread2 < thread2)
self.assertTrue(thread2 < thread3)
self.assertFalse(thread3 < thread2)
def test_accumulate_thread(self):
with mock.patch.dict(os.environ, {}):
parsed_args = pr_comments._parse_args(["83"])
threads_by_path = {}
review_threads = [
self.fake_thread_dict(line=2),
self.fake_thread_dict(line=4),
self.fake_thread_dict(path="other.md"),
self.fake_thread_dict(),
]
for thread in review_threads:
pr_comments._accumulate_thread(
parsed_args,
threads_by_path,
thread,
)
self.assertEqual(sorted(threads_by_path.keys()), ["foo.md", "other.md"])
threads = sorted(threads_by_path["foo.md"])
self.assertEqual(len(threads), 3)
self.assertEqual(threads[0].line, 2)
self.assertEqual(threads[1].line, 3)
self.assertEqual(threads[2].line, 4)
self.assertEqual(len(threads_by_path["other.md"]), 1)
@staticmethod
def fake_pr_comment(**kwargs):
return pr_comments._PRComment(
TestPRComments.fake_pr_comment_dict(**kwargs)
)
@staticmethod
def fake_pr_comment_dict(
body="comment",
created_at="2001-02-03T04:05:06Z",
):
pr_comment_dict = {
"author": {"login": "author"},
"body": body,
"createdAt": created_at,
"url": "http://xyz",
}
return pr_comment_dict
def test_pr_comment_format(self):
self.assertEqual(
self.fake_pr_comment().format(False),
"http://xyz\n author: comment",
)
self.assertEqual(
self.fake_pr_comment().format(True),
"http://xyz\n author at 2001-02-03 04:05:\n comment",
)
def test_pr_comment_lt(self):
pr_comment1 = self.fake_pr_comment()
pr_comment2 = self.fake_pr_comment(created_at="2002-02-03T04:05:06Z")
self.assertTrue(pr_comment1 < pr_comment2)
self.assertFalse(pr_comment2 < pr_comment1)
self.assertFalse(pr_comment2 < pr_comment2)
def test_accumulate_pr_comment(self):
with mock.patch.dict(os.environ, {}):
parsed_args = pr_comments._parse_args(["83"])
raw_comments = [
self.fake_pr_comment_dict(body="x"),
self.fake_pr_comment_dict(body=""),
self.fake_pr_comment_dict(
body="y", created_at="2000-02-03T04:05:06Z"
),
self.fake_pr_comment_dict(
body="z", created_at="2002-02-03T04:05:06Z"
),
]
comments = []
for raw_comment in raw_comments:
pr_comments._accumulate_pr_comment(
parsed_args, comments, raw_comment
)
comments.sort()
self.assertEqual(len(comments), 3)
self.assertEqual(comments[0].body, "y")
self.assertEqual(comments[1].body, "x")
self.assertEqual(comments[2].body, "z")
if __name__ == "__main__":
unittest.main()
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Updates the contributors-with-label-access team.
This team exists because we need a team to manage triage access to repos;
GitHub doesn't allow the org to be set to triage access, only read/write. It
will be updated to include all members of the carbon-language organization.
"""
__copyright__ = """
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
import argparse
# https://github.com/PyGithub/PyGithub
# GraphQL is preferred, but falling back to pygithub for unsupported mutations.
import github
import github_helpers
# The organization to mirror members from.
_ORG = "carbon-language"
# The team to mirror to.
_TEAM = "contributors-with-label-access"
# Accounts in the org to skip mirroring.
_IGNORE_ACCOUNTS = ("CarbonLangInfra", "google-admin", "googlebot")
# Queries organization members.
_ORG_MEMBER_QUERY = """
query {
organization(login: "%s") {
membersWithRole(first: 100%%(cursor)s) {
nodes {
login
}
%%(pagination)s
}
}
}
"""
# The path for nodes in _ORG_MEMBER_QUERY.
_ORG_MEMBER_PATH = ("organization", "membersWithRole")
# Queries team members.
_TEAM_MEMBER_QUERY = """
query {
organization(login: "%s") {
team(slug: "%s") {
members(first: 100%%(cursor)s) {
nodes {
login
}
%%(pagination)s
}
}
}
}
"""
# The path for nodes in _TEAM_MEMBER_QUERY.
_TEAM_MEMBER_PATH = ("organization", "team", "members")
def _parse_args(args=None):
"""Parses command-line arguments and flags."""
parser = argparse.ArgumentParser(description=__doc__)
github_helpers.add_access_token_arg(parser, "admin:org, repo")
return parser.parse_args(args=args)
def _load_org_members(client):
"""Loads org members."""
print("Loading %s..." % _ORG)
org_members = set()
ignored = set()
for node in client.execute_and_paginate(
_ORG_MEMBER_QUERY % _ORG, _ORG_MEMBER_PATH
):
login = node["login"]
if login not in _IGNORE_ACCOUNTS:
org_members.add(login)
else:
ignored.add(login)
print(
"%s has %d non-ignored members, and %d ignored."
% (_ORG, len(org_members), len(ignored))
)
unignored = set(_IGNORE_ACCOUNTS) - ignored
assert not unignored, "Missing ignored accounts: %s" % unignored
return org_members
def _load_team_members(client):
"""Load team members."""
print("Loading %s..." % _TEAM)
team_members = set()
for node in client.execute_and_paginate(
_TEAM_MEMBER_QUERY % (_ORG, _TEAM), _TEAM_MEMBER_PATH
):
team_members.add(node["login"])
print("%s has %d members." % (_ORG, len(team_members)))
return team_members
def _update_team(gh, org_members, team_members):
"""Updates the team if needed.
This switches to pygithub because GraphQL lacks equivalent mutation support.
"""
gh_team = gh.get_organization(_ORG).get_team_by_slug(_TEAM)
add_members = org_members - team_members
if add_members:
print("Adding members: %s" % ", ".join(add_members))
for member in add_members:
gh_team.add_membership(gh.get_user(member))
remove_members = team_members - org_members
if remove_members:
print("Removing members: %s" % ", ".join(remove_members))
for member in remove_members:
gh_team.remove_membership(gh.get_user(member))
def main():
parsed_args = _parse_args()
print("Connecting...")
client = github_helpers.Client(parsed_args)
org_members = _load_org_members(client)
team_members = _load_team_members(client)
if org_members != team_members:
gh = github.Github(parsed_args.access_token)
_update_team(gh, org_members, team_members)
print("Done!")
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
"""Tests for update_label_access.py."""
__copyright__ = """
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
Exceptions. See /LICENSE for license information.
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""
import github
import unittest
from unittest import mock
import github_helpers
import update_label_access
class TestUpdateLabelAccess(unittest.TestCase):
def setUp(self):
self.client = mock.create_autospec(github_helpers.Client, instance=True)
self.gh = mock.create_autospec(github.Github, instance=True)
self.gh_org = mock.create_autospec(github.Organization, instance=True)
self.gh_team = mock.create_autospec(github.Team, instance=True)
self.gh.get_organization = mock.MagicMock(return_value=self.gh_org)
self.gh_org.get_team_by_slug = mock.MagicMock(return_value=self.gh_team)
def _mock_nodes(self, logins):
self.client.execute_and_paginate.return_value = [
{"login": login} for login in logins
]
def test_load_org_members_empty(self):
self._mock_nodes([])
self.assertRaises(
AssertionError, update_label_access._load_org_members, self.client
)
def test_load_org_members_missing_ignored(self):
self._mock_nodes(["foo", "bar"])
self.assertRaises(
AssertionError, update_label_access._load_org_members, self.client
)
def test_load_org_members_ignored_only(self):
self._mock_nodes(update_label_access._IGNORE_ACCOUNTS)
self.assertEqual(
update_label_access._load_org_members(self.client), set()
)
def test_load_org_members_found(self):
self._mock_nodes(
["foo", "bar"] + list(update_label_access._IGNORE_ACCOUNTS)
)
self.assertEqual(
update_label_access._load_org_members(self.client),
set(["foo", "bar"]),
)
def test_load_team_members_empty(self):
self._mock_nodes([])
self.assertEqual(
update_label_access._load_team_members(self.client), set()
)
def test_load_team_members_found(self):
self._mock_nodes(["foo", "bar"])
self.assertEqual(
update_label_access._load_team_members(self.client),
set(["foo", "bar"]),
)
def test_update_team_empty(self):
update_label_access._update_team(self.gh, set(), set())
def test_update_team_equal(self):
update_label_access._update_team(
self.gh, set(["foo", "bar"]), set(["foo", "bar"])
)
def test_update_team_add(self):
self.gh.get_user = mock.MagicMock(return_value="bar-user")
self.gh_team.add_membership = mock.MagicMock()
update_label_access._update_team(
self.gh, set(["foo", "bar"]), set(["foo"])
)
self.gh.get_user.assert_called_once_with("bar")
self.gh_team.add_membership.assert_called_once_with("bar-user")
def test_update_team_remove(self):
self.gh.get_user = mock.MagicMock(return_value="bar-user")
self.gh_team.remove_membership = mock.MagicMock()
update_label_access._update_team(
self.gh, set(["foo"]), set(["foo", "bar"])
)
self.gh.get_user.assert_called_once_with("bar")
self.gh_team.remove_membership.assert_called_once_with("bar-user")
if __name__ == "__main__":
unittest.main()