Files
Alexei FedotovandGitHub 9806dc4ec7 Merge pull request #109867 from leshikus/advance-praktika-release-helpers
Extract tag-free CHVersion and praktika git/gh helpers from the CreateRelease work
2026-07-16 09:11:47 +00:00

1593 lines
63 KiB
Python

import dataclasses
import json
import os
import re
import shlex
import tempfile
import time
import traceback
from typing import Dict, List, Optional, Union
from praktika._environment import _Environment
from praktika.info import Info
from praktika.result import Result
from praktika.settings import Settings
from praktika.utils import Shell
class GH:
@dataclasses.dataclass
class GHIssue:
title: str
body: str
labels: List[str]
author: str
url: str
updated_at: str
created_at: str
number: int
is_closed: bool = False
@property
def html_url(self):
"""Alias for url field for compatibility"""
return self.url
@property
def state(self):
"""Backwards compatibility property for state field"""
return "closed" if self.is_closed else "open"
@classmethod
def from_gh_json(cls, json_data):
state_str = json_data.get("state", "open").lower()
return cls(
title=json_data["title"],
body=json_data["body"],
labels=[label["name"] for label in json_data["labels"]],
author=json_data["author"]["login"],
url=json_data["url"],
updated_at=json_data["updatedAt"],
created_at=json_data["createdAt"],
number=json_data["number"],
is_closed=(state_str == "closed"),
)
@dataclasses.dataclass
class CommitStatus:
state: str
description: str
url: str
context: str
@classmethod
def get_changed_files(cls, strict=False) -> List[str]:
info = Info()
res = None
if not info.is_local_run:
repo_name = info.repo_name
sha = info.sha
else:
repo_url = Shell.get_output(
"git config --get remote.origin.url", strict=True
)
repo_name = cls._repo_name_from_git_remote_url(repo_url)
if not repo_name:
raise RuntimeError(
f"Failed to extract repository name from remote URL [{repo_url}]"
)
sha = Shell.get_output("git rev-parse HEAD", strict=True)
assert repo_name
print(repo_name)
# Include both sides of renames: .filename is the destination path and
# .previous_filename (REST API only, absent for non-renames) is the
# source path. Without the source path a rename out of a watched
# directory is invisible to consumers such as job filtering and the
# read-only docs guard. Rename sources do not exist in the checkout at
# HEAD, same as deleted files, which were always included.
jq_both_sides = ".filename, (.previous_filename // empty)"
# In a merge-queue run PR_NUMBER is 0, but the queue entry is built for
# exactly one PR (parsed from the merge group head ref). Use that PR's
# paginated files list: the merge group SHA is a merge commit, and the
# commits API caps a commit's file list at 300 entries.
pr_number = info.pr_number
if pr_number <= 0 and info.is_merge_queue_event:
pr_number = info.linked_pr_number
if pr_number <= 0 and strict:
# The linked PR number could not be parsed from the merge group
# head ref. Falling back to `repos/{repo}/commits/{sha}` would
# reintroduce the 300-entry cap this branch exists to avoid, so a
# large PR could silently lose changed test files and turn the
# merge-queue flaky check into a false green. Fail closed instead:
# a merge-queue run always corresponds to exactly one PR, so a
# missing linked PR number is a real fault, not a skip condition.
raise RuntimeError(
"Merge-queue run has no linked PR number (could not parse it "
"from the merge group head ref); refusing to fall back to the "
"capped commits API for changed-file detection."
)
if pr_number > 0:
command = (
f"gh api repos/{repo_name}/pulls/{pr_number}/files "
f"--paginate --jq '.[] | {jq_both_sides}'"
)
else:
command = (
f"gh api repos/{repo_name}/commits/{sha} "
f"--jq '.files[] | {jq_both_sides}'"
)
# The GitHub API call is an idempotent read that occasionally fails with
# a transient error (rate limiting, a 5xx, or a GraphQL "Something went
# wrong" hiccup). Retry a few times with a small backoff so a momentary
# blip does not take down the whole workflow. Every non-zero exit code
# is treated as retryable: this is a read, so a spurious retry is cheap
# and we cannot reliably tell transient from permanent errors apart by
# the exit code alone.
attempts = 5
last_exit_code = 0
last_err = ""
last_out = ""
for attempt in range(attempts):
exit_code, changed_files_str, err = Shell.get_res_stdout_stderr(command)
if exit_code == 0:
res = (
list(dict.fromkeys(changed_files_str.split("\n")))
if changed_files_str
else []
)
break
last_exit_code, last_err, last_out = exit_code, err, changed_files_str
print(
f"Failed to get changed files, attempt [{attempt + 1}/{attempts}], "
f"exit code [{exit_code}], stderr [{err}]"
)
if attempt + 1 < attempts:
time.sleep(attempt + 1)
if res is None:
# Surface the actual command, exit code and stderr so the failure is
# diagnosable directly from the report, instead of resurfacing later
# as a confusing "NoneType is not iterable" in a downstream consumer
# that read the (never stored) changed files from the KV data.
message = (
f"Failed to retrieve the list of changed files after {attempts} attempts.\n"
f" command: {command}\n"
f" exit code: {last_exit_code}\n"
f" stderr: {last_err}\n"
f" stdout: {last_out}"
)
print(message)
if strict:
raise RuntimeError(message)
return res
@staticmethod
def _repo_name_from_git_remote_url(repo_url: str) -> str:
match = re.match(
r"^(?:https?://[^/]+/|git@[^:]+:|ssh://git@[^/]+/)([^/\s]+/[^/\s]+?)(?:\.git)?/?$",
repo_url,
)
return match.group(1) if match else ""
@classmethod
def do_command_with_retries(cls, command, verbose=False):
res = False
retry_count = 0
out, err = "", ""
while retry_count < Settings.MAX_RETRIES_GH and not res:
ret_code, out, err = Shell.get_res_stdout_stderr(command, verbose=verbose)
res = ret_code == 0
if not res and "Validation Failed" in err:
print(f"ERROR: GH command validation error {[err]}")
break
if not res and "Bad credentials" in err:
print("ERROR: GH credentials/auth failure")
break
if not res and "Resource not accessible" in err:
print("ERROR: GH permissions failure")
break
if not res:
retry_count += 1
delay = min(2 ** (retry_count + 1), 60)
time.sleep(delay)
if not res:
print(
f"ERROR: Failed to execute gh command [{command}] out:[{out}] err:[{err}] after [{retry_count}] attempts"
)
return res
@classmethod
def get_output_with_retries(cls, command, verbose=False):
"""Run a read-style ``gh`` command and return its stdout.
Mirrors :meth:`do_command_with_retries` but returns the captured
stdout instead of a boolean. Use this for any GitHub API read
(``gh api ...``, ``gh pr view ...``, ``gh pr diff ...``) that
previously used :meth:`Shell.get_output` without retries — those
calls would silently return an empty string on a transient 5xx
and propagate as ``json.loads('')`` errors or empty-result bugs.
Returns the trimmed stdout on success; an empty string if the
command keeps failing after ``MAX_RETRIES_GH`` attempts.
"""
retry_count = 0
out, err, ret_code = "", "", -1
while retry_count < Settings.MAX_RETRIES_GH:
ret_code, out, err = Shell.get_res_stdout_stderr(command, verbose=verbose)
if ret_code == 0:
return out
# Same non-retryable error classes as do_command_with_retries.
if "Validation Failed" in err:
print(f"ERROR: GH command validation error {[err]}")
break
if "Bad credentials" in err:
print("ERROR: GH credentials/auth failure")
break
if "Resource not accessible" in err:
print("ERROR: GH permissions failure")
break
retry_count += 1
delay = min(2 ** (retry_count + 1), 60)
time.sleep(delay)
print(
f"ERROR: Failed to execute gh command [{command}] out:[{out}] err:[{err}] after [{retry_count}] attempts"
)
return ""
@classmethod
def post_pr_comment(
cls, comment_body, or_update_comment_with_substring="", pr=None, repo=None
):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
temp_file_path = None
try:
if or_update_comment_with_substring:
print(f"check comment [{comment_body}] created")
safe_substr = shlex.quote(or_update_comment_with_substring)
cmd_check_created = (
f'gh api -H "Accept: application/vnd.github.v3+json" '
f'"/repos/{repo}/issues/{pr}/comments" '
f"--jq '.[] | {{id: .id, body: .body}}' | grep -F {safe_substr}"
)
output = cls.get_output_with_retries(cmd_check_created)
if output:
comment_ids = []
try:
comment_ids = [
json.loads(item.strip())["id"]
for item in output.split("\n")
if item.strip()
]
except Exception as ex:
print(f"Failed to retrieve PR comments with [{ex}]")
if comment_ids:
with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".txt", encoding="utf-8"
) as temp_file:
temp_file.write(comment_body)
temp_file_path = temp_file.name
for id in comment_ids:
cmd = f'gh api \
-X PATCH \
-H "Accept: application/vnd.github.v3+json" \
"/repos/{repo}/issues/comments/{id}" \
-F body=@{temp_file_path}'
print(f"Update existing comments [{id}]")
return cls.do_command_with_retries(cmd)
# default: create a new comment using a temporary file to avoid shell escaping/injection
with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".txt", encoding="utf-8"
) as temp_file:
temp_file.write(comment_body)
temp_file_path = temp_file.name
cmd = f"gh pr comment {pr} --repo {repo} --body-file {temp_file_path}"
return cls.do_command_with_retries(cmd)
finally:
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception:
pass
@classmethod
def post_pr_line_comment(
cls,
body_file,
commit_id=None,
path=None,
line=None,
side="RIGHT",
in_reply_to=None,
pr=None,
repo=None,
):
"""Post an inline review comment on a specific line of a PR diff,
or a reply on an existing inline thread.
When ``in_reply_to`` is set, the comment is posted as a reply on
the thread whose parent comment has that database id, and
``commit_id``/``path``/``line``/``side`` are ignored. Otherwise a
new top-level inline comment is created at the given location and
all four are required.
The body is read from ``body_file`` and passed via ``-F body=@<file>``,
which avoids two classes of bugs we have hit before:
1) Newlines collapsing to literal `\\n` when the body is inlined.
2) The body being posted as the literal `@<file>` string when a
caller mistakenly uses `-f` (raw field) instead of `-F` (typed
field with `@file` expansion).
"""
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
if not os.path.exists(body_file):
raise FileNotFoundError(f"Body file [{body_file}] not found")
if os.path.getsize(body_file) == 0:
raise ValueError(f"Body file [{body_file}] is empty")
if in_reply_to is not None:
cmd = (
f"gh api -X POST "
f'-H "Accept: application/vnd.github.v3+json" '
f'"/repos/{repo}/pulls/{pr}/comments/{int(in_reply_to)}/replies" '
f"-F body=@{shlex.quote(body_file)}"
)
else:
if commit_id is None or path is None or line is None:
raise ValueError(
"post_pr_line_comment requires commit_id, path, and line "
"when in_reply_to is not set"
)
cmd = (
f"gh api -X POST "
f'-H "Accept: application/vnd.github.v3+json" '
f'"/repos/{repo}/pulls/{pr}/comments" '
f"-F body=@{shlex.quote(body_file)} "
f"-f commit_id={shlex.quote(commit_id)} "
f"-f path={shlex.quote(path)} "
f"-F line={int(line)} "
f"-f side={shlex.quote(side)}"
)
return cls.do_command_with_retries(cmd)
@classmethod
def post_pr_review(
cls,
commit_id,
comments,
body="",
pr=None,
repo=None,
):
"""Post all inline findings as a single ``COMMENT`` PR review (one
review event, one notification) instead of many standalone inline
comments.
Only the ``COMMENT`` event is supported; this helper never approves or
requests changes, so an empty review is always a no-op rather than a
meaningful action.
``comments`` is a list of dicts, each describing one inline comment::
{"path": <file>, "line": <int>, "side": "RIGHT"|"LEFT",
"start_line": <int> (optional), "start_side": <str> (optional),
"body_file": <path>}
Each comment body is read from its ``body_file`` and the whole payload
is assembled with ``json.dumps``, so multi-line Markdown is escaped
correctly. This avoids the ``-f`` vs ``-F`` / literal ``@<file>`` and
literal ``\\n`` footguns that standalone ``gh api`` calls have hit.
GitHub rejects a ``COMMENT`` review with neither a body nor any
comments, so when ``comments`` is empty and ``body`` is empty the call
is skipped and True is returned.
"""
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
payload_comments = []
for c in comments:
body_file = c["body_file"]
if not os.path.exists(body_file):
raise FileNotFoundError(f"Body file [{body_file}] not found")
if os.path.getsize(body_file) == 0:
raise ValueError(f"Body file [{body_file}] is empty")
with open(body_file, "r", encoding="utf-8") as f:
comment = {"path": c["path"], "body": f.read()}
if c.get("start_line") is not None:
comment["start_line"] = int(c["start_line"])
comment["start_side"] = c.get("start_side", c.get("side", "RIGHT"))
comment["line"] = int(c["line"])
comment["side"] = c.get("side", "RIGHT")
payload_comments.append(comment)
if not payload_comments and not body:
print("No review body and no inline comments; skipping review post")
return True
payload = {"event": "COMMENT", "comments": payload_comments}
if commit_id:
payload["commit_id"] = commit_id
if body:
payload["body"] = body
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as f:
json.dump(payload, f)
payload_file = f.name
try:
cmd = (
f"gh api -X POST "
f'-H "Accept: application/vnd.github.v3+json" '
f'"/repos/{repo}/pulls/{pr}/reviews" '
f"--input {shlex.quote(payload_file)}"
)
return cls.do_command_with_retries(cmd)
finally:
os.unlink(payload_file)
@classmethod
def _gh_graphql_json(cls, query, variables, verbose=False):
"""Run a GraphQL query via ``gh api graphql`` and return parsed JSON.
``variables`` is a mapping of name to value: ``int`` and ``bool``
values use ``-F`` (typed), everything else uses ``-f`` (raw string).
Raises ``RuntimeError`` on transport failure (empty output after
retries) or if the response cannot be parsed; the caller is
responsible for checking GraphQL ``errors`` if any. Failing loud
is intentional: a silent empty result is indistinguishable from
"no data" and causes the reviewer to lose prior context.
"""
parts = [f"gh api graphql -f query={shlex.quote(query)}"]
for k, v in variables.items():
if isinstance(v, bool):
parts.append(f"-F {k}={'true' if v else 'false'}")
elif isinstance(v, int):
parts.append(f"-F {k}={int(v)}")
else:
parts.append(f"-f {k}={shlex.quote(str(v))}")
cmd = " ".join(parts)
out = cls.get_output_with_retries(cmd, verbose=verbose)
if not out:
raise RuntimeError(f"gh api graphql failed (no output) for cmd [{cmd}]")
try:
data = json.loads(out)
except json.JSONDecodeError as e:
raise RuntimeError(f"gh api graphql returned non-JSON output [{out[:200]}]: {e}")
if "errors" in data and data["errors"]:
raise RuntimeError(f"gh api graphql returned errors: {data['errors']}")
return data
@classmethod
def list_pr_review_threads(cls, pr=None, repo=None, verbose=False):
"""Return all review threads on a PR via GraphQL.
Each thread carries its node ``id`` (the value to pass to the
resolve/unresolve mutations), ``isResolved``, ``isOutdated``,
``resolvedBy`` (``{login}`` of the user who most recently
resolved the thread, or ``null`` if never resolved -- consumers
use this to tell apart bot-resolved from author-resolved
threads in stateless CI runs), ``path``, ``line``, and the
full list of comments under it (with ``databaseId`` for use as
``in_reply_to`` when replying, and ``createdAt`` for per-comment
timestamps). Both the thread list and each thread's comments
are paginated, so long PRs do not silently truncate. Raises
``RuntimeError`` on any transport / parse failure: a failure
to read prior discussion must not be confused with "no prior
discussion".
"""
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
owner, name = repo.split("/", 1)
thread_query = (
"query($owner:String!,$name:String!,$pr:Int!,$after:String){"
"repository(owner:$owner,name:$name){"
"pullRequest(number:$pr){"
"reviewThreads(first:100,after:$after){"
"pageInfo{hasNextPage endCursor}"
"nodes{id isResolved isOutdated resolvedBy{login} path line "
"comments(first:50){"
"pageInfo{hasNextPage endCursor}"
"nodes{databaseId createdAt author{login} body path line originalLine}"
"}}}}}}"
)
comments_query = (
"query($id:ID!,$after:String!){"
"node(id:$id){... on PullRequestReviewThread{"
"comments(first:50,after:$after){"
"pageInfo{hasNextPage endCursor}"
"nodes{databaseId createdAt author{login} body path line originalLine}"
"}}}}"
)
threads = []
thread_cursor = None
while True:
variables = {"owner": owner, "name": name, "pr": int(pr)}
if thread_cursor is not None:
variables["after"] = thread_cursor
data = cls._gh_graphql_json(thread_query, variables, verbose=verbose)
page = data["data"]["repository"]["pullRequest"]["reviewThreads"]
for thread in page["nodes"]:
comments = thread["comments"]
while comments["pageInfo"]["hasNextPage"]:
sub = cls._gh_graphql_json(
comments_query,
{"id": thread["id"], "after": comments["pageInfo"]["endCursor"]},
verbose=verbose,
)
next_page = sub["data"]["node"]["comments"]
comments["nodes"].extend(next_page["nodes"])
comments["pageInfo"] = next_page["pageInfo"]
threads.append(thread)
if not page["pageInfo"]["hasNextPage"]:
break
thread_cursor = page["pageInfo"]["endCursor"]
return threads
@classmethod
def _set_review_thread_resolution(cls, thread_id, resolve, verbose=False):
mutation_name = "resolveReviewThread" if resolve else "unresolveReviewThread"
query = (
f"mutation($threadId:ID!){{"
f"{mutation_name}(input:{{threadId:$threadId}}){{thread{{isResolved}}}}"
f"}}"
)
cmd = (
f"gh api graphql "
f"-f query={shlex.quote(query)} "
f"-f threadId={shlex.quote(thread_id)}"
)
return cls.do_command_with_retries(cmd, verbose=verbose)
@classmethod
def resolve_pr_review_thread(cls, thread_id, verbose=False):
"""Mark a PR review thread as resolved (GraphQL ``resolveReviewThread``).
``thread_id`` is the GraphQL node id from
:meth:`list_pr_review_threads`, not the REST comment id.
"""
return cls._set_review_thread_resolution(thread_id, resolve=True, verbose=verbose)
@classmethod
def unresolve_pr_review_thread(cls, thread_id, verbose=False):
"""Re-open a previously resolved PR review thread
(GraphQL ``unresolveReviewThread``)."""
return cls._set_review_thread_resolution(thread_id, resolve=False, verbose=verbose)
'''
TODO: @maxknv
The fact that a comment can get lost is also an issue for other CI automated comments.
I think it makes sense to make this the default behavior for post_updateable_comment() and avoid introducing another method.
'''
@classmethod
def post_fresh_comment(
cls,
tag: str,
body: str,
pr=None,
repo=None,
verbose=True,
):
"""Delete any existing comment with the given tag and post a new one at the bottom.
Unlike post_updateable_comment, this always creates a fresh comment so it
appears as the most recent comment (next to the merge button).
"""
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
TAG_START = f"<!-- CI automatic comment start :{tag}: -->"
TAG_END = f"<!-- CI automatic comment end :{tag}: -->"
# Fetch all comments and delete those carrying our tag.
cmd_list = (
f'gh api -H "Accept: application/vnd.github.v3+json" '
f'"/repos/{repo}/issues/{pr}/comments" '
f"--jq '[.[] | {{id: .id, body: .body}}]' --paginate"
)
output = cls.get_output_with_retries(cmd_list, verbose=verbose)
if output:
try:
for comment in json.loads(output):
if TAG_START in comment["body"] and TAG_END in comment["body"]:
comment_id = comment["id"]
if verbose:
print(f"Deleting old coverage comment [{comment_id}]")
Shell.run(
f'gh api -X DELETE '
f'-H "Accept: application/vnd.github.v3+json" '
f'"/repos/{repo}/issues/comments/{comment_id}"',
verbose=verbose,
)
except Exception as e:
print(f"WARNING: Failed to delete old comment: {e}")
# Post a new comment at the bottom.
full_body = f"{TAG_START}\n{body}\n{TAG_END}\n"
temp_file_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".txt", encoding="utf-8"
) as temp_file:
temp_file.write(full_body)
temp_file_path = temp_file.name
# Pass --repo so gh does not probe git remotes (Docker mounts can hit
# "detected dubious ownership" and fail comment creation).
cmd = f"gh pr comment {pr} --repo {repo} --body-file {temp_file_path}"
return cls.do_command_with_retries(cmd)
finally:
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception:
pass
@classmethod
def post_updateable_comment(
cls,
comment_tags_and_bodies: Dict[str, str],
pr=None,
repo=None,
only_update=False,
verbose=True,
):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
TAG_COMMENT_START = "<!-- CI automatic comment start :{TAG}: -->"
TAG_COMMENT_END = "<!-- CI automatic comment end :{TAG}: -->"
cmd_check_created = f'gh api -H "Accept: application/vnd.github.v3+json" \
"/repos/{repo}/issues/{pr}/comments" \
--jq \'[.[] | {{id: .id, body: .body}}]\' --paginate'
output = cls.get_output_with_retries(cmd_check_created, verbose=verbose)
try:
comments = json.loads(output) if output else []
except json.JSONDecodeError as e:
print(
f"ERROR: Failed to parse gh comments JSON: {e}. Treating as no existing comments."
)
comments = []
comment_to_update = None
id_to_update = None
for tag, body in comment_tags_and_bodies.items():
start_tag = TAG_COMMENT_START.format(TAG=tag)
end_tag = TAG_COMMENT_END.format(TAG=tag)
if not comment_to_update:
for comment in comments:
if start_tag in comment["body"] and end_tag in comment["body"]:
comment_to_update = comment
id_to_update = comment["id"]
if verbose:
print(f"Found comment to update [{id_to_update}]")
break
else:
if (
start_tag not in comment_to_update["body"]
or end_tag not in comment_to_update["body"]
):
print(
f"WARNING: Comment [{id_to_update}] has no tag [{tag}] - will append"
)
body = "" if not comment_to_update else comment_to_update["body"]
for tag, tag_body in comment_tags_and_bodies.items():
start_tag = TAG_COMMENT_START.format(TAG=tag)
end_tag = TAG_COMMENT_END.format(TAG=tag)
if not comment_to_update:
body += f"{start_tag}\n{tag_body}\n{end_tag}\n"
else:
if start_tag in body and end_tag in body:
rex = re.compile(
f"{re.escape(start_tag)}.*{re.escape(end_tag)}", re.DOTALL
)
replacement = f"{start_tag}\n{tag_body}\n{end_tag}"
body, _ = rex.subn(lambda _: replacement, body)
if verbose:
print(
f"Updated existing comment [{id_to_update}] tag [{tag}] with [{tag_body}], new [{body}]"
)
else:
body = body.removesuffix("\n") + "\n"
body += f"{start_tag}\n{tag_body}\n{end_tag}\n"
if verbose:
print(
f"Appended existing comment [{id_to_update}] tag [{tag}] with [{tag_body}], new [{body}]"
)
# Create temp file for body to avoid shell escaping issues
with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".txt", encoding="utf-8"
) as temp_file:
temp_file.write(body)
temp_file_path = temp_file.name
res = None
if id_to_update:
cmd = f'gh api -X PATCH \
-H "Accept: application/vnd.github.v3+json" \
"/repos/{repo}/issues/comments/{id_to_update}" \
-F body=@{temp_file_path}'
if verbose:
print(f"Update existing comments [{id_to_update}]")
res = cls.do_command_with_retries(cmd)
else:
if not only_update:
# Pass --repo so gh does not probe git remotes (Docker mounts can hit
# "detected dubious ownership" and fail comment creation).
cmd = f"gh pr comment {pr} --repo {repo} --body-file {temp_file_path}"
print("Create new comment")
res = cls.do_command_with_retries(cmd)
else:
print(
f"WARNING: comment to update not found, tags [{[k for k in comment_tags_and_bodies.keys()]}]"
)
# Clean up temp file
os.unlink(temp_file_path)
return res
@classmethod
def get_pr_contributors(cls, pr=None, repo=None):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
cmd = f"gh pr view {pr} --repo {repo} --json commits --jq '[.commits[].authors[].login]'"
contributors_str = cls.get_output_with_retries(cmd, verbose=True)
res = []
if contributors_str:
try:
res = json.loads(contributors_str)
except Exception:
print(
f"ERROR: Failed to fetch contributors list for PR [{pr}], repo [{repo}]"
)
traceback.print_exc()
return res
@classmethod
def get_pr_labels(cls, pr=None, repo=None):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
cmd = f"gh pr view {pr} --repo {repo} --json labels --jq '.labels[].name'"
output = cls.get_output_with_retries(cmd, verbose=True)
res = []
if output:
res = output.splitlines()
return list(set(res))
@classmethod
def get_pr_title_body_labels(cls, pr=None, repo=None):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
cmd = f"gh pr view {pr} --json title,body,labels --repo {repo}"
output = cls.get_output_with_retries(cmd, verbose=True)
try:
pr_data = json.loads(output)
title = pr_data["title"]
body = pr_data["body"]
labels = [l["name"] for l in pr_data["labels"]]
except Exception:
print("ERROR: Failed to get PR data")
traceback.print_exc()
Info().store_traceback()
return "", "", []
return title, body, labels
@classmethod
def get_pr_label_assigner(cls, label, pr=None, repo=None):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
cmd = f'gh api repos/{repo}/issues/{pr}/events --jq \'.[] | select(.event=="labeled" and .label.name=="{label}") | .actor.login\''
return cls.get_output_with_retries(cmd, verbose=True)
@classmethod
def get_pr_diff(cls, pr=None, repo=None):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
cmd = f"gh pr diff {pr} --repo {repo}"
return cls.get_output_with_retries(cmd, verbose=True)
@classmethod
def update_pr_body(cls, new_body=None, body_file=None, pr=None, repo=None):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
assert new_body or body_file, "Either new_body or body_file must be provided"
assert not (
new_body and body_file
), "Cannot provide both new_body and body_file"
if body_file:
# Use file for body to avoid shell escaping issues
cmd = f'gh api -X PATCH \
-H "Accept: application/vnd.github.v3+json" \
"/repos/{repo}/pulls/{pr}" \
-F body=@{body_file}'
else:
# Use inline body (original behavior)
escaped_body = new_body.replace("'", "'\"'\"'")
cmd = f'gh api -X PATCH \
-H "Accept: application/vnd.github.v3+json" \
"/repos/{repo}/pulls/{pr}" \
-f body=\'{escaped_body}\''
return cls.do_command_with_retries(cmd)
@classmethod
def post_commit_status(cls, name, status, description, url, sha="", repo=""):
"""
Sets GH commit status
:param name: commit status name
:param status:
:param description:
:param url:
:param sha: commit SHA (defaults to current environment SHA)
:param repo: repository in format owner/repo (defaults to current environment repo)
:return: True or False in case of error
"""
description_max_size = 80 # GH limits to 140, but 80 is reasonable
description = description[:description_max_size]
status = cls.convert_to_gh_status(status)
repo = repo or _Environment.get().REPOSITORY
sha = sha or _Environment.get().SHA
safe_state = shlex.quote(str(status))
safe_target = shlex.quote(str(url))
safe_description = shlex.quote(str(description))
safe_context = shlex.quote(str(name))
command = (
f"gh api -X POST -H 'Accept: application/vnd.github.v3+json' "
f"/repos/{repo}/statuses/{sha} "
f"-f state={safe_state} -f target_url={safe_target} "
f"-f description={safe_description} -f context={safe_context}"
)
return cls.do_command_with_retries(command)
@classmethod
def post_foreign_commit_status(
cls, name, status, description, url, repo, commit_sha
):
"""
Sets GH commit status in foreign repo or commit
:param name: commit status name
:param status:
:param description:
:param url:
:param repo: Foreign repo
:param commit_sha: Commit in a foreign repo
:return: True or False in case of error
"""
description_max_size = 80 # GH limits to 140, but 80 is reasonable
description = description[:description_max_size]
status = cls.convert_to_gh_status(status)
safe_state = shlex.quote(str(status))
safe_target = shlex.quote(str(url))
safe_description = shlex.quote(str(description))
safe_context = shlex.quote(str(name))
command = (
f"gh api -X POST -H 'Accept: application/vnd.github.v3+json' "
f"/repos/{repo}/statuses/{commit_sha} "
f"-f state={safe_state} -f target_url={safe_target} "
f"-f description={safe_description} -f context={safe_context}"
)
return cls.do_command_with_retries(command)
@classmethod
def get_commit_statuses(
cls, sha="", repo=""
) -> Optional[Dict[str, "GH.CommitStatus"]]:
"""
Fetch commit statuses for the given commit SHA and return the latest
status for each context. Returns `None` if the status list cannot be
retrieved or parsed.
"""
repo = repo or _Environment.get().REPOSITORY
sha = sha or _Environment.get().SHA
output = cls.get_output_with_retries(
f"gh api repos/{repo}/commits/{sha}/statuses --paginate"
)
if not output:
print("ERROR: Failed to fetch commit statuses")
return None
try:
statuses_list = json.loads(output)
except json.JSONDecodeError as ex:
print(f"ERROR: Failed to parse commit statuses: {ex}")
return None
status_map: Dict[str, GH.CommitStatus] = {}
for status in statuses_list:
context = status["context"]
if context not in status_map:
status_map[context] = GH.CommitStatus(
state=status["state"],
description=status.get("description", ""),
url=status.get("target_url", ""),
context=context,
)
return status_map
@classmethod
def merge_pr(cls, pr=None, repo=None, squash=False, keep_branch=False):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
extra_args = ""
if not keep_branch:
extra_args += " --delete-branch"
if squash:
extra_args += " --squash"
else:
extra_args += " --merge"
cmd = f"gh pr merge {pr} --repo {repo} {extra_args}"
return cls.do_command_with_retries(cmd)
@classmethod
def pr_has_conflicts(cls, pr=None, repo=None, verbose=False):
if not repo:
repo = _Environment.get().REPOSITORY
if not pr:
pr = _Environment.get().PR_NUMBER
cmd = f"gh pr view {pr} --repo {repo} --json mergeable --jq .mergeable"
output = cls.get_output_with_retries(cmd, verbose=verbose)
return output == "CONFLICTING"
@classmethod
def check_labels_exist(cls, labels: List[str], repo="", verbose=False):
"""
Raise a clear error if any of `labels` does not exist in `repo`.
`gh issue create` rejects the entire request with an opaque
`could not add label: '<name>' not found` message if a label is
unknown, and label sets differ between the public and private
repositories. Surface a precise, actionable error instead.
"""
if not labels:
return
if not repo:
repo = _Environment.get().REPOSITORY
existing_raw = Shell.get_output(
f"gh label list --repo {shlex.quote(repo)} --limit 1000 --json name --jq '.[].name'",
verbose=verbose,
)
existing = {line.strip() for line in (existing_raw or "").splitlines()}
missing = [label for label in labels if label not in existing]
if missing:
raise RuntimeError(
f"Cannot create issue in {repo}: label(s) {missing} do not exist there. "
f"Create them in the repository first (e.g. `gh label create '{missing[0]}' --repo {repo}`)."
)
@classmethod
def create_issue(
cls,
title,
body,
labels: List[str] = None,
repo="",
verbose=False,
no_strict=False,
) -> Optional[str]:
"""
Create a GitHub issue and return its URL.
Returns:
Issue URL string on success, None on failure
"""
if not repo:
repo = _Environment.get().REPOSITORY
if labels is None:
labels = []
# GitHub API limit for issue body is 65536 characters
max_body_length = 65536
if len(body) > max_body_length:
truncation_note = "\n\n... (truncated due to GitHub body size limit)"
body = body[: max_body_length - len(truncation_note)] + truncation_note
# `gh issue create` fails the whole call with an opaque error if any
# label is missing in the target repo (labels differ between the public
# and private repos). Check up front and surface a precise error.
cls.check_labels_exist(labels, repo=repo, verbose=verbose)
temp_file_path = None
try:
# Create temp file for body to avoid shell escaping issues
with tempfile.NamedTemporaryFile(
mode="w", delete=False, suffix=".txt", encoding="utf-8"
) as temp_file:
temp_file.write(body)
temp_file_path = temp_file.name
safe_repo = shlex.quote(repo)
safe_title = shlex.quote(title)
safe_body_file = shlex.quote(temp_file_path)
label_cmd = "".join(f" --label {shlex.quote(label)}" for label in labels)
cmd = (
f"gh issue create --repo {safe_repo} --title {safe_title} "
f"--body-file {safe_body_file}{label_cmd}"
)
issue_url = Shell.get_output_or_raise(cmd, verbose=verbose)
assert issue_url, "Failed to create issue"
return issue_url
except Exception:
if verbose:
print("ERROR: Failed to create issue")
traceback.print_exc()
if not no_strict:
assert False, "Failed to create issue"
finally:
if temp_file_path and os.path.exists(temp_file_path):
os.unlink(temp_file_path)
return None
@classmethod
def get_pr_url_by_branch(cls, branch, repo=None):
"""URL of the PR whose head is `branch`, or '' if there genuinely is none.
On the rerun-safety path (changelog / version-bump PR reuse), a transient
`gh pr list` failure must NOT be mistaken for "no PR exists" — that would
create a duplicate PR or let merge_prs run with an empty URL after the
release is already published. So the lookup is retried and raises on a
persistent failure. A successful `gh pr list --json` always prints at
least `[]`, so an empty result from the retried read means the command
itself failed; genuinely no PR is an empty JSON array.
"""
if not repo:
repo = _Environment.get().REPOSITORY
safe_repo = shlex.quote(repo)
safe_branch = shlex.quote(branch)
for state in ("open", "merged"):
cmd = (
f"gh pr list --repo {safe_repo} --head {safe_branch}"
f" --state {state} --json url"
)
raw = cls.get_output_with_retries(cmd)
if not raw:
raise RuntimeError(
f"gh pr list failed for branch [{branch}] in repo [{repo}] "
f"(state {state}) after retries; refusing to treat a failed "
f"lookup as 'no PR'"
)
prs = json.loads(raw)
if prs:
return prs[0]["url"]
print(f"No {state} PR found for branch [{branch}]")
return ""
_STATUS_TO_GH = {
Result.Status.OK: Result.GHStatus.SUCCESS,
Result.Status.FAIL: Result.GHStatus.FAILURE,
Result.Status.ERROR: Result.GHStatus.ERROR,
Result.Status.SKIPPED: Result.GHStatus.SUCCESS,
Result.Status.PENDING: Result.GHStatus.PENDING,
Result.Status.RUNNING: Result.GHStatus.PENDING,
Result.Status.DROPPED: Result.GHStatus.ERROR,
Result.Status.UNKNOWN: Result.GHStatus.FAILURE,
Result.Status.XFAIL: Result.GHStatus.SUCCESS,
Result.Status.XPASS: Result.GHStatus.FAILURE,
}
@classmethod
def convert_to_gh_status(cls, status):
"""Map Result.Status value to GitHub commit status API string."""
gh = cls._STATUS_TO_GH.get(status)
if gh is not None:
return gh
# Already a GH status string — pass through for idempotency
_GH_VALUES = set(cls._STATUS_TO_GH.values())
assert status in _GH_VALUES, f"Invalid status [{status}] for GH commit status"
return status
@classmethod
def print_log_in_group(cls, group_name: str, lines: Union[str, List[str]]):
if not isinstance(lines, (list, tuple, set)):
lines = [lines]
print(f"::group::{group_name}")
for line in lines:
print(line)
print("::endgroup::")
@classmethod
def print_actions_debug_info(cls):
cls.print_log_in_group("GITHUB_ENVS", Shell.get_output("env | grep ^GITHUB_"))
cls.print_log_in_group(
"GITHUB_EVENT", Shell.get_output("cat $GITHUB_EVENT_PATH")
)
@dataclasses.dataclass
class ResultSummaryForGH:
name: str
status: Result.Status
sha: str = ""
start_time: Optional[float] = None
duration: Optional[float] = None
failed_results: List["ResultSummaryForGH"] = dataclasses.field( # noqa: F821 # self-referential nested dataclass
default_factory=list
)
info: str = ""
comment: str = ""
extra_links: List[tuple] = dataclasses.field(default_factory=list)
@classmethod
def from_result(cls, result: Result, sha=""):
MAX_TEST_CASES_PER_JOB = 10
MAX_JOBS_PER_SUMMARY = 10
def flatten_results(results):
for r in results:
if not r.results:
yield r
else:
yield from flatten_results(r.results)
def extract_label_links_md(res: Result) -> str:
"""Render labels with links as markdown ``[name](link)`` chips.
Reads the unified ``ext['labels']`` and falls back to legacy
``ext['hlabels']`` for results stored before the unification.
"""
try:
if not (hasattr(res, "ext") and isinstance(res.ext, dict)):
return ""
links = []
for item in res.ext.get("labels", []) or []:
if isinstance(item, dict) and item.get("name") and item.get("link"):
links.append(f"[{item['name']}]({item['link']})")
for item in res.ext.get("hlabels", []) or []:
if isinstance(item, (list, tuple)) and len(item) >= 2:
text, href = item[0], item[1]
if text and href:
links.append(f"[{text}]({href})")
return ", ".join(links)
except Exception:
return ""
def has_label_links(res: Result) -> bool:
if not (hasattr(res, "ext") and isinstance(res.ext, dict)):
return False
if any(
isinstance(it, dict) and it.get("link")
for it in res.ext.get("labels", []) or []
):
return True
return bool(res.ext.get("hlabels"))
summary = cls(
name=result.name,
status=result.status,
sha=sha or Info().sha,
start_time=result.start_time,
duration=result.duration,
failed_results=[],
info=extract_label_links_md(result),
comment=result.ext.get("comment", ""),
)
# Filter and sort failed/error subresults by priority
# Priority: FAIL (0) > ERROR (1) > others (2)
def get_status_priority(r):
if r.status == Result.Status.FAIL:
return 0
elif r.status == Result.Status.ERROR:
return 1
else:
return 2
subresults = [
r for r in result.results if (r.is_completed() and not r.is_ok())
]
subresults = sorted(subresults, key=get_status_priority)
for sub_result in subresults:
failed_result = cls(
name=sub_result.name,
status=sub_result.status,
info=extract_label_links_md(sub_result),
comment=sub_result.ext.get("comment", ""),
)
failed_result.failed_results = [
cls(
name=r.name,
status=r.status,
info=extract_label_links_md(r),
comment=r.ext.get("comment", ""),
)
for r in flatten_results(sub_result.results)
if r.is_completed() and not r.is_ok()
]
if len(failed_result.failed_results) > MAX_TEST_CASES_PER_JOB:
remaining = (
len(failed_result.failed_results) - MAX_TEST_CASES_PER_JOB
)
note = f"{remaining} more test cases not shown"
failed_result.failed_results = failed_result.failed_results[
:MAX_TEST_CASES_PER_JOB
]
failed_result.failed_results.append(cls(name=note, status=""))
summary.failed_results.append(failed_result)
if len(summary.failed_results) > MAX_JOBS_PER_SUMMARY:
remaining = len(summary.failed_results) - MAX_JOBS_PER_SUMMARY
summary.failed_results = summary.failed_results[:MAX_JOBS_PER_SUMMARY]
print(f"NOTE: {remaining} more jobs not shown in PR comment")
# Collect links from jobs that have labels with links (e.g. keeper-stress Grafana
# links, perf-comparison combined dashboard). Include regardless of success/failure
# so links always appear when the job runs.
#
# Dedup key = the URL set (sorted tuple of label links), not the rendered markdown.
# We group by *destination*: jobs that point at the same set of URLs are collapsed
# into one PR-comment row regardless of label text or ordering. This is what makes
# the 6 (or 12) `Performance Comparison` shards collapse to a single row, while
# `keeper-stress` jobs — whose `Grafana: Run details` URL embeds a per-run time
# window — stay distinct because their URL sets differ.
#
# The collapsed row's label is the longest common prefix of the job names trimmed
# at a natural break (`(` or `,`), so `Performance Comparison (arm_release,
# master_head, 1/6)` ... `6/6` becomes `Performance Comparison`.
def _shared_job_label(names):
if len(names) == 1:
return names[0]
common = os.path.commonprefix(names)
for sep in (" (", ", ", "("):
idx = common.rfind(sep)
if idx > 0:
common = common[:idx]
break
common = common.rstrip(" ,(-")
return common or f"{names[0]} (+{len(names) - 1} more)"
def _url_key(res):
"""Sorted tuple of label link URLs (the dedup key)."""
urls = []
for item in (getattr(res, "ext", {}) or {}).get("labels", []) or []:
if isinstance(item, dict) and item.get("link"):
urls.append(item["link"])
for item in (getattr(res, "ext", {}) or {}).get("hlabels", []) or []:
if isinstance(item, (list, tuple)) and len(item) >= 2 and item[1]:
urls.append(item[1])
return tuple(sorted(urls))
groups = {}
group_order = []
for job_result in getattr(result, "results", []) or []:
if not has_label_links(job_result):
continue
links_md = extract_label_links_md(job_result)
if not links_md:
continue
key = _url_key(job_result)
if key not in groups:
groups[key] = {"names": [], "links_md": links_md}
group_order.append(key)
groups[key]["names"].append(job_result.name)
for key in group_order:
group = groups[key]
summary.extra_links.append(
(_shared_job_label(group["names"]), group["links_md"])
)
return summary
def to_markdown(self, pr_number=0, sha="", workflow_name="", branch=""):
def escape_pipes(text):
"""Escape special markdown characters for table cells"""
return str(text).replace("|", "\\|").replace("#", "\\#")
if self.status == Result.Status.OK:
symbol = "✅" # Green check mark
elif self.status == Result.Status.FAIL:
symbol = "❌" # Red cross mark
else:
symbol = "⏳" # Hourglass (in progress)
body = f"**Summary:** {symbol}\n"
# Render each extra_links group as a plain bullet under the Summary
# line. Keeping the (un-bolded) job-name prefix tells the reader
# which job(s) the labels came from without adding extra weight.
for name, links_md in self.extra_links:
body += f"- {name}: {links_md}\n"
if self.failed_results:
# Blank line so the failure section isn't parsed as continuation
# of the Summary paragraph or the preceding bullet list.
body += "\n"
if len(self.failed_results) > 15:
# Unindented + terminated with a blank line, otherwise the
# 4-space indent turns this into an indented code block that
# swallows the table that follows.
body += f"*15 failures out of {len(self.failed_results)} shown*:\n\n"
self.failed_results = self.failed_results[:15]
body += "|job_name|test_name|status|info|comment|\n"
body += "|:--|:--|:-:|:--|:--|\n"
if not ((pr_number or branch) and sha and workflow_name):
info = Info()
pr_number = info.pr_number
sha = info.sha
workflow_name = info.workflow_name
branch = info.git_branch
for failed_result in self.failed_results:
job_report_url = Info.get_specific_report_url_static(
pr_number,
branch,
sha,
failed_result.name,
workflow_name,
)
body += "|[{}]({})|{}|{}|{}|{}|\n".format(
failed_result.name,
job_report_url,
"",
failed_result.status,
failed_result.info or "",
failed_result.comment or "",
)
if failed_result.failed_results:
for sub_failed_result in failed_result.failed_results:
body += "|{}|{}|{}|{}|{}|\n".format(
"",
escape_pipes(sub_failed_result.name),
sub_failed_result.status,
escape_pipes(sub_failed_result.info or ""),
escape_pipes(sub_failed_result.comment or ""),
)
return body
if __name__ == "__main__":
import argparse
import sys
parser = argparse.ArgumentParser(description="GitHub PR comment helper")
subparsers = parser.add_subparsers(dest="command")
post_parser = subparsers.add_parser(
"post-or-update",
help="Post a new PR comment or update an existing one with the given tag",
)
post_parser.add_argument(
"--tag",
required=True,
help="Tag identifying the comment section (e.g. 'review')",
)
post_parser.add_argument(
"--file",
required=True,
dest="body_file",
help="Path to file containing the comment body",
)
post_parser.add_argument("--pr", type=int, default=None, help="PR number")
post_parser.add_argument(
"--repo", default=None, help="Repository in owner/repo format"
)
post_parser.add_argument(
"--only-update",
action="store_true",
help="Only update an existing comment; do not create a new one",
)
line_parser = subparsers.add_parser(
"post-pr-line-comment",
help="Post an inline review comment on a specific line of a PR diff",
)
line_parser.add_argument(
"--file",
required=True,
dest="body_file",
help="Path to file containing the comment body (read via -F body=@<file>)",
)
line_parser.add_argument(
"--commit",
default=None,
help="Commit SHA the comment refers to (required unless --reply-to is set)",
)
line_parser.add_argument(
"--path",
default=None,
help="Path of the file to comment on (required unless --reply-to is set)",
)
line_parser.add_argument(
"--line",
type=int,
default=None,
help="Line number in the PR diff (required unless --reply-to is set)",
)
line_parser.add_argument(
"--side", default="RIGHT", choices=["RIGHT", "LEFT"], help="Diff side"
)
line_parser.add_argument(
"--reply-to",
type=int,
default=None,
dest="reply_to",
help="databaseId of the parent comment to reply to. "
"When set, the comment is posted as a reply on the existing thread "
"and --commit/--path/--line are ignored.",
)
line_parser.add_argument("--pr", type=int, default=None, help="PR number")
line_parser.add_argument(
"--repo", default=None, help="Repository in owner/repo format"
)
review_parser = subparsers.add_parser(
"post-pr-review",
help="Post all inline findings as a single batched PR review",
)
review_parser.add_argument(
"--comments-file",
required=True,
dest="comments_file",
help="Path to a JSON file with a list of "
'{"path", "line", "side", "start_line"?, "start_side"?, "body_file"} '
"objects; each body_file holds that comment's Markdown body",
)
review_parser.add_argument(
"--body-file",
default=None,
dest="body_file",
help="Optional file with the top-level review body",
)
review_parser.add_argument(
"--commit",
default=None,
help="Commit SHA the review refers to",
)
review_parser.add_argument("--pr", type=int, default=None, help="PR number")
review_parser.add_argument(
"--repo", default=None, help="Repository in owner/repo format"
)
threads_parser = subparsers.add_parser(
"list-pr-review-threads",
help="List PR review threads via GraphQL (prints JSON to stdout)",
)
threads_parser.add_argument("--pr", type=int, default=None, help="PR number")
threads_parser.add_argument(
"--repo", default=None, help="Repository in owner/repo format"
)
resolve_parser = subparsers.add_parser(
"resolve-pr-review-thread",
help="Resolve a PR review thread via GraphQL",
)
resolve_parser.add_argument(
"--thread-id",
required=True,
dest="thread_id",
help="GraphQL node id of the review thread (from list-pr-review-threads)",
)
unresolve_parser = subparsers.add_parser(
"unresolve-pr-review-thread",
help="Re-open (unresolve) a PR review thread via GraphQL",
)
unresolve_parser.add_argument(
"--thread-id",
required=True,
dest="thread_id",
help="GraphQL node id of the review thread (from list-pr-review-threads)",
)
args = parser.parse_args()
if args.command == "post-or-update":
with open(args.body_file, "r", encoding="utf-8") as f:
body = f.read()
kwargs = dict(
comment_tags_and_bodies={args.tag: body},
only_update=args.only_update,
)
if args.pr is not None:
kwargs["pr"] = args.pr
if args.repo is not None:
kwargs["repo"] = args.repo
ok = GH.post_updateable_comment(**kwargs)
sys.exit(0 if ok else 1)
elif args.command == "post-pr-line-comment":
kwargs = dict(
body_file=args.body_file,
commit_id=args.commit,
path=args.path,
line=args.line,
side=args.side,
in_reply_to=args.reply_to,
)
if args.pr is not None:
kwargs["pr"] = args.pr
if args.repo is not None:
kwargs["repo"] = args.repo
ok = GH.post_pr_line_comment(**kwargs)
sys.exit(0 if ok else 1)
elif args.command == "post-pr-review":
with open(args.comments_file, "r", encoding="utf-8") as f:
comments = json.load(f)
body = ""
if args.body_file is not None:
with open(args.body_file, "r", encoding="utf-8") as f:
body = f.read()
kwargs = dict(
commit_id=args.commit,
comments=comments,
body=body,
)
if args.pr is not None:
kwargs["pr"] = args.pr
if args.repo is not None:
kwargs["repo"] = args.repo
ok = GH.post_pr_review(**kwargs)
sys.exit(0 if ok else 1)
elif args.command == "list-pr-review-threads":
kwargs = {}
if args.pr is not None:
kwargs["pr"] = args.pr
if args.repo is not None:
kwargs["repo"] = args.repo
threads = GH.list_pr_review_threads(**kwargs)
print(json.dumps(threads, indent=2))
sys.exit(0)
elif args.command == "resolve-pr-review-thread":
ok = GH.resolve_pr_review_thread(args.thread_id)
sys.exit(0 if ok else 1)
elif args.command == "unresolve-pr-review-thread":
ok = GH.unresolve_pr_review_thread(args.thread_id)
sys.exit(0 if ok else 1)
else:
parser.print_help()
sys.exit(1)