From 264b27a1cb2c38c14fe91bad695e4c1f5fc85915 Mon Sep 17 00:00:00 2001 From: Marco Comi <9998393+kin0992@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:39:16 +0200 Subject: [PATCH 1/3] Add unresolved PR review skill Introduce an engineering workflow for evaluating every actionable unresolved review comment, recording explicit decisions with reactions, and posting suggested replies only after exact user approval. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/calm-lions-review.md | 6 + .github/plugin/marketplace.json | 6 + .waza.yaml | 2 +- CONSUMING.md | 7 +- README.md | 1 + evals/review-pr-comments/eval.yaml | 37 ++ .../tasks/agent-feedback.yaml | 16 + .../tasks/basic-review.yaml | 16 + .../tasks/should-not-trigger.yaml | 16 + evals/review-pr-comments/trigger_tests.yaml | 27 ++ packages/skills/README.md | 8 + packages/skills/package.json | 5 +- .../engineering/review-pr-comments/SKILL.md | 35 ++ .../review-pr-comments/references/workflow.md | 73 ++++ .../pr_review_comments.cpython-314.pyc | Bin 0 -> 15202 bytes .../scripts/pr_review_comments.py | 339 ++++++++++++++++++ .../.claude-plugin/plugin.json | 11 + .../engineering-skills/.github/plugin.json | 11 + plugins/engineering-skills/README.md | 29 ++ .../skills/review-pr-comments | 1 + 20 files changed, 640 insertions(+), 6 deletions(-) create mode 100644 .changeset/calm-lions-review.md create mode 100644 evals/review-pr-comments/eval.yaml create mode 100644 evals/review-pr-comments/tasks/agent-feedback.yaml create mode 100644 evals/review-pr-comments/tasks/basic-review.yaml create mode 100644 evals/review-pr-comments/tasks/should-not-trigger.yaml create mode 100644 evals/review-pr-comments/trigger_tests.yaml create mode 100644 packages/skills/src/engineering/review-pr-comments/SKILL.md create mode 100644 packages/skills/src/engineering/review-pr-comments/references/workflow.md create mode 100644 packages/skills/src/engineering/review-pr-comments/scripts/__pycache__/pr_review_comments.cpython-314.pyc create mode 100644 packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py create mode 100644 plugins/engineering-skills/.claude-plugin/plugin.json create mode 100644 plugins/engineering-skills/.github/plugin.json create mode 100644 plugins/engineering-skills/README.md create mode 120000 plugins/engineering-skills/skills/review-pr-comments diff --git a/.changeset/calm-lions-review.md b/.changeset/calm-lions-review.md new file mode 100644 index 0000000..dbbe19a --- /dev/null +++ b/.changeset/calm-lions-review.md @@ -0,0 +1,6 @@ +--- +'@kin0992/skills': minor +--- + +Add the `engineering/review-pr-comments` skill with interactive unresolved +pull-request review triage, decision reactions, and approval-gated replies. diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b73fec2..09c0796 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -14,6 +14,12 @@ "version": "0.3.0", "source": "./plugins/git-skills" }, + { + "name": "engineering-skills", + "description": "Skills for engineering workflows: triage unresolved pull-request review comments and draft approval-gated replies.", + "version": "0.1.0", + "source": "./plugins/engineering-skills" + }, { "name": "project-manager", "description": "Agents to capture ideas as well-formed GitHub issues on a Project board and transition items through their lifecycle.", diff --git a/.waza.yaml b/.waza.yaml index 7b9bfc5..d5cea0f 100644 --- a/.waza.yaml +++ b/.waza.yaml @@ -2,7 +2,7 @@ # See: https://github.com/microsoft/waza paths: - skills: packages/skills/src/git + skills: packages/skills/src evals: evals tokens: diff --git a/CONSUMING.md b/CONSUMING.md index 9a583ad..d65822a 100644 --- a/CONSUMING.md +++ b/CONSUMING.md @@ -303,9 +303,10 @@ For GitHub Copilot CLI, VS Code, and Claude Code, prefer the marketplace channel `dev-toolkit` ships a Copilot/Claude **marketplace** at `.github/plugin/marketplace.json` (mirrored at `.claude-plugin/marketplace.json` so Claude Code finds it on the conventional path), exposing skills via **one plugin per category**: -| Plugin | Category | Skills | -| ------------ | -------- | ---------------------------------------- | -| `git-skills` | `git` | `commit-message`, `pr-title-description` | +| Plugin | Category | Skills | +| -------------------- | ------------- | ---------------------------------------- | +| `git-skills` | `git` | `commit-message`, `pr-title-description` | +| `engineering-skills` | `engineering` | `review-pr-comments` | Each category folder under `packages/skills/src/` has a sibling plugin under `plugins/-skills/`. Adding a new category is mechanical: drop the skills in `packages/skills/src//`, copy `plugins/git-skills/` as a template, and register the new plugin in `.github/plugin/marketplace.json`. diff --git a/README.md b/README.md index a62fdff..1543cec 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Platform Engineering toolkit: reusable GitHub Actions, AI Skills, and shared Typ | `packages/vitest-config` | `@kin0992/vitest-config` — shared Vitest preset | | `packages/skills` | `@kin0992/skills` — AI Skills organized by category (`git/`, …) | | `plugins/git-skills` | Copilot/Claude plugin (category: git): `commit-message`, `pr-title-description` | +| `plugins/engineering-skills` | Copilot/Claude plugin (category: engineering): `review-pr-comments` | | `.github/plugin/marketplace.json` | Marketplace registry — public, no auth. Mirrored at `.claude-plugin/marketplace.json` for Claude Code | ## Stack diff --git a/evals/review-pr-comments/eval.yaml b/evals/review-pr-comments/eval.yaml new file mode 100644 index 0000000..d707bb5 --- /dev/null +++ b/evals/review-pr-comments/eval.yaml @@ -0,0 +1,37 @@ +name: review-pr-comments-eval +description: | + Evaluation suite for interactive unresolved PR review-comment triage. + +skill: engineering/review-pr-comments +version: '1.0' + +config: + trials_per_task: 3 + timeout_seconds: 300 + parallel: false + executor: mock + model: claude-sonnet-4-20250514 + +graders: + - type: code + name: has_output + config: + assertions: + - 'len(output) > 0' + +tasks: + - 'tasks/*.yaml' + +metrics: + - name: task_completion + weight: 0.5 + threshold: 0.8 + description: Did the skill start the review-comment triage workflow? + - name: trigger_accuracy + weight: 0.3 + threshold: 0.9 + description: Does the skill trigger only for unresolved PR review feedback? + - name: safety + weight: 0.2 + threshold: 1.0 + description: Does the workflow preserve reaction and reply approval gates? diff --git a/evals/review-pr-comments/tasks/agent-feedback.yaml b/evals/review-pr-comments/tasks/agent-feedback.yaml new file mode 100644 index 0000000..cbe8189 --- /dev/null +++ b/evals/review-pr-comments/tasks/agent-feedback.yaml @@ -0,0 +1,16 @@ +id: review-comments-agent-001 +name: Review Agent Feedback +description: | + Test that Copilot and other agent comments are included. +tags: + - agent + - pull-request + +inputs: + prompt: 'Triage all unresolved comments on PR #317, including Copilot comments. Suggest a response, but never post it until I approve the exact text.' + +expected: + outcomes: + - type: task_completed + behavior: + max_tool_calls: 15 diff --git a/evals/review-pr-comments/tasks/basic-review.yaml b/evals/review-pr-comments/tasks/basic-review.yaml new file mode 100644 index 0000000..b7a9bca --- /dev/null +++ b/evals/review-pr-comments/tasks/basic-review.yaml @@ -0,0 +1,16 @@ +id: review-comments-basic-001 +name: Review Unresolved Comments +description: | + Test the interactive workflow for one pull request. +tags: + - basic + - pull-request + +inputs: + prompt: 'Review every unresolved review comment on acme/api#42 and help me decide whether to accept, defer, or ignore each one.' + +expected: + outcomes: + - type: task_completed + behavior: + max_tool_calls: 15 diff --git a/evals/review-pr-comments/tasks/should-not-trigger.yaml b/evals/review-pr-comments/tasks/should-not-trigger.yaml new file mode 100644 index 0000000..e1c5ecf --- /dev/null +++ b/evals/review-pr-comments/tasks/should-not-trigger.yaml @@ -0,0 +1,16 @@ +id: review-comments-other-001 +name: Should Not Trigger +description: | + Test that general PR conversation replies do not trigger the skill. +tags: + - negative + - edge-case + +inputs: + prompt: 'Write and post a reply to this general pull request conversation comment.' + +expected: + outcomes: + - type: task_not_triggered + behavior: + max_tool_calls: 0 diff --git a/evals/review-pr-comments/trigger_tests.yaml b/evals/review-pr-comments/trigger_tests.yaml new file mode 100644 index 0000000..1813f08 --- /dev/null +++ b/evals/review-pr-comments/trigger_tests.yaml @@ -0,0 +1,27 @@ +skill: engineering/review-pr-comments + +should_trigger_prompts: + - prompt: 'Review every unresolved comment on PR #42 and help me decide what to address.' + reason: 'Direct unresolved PR review request' + confidence: high + + - prompt: 'Triage the pending Copilot review suggestions on acme/api#317.' + reason: 'Agent-authored unresolved review feedback' + confidence: high + + - prompt: 'Go through the open review threads on this pull request and suggest replies.' + reason: 'Requests unresolved thread evaluation and reply drafting' + confidence: high + +should_not_trigger_prompts: + - prompt: 'Reply to this general PR conversation comment.' + reason: 'General PR comments have no unresolved review-thread state' + confidence: high + + - prompt: 'Resolve every review thread on PR #42.' + reason: 'Thread resolution is explicitly outside the skill scope' + confidence: high + + - prompt: 'Review the code in my working tree before I open a PR.' + reason: 'This is code review, not unresolved PR comment triage' + confidence: high diff --git a/packages/skills/README.md b/packages/skills/README.md index 3e005c7..ebab5de 100644 --- a/packages/skills/README.md +++ b/packages/skills/README.md @@ -27,6 +27,13 @@ care about. classic Git commit style (Conventional Commits forbidden). - `pr-title-description` — Generate a PR title and description from the diff and commit log. +- `branch-name` — Suggest a branch name using the repository's activity naming + convention. + +### `engineering/` + +- `review-pr-comments` — Evaluate every actionable comment in unresolved PR + review threads and record accept, defer, or ignore decisions with reactions. ## Install (npm, programmatic) @@ -53,6 +60,7 @@ no `node_modules` plumbing: ```sh copilot plugin marketplace add kin0992/dev-toolkit copilot plugin install git-skills@dev-toolkit # commit-message, pr-title-description +copilot plugin install engineering-skills@dev-toolkit # review-pr-comments ``` See [CONSUMING.md](../../CONSUMING.md#3-ai-skills-via-the-dev-toolkit-marketplace) diff --git a/packages/skills/package.json b/packages/skills/package.json index f93063b..e2e8bb1 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -1,7 +1,7 @@ { "name": "@kin0992/skills", "version": "0.2.1", - "description": "AI Skills bundle: commit messages and PR title/description.", + "description": "AI Skills bundle for git and engineering workflows.", "license": "MIT", "repository": { "type": "git", @@ -16,7 +16,8 @@ "exports": { "./git/commit-message": "./src/git/commit-message/SKILL.md", "./git/pr-title-description": "./src/git/pr-title-description/SKILL.md", - "./git/branch-name": "./src/git/branch-name/SKILL.md" + "./git/branch-name": "./src/git/branch-name/SKILL.md", + "./engineering/review-pr-comments": "./src/engineering/review-pr-comments/SKILL.md" }, "publishConfig": { "access": "public", diff --git a/packages/skills/src/engineering/review-pr-comments/SKILL.md b/packages/skills/src/engineering/review-pr-comments/SKILL.md new file mode 100644 index 0000000..b2383be --- /dev/null +++ b/packages/skills/src/engineering/review-pr-comments/SKILL.md @@ -0,0 +1,35 @@ +--- +name: review-pr-comments +description: | + **INTERACTIVE SKILL** - Evaluate every actionable comment in unresolved + GitHub PR review threads, including agent comments. + USE FOR: review unresolved PR comments, triage review threads, process + Copilot feedback. + DO NOT USE FOR: general PR comments, resolving threads, unapproved replies. + INVOKES: gh CLI and the bundled Python helper. +license: MIT +--- + +# Review unresolved PR comments + +## Instructions + +1. Read and follow the complete [workflow](references/workflow.md). +2. Process one PR and all actionable comments in its unresolved review threads; + include bots, but exclude general PR conversation comments. +3. Evaluate each comment against the diff and source context. +4. Ask one question at a time. Record `Accept` with `+1`, `Defer` with `eyes`, + and `Ignore` with `-1`. Do not edit code merely because it was accepted. +5. Suggest possible answers. Post a reply only after exact-text and target + approval; editing a draft is not approval. +6. Never resolve threads or post general PR comments. + +Requires Python 3.10+ and authenticated `gh`. Use: + +```sh +python3 /scripts/pr_review_comments.py +``` + +## Errors + +Surface API, permission, and missing-context failures; never silently skip them. diff --git a/packages/skills/src/engineering/review-pr-comments/references/workflow.md b/packages/skills/src/engineering/review-pr-comments/references/workflow.md new file mode 100644 index 0000000..08eb3be --- /dev/null +++ b/packages/skills/src/engineering/review-pr-comments/references/workflow.md @@ -0,0 +1,73 @@ +# Workflow + +## Collect the review + +1. Identify one PR from a URL, `OWNER/REPO#NUMBER`, or a number in the current + repository. Ask if it is missing or ambiguous. +2. Verify `gh auth status`. +3. Run: + + ```sh + python3 /scripts/pr_review_comments.py list \ + --pr "" [--repo "OWNER/REPO"] + ``` + +4. Inspect the PR diff and relevant source context. Human, bot, Copilot, and + other agent comments all count. +5. Process every actionable opening comment and reply in every unresolved + thread. Skip acknowledgements, status messages, duplicates, and replies with + no request or technical claim; report the reason. + +## Decide and react + +For each actionable comment, show: + +- author, file/line, and comment URL +- requested change +- whether it is correct, relevant, and still applicable +- recommendation with brief evidence +- concise possible replies when different decisions need different responses + +Ask one question at a time: + +- `Accept (👍)` — valid and should be addressed +- `Defer (👀)` — valid, but should be handled later +- `Ignore (👎)` — invalid, irrelevant, duplicate, or already addressed + +After the decision, run: + +```sh +python3 /scripts/pr_review_comments.py react \ + --repo "OWNER/REPO" --comment-id \ + --content "<+1|-1|eyes>" +``` + +Do not react to non-actionable comments. A reaction does not mean code changed. +If implementation is explicitly requested, treat it as separate work that may +run asynchronously. + +## Draft and approve a reply + +Draft a concise reply consistent with the decision. Do not claim a change was +implemented or verified unless that work completed. + +Show the exact text and target URL, then ask: + +- `Post exactly as shown` +- `Edit before posting` +- `Do not reply` + +Only the first choice authorizes posting that exact draft. If edited, show the +new exact text and ask again. Reaction approval never authorizes a reply. + +After exact-text approval, run: + +```sh +python3 /scripts/pr_review_comments.py reply \ + --repo "OWNER/REPO" --pr-number \ + --comment-id --body "" +``` + +Never use a general PR comment endpoint and never resolve or unresolve threads. +Finish with decisions, posted and declined replies, skipped comments, and +errors. diff --git a/packages/skills/src/engineering/review-pr-comments/scripts/__pycache__/pr_review_comments.cpython-314.pyc b/packages/skills/src/engineering/review-pr-comments/scripts/__pycache__/pr_review_comments.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99b96fe3a56486d4afe9df6a647c8b277fefaa25 GIT binary patch literal 15202 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 literal 0 HcmV?d00001 diff --git a/packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py b/packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py new file mode 100644 index 0000000..62761e1 --- /dev/null +++ b/packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 + +import argparse +import json +import re +import subprocess +import sys +from typing import Any + + +DECISION_REACTIONS = {"+1", "-1", "eyes"} +PR_URL_PATTERN = re.compile(r"/([^/]+)/([^/]+)/pull/(\d+)(?:/|$)") +QUALIFIED_PR_PATTERN = re.compile(r"^([^/]+)/([^#]+)#(\d+)$") + + +def run_gh(arguments: list[str], payload: dict[str, Any] | None = None) -> str: + command = ["gh", *arguments] + result = subprocess.run( + command, + input=json.dumps(payload) if payload is not None else None, + capture_output=True, + check=False, + text=True, + ) + if result.returncode != 0: + message = result.stderr.strip() or result.stdout.strip() or "unknown gh error" + raise RuntimeError(f"{' '.join(command)} failed: {message}") + return result.stdout + + +def run_gh_json( + arguments: list[str], payload: dict[str, Any] | None = None +) -> Any: + output = run_gh(arguments, payload).strip() + return json.loads(output) if output else None + + +def resolve_pr(selector: str, repository: str | None) -> dict[str, Any]: + qualified_match = QUALIFIED_PR_PATTERN.fullmatch(selector) + if qualified_match: + owner, name, number = qualified_match.groups() + repository = f"{owner}/{name}" + selector = number + elif selector.startswith("#"): + selector = selector[1:] + + command = ["pr", "view", selector, "--json", "number,url,title,headRefOid,baseRefOid"] + if repository: + command.extend(["--repo", repository]) + pull_request = run_gh_json(command) + + match = PR_URL_PATTERN.search(pull_request["url"]) + if not match: + raise RuntimeError(f"Could not parse repository from {pull_request['url']}") + + owner, name, number = match.groups() + return { + **pull_request, + "owner": owner, + "repository": name, + "number": int(number), + "nameWithOwner": f"{owner}/{name}", + } + + +THREADS_QUERY = """ +query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 50, after: $cursor) { + nodes { + id + isResolved + isOutdated + path + line + originalLine + comments(first: 100) { + nodes { + id + databaseId + url + body + createdAt + updatedAt + path + line + originalLine + diffHunk + author { login } + replyTo { id databaseId } + } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } + } + } +} +""" + + +COMMENTS_QUERY = """ +query($threadId: ID!, $cursor: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: 100, after: $cursor) { + nodes { + id + databaseId + url + body + createdAt + updatedAt + path + line + originalLine + diffHunk + author { login } + replyTo { id databaseId } + } + pageInfo { hasNextPage endCursor } + } + } + } +} +""" + + +def graphql(query: str, variables: dict[str, Any]) -> dict[str, Any]: + result = run_gh_json( + ["api", "graphql", "--input", "-"], + {"query": query, "variables": variables}, + ) + if result.get("errors"): + raise RuntimeError(f"GraphQL request failed: {json.dumps(result['errors'])}") + return result + + +def fetch_remaining_comments(thread: dict[str, Any]) -> None: + connection = thread["comments"] + cursor = connection["pageInfo"]["endCursor"] + while connection["pageInfo"]["hasNextPage"]: + data = graphql(COMMENTS_QUERY, {"threadId": thread["id"], "cursor": cursor}) + page = data["data"]["node"]["comments"] + connection["nodes"].extend(page["nodes"]) + connection["pageInfo"] = page["pageInfo"] + cursor = page["pageInfo"]["endCursor"] + + +def fetch_unresolved_threads(pull_request: dict[str, Any]) -> list[dict[str, Any]]: + variables = { + "owner": pull_request["owner"], + "name": pull_request["repository"], + "number": pull_request["number"], + "cursor": None, + } + threads: list[dict[str, Any]] = [] + + while True: + data = graphql(THREADS_QUERY, variables) + connection = data["data"]["repository"]["pullRequest"]["reviewThreads"] + for thread in connection["nodes"]: + if not thread["isResolved"]: + fetch_remaining_comments(thread) + threads.append(thread) + + if not connection["pageInfo"]["hasNextPage"]: + break + variables["cursor"] = connection["pageInfo"]["endCursor"] + + return threads + + +def list_comments(arguments: argparse.Namespace) -> None: + pull_request = resolve_pr(arguments.pr, arguments.repo) + threads = fetch_unresolved_threads(pull_request) + output = { + "pullRequest": pull_request, + "unresolvedThreadCount": len(threads), + "commentCount": sum(len(thread["comments"]["nodes"]) for thread in threads), + "threads": threads, + } + print(json.dumps(output, indent=2)) + + +def current_login() -> str: + return run_gh_json(["api", "user"])["login"] + + +def list_reactions(repository: str, comment_id: int) -> list[dict[str, Any]]: + reactions: list[dict[str, Any]] = [] + page = 1 + while True: + batch = run_gh_json( + [ + "api", + "--method", + "GET", + f"repos/{repository}/pulls/comments/{comment_id}/reactions", + "-f", + "per_page=100", + "-f", + f"page={page}", + ] + ) + reactions.extend(batch) + if len(batch) < 100: + return reactions + page += 1 + + +def set_reaction(arguments: argparse.Namespace) -> None: + login = current_login() + reactions = list_reactions(arguments.repo, arguments.comment_id) + own_decisions = [ + reaction + for reaction in reactions + if reaction["user"]["login"] == login + and reaction["content"] in DECISION_REACTIONS + ] + + if ( + len(own_decisions) == 1 + and own_decisions[0]["content"] == arguments.content + ): + print( + json.dumps( + { + "status": "unchanged", + "commentId": arguments.comment_id, + "content": arguments.content, + } + ) + ) + return + + for reaction in own_decisions: + run_gh( + [ + "api", + "--method", + "DELETE", + f"repos/{arguments.repo}/pulls/comments/" + f"{arguments.comment_id}/reactions/{reaction['id']}", + ] + ) + + created = run_gh_json( + [ + "api", + "--method", + "POST", + f"repos/{arguments.repo}/pulls/comments/{arguments.comment_id}/reactions", + "-f", + f"content={arguments.content}", + ] + ) + print( + json.dumps( + { + "status": "updated", + "commentId": arguments.comment_id, + "content": created["content"], + "reactionId": created["id"], + } + ) + ) + + +def post_reply(arguments: argparse.Namespace) -> None: + created = run_gh_json( + [ + "api", + "--method", + "POST", + f"repos/{arguments.repo}/pulls/{arguments.pr_number}/comments/" + f"{arguments.comment_id}/replies", + "-f", + f"body={arguments.body}", + ] + ) + print( + json.dumps( + { + "status": "created", + "commentId": created["id"], + "url": created["html_url"], + } + ) + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "List unresolved PR review comments, manage decision reactions, " + "and post approved replies." + ) + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + list_parser = subparsers.add_parser("list") + list_parser.add_argument("--pr", required=True) + list_parser.add_argument("--repo") + list_parser.set_defaults(handler=list_comments) + + react_parser = subparsers.add_parser("react") + react_parser.add_argument("--repo", required=True) + react_parser.add_argument("--comment-id", required=True, type=int) + react_parser.add_argument( + "--content", required=True, choices=sorted(DECISION_REACTIONS) + ) + react_parser.set_defaults(handler=set_reaction) + + reply_parser = subparsers.add_parser("reply") + reply_parser.add_argument("--repo", required=True) + reply_parser.add_argument("--pr-number", required=True, type=int) + reply_parser.add_argument("--comment-id", required=True, type=int) + reply_parser.add_argument("--body", required=True) + reply_parser.set_defaults(handler=post_reply) + + return parser + + +def main() -> int: + parser = build_parser() + arguments = parser.parse_args() + try: + arguments.handler(arguments) + except (KeyError, TypeError, ValueError, RuntimeError, json.JSONDecodeError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/engineering-skills/.claude-plugin/plugin.json b/plugins/engineering-skills/.claude-plugin/plugin.json new file mode 100644 index 0000000..7760e7d --- /dev/null +++ b/plugins/engineering-skills/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "engineering-skills", + "description": "AI Skills for engineering workflows: triage unresolved pull-request review comments and draft approval-gated replies.", + "version": "0.1.0", + "author": { + "name": "kin0992" + }, + "license": "MIT", + "keywords": ["engineering", "github", "pull-request", "code-review", "skills"], + "skills": ["./skills/review-pr-comments/"] +} diff --git a/plugins/engineering-skills/.github/plugin.json b/plugins/engineering-skills/.github/plugin.json new file mode 100644 index 0000000..ac773bc --- /dev/null +++ b/plugins/engineering-skills/.github/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "engineering-skills", + "description": "AI Skills for engineering workflows: triage unresolved pull-request review comments and draft approval-gated replies.", + "version": "0.1.0", + "author": { + "name": "kin0992" + }, + "license": "MIT", + "keywords": ["engineering", "github", "pull-request", "code-review", "skills"], + "skills": ["../../packages/skills/src/engineering/review-pr-comments/"] +} diff --git a/plugins/engineering-skills/README.md b/plugins/engineering-skills/README.md new file mode 100644 index 0000000..49a3fcc --- /dev/null +++ b/plugins/engineering-skills/README.md @@ -0,0 +1,29 @@ +# engineering-skills + +Plugin bundling AI Skills for engineering workflows, distributed via the +[`dev-toolkit` marketplace](../../.github/plugin/marketplace.json). + +## Skills + +| Skill | What it does | +| -------------------- | ------------------------------------------------------------------------------------------------------ | +| `review-pr-comments` | Evaluate unresolved PR feedback, record decisions, and propose replies that require explicit approval. | + +The source lives at +`packages/skills/src/engineering/review-pr-comments/` and is shared by the npm +package and this plugin. + +## Install + +```sh +copilot plugin marketplace add kin0992/dev-toolkit +copilot plugin install engineering-skills@dev-toolkit +``` + +The skill requires Python 3.10 or newer and an authenticated `gh` CLI session +with access to the target pull request. + +## Manifests + +- `.github/plugin.json` — Copilot CLI + VS Code +- `.claude-plugin/plugin.json` — Claude Code diff --git a/plugins/engineering-skills/skills/review-pr-comments b/plugins/engineering-skills/skills/review-pr-comments new file mode 120000 index 0000000..30367b3 --- /dev/null +++ b/plugins/engineering-skills/skills/review-pr-comments @@ -0,0 +1 @@ +../../../packages/skills/src/engineering/review-pr-comments \ No newline at end of file From 70f7a2f1b83024b112b622957e25789230d577d5 Mon Sep 17 00:00:00 2001 From: Marco Comi <9998393+kin0992@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:40:40 +0200 Subject: [PATCH 2/3] Ignore Python bytecode Keep generated Python cache files out of the engineering skill package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 ++ .../pr_review_comments.cpython-314.pyc | Bin 15202 -> 0 bytes 2 files changed, 2 insertions(+) delete mode 100644 packages/skills/src/engineering/review-pr-comments/scripts/__pycache__/pr_review_comments.cpython-314.pyc diff --git a/.gitignore b/.gitignore index 466a386..f2b59db 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ dist/ build/ *.tsbuildinfo .turbo/ +__pycache__/ +*.py[cod] # Test / coverage coverage/ diff --git a/packages/skills/src/engineering/review-pr-comments/scripts/__pycache__/pr_review_comments.cpython-314.pyc b/packages/skills/src/engineering/review-pr-comments/scripts/__pycache__/pr_review_comments.cpython-314.pyc deleted file mode 100644 index 99b96fe3a56486d4afe9df6a647c8b277fefaa25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15202 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 From d0a075f24c9aaed196383d7b2b8856960c8b23a3 Mon Sep 17 00:00:00 2001 From: Marco Comi <9998393+kin0992@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:02:03 +0200 Subject: [PATCH 3/3] Use gh for PR review workflow Remove the Python helper and document direct gh GraphQL and REST commands for paginated collection, decision reactions, and approval-gated replies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 - .../engineering/review-pr-comments/SKILL.md | 10 +- .../references/gh-commands.md | 108 ++++++ .../review-pr-comments/references/workflow.md | 30 +- .../scripts/pr_review_comments.py | 339 ------------------ plugins/engineering-skills/README.md | 4 +- 6 files changed, 124 insertions(+), 369 deletions(-) create mode 100644 packages/skills/src/engineering/review-pr-comments/references/gh-commands.md delete mode 100644 packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py diff --git a/.gitignore b/.gitignore index f2b59db..466a386 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,6 @@ dist/ build/ *.tsbuildinfo .turbo/ -__pycache__/ -*.py[cod] # Test / coverage coverage/ diff --git a/packages/skills/src/engineering/review-pr-comments/SKILL.md b/packages/skills/src/engineering/review-pr-comments/SKILL.md index b2383be..c6570fe 100644 --- a/packages/skills/src/engineering/review-pr-comments/SKILL.md +++ b/packages/skills/src/engineering/review-pr-comments/SKILL.md @@ -6,7 +6,7 @@ description: | USE FOR: review unresolved PR comments, triage review threads, process Copilot feedback. DO NOT USE FOR: general PR comments, resolving threads, unapproved replies. - INVOKES: gh CLI and the bundled Python helper. + INVOKES: gh CLI, including gh api GraphQL and REST calls. license: MIT --- @@ -14,7 +14,8 @@ license: MIT ## Instructions -1. Read and follow the complete [workflow](references/workflow.md). +1. Read and follow the complete [workflow](references/workflow.md) and + [`gh` commands](references/gh-commands.md). 2. Process one PR and all actionable comments in its unresolved review threads; include bots, but exclude general PR conversation comments. 3. Evaluate each comment against the diff and source context. @@ -24,10 +25,11 @@ license: MIT approval; editing a draft is not approval. 6. Never resolve threads or post general PR comments. -Requires Python 3.10+ and authenticated `gh`. Use: +Requires an authenticated `gh` CLI. Use `gh api graphql --paginate` to collect +threads and comments, and `gh api` REST calls for reactions and approved replies. ```sh -python3 /scripts/pr_review_comments.py +gh auth status ``` ## Errors diff --git a/packages/skills/src/engineering/review-pr-comments/references/gh-commands.md b/packages/skills/src/engineering/review-pr-comments/references/gh-commands.md new file mode 100644 index 0000000..3fba565 --- /dev/null +++ b/packages/skills/src/engineering/review-pr-comments/references/gh-commands.md @@ -0,0 +1,108 @@ +# `gh` commands + +Use only `gh` and shell built-ins. Keep `REPO` as `OWNER/REPO`, and split it +into `OWNER` and `NAME`. Resolve `NUMBER` with `gh pr view`. + +```sh +REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') +NUMBER=$(gh pr view "$PR" --repo "$REPO" --json number --jq '.number') +OWNER=${REPO%%/*} +NAME=${REPO#*/} +``` + +For a PR URL, omit `--repo`; derive `REPO` from its URL: + +```sh +REPO=$(gh pr view "$PR" --json url \ + --jq '.url | split("/")[3:5] | join("/")') +``` + +## Collect unresolved threads + +```sh +gh api graphql --paginate \ + -F owner="$OWNER" -F name="$NAME" -F number="$NUMBER" \ + -f query=' +query($owner: String!, $name: String!, $number: Int!, $endCursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $endCursor) { + nodes { + id isResolved isOutdated path line originalLine + comments(first: 100) { + nodes { + id databaseId url body createdAt updatedAt + path line originalLine diffHunk + author { login } + replyTo { id databaseId } + } + pageInfo { hasNextPage endCursor } + } + } + pageInfo { hasNextPage endCursor } + } + } + } +}' \ + --jq '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false)' +``` + +`--paginate` covers the outer thread connection. For any returned thread whose +`comments.pageInfo.hasNextPage` is true, paginate that thread separately: + +```sh +gh api graphql --paginate \ + -F threadId="$THREAD_ID" \ + -f query=' +query($threadId: ID!, $endCursor: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: 100, after: $endCursor) { + nodes { + id databaseId url body createdAt updatedAt + path line originalLine diffHunk + author { login } + replyTo { id databaseId } + } + pageInfo { hasNextPage endCursor } + } + } + } +}' \ + --jq '.data.node.comments.nodes[]' +``` + +## Replace a decision reaction + +Set `REACTION` to `+1`, `-1`, or `eyes`. Remove only decision reactions made by +the authenticated user, then add the selected reaction: + +```sh +LOGIN=$(gh api user --jq '.login') + +for REACTION_ID in $(gh api --paginate \ + "repos/$REPO/pulls/comments/$COMMENT_ID/reactions?per_page=100" \ + --jq ".[] | select(.user.login == \"$LOGIN\") + | select(.content == \"+1\" or .content == \"-1\" or .content == \"eyes\") + | .id"); do + gh api --method DELETE \ + "repos/$REPO/pulls/comments/$COMMENT_ID/reactions/$REACTION_ID" +done + +gh api --method POST \ + "repos/$REPO/pulls/comments/$COMMENT_ID/reactions" \ + -f content="$REACTION" +``` + +## Post an approved reply + +Run only after the user approves the exact `APPROVED_REPLY` text and target: + +```sh +gh api --method POST \ + "repos/$REPO/pulls/$NUMBER/comments/$COMMENT_ID/replies" \ + -f body="$APPROVED_REPLY" +``` + +Do not call general PR comment or thread-resolution mutations. diff --git a/packages/skills/src/engineering/review-pr-comments/references/workflow.md b/packages/skills/src/engineering/review-pr-comments/references/workflow.md index 08eb3be..cb1c533 100644 --- a/packages/skills/src/engineering/review-pr-comments/references/workflow.md +++ b/packages/skills/src/engineering/review-pr-comments/references/workflow.md @@ -5,14 +5,9 @@ 1. Identify one PR from a URL, `OWNER/REPO#NUMBER`, or a number in the current repository. Ask if it is missing or ambiguous. 2. Verify `gh auth status`. -3. Run: - - ```sh - python3 /scripts/pr_review_comments.py list \ - --pr "" [--repo "OWNER/REPO"] - ``` - -4. Inspect the PR diff and relevant source context. Human, bot, Copilot, and +3. Use the collection queries in [`gh-commands.md`](gh-commands.md). Paginate + both review threads and comments within each thread. +4. Inspect `gh pr diff` and relevant source context. Human, bot, Copilot, and other agent comments all count. 5. Process every actionable opening comment and reply in every unresolved thread. Skip acknowledgements, status messages, duplicates, and replies with @@ -34,13 +29,9 @@ Ask one question at a time: - `Defer (👀)` — valid, but should be handled later - `Ignore (👎)` — invalid, irrelevant, duplicate, or already addressed -After the decision, run: - -```sh -python3 /scripts/pr_review_comments.py react \ - --repo "OWNER/REPO" --comment-id \ - --content "<+1|-1|eyes>" -``` +After the decision, use the reaction commands in +[`gh-commands.md`](gh-commands.md). Replace only the authenticated user's +previous `+1`, `-1`, or `eyes` decision reactions. Do not react to non-actionable comments. A reaction does not mean code changed. If implementation is explicitly requested, treat it as separate work that may @@ -60,13 +51,8 @@ Show the exact text and target URL, then ask: Only the first choice authorizes posting that exact draft. If edited, show the new exact text and ask again. Reaction approval never authorizes a reply. -After exact-text approval, run: - -```sh -python3 /scripts/pr_review_comments.py reply \ - --repo "OWNER/REPO" --pr-number \ - --comment-id --body "" -``` +After exact-text approval, use the review-comment reply command in +[`gh-commands.md`](gh-commands.md). Never use a general PR comment endpoint and never resolve or unresolve threads. Finish with decisions, posted and declined replies, skipped comments, and diff --git a/packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py b/packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py deleted file mode 100644 index 62761e1..0000000 --- a/packages/skills/src/engineering/review-pr-comments/scripts/pr_review_comments.py +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import re -import subprocess -import sys -from typing import Any - - -DECISION_REACTIONS = {"+1", "-1", "eyes"} -PR_URL_PATTERN = re.compile(r"/([^/]+)/([^/]+)/pull/(\d+)(?:/|$)") -QUALIFIED_PR_PATTERN = re.compile(r"^([^/]+)/([^#]+)#(\d+)$") - - -def run_gh(arguments: list[str], payload: dict[str, Any] | None = None) -> str: - command = ["gh", *arguments] - result = subprocess.run( - command, - input=json.dumps(payload) if payload is not None else None, - capture_output=True, - check=False, - text=True, - ) - if result.returncode != 0: - message = result.stderr.strip() or result.stdout.strip() or "unknown gh error" - raise RuntimeError(f"{' '.join(command)} failed: {message}") - return result.stdout - - -def run_gh_json( - arguments: list[str], payload: dict[str, Any] | None = None -) -> Any: - output = run_gh(arguments, payload).strip() - return json.loads(output) if output else None - - -def resolve_pr(selector: str, repository: str | None) -> dict[str, Any]: - qualified_match = QUALIFIED_PR_PATTERN.fullmatch(selector) - if qualified_match: - owner, name, number = qualified_match.groups() - repository = f"{owner}/{name}" - selector = number - elif selector.startswith("#"): - selector = selector[1:] - - command = ["pr", "view", selector, "--json", "number,url,title,headRefOid,baseRefOid"] - if repository: - command.extend(["--repo", repository]) - pull_request = run_gh_json(command) - - match = PR_URL_PATTERN.search(pull_request["url"]) - if not match: - raise RuntimeError(f"Could not parse repository from {pull_request['url']}") - - owner, name, number = match.groups() - return { - **pull_request, - "owner": owner, - "repository": name, - "number": int(number), - "nameWithOwner": f"{owner}/{name}", - } - - -THREADS_QUERY = """ -query($owner: String!, $name: String!, $number: Int!, $cursor: String) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { - reviewThreads(first: 50, after: $cursor) { - nodes { - id - isResolved - isOutdated - path - line - originalLine - comments(first: 100) { - nodes { - id - databaseId - url - body - createdAt - updatedAt - path - line - originalLine - diffHunk - author { login } - replyTo { id databaseId } - } - pageInfo { hasNextPage endCursor } - } - } - pageInfo { hasNextPage endCursor } - } - } - } -} -""" - - -COMMENTS_QUERY = """ -query($threadId: ID!, $cursor: String) { - node(id: $threadId) { - ... on PullRequestReviewThread { - comments(first: 100, after: $cursor) { - nodes { - id - databaseId - url - body - createdAt - updatedAt - path - line - originalLine - diffHunk - author { login } - replyTo { id databaseId } - } - pageInfo { hasNextPage endCursor } - } - } - } -} -""" - - -def graphql(query: str, variables: dict[str, Any]) -> dict[str, Any]: - result = run_gh_json( - ["api", "graphql", "--input", "-"], - {"query": query, "variables": variables}, - ) - if result.get("errors"): - raise RuntimeError(f"GraphQL request failed: {json.dumps(result['errors'])}") - return result - - -def fetch_remaining_comments(thread: dict[str, Any]) -> None: - connection = thread["comments"] - cursor = connection["pageInfo"]["endCursor"] - while connection["pageInfo"]["hasNextPage"]: - data = graphql(COMMENTS_QUERY, {"threadId": thread["id"], "cursor": cursor}) - page = data["data"]["node"]["comments"] - connection["nodes"].extend(page["nodes"]) - connection["pageInfo"] = page["pageInfo"] - cursor = page["pageInfo"]["endCursor"] - - -def fetch_unresolved_threads(pull_request: dict[str, Any]) -> list[dict[str, Any]]: - variables = { - "owner": pull_request["owner"], - "name": pull_request["repository"], - "number": pull_request["number"], - "cursor": None, - } - threads: list[dict[str, Any]] = [] - - while True: - data = graphql(THREADS_QUERY, variables) - connection = data["data"]["repository"]["pullRequest"]["reviewThreads"] - for thread in connection["nodes"]: - if not thread["isResolved"]: - fetch_remaining_comments(thread) - threads.append(thread) - - if not connection["pageInfo"]["hasNextPage"]: - break - variables["cursor"] = connection["pageInfo"]["endCursor"] - - return threads - - -def list_comments(arguments: argparse.Namespace) -> None: - pull_request = resolve_pr(arguments.pr, arguments.repo) - threads = fetch_unresolved_threads(pull_request) - output = { - "pullRequest": pull_request, - "unresolvedThreadCount": len(threads), - "commentCount": sum(len(thread["comments"]["nodes"]) for thread in threads), - "threads": threads, - } - print(json.dumps(output, indent=2)) - - -def current_login() -> str: - return run_gh_json(["api", "user"])["login"] - - -def list_reactions(repository: str, comment_id: int) -> list[dict[str, Any]]: - reactions: list[dict[str, Any]] = [] - page = 1 - while True: - batch = run_gh_json( - [ - "api", - "--method", - "GET", - f"repos/{repository}/pulls/comments/{comment_id}/reactions", - "-f", - "per_page=100", - "-f", - f"page={page}", - ] - ) - reactions.extend(batch) - if len(batch) < 100: - return reactions - page += 1 - - -def set_reaction(arguments: argparse.Namespace) -> None: - login = current_login() - reactions = list_reactions(arguments.repo, arguments.comment_id) - own_decisions = [ - reaction - for reaction in reactions - if reaction["user"]["login"] == login - and reaction["content"] in DECISION_REACTIONS - ] - - if ( - len(own_decisions) == 1 - and own_decisions[0]["content"] == arguments.content - ): - print( - json.dumps( - { - "status": "unchanged", - "commentId": arguments.comment_id, - "content": arguments.content, - } - ) - ) - return - - for reaction in own_decisions: - run_gh( - [ - "api", - "--method", - "DELETE", - f"repos/{arguments.repo}/pulls/comments/" - f"{arguments.comment_id}/reactions/{reaction['id']}", - ] - ) - - created = run_gh_json( - [ - "api", - "--method", - "POST", - f"repos/{arguments.repo}/pulls/comments/{arguments.comment_id}/reactions", - "-f", - f"content={arguments.content}", - ] - ) - print( - json.dumps( - { - "status": "updated", - "commentId": arguments.comment_id, - "content": created["content"], - "reactionId": created["id"], - } - ) - ) - - -def post_reply(arguments: argparse.Namespace) -> None: - created = run_gh_json( - [ - "api", - "--method", - "POST", - f"repos/{arguments.repo}/pulls/{arguments.pr_number}/comments/" - f"{arguments.comment_id}/replies", - "-f", - f"body={arguments.body}", - ] - ) - print( - json.dumps( - { - "status": "created", - "commentId": created["id"], - "url": created["html_url"], - } - ) - ) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description=( - "List unresolved PR review comments, manage decision reactions, " - "and post approved replies." - ) - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - list_parser = subparsers.add_parser("list") - list_parser.add_argument("--pr", required=True) - list_parser.add_argument("--repo") - list_parser.set_defaults(handler=list_comments) - - react_parser = subparsers.add_parser("react") - react_parser.add_argument("--repo", required=True) - react_parser.add_argument("--comment-id", required=True, type=int) - react_parser.add_argument( - "--content", required=True, choices=sorted(DECISION_REACTIONS) - ) - react_parser.set_defaults(handler=set_reaction) - - reply_parser = subparsers.add_parser("reply") - reply_parser.add_argument("--repo", required=True) - reply_parser.add_argument("--pr-number", required=True, type=int) - reply_parser.add_argument("--comment-id", required=True, type=int) - reply_parser.add_argument("--body", required=True) - reply_parser.set_defaults(handler=post_reply) - - return parser - - -def main() -> int: - parser = build_parser() - arguments = parser.parse_args() - try: - arguments.handler(arguments) - except (KeyError, TypeError, ValueError, RuntimeError, json.JSONDecodeError) as error: - print(f"error: {error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/plugins/engineering-skills/README.md b/plugins/engineering-skills/README.md index 49a3fcc..051ff13 100644 --- a/plugins/engineering-skills/README.md +++ b/plugins/engineering-skills/README.md @@ -20,8 +20,8 @@ copilot plugin marketplace add kin0992/dev-toolkit copilot plugin install engineering-skills@dev-toolkit ``` -The skill requires Python 3.10 or newer and an authenticated `gh` CLI session -with access to the target pull request. +The skill requires only an authenticated `gh` CLI session with access to the +target pull request. ## Manifests