Skip to content

Reopen issues moved out of a terminal column (reconciliation) #234

Reopen issues moved out of a terminal column (reconciliation)

Reopen issues moved out of a terminal column (reconciliation) #234

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