mirror of
https://github.com/carbon-language/carbon-lang.git
synced 2026-10-05 22:02:55 +01:00
Rename github to github_tools so that modules don't conflict with PyGithub (#223)
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
from github_tools 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()
|
||||
Executable
+451
@@ -0,0 +1,451 @@
|
||||
#!/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 os
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
# To support direct runs, ensure the pythonpath has the repo root.
|
||||
_PYTHONPATH = os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _PYTHONPATH not in sys.path:
|
||||
sys.path.insert(0, _PYTHONPATH)
|
||||
|
||||
from github_tools 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()
|
||||
@@ -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
|
||||
|
||||
from github_tools 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()
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/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
|
||||
import os
|
||||
import sys
|
||||
|
||||
# https://github.com/PyGithub/PyGithub
|
||||
# GraphQL is preferred, but falling back to pygithub for unsupported mutations.
|
||||
import github
|
||||
|
||||
# To support direct runs, ensure the pythonpath has the repo root.
|
||||
_PYTHONPATH = os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _PYTHONPATH not in sys.path:
|
||||
sys.path.insert(0, _PYTHONPATH)
|
||||
|
||||
from github_tools 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()
|
||||
@@ -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
|
||||
|
||||
from github_tools import github_helpers
|
||||
from github_tools 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()
|
||||
Reference in New Issue
Block a user