diff --git a/assets/scripts/pr-stack-resolve.sh b/assets/scripts/pr-stack-resolve.sh new file mode 100644 index 000000000..358ef7f95 --- /dev/null +++ b/assets/scripts/pr-stack-resolve.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# pr-stack-resolve.sh — deterministic PR stack resolution checks +# +# Discovers all open PRs, orders them by stack position, and reports the +# resolution state of each. Does NOT make changes — only reports. +# The agent (dispatched via the formula) does the actual /act work. +# +# Output: JSON report to stdout, human-readable log to stderr. +# Exit: 0 if all PRs resolved, 1 if any need work. +set -euo pipefail + +REPO="sverka-dev/sverka" +cd "$(git rev-parse --show-toplevel)" + +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +log() { echo "[pr-resolve $TIMESTAMP] $*" >&2; } + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# --- 1. Fetch latest --- +log "Fetching latest from origin..." +git fetch origin --prune 2>&1 | grep -v "^From " || true + +# --- 2. Discover all open PRs --- +log "Discovering open PRs..." +gh pr list --repo "$REPO" --state open --json number,headRefName,baseRefName,title > "$TMPDIR/prs.json" 2>/dev/null + +PR_COUNT=$(python3 -c "import json; print(len(json.load(open('$TMPDIR/prs.json'))))" 2>/dev/null || echo "0") + +if [ "$PR_COUNT" -eq 0 ]; then + log "No open PRs. All resolved." + echo '{"prs":[],"all_resolved":true,"prs_resolved":0,"prs_needs_work":0}' + exit 0 +fi + +log "Found $PR_COUNT open PR(s)." + +# --- 3. Order PRs by stack position (base → top) --- +python3 -c " +import json +prs = json.load(open('$TMPDIR/prs.json')) +by_base = {} +for p in prs: + by_base.setdefault(p['baseRefName'], []).append(p) + +ordered = [] +visited = set() + +def visit(base): + for p in by_base.get(base, []): + if p['number'] in visited: + continue + visited.add(p['number']) + ordered.append(p) + visit(p['headRefName']) + +visit('main') +for p in prs: + if p['number'] not in visited: + ordered.append(p) + +with open('$TMPDIR/ordered.json', 'w') as f: + json.dump(ordered, f) + +print(' '.join(str(p['number']) for p in ordered)) +" > "$TMPDIR/order.txt" + +ORDERED_NUMBERS=$(cat "$TMPDIR/order.txt") +log "Stack order (base → top): $ORDERED_NUMBERS" + +# --- 4. Check each PR --- +ALL_RESOLVED=true +NEEDS_WORK_COUNT=0 +RESOLVED_COUNT=0 +ENTRIES="" + +for PR_NUM in $ORDERED_NUMBERS; do + PR_DATA=$(python3 -c " +import json +for p in json.load(open('$TMPDIR/ordered.json')): + if p['number'] == $PR_NUM: + print(json.dumps(p)) + break +") + + HEAD_REF=$(echo "$PR_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin)['headRefName'])") + BASE_REF=$(echo "$PR_DATA" | python3 -c "import json,sys; print(json.load(sys.stdin)['baseRefName'])") + TITLE=$(echo "$PR_DATA" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['title'].replace('\"','\\'\"'))") + + log "PR #$PR_NUM: $TITLE (head=$HEAD_REF base=$BASE_REF)" + + # Check 1: Merge state + MERGE_STATE=$(gh pr view "$PR_NUM" --repo "$REPO" --json mergeStateStatus -q '.mergeStateStatus' 2>/dev/null || echo "unknown") + HAS_CONFLICT=false + if [ "$MERGE_STATE" = "DIRTY" ] || [ "$MERGE_STATE" = "BLOCKED" ]; then + HAS_CONFLICT=true + fi + + # Check 2: Unresolved review threads (via GraphQL — REST API can't filter by resolved state) + # GraphQL variables ($owner, $repo, $pr) are not shell expansion + # shellcheck disable=SC2016 + OPEN_THREADS=$(gh api graphql -f query=' +query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + reviewThreads(first: 100) { + nodes { isResolved } + } + } + } +}' -F owner=sverka-dev -F repo=sverka -F pr="$PR_NUM" 2>/dev/null | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + threads = data['data']['repository']['pullRequest']['reviewThreads']['nodes'] + print(sum(1 for t in threads if not t['isResolved'])) +except: + print(0) +" 2>/dev/null || echo "0") + + # Check 3: CI status + CI_STATE="unknown" + CI_CHECKS=$(gh pr checks "$PR_NUM" --repo "$REPO" --json name,bucket --required 2>/dev/null || echo "[]") + CI_STATE=$(echo "$CI_CHECKS" | python3 -c " +import json, sys +try: + checks = json.load(sys.stdin) + failing = [c['name'] for c in checks if c.get('bucket') == 'fail'] + pending = [c for c in checks if c.get('bucket') == 'pending'] + if failing: + print('FAIL:' + ','.join(failing)) + elif pending: + print('PENDING') + else: + print('PASS') +except: + print('UNKNOWN') +") + + # Check 4: Is branch behind base? + BEHIND=false + BEHIND_COUNT=0 + git fetch origin "$HEAD_REF" 2>/dev/null || true + git fetch origin "$BASE_REF" 2>/dev/null || true + DIVERGENCE=$(git rev-list --left-right --count "origin/$BASE_REF...origin/$HEAD_REF" 2>/dev/null || echo "0 0") + BEHIND_COUNT=$(echo "$DIVERGENCE" | awk '{print $1}') + if [ "$BEHIND_COUNT" -gt 0 ] 2>/dev/null; then + BEHIND=true + fi + + # Determine resolution state + RESOLVED=true + REASONS="" + + if [ "$HAS_CONFLICT" = "true" ]; then + RESOLVED=false + REASONS="${REASONS}merge_conflict " + fi + + if [ "$OPEN_THREADS" -gt 0 ] 2>/dev/null; then + RESOLVED=false + REASONS="${REASONS}open_threads($OPEN_THREADS) " + fi + + if [[ "$CI_STATE" == FAIL* ]]; then + RESOLVED=false + REASONS="${REASONS}ci_failing(${CI_STATE#FAIL:}) " + fi + + if [[ "$CI_STATE" == "PENDING" ]]; then + RESOLVED=false + REASONS="${REASONS}ci_pending " + fi + + if [ "$BEHIND" = "true" ]; then + RESOLVED=false + REASONS="${REASONS}behind_base($BEHIND_COUNT) " + fi + + if [ "$RESOLVED" = "true" ]; then + log " ✓ RESOLVED (CI=$CI_STATE, threads=0, no conflicts, rebased)" + RESOLVED_COUNT=$((RESOLVED_COUNT + 1)) + else + log " ✗ NEEDS WORK: ${REASONS:-unknown}" + ALL_RESOLVED=false + NEEDS_WORK_COUNT=$((NEEDS_WORK_COUNT + 1)) + fi + + # Build JSON entry + PY_RESOLVED=$( [ "$RESOLVED" = "true" ] && echo "True" || echo "False" ) + PY_BEHIND=$( [ "$BEHIND" = "true" ] && echo "True" || echo "False" ) + ENTRY=$(python3 -c " +import json +entry = { + 'number': $PR_NUM, + 'head': '$HEAD_REF', + 'base': '$BASE_REF', + 'resolved': $PY_RESOLVED, + 'merge_state': '$MERGE_STATE', + 'open_threads': $OPEN_THREADS, + 'ci_state': '$CI_STATE', + 'behind': $PY_BEHIND, + 'behind_count': $BEHIND_COUNT, + 'reasons': '${REASONS:-}'.strip() +} +print(json.dumps(entry)) +") + if [ -n "$ENTRIES" ]; then + ENTRIES="$ENTRIES,$ENTRY" + else + ENTRIES="$ENTRY" + fi +done + +ALL_RESOLVED_JSON=$( [ "$ALL_RESOLVED" = "true" ] && echo "true" || echo "false" ) +ENTRIES_JSON="$ENTRIES" +echo "{\"timestamp\":\"$TIMESTAMP\",\"prs\":[$ENTRIES_JSON],\"all_resolved\":$ALL_RESOLVED_JSON,\"prs_resolved\":$RESOLVED_COUNT,\"prs_needs_work\":$NEEDS_WORK_COUNT}" + +# --- 5. Nudge mayor if action is needed --- +if [ "$ALL_RESOLVED" = "true" ]; then + log "All $RESOLVED_COUNT PR(s) resolved." + exit 0 +fi + +log "$NEEDS_WORK_COUNT PR(s) need work, $RESOLVED_COUNT resolved. Building nudge for mayor..." + +# Build a prioritized action message for the mayor +NUDGE_MSG="PR stack resolve: $NEEDS_WORK_COUNT of $PR_COUNT PR(s) need work.\n\n" + +# Priority 1: Merge conflicts (must fix first — blocks everything above) +CONFLICT_PRS=$(echo "$ENTRIES_JSON" | python3 -c " +import json, sys +entries = json.loads('[' + sys.stdin.read() + ']') +conflicts = [e for e in entries if e.get('merge_state') in ('DIRTY', 'BLOCKED')] +for e in conflicts: + print(f\"PR #{e['number']} ({e['head']}): {e['reasons']}\") +" 2>/dev/null || true) + +if [ -n "$CONFLICT_PRS" ]; then + NUDGE_MSG="${NUDGE_MSG}PRIORITY 1 — MERGE CONFLICTS (fix first, cascade rebase up):\n$CONFLICT_PRS\n\n" + NUDGE_MSG="${NUDGE_MSG}For each: git fetch origin, checkout branch, rebase onto origin/, resolve conflicts, git push --force-with-lease. Then rebase all PRs above onto the new base.\n\n" +fi + +# Priority 2: Branches behind base (rebase needed) +BEHIND_PRS=$(echo "$ENTRIES_JSON" | python3 -c " +import json, sys +entries = json.loads('[' + sys.stdin.read() + ']') +behind = [e for e in entries if e.get('behind') and e.get('merge_state') not in ('DIRTY', 'BLOCKED')] +for e in behind: + print(f\"PR #{e['number']} ({e['head']}): {e['behind_count']} commits behind {e['base']}\") +" 2>/dev/null || true) + +if [ -n "$BEHIND_PRS" ]; then + NUDGE_MSG="${NUDGE_MSG}PRIORITY 2 — BEHIND BASE (rebase):\n$BEHIND_PRS\n\n" +fi + +# Priority 3: CI failures +CI_FAIL_PRS=$(echo "$ENTRIES_JSON" | python3 -c " +import json, sys +entries = json.loads('[' + sys.stdin.read() + ']') +fails = [e for e in entries if e.get('ci_state', '').startswith('FAIL')] +for e in fails: + print(f\"PR #{e['number']} ({e['head']}): {e['ci_state']}\") +" 2>/dev/null || true) + +if [ -n "$CI_FAIL_PRS" ]; then + NUDGE_MSG="${NUDGE_MSG}PRIORITY 3 — CI FAILING:\n$CI_FAIL_PRS\n\n" + NUDGE_MSG="${NUDGE_MSG}Read logs with: gh pr checks --watch. /act to fix, push, wait for re-run.\n\n" +fi + +# Priority 4: Open review threads +THREAD_PRS=$(echo "$ENTRIES_JSON" | python3 -c " +import json, sys +entries = json.loads('[' + sys.stdin.read() + ']') +threads = [e for e in entries if e.get('open_threads', 0) > 0] +for e in threads: + print(f\"PR #{e['number']} ({e['head']}): {e['open_threads']} open thread(s)\") +" 2>/dev/null || true) + +if [ -n "$THREAD_PRS" ]; then + NUDGE_MSG="${NUDGE_MSG}PRIORITY 4 — OPEN REVIEW THREADS:\n$THREAD_PRS\n\n" + NUDGE_MSG="${NUDGE_MSG}For each PR: fetch comments with gh api repos/sverka-dev/sverka/pulls//comments. For each thread: read comment, /act to fix or reply, push fix, resolve thread. AFTER each /act, RE-FETCH comments to check for NEW replies. Loop until no new comments.\n\n" +fi + +NUDGE_MSG="${NUDGE_MSG}After all fixes: re-run this check. Goal: all_resolved=true." + +# Send nudge to mayor +if gc session nudge mayor "$(echo -e "$NUDGE_MSG")" 2>/dev/null; then + log "Nudged mayor with $NEEDS_WORK_COUNT PR action items" +else + log "Failed to nudge mayor" +fi + +# Also mail human if there are conflicts or CI failures +if [ -n "$CONFLICT_PRS" ] || [ -n "$CI_FAIL_PRS" ]; then + gc mail send human "PR stack: critical issues" "$(echo -e "$NUDGE_MSG")" 2>/dev/null || true +fi + +exit 1 diff --git a/formulas/pr-stack-resolve.toml b/formulas/pr-stack-resolve.toml new file mode 100644 index 000000000..7b3bbbb65 --- /dev/null +++ b/formulas/pr-stack-resolve.toml @@ -0,0 +1,160 @@ +formula = "pr-stack-resolve" +description = "Resolve all open PRs in the stack: rebase, /act on review comments, follow CI, loop until green" + +[requires] +formula_compiler = ">=2.0.0" + +[[steps]] +id = "discover" +title = "Discover and assess PR stack" +description = """ +Run the deterministic check script to discover all open PRs and their +resolution state: + + bash assets/scripts/pr-stack-resolve.sh + +This outputs a JSON report showing each PR's: merge state, open review +threads, CI status, and whether it's behind its base. + +If ALL PRs are resolved (exit 0), close the formula — nothing to do. + +For each PR that NEEDS WORK, note the reasons: +- merge_conflict → needs rebase +- open_threads(N) → needs /act on N review comments +- ci_failing(checks) → needs CI fix +- ci_pending → wait for CI to complete +- behind_base(N) → needs rebase on base + +Order work by stack position (base → top) — fixing a lower PR may +cascade to upper PRs. +""" +agent = "builder" + +[[steps]] +id = "rebase" +title = "Rebase PRs on their base branches" +description = """ +For each PR that needs rebase (behind_base or merge_conflict), working +from BOTTOM of stack to TOP: + +1. Fetch latest: + git fetch origin + +2. Checkout the PR's head branch: + git checkout + +3. Rebase on the PR's base branch: + git rebase origin/ + +4. If rebase fails with conflicts: + - STOP. Do NOT force anything. + - Report the conflict to the mayor. + - Do NOT continue to upper PRs. + +5. If rebase succeeds, force-push with lease: + git push --force-with-lease origin + +6. After rebasing a PR, ALL PRs above it in the stack MUST also be + rebased on their (now-updated) bases. Cascade upward. + +NEVER use `git push --force` without `--force-with-lease`. +NEVER skip the fetch step. +NEVER work on a stale branch. +""" +needs = ["discover"] +agent = "builder" + +[[steps]] +id = "act-comments" +title = "/act on all open review comments" +description = """ +For each PR with open review threads, resolve them: + +1. Fetch all review comments: + gh api repos/sverka-dev/sverka/pulls//comments + +2. For each unresolved thread: + a. Read the comment carefully. + b. Decide: fix the code, or reply with reasoning. + c. If fixing: make the change, commit, push. + d. If replying: post a substantive reply explaining the decision. + e. After pushing a fix, wait for CI to start. + +3. CRITICAL — AFTER each /act, re-check for NEW review comments: + - Fetch comments again: gh api repos/sverka-dev/sverka/pulls//comments + - Compare with previous fetch — are there new comments? + - If yes → /act on those too. + - Repeat until no new comments appear. + +This prevents the known bug where agents reply but don't check for +follow-up comments. You MUST loop until stable. + +4. After all comments are addressed and no new ones appear, verify: + - All threads are resolved (0 open) + - The fix commits exist on the branch +""" +needs = ["rebase"] +agent = "builder" + +[[steps]] +id = "ci-followup" +title = "Follow CI to green" +description = """ +After all pushes (rebase + /act fixes), follow CI to completion: + +1. For each PR that was pushed to: + gh pr checks --watch + +2. If CI fails: + a. Read the failing check logs: + gh run view --log-failed + b. Identify the root cause. + c. Fix the issue (this is another /act). + d. Push the fix. + e. Wait for CI again. + f. Re-check for new review comments (CI may trigger bot reviews). + +3. If CI passes: + a. Check for new quality findings (SonarCloud, CodeQL, etc.). + b. If new findings → /act to fix them. + c. If no findings → PR is CI-green. + +4. Core pipeline MUST pass: + bun run build && bun run lint && bun run typecheck && bun run test + + If any of these fail locally, fix before pushing. +""" +needs = ["act-comments"] +agent = "builder" + +[[steps]] +id = "verify" +title = "Verify all PRs are resolved" +description = """ +Run the deterministic check script one final time: + + bash assets/scripts/pr-stack-resolve.sh + +A PR is RESOLVED when ALL of: +✓ CI green (build, lint, typecheck, test, quality gates) +✓ All review threads resolved (0 open) +✓ No merge conflicts with base +✓ Branch is rebased on latest base +✓ No new quality findings +✓ At least one follow-up check after last /act (no new comments appeared) + +If any PR is still not resolved, loop back to the appropriate step: +- merge_conflict → rebase step +- open_threads → act-comments step +- ci_failing → ci-followup step +- behind_base → rebase step + +If ALL PRs are resolved, report to the mayor: +- Total PRs resolved +- Any PRs that were skipped and why +- Summary of changes made + +The mayor will then send a mail to the human with the resolution report. +""" +needs = ["ci-followup"] +agent = "builder" diff --git a/orders/pr-stack-resolve.toml b/orders/pr-stack-resolve.toml new file mode 100644 index 000000000..906952ca0 --- /dev/null +++ b/orders/pr-stack-resolve.toml @@ -0,0 +1,10 @@ +# pr-stack-resolve — permanent PR stack resolution watchdog. +# Runs every 20m via cooldown trigger. Monitors all open PRs in the stack: +# CI status, review threads, merge conflicts, branch divergence. +# Nudges mayor with specific /act instructions when action is needed. +# Does NOT self-modify city config. Agents do the work; this script detects. +[order] +description = "PR stack watchdog: CI, review threads, conflicts, rebase needs" +exec = "/home/pepl/projects/sverka/assets/scripts/pr-stack-resolve.sh" +trigger = "cooldown" +interval = "20m"