mirror of
https://github.com/SeriousBug/dotfiles
synced 2026-07-07 10:52:39 -05:00
Add respond-to-pr skill
Gathers a GitHub PR's title, description, reviews, per-line comments, and conversation comments via gh into one XML report (gather_pr.py), tagging each author as is_me/is_bot/is_human and giving every comment an id. The skill reads that output, evaluates each review point, fixes obvious bugs/security issues, asks the user on nuanced calls, replies to bots directly, and confirms with the user before replying to humans. Claude-Session: https://claude.ai/code/session_01AhP6LpRrKxH7yJPb6pfLFE
This commit is contained in:
parent
6f6874b27b
commit
a0c3471053
70
claude/skills/respond-to-pr/SKILL.md
Normal file
70
claude/skills/respond-to-pr/SKILL.md
Normal file
|
|
@ -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 <pr-url>`, 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 <number-or-url-or-nothing>
|
||||
```
|
||||
|
||||
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 `<no_pr_found>`, **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 `<review>`, `<comment>`, and `<line_comment>` 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.
|
||||
266
claude/skills/respond-to-pr/gather_pr.py
Executable file
266
claude/skills/respond-to-pr/gather_pr.py
Executable file
|
|
@ -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 <no_pr_found> 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("]]>", "]]]]><![CDATA[>")
|
||||
return f"<![CDATA[{text}]]>"
|
||||
|
||||
|
||||
def main():
|
||||
arg = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
|
||||
pr = resolve_pr(arg)
|
||||
if pr is None:
|
||||
print("<no_pr_found>")
|
||||
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("</no_pr_found>")
|
||||
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'<pull_request number="{number}" '
|
||||
f'repo="{escape(owner)}/{escape(repo)}" '
|
||||
f'state="{escape(pr.get("state", ""))}" '
|
||||
f'draft="{str(pr.get("isDraft", False)).lower()}" '
|
||||
f'url="{escape(pr.get("url", ""))}">'
|
||||
)
|
||||
out.append(f" <title>{cdata(pr.get('title'))}</title>")
|
||||
out.append(f" <author {author_attrs_from_gh_view(pr.get('author'), me)} />")
|
||||
out.append(f" <description>{cdata(pr.get('body'))}</description>")
|
||||
|
||||
out.append(" <reviews>")
|
||||
if not reviews:
|
||||
out.append(" <!-- no formal reviews -->")
|
||||
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' <review id="{rid}" {user_attrs(r.get("user"), me)} '
|
||||
f'state="{escape(state)}" submitted_at="{escape(submitted)}">'
|
||||
)
|
||||
if body.strip():
|
||||
out.append(f" <body>{cdata(body)}</body>")
|
||||
for c in kids:
|
||||
out.append(_render_line_comment(c, me, indent=" "))
|
||||
out.append(" </review>")
|
||||
out.append(" </reviews>")
|
||||
|
||||
if orphan_line_comments:
|
||||
out.append(" <line_comments_outside_reviews>")
|
||||
for c in orphan_line_comments:
|
||||
out.append(_render_line_comment(c, me, indent=" "))
|
||||
out.append(" </line_comments_outside_reviews>")
|
||||
|
||||
out.append(" <conversation_comments>")
|
||||
if not issue_comments:
|
||||
out.append(" <!-- no conversation comments -->")
|
||||
for c in issue_comments:
|
||||
out.append(
|
||||
f' <comment id="{c.get("id")}" {user_attrs(c.get("user"), me)} '
|
||||
f'created_at="{escape(c.get("created_at", "") or "")}">'
|
||||
)
|
||||
out.append(f" <body>{cdata(c.get('body'))}</body>")
|
||||
out.append(" </comment>")
|
||||
out.append(" </conversation_comments>")
|
||||
|
||||
out.append("</pull_request>")
|
||||
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}<line_comment {' '.join(attrs)}>"]
|
||||
diff = (c.get("diff_hunk") or "").strip()
|
||||
if diff:
|
||||
parts.append(f"{indent} <diff_hunk>{cdata(diff)}</diff_hunk>")
|
||||
parts.append(f"{indent} <body>{cdata(c.get('body'))}</body>")
|
||||
parts.append(f"{indent}</line_comment>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue