Skip to content

Commit c2936ff

Browse files
committed
chore(ci): add Warden review setup and skill catalog
Wire Warden into PR review using the getsentry/warden GitHub Action. - warden.toml: 13 skills total - 8 project-specific review skills scoped to the file lists in each SKILL.md (docs-release, packaging-resource, rendering-streaming, runtime-boundary, snapshot-fixture, structured-output, test-boundary, tool-contract) - 5 remote skills from getsentry/skills (find-bugs, security-review, gha-security-review, code-review, code-simplifier) scoped to the surfaces they apply to - warden-sweep is intentionally not registered: invoke manually via warden --skill warden-sweep - .github/workflows/warden.yml runs on opened/synchronize/reopened - .agents/skills/ holds local skill sources (also exposed via .claude/skills symlink)
1 parent e5d8ca2 commit c2936ff

26 files changed

Lines changed: 3683 additions & 0 deletions

File tree

.agents/skills/warden-sweep/SKILL.md

Lines changed: 400 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
Fix a verified code issue. You are working in a git worktree at: ${WORKTREE}
2+
3+
## Finding
4+
- Title: ${TITLE}
5+
- File: ${FILE_PATH}:${START_LINE}
6+
- Description: ${DESCRIPTION}
7+
- Verification: ${REASONING}
8+
- Suggested Fix: ${FIX_DESCRIPTION}
9+
```diff
10+
${FIX_DIFF}
11+
```
12+
13+
## Instructions
14+
15+
### Step 1: Understand the code
16+
Read the file at ${WORKTREE}/${FILE_PATH}. Read at least 50 lines above and below the reported location. Trace callers and callees of the affected code using Grep/Glob to understand how it is used. Do NOT skip this step.
17+
18+
### Step 2: Apply a minimal fix
19+
Apply the smallest change that addresses the finding. If the suggested diff doesn't apply cleanly, adapt it while preserving intent. Do NOT refactor surrounding code, rename variables, add comments, or make any change beyond what the finding requires.
20+
21+
### Step 3: Write tests
22+
Write or update tests that verify the fix:
23+
- Follow existing test patterns (co-located files, same framework)
24+
- At minimum, write a test that would have caught the original bug
25+
- Test the specific edge case, not just the happy path
26+
27+
Only modify the fix target and its test file.
28+
29+
### Step 4: Self-review
30+
Before staging, run `git diff` in the worktree and review every changed line. Verify:
31+
1. The change addresses the specific finding described, not something else
32+
2. No unrelated code was modified (no drive-by cleanups, no formatting changes)
33+
3. Trace through changed code paths: does the fix introduce any new bug, null reference, type error, or broken import?
34+
4. Tests exercise the fix (the failure case), not just that the code runs
35+
36+
If ANY check fails, fix the problem before proceeding. If the suggested fix is wrong or would introduce a regression you cannot resolve, do NOT commit. Instead, skip to the output step and report why.
37+
38+
### Step 5: Commit
39+
Do NOT run tests locally. CI will validate the changes.
40+
41+
Stage and commit with this exact message:
42+
43+
fix: ${TITLE}
44+
45+
Warden finding ${FINDING_ID}
46+
Severity: ${SEVERITY}
47+
48+
Co-Authored-By: Warden <noreply@getsentry.com>
49+
50+
### Step 6: Output
51+
Return ONLY valid JSON (no surrounding text). Use `"status": "applied"` if you committed a fix, or `"status": "skipped"` if you did not.
52+
53+
```json
54+
{
55+
"status": "applied",
56+
"filesChanged": ["src/example.ts"],
57+
"testFilesChanged": ["src/example.test.ts"],
58+
"selfReview": "Verified the fix addresses the null check and test covers the failure case",
59+
"skipReason": null
60+
}
61+
```
62+
63+
When skipping:
64+
```json
65+
{
66+
"status": "skipped",
67+
"filesChanged": [],
68+
"testFilesChanged": [],
69+
"selfReview": null,
70+
"skipReason": "The suggested fix would introduce a regression in the error handling path"
71+
}
72+
```
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
Verify a code analysis finding. Determine if this is a TRUE issue or a FALSE POSITIVE.
2+
Do NOT write or edit any files. Research only.
3+
4+
## Finding
5+
- Title: ${TITLE}
6+
- Severity: ${SEVERITY} | Confidence: ${CONFIDENCE}
7+
- Skill: ${SKILL}
8+
- Location: ${FILE_PATH}:${START_LINE}-${END_LINE}
9+
- Description: ${DESCRIPTION}
10+
- Verification hint: ${VERIFICATION}
11+
12+
## Instructions
13+
1. Read the file at the reported location. Examine at least 50 lines of surrounding context.
14+
2. Trace data flow to/from the flagged code using Grep/Glob.
15+
3. Check if the issue is mitigated elsewhere (guards, validation, try/catch upstream).
16+
4. Check if the issue is actually reachable in practice.
17+
18+
Return your verdict as JSON:
19+
{
20+
"findingId": "${FINDING_ID}",
21+
"verdict": "verified" or "rejected",
22+
"confidence": "high" or "medium" or "low",
23+
"reasoning": "2-3 sentence explanation",
24+
"traceNotes": "What code paths you examined"
25+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Shared utilities for warden-sweep scripts."""
2+
from __future__ import annotations
3+
4+
import json
5+
import os
6+
import subprocess
7+
from typing import Any
8+
9+
10+
def run_cmd(
11+
args: list[str], timeout: int = 30, cwd: str | None = None
12+
) -> subprocess.CompletedProcess[str]:
13+
"""Run a command and return the result."""
14+
return subprocess.run(
15+
args,
16+
capture_output=True,
17+
text=True,
18+
timeout=timeout,
19+
cwd=cwd,
20+
)
21+
22+
23+
def run_cmd_stdout(
24+
args: list[str], timeout: int = 30, cwd: str | None = None
25+
) -> str | None:
26+
"""Run a command and return stripped stdout, or None on failure."""
27+
try:
28+
result = run_cmd(args, timeout=timeout, cwd=cwd)
29+
return result.stdout.strip() if result.returncode == 0 else None
30+
except (subprocess.TimeoutExpired, FileNotFoundError):
31+
return None
32+
33+
34+
def read_json(path: str) -> dict[str, Any] | None:
35+
"""Read a JSON file and return parsed object, or None on failure."""
36+
if not os.path.exists(path):
37+
return None
38+
try:
39+
with open(path) as f:
40+
return json.load(f)
41+
except (json.JSONDecodeError, OSError):
42+
return None
43+
44+
45+
def write_json(path: str, data: dict[str, Any]) -> None:
46+
"""Write a dict to a JSON file with trailing newline."""
47+
with open(path, "w") as f:
48+
json.dump(data, f, indent=2)
49+
f.write("\n")
50+
51+
52+
def read_jsonl(path: str) -> list[dict[str, Any]]:
53+
"""Read a JSONL file and return list of parsed objects."""
54+
entries: list[dict[str, Any]] = []
55+
if not os.path.exists(path):
56+
return entries
57+
with open(path) as f:
58+
for line in f:
59+
line = line.strip()
60+
if not line:
61+
continue
62+
try:
63+
entries.append(json.loads(line))
64+
except json.JSONDecodeError:
65+
continue
66+
return entries
67+
68+
69+
def severity_badge(severity: str) -> str:
70+
"""Return a markdown-friendly severity indicator."""
71+
badges = {
72+
"critical": "**CRITICAL**",
73+
"high": "**HIGH**",
74+
"medium": "MEDIUM",
75+
"low": "LOW",
76+
"info": "info",
77+
}
78+
return badges.get(severity, severity)
79+
80+
81+
def pr_number_from_url(pr_url: str) -> str:
82+
"""Extract the PR or issue number from a GitHub URL's last path segment."""
83+
return pr_url.rstrip("/").split("/")[-1]
84+
85+
86+
def ensure_github_label(name: str, color: str, description: str) -> None:
87+
"""Create a GitHub label if it doesn't exist (idempotent)."""
88+
try:
89+
subprocess.run(
90+
[
91+
"gh", "label", "create", name,
92+
"--color", color,
93+
"--description", description,
94+
],
95+
capture_output=True,
96+
timeout=15,
97+
)
98+
except (subprocess.TimeoutExpired, FileNotFoundError):
99+
pass
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env python3
2+
# /// script
3+
# requires-python = ">=3.9"
4+
# ///
5+
"""
6+
Warden Sweep: Create tracking issue.
7+
8+
Creates a GitHub issue summarizing the sweep results after verification
9+
but before patching. Gives every PR a parent to reference and gives
10+
reviewers a single place to see the full picture.
11+
12+
Usage:
13+
uv run create_issue.py <sweep-dir>
14+
15+
Stdout: JSON with issueUrl and issueNumber
16+
Stderr: Progress lines
17+
18+
Idempotent: if issueUrl already exists in manifest, skips creation.
19+
"""
20+
from __future__ import annotations
21+
22+
import argparse
23+
import json
24+
import os
25+
import subprocess
26+
import sys
27+
from typing import Any
28+
29+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30+
from _utils import ( # noqa: E402
31+
ensure_github_label,
32+
pr_number_from_url,
33+
read_json,
34+
read_jsonl,
35+
severity_badge,
36+
write_json,
37+
)
38+
39+
40+
def build_issue_body(
41+
run_id: str,
42+
scan_index: list[dict[str, Any]],
43+
all_findings: list[dict[str, Any]],
44+
verified: list[dict[str, Any]],
45+
rejected: list[dict[str, Any]],
46+
) -> str:
47+
"""Build the GitHub issue body markdown."""
48+
files_scanned = sum(1 for e in scan_index if e.get("status") == "complete")
49+
files_timed_out = sum(
50+
1 for e in scan_index
51+
if e.get("status") == "error" and e.get("error") == "timeout"
52+
)
53+
files_errored = sum(
54+
1 for e in scan_index
55+
if e.get("status") == "error" and e.get("error") != "timeout"
56+
)
57+
58+
# Collect unique skills from scan index
59+
skills: set[str] = set()
60+
for entry in scan_index:
61+
for skill in entry.get("skills", []):
62+
skills.add(skill)
63+
64+
lines = [
65+
f"## Warden Sweep `{run_id}`",
66+
"",
67+
"| Metric | Count |",
68+
"|--------|-------|",
69+
f"| Files scanned | {files_scanned} |",
70+
f"| Files timed out | {files_timed_out} |",
71+
f"| Files errored | {files_errored} |",
72+
f"| Total findings | {len(all_findings)} |",
73+
f"| Verified | {len(verified)} |",
74+
f"| Rejected | {len(rejected)} |",
75+
"",
76+
]
77+
78+
if verified:
79+
lines.append("### Verified Findings")
80+
lines.append("")
81+
lines.append("| Severity | Skill | File | Title |")
82+
lines.append("|----------|-------|------|-------|")
83+
for f in verified:
84+
sev = severity_badge(f.get("severity", "info"))
85+
skill = f.get("skill", "")
86+
file_path = f.get("file", "")
87+
start_line = f.get("startLine")
88+
location = f"{file_path}:{start_line}" if start_line else file_path
89+
title = f.get("title", "")
90+
lines.append(f"| {sev} | {skill} | `{location}` | {title} |")
91+
lines.append("")
92+
93+
if skills:
94+
lines.append("### Skills Run")
95+
lines.append("")
96+
lines.append(", ".join(sorted(skills)))
97+
lines.append("")
98+
99+
lines.append("> Generated by Warden Sweep. PRs referencing this issue will appear below.")
100+
101+
return "\n".join(lines) + "\n"
102+
103+
104+
def create_github_issue(title: str, body: str) -> dict[str, Any]:
105+
"""Create a GitHub issue with the warden label. Returns issueUrl and issueNumber."""
106+
ensure_github_label("warden", "5319E7", "Automated fix from Warden Sweep")
107+
108+
result = subprocess.run(
109+
[
110+
"gh", "issue", "create",
111+
"--label", "warden",
112+
"--title", title,
113+
"--body", body,
114+
],
115+
capture_output=True,
116+
text=True,
117+
timeout=30,
118+
)
119+
120+
if result.returncode != 0:
121+
raise RuntimeError(f"gh issue create failed: {result.stderr.strip()}")
122+
123+
issue_url = result.stdout.strip()
124+
try:
125+
issue_number = int(pr_number_from_url(issue_url))
126+
except (ValueError, IndexError):
127+
raise RuntimeError(f"Could not parse issue number from gh output: {issue_url}")
128+
129+
return {"issueUrl": issue_url, "issueNumber": issue_number}
130+
131+
132+
def main() -> None:
133+
parser = argparse.ArgumentParser(
134+
description="Warden Sweep: Create tracking issue"
135+
)
136+
parser.add_argument("sweep_dir", help="Path to the sweep directory")
137+
args = parser.parse_args()
138+
139+
sweep_dir = args.sweep_dir
140+
data_dir = os.path.join(sweep_dir, "data")
141+
manifest_path = os.path.join(data_dir, "manifest.json")
142+
143+
if not os.path.isdir(sweep_dir):
144+
print(
145+
json.dumps({"error": f"Sweep directory not found: {sweep_dir}"}),
146+
file=sys.stdout,
147+
)
148+
sys.exit(1)
149+
150+
manifest = read_json(manifest_path) or {}
151+
152+
# Idempotency: if issue already exists, return existing values
153+
if manifest.get("issueUrl"):
154+
output = {
155+
"issueUrl": manifest["issueUrl"],
156+
"issueNumber": manifest.get("issueNumber", 0),
157+
}
158+
print(json.dumps(output))
159+
return
160+
161+
run_id = manifest.get("runId", "unknown")
162+
163+
# Read sweep data
164+
scan_index = read_jsonl(os.path.join(data_dir, "scan-index.jsonl"))
165+
all_findings = read_jsonl(os.path.join(data_dir, "all-findings.jsonl"))
166+
verified = read_jsonl(os.path.join(data_dir, "verified.jsonl"))
167+
rejected = read_jsonl(os.path.join(data_dir, "rejected.jsonl"))
168+
169+
files_scanned = sum(1 for e in scan_index if e.get("status") == "complete")
170+
171+
# Build issue
172+
title = f"Warden Sweep {run_id}: {len(verified)} findings across {files_scanned} files"
173+
body = build_issue_body(run_id, scan_index, all_findings, verified, rejected)
174+
175+
print("Creating tracking issue...", file=sys.stderr)
176+
result = create_github_issue(title, body)
177+
print(f"Created issue: {result['issueUrl']}", file=sys.stderr)
178+
179+
# Write issueUrl and issueNumber to manifest
180+
manifest["issueUrl"] = result["issueUrl"]
181+
manifest["issueNumber"] = result["issueNumber"]
182+
manifest.setdefault("phases", {})["issue"] = "complete"
183+
write_json(manifest_path, manifest)
184+
185+
print(json.dumps(result))
186+
187+
188+
if __name__ == "__main__":
189+
main()

0 commit comments

Comments
 (0)