Reopen issues moved out of a terminal column (reconciliation) #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Reopen issues moved out of a terminal column (reconciliation) | |
| # Board -> issue "reopen" is the one behavior GitHub gives no immediate path | |
| # for: projects_v2_item can't trigger a repo workflow, and there is no native | |
| # "auto-reopen" (native only ships Auto-close). So this reconciles on a timer. | |
| # See docs/decisions/0004 and docs/active-spikes/board-sync-redesign.md. | |
| on: | |
| # IMPORTANT: the schedule is intentionally left commented out until the | |
| # one-time baseline-terminal-cleanup has been run. Before that, this would | |
| # mass-reopen legacy issues closed before the automation existed. Run this | |
| # via "Run workflow" (dry_run) first, confirm it wouldn't touch legacy | |
| # issues, then uncomment the cron. See board-sync-redesign.todo.md. | |
| # schedule: | |
| # - cron: '*/5 * * * *' | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: 'Dry run (log without reopening)' | |
| required: false | |
| default: 'true' | |
| type: boolean | |
| env: | |
| GH_TOKEN: ${{ secrets.ACTIONS_TOKEN }} | |
| # Skip issues closed within this window — close-to-done / closed-to-filled | |
| # may still be moving a freshly-closed issue into its terminal column. | |
| FRESH_CLOSE_SECONDS: "600" | |
| jobs: | |
| reconcile: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Reopen closed issues whose card sits in a non-terminal column | |
| run: | | |
| DRY_RUN="${{ github.event.inputs.dry_run || 'false' }}" | |
| NOW=$(date +%s) | |
| echo "Dry run: $DRY_RUN" | |
| echo "---" | |
| reconcile_board() { | |
| PROJECT_ID="$1" | |
| TERMINAL_OPTION="$2" | |
| BOARD_NAME="$3" | |
| CURSOR="" | |
| while true; do | |
| if [ -n "$CURSOR" ]; then | |
| RESULT=$(gh api graphql -f query=' | |
| query($project: ID!, $cursor: String!) { | |
| node(id: $project) { | |
| ... on ProjectV2 { | |
| items(first: 100, after: $cursor) { | |
| nodes { | |
| fieldValueByName(name: "Status") { | |
| ... on ProjectV2ItemFieldSingleSelectValue { optionId } | |
| } | |
| content { | |
| ... on Issue { | |
| number | |
| state | |
| closedAt | |
| repository { nameWithOwner } | |
| } | |
| } | |
| } | |
| pageInfo { hasNextPage endCursor } | |
| } | |
| } | |
| } | |
| }' -f project="$PROJECT_ID" -f cursor="$CURSOR") | |
| else | |
| RESULT=$(gh api graphql -f query=' | |
| query($project: ID!) { | |
| node(id: $project) { | |
| ... on ProjectV2 { | |
| items(first: 100) { | |
| nodes { | |
| fieldValueByName(name: "Status") { | |
| ... on ProjectV2ItemFieldSingleSelectValue { optionId } | |
| } | |
| content { | |
| ... on Issue { | |
| number | |
| state | |
| closedAt | |
| repository { nameWithOwner } | |
| } | |
| } | |
| } | |
| pageInfo { hasNextPage endCursor } | |
| } | |
| } | |
| } | |
| }' -f project="$PROJECT_ID") | |
| fi | |
| HAS_NEXT=$(echo "$RESULT" | jq -r '.data.node.items.pageInfo.hasNextPage') | |
| CURSOR=$(echo "$RESULT" | jq -r '.data.node.items.pageInfo.endCursor') | |
| while IFS= read -r item; do | |
| OPTION_ID=$(echo "$item" | jq -r '.fieldValueByName.optionId // ""') | |
| NUMBER=$(echo "$item" | jq -r '.content.number // ""') | |
| STATE=$(echo "$item" | jq -r '.content.state // ""') | |
| CLOSED_AT=$(echo "$item" | jq -r '.content.closedAt // ""') | |
| REPO=$(echo "$item" | jq -r '.content.repository.nameWithOwner // ""') | |
| # Only closed issues, with a status set, not already terminal | |
| [ "$STATE" != "CLOSED" ] && continue | |
| [ -z "$OPTION_ID" ] && continue | |
| [ "$OPTION_ID" == "$TERMINAL_OPTION" ] && continue | |
| [ -z "$NUMBER" ] || [ -z "$REPO" ] && continue | |
| # Fresh-close guard | |
| if [ -n "$CLOSED_AT" ]; then | |
| CLOSED_TS=$(date -d "$CLOSED_AT" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$CLOSED_AT" +%s) | |
| if [ $(( NOW - CLOSED_TS )) -lt "$FRESH_CLOSE_SECONDS" ]; then | |
| echo "Skip #$NUMBER ($BOARD_NAME) — closed less than ${FRESH_CLOSE_SECONDS}s ago." | |
| continue | |
| fi | |
| fi | |
| echo "Would reopen: #$NUMBER in $REPO ($BOARD_NAME — non-terminal column, issue closed)" | |
| if [ "$DRY_RUN" != "true" ]; then | |
| gh issue reopen "$NUMBER" --repo "$REPO" | |
| echo " → Reopened." | |
| fi | |
| done < <(echo "$RESULT" | jq -c '.data.node.items.nodes[]') | |
| [ "$HAS_NEXT" != "true" ] && break | |
| done | |
| } | |
| # Org Kanban: terminal column is Done. Open Roles: terminal is Filled. | |
| reconcile_board "PVT_kwDOAA7NZM4AqQF8" "98236657" "Org Kanban" | |
| reconcile_board "PVT_kwDOAA7NZM4AmdbW" "47fc9ee4" "Open Roles" | |
| echo "---" | |
| if [ "$DRY_RUN" == "true" ]; then | |
| echo "Dry run complete. Run with dry_run=false to apply." | |
| fi |