From 4367a4753833d13f98f96aad7de1eb740884d67b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 22 Feb 2026 12:34:24 -0800 Subject: [PATCH 1/3] feat(sweep): Add master tracking issue for sweep PRs Create a GitHub issue before patching that summarizes sweep results and gives reviewers a single coordination point. Each PR references the tracking issue, and the organize phase posts a completion comment with a task list of all created PRs. New script create_issue.py handles issue creation with idempotency (skips if issueUrl already in manifest). Also extracts shared utilities (read_json, write_json, severity_badge, pr_number_from_url) into _utils.py, improves find_reviewers.py to exclude the current user, and separates timeout vs error tracking in scan output. Co-Authored-By: Claude Opus 4.6 --- skills/warden-sweep/SKILL.md | 59 ++++- skills/warden-sweep/scripts/_utils.py | 35 +++ skills/warden-sweep/scripts/create_issue.py | 204 ++++++++++++++++++ skills/warden-sweep/scripts/find_reviewers.py | 16 +- .../warden-sweep/scripts/generate_report.py | 45 ++-- skills/warden-sweep/scripts/organize.py | 117 +++++++++- skills/warden-sweep/scripts/scan.py | 73 ++++--- 7 files changed, 475 insertions(+), 74 deletions(-) create mode 100644 skills/warden-sweep/scripts/create_issue.py diff --git a/skills/warden-sweep/SKILL.md b/skills/warden-sweep/SKILL.md index cb7bbe7d..a5b0e37c 100644 --- a/skills/warden-sweep/SKILL.md +++ b/skills/warden-sweep/SKILL.md @@ -30,9 +30,17 @@ Fetches open warden-labeled PRs, builds file-to-PR dedup index, caches diffs for uv run ${CLAUDE_SKILL_ROOT}/scripts/index_prs.py ``` +### `scripts/create_issue.py` + +Creates a GitHub tracking issue summarizing sweep results. Run after verification, before patching. + +```bash +uv run ${CLAUDE_SKILL_ROOT}/scripts/create_issue.py +``` + ### `scripts/organize.py` -Tags security findings, labels security PRs, updates finding reports with PR links, generates summary report, finalizes manifest. +Tags security findings, labels security PRs, updates finding reports with PR links, posts final results to tracking issue, generates summary report, finalizes manifest. ```bash uv run ${CLAUDE_SKILL_ROOT}/scripts/organize.py @@ -87,7 +95,7 @@ Parse the JSON stdout. Save `runId` and `sweepDir` for subsequent phases. ``` ## Scan Complete -Scanned **{filesScanned}** files, **{filesErrored}** errors. +Scanned **{filesScanned}** files, **{filesTimedOut}** timed out, **{filesErrored}** errors. ### Findings ({totalFindings} total) @@ -99,7 +107,7 @@ Scanned **{filesScanned}** files, **{filesErrored}** errors. Render every finding from the `findings` array. Bold severity for high and above. -**On failure**: If exit code 1, show the error JSON and stop. If exit code 2, show the partial results and note which files errored. +**On failure**: If exit code 1, show the error JSON and stop. If exit code 2, show the partial results. List timed-out files separately from errored files so users know which can be retried. --- @@ -111,7 +119,7 @@ Deep-trace each finding using Task subagents to qualify or disqualify. Check if `data/verify/.json` already exists (incrementality). If it does, skip. -Launch a Task subagent (`subagent_type: "general-purpose"`) for each finding. Process findings sequentially (one at a time) to keep output organized. +Launch a Task subagent (`subagent_type: "general-purpose"`) for each finding. Process findings in parallel batches of up to 8 to improve throughput. **Task prompt for each finding:** @@ -195,9 +203,35 @@ Update manifest: set `phases.verify` to `"complete"`. --- -## Phase 3: Patch +## Phase 3: Issue + +Create a tracking issue that ties all PRs together and gives reviewers a single overview. + +**Run** (1 tool call): + +```bash +uv run ${CLAUDE_SKILL_ROOT}/scripts/create_issue.py ${SWEEP_DIR} +``` + +Parse the JSON stdout. Save `issueUrl` and `issueNumber` for Phase 4. + +**Report** to user: + +``` +## Tracking Issue Created + +{issueUrl} +``` + +**On failure**: Show the error. Continue to Phase 4 (PRs can still be created without a tracking issue). + +--- + +## Phase 4: Patch + +For each verified finding, create a worktree, fix the code, and open a draft PR. Process findings **sequentially** (one at a time) since parallel subagents cross-contaminate worktrees. -For each verified finding, create a worktree, fix the code, and open a draft PR. +**Severity triage**: Patch HIGH and above. For MEDIUM, only patch findings from bug-detection skills (e.g., `code-review`, `security-review`). Skip LOW and INFO findings. **Step 0: Index existing PRs** (1 tool call): @@ -275,8 +309,7 @@ uv run ${CLAUDE_SKILL_ROOT}/scripts/find_reviewers.py "${FILE_PATH}" **Step 4: Create draft PR** ```bash -cd "${WORKTREE}" -git push -u origin "${BRANCH}" +cd "${WORKTREE}" && git push -u origin HEAD:"${BRANCH}" ``` Create the PR with a 1-2 sentence "What" summary based on the finding and fix, followed by the finding description and verification reasoning: @@ -298,6 +331,9 @@ ${REASONING} Automated fix for Warden finding ${FINDING_ID} (${SEVERITY}, detected by ${SKILL}). + +Ref #${ISSUE_NUMBER} + > This PR was auto-generated by a Warden Sweep (run ${RUN_ID}). > The finding has been validated through automated deep tracing, > but human confirmation is requested as this is batch work. @@ -340,7 +376,7 @@ Update manifest: set `phases.patch` to `"complete"`. --- -## Phase 4: Organize +## Phase 5: Organize **Run** (1 tool call): @@ -376,8 +412,9 @@ Each phase is incremental. To resume from where you left off: 1. Check `data/manifest.json` to see which phases are complete 2. For scan: pass `--sweep-dir` to `scan.py` 3. For verify: existing `data/verify/.json` files are skipped -4. For patch: existing entries in `data/patches.jsonl` are skipped -5. For organize: safe to re-run (idempotent) +4. For issue: `create_issue.py` is idempotent (skips if `issueUrl` in manifest) +5. For patch: existing entries in `data/patches.jsonl` are skipped +6. For organize: safe to re-run (idempotent) ## Output Directory Structure diff --git a/skills/warden-sweep/scripts/_utils.py b/skills/warden-sweep/scripts/_utils.py index 85f64b29..9e781165 100644 --- a/skills/warden-sweep/scripts/_utils.py +++ b/skills/warden-sweep/scripts/_utils.py @@ -20,6 +20,24 @@ def run_cmd( ) +def read_json(path: str) -> dict[str, Any] | None: + """Read a JSON file and return parsed object, or None on failure.""" + if not os.path.exists(path): + return None + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return None + + +def write_json(path: str, data: dict[str, Any]) -> None: + """Write a dict to a JSON file with trailing newline.""" + with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + + def read_jsonl(path: str) -> list[dict[str, Any]]: """Read a JSONL file and return list of parsed objects.""" entries: list[dict[str, Any]] = [] @@ -35,3 +53,20 @@ def read_jsonl(path: str) -> list[dict[str, Any]]: except json.JSONDecodeError: continue return entries + + +def severity_badge(severity: str) -> str: + """Return a markdown-friendly severity indicator.""" + badges = { + "critical": "**CRITICAL**", + "high": "**HIGH**", + "medium": "MEDIUM", + "low": "LOW", + "info": "info", + } + return badges.get(severity, severity) + + +def pr_number_from_url(pr_url: str) -> str: + """Extract the PR or issue number from a GitHub URL's last path segment.""" + return pr_url.rstrip("/").split("/")[-1] diff --git a/skills/warden-sweep/scripts/create_issue.py b/skills/warden-sweep/scripts/create_issue.py new file mode 100644 index 00000000..987c14d4 --- /dev/null +++ b/skills/warden-sweep/scripts/create_issue.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# /// +""" +Warden Sweep: Create tracking issue. + +Creates a GitHub issue summarizing the sweep results after verification +but before patching. Gives every PR a parent to reference and gives +reviewers a single place to see the full picture. + +Usage: + uv run create_issue.py + +Stdout: JSON with issueUrl and issueNumber +Stderr: Progress lines + +Idempotent: if issueUrl already exists in manifest, skips creation. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _utils import ( # noqa: E402 + pr_number_from_url, + read_json, + read_jsonl, + severity_badge, + write_json, +) + + +def build_issue_body( + run_id: str, + scan_index: list[dict[str, Any]], + all_findings: list[dict[str, Any]], + verified: list[dict[str, Any]], + rejected: list[dict[str, Any]], +) -> str: + """Build the GitHub issue body markdown.""" + files_scanned = sum(1 for e in scan_index if e.get("status") == "complete") + files_timed_out = sum( + 1 for e in scan_index + if e.get("status") == "error" and e.get("error") == "timeout" + ) + files_errored = sum( + 1 for e in scan_index + if e.get("status") == "error" and e.get("error") != "timeout" + ) + + # Collect unique skills from scan index + skills: set[str] = set() + for entry in scan_index: + for skill in entry.get("skills", []): + skills.add(skill) + + lines = [ + f"## Warden Sweep `{run_id}`", + "", + "| Metric | Count |", + "|--------|-------|", + f"| Files scanned | {files_scanned} |", + f"| Files timed out | {files_timed_out} |", + f"| Files errored | {files_errored} |", + f"| Total findings | {len(all_findings)} |", + f"| Verified | {len(verified)} |", + f"| Rejected | {len(rejected)} |", + "", + ] + + if verified: + lines.append("### Verified Findings") + lines.append("") + lines.append("| Severity | Skill | File | Title |") + lines.append("|----------|-------|------|-------|") + for f in verified: + sev = severity_badge(f.get("severity", "info")) + skill = f.get("skill", "") + file_path = f.get("file", "") + start_line = f.get("startLine") + location = f"{file_path}:{start_line}" if start_line else file_path + title = f.get("title", "") + lines.append(f"| {sev} | {skill} | `{location}` | {title} |") + lines.append("") + + if skills: + lines.append("### Skills Run") + lines.append("") + lines.append(", ".join(sorted(skills))) + lines.append("") + + lines.append("> Generated by Warden Sweep. PRs referencing this issue will appear below.") + + return "\n".join(lines) + "\n" + + +def ensure_warden_label() -> None: + """Ensure the warden label exists on GitHub (idempotent).""" + try: + subprocess.run( + [ + "gh", "label", "create", "warden", + "--color", "5319E7", + "--description", "Automated fix from Warden Sweep", + ], + capture_output=True, + timeout=15, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + +def create_github_issue(title: str, body: str) -> dict[str, Any]: + """Create a GitHub issue with the warden label. Returns issueUrl and issueNumber.""" + ensure_warden_label() + + result = subprocess.run( + [ + "gh", "issue", "create", + "--label", "warden", + "--title", title, + "--body", body, + ], + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + raise RuntimeError(f"gh issue create failed: {result.stderr.strip()}") + + issue_url = result.stdout.strip() + try: + issue_number = int(pr_number_from_url(issue_url)) + except (ValueError, IndexError): + raise RuntimeError(f"Could not parse issue number from gh output: {issue_url}") + + return {"issueUrl": issue_url, "issueNumber": issue_number} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Warden Sweep: Create tracking issue" + ) + parser.add_argument("sweep_dir", help="Path to the sweep directory") + args = parser.parse_args() + + sweep_dir = args.sweep_dir + data_dir = os.path.join(sweep_dir, "data") + manifest_path = os.path.join(data_dir, "manifest.json") + + if not os.path.isdir(sweep_dir): + print( + json.dumps({"error": f"Sweep directory not found: {sweep_dir}"}), + file=sys.stdout, + ) + sys.exit(1) + + manifest = read_json(manifest_path) or {} + + # Idempotency: if issue already exists, return existing values + if manifest.get("issueUrl"): + output = { + "issueUrl": manifest["issueUrl"], + "issueNumber": manifest.get("issueNumber", 0), + } + print(json.dumps(output)) + return + + run_id = manifest.get("runId", "unknown") + + # Read sweep data + scan_index = read_jsonl(os.path.join(data_dir, "scan-index.jsonl")) + all_findings = read_jsonl(os.path.join(data_dir, "all-findings.jsonl")) + verified = read_jsonl(os.path.join(data_dir, "verified.jsonl")) + rejected = read_jsonl(os.path.join(data_dir, "rejected.jsonl")) + + files_scanned = sum(1 for e in scan_index if e.get("status") == "complete") + + # Build issue + title = f"Warden Sweep {run_id}: {len(verified)} findings across {files_scanned} files" + body = build_issue_body(run_id, scan_index, all_findings, verified, rejected) + + print("Creating tracking issue...", file=sys.stderr) + result = create_github_issue(title, body) + print(f"Created issue: {result['issueUrl']}", file=sys.stderr) + + # Write issueUrl and issueNumber to manifest + manifest["issueUrl"] = result["issueUrl"] + manifest["issueNumber"] = result["issueNumber"] + manifest.setdefault("phases", {})["issue"] = "complete" + write_json(manifest_path, manifest) + + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/skills/warden-sweep/scripts/find_reviewers.py b/skills/warden-sweep/scripts/find_reviewers.py index 6e257e9a..89b973c5 100755 --- a/skills/warden-sweep/scripts/find_reviewers.py +++ b/skills/warden-sweep/scripts/find_reviewers.py @@ -86,6 +86,12 @@ def email_to_github_username(email: str) -> str | None: return output if output else None +def get_current_github_user() -> str | None: + """Get the currently authenticated GitHub username.""" + output = run_cmd(["gh", "api", "/user", "--jq", ".login"]) + return output if output else None + + def main(): parser = argparse.ArgumentParser( description="Find top git contributors for PR reviewer assignment" @@ -97,7 +103,11 @@ def main(): ) args = parser.parse_args() - emails = get_top_authors(args.file_path, args.count) + current_user = get_current_github_user() + + # Request extra candidates to compensate for self-exclusion + fetch_count = args.count + 1 if current_user else args.count + emails = get_top_authors(args.file_path, fetch_count) if not emails: print(json.dumps({"reviewers": [], "note": "No recent authors found"})) return @@ -105,10 +115,10 @@ def main(): reviewers: list[str] = [] for email in emails: username = email_to_github_username(email) - if username: + if username and username != current_user: reviewers.append(username) - print(json.dumps({"reviewers": reviewers})) + print(json.dumps({"reviewers": reviewers[:args.count]})) if __name__ == "__main__": diff --git a/skills/warden-sweep/scripts/generate_report.py b/skills/warden-sweep/scripts/generate_report.py index 931e02f4..783ef2c8 100755 --- a/skills/warden-sweep/scripts/generate_report.py +++ b/skills/warden-sweep/scripts/generate_report.py @@ -23,30 +23,7 @@ from typing import Any sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _utils import read_jsonl # noqa: E402 - - -def read_json(path: str) -> dict[str, Any] | None: - """Read a JSON file and return parsed object.""" - if not os.path.exists(path): - return None - try: - with open(path) as f: - return json.load(f) - except (json.JSONDecodeError, OSError): - return None - - -def severity_badge(severity: str) -> str: - """Return a markdown-friendly severity indicator.""" - badges = { - "critical": "**CRITICAL**", - "high": "**HIGH**", - "medium": "MEDIUM", - "low": "LOW", - "info": "info", - } - return badges.get(severity, severity) +from _utils import read_json, read_jsonl, severity_badge # noqa: E402 def generate_summary_md( @@ -65,7 +42,14 @@ def generate_summary_md( completed_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") files_scanned = sum(1 for e in scan_index if e.get("status") == "complete") - files_errored = sum(1 for e in scan_index if e.get("status") == "error") + files_timed_out = sum( + 1 for e in scan_index + if e.get("status") == "error" and e.get("error") == "timeout" + ) + files_errored = sum( + 1 for e in scan_index + if e.get("status") == "error" and e.get("error") != "timeout" + ) prs_created = sum(1 for p in patches if p.get("status") == "created") prs_failed = sum(1 for p in patches if p.get("status") == "error") @@ -88,6 +72,7 @@ def generate_summary_md( f"| Metric | Count |", f"|--------|-------|", f"| Files scanned | {files_scanned} |", + f"| Files timed out | {files_timed_out} |", f"| Files errored | {files_errored} |", f"| Total findings | {len(all_findings)} |", f"| Verified | {len(verified)} |", @@ -176,6 +161,14 @@ def generate_report_json( completed_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") files_scanned = sum(1 for e in scan_index if e.get("status") == "complete") + files_timed_out = sum( + 1 for e in scan_index + if e.get("status") == "error" and e.get("error") == "timeout" + ) + files_errored = sum( + 1 for e in scan_index + if e.get("status") == "error" and e.get("error") != "timeout" + ) prs_created = sum(1 for p in patches if p.get("status") == "created") prs_failed = sum(1 for p in patches if p.get("status") == "error") @@ -190,6 +183,8 @@ def generate_report_json( "completedAt": completed_at, "scan": { "filesScanned": files_scanned, + "filesTimedOut": files_timed_out, + "filesErrored": files_errored, "totalFindings": len(all_findings), }, "verify": { diff --git a/skills/warden-sweep/scripts/organize.py b/skills/warden-sweep/scripts/organize.py index 30877a33..28f684fc 100644 --- a/skills/warden-sweep/scripts/organize.py +++ b/skills/warden-sweep/scripts/organize.py @@ -36,7 +36,7 @@ from typing import Any sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _utils import read_jsonl # noqa: E402 +from _utils import pr_number_from_url, read_json, read_jsonl, write_json # noqa: E402 SECURITY_SKILL_PATTERNS = [ @@ -54,6 +54,15 @@ def is_security_skill(skill_name: str) -> bool: return name_lower in SECURITY_SKILL_PATTERNS +def severity_label(severity: str) -> str: + """Format a severity string for inline display in issue comments.""" + if not severity: + return "" + if severity in ("critical", "high"): + return f" (**{severity.upper()}**)" + return f" ({severity.upper()})" + + def identify_security_findings( sweep_dir: str, ) -> list[dict[str, Any]]: @@ -156,6 +165,96 @@ def label_security_prs( return labeled +def update_tracking_issue(sweep_dir: str) -> None: + """Post a comment on the tracking issue with final PR results.""" + manifest = read_json(os.path.join(sweep_dir, "data", "manifest.json")) + if not manifest: + return + + issue_url = manifest.get("issueUrl") + if not issue_url: + return + + patches = read_jsonl(os.path.join(sweep_dir, "data", "patches.jsonl")) + verified = read_jsonl(os.path.join(sweep_dir, "data", "verified.jsonl")) + security_index = read_jsonl(os.path.join(sweep_dir, "security", "index.jsonl")) + + # Build lookup from findingId to verified finding + verified_lookup: dict[str, dict[str, Any]] = {} + for f in verified: + fid = f.get("findingId", "") + if fid: + verified_lookup[fid] = f + + security_ids = {f.get("findingId", "") for f in security_index} + + created = sum(1 for p in patches if p.get("status") == "created") + existing = sum(1 for p in patches if p.get("status") == "existing") + failed = sum(1 for p in patches if p.get("status") == "error") + + lines = [ + "## Sweep Complete", + "", + "| PRs Created | PRs Skipped (existing) | PRs Failed | Security Findings |", + "|-------------|------------------------|------------|-------------------|", + f"| {created} | {existing} | {failed} | {len(security_index)} |", + "", + ] + + # PR task list + pr_entries = [p for p in patches if p.get("status") == "created" and p.get("prUrl")] + if pr_entries: + lines.append("### PRs") + lines.append("") + for p in pr_entries: + fid = p.get("findingId", "") + pr_number = pr_number_from_url(p.get("prUrl", "")) + finding = verified_lookup.get(fid, {}) + title = finding.get("title", fid) + sev = severity_label(finding.get("severity", "")) + lines.append(f"- [ ] #{pr_number} - fix: {title}{sev}") + lines.append("") + + # Security findings section + security_prs = [ + p for p in patches + if p.get("status") == "created" + and p.get("findingId", "") in security_ids + and p.get("prUrl") + ] + if security_prs: + lines.append("### Security Findings") + lines.append("") + for p in security_prs: + fid = p.get("findingId", "") + pr_number = pr_number_from_url(p.get("prUrl", "")) + finding = verified_lookup.get(fid, {}) + title = finding.get("title", fid) + sev = severity_label(finding.get("severity", "")) + lines.append(f"- #{pr_number} - {title}{sev}") + lines.append("") + + body = "\n".join(lines) + + try: + result = subprocess.run( + ["gh", "issue", "comment", issue_url, "--body", body], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + print( + f"Warning: Failed to comment on tracking issue: {result.stderr.strip()}", + file=sys.stderr, + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + print( + f"Warning: Failed to comment on tracking issue: {e}", + file=sys.stderr, + ) + + def update_findings_with_pr_links(sweep_dir: str) -> None: """Append PR links to findings/*.md for created PRs.""" patches = read_jsonl(os.path.join(sweep_dir, "data", "patches.jsonl")) @@ -218,20 +317,16 @@ def run_generate_report(sweep_dir: str, script_dir: str) -> None: def update_manifest(sweep_dir: str) -> None: """Mark organize phase complete and add completedAt timestamp.""" manifest_path = os.path.join(sweep_dir, "data", "manifest.json") - if not os.path.exists(manifest_path): + manifest = read_json(manifest_path) + if not manifest: return - with open(manifest_path) as f: - manifest = json.load(f) - manifest.setdefault("phases", {})["organize"] = "complete" manifest["completedAt"] = datetime.now(timezone.utc).strftime( "%Y-%m-%dT%H:%M:%SZ" ) - with open(manifest_path, "w") as f: - json.dump(manifest, f, indent=2) - f.write("\n") + write_json(manifest_path, manifest) def main() -> None: @@ -279,7 +374,11 @@ def main() -> None: print("Generating summary and report...", file=sys.stderr) run_generate_report(sweep_dir, script_dir) - # Step 6: Update manifest + # Step 6: Update tracking issue with PR results + print("Updating tracking issue...", file=sys.stderr) + update_tracking_issue(sweep_dir) + + # Step 7: Update manifest update_manifest(sweep_dir) # Gather stats for output diff --git a/skills/warden-sweep/scripts/scan.py b/skills/warden-sweep/scripts/scan.py index 0d530431..642454a4 100644 --- a/skills/warden-sweep/scripts/scan.py +++ b/skills/warden-sweep/scripts/scan.py @@ -28,6 +28,8 @@ import secrets import subprocess import sys +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -101,6 +103,7 @@ def write_manifest(sweep_dir: str, run_id: str) -> None: "phases": { "scan": "pending", "verify": "pending", + "issue": "pending", "patch": "pending", "organize": "pending", }, @@ -301,7 +304,7 @@ def log_path_for_file(sweep_dir: str, file_path: str) -> str: def scan_file( - file_path: str, log_file: str, timeout: int = 300 + file_path: str, log_file: str, timeout: int = 600 ) -> dict[str, Any]: """Run warden on a single file. Returns scan-index entry.""" try: @@ -309,8 +312,6 @@ def scan_file( [ "warden", file_path, "--json", "--log", - "--min-confidence", "off", - "--fail-on", "off", "--quiet", "--output", log_file, ], @@ -542,30 +543,41 @@ def main() -> None: file=sys.stderr, ) - # Scan remaining files + # Scan remaining files concurrently scanned = already_done + index_lock = threading.Lock() - for i, file_path in enumerate(remaining, start=1): + def _scan_and_record(file_path: str) -> dict[str, Any]: log_file = log_path_for_file(sweep_dir, file_path) entry = scan_file(file_path, log_file) - # Append to scan-index.jsonl - with open(scan_index_path, "a") as f: - f.write(json.dumps(entry) + "\n") + with index_lock: + with open(scan_index_path, "a") as f: + f.write(json.dumps(entry) + "\n") - scanned += 1 - if entry["status"] == "error": - print( - f"[{scanned}/{total}] {file_path} (ERROR: {entry.get('error', 'unknown')})", - file=sys.stderr, - ) - else: - count = entry.get("findingCount", 0) - suffix = f"({count} finding{'s' if count != 1 else ''})" if count > 0 else "" - print( - f"[{scanned}/{total}] {file_path} {suffix}".rstrip(), - file=sys.stderr, - ) + return entry + + with ThreadPoolExecutor(max_workers=4) as pool: + futures = { + pool.submit(_scan_and_record, fp): fp for fp in remaining + } + for future in as_completed(futures): + entry = future.result() + scanned += 1 + file_path = entry.get("file", futures[future]) + if entry["status"] == "error": + label = "TIMEOUT" if entry.get("error") == "timeout" else "ERROR" + print( + f"[{scanned}/{total}] {file_path} ({label}: {entry.get('error', 'unknown')})", + file=sys.stderr, + ) + else: + count = entry.get("findingCount", 0) + suffix = f"({count} finding{'s' if count != 1 else ''})" if count > 0 else "" + print( + f"[{scanned}/{total}] {file_path} {suffix}".rstrip(), + file=sys.stderr, + ) # Extract findings script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -578,6 +590,7 @@ def main() -> None: # so that resumed scans don't include stale errors for files that later succeeded. # Scope to current file list so counts stay consistent with `scanned`. files_set = set(files) + timeouts: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] if os.path.exists(scan_index_path): last_status: dict[str, dict[str, Any]] = {} @@ -595,36 +608,44 @@ def main() -> None: continue for entry in last_status.values(): if entry.get("status") == "error": - errors.append({ + item = { "file": entry.get("file", ""), "error": entry.get("error", "unknown"), "exitCode": entry.get("exitCode", -1), - }) + } + if entry.get("error") == "timeout": + timeouts.append(item) + else: + errors.append(item) + + total_failed = len(timeouts) + len(errors) # Output JSON summary output = { "runId": run_id, "sweepDir": sweep_dir, - "filesScanned": scanned - len(errors), + "filesScanned": scanned - total_failed, + "filesTimedOut": len(timeouts), "filesErrored": len(errors), "totalFindings": len(findings), "bySeverity": by_severity, "findingsPath": os.path.join(sweep_dir, "data", "all-findings.jsonl"), "findings": findings, + "timeouts": timeouts, "errors": errors, } print(json.dumps(output, indent=2)) # Fatal only if every file across all runs errored (no successful scans at all) - successful = scanned - len(errors) + successful = scanned - total_failed if successful == 0 and scanned > 0: update_manifest_phase(sweep_dir, "scan", "error") sys.exit(1) update_manifest_phase(sweep_dir, "scan", "complete") - if len(errors) > 0: + if total_failed > 0: sys.exit(2) From 3f68ff26389961823985aeb13654276087dc0c38 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 22 Feb 2026 13:06:05 -0800 Subject: [PATCH 2/3] fix(sweep): Address review bot feedback on tracking issue PR Make update_tracking_issue() idempotent by checking for an existing "Sweep Complete" comment before posting a new one. This preserves the organize phase's re-run safety guarantee. Restore --fail-on off flag in scan_file() that was inadvertently removed. Without it, warden exits non-zero on findings and scan_file misclassifies those files as errors, silently dropping findings. Co-Authored-By: Claude Opus 4.6 --- skills/warden-sweep/scripts/organize.py | 21 ++++++++++++++++++++- skills/warden-sweep/scripts/scan.py | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/skills/warden-sweep/scripts/organize.py b/skills/warden-sweep/scripts/organize.py index 28f684fc..9c63b775 100644 --- a/skills/warden-sweep/scripts/organize.py +++ b/skills/warden-sweep/scripts/organize.py @@ -165,8 +165,23 @@ def label_security_prs( return labeled +def _has_sweep_complete_comment(issue_url: str) -> bool: + """Check if the tracking issue already has a 'Sweep Complete' comment.""" + try: + result = subprocess.run( + ["gh", "issue", "view", issue_url, "--json", "comments", "--jq", + '.comments[].body | select(startswith("## Sweep Complete"))'], + capture_output=True, + text=True, + timeout=15, + ) + return result.returncode == 0 and result.stdout.strip() != "" + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + def update_tracking_issue(sweep_dir: str) -> None: - """Post a comment on the tracking issue with final PR results.""" + """Post a comment on the tracking issue with final PR results. Idempotent.""" manifest = read_json(os.path.join(sweep_dir, "data", "manifest.json")) if not manifest: return @@ -175,6 +190,10 @@ def update_tracking_issue(sweep_dir: str) -> None: if not issue_url: return + if _has_sweep_complete_comment(issue_url): + print("Tracking issue already has completion comment, skipping.", file=sys.stderr) + return + patches = read_jsonl(os.path.join(sweep_dir, "data", "patches.jsonl")) verified = read_jsonl(os.path.join(sweep_dir, "data", "verified.jsonl")) security_index = read_jsonl(os.path.join(sweep_dir, "security", "index.jsonl")) diff --git a/skills/warden-sweep/scripts/scan.py b/skills/warden-sweep/scripts/scan.py index 642454a4..8e223811 100644 --- a/skills/warden-sweep/scripts/scan.py +++ b/skills/warden-sweep/scripts/scan.py @@ -312,6 +312,7 @@ def scan_file( [ "warden", file_path, "--json", "--log", + "--fail-on", "off", "--quiet", "--output", log_file, ], From 987a53108c5508e85fec14e5ec20d8ec48d88c03 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 22 Feb 2026 13:18:15 -0800 Subject: [PATCH 3/3] fix(sweep): Restore --min-confidence flag and deduplicate label creation Restore --min-confidence off in scan_file() that was inadvertently removed. Without it, warden applies its default confidence threshold and silently drops low-confidence findings. Extract ensure_github_label() into _utils.py to eliminate three duplicate implementations across scan.py, create_issue.py, and organize.py. Co-Authored-By: Claude Opus 4.6 --- skills/warden-sweep/scripts/_utils.py | 16 ++++++++++++++++ skills/warden-sweep/scripts/create_issue.py | 19 ++----------------- skills/warden-sweep/scripts/organize.py | 15 ++------------- skills/warden-sweep/scripts/scan.py | 21 +++------------------ 4 files changed, 23 insertions(+), 48 deletions(-) diff --git a/skills/warden-sweep/scripts/_utils.py b/skills/warden-sweep/scripts/_utils.py index 9e781165..973b0b81 100644 --- a/skills/warden-sweep/scripts/_utils.py +++ b/skills/warden-sweep/scripts/_utils.py @@ -70,3 +70,19 @@ def severity_badge(severity: str) -> str: def pr_number_from_url(pr_url: str) -> str: """Extract the PR or issue number from a GitHub URL's last path segment.""" return pr_url.rstrip("/").split("/")[-1] + + +def ensure_github_label(name: str, color: str, description: str) -> None: + """Create a GitHub label if it doesn't exist (idempotent).""" + try: + subprocess.run( + [ + "gh", "label", "create", name, + "--color", color, + "--description", description, + ], + capture_output=True, + timeout=15, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass diff --git a/skills/warden-sweep/scripts/create_issue.py b/skills/warden-sweep/scripts/create_issue.py index 987c14d4..d1293c89 100644 --- a/skills/warden-sweep/scripts/create_issue.py +++ b/skills/warden-sweep/scripts/create_issue.py @@ -28,6 +28,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _utils import ( # noqa: E402 + ensure_github_label, pr_number_from_url, read_json, read_jsonl, @@ -100,25 +101,9 @@ def build_issue_body( return "\n".join(lines) + "\n" -def ensure_warden_label() -> None: - """Ensure the warden label exists on GitHub (idempotent).""" - try: - subprocess.run( - [ - "gh", "label", "create", "warden", - "--color", "5319E7", - "--description", "Automated fix from Warden Sweep", - ], - capture_output=True, - timeout=15, - ) - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - - def create_github_issue(title: str, body: str) -> dict[str, Any]: """Create a GitHub issue with the warden label. Returns issueUrl and issueNumber.""" - ensure_warden_label() + ensure_github_label("warden", "5319E7", "Automated fix from Warden Sweep") result = subprocess.run( [ diff --git a/skills/warden-sweep/scripts/organize.py b/skills/warden-sweep/scripts/organize.py index 9c63b775..efd1edbe 100644 --- a/skills/warden-sweep/scripts/organize.py +++ b/skills/warden-sweep/scripts/organize.py @@ -36,7 +36,7 @@ from typing import Any sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _utils import pr_number_from_url, read_json, read_jsonl, write_json # noqa: E402 +from _utils import ensure_github_label, pr_number_from_url, read_json, read_jsonl, write_json # noqa: E402 SECURITY_SKILL_PATTERNS = [ @@ -110,18 +110,7 @@ def copy_security_findings( def create_security_label() -> None: """Create the security label on GitHub (idempotent).""" - try: - subprocess.run( - [ - "gh", "label", "create", "security", - "--color", "D93F0B", - "--description", "Security-related changes", - ], - capture_output=True, - timeout=15, - ) - except (subprocess.TimeoutExpired, FileNotFoundError): - pass + ensure_github_label("security", "D93F0B", "Security-related changes") def label_security_prs( diff --git a/skills/warden-sweep/scripts/scan.py b/skills/warden-sweep/scripts/scan.py index 8e223811..be4c0de9 100644 --- a/skills/warden-sweep/scripts/scan.py +++ b/skills/warden-sweep/scripts/scan.py @@ -35,7 +35,7 @@ from typing import Any sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _utils import run_cmd # noqa: E402 +from _utils import ensure_github_label, run_cmd # noqa: E402 SUPPORTED_EXTENSIONS = { @@ -68,22 +68,6 @@ def create_sweep_dir(sweep_dir: str) -> None: os.makedirs(os.path.join(sweep_dir, subdir), exist_ok=True) -def create_warden_label() -> None: - """Create the warden label on GitHub (idempotent).""" - try: - subprocess.run( - [ - "gh", "label", "create", "warden", - "--color", "5319E7", - "--description", "Automated fix from Warden Sweep", - ], - capture_output=True, - timeout=15, - ) - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - - def write_manifest(sweep_dir: str, run_id: str) -> None: """Write the initial manifest.json.""" repo = "unknown" @@ -312,6 +296,7 @@ def scan_file( [ "warden", file_path, "--json", "--log", + "--min-confidence", "off", "--fail-on", "off", "--quiet", "--output", log_file, @@ -512,7 +497,7 @@ def main() -> None: if not os.path.exists(manifest_path): write_manifest(sweep_dir, run_id) - create_warden_label() + ensure_github_label("warden", "5319E7", "Automated fix from Warden Sweep") # Enumerate files ignore_patterns = load_ignore_paths()