-
Notifications
You must be signed in to change notification settings - Fork 1
feat: PR stack resolution harness — formula, order, script #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
52ba85c
9c31f6d
5f4a9b1
10832d4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
|
|
||
| # --- 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 | ||
|
ThePlenkov marked this conversation as resolved.
|
||
|
|
||
| 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 | ||
|
Check failure on line 31 in assets/scripts/pr-stack-resolve.sh
|
||
|
ThePlenkov marked this conversation as resolved.
|
||
| 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')) | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
| 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('\"','\\'\"'))") | ||
|
ThePlenkov marked this conversation as resolved.
|
||
|
|
||
| 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 | ||
|
Check failure on line 96 in assets/scripts/pr-stack-resolve.sh
|
||
| HAS_CONFLICT=true | ||
| fi | ||
|
ThePlenkov marked this conversation as resolved.
|
||
|
|
||
| # 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) { | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| nodes { isResolved } | ||
| } | ||
|
ThePlenkov marked this conversation as resolved.
qodo-code-review[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| }' -F owner=sverka-dev -F repo=sverka -F pr="$PR_NUM" 2>/dev/null | python3 -c " | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| 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") | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
|
|
||
| # 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 | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| 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}') | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| if [ "$BEHIND_COUNT" -gt 0 ] 2>/dev/null; then | ||
|
Check failure on line 148 in assets/scripts/pr-stack-resolve.sh
|
||
| BEHIND=true | ||
| fi | ||
|
|
||
| # Determine resolution state | ||
| RESOLVED=true | ||
| REASONS="" | ||
|
|
||
| if [ "$HAS_CONFLICT" = "true" ]; then | ||
|
Check failure on line 156 in assets/scripts/pr-stack-resolve.sh
|
||
| RESOLVED=false | ||
| REASONS="${REASONS}merge_conflict " | ||
| fi | ||
|
|
||
| if [ "$OPEN_THREADS" -gt 0 ] 2>/dev/null; then | ||
|
Check failure on line 161 in assets/scripts/pr-stack-resolve.sh
|
||
| 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 | ||
|
Check failure on line 176 in assets/scripts/pr-stack-resolve.sh
|
||
| RESOLVED=false | ||
| REASONS="${REASONS}behind_base($BEHIND_COUNT) " | ||
| fi | ||
|
|
||
| if [ "$RESOLVED" = "true" ]; then | ||
|
Check failure on line 181 in assets/scripts/pr-stack-resolve.sh
|
||
| 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" ) | ||
|
Check failure on line 191 in assets/scripts/pr-stack-resolve.sh
|
||
| PY_BEHIND=$( [ "$BEHIND" = "true" ] && echo "True" || echo "False" ) | ||
|
Check failure on line 192 in assets/scripts/pr-stack-resolve.sh
|
||
| ENTRY=$(python3 -c " | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| import json | ||
| entry = { | ||
| 'number': $PR_NUM, | ||
| 'head': '$HEAD_REF', | ||
| 'base': '$BASE_REF', | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| 'resolved': $PY_RESOLVED, | ||
| 'merge_state': '$MERGE_STATE', | ||
| 'open_threads': $OPEN_THREADS, | ||
| 'ci_state': '$CI_STATE', | ||
|
ThePlenkov marked this conversation as resolved.
|
||
| 'behind': $PY_BEHIND, | ||
| 'behind_count': $BEHIND_COUNT, | ||
| 'reasons': '${REASONS:-}'.strip() | ||
| } | ||
| print(json.dumps(entry)) | ||
| ") | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if [ -n "$ENTRIES" ]; then | ||
|
Check failure on line 209 in assets/scripts/pr-stack-resolve.sh
|
||
| ENTRIES="$ENTRIES,$ENTRY" | ||
| else | ||
| ENTRIES="$ENTRY" | ||
| fi | ||
| done | ||
|
|
||
| ALL_RESOLVED_JSON=$( [ "$ALL_RESOLVED" = "true" ] && echo "true" || echo "false" ) | ||
|
Check failure on line 216 in assets/scripts/pr-stack-resolve.sh
|
||
| 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 | ||
|
Check failure on line 221 in assets/scripts/pr-stack-resolve.sh
|
||
| 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 | ||
|
Check failure on line 240 in assets/scripts/pr-stack-resolve.sh
|
||
| 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/<base>, 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 | ||
|
Check failure on line 254 in assets/scripts/pr-stack-resolve.sh
|
||
| 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 | ||
|
Check failure on line 267 in assets/scripts/pr-stack-resolve.sh
|
||
| NUDGE_MSG="${NUDGE_MSG}PRIORITY 3 — CI FAILING:\n$CI_FAIL_PRS\n\n" | ||
| NUDGE_MSG="${NUDGE_MSG}Read logs with: gh pr checks <PR> --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 | ||
|
Check failure on line 281 in assets/scripts/pr-stack-resolve.sh
|
||
| 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/<PR>/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 | ||
|
Check failure on line 296 in assets/scripts/pr-stack-resolve.sh
|
||
| gc mail send human "PR stack: critical issues" "$(echo -e "$NUDGE_MSG")" 2>/dev/null || true | ||
| fi | ||
|
|
||
| exit 1 | ||
Uh oh!
There was an error while loading. Please reload this page.