diff --git a/claude/skills/respond-to-pr/SKILL.md b/claude/skills/respond-to-pr/SKILL.md new file mode 100644 index 0000000..3a96189 --- /dev/null +++ b/claude/skills/respond-to-pr/SKILL.md @@ -0,0 +1,70 @@ +--- +name: respond-to-pr +description: Read the reviews and comments on a GitHub PR and decide how to respond. Invoked as `/respond-to-pr 106`, `/respond-to-pr `, or `/respond-to-pr` (no arg, uses the PR for the current branch). Gathers everything via one helper script, evaluates each review point, fixes obvious bugs/security issues, and asks the user for the nuanced calls. May reply to bot comments; only replies to humans after the user confirms. +--- + +You are responding to the reviews and comments on a GitHub pull request. Look +at what the reviewers said, decide what actually needs to change, make the +changes, and respond appropriately. + +## Step 1: Gather the PR + +The argument is the PR number, a PR URL, or nothing: + +- **A number** (`106`) — pass it as-is. +- **A URL** (`https://github.com/owner/repo/pull/106`) — pass the whole URL. +- **Nothing** — pass nothing; the script resolves the PR for the current branch. + +Run the helper **once**: + +```sh +python3 ~/.claude/skills/respond-to-pr/gather_pr.py +``` + +The script makes every `gh` call needed — PR title, description, all reviews, +per-line review comments, and conversation comments, formatted as XML. **Work +solely from its output. Do not make additional `gh` calls unless absolutely +necessary** — the script already gives you everything. + +If it prints ``, **stop and ask the user** which PR to work on. Do +not guess. + +## Step 2: Read the output + +The XML marks every author: + +- `is_me="true"` — written by the user's own gh account (the user, or you on + their behalf earlier). This is not a reviewer asking you for something; it is + what was already written. Do not "reply" to it. +- `is_bot="true"` — a bot account (review bots, CI). You **may** respond to and + act on these directly. +- `is_human="true"` — a human other than the user. **Do not respond to these** + until the user confirms (see Step 4). + +Every ``, ``, and `` has an `id="..."` — use it +if the user asks you to reply to a specific comment. + +## Step 3: Evaluate, don't obey + +You do **not** have to do everything the reviewers say. Evaluate each point and +decide whether it actually needs to be addressed: + +- **Obvious bugs and security vulnerabilities — fix them.** No need to ask. +- **Anything nuanced** (design trade-offs, style preferences, debatable + refactors, anything where reasonable people disagree, or where you are + unsure) — **ask the user to make the final call** before acting. Lay out the + reviewer's point and your read on it, and let the user decide. + +Check out the PR branch before making changes. When you make changes, commit +and push to the **PR branch** (never a different branch), per the user's global +review-response instructions. + +## Step 4: Responding to comments + +- **Bot comments** — you may reply directly (e.g. `gh pr comment`, or a reply + to a review comment via the GitHub API using its `id`) and act on them. +- **Human comments** — draft your intended response, **show it to the user, and + ask for confirmation**. Only post a reply to a human after the user okays it. + +When the user asks you to reply to a specific comment, use its `id` from the XML +to target it. diff --git a/claude/skills/respond-to-pr/gather_pr.py b/claude/skills/respond-to-pr/gather_pr.py new file mode 100755 index 0000000..01f9567 --- /dev/null +++ b/claude/skills/respond-to-pr/gather_pr.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Gather a GitHub PR's metadata, reviews, and comments into one XML report. + +This is the data-gathering half of the respond-to-pr skill. The agent runs this +script ONCE and works solely from its output. The script makes every `gh` call +needed, so the agent should not make additional `gh` calls unless absolutely +necessary. + +Usage: + gather_pr.py 106 # by PR number (uses current repo) + gather_pr.py https://github.com/o/r/pull/106 # by URL + gather_pr.py # no arg -> PR for the current branch + +If no PR can be resolved (e.g. nothing given and the current branch has no PR), +the script prints a marker and exits non-zero so the agent knows +to stop and ask the user what to do. + +The XML output marks, for every author: + is_me="true" the comment was written by the authenticated gh user (you, + via the user's gh access). Do NOT reply to these as if they + were a reviewer; this is what the user (or you on their behalf) + already wrote. + is_bot="true" the author is a bot account. The agent MAY respond to bot + comments directly. + is_human="true" a human other than the authenticated user. The agent must + NOT respond to these without the user's explicit confirmation. + +Every review and comment carries an id="..." so the agent can reply to a +specific comment if the user asks. +""" + +import json +import re +import subprocess +import sys +from xml.sax.saxutils import escape + + +def gh(args, check=True): + """Run a gh command, return (returncode, stdout, stderr).""" + proc = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + ) + if check and proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or f"gh {' '.join(args)} failed") + return proc.returncode, proc.stdout, proc.stderr + + +def gh_json(args): + _, out, _ = gh(args) + return json.loads(out) if out.strip() else None + + +def gh_api_paginated(path): + """gh api with --paginate, returns a flat list.""" + _, out, _ = gh(["api", "--paginate", path]) + # --paginate concatenates JSON arrays as separate documents on separate + # lines only when using --slurp; here gh merges into one array for array + # endpoints, so a single json.loads works. + out = out.strip() + if not out: + return [] + try: + data = json.loads(out) + except json.JSONDecodeError: + # Multiple JSON arrays back to back; merge them. + data = [] + decoder = json.JSONDecoder() + idx = 0 + while idx < len(out): + while idx < len(out) and out[idx].isspace(): + idx += 1 + if idx >= len(out): + break + obj, end = decoder.raw_decode(out, idx) + if isinstance(obj, list): + data.extend(obj) + else: + data.append(obj) + idx = end + return data + + +def resolve_pr(arg): + """Resolve the PR. Returns dict with number, owner, repo, and base JSON, + or None if no PR could be found.""" + fields = "number,title,body,url,author,state,isDraft,headRefName,baseRefName" + view_args = ["pr", "view"] + if arg: + view_args.append(arg) + view_args += ["--json", fields] + code, out, err = gh(view_args, check=False) + if code != 0: + return None + data = json.loads(out) + # Parse owner/repo from the canonical PR url: github.com/{owner}/{repo}/pull/N + m = re.search(r"github\.com/([^/]+)/([^/]+)/pull/(\d+)", data.get("url", "")) + if not m: + return None + data["owner"], data["repo"] = m.group(1), m.group(2) + data["number"] = int(m.group(3)) + return data + + +def get_me(): + try: + _, out, _ = gh(["api", "user", "--jq", ".login"]) + return out.strip() + except RuntimeError: + return None + + +def user_attrs(user, me): + """Build is_me / is_bot / is_human attribute string from a GitHub user obj.""" + if not user: + return 'login="unknown"' + login = user.get("login", "unknown") + utype = user.get("type", "") + is_bot = utype == "Bot" or login.endswith("[bot]") + is_me = me is not None and login == me + parts = [f'login="{escape(login, {chr(34): """})}"'] + if is_me: + parts.append('is_me="true"') + elif is_bot: + parts.append('is_bot="true"') + else: + parts.append('is_human="true"') + return " ".join(parts) + + +def author_attrs_from_gh_view(author, me): + """gh pr view's author obj uses `login` and `is_bot`.""" + login = (author or {}).get("login", "unknown") + is_bot = (author or {}).get("is_bot", False) + is_me = me is not None and login == me + parts = [f'login="{escape(login, {chr(34): """})}"'] + if is_me: + parts.append('is_me="true"') + elif is_bot: + parts.append('is_bot="true"') + else: + parts.append('is_human="true"') + return " ".join(parts) + + +def cdata(text): + text = text or "" + # ]]> cannot appear inside a CDATA section; split it if present. + text = text.replace("]]>", "]]]]>") + return f"" + + +def main(): + arg = sys.argv[1] if len(sys.argv) > 1 else "" + + pr = resolve_pr(arg) + if pr is None: + print("") + if arg: + print(f" Could not resolve a PR from argument: {arg}") + else: + print(" No PR found for the current branch, and no PR number/URL was given.") + print("") + sys.exit(1) + + owner, repo, number = pr["owner"], pr["repo"], pr["number"] + me = get_me() + + # Reviews (the review summaries + state): pulls/{n}/reviews + reviews = gh_api_paginated(f"repos/{owner}/{repo}/pulls/{number}/reviews") + # Per-line review comments: pulls/{n}/comments + line_comments = gh_api_paginated(f"repos/{owner}/{repo}/pulls/{number}/comments") + # General conversation comments: issues/{n}/comments + issue_comments = gh_api_paginated(f"repos/{owner}/{repo}/issues/{number}/comments") + + # Group line comments by the review they belong to. + by_review = {} + orphan_line_comments = [] + for c in line_comments: + rid = c.get("pull_request_review_id") + if rid: + by_review.setdefault(rid, []).append(c) + else: + orphan_line_comments.append(c) + + out = [] + out.append( + f'' + ) + out.append(f" {cdata(pr.get('title'))}") + out.append(f" ") + out.append(f" {cdata(pr.get('body'))}") + + out.append(" ") + if not reviews: + out.append(" ") + for r in reviews: + rid = r.get("id") + state = r.get("state", "") + submitted = r.get("submitted_at", "") or "" + body = r.get("body") or "" + kids = by_review.get(rid, []) + # Skip empty pending/commented reviews that carry no body and no comments. + if not body.strip() and not kids and state in ("COMMENTED", "PENDING"): + continue + out.append( + f' ' + ) + if body.strip(): + out.append(f" {cdata(body)}") + for c in kids: + out.append(_render_line_comment(c, me, indent=" ")) + out.append(" ") + out.append(" ") + + if orphan_line_comments: + out.append(" ") + for c in orphan_line_comments: + out.append(_render_line_comment(c, me, indent=" ")) + out.append(" ") + + out.append(" ") + if not issue_comments: + out.append(" ") + for c in issue_comments: + out.append( + f' ' + ) + out.append(f" {cdata(c.get('body'))}") + out.append(" ") + out.append(" ") + + out.append("") + print("\n".join(out)) + + +def _render_line_comment(c, me, indent): + attrs = [ + f'id="{c.get("id")}"', + user_attrs(c.get("user"), me), + f'path="{escape(c.get("path", "") or "")}"', + ] + line = c.get("line") or c.get("original_line") + if line is not None: + attrs.append(f'line="{line}"') + if c.get("in_reply_to_id"): + attrs.append(f'in_reply_to="{c["in_reply_to_id"]}"') + parts = [f"{indent}"] + diff = (c.get("diff_hunk") or "").strip() + if diff: + parts.append(f"{indent} {cdata(diff)}") + parts.append(f"{indent} {cdata(c.get('body'))}") + parts.append(f"{indent}") + return "\n".join(parts) + + +if __name__ == "__main__": + main()