diff --git a/plugins/code-quality-scanner/plugin.md b/plugins/code-quality-scanner/plugin.md new file mode 100644 index 0000000000..d8cd86d693 --- /dev/null +++ b/plugins/code-quality-scanner/plugin.md @@ -0,0 +1,28 @@ ++++ +name = "code-quality-scanner" +description = "Scan codebase for refactoring opportunities using golangci-lint and churn analysis" +version = 1 + +[gate] +type = "cooldown" +duration = "12h" + +[tracking] +labels = ["plugin:code-quality-scanner", "category:quality"] +digest = true + +[execution] +timeout = "10m" +notify_on_failure = true +severity = "low" ++++ + +# Code Quality Scanner + +Periodically scans the codebase for refactoring opportunities by combining +static analysis (golangci-lint with aggressive linters) and git churn data +(files changed most often). The intersection — high complexity + high churn — +identifies the highest-value refactoring targets. + +Creates beads labeled `refactor-opportunity` for actionable items. +Deduplicates against existing beads. Closes beads when code is cleaned up. diff --git a/plugins/code-quality-scanner/run.sh b/plugins/code-quality-scanner/run.sh new file mode 100755 index 0000000000..6e6aa2b7f9 --- /dev/null +++ b/plugins/code-quality-scanner/run.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# code-quality-scanner/run.sh — Find refactoring opportunities via lint + churn. +# +# Combines golangci-lint (complexity, duplication, long functions) with git churn +# analysis. The intersection = highest-value refactoring targets. + +set -euo pipefail + +TOWN_ROOT="${GT_TOWN_ROOT:-$(gt town root 2>/dev/null)}" + +log() { echo "[code-quality-scanner] $*"; } + +# --- Discover rig repos ------------------------------------------------------- + +RIG_JSON=$(gt rig list --json 2>/dev/null) || { log "SKIP: could not get rig list"; exit 0; } + +REPOS=$(echo "$RIG_JSON" | python3 -c " +import json, sys +rigs = json.load(sys.stdin) +for r in rigs: + p = r.get('repo_path') or '' + if p: print(f'{r.get(\"name\",\"\")}\t{p}') +" 2>/dev/null) + +if [ -z "$REPOS" ]; then + log "SKIP: no rig repos found" + exit 0 +fi + +# --- Scan each repo ----------------------------------------------------------- + +TOTAL_HOTSPOTS=0 +TOTAL_COMPLEX=0 +TOTAL_LONG=0 +TOTAL_DUPL=0 +CREATED=0 + +# Get existing refactor beads for deduplication +EXISTING=$(bd list --label refactor-opportunity --status open --json 2>/dev/null || echo "[]") + +while IFS=$'\t' read -r RIG_NAME REPO_PATH; do + [ -z "$REPO_PATH" ] && continue + [ -d "$REPO_PATH" ] || continue + + # Check if it's a Go project + if [ ! -f "$REPO_PATH/go.mod" ]; then + continue + fi + + log "" + log "=== Scanning: $RIG_NAME ($REPO_PATH) ===" + + # --- Step 1: Git churn analysis (last 90 days) --- + log " Analyzing git churn (90 days)..." + CHURN=$(cd "$REPO_PATH" && git log --since="90 days ago" --format= --name-only -- '*.go' 2>/dev/null \ + | sort | uniq -c | sort -rn | head -30) + + # Build churn map: file -> change count + declare -A CHURN_MAP + while read -r count file; do + [ -z "$file" ] && continue + CHURN_MAP["$file"]=$count + done <<< "$CHURN" + + # --- Step 2: Complexity analysis via golangci-lint --- + log " Running complexity analysis..." + + # Run golangci-lint with complexity-focused linters + # Use --out-format json for parseable output + LINT_OUTPUT=$(cd "$REPO_PATH" && golangci-lint run \ + --enable gocognit,cyclop,funlen \ + --disable-all \ + --enable gocognit --enable cyclop --enable funlen \ + --out-format json \ + --timeout 5m \ + --issues-exit-code 0 \ + 2>/dev/null || echo '{"Issues":[]}') + + # --- Step 3: Parse and cross-reference with churn --- + RESULTS=$(echo "$LINT_OUTPUT" | python3 -c " +import json, sys, os + +churn_raw = '''$CHURN''' +churn_map = {} +for line in churn_raw.strip().split('\n'): + line = line.strip() + if not line: continue + parts = line.split(None, 1) + if len(parts) == 2: + churn_map[parts[1]] = int(parts[0]) + +try: + data = json.load(sys.stdin) +except: + data = {'Issues': []} + +issues = data.get('Issues') or [] +hotspots = [] + +for issue in issues: + file = issue.get('Pos', {}).get('Filename', '') + line = issue.get('Pos', {}).get('Line', 0) + linter = issue.get('FromLinter', '') + text = issue.get('Text', '') + + # Get churn count for this file + churn = churn_map.get(file, 0) + + # Classify + category = 'smell' + if 'cognitive complexity' in text.lower() or 'cyclomatic complexity' in text.lower(): + category = 'complexity' + elif 'lines' in text.lower() and 'func' in text.lower(): + category = 'long-function' + + # Priority: high churn + high complexity = hotspot + is_hotspot = churn >= 5 and category == 'complexity' + + hotspots.append({ + 'file': file, + 'line': line, + 'linter': linter, + 'text': text, + 'category': category, + 'churn': churn, + 'hotspot': is_hotspot + }) + +# Sort: hotspots first, then by churn +hotspots.sort(key=lambda h: (-h['hotspot'], -h['churn'])) + +# Summarize +complex_count = sum(1 for h in hotspots if h['category'] == 'complexity') +long_count = sum(1 for h in hotspots if h['category'] == 'long-function') +hotspot_count = sum(1 for h in hotspots if h['hotspot']) + +print(json.dumps({ + 'issues': hotspots[:50], # Top 50 issues + 'complex': complex_count, + 'long_functions': long_count, + 'hotspots': hotspot_count, + 'total': len(hotspots) +})) +" 2>/dev/null || echo '{"issues":[],"complex":0,"long_functions":0,"hotspots":0,"total":0}') + + HOTSPOTS=$(echo "$RESULTS" | python3 -c "import json,sys; print(json.load(sys.stdin)['hotspots'])" 2>/dev/null || echo "0") + COMPLEX=$(echo "$RESULTS" | python3 -c "import json,sys; print(json.load(sys.stdin)['complex'])" 2>/dev/null || echo "0") + LONG=$(echo "$RESULTS" | python3 -c "import json,sys; print(json.load(sys.stdin)['long_functions'])" 2>/dev/null || echo "0") + + TOTAL_HOTSPOTS=$((TOTAL_HOTSPOTS + HOTSPOTS)) + TOTAL_COMPLEX=$((TOTAL_COMPLEX + COMPLEX)) + TOTAL_LONG=$((TOTAL_LONG + LONG)) + + log " Found: $HOTSPOTS hotspot(s), $COMPLEX complex, $LONG long function(s)" + + # --- Step 4: Create beads for hotspots (high churn + complex) --- + if [ "$HOTSPOTS" -gt 0 ]; then + echo "$RESULTS" | python3 -c " +import json, sys, subprocess + +data = json.load(sys.stdin) +existing = json.loads('''$(echo "$EXISTING" | sed "s/'/\"/g")''') if '''$(echo "$EXISTING")''' != '[]' else [] +existing_titles = {e.get('title','') for e in existing} +created = 0 + +for issue in data['issues']: + if not issue['hotspot']: + continue + + title = f\"refactor: {issue['file']}:{issue['line']} — {issue['category']} (churn={issue['churn']})\" + if title in existing_titles: + continue + + desc = f\"**{issue['text']}**\n\nFile: {issue['file']}:{issue['line']}\nChurn: {issue['churn']} changes in 90 days\nLinter: {issue['linter']}\nRig: $RIG_NAME\n\nThis file is both complex AND frequently changed — high-value refactoring target.\" + + try: + subprocess.run(['bd', 'create', title, '-t', 'task', '-p', '3', + '-d', desc, '-l', 'refactor-opportunity', '--silent'], + capture_output=True, timeout=10) + created += 1 + except: pass + +print(created) +" 2>/dev/null | read -r NEW_CREATED || NEW_CREATED=0 + CREATED=$((CREATED + ${NEW_CREATED:-0})) + fi + + # Print top hotspots + echo "$RESULTS" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for h in data['issues'][:10]: + icon = '🔥' if h['hotspot'] else '⚠️' + print(f\" {icon} {h['file']}:{h['line']} [{h['category']}] churn={h['churn']} — {h['text'][:80]}\") +" 2>/dev/null + + unset CHURN_MAP + +done <<< "$REPOS" + +# --- Report ------------------------------------------------------------------- + +SUMMARY="$TOTAL_HOTSPOTS hotspot(s), $TOTAL_COMPLEX complex, $TOTAL_LONG long func(s), $CREATED bead(s) created" +log "" +log "=== Code Quality Summary: $SUMMARY ===" + +bd create "code-quality-scanner: $SUMMARY" -t chore --ephemeral \ + -l type:plugin-run,plugin:code-quality-scanner,result:success \ + -d "$SUMMARY" --silent 2>/dev/null || true diff --git a/plugins/doc-freshness/plugin.md b/plugins/doc-freshness/plugin.md new file mode 100644 index 0000000000..2f1f7da235 --- /dev/null +++ b/plugins/doc-freshness/plugin.md @@ -0,0 +1,31 @@ ++++ +name = "doc-freshness" +description = "Detect stale documentation that has drifted from the code it describes" +version = 1 + +[gate] +type = "cooldown" +duration = "24h" + +[tracking] +labels = ["plugin:doc-freshness", "category:quality"] +digest = true + +[execution] +timeout = "5m" +notify_on_failure = true +severity = "low" ++++ + +# Doc Freshness + +Detects documentation that has drifted from the code it describes by tracking +code-doc coupling. When code changes significantly but the docs that reference +it haven't been updated, those docs are flagged as potentially stale. + +Detection strategies: +1. Code-doc coupling: .md files that reference .go files/functions +2. CLI help drift: command --help vs docs that describe those commands +3. Dead references: docs mentioning files, functions, or flags that no longer exist + +Creates beads labeled `doc-stale` for docs that need updating. diff --git a/plugins/doc-freshness/run.sh b/plugins/doc-freshness/run.sh new file mode 100755 index 0000000000..55ae9d8050 --- /dev/null +++ b/plugins/doc-freshness/run.sh @@ -0,0 +1,279 @@ +#!/usr/bin/env bash +# doc-freshness/run.sh — Detect stale documentation. +# +# Tracks code-doc coupling: which .md files reference which code files/symbols. +# When code changes but the referencing docs don't, flags them as stale. + +set -euo pipefail + +TOWN_ROOT="${GT_TOWN_ROOT:-$(gt town root 2>/dev/null)}" + +log() { echo "[doc-freshness] $*"; } + +# --- Discover rig repos ------------------------------------------------------- + +RIG_JSON=$(gt rig list --json 2>/dev/null) || { log "SKIP: could not get rig list"; exit 0; } + +REPOS=$(echo "$RIG_JSON" | python3 -c " +import json, sys +rigs = json.load(sys.stdin) +for r in rigs: + p = r.get('repo_path') or '' + if p: print(f'{r.get(\"name\",\"\")}\t{p}') +" 2>/dev/null) + +if [ -z "$REPOS" ]; then + log "SKIP: no rig repos found" + exit 0 +fi + +TOTAL_STALE=0 +TOTAL_DEAD_REFS=0 +TOTAL_CLI_DRIFT=0 +CREATED=0 + +EXISTING=$(bd list --label doc-stale --status open --json 2>/dev/null || echo "[]") + +while IFS=$'\t' read -r RIG_NAME REPO_PATH; do + [ -z "$REPO_PATH" ] && continue + [ -d "$REPO_PATH" ] || continue + + log "" + log "=== Scanning: $RIG_NAME ($REPO_PATH) ===" + + cd "$REPO_PATH" + + # --- Step 1: Find all markdown docs --- + MD_FILES=$(find . -name '*.md' -not -path './.git/*' -not -path './vendor/*' -not -path './node_modules/*' 2>/dev/null) + MD_COUNT=$(echo "$MD_FILES" | grep -c . || echo "0") + log " Found $MD_COUNT markdown file(s)" + + # --- Step 2: Code-doc coupling — find stale docs --- + log " Analyzing code-doc coupling..." + + STALE_DOCS=$(python3 -c " +import os, re, subprocess, json +from datetime import datetime + +repo = '$REPO_PATH' +stale = [] + +# Get all .md files +md_files = [] +for root, dirs, files in os.walk(repo): + dirs[:] = [d for d in dirs if d not in ('.git', 'vendor', 'node_modules', '.medici')] + for f in files: + if f.endswith('.md'): + md_files.append(os.path.join(root, f)) + +for md_path in md_files: + rel_md = os.path.relpath(md_path, repo) + try: + content = open(md_path).read() + except: + continue + + # Extract code references from the markdown: + # 1. File paths: internal/foo/bar.go, cmd/foo.go + # 2. Function names in backticks: \`FunctionName\` + # 3. Command examples: gt , bd + go_file_refs = set(re.findall(r'(?:internal|cmd|pkg)/[\w/]+\.go', content)) + func_refs = set(re.findall(r'\x60(\w{3,}(?:\.(?:go|toml|json|yaml))?)\x60', content)) + + if not go_file_refs: + continue + + # Get last modified time of the doc + try: + md_mtime = int(subprocess.check_output( + ['git', '-C', repo, 'log', '-1', '--format=%ct', '--', rel_md], + stderr=subprocess.DEVNULL, text=True).strip() or '0') + except: + continue + + if md_mtime == 0: + continue + + # Check each referenced .go file + stale_refs = [] + for go_ref in go_file_refs: + go_path = os.path.join(repo, go_ref) + if not os.path.exists(go_path): + # Dead reference + stale_refs.append(f'DEAD: {go_ref} (file no longer exists)') + continue + + # Get last modified time of the code file + try: + code_mtime = int(subprocess.check_output( + ['git', '-C', repo, 'log', '-1', '--format=%ct', '--', go_ref], + stderr=subprocess.DEVNULL, text=True).strip() or '0') + except: + continue + + if code_mtime == 0: + continue + + # Get lines changed since doc was last updated + try: + diff_stat = subprocess.check_output( + ['git', '-C', repo, 'diff', '--stat', + f'--since-as-filter={datetime.fromtimestamp(md_mtime).isoformat()}', + '--', go_ref], + stderr=subprocess.DEVNULL, text=True).strip() + except: + diff_stat = '' + + # Simpler: if code file was modified after doc, flag it + if code_mtime > md_mtime: + # Count how many commits changed the code since the doc was updated + try: + commit_count = int(subprocess.check_output( + ['git', '-C', repo, 'rev-list', '--count', + f'--since={md_mtime}', 'HEAD', '--', go_ref], + stderr=subprocess.DEVNULL, text=True).strip() or '0') + except: + commit_count = 0 + + if commit_count >= 3: + stale_refs.append(f'STALE: {go_ref} ({commit_count} commits since doc updated)') + + if stale_refs: + stale.append({ + 'doc': rel_md, + 'refs': stale_refs, + 'ref_count': len(stale_refs), + 'dead_count': sum(1 for r in stale_refs if r.startswith('DEAD')), + 'stale_count': sum(1 for r in stale_refs if r.startswith('STALE')) + }) + +# Sort by most stale references first +stale.sort(key=lambda s: -s['ref_count']) + +print(json.dumps(stale[:30])) +" 2>/dev/null || echo '[]') + + STALE_COUNT=$(echo "$STALE_DOCS" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") + DEAD_COUNT=$(echo "$STALE_DOCS" | python3 -c "import json,sys; print(sum(d['dead_count'] for d in json.load(sys.stdin)))" 2>/dev/null || echo "0") + + log " Found $STALE_COUNT stale doc(s), $DEAD_COUNT dead reference(s)" + TOTAL_STALE=$((TOTAL_STALE + STALE_COUNT)) + TOTAL_DEAD_REFS=$((TOTAL_DEAD_REFS + DEAD_COUNT)) + + # Print findings + echo "$STALE_DOCS" | python3 -c " +import json, sys +docs = json.load(sys.stdin) +for d in docs[:15]: + icon = '💀' if d['dead_count'] > 0 else '📝' + print(f\" {icon} {d['doc']} ({d['ref_count']} issue(s))\") + for ref in d['refs'][:5]: + print(f' {ref}') +" 2>/dev/null + + # --- Step 3: CLI help drift --- + log " Checking CLI help drift..." + + if [ -f "$REPO_PATH/go.mod" ] && command -v gt >/dev/null 2>&1; then + CLI_DRIFT=$(python3 -c " +import os, re, subprocess, json + +repo = '$REPO_PATH' +drift = [] + +# Find docs that contain gt/bd command examples +for root, dirs, files in os.walk(repo): + dirs[:] = [d for d in dirs if d not in ('.git', 'vendor', 'node_modules', '.medici')] + for f in files: + if not f.endswith('.md'): continue + path = os.path.join(root, f) + try: + content = open(path).read() + except: + continue + + # Find command references like: gt estop, gt rig park, bd list + commands = set(re.findall(r'(?:gt|bd)\s+(\w+(?:\s+\w+)?)', content)) + if not commands: + continue + + # Check if those subcommands still exist + missing = [] + for cmd in commands: + parts = cmd.split() + # Only check top-level subcommands (gt ) + if len(parts) == 1: + # Check if subcommand exists + try: + result = subprocess.run(['gt', parts[0], '--help'], + capture_output=True, timeout=5) + if result.returncode != 0 and b'unknown command' in result.stderr: + missing.append(f'gt {cmd}') + except: + pass + + if missing: + rel = os.path.relpath(path, repo) + drift.append({'doc': rel, 'missing': missing}) + +print(json.dumps(drift[:10])) +" 2>/dev/null || echo '[]') + + CLI_COUNT=$(echo "$CLI_DRIFT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") + TOTAL_CLI_DRIFT=$((TOTAL_CLI_DRIFT + CLI_COUNT)) + + if [ "$CLI_COUNT" -gt 0 ]; then + log " Found $CLI_COUNT doc(s) with CLI drift" + echo "$CLI_DRIFT" | python3 -c " +import json, sys +for d in json.load(sys.stdin): + print(f\" ⚠️ {d['doc']}: references removed commands: {', '.join(d['missing'])}\") +" 2>/dev/null + fi + fi + + # --- Step 4: Create beads for stale docs --- + if [ "$STALE_COUNT" -gt 0 ]; then + echo "$STALE_DOCS" | python3 -c " +import json, sys, subprocess + +docs = json.load(sys.stdin) +existing = json.loads('''$(echo "$EXISTING" | sed "s/'/\"/g")''') if '''$(echo "$EXISTING")''' != '[]' else [] +existing_titles = {e.get('title','') for e in existing} +created = 0 + +for doc in docs: + # Only create beads for docs with dead refs or significant staleness + if doc['dead_count'] == 0 and doc['stale_count'] < 2: + continue + + title = f\"doc-stale: {doc['doc']} ({doc['ref_count']} stale ref(s))\" + if title in existing_titles: + continue + + refs_text = '\n'.join(f'- {r}' for r in doc['refs'][:10]) + desc = f\"Documentation may be stale:\n\n{refs_text}\n\nRig: $RIG_NAME\" + + try: + subprocess.run(['bd', 'create', title, '-t', 'task', '-p', '3', + '-d', desc, '-l', 'doc-stale', '--silent'], + capture_output=True, timeout=10) + created += 1 + except: pass + +print(created) +" 2>/dev/null | read -r NEW_CREATED || NEW_CREATED=0 + CREATED=$((CREATED + ${NEW_CREATED:-0})) + fi + +done <<< "$REPOS" + +# --- Report ------------------------------------------------------------------- + +SUMMARY="$TOTAL_STALE stale doc(s), $TOTAL_DEAD_REFS dead ref(s), $TOTAL_CLI_DRIFT CLI drift, $CREATED bead(s) created" +log "" +log "=== Doc Freshness Summary: $SUMMARY ===" + +bd create "doc-freshness: $SUMMARY" -t chore --ephemeral \ + -l type:plugin-run,plugin:doc-freshness,result:success \ + -d "$SUMMARY" --silent 2>/dev/null || true diff --git a/plugins/github-sheriff/run.sh b/plugins/github-sheriff/run.sh new file mode 100755 index 0000000000..ca1b960b45 --- /dev/null +++ b/plugins/github-sheriff/run.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# github-sheriff/run.sh — Monitor GitHub CI on open PRs, create beads for failures. +# +# Categorizes PRs as easy-wins or needs-review. Creates ci-failure beads +# for new CI failures (deduplicates against existing beads). + +set -euo pipefail + +TOWN_ROOT="${GT_TOWN_ROOT:-$(gt town root 2>/dev/null)}" + +log() { echo "[github-sheriff] $*"; } + +# --- Preflight --------------------------------------------------------------- + +gh auth status 2>/dev/null || { + log "SKIP: gh CLI not authenticated" + exit 0 +} + +# Discover repos from all rigs +RIG_JSON=$(gt rig list --json 2>/dev/null) || { + log "SKIP: could not get rig list" + exit 0 +} + +REPOS=$(echo "$RIG_JSON" | python3 -c " +import json, sys, subprocess +rigs = json.load(sys.stdin) +seen = set() +for r in rigs: + p = r.get('repo_path') or '' + if not p: continue + try: + url = subprocess.check_output(['git', '-C', p, 'remote', 'get-url', 'origin'], + stderr=subprocess.DEVNULL, text=True).strip() + # Extract owner/repo from git URL + import re + m = re.search(r'github\.com[:/](.+?)(?:\.git)?$', url) + if m: + repo = m.group(1) + if repo not in seen: + seen.add(repo) + print(f'{repo}\t{r.get(\"name\",\"\")}\t{p}') + except: pass +" 2>/dev/null) + +if [ -z "$REPOS" ]; then + log "SKIP: no GitHub repos found" + exit 0 +fi + +REPO_COUNT=$(echo "$REPOS" | wc -l | tr -d ' ') +log "Checking $REPO_COUNT repo(s)..." + +# --- Process each repo -------------------------------------------------------- + +TOTAL_PRS=0 +TOTAL_EASY=0 +TOTAL_REVIEW=0 +TOTAL_CREATED=0 + +# Date 7 days ago (macOS compatible) +SINCE=$(date -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2020-01-01T00:00:00Z") + +while IFS=$'\t' read -r REPO RIG_NAME RIG_PATH; do + [ -z "$REPO" ] && continue + log "" + log "=== $REPO ===" + + PRS=$(gh pr list --repo "$REPO" --state open \ + --json number,title,author,additions,deletions,mergeable,statusCheckRollup,url,updatedAt \ + --limit 100 2>/dev/null) || { + log " Failed to fetch PRs for $REPO" + continue + } + + # Filter to recent PRs + PRS=$(echo "$PRS" | python3 -c " +import json, sys +prs = json.load(sys.stdin) +since = '$SINCE' +print(json.dumps([p for p in prs if p.get('updatedAt','') >= since])) +" 2>/dev/null) + + PR_COUNT=$(echo "$PRS" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") + + if [ "$PR_COUNT" -eq 0 ]; then + log " No recent open PRs" + continue + fi + + TOTAL_PRS=$((TOTAL_PRS + PR_COUNT)) + + # Categorize PRs + RESULT=$(echo "$PRS" | python3 -c " +import json, sys + +prs = json.load(sys.stdin) +easy_wins = [] +needs_review = [] +failures = [] + +for pr in prs: + num = pr['number'] + title = pr['title'] + author = pr.get('author', {}).get('login', 'unknown') + adds = pr.get('additions', 0) + dels = pr.get('deletions', 0) + total = adds + dels + mergeable = pr.get('mergeable', '') + checks = pr.get('statusCheckRollup', []) or [] + + total_checks = len(checks) + passing = sum(1 for c in checks if c.get('conclusion') in ('SUCCESS','NEUTRAL','SKIPPED') or c.get('state') == 'SUCCESS') + ci_pass = total_checks > 0 and total_checks == passing + + # Collect failures + for c in checks: + conc = c.get('conclusion', '') + state = c.get('state', '') + if conc in ('FAILURE','CANCELLED','TIMED_OUT') or state in ('FAILURE','ERROR'): + failures.append(f\"{num}|{title}|{c.get('name','')}|{c.get('detailsUrl','')}\") + + if mergeable == 'MERGEABLE' and ci_pass and total < 200: + easy_wins.append(f'PR #{num}: {title} (by {author}, +{adds}/-{dels})') + else: + reasons = [] + if mergeable != 'MERGEABLE': reasons.append('conflicts') + if not ci_pass: reasons.append('ci-failing') + if total >= 200: reasons.append(f'large({total}loc)') + needs_review.append(f'PR #{num}: {title} (by {author}, {\" \".join(reasons)})') + +print(json.dumps({'easy_wins': easy_wins, 'needs_review': needs_review, 'failures': failures})) +" 2>/dev/null) + + EASY=$(echo "$RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['easy_wins']))" 2>/dev/null || echo "0") + REVIEW=$(echo "$RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['needs_review']))" 2>/dev/null || echo "0") + FAIL_COUNT=$(echo "$RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['failures']))" 2>/dev/null || echo "0") + + TOTAL_EASY=$((TOTAL_EASY + EASY)) + TOTAL_REVIEW=$((TOTAL_REVIEW + REVIEW)) + + if [ "$EASY" -gt 0 ]; then + log " Easy wins ($EASY):" + echo "$RESULT" | python3 -c "import json,sys; [print(f' {w}') for w in json.load(sys.stdin)['easy_wins']]" 2>/dev/null + fi + if [ "$REVIEW" -gt 0 ]; then + log " Needs review ($REVIEW):" + echo "$RESULT" | python3 -c "import json,sys; [print(f' {w}') for w in json.load(sys.stdin)['needs_review']]" 2>/dev/null + fi + + # Create CI failure beads (only for repos we own) + REPO_OWNER=$(echo "$REPO" | cut -d'/' -f1) + if [ "$REPO_OWNER" = "outdoorsea" ] && [ "$FAIL_COUNT" -gt 0 ]; then + # Get existing ci-failure beads for deduplication + EXISTING=$(bd list --label ci-failure --status open --json 2>/dev/null || echo "[]") + + echo "$RESULT" | python3 -c " +import json, sys, subprocess + +data = json.load(sys.stdin) +existing = json.loads('''$(echo "$EXISTING" | tr "'" '"')''') if '''$(echo "$EXISTING")''' != '[]' else [] +existing_titles = {e.get('title','') for e in existing} +created = 0 + +for f in data['failures']: + parts = f.split('|', 3) + if len(parts) < 3: continue + pr_num, pr_title, check_name = parts[0], parts[1], parts[2] + check_url = parts[3] if len(parts) > 3 else '' + + bead_title = f'CI failure: {check_name} on PR #{pr_num}' + if bead_title in existing_titles: + continue + + desc = f'CI check \`{check_name}\` failed on PR #{pr_num} ({pr_title})\nPR: https://github.com/$REPO/pull/{pr_num}' + if check_url: + desc += f'\nCheck: {check_url}' + + try: + subprocess.run(['bd', 'create', bead_title, '-t', 'task', '-p', '2', + '-d', desc, '-l', 'ci-failure', '--silent'], + capture_output=True, timeout=10) + created += 1 + except: pass + +print(created) +" 2>/dev/null | read -r NEW_CREATED || NEW_CREATED=0 + TOTAL_CREATED=$((TOTAL_CREATED + ${NEW_CREATED:-0})) + fi + +done <<< "$REPOS" + +# --- Report ------------------------------------------------------------------- + +SUMMARY="$REPO_COUNT repo(s), $TOTAL_PRS PR(s): $TOTAL_EASY easy win(s), $TOTAL_REVIEW need review, $TOTAL_CREATED bead(s) created" +log "" +log "=== GitHub Sheriff Summary ===" +log "$SUMMARY" + +bd create "github-sheriff: $SUMMARY" -t chore --ephemeral \ + -l type:plugin-run,plugin:github-sheriff,result:success \ + -d "$SUMMARY" --silent 2>/dev/null || true diff --git a/plugins/llm-doctor/plugin.md b/plugins/llm-doctor/plugin.md new file mode 100644 index 0000000000..3296fac88c --- /dev/null +++ b/plugins/llm-doctor/plugin.md @@ -0,0 +1,66 @@ ++++ +name = "llm-doctor" +description = "Local LLM-assisted troubleshooting when Claude API is unreachable" +version = 1 + +[gate] +type = "cooldown" +duration = "5m" + +[tracking] +labels = ["plugin:llm-doctor", "category:resilience"] +digest = true + +[execution] +timeout = "2m" +notify_on_failure = true +severity = "high" ++++ + +# LLM Doctor + +Monitors LLM API health and uses a local Ollama model to diagnose failures +when the remote API is unreachable. Escalates to the Overseer with a +structured diagnosis. + +## How It Works + +1. Probe the configured LLM provider (Anthropic direct, Bedrock, Vertex) +2. If healthy: exit quietly +3. If unhealthy: gather diagnostics (DNS, network, key validity, error codes) +4. Feed diagnostics to local Ollama model for classification and suggested fix +5. Escalate via `gt escalate` + mail to Overseer + +Falls back to shell-only diagnosis if Ollama is not available. + +## Requirements + +- Ollama installed and running (`brew install ollama && ollama serve`) +- No Ollama? Plugin still works — just produces shell-based diagnosis instead + +## Model Discovery + +The plugin auto-discovers the best available Ollama model via `resolve-model.sh`: + +1. If `LLM_DOCTOR_OLLAMA_MODEL` is set, use that (explicit override) +2. Walk a preference list (smallest first): llama3.2:1b, phi4-mini:3.8b, + llama3.2:3b, llama3.1:8b, qwen2.5:7b, gemma2:9b, qwen2.5:32b +3. If no preferred model is pulled, use whatever IS pulled +4. If nothing is pulled, auto-pull the smallest preferred model (llama3.2:1b) +5. Override preference list: `LLM_DOCTOR_MODEL_PREFS="model1:model2:model3"` + +The doctor only needs basic classification — a 1B model suffices. + +## Relationship to rate-limit-watchdog + +The rate-limit-watchdog handles 429s with estop/thaw. This plugin handles +everything else: network failures, auth errors, API outages, DNS issues. +It does NOT duplicate rate-limit handling — it defers to the watchdog for 429s. + +## Testing + +```bash +./test.sh # Run full test suite (mocked, no real API calls) +./test.sh --verbose # Verbose output +./run.sh --dry-run --force # Live test (probes real API, no escalation) +``` diff --git a/plugins/llm-doctor/prompts/diagnose.txt b/plugins/llm-doctor/prompts/diagnose.txt new file mode 100644 index 0000000000..caa09eca15 --- /dev/null +++ b/plugins/llm-doctor/prompts/diagnose.txt @@ -0,0 +1,18 @@ +You are a network and API troubleshooting assistant for a multi-agent AI system called Gas Town. The system depends on the Anthropic Claude API to function. The API is currently unreachable or returning errors. + +Analyze the following diagnostics and provide: +1. CLASSIFICATION: One of: network-down, dns-failure, auth-invalid, auth-expired, api-outage, api-degraded, proxy-error, unknown +2. ROOT CAUSE: One sentence describing the most likely cause +3. SUGGESTED FIX: 1-3 concrete steps the operator should take +4. URGENCY: critical (system fully down), high (degraded), medium (intermittent) + +Be concise. No preamble. Use this exact format: + +CLASSIFICATION: +ROOT CAUSE: +SUGGESTED FIX: +- +- +URGENCY: + +--- DIAGNOSTICS --- diff --git a/plugins/llm-doctor/resolve-model.sh b/plugins/llm-doctor/resolve-model.sh new file mode 100755 index 0000000000..422818425a --- /dev/null +++ b/plugins/llm-doctor/resolve-model.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# resolve-model.sh — Find or pull the best available Ollama model for diagnosis. +# +# Walks a preference list from smallest to largest. Returns the first model +# that is already pulled, or pulls the smallest one if none are available. +# +# Usage: +# source resolve-model.sh +# MODEL=$(resolve_ollama_model) # Returns model name or empty string +# MODEL=$(resolve_ollama_model quiet) # Suppress log output +# +# Environment: +# LLM_DOCTOR_OLLAMA_MODEL — Override: skip discovery entirely +# OLLAMA_URL — Ollama endpoint (default: http://localhost:11434) +# LLM_DOCTOR_MODEL_PREFS — Colon-separated override of preference list + +# Model preference list: smallest → largest. +# The doctor only needs basic classification — a 1B model suffices. +# Larger models produce better analysis but use more RAM and respond slower. +DEFAULT_MODEL_PREFS=( + "llama3.2:1b" # 1.3GB — minimal, fast, good enough for classification + "phi4-mini:3.8b" # 2.4GB — strong reasoning for size + "llama3.2:3b" # 2.0GB — good balance + "llama3.1:8b" # 4.7GB — better analysis, heavier + "qwen2.5:7b" # 4.7GB — strong multilingual + "gemma2:9b" # 5.4GB — google's compact model + "qwen2.5:32b" # 20GB — overkill but if it's all you have +) + +_resolve_log() { + [[ "${1:-}" == "quiet" ]] && return + echo "[llm-doctor:model] $2" >&2 +} + +resolve_ollama_model() { + local quiet="${1:-}" + local ollama_url="${OLLAMA_URL:-http://localhost:11434}" + + # If explicit override is set, just use it (user knows what they want) + if [[ -n "${LLM_DOCTOR_OLLAMA_MODEL:-}" ]]; then + _resolve_log "$quiet" "Using explicit model: $LLM_DOCTOR_OLLAMA_MODEL" + echo "$LLM_DOCTOR_OLLAMA_MODEL" + return 0 + fi + + # Check if Ollama is reachable + local http_rc + http_rc=$(curl -s -o /dev/null -w '%{http_code}' "$ollama_url/api/tags" \ + --connect-timeout 3 --max-time 5 2>/dev/null || echo "000") + + if [[ "$http_rc" != "200" ]]; then + _resolve_log "$quiet" "Ollama not reachable (HTTP $http_rc)" + return 1 + fi + + # Get list of pulled models + local tags_json + tags_json=$(curl -s "$ollama_url/api/tags" --max-time 5 2>/dev/null || echo "{}") + + local pulled_models + pulled_models=$(echo "$tags_json" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) + for m in data.get("models", []): + print(m["name"]) +except: + pass +' 2>/dev/null) + + # Build preference list (allow override via env) + local prefs=() + if [[ -n "${LLM_DOCTOR_MODEL_PREFS:-}" ]]; then + IFS=':' read -ra prefs <<< "$LLM_DOCTOR_MODEL_PREFS" + else + prefs=("${DEFAULT_MODEL_PREFS[@]}") + fi + + # Phase 1: Check if any preferred model is already pulled + for model in "${prefs[@]}"; do + # Match both exact name and name without tag (e.g., "llama3.2:1b" matches "llama3.2:1b") + if echo "$pulled_models" | grep -qF "$model"; then + _resolve_log "$quiet" "Found preferred model: $model (already pulled)" + echo "$model" + return 0 + fi + done + + # Phase 2: Check if ANY pulled model could work (not in prefs but available) + local first_available + first_available=$(echo "$pulled_models" | head -1) + if [[ -n "$first_available" ]]; then + _resolve_log "$quiet" "No preferred model found, using available: $first_available" + echo "$first_available" + return 0 + fi + + # Phase 3: No models at all — try to pull the smallest preferred model + _resolve_log "$quiet" "No models available — pulling ${prefs[0]}..." + local pull_result + pull_result=$(curl -s -X POST "$ollama_url/api/pull" \ + -d "{\"name\":\"${prefs[0]}\",\"stream\":false}" \ + --max-time 600 2>/dev/null || echo "") + + if [[ -n "$pull_result" ]] && echo "$pull_result" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +sys.exit(0 if r.get("status") == "success" else 1) +' 2>/dev/null; then + _resolve_log "$quiet" "Pulled ${prefs[0]} successfully" + echo "${prefs[0]}" + return 0 + fi + + _resolve_log "$quiet" "Failed to pull ${prefs[0]} — no model available" + return 1 +} + +# If run directly (not sourced), execute and print result +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + MODEL=$(resolve_ollama_model "$@") + RC=$? + if [[ $RC -eq 0 ]]; then + echo "$MODEL" + else + echo "No model available" >&2 + exit 1 + fi +fi diff --git a/plugins/llm-doctor/run.sh b/plugins/llm-doctor/run.sh new file mode 100755 index 0000000000..52de2b0a99 --- /dev/null +++ b/plugins/llm-doctor/run.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +# llm-doctor/run.sh — Diagnose LLM API failures using local Ollama. +# +# Probes the configured LLM provider. On failure, gathers diagnostics, +# optionally feeds them to a local Ollama model for classification, +# and escalates to the Overseer. +# +# Usage: ./run.sh [--force] [--dry-run] +# +# --force Run diagnosis even if API is healthy (for testing) +# --dry-run Print diagnosis but don't escalate +# +# Model selection: Set LLM_DOCTOR_OLLAMA_MODEL to override, or let +# resolve-model.sh auto-discover the best available model. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOWN_ROOT="${GT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}" + +# --- Configuration ----------------------------------------------------------- + +PROBE_MODEL="${LLM_DOCTOR_PROBE_MODEL:-claude-haiku-4-5-20251001}" +OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}" +# Model resolved dynamically — see resolve-model.sh +source "$SCRIPT_DIR/resolve-model.sh" +API_BASE="${ANTHROPIC_BASE_URL:-https://api.anthropic.com}" +PROMPT_FILE="$SCRIPT_DIR/prompts/diagnose.txt" +STATE_DIR="$TOWN_ROOT/.llm-doctor" +LAST_HEALTHY_FILE="$STATE_DIR/last-healthy" +LAST_DIAGNOSIS_FILE="$STATE_DIR/last-diagnosis" +CONSECUTIVE_FAIL_FILE="$STATE_DIR/consecutive-failures" + +mkdir -p "$STATE_DIR" + +# --- Argument parsing --------------------------------------------------------- + +FORCE=false +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --force) FORCE=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --help|-h) + echo "Usage: $0 [--force] [--dry-run]" + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# --- Helpers ------------------------------------------------------------------ + +log() { echo "[llm-doctor] $*"; } + +timestamp() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } + +increment_failures() { + local count=0 + [[ -f "$CONSECUTIVE_FAIL_FILE" ]] && count=$(cat "$CONSECUTIVE_FAIL_FILE") + count=$((count + 1)) + echo "$count" > "$CONSECUTIVE_FAIL_FILE" + echo "$count" +} + +reset_failures() { + echo "0" > "$CONSECUTIVE_FAIL_FILE" +} + +get_failure_count() { + [[ -f "$CONSECUTIVE_FAIL_FILE" ]] && cat "$CONSECUTIVE_FAIL_FILE" || echo "0" +} + +# --- Step 1: Probe the API --------------------------------------------------- + +log "Probing $API_BASE ..." + +# Determine auth method: API key (x-api-key) vs OAuth token (Bearer). +# Claude Max users have ANTHROPIC_AUTH_TOKEN; API users have ANTHROPIC_API_KEY. +AUTH_HEADERS=() +AUTH_METHOD="none" +if [[ -n "${ANTHROPIC_AUTH_TOKEN:-}" ]]; then + AUTH_HEADERS=(-H "Authorization: Bearer ${ANTHROPIC_AUTH_TOKEN}") + AUTH_METHOD="oauth" +elif [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + AUTH_HEADERS=(-H "x-api-key: ${ANTHROPIC_API_KEY}") + AUTH_METHOD="api-key" +else + AUTH_HEADERS=(-H "x-api-key: missing") + AUTH_METHOD="none" +fi + +log "Auth method: $AUTH_METHOD" + +# Capture both HTTP code and response body for error analysis. +PROBE_TMPFILE=$(mktemp) +trap "rm -f $PROBE_TMPFILE" EXIT + +HTTP_CODE=$(curl -s -o "$PROBE_TMPFILE" -w '%{http_code}' \ + -X POST "${API_BASE}/v1/messages" \ + "${AUTH_HEADERS[@]}" \ + -H "anthropic-version: 2023-06-01" \ + -H "content-type: application/json" \ + -d "{\"model\":\"$PROBE_MODEL\",\"max_tokens\":1,\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \ + --connect-timeout 10 \ + --max-time 20 \ + 2>/dev/null || echo "000") + +RESPONSE_BODY=$(cat "$PROBE_TMPFILE" 2>/dev/null | head -c 2000 || true) + +log "Probe result: HTTP $HTTP_CODE" + +# --- Step 2: Decide if we need to diagnose ------------------------------------ + +NEEDS_DIAGNOSIS=false +FAILURE_TYPE="" + +case "$HTTP_CODE" in + 200|201) + log "API healthy" + date -u +%s > "$LAST_HEALTHY_FILE" + reset_failures + if ! $FORCE; then + exit 0 + fi + log "--force: running diagnosis anyway" + FAILURE_TYPE="forced-test" + NEEDS_DIAGNOSIS=true + ;; + 429) + # Defer to rate-limit-watchdog — don't duplicate estop logic. + log "Rate limited (429) — deferring to rate-limit-watchdog" + exit 0 + ;; + 401) + FAILURE_TYPE="auth-error" + NEEDS_DIAGNOSIS=true + ;; + 403) + FAILURE_TYPE="forbidden" + NEEDS_DIAGNOSIS=true + ;; + 500|502|503|504) + FAILURE_TYPE="api-server-error" + NEEDS_DIAGNOSIS=true + ;; + 000) + FAILURE_TYPE="network-unreachable" + NEEDS_DIAGNOSIS=true + ;; + *) + FAILURE_TYPE="unexpected-$HTTP_CODE" + NEEDS_DIAGNOSIS=true + ;; +esac + +if ! $NEEDS_DIAGNOSIS; then + exit 0 +fi + +FAIL_COUNT=$(increment_failures) +log "Failure detected: $FAILURE_TYPE (consecutive: $FAIL_COUNT)" + +# --- Step 3: Gather diagnostics ----------------------------------------------- + +DIAG="" + +# 3a. Basic info +DIAG+="TIMESTAMP: $(timestamp)"$'\n' +DIAG+="FAILURE_TYPE: $FAILURE_TYPE"$'\n' +DIAG+="HTTP_CODE: $HTTP_CODE"$'\n' +DIAG+="API_BASE: $API_BASE"$'\n' +DIAG+="CONSECUTIVE_FAILURES: $FAIL_COUNT"$'\n' + +# 3b. Last healthy timestamp +if [[ -f "$LAST_HEALTHY_FILE" ]]; then + LAST_HEALTHY=$(cat "$LAST_HEALTHY_FILE") + NOW=$(date -u +%s) + MINUTES_AGO=$(( (NOW - LAST_HEALTHY) / 60 )) + DIAG+="LAST_HEALTHY: ${MINUTES_AGO} minutes ago"$'\n' +else + DIAG+="LAST_HEALTHY: unknown (never recorded)"$'\n' +fi + +# 3c. Response body (truncated) +if [[ -n "$RESPONSE_BODY" ]]; then + DIAG+="RESPONSE_BODY: $RESPONSE_BODY"$'\n' +fi + +# 3d. DNS resolution +API_HOST=$(echo "$API_BASE" | sed -E 's|https?://||' | cut -d/ -f1 | cut -d: -f1) +DNS_RESULT=$(dig +short "$API_HOST" 2>/dev/null | head -3 || echo "dig failed") +DIAG+="DNS_LOOKUP ($API_HOST): $DNS_RESULT"$'\n' + +# 3e. Network connectivity +PING_RESULT=$(ping -c 1 -W 3 8.8.8.8 2>&1 | tail -1 || echo "ping failed") +DIAG+="INTERNET_PING: $PING_RESULT"$'\n' + +# 3f. TLS check +TLS_RESULT=$(echo | openssl s_client -connect "$API_HOST:443" -servername "$API_HOST" 2>&1 \ + | grep -E "Verify return code|subject=" | head -2 || echo "TLS check failed") +DIAG+="TLS_CHECK: $TLS_RESULT"$'\n' + +# 3g. Auth check (redacted) +DIAG+="AUTH_METHOD: $AUTH_METHOD"$'\n' +if [[ "$AUTH_METHOD" == "oauth" ]]; then + TOKEN_PREFIX="${ANTHROPIC_AUTH_TOKEN:0:12}..." + DIAG+="AUTH_TOKEN: present ($TOKEN_PREFIX)"$'\n' +elif [[ "$AUTH_METHOD" == "api-key" ]]; then + if [[ "${ANTHROPIC_API_KEY}" == sk-ant-* ]]; then + KEY_PREFIX="${ANTHROPIC_API_KEY:0:12}..." + DIAG+="API_KEY: present ($KEY_PREFIX)"$'\n' + else + DIAG+="API_KEY: present (non-standard prefix: ${ANTHROPIC_API_KEY:0:6}...)"$'\n' + fi +else + DIAG+="AUTH: NOT SET (no ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN)"$'\n' +fi + +# 3h. Proxy configuration +if [[ -n "${HTTPS_PROXY:-}" ]]; then + DIAG+="HTTPS_PROXY: $HTTPS_PROXY"$'\n' +fi +if [[ -n "${HTTP_PROXY:-}" ]]; then + DIAG+="HTTP_PROXY: $HTTP_PROXY"$'\n' +fi + +# 3i. Active agent count +# Count agent sessions across all gt tmux sockets +AGENT_COUNT=0 +for sock in /tmp/tmux-$(id -u)/gt-*; do + [[ -S "$sock" ]] || continue + SOCK_NAME=$(basename "$sock") + COUNT=$(tmux -L "$SOCK_NAME" list-sessions 2>/dev/null | wc -l | tr -d ' ' || echo 0) + AGENT_COUNT=$((AGENT_COUNT + COUNT)) +done +DIAG+="ACTIVE_AGENT_SESSIONS: $AGENT_COUNT"$'\n' + +# 3j. Estop status +if [[ -f "$TOWN_ROOT/ESTOP" ]]; then + DIAG+="ESTOP: ACTIVE — $(cat "$TOWN_ROOT/ESTOP" | head -1)"$'\n' +else + DIAG+="ESTOP: not active"$'\n' +fi + +log "Diagnostics gathered" + +# --- Step 4: Diagnose with Ollama (if available) ------------------------------ + +DIAGNOSIS="" +USED_OLLAMA=false + +# Resolve best available Ollama model +OLLAMA_MODEL="" +if OLLAMA_MODEL=$(resolve_ollama_model 2>/dev/null); then + log "Ollama available — running diagnosis with $OLLAMA_MODEL" + + PROMPT=$(cat "$PROMPT_FILE") + FULL_PROMPT="${PROMPT}"$'\n'"${DIAG}" + + # Escape for JSON + ESCAPED_PROMPT=$(printf '%s' "$FULL_PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))') + + OLLAMA_RESPONSE=$(curl -s -X POST "$OLLAMA_URL/api/generate" \ + -H "content-type: application/json" \ + -d "{\"model\":\"$OLLAMA_MODEL\",\"prompt\":$ESCAPED_PROMPT,\"stream\":false,\"options\":{\"temperature\":0.1,\"num_predict\":300}}" \ + --max-time 90 2>/dev/null || echo "") + + if [[ -n "$OLLAMA_RESPONSE" ]]; then + DIAGNOSIS=$(echo "$OLLAMA_RESPONSE" | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r.get("response",""))' 2>/dev/null || echo "") + if [[ -n "$DIAGNOSIS" ]]; then + USED_OLLAMA=true + log "Ollama diagnosis complete" + fi + fi +fi + +# Fallback: shell-based classification if Ollama unavailable or failed +if ! $USED_OLLAMA; then + log "Ollama unavailable — using shell-based diagnosis" + case "$FAILURE_TYPE" in + network-unreachable) + if echo "$PING_RESULT" | grep -q "fail"; then + DIAGNOSIS="CLASSIFICATION: network-down +ROOT CAUSE: No internet connectivity — ping to 8.8.8.8 failed. +SUGGESTED FIX: +- Check network connection and router +- Check VPN status if applicable +URGENCY: critical" + elif echo "$DNS_RESULT" | grep -q "fail"; then + DIAGNOSIS="CLASSIFICATION: dns-failure +ROOT CAUSE: DNS resolution failed for $API_HOST. +SUGGESTED FIX: +- Check DNS settings (try: dig $API_HOST) +- Try alternate DNS (8.8.8.8, 1.1.1.1) +URGENCY: critical" + else + DIAGNOSIS="CLASSIFICATION: network-down +ROOT CAUSE: Cannot reach $API_BASE — network or firewall issue. +SUGGESTED FIX: +- Check firewall rules +- Test: curl -v $API_BASE/v1/messages +URGENCY: critical" + fi + ;; + auth-error) + if [[ "$AUTH_METHOD" == "oauth" ]]; then + DIAGNOSIS="CLASSIFICATION: auth-invalid +ROOT CAUSE: API returned 401 — OAuth token is invalid or expired. +SUGGESTED FIX: +- Re-authenticate: claude login +- Check ANTHROPIC_AUTH_TOKEN is current +- Claude Max tokens expire — may need refresh +URGENCY: critical" + elif [[ "$AUTH_METHOD" == "api-key" ]]; then + DIAGNOSIS="CLASSIFICATION: auth-invalid +ROOT CAUSE: API returned 401 — API key is invalid or expired. +SUGGESTED FIX: +- Check ANTHROPIC_API_KEY in environment +- Rotate key at console.anthropic.com +- Verify key has not been revoked +URGENCY: critical" + else + DIAGNOSIS="CLASSIFICATION: auth-invalid +ROOT CAUSE: API returned 401 — no credentials configured. +SUGGESTED FIX: +- Set ANTHROPIC_API_KEY (API users) or ANTHROPIC_AUTH_TOKEN (Claude Max) +- Claude Max: run 'claude login' to authenticate +URGENCY: critical" + fi + ;; + forbidden) + DIAGNOSIS="CLASSIFICATION: auth-expired +ROOT CAUSE: API returned 403 — key lacks required permissions or account issue. +SUGGESTED FIX: +- Check account status at console.anthropic.com +- Verify API key permissions and billing +URGENCY: high" + ;; + api-server-error) + DIAGNOSIS="CLASSIFICATION: api-outage +ROOT CAUSE: API returned $HTTP_CODE — Anthropic service is experiencing issues. +SUGGESTED FIX: +- Check status.anthropic.com for incidents +- Wait and retry — server errors are typically transient +- If persistent (>15 min), consider switching to Bedrock/Vertex provider +URGENCY: high" + ;; + *) + DIAGNOSIS="CLASSIFICATION: unknown +ROOT CAUSE: Unexpected HTTP $HTTP_CODE from API. +SUGGESTED FIX: +- Check response body for details +- Test manually: curl -v $API_BASE/v1/messages +URGENCY: medium" + ;; + esac +fi + +# --- Step 5: Save diagnosis --------------------------------------------------- + +{ + echo "--- LLM Doctor Diagnosis ---" + echo "Time: $(timestamp)" + echo "Diagnosed by: $(if $USED_OLLAMA; then echo "Ollama ($OLLAMA_MODEL)"; else echo "shell fallback"; fi)" + echo "" + echo "$DIAGNOSIS" + echo "" + echo "--- Raw Diagnostics ---" + echo "$DIAG" +} > "$LAST_DIAGNOSIS_FILE" + +log "Diagnosis saved to $LAST_DIAGNOSIS_FILE" + +# --- Step 6: Escalate --------------------------------------------------------- + +if $DRY_RUN; then + log "DRY RUN — would escalate:" + cat "$LAST_DIAGNOSIS_FILE" + exit 0 +fi + +# Determine severity from diagnosis +ESCALATION_SEVERITY="high" +if echo "$DIAGNOSIS" | grep -q "URGENCY: critical"; then + ESCALATION_SEVERITY="critical" +elif echo "$DIAGNOSIS" | grep -q "URGENCY: medium"; then + ESCALATION_SEVERITY="medium" +fi + +# Extract classification line for subject +CLASSIFICATION=$(echo "$DIAGNOSIS" | grep "^CLASSIFICATION:" | head -1 | sed 's/CLASSIFICATION: //') +CLASSIFICATION="${CLASSIFICATION:-unknown}" + +# Only escalate on first failure or every 3rd consecutive failure (avoid spam) +if [[ "$FAIL_COUNT" -eq 1 ]] || [[ $((FAIL_COUNT % 3)) -eq 0 ]]; then + log "Escalating ($ESCALATION_SEVERITY): $CLASSIFICATION" + + gt escalate "LLM API: $CLASSIFICATION ($FAILURE_TYPE)" \ + --severity "$ESCALATION_SEVERITY" \ + --reason "$(cat "$LAST_DIAGNOSIS_FILE")" 2>/dev/null || true + + # Also mail the Overseer directly for critical issues + if [[ "$ESCALATION_SEVERITY" == "critical" ]]; then + gt mail send --human -s "LLM DOWN: $CLASSIFICATION" --stdin </dev/null || true + +log "Done" diff --git a/plugins/llm-doctor/test.sh b/plugins/llm-doctor/test.sh new file mode 100755 index 0000000000..78c6f287bc --- /dev/null +++ b/plugins/llm-doctor/test.sh @@ -0,0 +1,594 @@ +#!/usr/bin/env bash +# test.sh — Test suite for llm-doctor plugin. +# +# Runs all tests using mock HTTP responses. No real API or Ollama calls. +# Usage: ./test.sh [--verbose] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TEST_TMPDIR=$(mktemp -d) +trap "rm -rf $TEST_TMPDIR" EXIT + +VERBOSE=false +[[ "${1:-}" == "--verbose" ]] && VERBOSE=true + +PASS=0 +FAIL=0 +ERRORS="" + +# --- Test helpers ------------------------------------------------------------- + +log_test() { echo " TEST: $1"; } + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + ERRORS+=" FAIL: $label — expected '$expected', got '$actual'"$'\n' + echo " FAIL: $label" + fi +} + +assert_contains() { + local label="$1" needle="$2" haystack="$3" + if echo "$haystack" | grep -qF "$needle"; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + ERRORS+=" FAIL: $label — '$needle' not found in output"$'\n' + echo " FAIL: $label" + fi +} + +assert_not_contains() { + local label="$1" needle="$2" haystack="$3" + if ! echo "$haystack" | grep -qF "$needle"; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + ERRORS+=" FAIL: $label — '$needle' should NOT be in output"$'\n' + echo " FAIL: $label" + fi +} + +assert_file_exists() { + local label="$1" path="$2" + if [[ -f "$path" ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + ERRORS+=" FAIL: $label — file not found: $path"$'\n' + echo " FAIL: $label" + fi +} + +assert_exit_code() { + local label="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + PASS=$((PASS + 1)) + else + FAIL=$((FAIL + 1)) + ERRORS+=" FAIL: $label — expected exit $expected, got $actual"$'\n' + echo " FAIL: $label" + fi +} + +# --- Mock infrastructure ------------------------------------------------------ +# +# We create a mock bin/ directory with fake `curl`, `gt`, `bd`, `dig`, `ping`, +# `openssl`, and `tmux` commands. Tests prepend this to PATH so run.sh calls +# our mocks instead of real binaries. + +MOCK_BIN="$TEST_TMPDIR/mock-bin" +mkdir -p "$MOCK_BIN" + +# Mock state files — tests write these to control mock behavior +MOCK_STATE="$TEST_TMPDIR/mock-state" +mkdir -p "$MOCK_STATE" + +# --- curl mock --- +# Reads MOCK_STATE/curl_responses to determine behavior. +# Format: one file per URL pattern, named by keyword. +cat > "$MOCK_BIN/curl" << 'MOCK_CURL' +#!/usr/bin/env bash +# Mock curl — returns responses based on MOCK_STATE files. +MOCK_STATE="${MOCK_STATE:?}" +ARGS="$*" + +# Parse -w flag for http_code format +if echo "$ARGS" | grep -q '%{http_code}'; then + WANTS_CODE=true +else + WANTS_CODE=false +fi + +# Parse -o flag for output file +OUTPUT_FILE="" +if echo "$ARGS" | grep -q '\-o '; then + OUTPUT_FILE=$(echo "$ARGS" | sed -E 's/.*-o ([^ ]+).*/\1/') +fi + +# Determine which mock to serve based on URL +if echo "$ARGS" | grep -q '/v1/messages'; then + CODE=$(cat "$MOCK_STATE/api_http_code" 2>/dev/null || echo "200") + BODY=$(cat "$MOCK_STATE/api_response_body" 2>/dev/null || echo '{"type":"message"}') + if [[ -n "$OUTPUT_FILE" ]]; then + echo "$BODY" > "$OUTPUT_FILE" + fi + if $WANTS_CODE; then + echo "$CODE" + else + echo "$BODY" + fi +elif echo "$ARGS" | grep -q '/api/tags'; then + CODE=$(cat "$MOCK_STATE/ollama_tags_code" 2>/dev/null || echo "000") + BODY=$(cat "$MOCK_STATE/ollama_tags_body" 2>/dev/null || echo '{}') + if [[ -n "$OUTPUT_FILE" ]]; then + echo "$BODY" > "$OUTPUT_FILE" + fi + if $WANTS_CODE; then + echo "$CODE" + else + echo "$BODY" + fi +elif echo "$ARGS" | grep -q '/api/generate'; then + BODY=$(cat "$MOCK_STATE/ollama_generate_body" 2>/dev/null || echo '{"response":"CLASSIFICATION: test\nROOT CAUSE: test\nSUGGESTED FIX:\n- test\nURGENCY: high"}') + echo "$BODY" +elif echo "$ARGS" | grep -q '/api/pull'; then + echo '{"status":"success"}' +else + echo "mock-curl: unhandled URL in: $ARGS" >&2 + echo "000" +fi +MOCK_CURL +chmod +x "$MOCK_BIN/curl" + +# --- gt mock --- +cat > "$MOCK_BIN/gt" << 'MOCK_GT' +#!/usr/bin/env bash +MOCK_STATE="${MOCK_STATE:?}" +echo "gt $*" >> "$MOCK_STATE/gt_calls" +MOCK_GT +chmod +x "$MOCK_BIN/gt" + +# --- bd mock --- +cat > "$MOCK_BIN/bd" << 'MOCK_BD' +#!/usr/bin/env bash +MOCK_STATE="${MOCK_STATE:?}" +echo "bd $*" >> "$MOCK_STATE/bd_calls" +MOCK_BD +chmod +x "$MOCK_BIN/bd" + +# --- dig mock --- +cat > "$MOCK_BIN/dig" << 'MOCK_DIG' +#!/usr/bin/env bash +echo "93.184.216.34" +MOCK_DIG +chmod +x "$MOCK_BIN/dig" + +# --- ping mock --- +cat > "$MOCK_BIN/ping" << 'MOCK_PING' +#!/usr/bin/env bash +MOCK_STATE="${MOCK_STATE:?}" +if [[ -f "$MOCK_STATE/ping_fail" ]]; then + echo "ping: sendto: Network is unreachable" + echo "ping failed" + exit 1 +else + echo "PING 8.8.8.8 (8.8.8.8): 56 data bytes" + echo "round-trip min/avg/max/stddev = 1.0/1.0/1.0/0.0 ms" +fi +MOCK_PING +chmod +x "$MOCK_BIN/ping" + +# --- openssl mock --- +cat > "$MOCK_BIN/openssl" << 'MOCK_SSL' +#!/usr/bin/env bash +echo "subject=CN=api.anthropic.com" +echo "Verify return code: 0 (ok)" +MOCK_SSL +chmod +x "$MOCK_BIN/openssl" + +# --- tmux mock --- +cat > "$MOCK_BIN/tmux" << 'MOCK_TMUX' +#!/usr/bin/env bash +# Return nothing (no sessions) +exit 0 +MOCK_TMUX +chmod +x "$MOCK_BIN/tmux" + +# --- python3 must be real (used for JSON parsing) --- +# We leave python3 on the real PATH, just prepend our mocks. + +# --- Run helper: execute run.sh with mocked environment ---------------------- + +run_doctor() { + local test_town="$TEST_TMPDIR/town" + mkdir -p "$test_town" + + # Clean state between runs + rm -f "$MOCK_STATE/gt_calls" "$MOCK_STATE/bd_calls" + rm -rf "$test_town/.llm-doctor" + + # Export mock state path for mock scripts + export MOCK_STATE + + # Run with mocked PATH (mocks first, then real python3/bash) + env \ + PATH="$MOCK_BIN:$PATH" \ + GT_ROOT="$test_town" \ + ANTHROPIC_API_KEY="${TEST_API_KEY:-sk-ant-test-key-1234}" \ + ANTHROPIC_BASE_URL="https://api.anthropic.com" \ + OLLAMA_URL="http://localhost:11434" \ + bash "$SCRIPT_DIR/run.sh" --dry-run "$@" 2>&1 +} + +# ============================================================================= +# TESTS +# ============================================================================= + +echo "=== llm-doctor test suite ===" +echo "" + +# --- Test Group 1: API Health Detection -------------------------------------- +echo "--- API Health Detection ---" + +# Test 1.1: Healthy API exits cleanly +log_test "1.1: Healthy API (200) exits cleanly" +echo "200" > "$MOCK_STATE/api_http_code" +echo '{"type":"message","content":[{"text":"ok"}]}' > "$MOCK_STATE/api_response_body" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "1.1a: reports healthy" "API healthy" "$OUTPUT" +assert_not_contains "1.1b: no diagnosis triggered" "Failure detected" "$OUTPUT" + +# Test 1.2: 401 triggers auth diagnosis +log_test "1.2: Auth error (401) triggers diagnosis" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" # Ollama down +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "1.2a: detects auth error" "auth-error" "$OUTPUT" +assert_contains "1.2b: classifies auth" "CLASSIFICATION: auth-invalid" "$OUTPUT" +assert_contains "1.2c: suggests fix" "ANTHROPIC_API_KEY" "$OUTPUT" +assert_contains "1.2d: critical urgency" "URGENCY: critical" "$OUTPUT" + +# Test 1.3: 403 triggers forbidden diagnosis +log_test "1.3: Forbidden (403) triggers diagnosis" +echo "403" > "$MOCK_STATE/api_http_code" +echo '{"type":"error","error":{"type":"permission_error","message":"forbidden"}}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "1.3a: detects forbidden" "forbidden" "$OUTPUT" +assert_contains "1.3b: classifies auth-expired" "CLASSIFICATION: auth-expired" "$OUTPUT" +assert_contains "1.3c: high urgency" "URGENCY: high" "$OUTPUT" + +# Test 1.4: 500 triggers server error diagnosis +log_test "1.4: Server error (500) triggers diagnosis" +echo "500" > "$MOCK_STATE/api_http_code" +echo '{"type":"error","error":{"type":"api_error","message":"internal error"}}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "1.4a: detects server error" "api-server-error" "$OUTPUT" +assert_contains "1.4b: classifies api-outage" "CLASSIFICATION: api-outage" "$OUTPUT" +assert_contains "1.4c: mentions status page" "status.anthropic.com" "$OUTPUT" + +# Test 1.5: 000 (network failure) triggers network diagnosis +log_test "1.5: Network failure (000) triggers diagnosis" +echo "000" > "$MOCK_STATE/api_http_code" +echo "" > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "1.5a: detects network" "network-unreachable" "$OUTPUT" +assert_contains "1.5b: classifies network-down" "CLASSIFICATION: network-down" "$OUTPUT" +assert_contains "1.5c: critical urgency" "URGENCY: critical" "$OUTPUT" + +# Test 1.6: 429 defers to rate-limit-watchdog +log_test "1.6: Rate limit (429) defers to watchdog" +echo "429" > "$MOCK_STATE/api_http_code" +echo '{"type":"error","error":{"type":"rate_limit_error"}}' > "$MOCK_STATE/api_response_body" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "1.6a: defers to watchdog" "deferring to rate-limit-watchdog" "$OUTPUT" +assert_not_contains "1.6b: no diagnosis" "Failure detected" "$OUTPUT" + +# Test 1.7: Network failure with ping failure +log_test "1.7: Network + ping failure → network-down" +echo "000" > "$MOCK_STATE/api_http_code" +echo "" > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +touch "$MOCK_STATE/ping_fail" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "1.7: full network down" "network-down" "$OUTPUT" +rm -f "$MOCK_STATE/ping_fail" + +# --- Test Group 2: Ollama Integration ---------------------------------------- +echo "" +echo "--- Ollama Integration ---" + +# Test 2.1: Ollama available with model → uses LLM diagnosis +log_test "2.1: Ollama with model → LLM diagnosis" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{"type":"error","error":{"type":"authentication_error","message":"invalid key"}}' > "$MOCK_STATE/api_response_body" +echo "200" > "$MOCK_STATE/ollama_tags_code" +echo '{"models":[{"name":"llama3.1:8b","size":4700000000}]}' > "$MOCK_STATE/ollama_tags_body" +echo '{"response":"CLASSIFICATION: auth-invalid\nROOT CAUSE: Bad API key.\nSUGGESTED FIX:\n- Rotate key\nURGENCY: critical"}' > "$MOCK_STATE/ollama_generate_body" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "2.1a: uses Ollama" "Ollama available" "$OUTPUT" +assert_contains "2.1b: diagnosis complete" "Ollama diagnosis complete" "$OUTPUT" +assert_contains "2.1c: diagnosed by Ollama" "Diagnosed by: Ollama" "$OUTPUT" + +# Test 2.2: Ollama down → shell fallback +log_test "2.2: Ollama down → shell fallback" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{"type":"error","error":{"type":"authentication_error"}}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "2.2a: falls back to shell" "shell fallback" "$OUTPUT" +assert_contains "2.2b: still classifies" "CLASSIFICATION: auth-invalid" "$OUTPUT" + +# Test 2.3: Ollama running but generate fails → shell fallback +log_test "2.3: Ollama generate failure → shell fallback" +echo "500" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "200" > "$MOCK_STATE/ollama_tags_code" +echo '{"models":[{"name":"llama3.1:8b"}]}' > "$MOCK_STATE/ollama_tags_body" +echo '' > "$MOCK_STATE/ollama_generate_body" # Empty response = failure +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "2.3: falls back" "shell fallback" "$OUTPUT" + +# --- Test Group 3: State Management ------------------------------------------ +echo "" +echo "--- State Management ---" + +# Test 3.1: Consecutive failure tracking +log_test "3.1: Consecutive failure counter increments" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" + +# Run 1 +run_doctor > /dev/null 2>&1 || true +# Run 2 (reuse same town dir) +TEST_TOWN="$TEST_TMPDIR/town" +FAIL_FILE="$TEST_TOWN/.llm-doctor/consecutive-failures" +# Can't easily test across runs in dry-run mode since each run_doctor creates fresh state +# Instead, verify the file was created +assert_file_exists "3.1: failure file created" "$FAIL_FILE" + +# Test 3.2: Diagnosis file saved +log_test "3.2: Diagnosis file saved" +DIAG_FILE="$TEST_TOWN/.llm-doctor/last-diagnosis" +assert_file_exists "3.2: diagnosis file" "$DIAG_FILE" +DIAG_CONTENT=$(cat "$DIAG_FILE") +assert_contains "3.2b: has header" "LLM Doctor Diagnosis" "$DIAG_CONTENT" +assert_contains "3.2c: has raw diag" "Raw Diagnostics" "$DIAG_CONTENT" + +# --- Test Group 4: Escalation Logic ------------------------------------------ +echo "" +echo "--- Escalation Logic ---" + +# Test 4.1: --force with healthy API → forced-test type +log_test "4.1: --force runs diagnosis on healthy API" +echo "200" > "$MOCK_STATE/api_http_code" +echo '{"type":"message"}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor --force 2>&1 || true) +assert_contains "4.1a: forced test" "forced-test" "$OUTPUT" +assert_contains "4.1b: still runs diagnosis" "would escalate" "$OUTPUT" + +# Test 4.2: Critical urgency detected from diagnosis +log_test "4.2: Critical urgency extraction" +echo "000" > "$MOCK_STATE/api_http_code" +echo "" > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "4.2: critical urgency in output" "URGENCY: critical" "$OUTPUT" + +# --- Test Group 5: Diagnostics Gathering ------------------------------------- +echo "" +echo "--- Diagnostics Gathering ---" + +# Test 5.1: API key redaction +log_test "5.1: API key is redacted in diagnostics" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +export TEST_API_KEY="sk-ant-api03-secret-key-do-not-leak" +OUTPUT=$(run_doctor 2>&1 || true) +assert_not_contains "5.1a: full key not in output" "secret-key-do-not-leak" "$OUTPUT" +assert_contains "5.1b: prefix shown" "sk-ant-api03" "$OUTPUT" +unset TEST_API_KEY + +# Test 5.2: Missing API key detected +log_test "5.2: Missing API key reported" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +export TEST_API_KEY="" +OUTPUT=$(env ANTHROPIC_API_KEY="" bash -c " + export MOCK_STATE='$MOCK_STATE' + export PATH='$MOCK_BIN:$PATH' + export GT_ROOT='$TEST_TMPDIR/town' + export ANTHROPIC_BASE_URL='https://api.anthropic.com' + export OLLAMA_URL='http://localhost:11434' + bash '$SCRIPT_DIR/run.sh' --dry-run 2>&1 +" || true) +assert_contains "5.2: reports auth not set" "AUTH: NOT SET" "$OUTPUT" +unset TEST_API_KEY + +# Test 5.3: DNS info collected +log_test "5.3: DNS info in diagnostics" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "5.3: DNS lookup present" "DNS_LOOKUP" "$OUTPUT" + +# Test 5.4: ESTOP status reported +log_test "5.4: ESTOP status in diagnostics" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "5.4: ESTOP status" "ESTOP:" "$OUTPUT" + +# --- Test Group 5b: Auth Methods --------------------------------------------- +echo "" +echo "--- Auth Methods ---" + +# Test 5b.1: OAuth token auth (Claude Max) +log_test "5b.1: OAuth token auth (Claude Max)" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_STATE="$MOCK_STATE" \ + GT_ROOT="$TEST_TMPDIR/town" \ + ANTHROPIC_API_KEY="" \ + ANTHROPIC_AUTH_TOKEN="oauth-test-token-abc123" \ + ANTHROPIC_BASE_URL="https://api.anthropic.com" \ + OLLAMA_URL="http://localhost:11434" \ + bash "$SCRIPT_DIR/run.sh" --dry-run 2>&1 || true) +assert_contains "5b.1a: detects oauth method" "AUTH_METHOD: oauth" "$OUTPUT" +assert_contains "5b.1b: token prefix shown" "oauth-test-t..." "$OUTPUT" +assert_not_contains "5b.1c: full token not leaked" "abc123" "$OUTPUT" +assert_contains "5b.1d: oauth-specific fix" "claude login" "$OUTPUT" + +# Test 5b.2: API key auth +log_test "5b.2: API key auth" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_STATE="$MOCK_STATE" \ + GT_ROOT="$TEST_TMPDIR/town" \ + ANTHROPIC_API_KEY="sk-ant-api03-testkey" \ + ANTHROPIC_AUTH_TOKEN="" \ + ANTHROPIC_BASE_URL="https://api.anthropic.com" \ + OLLAMA_URL="http://localhost:11434" \ + bash "$SCRIPT_DIR/run.sh" --dry-run 2>&1 || true) +assert_contains "5b.2a: detects api-key method" "AUTH_METHOD: api-key" "$OUTPUT" +assert_contains "5b.2b: key-specific fix" "console.anthropic.com" "$OUTPUT" + +# Test 5b.3: No auth configured +log_test "5b.3: No auth configured" +echo "401" > "$MOCK_STATE/api_http_code" +echo '{}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_STATE="$MOCK_STATE" \ + GT_ROOT="$TEST_TMPDIR/town" \ + ANTHROPIC_API_KEY="" \ + ANTHROPIC_AUTH_TOKEN="" \ + ANTHROPIC_BASE_URL="https://api.anthropic.com" \ + OLLAMA_URL="http://localhost:11434" \ + bash "$SCRIPT_DIR/run.sh" --dry-run 2>&1 || true) +assert_contains "5b.3a: detects no auth" "AUTH_METHOD: none" "$OUTPUT" +assert_contains "5b.3b: reports not set" "NOT SET" "$OUTPUT" + +# Test 5b.4: OAuth takes priority over API key when both set +log_test "5b.4: OAuth takes priority over API key" +echo "200" > "$MOCK_STATE/api_http_code" +echo '{"type":"message"}' > "$MOCK_STATE/api_response_body" +OUTPUT=$(env \ + PATH="$MOCK_BIN:$PATH" \ + MOCK_STATE="$MOCK_STATE" \ + GT_ROOT="$TEST_TMPDIR/town" \ + ANTHROPIC_API_KEY="sk-ant-api03-testkey" \ + ANTHROPIC_AUTH_TOKEN="oauth-test-token" \ + ANTHROPIC_BASE_URL="https://api.anthropic.com" \ + OLLAMA_URL="http://localhost:11434" \ + bash "$SCRIPT_DIR/run.sh" --dry-run --force 2>&1 || true) +assert_contains "5b.4: oauth wins" "AUTH_METHOD: oauth" "$OUTPUT" + +# --- Test Group 6: Model Resolution ----------------------------------------- +echo "" +echo "--- Model Resolution ---" + +# Test 6.1: Explicit model override +log_test "6.1: LLM_DOCTOR_OLLAMA_MODEL override" +OUTPUT=$(env MOCK_STATE="$MOCK_STATE" OLLAMA_URL="http://localhost:11434" \ + LLM_DOCTOR_OLLAMA_MODEL="my-custom-model:latest" \ + PATH="$MOCK_BIN:$PATH" \ + bash -c 'source "'"$SCRIPT_DIR"'/resolve-model.sh"; resolve_ollama_model 2>&1') +assert_contains "6.1: uses explicit model" "my-custom-model:latest" "$OUTPUT" + +# Test 6.2: Ollama unreachable returns failure +log_test "6.2: Ollama unreachable → failure" +echo "000" > "$MOCK_STATE/ollama_tags_code" +RC=0 +env MOCK_STATE="$MOCK_STATE" OLLAMA_URL="http://localhost:11434" \ + PATH="$MOCK_BIN:$PATH" \ + bash -c 'source "'"$SCRIPT_DIR"'/resolve-model.sh"; resolve_ollama_model quiet' > /dev/null 2>&1 || RC=$? +assert_exit_code "6.2: returns non-zero" "1" "$RC" + +# Test 6.3: Finds preferred model from pulled list +log_test "6.3: Finds preferred model in pulled list" +echo "200" > "$MOCK_STATE/ollama_tags_code" +echo '{"models":[{"name":"qwen2.5:32b","size":20000000000},{"name":"llama3.1:8b","size":4700000000}]}' > "$MOCK_STATE/ollama_tags_body" +OUTPUT=$(env MOCK_STATE="$MOCK_STATE" OLLAMA_URL="http://localhost:11434" \ + PATH="$MOCK_BIN:$PATH" \ + bash -c 'source "'"$SCRIPT_DIR"'/resolve-model.sh"; resolve_ollama_model 2>&1') +# Should pick llama3.1:8b (higher preference than qwen2.5:32b) +assert_contains "6.3: picks preferred model" "llama3.1:8b" "$OUTPUT" + +# Test 6.4: Falls back to any available model +log_test "6.4: Falls back to non-preferred but available model" +echo "200" > "$MOCK_STATE/ollama_tags_code" +echo '{"models":[{"name":"mistral:7b","size":4000000000}]}' > "$MOCK_STATE/ollama_tags_body" +OUTPUT=$(env MOCK_STATE="$MOCK_STATE" OLLAMA_URL="http://localhost:11434" \ + PATH="$MOCK_BIN:$PATH" \ + bash -c 'source "'"$SCRIPT_DIR"'/resolve-model.sh"; resolve_ollama_model 2>&1') +assert_contains "6.4: uses available model" "mistral:7b" "$OUTPUT" + +# --- Test Group 7: Edge Cases ------------------------------------------------ +echo "" +echo "--- Edge Cases ---" + +# Test 7.1: Unexpected HTTP code +log_test "7.1: Unexpected HTTP code (418)" +echo "418" > "$MOCK_STATE/api_http_code" +echo '{"error":"I am a teapot"}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "7.1a: detected" "unexpected-418" "$OUTPUT" +assert_contains "7.1b: unknown classification" "CLASSIFICATION: unknown" "$OUTPUT" + +# Test 7.2: 502 Bad Gateway +log_test "7.2: 502 Bad Gateway" +echo "502" > "$MOCK_STATE/api_http_code" +echo 'Bad Gateway' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "7.2: classified as api-outage" "api-outage" "$OUTPUT" + +# Test 7.3: 503 Service Unavailable +log_test "7.3: 503 Service Unavailable" +echo "503" > "$MOCK_STATE/api_http_code" +echo '{"error":"overloaded"}' > "$MOCK_STATE/api_response_body" +echo "000" > "$MOCK_STATE/ollama_tags_code" +OUTPUT=$(run_doctor 2>&1 || true) +assert_contains "7.3: classified as server error" "api-server-error" "$OUTPUT" + +# ============================================================================= +# RESULTS +# ============================================================================= + +echo "" +echo "===========================================" +echo " Results: $PASS passed, $FAIL failed" +echo "===========================================" + +if [[ $FAIL -gt 0 ]]; then + echo "" + echo "Failures:" + echo "$ERRORS" + exit 1 +fi + +echo " All tests passed." diff --git a/plugins/outbound-pr-tracker/plugin.md b/plugins/outbound-pr-tracker/plugin.md new file mode 100644 index 0000000000..1d3c0e0730 --- /dev/null +++ b/plugins/outbound-pr-tracker/plugin.md @@ -0,0 +1,420 @@ ++++ +name = "outbound-pr-tracker" +description = "Track outbound fix-merge PRs on upstream repos: CI status, maintainer feedback, merge/close events" +version = 1 + +[gate] +type = "cooldown" +duration = "2h" + +[tracking] +labels = ["plugin:outbound-pr-tracker", "category:pr-tracking"] +digest = true + +[execution] +timeout = "3m" +notify_on_failure = true +severity = "medium" ++++ + +# Outbound PR Tracker + +Tracks PRs that we (outdoorsea) have submitted to upstream repos. Closes the +loop on the fix-merge workflow by monitoring CI status, maintainer feedback, +and merge/close events. + +This is the outbound counterpart to the `github-sheriff` plugin, which monitors +inbound PRs on repos we own. This plugin monitors PRs we've sent upstream. + +Requires: `gh` CLI installed and authenticated (`gh auth status`). + +## Detection + +Verify `gh` is available and authenticated: + +```bash +gh auth status 2>/dev/null +if [ $? -ne 0 ]; then + echo "SKIP: gh CLI not authenticated" + exit 0 +fi +``` + +Detect the upstream repo from the rig's git remote: + +```bash +# Get upstream remote (the repo we submit PRs TO) +UPSTREAM=$(git -C "$GT_RIG_ROOT" remote get-url upstream 2>/dev/null \ + | sed -E 's|.*github\.com[:/]||; s|\.git$||') + +if [ -z "$UPSTREAM" ]; then + echo "SKIP: no upstream remote configured" + exit 0 +fi + +# Our fork's GitHub org (the author of outbound PRs) +OUR_ORG=$(git -C "$GT_RIG_ROOT" remote get-url origin 2>/dev/null \ + | sed -E 's|.*github\.com[:/]||; s|/.*||') + +if [ -z "$OUR_ORG" ]; then + echo "SKIP: could not detect origin org" + exit 0 +fi + +echo "Tracking outbound PRs: author=$OUR_ORG upstream=$UPSTREAM" +``` + +## Action + +### Step 1: Fetch open outbound PRs + +Query the upstream repo for PRs authored by our org. This finds all our +fix-merge PRs, feature submissions, etc. + +```bash +OPEN_PRS=$(gh pr list --repo "$UPSTREAM" --author "$OUR_ORG" --state open \ + --json number,title,url,state,statusCheckRollup,reviews,comments,updatedAt,createdAt \ + --limit 50 2>/dev/null || echo "[]") + +OPEN_COUNT=$(echo "$OPEN_PRS" | jq 'length') +echo "Found $OPEN_COUNT open outbound PR(s) on $UPSTREAM" +``` + +### Step 2: Fetch recently closed/merged outbound PRs + +Check PRs closed in the last 7 days to detect merges and rejections: + +```bash +SINCE=$(date -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-7d +%Y-%m-%dT%H:%M:%SZ) + +CLOSED_PRS=$(gh pr list --repo "$UPSTREAM" --author "$OUR_ORG" --state closed \ + --json number,title,url,state,mergedAt,closedAt,updatedAt \ + --limit 50 2>/dev/null || echo "[]") + +# Filter to recently closed only +CLOSED_PRS=$(echo "$CLOSED_PRS" | jq --arg since "$SINCE" \ + '[.[] | select((.closedAt // .updatedAt) >= $since)]') + +CLOSED_COUNT=$(echo "$CLOSED_PRS" | jq 'length') +echo "Found $CLOSED_COUNT recently closed outbound PR(s)" +``` + +### Step 3: Categorize open PRs + +Process each open PR to determine its state and any actions needed: + +```bash +CI_FAILING=() +REVIEW_COMMENTS=() +CHANGES_REQUESTED=() +APPROVED=() +WAITING=() + +# Derive rig name for bead operations +RIG_NAME=$(basename "$(dirname "$(dirname "$GT_RIG_ROOT")")" 2>/dev/null) +RIG_FLAG="" +[ -n "$RIG_NAME" ] && RIG_FLAG="--rig $RIG_NAME" + +while IFS= read -r PR_JSON; do + [ -z "$PR_JSON" ] && continue + + PR_NUM=$(echo "$PR_JSON" | jq -r '.number') + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') + PR_URL=$(echo "$PR_JSON" | jq -r '.url') + + # --- CI Status --- + TOTAL_CHECKS=$(echo "$PR_JSON" | jq '.statusCheckRollup | length') + PASSING_CHECKS=$(echo "$PR_JSON" | jq '[.statusCheckRollup[] | select( + .conclusion == "SUCCESS" or .conclusion == "NEUTRAL" or + .conclusion == "SKIPPED" or .state == "SUCCESS" + )] | length') + PENDING_CHECKS=$(echo "$PR_JSON" | jq '[.statusCheckRollup[] | select( + .state == "PENDING" or .conclusion == null + )] | length') + + if [ "$TOTAL_CHECKS" -gt 0 ] && [ "$TOTAL_CHECKS" -eq "$PASSING_CHECKS" ]; then + CI_STATUS="passing" + elif [ "$PENDING_CHECKS" -gt 0 ] && [ "$PASSING_CHECKS" -eq 0 ] && [ "$TOTAL_CHECKS" -eq "$PENDING_CHECKS" ]; then + CI_STATUS="pending" + else + CI_STATUS="failing" + fi + + # Collect individual check failures + FAILED_CHECKS="" + while IFS= read -r CHECK; do + [ -z "$CHECK" ] && continue + CHECK_NAME=$(echo "$CHECK" | jq -r '.name') + FAILED_CHECKS="${FAILED_CHECKS}${CHECK_NAME}, " + done < <(echo "$PR_JSON" | jq -c '.statusCheckRollup[] | select( + .conclusion == "FAILURE" or .conclusion == "CANCELLED" or + .conclusion == "TIMED_OUT" or .state == "FAILURE" or .state == "ERROR" + )') + FAILED_CHECKS="${FAILED_CHECKS%, }" + + if [ "$CI_STATUS" = "failing" ] && [ -n "$FAILED_CHECKS" ]; then + CI_FAILING+=("$PR_NUM|$PR_TITLE|$PR_URL|$FAILED_CHECKS") + fi + + # --- Review Status --- + # Check for CHANGES_REQUESTED or APPROVED reviews + HAS_CHANGES_REQUESTED=$(echo "$PR_JSON" | jq '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | length') + HAS_APPROVED=$(echo "$PR_JSON" | jq '[.reviews[] | select(.state == "APPROVED")] | length') + + # Check for recent comments (review comments from maintainers) + COMMENT_COUNT=$(echo "$PR_JSON" | jq '.comments | length') + + if [ "$HAS_CHANGES_REQUESTED" -gt 0 ]; then + REVIEWER=$(echo "$PR_JSON" | jq -r '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | last | .author.login') + CHANGES_REQUESTED+=("$PR_NUM|$PR_TITLE|$PR_URL|$REVIEWER") + elif [ "$HAS_APPROVED" -gt 0 ]; then + APPROVED+=("$PR_NUM|$PR_TITLE|$PR_URL") + elif [ "$COMMENT_COUNT" -gt 0 ]; then + LAST_COMMENTER=$(echo "$PR_JSON" | jq -r '.comments | last | .author.login // "unknown"') + # Only flag if last comment is NOT from us + if [ "$LAST_COMMENTER" != "$OUR_ORG" ]; then + REVIEW_COMMENTS+=("$PR_NUM|$PR_TITLE|$PR_URL|$LAST_COMMENTER") + else + WAITING+=("$PR_NUM|$PR_TITLE|$PR_URL|$CI_STATUS") + fi + else + WAITING+=("$PR_NUM|$PR_TITLE|$PR_URL|$CI_STATUS") + fi + +done < <(echo "$OPEN_PRS" | jq -c '.[]') +``` + +### Step 4: Detect merged and rejected PRs + +```bash +MERGED=() +REJECTED=() + +while IFS= read -r PR_JSON; do + [ -z "$PR_JSON" ] && continue + + PR_NUM=$(echo "$PR_JSON" | jq -r '.number') + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') + PR_URL=$(echo "$PR_JSON" | jq -r '.url') + MERGED_AT=$(echo "$PR_JSON" | jq -r '.mergedAt // empty') + + if [ -n "$MERGED_AT" ]; then + MERGED+=("$PR_NUM|$PR_TITLE|$PR_URL") + else + REJECTED+=("$PR_NUM|$PR_TITLE|$PR_URL") + fi +done < <(echo "$CLOSED_PRS" | jq -c '.[]') +``` + +### Step 5: Create beads for actionable items + +Deduplicate against existing beads before creating new ones: + +```bash +EXISTING=$(bd list --label outbound-pr --status open $RIG_FLAG --json 2>/dev/null || echo "[]") +CREATED=0 +SKIPPED=0 + +# --- CI Failures: create beads for crew to diagnose --- +for F in "${CI_FAILING[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL CHECKS <<< "$F" + BEAD_TITLE="Outbound PR #$PR_NUM: CI failing ($CHECKS)" + + if echo "$EXISTING" | jq -e --arg n "$PR_NUM" '.[] | select(.title | contains("#" + $n + ":"))' > /dev/null 2>&1; then + SKIPPED=$((SKIPPED + 1)) + continue + fi + + bd create "$BEAD_TITLE" -t task -p 2 \ + -d "CI checks failing on our outbound PR. + +PR: $PR_URL +Failed checks: $CHECKS + +Action: diagnose failure, push fix to the PR branch, or escalate if upstream CI is broken." \ + -l outbound-pr,ci-failure \ + $RIG_FLAG \ + --silent 2>/dev/null && CREATED=$((CREATED + 1)) +done + +# --- Changes Requested: create beads for crew to respond --- +for F in "${CHANGES_REQUESTED[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL REVIEWER <<< "$F" + BEAD_TITLE="Outbound PR #$PR_NUM: changes requested by $REVIEWER" + + if echo "$EXISTING" | jq -e --arg n "$PR_NUM" '.[] | select(.title | contains("#" + $n + ":"))' > /dev/null 2>&1; then + SKIPPED=$((SKIPPED + 1)) + continue + fi + + bd create "$BEAD_TITLE" -t task -p 2 \ + -d "Maintainer requested changes on our outbound PR. + +PR: $PR_URL +Reviewer: $REVIEWER + +Action: review feedback, make requested changes, push update." \ + -l outbound-pr,changes-requested \ + $RIG_FLAG \ + --silent 2>/dev/null && CREATED=$((CREATED + 1)) +done + +# --- Review Comments: surface for crew response --- +for F in "${REVIEW_COMMENTS[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL COMMENTER <<< "$F" + BEAD_TITLE="Outbound PR #$PR_NUM: maintainer comment from $COMMENTER" + + if echo "$EXISTING" | jq -e --arg n "$PR_NUM" '.[] | select(.title | contains("#" + $n + ":"))' > /dev/null 2>&1; then + SKIPPED=$((SKIPPED + 1)) + continue + fi + + bd create "$BEAD_TITLE" -t task -p 3 \ + -d "Maintainer commented on our outbound PR. + +PR: $PR_URL +Commenter: $COMMENTER + +Action: read comment, respond or make changes as needed." \ + -l outbound-pr,maintainer-comment \ + $RIG_FLAG \ + --silent 2>/dev/null && CREATED=$((CREATED + 1)) +done + +# --- Merged: emit activity event, close any tracking beads --- +for F in "${MERGED[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL <<< "$F" + echo " MERGED: PR #$PR_NUM — $PR_TITLE ($PR_URL)" + + gt activity emit outbound_pr_merged \ + --message "Outbound PR #$PR_NUM merged upstream: $PR_TITLE ($UPSTREAM)" \ + 2>/dev/null || true + + # Close any open tracking beads for this PR + TRACKING_ID=$(echo "$EXISTING" | jq -r --arg n "$PR_NUM" \ + '.[] | select(.title | contains("#" + $n + ":")) | .id // empty' 2>/dev/null | head -1) + if [ -n "$TRACKING_ID" ]; then + bd close "$TRACKING_ID" --reason="merged: PR #$PR_NUM merged upstream" \ + $RIG_FLAG 2>/dev/null || true + echo " Closed tracking bead: $TRACKING_ID" + fi +done + +# --- Rejected (closed without merge): flag for review --- +for F in "${REJECTED[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL <<< "$F" + BEAD_TITLE="Outbound PR #$PR_NUM: closed without merge — review needed" + + if echo "$EXISTING" | jq -e --arg n "$PR_NUM" '.[] | select(.title | contains("#" + $n + ":"))' > /dev/null 2>&1; then + SKIPPED=$((SKIPPED + 1)) + continue + fi + + bd create "$BEAD_TITLE" -t task -p 2 \ + -d "Our outbound PR was closed without being merged. + +PR: $PR_URL + +Action: review why it was closed. Consider: resubmit with changes, open discussion, or accept rejection." \ + -l outbound-pr,rejected \ + $RIG_FLAG \ + --silent 2>/dev/null && CREATED=$((CREATED + 1)) + + gt activity emit outbound_pr_rejected \ + --message "Outbound PR #$PR_NUM closed without merge: $PR_TITLE ($UPSTREAM)" \ + 2>/dev/null || true +done +``` + +### Step 6: Print patrol summary + +```bash +echo "" +echo "=== Outbound PR Tracker Summary ===" +echo "Upstream: $UPSTREAM (author: $OUR_ORG)" +echo "" + +if [ ${#CI_FAILING[@]} -gt 0 ]; then + echo "CI Failing (${#CI_FAILING[@]}):" + for F in "${CI_FAILING[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL CHECKS <<< "$F" + echo " PR #$PR_NUM: $PR_TITLE — failing: $CHECKS" + done +fi + +if [ ${#CHANGES_REQUESTED[@]} -gt 0 ]; then + echo "Changes Requested (${#CHANGES_REQUESTED[@]}):" + for F in "${CHANGES_REQUESTED[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL REVIEWER <<< "$F" + echo " PR #$PR_NUM: $PR_TITLE — by $REVIEWER" + done +fi + +if [ ${#REVIEW_COMMENTS[@]} -gt 0 ]; then + echo "Maintainer Comments (${#REVIEW_COMMENTS[@]}):" + for F in "${REVIEW_COMMENTS[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL COMMENTER <<< "$F" + echo " PR #$PR_NUM: $PR_TITLE — from $COMMENTER" + done +fi + +if [ ${#APPROVED[@]} -gt 0 ]; then + echo "Approved (${#APPROVED[@]}):" + for F in "${APPROVED[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL <<< "$F" + echo " PR #$PR_NUM: $PR_TITLE" + done +fi + +if [ ${#MERGED[@]} -gt 0 ]; then + echo "Recently Merged (${#MERGED[@]}):" + for F in "${MERGED[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL <<< "$F" + echo " PR #$PR_NUM: $PR_TITLE" + done +fi + +if [ ${#REJECTED[@]} -gt 0 ]; then + echo "Closed Without Merge (${#REJECTED[@]}):" + for F in "${REJECTED[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL <<< "$F" + echo " PR #$PR_NUM: $PR_TITLE" + done +fi + +if [ ${#WAITING[@]} -gt 0 ]; then + echo "Waiting (${#WAITING[@]}):" + for F in "${WAITING[@]}"; do + IFS='|' read -r PR_NUM PR_TITLE PR_URL CI_STATUS <<< "$F" + echo " PR #$PR_NUM: $PR_TITLE — CI: $CI_STATUS" + done +fi + +echo "" +echo "Beads: $CREATED created, $SKIPPED already tracked" +``` + +## Record Result + +```bash +SUMMARY="$UPSTREAM: $OPEN_COUNT open, ${#CI_FAILING[@]} CI-failing, ${#CHANGES_REQUESTED[@]} changes-requested, ${#REVIEW_COMMENTS[@]} comments, ${#MERGED[@]} merged, ${#REJECTED[@]} rejected, $CREATED bead(s) created" +echo "$SUMMARY" +``` + +On success: +```bash +bd create "outbound-pr-tracker: $SUMMARY" -t chore --ephemeral \ + -l type:plugin-run,plugin:outbound-pr-tracker,result:success \ + -d "$SUMMARY" --silent 2>/dev/null || true +``` + +On failure: +```bash +bd create "outbound-pr-tracker: FAILED" -t chore --ephemeral \ + -l type:plugin-run,plugin:outbound-pr-tracker,result:failure \ + -d "Outbound PR tracker failed: $ERROR" --silent 2>/dev/null || true + +gt escalate "Plugin FAILED: outbound-pr-tracker" \ + --severity medium \ + --reason "$ERROR" +``` diff --git a/plugins/outbound-pr-tracker/run.sh b/plugins/outbound-pr-tracker/run.sh new file mode 100755 index 0000000000..8ee00981c1 --- /dev/null +++ b/plugins/outbound-pr-tracker/run.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# outbound-pr-tracker/run.sh — Track our PRs on upstream repos. +# +# Monitors CI status, maintainer feedback, and merge/close events for PRs +# we've submitted to upstream repos. Creates beads for actionable items. + +set -euo pipefail + +log() { echo "[outbound-pr-tracker] $*"; } + +# --- Preflight --------------------------------------------------------------- + +gh auth status 2>/dev/null || { log "SKIP: gh CLI not authenticated"; exit 0; } + +# Discover rigs and their upstream remotes +RIG_JSON=$(gt rig list --json 2>/dev/null) || { log "SKIP: could not get rig list"; exit 0; } + +# Build list of upstream repos with their rig info +REPOS=$(echo "$RIG_JSON" | python3 -c " +import json, sys, subprocess, re +rigs = json.load(sys.stdin) +seen = set() +for r in rigs: + p = r.get('repo_path') or '' + if not p: continue + try: + upstream = subprocess.check_output(['git', '-C', p, 'remote', 'get-url', 'upstream'], + stderr=subprocess.DEVNULL, text=True).strip() + origin = subprocess.check_output(['git', '-C', p, 'remote', 'get-url', 'origin'], + stderr=subprocess.DEVNULL, text=True).strip() + m_up = re.search(r'github\.com[:/](.+?)(?:\.git)?$', upstream) + m_or = re.search(r'github\.com[:/](.+?)(?:\.git)?$', origin) + if m_up and m_or: + repo = m_up.group(1) + org = m_or.group(1).split('/')[0] + if repo not in seen: + seen.add(repo) + print(f'{repo}\t{org}\t{r.get(\"name\",\"\")}') + except: pass +" 2>/dev/null) + +if [ -z "$REPOS" ]; then + log "SKIP: no upstream repos found" + exit 0 +fi + +REPO_COUNT=$(echo "$REPOS" | wc -l | tr -d ' ') +log "Tracking outbound PRs for $REPO_COUNT upstream repo(s)..." + +SINCE=$(date -v-7d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2020-01-01T00:00:00Z") + +TOTAL_OPEN=0 +TOTAL_MERGED=0 +TOTAL_CREATED=0 + +# --- Process each upstream repo ----------------------------------------------- + +while IFS=$'\t' read -r UPSTREAM OUR_ORG RIG_NAME; do + [ -z "$UPSTREAM" ] && continue + log "" + log "=== $UPSTREAM (author: $OUR_ORG) ===" + + # Fetch open PRs by our org + OPEN_PRS=$(gh pr list --repo "$UPSTREAM" --author "$OUR_ORG" --state open \ + --json number,title,url,statusCheckRollup,reviews,comments,updatedAt \ + --limit 50 2>/dev/null || echo "[]") + + OPEN_COUNT=$(echo "$OPEN_PRS" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "0") + TOTAL_OPEN=$((TOTAL_OPEN + OPEN_COUNT)) + + # Fetch recently closed PRs + CLOSED_PRS=$(gh pr list --repo "$UPSTREAM" --author "$OUR_ORG" --state closed \ + --json number,title,url,state,mergedAt,closedAt,updatedAt \ + --limit 50 2>/dev/null || echo "[]") + + # Categorize with python + RESULT=$(python3 -c " +import json, sys + +open_prs = json.loads('''$(echo "$OPEN_PRS")''') +closed_prs = json.loads('''$(echo "$CLOSED_PRS")''') +since = '$SINCE' + +ci_failing = [] +changes_requested = [] +comments = [] +approved = [] +waiting = [] + +for pr in open_prs: + n, t, u = pr['number'], pr['title'], pr['url'] + checks = pr.get('statusCheckRollup') or [] + total = len(checks) + passing = sum(1 for c in checks if c.get('conclusion') in ('SUCCESS','NEUTRAL','SKIPPED') or c.get('state') == 'SUCCESS') + ci_pass = total > 0 and total == passing + + failed = [c.get('name','?') for c in checks if c.get('conclusion') in ('FAILURE','CANCELLED','TIMED_OUT') or c.get('state') in ('FAILURE','ERROR')] + if failed: + ci_failing.append({'num': n, 'title': t, 'url': u, 'checks': ', '.join(failed)}) + + reviews = pr.get('reviews') or [] + has_cr = any(r.get('state') == 'CHANGES_REQUESTED' for r in reviews) + has_ap = any(r.get('state') == 'APPROVED' for r in reviews) + cmts = pr.get('comments') or [] + + if has_cr: + reviewer = next((r.get('author',{}).get('login','?') for r in reversed(reviews) if r.get('state') == 'CHANGES_REQUESTED'), '?') + changes_requested.append({'num': n, 'title': t, 'url': u, 'reviewer': reviewer}) + elif has_ap: + approved.append({'num': n, 'title': t, 'url': u}) + elif cmts: + last = cmts[-1].get('author',{}).get('login','') + if last != '$OUR_ORG': + comments.append({'num': n, 'title': t, 'url': u, 'commenter': last}) + else: + waiting.append({'num': n, 'title': t, 'url': u, 'ci': 'pass' if ci_pass else 'fail'}) + else: + waiting.append({'num': n, 'title': t, 'url': u, 'ci': 'pass' if ci_pass else 'fail'}) + +merged = [{'num': p['number'], 'title': p['title'], 'url': p['url']} + for p in closed_prs if p.get('mergedAt') and (p.get('closedAt','') >= since or p.get('updatedAt','') >= since)] +rejected = [{'num': p['number'], 'title': p['title'], 'url': p['url']} + for p in closed_prs if not p.get('mergedAt') and (p.get('closedAt','') >= since or p.get('updatedAt','') >= since)] + +print(json.dumps({'ci_failing': ci_failing, 'changes_requested': changes_requested, + 'comments': comments, 'approved': approved, 'waiting': waiting, + 'merged': merged, 'rejected': rejected})) +" 2>/dev/null || echo '{}') + + # Print summary + for cat in ci_failing changes_requested comments approved merged rejected waiting; do + COUNT=$(echo "$RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('$cat',[])))" 2>/dev/null || echo "0") + [ "$COUNT" -gt 0 ] && log " $cat: $COUNT" + done + + MERGED_COUNT=$(echo "$RESULT" | python3 -c "import json,sys; print(len(json.load(sys.stdin).get('merged',[])))" 2>/dev/null || echo "0") + TOTAL_MERGED=$((TOTAL_MERGED + MERGED_COUNT)) + + # Create beads for actionable items + EXISTING=$(bd list --label outbound-pr --status open --json 2>/dev/null || echo "[]") + + CREATED=$(echo "$RESULT" | python3 -c " +import json, sys, subprocess + +data = json.load(sys.stdin) +existing = json.loads('''$(echo "$EXISTING" | sed "s/'/\"/g")''') if '''$(echo "$EXISTING")''' != '[]' else [] +existing_titles = {e.get('title','') for e in existing} +created = 0 + +for item in data.get('ci_failing', []): + title = f\"Outbound PR #{item['num']}: CI failing ({item['checks']})\" + if title in existing_titles: continue + try: + subprocess.run(['bd', 'create', title, '-t', 'task', '-p', '2', + '-d', f\"CI failing on {item['url']}\nChecks: {item['checks']}\", + '-l', 'outbound-pr,ci-failure', '--silent'], + capture_output=True, timeout=10) + created += 1 + except: pass + +for item in data.get('changes_requested', []): + title = f\"Outbound PR #{item['num']}: changes requested by {item['reviewer']}\" + if title in existing_titles: continue + try: + subprocess.run(['bd', 'create', title, '-t', 'task', '-p', '2', + '-d', f\"Changes requested on {item['url']}\nReviewer: {item['reviewer']}\", + '-l', 'outbound-pr,changes-requested', '--silent'], + capture_output=True, timeout=10) + created += 1 + except: pass + +for item in data.get('merged', []): + for e in existing: + if f\"#{item['num']}:\" in e.get('title', ''): + try: + subprocess.run(['bd', 'close', e['id'], '--reason', f\"merged: PR #{item['num']}\"], + capture_output=True, timeout=10) + except: pass + +print(created) +" 2>/dev/null || echo "0") + TOTAL_CREATED=$((TOTAL_CREATED + CREATED)) + +done <<< "$REPOS" + +# --- Report ------------------------------------------------------------------- + +SUMMARY="$REPO_COUNT repo(s): $TOTAL_OPEN open, $TOTAL_MERGED merged, $TOTAL_CREATED bead(s) created" +log "" +log "=== Outbound PR Tracker Summary ===" +log "$SUMMARY" + +bd create "outbound-pr-tracker: $SUMMARY" -t chore --ephemeral \ + -l type:plugin-run,plugin:outbound-pr-tracker,result:success \ + -d "$SUMMARY" --silent 2>/dev/null || true diff --git a/plugins/quality-review/run.sh b/plugins/quality-review/run.sh new file mode 100755 index 0000000000..bb2bf80838 --- /dev/null +++ b/plugins/quality-review/run.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# quality-review/run.sh — Analyze merge quality trends from refinery data. +# +# Queries quality-review result wisps from last 24h, computes per-worker +# trends, and alerts on quality breaches. + +set -euo pipefail + +log() { echo "[quality-review] $*"; } + +# --- Query results ------------------------------------------------------------ + +log "Fetching quality-review results from last 24h..." + +RESULTS=$(bd list --json --all -l type:plugin-run,plugin:quality-review-result --limit 0 2>/dev/null || echo "[]") + +# Filter to last 24h and compute trends with python +ANALYSIS=$(python3 -c " +import json, sys +from datetime import datetime, timedelta, timezone + +results = json.loads('''$(echo "$RESULTS" | sed "s/'/\"/g")''') +if not results: + print(json.dumps({'workers': [], 'total': 0, 'breaches': 0, 'warnings': 0})) + sys.exit(0) + +cutoff = datetime.now(timezone.utc) - timedelta(hours=24) + +# Parse worker data from labels +workers = {} +for r in results: + created = r.get('created_at', '') + try: + dt = datetime.fromisoformat(created.replace('Z', '+00:00')) + if dt < cutoff: + continue + except: + continue + + labels = {l.get('name',''): l.get('name','') for l in r.get('labels', [])} + # Extract from label format: worker:name, score:0.85, etc. + worker = None + score = None + rig = None + rec = None + for l in r.get('labels', []): + name = l.get('name', '') + if name.startswith('worker:'): + worker = name.split(':', 1)[1] + elif name.startswith('score:'): + try: score = float(name.split(':', 1)[1]) + except: pass + elif name.startswith('rig:'): + rig = name.split(':', 1)[1] + elif name.startswith('recommendation:'): + rec = name.split(':', 1)[1] + + if worker and score is not None: + if worker not in workers: + workers[worker] = {'scores': [], 'rejections': 0, 'total': 0, 'rig': rig or '?'} + workers[worker]['scores'].append(score) + workers[worker]['total'] += 1 + if rec == 'request_changes': + workers[worker]['rejections'] += 1 + +# Compute trends +output = [] +breaches = 0 +warnings = 0 +for name, data in workers.items(): + scores = data['scores'] + avg = sum(scores) / len(scores) + rejection_rate = data['rejections'] / data['total'] if data['total'] > 0 else 0 + + # Trend: compare first half vs second half + mid = len(scores) // 2 + if mid > 0: + first_half = sum(scores[:mid]) / mid + second_half = sum(scores[mid:]) / len(scores[mid:]) + diff = second_half - first_half + if diff > 0.05: trend = 'improving' + elif diff < -0.05: trend = 'declining' + else: trend = 'stable' + else: + trend = 'stable' + + if avg < 0.45: + status = 'BREACH' + breaches += 1 + elif avg < 0.60: + status = 'WARN' + warnings += 1 + else: + status = 'OK' + + output.append({ + 'name': name, 'rig': data['rig'], 'avg': round(avg, 2), + 'count': data['total'], 'rejections': data['rejections'], + 'rejection_rate': round(rejection_rate * 100, 1), + 'trend': trend, 'status': status + }) + +print(json.dumps({ + 'workers': sorted(output, key=lambda w: w['avg']), + 'total': sum(d['total'] for d in workers.values()), + 'breaches': breaches, + 'warnings': warnings +})) +" 2>/dev/null || echo '{"workers":[],"total":0,"breaches":0,"warnings":0}') + +TOTAL=$(echo "$ANALYSIS" | python3 -c "import json,sys; print(json.load(sys.stdin)['total'])" 2>/dev/null || echo "0") +WORKER_COUNT=$(echo "$ANALYSIS" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['workers']))" 2>/dev/null || echo "0") +BREACHES=$(echo "$ANALYSIS" | python3 -c "import json,sys; print(json.load(sys.stdin)['breaches'])" 2>/dev/null || echo "0") +WARNINGS=$(echo "$ANALYSIS" | python3 -c "import json,sys; print(json.load(sys.stdin)['warnings'])" 2>/dev/null || echo "0") + +if [ "$TOTAL" -eq 0 ]; then + log "No quality-review results in last 24h. Nothing to analyze." + bd create "quality-review: No results in last 24h" -t chore --ephemeral \ + -l type:plugin-run,plugin:quality-review,result:success \ + -d "No quality-review results in last 24h." --silent 2>/dev/null || true + exit 0 +fi + +# --- Print trends ------------------------------------------------------------- + +log "Analyzed $WORKER_COUNT workers over $TOTAL reviews:" +echo "$ANALYSIS" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for w in data['workers']: + icon = '🔴' if w['status'] == 'BREACH' else ('🟡' if w['status'] == 'WARN' else '🟢') + print(f\" {icon} {w['name']} ({w['rig']}): avg={w['avg']} reviews={w['count']} rejections={w['rejection_rate']}% trend={w['trend']}\") +" 2>/dev/null + +# --- Alert on breaches -------------------------------------------------------- + +if [ "$BREACHES" -gt 0 ]; then + log "Sending breach alerts..." + echo "$ANALYSIS" | python3 -c " +import json, sys, subprocess +data = json.load(sys.stdin) +for w in data['workers']: + if w['status'] != 'BREACH': continue + msg = f\"Worker: {w['name']}\nRig: {w['rig']}\nAvg Score: {w['avg']}\nReviews: {w['count']}\nRejection Rate: {w['rejection_rate']}%\nTrend: {w['trend']}\n\nAction: Review recent merges from this worker for quality issues.\" + subprocess.run(['gt', 'mail', 'send', 'mayor/', '-s', f\"Quality BREACH: {w['name']}\", '--stdin'], + input=msg, text=True, capture_output=True, timeout=10) + subprocess.run(['gt', 'escalate', f\"Quality BREACH: {w['name']} (avg: {w['avg']})\", + '-s', 'medium'], capture_output=True, timeout=10) +" 2>/dev/null +fi + +# --- Report ------------------------------------------------------------------- + +SUMMARY="$WORKER_COUNT worker(s), $TOTAL review(s): $BREACHES breach(es), $WARNINGS warning(s)" +log "=== Quality Review Summary: $SUMMARY ===" + +bd create "quality-review: $SUMMARY" -t chore --ephemeral \ + -l type:plugin-run,plugin:quality-review,result:success \ + -d "$SUMMARY" --silent 2>/dev/null || true diff --git a/plugins/submodule-gitignore/plugin.md b/plugins/submodule-gitignore/plugin.md new file mode 100644 index 0000000000..71aa6acfa0 --- /dev/null +++ b/plugins/submodule-gitignore/plugin.md @@ -0,0 +1,35 @@ ++++ +name = "submodule-gitignore" +description = "Inject Gas Town gitignore entries into rig project repos to prevent GT operational files from appearing as untracked" +version = 1 + +[gate] +type = "cooldown" +duration = "12h" + +[tracking] +labels = ["plugin:submodule-gitignore", "category:git-hygiene"] +digest = true + +[execution] +type = "script" +timeout = "5m" +notify_on_failure = true +severity = "low" ++++ + +# Submodule Gitignore + +Scans all rig project repos and ensures Gas Town operational files are listed +in each repo's `.gitignore`. Prevents GT-created files (`.claude/`, `state.json`, +`.beads/` runtime artifacts, etc.) from appearing as untracked changes in +project repositories. + +Idempotent: uses a guard marker comment to detect whether the block has already +been injected. Skips repos that already have the guard. + +## Run + +```bash +cd /Users/jeremy/gt/plugins/submodule-gitignore && bash run.sh +``` diff --git a/plugins/submodule-gitignore/run.sh b/plugins/submodule-gitignore/run.sh new file mode 100644 index 0000000000..0372a8f0c4 --- /dev/null +++ b/plugins/submodule-gitignore/run.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# submodule-gitignore/run.sh — Inject Gas Town gitignore entries into rig project repos. +# +# Scans all rigs, finds their project repo checkouts (mayor/rig/), and ensures +# Gas Town operational files are gitignored. Commits + pushes changes with || true. +# Idempotent via guard marker comment. + +set -euo pipefail + +GUARD_MARKER="# Gas Town operational files (managed by submodule-gitignore plugin)" + +# Gas Town files that should be gitignored in project repos. +# These are created by GT at runtime and must not leak into project history. +# NOTE: .beads/ is NOT included — beads manages its own .beads/.gitignore +# (created by bd init) which selectively ignores runtime files. Adding .beads/ +# here would override that and break bd sync. This has regressed before. +GT_PATTERNS=( + ".runtime/" + ".claude/" + ".logs/" + "state.json" + "config.json" + "__pycache__/" + "crew/" + "polecats/" + "refinery/" + "witness/" + "mayor/" + "archive/" + ".beads/.locks/" + ".beads/locks/" + ".beads/audit.log" + ".beads/metadata.json" + ".beads/PRIME.md" + ".beads/.gt-types-configured" + ".beads/backup/" + ".beads/dolt-server.port" + ".repo.git/" + ".land-worktree/" +) + +log() { echo "[submodule-gitignore] $*"; } + +# --- Discover town root ------------------------------------------------------- + +GT_ROOT="${GT_ROOT:-$HOME/gt}" +if [ ! -d "$GT_ROOT" ]; then + log "ERROR: GT_ROOT=$GT_ROOT does not exist" + exit 1 +fi + +# --- Enumerate rigs ------------------------------------------------------------ + +RIG_JSON=$(gt rig list --json 2>/dev/null) || { + log "SKIP: could not get rig list" + exit 0 +} + +RIG_NAMES=$(echo "$RIG_JSON" | python3 -c " +import json, sys +rigs = json.load(sys.stdin) +for r in rigs: + print(r['name']) +" 2>/dev/null) || { + log "SKIP: could not parse rig list" + exit 0 +} + +if [ -z "$RIG_NAMES" ]; then + log "SKIP: no rigs found" + exit 0 +fi + +RIG_COUNT=$(echo "$RIG_NAMES" | wc -l | tr -d ' ') +log "Found $RIG_COUNT rig(s) to scan" + +# --- Helper: check if pattern is covered by existing gitignore ----------------- + +pattern_covered() { + local content="$1" + local pattern="$2" + + # Strip leading slash for comparison + local norm_pattern="${pattern#/}" + + while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace + [ -z "$line" ] && continue + [[ "$line" == \#* ]] && continue # skip comments + + local norm_line="${line#/}" + + # Exact match + [ "$norm_line" = "$norm_pattern" ] && return 0 + + # Trailing slash variants + [ "$norm_line" = "${norm_pattern%/}" ] && return 0 + [ "${norm_line}/" = "$norm_pattern" ] && return 0 + + # Broader directory covers specific subpath (e.g. .beads/ covers .beads/locks/) + if [[ "$norm_line" == */ ]] && [[ "$norm_pattern" == "$norm_line"* ]]; then + return 0 + fi + done <<< "$content" + + return 1 +} + +# --- Process each rig ---------------------------------------------------------- + +UPDATED=0 +SKIPPED=0 +ERRORS=() + +while IFS= read -r RIG_NAME; do + [ -z "$RIG_NAME" ] && continue + + # Find the rig's project repo checkout + REPO_PATH="$GT_ROOT/$RIG_NAME/mayor/rig" + + if [ ! -d "$REPO_PATH" ]; then + log " $RIG_NAME: no mayor/rig checkout, skipping" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + # Verify it's a git repo + if ! git -C "$REPO_PATH" rev-parse --git-dir >/dev/null 2>&1; then + log " $RIG_NAME: mayor/rig is not a git repo, skipping" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + GITIGNORE="$REPO_PATH/.gitignore" + + # Read existing content + EXISTING="" + if [ -f "$GITIGNORE" ]; then + EXISTING=$(cat "$GITIGNORE") + fi + + # Check for guard marker — if present, already managed + if echo "$EXISTING" | grep -qF "$GUARD_MARKER"; then + log " $RIG_NAME: guard block present, skipping" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + # Find missing patterns + MISSING=() + for pattern in "${GT_PATTERNS[@]}"; do + if ! pattern_covered "$EXISTING" "$pattern"; then + MISSING+=("$pattern") + fi + done + + if [ ${#MISSING[@]} -eq 0 ]; then + log " $RIG_NAME: all patterns present (no guard marker, but covered)" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + log " $RIG_NAME: injecting ${#MISSING[@]} pattern(s)" + + # Append guard block with missing patterns + { + # Ensure we start on a new line + if [ -n "$EXISTING" ] && [ "$(tail -c1 "$GITIGNORE" 2>/dev/null | wc -l)" -eq 0 ]; then + echo "" + fi + echo "" + echo "$GUARD_MARKER" + for pattern in "${MISSING[@]}"; do + echo "$pattern" + done + } >> "$GITIGNORE" + + # Commit + push (|| true — best effort, don't fail the plugin) + if git -C "$REPO_PATH" add .gitignore 2>/dev/null; then + if git -C "$REPO_PATH" diff --cached --quiet 2>/dev/null; then + log " $RIG_NAME: no effective changes after staging" + else + COMMIT_MSG="chore: add Gas Town gitignore entries + +Injected by submodule-gitignore plugin. Prevents Gas Town operational +files from appearing as untracked in the project repository." + + if git -C "$REPO_PATH" commit -m "$COMMIT_MSG" 2>/dev/null; then + log " $RIG_NAME: committed gitignore update" + if git -C "$REPO_PATH" push 2>/dev/null; then + log " $RIG_NAME: pushed to remote" + else + log " $RIG_NAME: WARN: push failed (will retry next run)" + fi + + # Update parent submodule pointer if applicable + PARENT_DIR="$GT_ROOT/$RIG_NAME" + if [ -f "$PARENT_DIR/.gitmodules" ] 2>/dev/null; then + git -C "$PARENT_DIR" add mayor/rig 2>/dev/null || true + git -C "$PARENT_DIR" commit -m "chore: update mayor/rig submodule pointer" 2>/dev/null || true + git -C "$PARENT_DIR" push 2>/dev/null || true + log " $RIG_NAME: updated parent submodule pointer" + fi + + UPDATED=$((UPDATED + 1)) + else + log " $RIG_NAME: WARN: commit failed" + ERRORS+=("$RIG_NAME:commit-failed") + fi + fi + else + log " $RIG_NAME: WARN: git add failed" + ERRORS+=("$RIG_NAME:add-failed") + fi +done <<< "$RIG_NAMES" + +# --- Report -------------------------------------------------------------------- + +SUMMARY="$RIG_COUNT rig(s) scanned: $UPDATED updated, $SKIPPED skipped, ${#ERRORS[@]} error(s)" +log "" +log "=== Submodule Gitignore Summary ===" +log "$SUMMARY" + +RESULT="success" +[[ ${#ERRORS[@]} -gt 0 ]] && RESULT="warning" + +bd create "submodule-gitignore: $SUMMARY" -t chore --ephemeral \ + -l type:plugin-run,plugin:submodule-gitignore,result:$RESULT \ + --silent 2>/dev/null || true + +if [[ ${#ERRORS[@]} -gt 0 ]]; then + gt escalate "submodule-gitignore: ${#ERRORS[@]} error(s): ${ERRORS[*]}" \ + -s low \ + --reason "Gitignore injection failed for: ${ERRORS[*]}" 2>/dev/null || true +fi