|
1 | 1 | name: AI Unstick Deadlocked Issues |
2 | 2 |
|
3 | 3 | on: |
| 4 | + schedule: |
| 5 | + - cron: '*/30 * * * *' |
4 | 6 | workflow_dispatch: |
5 | 7 | inputs: |
6 | 8 | issue_number: |
7 | 9 | description: 'Specific issue number to unstick (leave empty for all stuck issues)' |
8 | 10 | required: false |
9 | 11 | default: '' |
10 | 12 |
|
| 13 | +concurrency: |
| 14 | + group: ai-unstick |
| 15 | + cancel-in-progress: false |
| 16 | + |
11 | 17 | permissions: |
12 | 18 | issues: write |
| 19 | + pull-requests: read |
| 20 | + contents: read |
13 | 21 |
|
14 | 22 | jobs: |
15 | 23 | unstick: |
16 | 24 | runs-on: ubuntu-latest |
17 | 25 | steps: |
18 | | - - name: Find and unstick deadlocked issues |
| 26 | + - name: Find and unstick stalled issues |
19 | 27 | env: |
20 | 28 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
21 | | - INPUT_ISSUE: ${{ inputs.issue_number }} |
| 29 | + INPUT_ISSUE: ${{ github.event.inputs.issue_number || '' }} |
22 | 30 | run: | |
23 | 31 | REPO="${{ github.repository }}" |
24 | 32 | UNSTUCK=0 |
| 33 | + BLOCKED=0 |
25 | 34 | SKIPPED=0 |
| 35 | + PROCESSED="" |
| 36 | + THRESHOLD_DATE=$(date -u -d '40 minutes ago' +%Y-%m-%dT%H:%M:%SZ) |
| 37 | + SINCE_DATE=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) |
26 | 38 |
|
27 | | - if [ -n "$INPUT_ISSUE" ]; then |
28 | | - # Specific issue mode |
29 | | - ISSUES="$INPUT_ISSUE" |
30 | | - echo "Targeting specific issue: #$INPUT_ISSUE" |
31 | | - else |
32 | | - # Find all deadlocked issues: have both ai-review-ready AND ai-debugging |
33 | | - ISSUES=$(gh issue list --repo "$REPO" \ |
34 | | - --label "ai-review-ready" --label "ai-debugging" \ |
35 | | - --state open --json number --jq '.[].number' 2>/dev/null) |
36 | | - if [ -z "$ISSUES" ]; then |
37 | | - echo "No stuck issues found — nothing to do" |
38 | | - exit 0 |
| 39 | + was_processed() { |
| 40 | + case " $PROCESSED " in |
| 41 | + *" $1 "*) |
| 42 | + return 0 |
| 43 | + ;; |
| 44 | + *) |
| 45 | + return 1 |
| 46 | + ;; |
| 47 | + esac |
| 48 | + } |
| 49 | +
|
| 50 | + mark_processed() { |
| 51 | + if ! was_processed "$1"; then |
| 52 | + PROCESSED="$PROCESSED $1" |
39 | 53 | fi |
40 | | - echo "Found stuck issues: $ISSUES" |
41 | | - fi |
| 54 | + } |
42 | 55 |
|
43 | | - for ISSUE_NUM in $ISSUES; do |
44 | | - echo "" |
45 | | - echo "=== Processing issue #$ISSUE_NUM ===" |
| 56 | + reset_issue() { |
| 57 | + ISSUE_NUM="$1" |
| 58 | + REASON="$2" |
46 | 59 |
|
47 | | - # Verify the issue exists and check current labels |
48 | | - CURRENT_LABELS=$(gh issue view "$ISSUE_NUM" --repo "$REPO" \ |
49 | | - --json labels --jq '[.labels[].name]' 2>/dev/null) |
50 | | - if [ $? -ne 0 ]; then |
51 | | - echo "Issue #$ISSUE_NUM not found — skipping" |
| 60 | + if ! gh issue view "$ISSUE_NUM" --repo "$REPO" --json number --jq '.number' >/dev/null 2>&1; then |
| 61 | + echo "Issue #$ISSUE_NUM not found - skipping" |
52 | 62 | SKIPPED=$((SKIPPED + 1)) |
53 | | - continue |
| 63 | + return |
54 | 64 | fi |
55 | | - echo "Current labels: $CURRENT_LABELS" |
56 | 65 |
|
57 | | - # Remove ai-review-ready FIRST (critical: this is the exclude label in ai-task-auto-label) |
58 | | - gh issue edit "$ISSUE_NUM" --repo "$REPO" \ |
59 | | - --remove-label "ai-review-ready" 2>/dev/null || true |
| 66 | + RESET_COUNT=$(gh api "repos/${REPO}/issues/${ISSUE_NUM}/comments" \ |
| 67 | + --paginate \ |
| 68 | + --jq "[.[] | select(.created_at >= \"${SINCE_DATE}\") | select(.body | contains(\"<!-- ai-stall-reset -->\"))] | length" 2>/dev/null || printf "0") |
60 | 69 |
|
61 | | - # Remove ai-in-progress if present |
62 | | - gh issue edit "$ISSUE_NUM" --repo "$REPO" \ |
63 | | - --remove-label "ai-in-progress" 2>/dev/null || true |
| 70 | + if [ -z "$RESET_COUNT" ]; then |
| 71 | + RESET_COUNT=0 |
| 72 | + fi |
| 73 | +
|
| 74 | + if [ "$RESET_COUNT" -ge 3 ]; then |
| 75 | + echo "Circuit breaker tripped for #$ISSUE_NUM ($RESET_COUNT reset attempts in last 24h)" |
| 76 | + gh issue edit "$ISSUE_NUM" --repo "$REPO" \ |
| 77 | + --remove-label "ai-task" --remove-label "ai-in-progress" \ |
| 78 | + --remove-label "ai-review-ready" --remove-label "ai-debugging" \ |
| 79 | + --add-label "ai-blocked" 2>/dev/null || true |
| 80 | +
|
| 81 | + gh issue comment "$ISSUE_NUM" --repo "$REPO" \ |
| 82 | + --body "<!-- ai-stall-circuit-breaker --> ⛔ Circuit breaker tripped after $RESET_COUNT stall resets in the last 24 hours. Removed AI lifecycle labels and added \`ai-blocked\`. Reason: $REASON" |
| 83 | +
|
| 84 | + BLOCKED=$((BLOCKED + 1)) |
| 85 | + return |
| 86 | + fi |
64 | 87 |
|
65 | | - # Add ai-task (the orchestrator pickup label) |
66 | | - # Keep ai-debugging (signals this is a retry) |
67 | 88 | gh issue edit "$ISSUE_NUM" --repo "$REPO" \ |
68 | | - --add-label "ai-task" 2>/dev/null || true |
| 89 | + --remove-label "ai-review-ready" --remove-label "ai-in-progress" \ |
| 90 | + --remove-label "ai-debugging" --add-label "ai-task" 2>/dev/null || true |
69 | 91 |
|
70 | | - # Post unstick comment |
71 | 92 | gh issue comment "$ISSUE_NUM" --repo "$REPO" \ |
72 | | - --body "<!-- ai-unstick --> 🔧 Labels reset by ai-unstick workflow. Removed \`ai-review-ready\`, added \`ai-task\`. Issue is now eligible for orchestrator pickup." |
73 | | -
|
74 | | - # Verify final state |
75 | | - FINAL_LABELS=$(gh issue view "$ISSUE_NUM" --repo "$REPO" \ |
76 | | - --json labels --jq '[.labels[].name]' 2>/dev/null) |
77 | | - echo "Final labels: $FINAL_LABELS" |
| 93 | + --body "<!-- ai-stall-reset --> 🔧 Stall reset by ai-unstick. Reason: $REASON. Action: removed \`ai-review-ready\`, \`ai-in-progress\`, \`ai-debugging\`; added \`ai-task\`." |
78 | 94 |
|
79 | 95 | UNSTUCK=$((UNSTUCK + 1)) |
80 | | - done |
| 96 | + } |
| 97 | +
|
| 98 | + if [ -n "$INPUT_ISSUE" ]; then |
| 99 | + echo "Manual mode: targeting issue #$INPUT_ISSUE" |
| 100 | + reset_issue "$INPUT_ISSUE" "manual workflow_dispatch reset request" |
| 101 | + else |
| 102 | + echo "Automated mode: evaluating stuck-state strategies" |
| 103 | +
|
| 104 | + STRATEGY1=$(gh issue list --repo "$REPO" \ |
| 105 | + --label "ai-review-ready" --label "ai-debugging" \ |
| 106 | + --state open --json number --jq '.[].number' 2>/dev/null) |
| 107 | +
|
| 108 | + for ISSUE_NUM in $STRATEGY1; do |
| 109 | + if was_processed "$ISSUE_NUM"; then |
| 110 | + continue |
| 111 | + fi |
| 112 | + reset_issue "$ISSUE_NUM" "strategy1: issue has both ai-review-ready and ai-debugging" |
| 113 | + mark_processed "$ISSUE_NUM" |
| 114 | + done |
| 115 | +
|
| 116 | + STRATEGY2=$(gh issue list --repo "$REPO" \ |
| 117 | + --label "ai-in-progress" --label "ai-debugging" \ |
| 118 | + --state open --json number --jq '.[].number' 2>/dev/null) |
| 119 | +
|
| 120 | + for ISSUE_NUM in $STRATEGY2; do |
| 121 | + if was_processed "$ISSUE_NUM"; then |
| 122 | + continue |
| 123 | + fi |
| 124 | + reset_issue "$ISSUE_NUM" "strategy2: issue has both ai-in-progress and ai-debugging" |
| 125 | + mark_processed "$ISSUE_NUM" |
| 126 | + done |
| 127 | +
|
| 128 | + STRATEGY3=$(gh issue list --repo "$REPO" \ |
| 129 | + --label "ai-in-progress" \ |
| 130 | + --state open --json number --jq '.[].number' 2>/dev/null) |
| 131 | +
|
| 132 | + for ISSUE_NUM in $STRATEGY3; do |
| 133 | + if was_processed "$ISSUE_NUM"; then |
| 134 | + continue |
| 135 | + fi |
| 136 | +
|
| 137 | + LATEST_COMMENT_DATE=$(gh issue view "$ISSUE_NUM" --repo "$REPO" \ |
| 138 | + --json comments --jq '.comments | map(.createdAt) | sort | last // ""' 2>/dev/null) |
| 139 | +
|
| 140 | + PR_NUMBER=$(gh pr list --repo "$REPO" \ |
| 141 | + --search "head:ai/issue-${ISSUE_NUM}" --state open \ |
| 142 | + --json number --jq '.[0].number // ""' 2>/dev/null) |
| 143 | +
|
| 144 | + LATEST_COMMIT_DATE="" |
| 145 | + if [ -n "$PR_NUMBER" ]; then |
| 146 | + LATEST_COMMIT_DATE=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ |
| 147 | + --json commits --jq '.commits | map(.committedDate) | sort | last // ""' 2>/dev/null) |
| 148 | + fi |
| 149 | +
|
| 150 | + RECENT_COMMENT=0 |
| 151 | + RECENT_COMMIT=0 |
| 152 | +
|
| 153 | + if [ -n "$LATEST_COMMENT_DATE" ] && [ "$LATEST_COMMENT_DATE" \> "$THRESHOLD_DATE" ]; then |
| 154 | + RECENT_COMMENT=1 |
| 155 | + fi |
| 156 | +
|
| 157 | + if [ -n "$LATEST_COMMIT_DATE" ] && [ "$LATEST_COMMIT_DATE" \> "$THRESHOLD_DATE" ]; then |
| 158 | + RECENT_COMMIT=1 |
| 159 | + fi |
| 160 | +
|
| 161 | + if [ "$RECENT_COMMENT" -eq 1 ] || [ "$RECENT_COMMIT" -eq 1 ]; then |
| 162 | + echo "Skipping #$ISSUE_NUM - recent activity detected" |
| 163 | + SKIPPED=$((SKIPPED + 1)) |
| 164 | + continue |
| 165 | + fi |
| 166 | +
|
| 167 | + reset_issue "$ISSUE_NUM" "strategy3: ai-in-progress stalled over 40 minutes (no recent comment or PR commit activity)" |
| 168 | + mark_processed "$ISSUE_NUM" |
| 169 | + done |
| 170 | +
|
| 171 | + STRATEGY4=$(gh issue list --repo "$REPO" \ |
| 172 | + --label "ai-review-ready" \ |
| 173 | + --state open --json number --jq '.[].number' 2>/dev/null) |
| 174 | +
|
| 175 | + for ISSUE_NUM in $STRATEGY4; do |
| 176 | + if was_processed "$ISSUE_NUM"; then |
| 177 | + continue |
| 178 | + fi |
| 179 | +
|
| 180 | + PR_COUNT=$(gh pr list --repo "$REPO" \ |
| 181 | + --search "head:ai/issue-${ISSUE_NUM}" --state open \ |
| 182 | + --json number --jq 'length' 2>/dev/null || printf "0") |
| 183 | +
|
| 184 | + if [ "$PR_COUNT" -gt 0 ]; then |
| 185 | + echo "Skipping #$ISSUE_NUM - open PR found" |
| 186 | + SKIPPED=$((SKIPPED + 1)) |
| 187 | + continue |
| 188 | + fi |
| 189 | +
|
| 190 | + reset_issue "$ISSUE_NUM" "strategy4: ai-review-ready issue has no associated open PR" |
| 191 | + mark_processed "$ISSUE_NUM" |
| 192 | + done |
| 193 | + fi |
81 | 194 |
|
82 | | - echo "" |
| 195 | + echo |
83 | 196 | echo "=== Summary ===" |
84 | 197 | echo "Unstuck: $UNSTUCK issues" |
| 198 | + echo "Blocked: $BLOCKED issues" |
85 | 199 | echo "Skipped: $SKIPPED issues" |
0 commit comments