scripts/fetch_review_context.py
#!/usr/bin/env python3
"""Fetch complete pull-request review context through the GitHub CLI."""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit
PR_PATH = re.compile(r"^/([^/]+)/([^/]+)/pull/([1-9]\d*)(?:/.*)?$")
@dataclass(frozen=True)
class PullRequestTarget:
host: str
owner: str
repo: str
number: int
CONVERSATION_QUERY = """\
query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
number url title state baseRefName headRefName
comments(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id url body createdAt updatedAt author { login } }
}
}
}
}
"""
REVIEWS_QUERY = """\
query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
number url title state baseRefName headRefName
reviews(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id url state body submittedAt author { login } }
}
}
}
}
"""
THREADS_QUERY = """\
query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
number url title state baseRefName headRefName
reviewThreads(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id isResolved isOutdated path line diffSide startLine startDiffSide
originalLine originalStartLine resolvedBy { login }
comments(first: 100) {
pageInfo { hasNextPage endCursor }
nodes {
id url body diffHunk createdAt updatedAt author { login }
}
}
}
}
}
}
}
"""
THREAD_COMMENTS_QUERY = """\
query($thread_id: ID!, $cursor: String!) {
node(id: $thread_id) {
... on PullRequestReviewThread {
comments(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id url body diffHunk createdAt updatedAt author { login }
}
}
}
}
}
"""
def run(command: list[str], stdin: str | None = None) -> str:
completed = subprocess.run(command, input=stdin, capture_output=True, text=True)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(f"Command failed: {' '.join(command)}\n{detail}")
return completed.stdout
def run_json(command: list[str], stdin: str | None = None) -> dict[str, Any]:
output = run(command, stdin=stdin)
try:
payload = json.loads(output)
except json.JSONDecodeError as error:
raise RuntimeError(f"GitHub CLI returned invalid JSON: {error}") from error
if not isinstance(payload, dict):
raise RuntimeError("GitHub CLI returned an unexpected JSON value")
return payload
def ensure_authenticated(host: str) -> None:
try:
run(["gh", "auth", "status", "--active", "--hostname", host])
except (FileNotFoundError, RuntimeError) as error:
raise RuntimeError(
f"Could not verify the active GitHub account on {host}: {error}\n"
f"If authentication needs repair, run `gh auth login --hostname {host}`."
) from error
def parse_pr_url(url: str) -> PullRequestTarget:
parsed = urlsplit(url)
match = PR_PATH.fullmatch(parsed.path)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or not match:
raise ValueError(f"Unsupported pull-request URL: {url}")
owner, repo, number = match.groups()
return PullRequestTarget(parsed.netloc.lower(), owner, repo, int(number))
def parse_repository(value: str, default_host: str | None = None) -> tuple[str, str, str]:
parts = value.split("/")
if len(parts) == 2 and all(parts):
return default_host or os.environ.get("GH_HOST") or "github.com", *parts
if len(parts) == 3 and all(parts):
return parts[0].lower(), parts[1], parts[2]
raise ValueError("--repo must be [HOST/]OWNER/REPO")
def resolve_pr(repo: str | None, pr: str | None) -> PullRequestTarget:
if pr and "://" in pr:
target = parse_pr_url(pr)
if repo and tuple(part.lower() for part in parse_repository(repo, target.host)) != (
target.host, target.owner.lower(), target.repo.lower()
):
raise ValueError("--repo does not match the repository in --pr")
return target
if pr:
if not pr.isdigit() or int(pr) < 1:
raise ValueError("--pr must be a pull-request number or GitHub pull-request URL")
if not repo:
repository = run_json(["gh", "repo", "view", "--json", "url"])
return parse_pr_url(f"{str(repository['url']).rstrip('/')}/pull/{pr}")
host, owner, name = parse_repository(repo)
return PullRequestTarget(host, owner, name, int(pr))
if repo:
raise ValueError("--repo requires --pr")
current = run_json(["gh", "pr", "view", "--json", "number,url"])
return parse_pr_url(str(current["url"]))
def graphql(host: str, query: str, **variables: str | int | None) -> dict[str, Any]:
command = [
"gh",
"api",
"graphql",
"--hostname",
host,
"-F",
"query=@-",
]
for name, value in variables.items():
if value is not None:
command.extend(["-F", f"{name}={value}"])
payload = run_json(command, stdin=query)
if payload.get("errors"):
raise RuntimeError(f"GitHub GraphQL errors: {json.dumps(payload['errors'])}")
return payload
def next_cursor(connection: dict[str, Any]) -> str | None:
page_info = connection["pageInfo"]
if not page_info["hasNextPage"]:
return None
cursor = page_info["endCursor"]
if not cursor:
raise RuntimeError("GitHub reported more results without a pagination cursor")
return str(cursor)
def fetch_connection(
query: str, connection_name: str, target: PullRequestTarget
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
cursor: str | None = None
metadata: dict[str, Any] | None = None
nodes: list[dict[str, Any]] = []
while True:
payload = graphql(
target.host, query,
owner=target.owner, repo=target.repo, number=target.number, cursor=cursor,
)
repository = payload.get("data", {}).get("repository")
pull_request = repository and repository.get("pullRequest")
if not pull_request:
raise RuntimeError(f"Pull request {target.host}/{target.owner}/{target.repo}#{target.number} was not found")
if metadata is None:
metadata = {
"host": target.host,
"owner": target.owner,
"repo": target.repo,
"number": pull_request["number"],
"url": pull_request["url"],
"title": pull_request["title"],
"state": pull_request["state"],
"base_ref": pull_request["baseRefName"],
"head_ref": pull_request["headRefName"],
}
connection = pull_request[connection_name]
nodes.extend(connection.get("nodes") or [])
cursor = next_cursor(connection)
if cursor is None:
break
assert metadata is not None
return metadata, nodes
def complete_thread_comments(host: str, thread: dict[str, Any]) -> None:
comments = thread["comments"]
cursor = next_cursor(comments)
while cursor is not None:
payload = graphql(host, THREAD_COMMENTS_QUERY, thread_id=thread["id"], cursor=cursor)
node = payload.get("data", {}).get("node")
if not node or "comments" not in node:
raise RuntimeError(f"Could not fetch the remaining comments for thread {thread['id']}")
page = node["comments"]
comments["nodes"].extend(page["nodes"])
comments["pageInfo"] = page["pageInfo"]
following_cursor = next_cursor(page)
if following_cursor == cursor:
raise RuntimeError(f"Comment pagination did not advance for thread {thread['id']}")
cursor = following_cursor
def fetch_all(target: PullRequestTarget) -> dict[str, Any]:
metadata, conversation_comments = fetch_connection(
CONVERSATION_QUERY, "comments", target
)
_, reviews = fetch_connection(REVIEWS_QUERY, "reviews", target)
_, review_threads = fetch_connection(THREADS_QUERY, "reviewThreads", target)
for thread in review_threads:
complete_thread_comments(target.host, thread)
return {
"pull_request": metadata,
"conversation_comments": conversation_comments,
"reviews": reviews,
"review_threads": review_threads,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", help="Repository in [HOST/]OWNER/REPO form")
parser.add_argument("--pr", help="Pull-request number or URL; defaults to the current branch PR")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
target = resolve_pr(args.repo, args.pr)
ensure_authenticated(target.host)
print(json.dumps(fetch_all(target), indent=2))
except (FileNotFoundError, KeyError, ValueError, RuntimeError) as error:
print(str(error), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
SKILL.md
---
name: address-review-feedback
description: Evaluate pull-request review feedback, implement justified fixes, and reply to and resolve addressed threads.
---
# Address Review Feedback
Work through PR feedback using independent judgment. Evaluate claims against the user's latest decisions, accepted PR requirements, and repository guidance. A reviewer's confidence, repetition, bot identity, or supplied patch is not evidence that a change is needed or correct.
## Scope and authorization
An audit-only request stays read-only: report assessments and proposed fixes. A request to fix feedback authorizes justified, in-scope local changes. A request to **address and resolve PR feedback** also authorizes the necessary commits, pushes, thread replies, and resolutions. Reuse prior authorization without asking again per thread; honor requests to review proposals before implementation. Ask only for missing decisions or operations outside the authorized scope, and continue independent work while waiting.
Do not force-push, merge, submit a review, change PR metadata, or create follow-up issues under this workflow unless explicitly requested. Preserve unrelated work and the existing branch; explain and obtain permission before creating or switching branches unless already explicitly authorized.
## Establish the PR
- Resolve the exact PR and GitHub host from its URL, repository and number, or current branch. Before editing, verify the checkout represents its head repository, branch, and current commit.
- Read thread-aware context through an available connector or `python "<skill-path>/scripts/fetch_review_context.py"`. Pass `--pr URL` or `--repo HOST/OWNER/REPO --pr NUMBER`; omit both for the current branch PR. Unqualified `OWNER/REPO` uses `GH_HOST`, otherwise `github.com`.
- Retain the host for all operations. The helper checks `gh auth status --active --hostname HOST`; diagnose only the target account's failure and request login only when authentication needs repair.
- Cover every unresolved review thread, including outdated ones. Consult resolved threads, review summaries, and general PR comments when they contain relevant context or separate actionable feedback. Track each source so none is silently skipped.
## Work through the feedback
For each thread:
1. **Validate the concern.** Read the entire conversation and inspect the current code, PR diff, requirements, repository guidance, and relevant callers or tests. Identify the actual failure, contract violation, or concrete maintenance cost. Separate established facts from assumptions; missing evidence means uncertainty, not automatic acceptance or dismissal.
2. **Judge the remedy independently.** Check whether the suggestion addresses the cause, preserves intended behavior, fits the codebase, and earns its complexity. A valid concern can have a poor remedy: choose a better fix. Optional preferences do not become requirements merely because a reviewer proposed them. Decline unnecessary or counterproductive changes with concrete reasoning.
3. **Act on the assessment.** For a justified, in-scope fix, briefly explain the proposed implementation and why it fits, then implement when authorized. Verify the changed behavior and recheck the resulting diff; reuse still-valid verification and add tests only for realistic regressions. For invalid, already-addressed, or unjustified optional suggestions, make no code change. Leave uncertain, incomplete, failed-verification, or undecided scope questions open and explain what is needed.
4. **Reply and resolve when authorized.** For a fix, commit only the intended changes and push to the PR's actual head without force. Confirm GitHub contains the verified commit before claiming the fix is published. Reply in the original thread with the outcome, concise evidence or reasoning, and the commit and verification for a fix. For declined or already-addressed feedback, explain why no change is needed. Resolve only after the reply succeeds and every concern in that thread has a supported disposition; unresolved work or an unapproved deferral keeps it open.
Related concerns may share an implementation, commit, or verification run, but reply to each original thread. General PR comments have no thread-resolution state; respond where appropriate without claiming to resolve them.
Before a reply or resolution, refresh the thread and relevant PR head to catch intervening changes. Preserve others' work, skip already-completed operations, and read back uncertain outcomes before retrying to avoid duplicate replies. Verify each reply and resolution; report publication failures without claiming completion.
Finish with a short summary of fixes, declined or already-addressed feedback, and remaining open threads with their blockers. Link the PR and relevant commits; disclose missing coverage. Do not create a large classification report unless requested.