Commit 2eba9110a137

Vincent Demeester <vincent@sbr.pm>
2026-04-15 16:14:14
feat: add GHFixCI, GHReviewBotComments skills and prompt templates
Added two new skills adapted from chmouel/rc-config: GHFixCI for diagnosing and fixing failing CI checks with bundled Python script, and GHReviewBotComments for triaging bot review comments with classification, severity, and GraphQL thread resolution. Added 9 prompt templates for gitcommit, jirabug, jira-release-note, pac-release, rewrite, spellen, spellfr, gitbranch, and markdown. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent cab3b4c
Changed files (6)
dots
config
claude
skills
dots/config/claude/skills/GHFixCI/scripts/inspect_pr_checks.py
@@ -0,0 +1,529 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+from shutil import which
+from typing import Any, Iterable, Sequence
+
+FAILURE_CONCLUSIONS = {
+    "failure",
+    "cancelled",
+    "timed_out",
+    "action_required",
+}
+
+FAILURE_STATES = {
+    "failure",
+    "error",
+    "cancelled",
+    "timed_out",
+    "action_required",
+}
+
+FAILURE_BUCKETS = {"fail"}
+
+FAILURE_MARKERS = (
+    "error",
+    "fail",
+    "failed",
+    "traceback",
+    "exception",
+    "assert",
+    "panic",
+    "fatal",
+    "timeout",
+    "segmentation fault",
+)
+
+DEFAULT_MAX_LINES = 160
+DEFAULT_CONTEXT_LINES = 30
+PENDING_LOG_MARKERS = (
+    "still in progress",
+    "log will be available when it is complete",
+)
+
+
+class GhResult:
+    def __init__(self, returncode: int, stdout: str, stderr: str):
+        self.returncode = returncode
+        self.stdout = stdout
+        self.stderr = stderr
+
+
+def run_gh_command(args: Sequence[str], cwd: Path) -> GhResult:
+    process = subprocess.run(
+        ["gh", *args],
+        cwd=cwd,
+        text=True,
+        capture_output=True,
+    )
+    return GhResult(process.returncode, process.stdout, process.stderr)
+
+
+def run_gh_command_raw(args: Sequence[str], cwd: Path) -> tuple[int, bytes, str]:
+    process = subprocess.run(
+        ["gh", *args],
+        cwd=cwd,
+        capture_output=True,
+    )
+    stderr = process.stderr.decode(errors="replace")
+    return process.returncode, process.stdout, stderr
+
+
+def parse_args() -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description=(
+            "Inspect failing GitHub PR checks, fetch GitHub Actions logs, and extract a "
+            "failure snippet."
+        ),
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
+    parser.add_argument(
+        "--repo", default=".", help="Path inside the target Git repository."
+    )
+    parser.add_argument(
+        "--pr", default=None, help="PR number or URL (defaults to current branch PR)."
+    )
+    parser.add_argument("--max-lines", type=int, default=DEFAULT_MAX_LINES)
+    parser.add_argument("--context", type=int, default=DEFAULT_CONTEXT_LINES)
+    parser.add_argument(
+        "--json", action="store_true", help="Emit JSON instead of text output."
+    )
+    return parser.parse_args()
+
+
+def main() -> int:
+    args = parse_args()
+    repo_root = find_git_root(Path(args.repo))
+    if repo_root is None:
+        print("Error: not inside a Git repository.", file=sys.stderr)
+        return 1
+
+    if not ensure_gh_available(repo_root):
+        return 1
+
+    pr_value = resolve_pr(args.pr, repo_root)
+    if pr_value is None:
+        return 1
+
+    checks = fetch_checks(pr_value, repo_root)
+    if checks is None:
+        return 1
+
+    failing = [c for c in checks if is_failing(c)]
+    if not failing:
+        print(f"PR #{pr_value}: no failing checks detected.")
+        return 0
+
+    results = []
+    for check in failing:
+        results.append(
+            analyze_check(
+                check,
+                repo_root=repo_root,
+                max_lines=max(1, args.max_lines),
+                context=max(1, args.context),
+            )
+        )
+
+    if args.json:
+        print(json.dumps({"pr": pr_value, "results": results}, indent=2))
+    else:
+        render_results(pr_value, results)
+
+    return 1
+
+
+def find_git_root(start: Path) -> Path | None:
+    result = subprocess.run(
+        ["git", "rev-parse", "--show-toplevel"],
+        cwd=start,
+        text=True,
+        capture_output=True,
+    )
+    if result.returncode != 0:
+        return None
+    return Path(result.stdout.strip())
+
+
+def ensure_gh_available(repo_root: Path) -> bool:
+    if which("gh") is None:
+        print("Error: gh is not installed or not on PATH.", file=sys.stderr)
+        return False
+    result = run_gh_command(["auth", "status"], cwd=repo_root)
+    if result.returncode == 0:
+        return True
+    message = (result.stderr or result.stdout or "").strip()
+    print(message or "Error: gh not authenticated.", file=sys.stderr)
+    return False
+
+
+def resolve_pr(pr_value: str | None, repo_root: Path) -> str | None:
+    if pr_value:
+        return pr_value
+    result = run_gh_command(["pr", "view", "--json", "number"], cwd=repo_root)
+    if result.returncode != 0:
+        message = (result.stderr or result.stdout or "").strip()
+        print(message or "Error: unable to resolve PR.", file=sys.stderr)
+        return None
+    try:
+        data = json.loads(result.stdout or "{}")
+    except json.JSONDecodeError:
+        print("Error: unable to parse PR JSON.", file=sys.stderr)
+        return None
+    number = data.get("number")
+    if not number:
+        print("Error: no PR number found.", file=sys.stderr)
+        return None
+    return str(number)
+
+
+def fetch_checks(pr_value: str, repo_root: Path) -> list[dict[str, Any]] | None:
+    primary_fields = [
+        "name",
+        "state",
+        "conclusion",
+        "detailsUrl",
+        "startedAt",
+        "completedAt",
+    ]
+    result = run_gh_command(
+        ["pr", "checks", pr_value, "--json", ",".join(primary_fields)],
+        cwd=repo_root,
+    )
+    if result.returncode != 0:
+        message = "\n".join(filter(None, [result.stderr, result.stdout])).strip()
+        available_fields = parse_available_fields(message)
+        if available_fields:
+            fallback_fields = [
+                "name",
+                "state",
+                "bucket",
+                "link",
+                "startedAt",
+                "completedAt",
+                "workflow",
+            ]
+            selected_fields = [
+                field for field in fallback_fields if field in available_fields
+            ]
+            if not selected_fields:
+                print(
+                    "Error: no usable fields available for gh pr checks.",
+                    file=sys.stderr,
+                )
+                return None
+            result = run_gh_command(
+                ["pr", "checks", pr_value, "--json", ",".join(selected_fields)],
+                cwd=repo_root,
+            )
+            if result.returncode != 0:
+                message = (result.stderr or result.stdout or "").strip()
+                print(message or "Error: gh pr checks failed.", file=sys.stderr)
+                return None
+        else:
+            print(message or "Error: gh pr checks failed.", file=sys.stderr)
+            return None
+    try:
+        data = json.loads(result.stdout or "[]")
+    except json.JSONDecodeError:
+        print("Error: unable to parse checks JSON.", file=sys.stderr)
+        return None
+    if not isinstance(data, list):
+        print("Error: unexpected checks JSON shape.", file=sys.stderr)
+        return None
+    return data
+
+
+def is_failing(check: dict[str, Any]) -> bool:
+    conclusion = normalize_field(check.get("conclusion"))
+    if conclusion in FAILURE_CONCLUSIONS:
+        return True
+    state = normalize_field(check.get("state") or check.get("status"))
+    if state in FAILURE_STATES:
+        return True
+    bucket = normalize_field(check.get("bucket"))
+    return bucket in FAILURE_BUCKETS
+
+
+def analyze_check(
+    check: dict[str, Any],
+    repo_root: Path,
+    max_lines: int,
+    context: int,
+) -> dict[str, Any]:
+    url = check.get("detailsUrl") or check.get("link") or ""
+    run_id = extract_run_id(url)
+    job_id = extract_job_id(url)
+    base: dict[str, Any] = {
+        "name": check.get("name", ""),
+        "detailsUrl": url,
+        "runId": run_id,
+        "jobId": job_id,
+    }
+
+    if run_id is None:
+        base["status"] = "external"
+        base["note"] = "No GitHub Actions run id detected in detailsUrl."
+        return base
+
+    metadata = fetch_run_metadata(run_id, repo_root)
+    log_text, log_error, log_status = fetch_check_log(
+        run_id=run_id,
+        job_id=job_id,
+        repo_root=repo_root,
+    )
+
+    if log_status == "pending":
+        base["status"] = "log_pending"
+        base["note"] = log_error or "Logs are not available yet."
+        if metadata:
+            base["run"] = metadata
+        return base
+
+    if log_error:
+        base["status"] = "log_unavailable"
+        base["error"] = log_error
+        if metadata:
+            base["run"] = metadata
+        return base
+
+    snippet = extract_failure_snippet(log_text, max_lines=max_lines, context=context)
+    base["status"] = "ok"
+    base["run"] = metadata or {}
+    base["logSnippet"] = snippet
+    base["logTail"] = tail_lines(log_text, max_lines)
+    return base
+
+
+def extract_run_id(url: str) -> str | None:
+    if not url:
+        return None
+    for pattern in (r"/actions/runs/(\d+)", r"/runs/(\d+)"):
+        match = re.search(pattern, url)
+        if match:
+            return match.group(1)
+    return None
+
+
+def extract_job_id(url: str) -> str | None:
+    if not url:
+        return None
+    match = re.search(r"/actions/runs/\d+/job/(\d+)", url)
+    if match:
+        return match.group(1)
+    match = re.search(r"/job/(\d+)", url)
+    if match:
+        return match.group(1)
+    return None
+
+
+def fetch_run_metadata(run_id: str, repo_root: Path) -> dict[str, Any] | None:
+    fields = [
+        "conclusion",
+        "status",
+        "workflowName",
+        "name",
+        "event",
+        "headBranch",
+        "headSha",
+        "url",
+    ]
+    result = run_gh_command(
+        ["run", "view", run_id, "--json", ",".join(fields)], cwd=repo_root
+    )
+    if result.returncode != 0:
+        return None
+    try:
+        data = json.loads(result.stdout or "{}")
+    except json.JSONDecodeError:
+        return None
+    if not isinstance(data, dict):
+        return None
+    return data
+
+
+def fetch_check_log(
+    run_id: str,
+    job_id: str | None,
+    repo_root: Path,
+) -> tuple[str, str, str]:
+    log_text, log_error = fetch_run_log(run_id, repo_root)
+    if not log_error:
+        return log_text, "", "ok"
+
+    if is_log_pending_message(log_error) and job_id:
+        job_log, job_error = fetch_job_log(job_id, repo_root)
+        if job_log:
+            return job_log, "", "ok"
+        if job_error and is_log_pending_message(job_error):
+            return "", job_error, "pending"
+        if job_error:
+            return "", job_error, "error"
+        return "", log_error, "pending"
+
+    if is_log_pending_message(log_error):
+        return "", log_error, "pending"
+
+    return "", log_error, "error"
+
+
+def fetch_run_log(run_id: str, repo_root: Path) -> tuple[str, str]:
+    result = run_gh_command(["run", "view", run_id, "--log"], cwd=repo_root)
+    if result.returncode != 0:
+        error = (result.stderr or result.stdout or "").strip()
+        return "", error or "gh run view failed"
+    return result.stdout, ""
+
+
+def fetch_job_log(job_id: str, repo_root: Path) -> tuple[str, str]:
+    repo_slug = fetch_repo_slug(repo_root)
+    if not repo_slug:
+        return "", "Error: unable to resolve repository name for job logs."
+    endpoint = f"/repos/{repo_slug}/actions/jobs/{job_id}/logs"
+    returncode, stdout_bytes, stderr = run_gh_command_raw(
+        ["api", endpoint], cwd=repo_root
+    )
+    if returncode != 0:
+        message = (stderr or stdout_bytes.decode(errors="replace")).strip()
+        return "", message or "gh api job logs failed"
+    if is_zip_payload(stdout_bytes):
+        return "", "Job logs returned a zip archive; unable to parse."
+    return stdout_bytes.decode(errors="replace"), ""
+
+
+def fetch_repo_slug(repo_root: Path) -> str | None:
+    result = run_gh_command(["repo", "view", "--json", "nameWithOwner"], cwd=repo_root)
+    if result.returncode != 0:
+        return None
+    try:
+        data = json.loads(result.stdout or "{}")
+    except json.JSONDecodeError:
+        return None
+    name_with_owner = data.get("nameWithOwner")
+    if not name_with_owner:
+        return None
+    return str(name_with_owner)
+
+
+def normalize_field(value: Any) -> str:
+    if value is None:
+        return ""
+    return str(value).strip().lower()
+
+
+def parse_available_fields(message: str) -> list[str]:
+    if "Available fields:" not in message:
+        return []
+    fields: list[str] = []
+    collecting = False
+    for line in message.splitlines():
+        if "Available fields:" in line:
+            collecting = True
+            continue
+        if not collecting:
+            continue
+        field = line.strip()
+        if not field:
+            continue
+        fields.append(field)
+    return fields
+
+
+def is_log_pending_message(message: str) -> bool:
+    lowered = message.lower()
+    return any(marker in lowered for marker in PENDING_LOG_MARKERS)
+
+
+def is_zip_payload(payload: bytes) -> bool:
+    return payload.startswith(b"PK")
+
+
+def extract_failure_snippet(log_text: str, max_lines: int, context: int) -> str:
+    lines = log_text.splitlines()
+    if not lines:
+        return ""
+
+    marker_index = find_failure_index(lines)
+    if marker_index is None:
+        return "\n".join(lines[-max_lines:])
+
+    start = max(0, marker_index - context)
+    end = min(len(lines), marker_index + context)
+    window = lines[start:end]
+    if len(window) > max_lines:
+        window = window[-max_lines:]
+    return "\n".join(window)
+
+
+def find_failure_index(lines: Sequence[str]) -> int | None:
+    for idx in range(len(lines) - 1, -1, -1):
+        lowered = lines[idx].lower()
+        if any(marker in lowered for marker in FAILURE_MARKERS):
+            return idx
+    return None
+
+
+def tail_lines(text: str, max_lines: int) -> str:
+    if max_lines <= 0:
+        return ""
+    lines = text.splitlines()
+    return "\n".join(lines[-max_lines:])
+
+
+def render_results(pr_number: str, results: Iterable[dict[str, Any]]) -> None:
+    results_list = list(results)
+    print(f"PR #{pr_number}: {len(results_list)} failing checks analyzed.")
+    for result in results_list:
+        print("-" * 60)
+        print(f"Check: {result.get('name', '')}")
+        if result.get("detailsUrl"):
+            print(f"Details: {result['detailsUrl']}")
+        run_id = result.get("runId")
+        if run_id:
+            print(f"Run ID: {run_id}")
+        job_id = result.get("jobId")
+        if job_id:
+            print(f"Job ID: {job_id}")
+        status = result.get("status", "unknown")
+        print(f"Status: {status}")
+
+        run_meta = result.get("run", {})
+        if run_meta:
+            branch = run_meta.get("headBranch", "")
+            sha = (run_meta.get("headSha") or "")[:12]
+            workflow = run_meta.get("workflowName") or run_meta.get("name") or ""
+            conclusion = run_meta.get("conclusion") or run_meta.get("status") or ""
+            print(f"Workflow: {workflow} ({conclusion})")
+            if branch or sha:
+                print(f"Branch/SHA: {branch} {sha}")
+            if run_meta.get("url"):
+                print(f"Run URL: {run_meta['url']}")
+
+        if result.get("note"):
+            print(f"Note: {result['note']}")
+
+        if result.get("error"):
+            print(f"Error fetching logs: {result['error']}")
+            continue
+
+        snippet = result.get("logSnippet") or ""
+        if snippet:
+            print("Failure snippet:")
+            print(indent_block(snippet, prefix="  "))
+        else:
+            print("No snippet available.")
+    print("-" * 60)
+
+
+def indent_block(text: str, prefix: str = "  ") -> str:
+    return "\n".join(f"{prefix}{line}" for line in text.splitlines())
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
dots/config/claude/skills/GHFixCI/workflows/FixCI.md
@@ -0,0 +1,90 @@
+# FixCI Workflow
+
+## Steps
+
+### 1. Verify Authentication
+
+```bash
+gh auth status
+```
+
+If not authenticated, stop and ask user to run `gh auth login`.
+
+### 2. Resolve PR
+
+If user provided a PR number or URL, use it. Otherwise resolve from current branch:
+
+```bash
+gh pr view --json number,url
+```
+
+### 3. Inspect Failing Checks
+
+Run the bundled script first:
+
+```bash
+python3 "<skill-dir>/scripts/inspect_pr_checks.py" --repo "." --pr "<number>"
+```
+
+The script:
+- Fetches all PR checks
+- Filters to failing ones (failure, cancelled, timed_out, action_required)
+- Pulls GitHub Actions logs for each failure
+- Extracts failure snippets around error markers
+- Reports run metadata (workflow, branch, SHA)
+
+### 4. Analyze Failures
+
+For each failing check:
+1. Read the failure snippet from script output
+2. Identify the root cause category:
+   - **Test failure**: Which test(s) failed, what assertion broke
+   - **Build failure**: Compilation error, missing dependency
+   - **Lint/format**: Style violation, formatting issue
+   - **Timeout**: Which step hung, resource exhaustion
+   - **Infra**: Runner issue, network failure, flaky setup
+3. Check if the failure is **actionable** (code fix needed) vs **transient** (retry may fix)
+
+For GitHub Actions-specific issues (workflow config, permissions, caching), delegate to `reviewer-ghactions` subagent for deeper analysis.
+
+### 5. Summarize and Plan
+
+Present a summary:
+
+```
+## CI Failures for PR #<number>
+
+### <Check Name> — <Category>
+**Status:** <conclusion>
+**Workflow:** <workflow name>
+**Cause:** <one-line explanation>
+**Evidence:**
+```
+<relevant log snippet>
+```
+**Fix:** <proposed action>
+
+### ...
+```
+
+### 6. Wait for Approval
+
+**CRITICAL: Do NOT edit code without explicit user approval.**
+
+Ask: "Should I implement these fixes?" or present options if multiple approaches exist.
+
+### 7. Implement Fix
+
+After approval:
+1. Make the code changes
+2. Run relevant local tests to verify
+3. Report what was changed
+
+### 8. Verify
+
+After fix is pushed:
+```bash
+gh pr checks <number> --watch
+```
+
+Or suggest user to check CI status after push.
dots/config/claude/skills/GHFixCI/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: GHFixCI
+description: Inspect and fix failing GitHub Actions checks on a pull request. USE WHEN user says 'fix ci', 'fix checks', 'why is CI failing', 'debug CI', 'ci is red', or wants to diagnose and fix PR check failures.
+---
+
+# GitHub PR CI Fix
+
+Diagnose failing GitHub Actions checks on a pull request, summarize failures with log evidence, and propose or implement fixes after user approval.
+
+## Workflow Routing
+
+| Workflow | Trigger | File |
+|----------|---------|------|
+| **FixCI** | "fix ci", "fix checks", "why is CI failing", "debug CI" | `workflows/FixCI.md` |
+
+## Prerequisites
+
+- `gh` is installed and authenticated
+- Target repository is available locally
+- Target PR is either provided or associated with the current branch
+
+Non-GitHub Actions providers (Buildkite, Jenkins, etc.) are treated as external — report their details URL but don't try to debug them.
+
+## Quick Start
+
+```bash
+python3 "<path-to-skill>/scripts/inspect_pr_checks.py" --repo "." --pr "<number-or-url>"
+# Add --json for structured output
+```
+
+## Workflow
+
+1. Verify `gh auth status`
+2. Resolve the PR from the current branch or user input
+3. Inspect failing checks with the bundled script
+4. Pull relevant Actions logs and extract the failure snippet
+5. Summarize: failing checks, log evidence, likely cause
+6. Draft a concise fix plan and **wait for user approval before editing code**
+7. After changes, rerun relevant tests and recheck PR status
+
+## Manual Fallback
+
+If the script is insufficient:
+
+```bash
+gh pr checks <pr> --json name,state,conclusion,detailsUrl
+gh run view <run_id> --json conclusion,status,workflowName,name,event,headBranch,headSha,url
+gh run view <run_id> --log
+```
+
+## Integration
+
+- Uses `reviewer-ghactions` subagent for GitHub Actions-specific analysis when deeper review is needed
+- References `gh-pr` tool for comprehensive PR context (`gh-pr review <number> --llm`)
+- For basic check status reporting without fixes, see the **GitHub** skill's CheckStatus workflow
+
+## Related Skills
+
+- **GitHub**: PR management, check status reporting, CI restarts
+- **CodeReview**: Code review with `architecture` focus via oracle subagent
dots/config/claude/skills/GHReviewBotComments/scripts/fetch_bot_reviews.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+"""
+Fetch unresolved PR review threads authored by bots for the current branch's PR.
+
+Outputs JSON with:
+  - pull_request metadata (number, url, title, owner, repo)
+  - bot_threads: list of unresolved review threads where the first comment
+    was authored by a bot (login ending in [bot] or type Bot)
+
+Usage:
+  python3 fetch_bot_reviews.py [--pr NUMBER]
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from typing import Any
+
+QUERY = """\
+query(
+  $owner: String!,
+  $repo: String!,
+  $number: Int!,
+  $threadsCursor: String
+) {
+  repository(owner: $owner, name: $repo) {
+    pullRequest(number: $number) {
+      number
+      url
+      title
+      state
+      reviewThreads(first: 100, after: $threadsCursor) {
+        pageInfo { hasNextPage endCursor }
+        nodes {
+          id
+          isResolved
+          isOutdated
+          path
+          line
+          startLine
+          comments(first: 50) {
+            nodes {
+              id
+              databaseId
+              body
+              createdAt
+              author {
+                login
+                __typename
+              }
+              url
+            }
+          }
+        }
+      }
+    }
+  }
+}
+"""
+
+
+def _run(cmd: list[str], stdin: str | None = None) -> str:
+    p = subprocess.run(cmd, input=stdin, capture_output=True, text=True)
+    if p.returncode != 0:
+        raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}")
+    return p.stdout
+
+
+def _run_json(cmd: list[str], stdin: str | None = None) -> dict[str, Any]:
+    out = _run(cmd, stdin=stdin)
+    try:
+        return json.loads(out)
+    except json.JSONDecodeError as e:
+        raise RuntimeError(f"Failed to parse JSON: {e}\nRaw:\n{out}") from e
+
+
+def _is_bot(author: dict[str, Any] | None) -> bool:
+    if not author:
+        return False
+    if author.get("__typename") == "Bot":
+        return True
+    login = author.get("login", "")
+    return login.endswith("[bot]")
+
+
+def get_pr_ref(pr_number: int | None = None) -> tuple[str, str, int]:
+    """Resolve owner, repo, number from the PR URL (works for fork PRs too)."""
+    cmd = ["gh", "pr", "view", "--json", "number,url"]
+    if pr_number is not None:
+        cmd.insert(3, str(pr_number))
+    pr = _run_json(cmd)
+    number = int(pr["number"])
+    # URL format: https://github.com/{owner}/{repo}/pull/{number}
+    parts = pr["url"].rstrip("/").split("/")
+    owner = parts[-4]
+    repo = parts[-3]
+    return owner, repo, number
+
+
+def fetch_threads(
+    owner: str, repo: str, number: int
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+    """Fetch all review threads, return (pr_meta, all_threads)."""
+    all_threads: list[dict[str, Any]] = []
+    cursor: str | None = None
+    pr_meta: dict[str, Any] | None = None
+
+    while True:
+        cmd = [
+            "gh",
+            "api",
+            "graphql",
+            "-F",
+            "query=@-",
+            "-F",
+            f"owner={owner}",
+            "-F",
+            f"repo={repo}",
+            "-F",
+            f"number={number}",
+        ]
+        if cursor:
+            cmd += ["-F", f"threadsCursor={cursor}"]
+
+        payload = _run_json(cmd, stdin=QUERY)
+        if "errors" in payload and payload["errors"]:
+            raise RuntimeError(
+                f"GraphQL errors:\n{json.dumps(payload['errors'], indent=2)}"
+            )
+
+        pr = payload["data"]["repository"]["pullRequest"]
+        if pr_meta is None:
+            pr_meta = {
+                "number": pr["number"],
+                "url": pr["url"],
+                "title": pr["title"],
+                "state": pr["state"],
+                "owner": owner,
+                "repo": repo,
+            }
+
+        t = pr["reviewThreads"]
+        all_threads.extend(t.get("nodes") or [])
+
+        if t["pageInfo"]["hasNextPage"]:
+            cursor = t["pageInfo"]["endCursor"]
+        else:
+            break
+
+    assert pr_meta is not None
+    return pr_meta, all_threads
+
+
+def filter_bot_unresolved(threads: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    """Return only unresolved threads where the first comment is by a bot."""
+    result = []
+    for thread in threads:
+        if thread.get("isResolved"):
+            continue
+        comments = thread.get("comments", {}).get("nodes", [])
+        if not comments:
+            continue
+        first_author = comments[0].get("author")
+        if _is_bot(first_author):
+            first = comments[0]
+            # Extract suggestion blocks
+            body = first.get("body", "")
+            has_suggestion = "```suggestion" in body
+            result.append(
+                {
+                    "thread_id": thread["id"],
+                    "file": thread.get("path", ""),
+                    "line": thread.get("line"),
+                    "start_line": thread.get("startLine"),
+                    "is_outdated": thread.get("isOutdated", False),
+                    "comment_id": first.get("databaseId"),
+                    "comment_url": first.get("url", ""),
+                    "author": first_author.get("login", "unknown"),
+                    "body": body,
+                    "has_suggestion": has_suggestion,
+                    "reply_count": len(comments) - 1,
+                }
+            )
+    return result
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Fetch unresolved bot review threads")
+    parser.add_argument(
+        "--pr", type=int, default=None, help="PR number (defaults to current branch PR)"
+    )
+    args = parser.parse_args()
+
+    try:
+        _run(["gh", "auth", "status"])
+    except RuntimeError:
+        print("gh not authenticated. Run: gh auth login", file=sys.stderr)
+        sys.exit(1)
+
+    owner, repo, number = get_pr_ref(args.pr)
+    pr_meta, threads = fetch_threads(owner, repo, number)
+    bot_threads = filter_bot_unresolved(threads)
+
+    output = {
+        "pull_request": pr_meta,
+        "bot_threads": bot_threads,
+        "total_unresolved_bot_threads": len(bot_threads),
+    }
+    print(json.dumps(output, indent=2))
+
+
+if __name__ == "__main__":
+    main()
dots/config/claude/skills/GHReviewBotComments/workflows/TriageBotComments.md
@@ -0,0 +1,89 @@
+# TriageBotComments Workflow
+
+## Steps
+
+### 1. Fetch Unresolved Bot Threads
+
+```bash
+python3 "<skill-dir>/scripts/fetch_bot_reviews.py"
+# or with explicit PR
+python3 "<skill-dir>/scripts/fetch_bot_reviews.py" --pr <number>
+```
+
+If no bot threads found, report "No unresolved bot comments" and stop.
+
+### 2. Analyze Each Comment
+
+For each thread:
+1. Read the referenced code at the file/line from the thread
+2. Classify: `bug`, `duplication`, `style`, `refactor`, `incorrect`
+3. Judge severity: `high`, `medium`, `low`
+4. Determine recommendation: `fix` or `discard`
+
+**Verify every claim against actual code.** Bots frequently misread context.
+
+### 3. Present Recommendations
+
+Show a compact numbered summary:
+
+```
+## Bot Comment Triage — PR #<number>
+
+| # | File:Line | Bot | Category | Severity | Recommendation |
+|---|-----------|-----|----------|----------|----------------|
+| 1 | src/auth.go:42 | golangci-lint | bug | high | fix |
+| 2 | src/util.go:18 | copilot | style | low | discard |
+| 3 | src/api.go:99 | coderabbit | incorrect | - | discard |
+
+### Details
+
+**1. src/auth.go:42** — golangci-lint flagged unchecked error return
+Severity: high — real bug, error is silently dropped
+→ Recommend: fix
+
+**2. src/util.go:18** — copilot suggests renaming variable
+Severity: low — current name is fine in context
+→ Recommend: discard
+
+**3. src/api.go:99** — coderabbit claims unused parameter
+Severity: n/a — parameter IS used on line 105, bot misread
+→ Recommend: discard as incorrect
+```
+
+Ask user which items to fix (if not already specified).
+
+### 4. Apply Selected Fixes
+
+For each chosen fix:
+1. Make the code change
+2. Run any relevant formatter
+3. Run the smallest useful test set
+
+### 5. Reply and Resolve
+
+For every targeted thread, post a professional reply:
+
+**Fixed:**
+```
+Fixed — [brief description of what changed].
+```
+
+**Discarded as incorrect:**
+```
+Keeping current code — [brief explanation why suggestion doesn't apply].
+```
+
+**Discarded as optional:**
+```
+Acknowledged — keeping current approach [brief reason].
+```
+
+Then resolve the thread:
+```bash
+gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies -f body="<reply>"
+gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<thread_id>"}) { thread { id isResolved } } }'
+```
+
+### 6. Verify
+
+Run the fetch script again and confirm targeted threads are resolved.
dots/config/claude/skills/GHReviewBotComments/SKILL.md
@@ -0,0 +1,67 @@
+---
+name: GHReviewBotComments
+description: Triage unresolved automated PR review comments from bots, classify validity, implement fixes, and reply to threads. USE WHEN user says 'bot comments', 'review bot feedback', 'triage bot reviews', 'fix bot comments', or wants to handle automated review comments on a PR.
+---
+
+# Bot Review Comment Triage
+
+Sort automated review comments into actionable vs non-actionable feedback, apply the changes that matter, and leave clear professional replies for every thread.
+
+## Workflow Routing
+
+| Workflow | Trigger | File |
+|----------|---------|------|
+| **TriageBotComments** | "bot comments", "triage bot reviews", "fix bot comments" | `workflows/TriageBotComments.md` |
+
+## Prerequisites
+
+- `gh` is installed and authenticated
+- Target PR is available from current branch or via `--pr`
+
+## Quick Start
+
+```bash
+python3 "<path-to-skill>/scripts/fetch_bot_reviews.py"
+python3 "<path-to-skill>/scripts/fetch_bot_reviews.py" --pr <number>
+```
+
+Returns JSON with unresolved threads authored by bots, including file, line, thread ID, comment ID, and body.
+
+## Classification
+
+For each bot comment, classify as:
+
+| Category | Description |
+|----------|-------------|
+| `bug` | Real bug or correctness risk |
+| `duplication` | Code duplication issue |
+| `style` | Style or formatting suggestion |
+| `refactor` | Refactoring suggestion |
+| `incorrect` | Bot misread context, suggestion is wrong |
+
+And severity:
+
+| Severity | Meaning |
+|----------|---------|
+| `high` | Real bugs or correctness risks — should fix |
+| `medium` | Valid but optional improvements |
+| `low` | Style or low-value cleanup |
+
+**Always verify bot claims against actual code.** Bots often misread context.
+
+## Reply and Resolve
+
+For each resolved thread, post a reply via GitHub API:
+
+```bash
+# Reply to a comment
+gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies -f body="<reply>"
+
+# Resolve the thread
+gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "<thread_id>"}) { thread { id isResolved } } }'
+```
+
+## Related Skills
+
+- **GitHub**: PR management, ResolvePRComments workflow (for human review comments)
+- **GHFixCI**: For CI check failures (different from review comments)