Reopen issues moved out of a terminal column (reconciliation) #234
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: | |
| # Baseline confirmed clean on 2026-07-08 (dry-run found no closed issues in | |
| # non-terminal columns on either board), and a dry-run of this reconciler | |
| # found nothing to reopen — so the schedule is enabled. If you ever need to | |
| # re-baseline, run baseline-terminal-cleanup first. See board-sync-redesign. | |
| 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 |