Skip to content

t5508: default _draft_enabled to true, only disable when explicitly off #10672

t5508: default _draft_enabled to true, only disable when explicitly off

t5508: default _draft_enabled to true, only disable when explicitly off #10672

Workflow file for this run

name: Issue Sync - Bi-directional TODO.md ↔ GitHub Issues
on:
push:
branches: [main]
paths:
- 'TODO.md'
- 'todo/PLANS.md'
- 'todo/tasks/**'
pull_request_target:
branches: [main]
types: [closed, opened, edited]
issues:
types: [opened, closed]
workflow_dispatch:
inputs:
command:
description: 'Sync command to run'
required: true
default: 'status'
type: choice
options:
- status
- pull
- push
- close
- reconcile
- enrich
jobs:
sync-on-push:
name: Sync TODO.md → GitHub Issues
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: issue-sync-push-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
issues: write
steps:
- name: Check commit author
id: check-author
env:
COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }}
run: |
# Prevent infinite loops: skip if this commit was made by GitHub Actions
# Note: COMMIT_AUTHOR passed via env to prevent shell injection from
# untrusted input (backticks, $() etc in author names)
if [[ "$COMMIT_AUTHOR" == "GitHub Actions" ]] || [[ "$COMMIT_AUTHOR" == "github-actions[bot]" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping: commit was made by GitHub Actions (loop prevention)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.check-author.outputs.skip != 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
if: steps.check-author.outputs.skip != 'true'
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
- name: Close issues for completed tasks
if: steps.check-author.outputs.skip != 'true'
run: |
chmod +x .agents/scripts/issue-sync-helper.sh
chmod +x .agents/scripts/shared-constants.sh
echo "=== Closing issues for completed tasks ==="
bash .agents/scripts/issue-sync-helper.sh close --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Push new tasks as issues
if: steps.check-author.outputs.skip != 'true'
run: |
echo "=== Creating issues for new tasks ==="
bash .agents/scripts/issue-sync-helper.sh push --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enrich plan-linked issues
if: steps.check-author.outputs.skip != 'true'
run: |
echo "=== Enriching plan-linked issues ==="
bash .agents/scripts/issue-sync-helper.sh enrich --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Pull any missing refs back to TODO.md
if: steps.check-author.outputs.skip != 'true'
run: |
echo "=== Pulling issue refs to TODO.md ==="
bash .agents/scripts/issue-sync-helper.sh pull --verbose || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and push TODO.md updates
if: steps.check-author.outputs.skip != 'true'
run: |
if git diff --quiet TODO.md 2>/dev/null; then
echo "No TODO.md changes to commit"
else
git add TODO.md
git commit -m "chore: sync GitHub issue refs to TODO.md [skip ci]"
# Retry loop for concurrent pushes
for i in 1 2 3; do
echo "Push attempt $i..."
git pull --rebase origin main || true
if git push; then
echo "Push succeeded on attempt $i"
exit 0
fi
echo "Push failed, retrying..."
sleep $((i * 3))
done
echo "All push attempts failed"
exit 1
fi
- name: Show sync status
if: steps.check-author.outputs.skip != 'true'
run: |
bash .agents/scripts/issue-sync-helper.sh status || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-on-issue:
name: Sync GitHub Issue → TODO.md
if: github.event_name == 'issues'
runs-on: ubuntu-latest
timeout-minutes: 5
concurrency:
group: issue-sync-issue-${{ github.event.issue.number }}
cancel-in-progress: true
permissions:
contents: write
issues: read
steps:
- name: Check issue title format
id: check-issue
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
# Note: ISSUE_TITLE passed via env to prevent shell injection from
# untrusted input (backticks in titles cause `command not found` errors)
if echo "$ISSUE_TITLE" | grep -qE '^t[0-9]+'; then
echo "sync=true" >> "$GITHUB_OUTPUT"
TASK_ID=$(echo "$ISSUE_TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*')
echo "task_id=$TASK_ID" >> "$GITHUB_OUTPUT"
echo "Issue #$ISSUE_NUMBER matches task $TASK_ID"
else
echo "sync=false" >> "$GITHUB_OUTPUT"
echo "Issue #$ISSUE_NUMBER does not have t-number prefix, skipping"
fi
- name: Checkout
if: steps.check-issue.outputs.sync == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
if: steps.check-issue.outputs.sync == 'true'
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
- name: Sync issue ref to TODO.md
if: steps.check-issue.outputs.sync == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TASK_ID: ${{ steps.check-issue.outputs.task_id }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
chmod +x .agents/scripts/issue-sync-helper.sh
chmod +x .agents/scripts/shared-constants.sh
echo "=== New issue opened: ${TASK_ID} (#${ISSUE_NUMBER}) ==="
bash .agents/scripts/issue-sync-helper.sh pull --verbose || true
- name: Commit and push TODO.md updates
if: steps.check-issue.outputs.sync == 'true'
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
if git diff --quiet TODO.md 2>/dev/null; then
echo "No TODO.md changes to commit"
else
git add TODO.md
git commit -m "chore: sync ref:GH#${ISSUE_NUMBER} to TODO.md [skip ci]"
for i in 1 2 3; do
echo "Push attempt $i..."
git pull --rebase origin main || true
if git push; then
echo "Push succeeded on attempt $i"
exit 0
fi
sleep $((i * 3))
done
echo "All push attempts failed"
exit 1
fi
manual-sync:
name: Manual Sync
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: issue-sync-manual
cancel-in-progress: false
permissions:
contents: write
issues: write
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
- name: Run sync command
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SYNC_COMMAND: ${{ github.event.inputs.command }}
run: |
chmod +x .agents/scripts/issue-sync-helper.sh
chmod +x .agents/scripts/shared-constants.sh
echo "=== Running: issue-sync-helper.sh $SYNC_COMMAND ==="
bash .agents/scripts/issue-sync-helper.sh "$SYNC_COMMAND" --verbose
- name: Commit and push TODO.md updates
if: github.event.inputs.command == 'pull' || github.event.inputs.command == 'push'
env:
SYNC_COMMAND: ${{ github.event.inputs.command }}
run: |
if git diff --quiet TODO.md 2>/dev/null; then
echo "No TODO.md changes to commit"
else
git add TODO.md
git commit -m "chore: manual issue sync ($SYNC_COMMAND) [skip ci]"
for i in 1 2 3; do
git pull --rebase origin main || true
if git push; then
exit 0
fi
sleep $((i * 3))
done
exit 1
fi
# =========================================================================
# t1339: Sync issue hygiene on PR merge
# =========================================================================
# When a PR merges to main, this job:
# 1. Extracts task ID from PR title (tNNN: ... pattern)
# 2. Finds linked issues (from PR body Closes/Fixes/Resolves #NNN, or by task ID)
# 3. Posts a closing comment with PR link
# 4. Applies status:done label, removes stale status labels
# 5. Updates TODO.md with pr:#NNN proof-log and marks task [x]
#
# This ensures all closing paths (interactive, worker, pulse) get the same
# hygiene treatment — previously only the TODO.md push path ran issue-sync.
sync-on-pr-merge:
name: Sync Issue Hygiene on PR Merge
if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true
runs-on: ubuntu-latest
timeout-minutes: 5
concurrency:
group: issue-sync-pr-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: write
steps:
# Security: This job uses pull_request_target to get write permissions for
# fork PRs. It only checks out ref:main (base branch), never fork code.
# Event metadata (PR title, body, number) is used for issue hygiene only.
- name: Extract task ID and collect linked issues
id: extract
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# Extract task ID from PR title (e.g., "t1329: Cross-review judge pipeline")
TASK_ID=""
if echo "$PR_TITLE" | grep -qE '^t[0-9]+'; then
TASK_ID=$(echo "$PR_TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*')
fi
echo "task_id=$TASK_ID" >> "$GITHUB_OUTPUT"
# Collect linked issue numbers from PR body (Closes #NNN, Fixes #NNN, Resolves #NNN)
LINKED_ISSUES=""
if [[ -n "$PR_BODY" ]]; then
LINKED_ISSUES=$(echo "$PR_BODY" | grep -oiE '(closes?|fixes?|resolves?)[[:space:]]*#[0-9]+' | grep -oE '[0-9]+' | sort -u | tr '\n' ' ' || true)
fi
echo "linked_issues=$LINKED_ISSUES" >> "$GITHUB_OUTPUT"
# Determine if we have anything to process
if [[ -n "$TASK_ID" || -n "$LINKED_ISSUES" ]]; then
echo "has_work=true" >> "$GITHUB_OUTPUT"
else
echo "has_work=false" >> "$GITHUB_OUTPUT"
fi
echo "PR #$PR_NUMBER: task_id=$TASK_ID linked_issues=$LINKED_ISSUES"
- name: Find issue by task ID (fallback)
id: find-issue
if: steps.extract.outputs.has_work == 'true' && steps.extract.outputs.task_id != ''
env:
TASK_ID: ${{ steps.extract.outputs.task_id }}
LINKED_ISSUES: ${{ steps.extract.outputs.linked_issues }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# If no explicit Closes #NNN in PR body, search for the issue by task ID in title
if [[ -z "$LINKED_ISSUES" ]]; then
FOUND=$(gh issue list --repo "$REPO" --state all --search "${TASK_ID}:" --json number,title --limit 5 \
| jq -r --arg tid "$TASK_ID" '[.[] | select(.title | test("^" + $tid + "[.:\\s]"))] | .[0].number // empty')
if [[ -n "$FOUND" && "$FOUND" != "null" ]]; then
echo "found_issues=$FOUND" >> "$GITHUB_OUTPUT"
echo "Found issue #$FOUND for task $TASK_ID"
else
echo "found_issues=" >> "$GITHUB_OUTPUT"
echo "No issue found for task $TASK_ID"
fi
else
echo "found_issues=" >> "$GITHUB_OUTPUT"
echo "Using linked issues from PR body: $LINKED_ISSUES"
fi
- name: Apply closing hygiene to linked issues
if: steps.extract.outputs.has_work == 'true'
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
LINKED_ISSUES: ${{ steps.extract.outputs.linked_issues }}
FOUND_ISSUES: ${{ steps.find-issue.outputs.found_issues }}
TASK_ID: ${{ steps.extract.outputs.task_id }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Merge all issue numbers (from PR body + fallback search)
ALL_ISSUES="$LINKED_ISSUES $FOUND_ISSUES"
ALL_ISSUES=$(echo "$ALL_ISSUES" | tr ' ' '\n' | sort -u | { grep -v '^$' || true; } | tr '\n' ' ')
if [[ -z "$ALL_ISSUES" ]]; then
echo "No linked issues found — skipping closing hygiene"
exit 0
fi
echo "Processing issues: $ALL_ISSUES"
for ISSUE_NUM in $ALL_ISSUES; do
echo "--- Issue #$ISSUE_NUM ---"
# Post closing comment if none exists from this PR
EXISTING_COMMENT=$(gh api "repos/${REPO}/issues/${ISSUE_NUM}/comments" \
--jq "[.[] | select(.body | test(\"Completed via.*PR #${PR_NUMBER}\"))] | length" 2>/dev/null || echo "0")
if [[ "$EXISTING_COMMENT" == "0" ]]; then
TASK_REF=""
if [[ -n "$TASK_ID" ]]; then
TASK_REF=" Task $TASK_ID"
fi
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body "Completed via [PR #${PR_NUMBER}](${PR_URL}).${TASK_REF} merged to main."
echo "Posted closing comment on #$ISSUE_NUM"
else
echo "Closing comment already exists on #$ISSUE_NUM"
fi
# Apply status:done label (create if needed)
gh label create "status:done" --color "6F42C1" --description "Task is complete" --repo "$REPO" --force 2>/dev/null || true
gh issue edit "$ISSUE_NUM" --repo "$REPO" --add-label "status:done" 2>/dev/null || true
# Remove stale status labels
for STALE_LABEL in "status:available" "status:queued" "status:claimed" "status:in-review" "status:in-progress" "status:blocked" "status:verify-failed"; do
gh issue edit "$ISSUE_NUM" --repo "$REPO" --remove-label "$STALE_LABEL" 2>/dev/null || true
done
echo "Updated labels on #$ISSUE_NUM (added status:done, removed stale)"
done
- name: Checkout repo for TODO.md update
if: steps.extract.outputs.task_id != ''
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update TODO.md proof-log
if: steps.extract.outputs.task_id != ''
env:
TASK_ID: ${{ steps.extract.outputs.task_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
TODAY=$(date +%Y-%m-%d)
PROOF="pr:#${PR_NUMBER} completed:${TODAY}"
# Check if task exists as unchecked in TODO.md
if ! grep -qE "^[[:space:]]*- \[ \] ${TASK_ID} " TODO.md; then
echo "Task $TASK_ID not found as unchecked in TODO.md — skipping proof-log"
# May already be [x], or may not exist in this repo's TODO.md
exit 0
fi
# Check if already marked complete with this PR
if grep -qE "^[[:space:]]*- \[x\] ${TASK_ID} .*pr:#${PR_NUMBER}" TODO.md; then
echo "Task $TASK_ID already has proof-log for PR #${PR_NUMBER}"
exit 0
fi
# Mark complete: [ ] -> [x], append proof-log
sed -i -E "s/^([[:space:]]*- )\[ \] (${TASK_ID} .*)\$/\1[x] \2 ${PROOF}/" TODO.md
# Verify the change was made
if ! grep -qE "^[[:space:]]*- \[x\] ${TASK_ID} .*pr:#${PR_NUMBER}" TODO.md; then
echo "::warning::Failed to update TODO.md for $TASK_ID — sed pattern did not match"
exit 0
fi
echo "Marked $TASK_ID complete with proof-log: $PROOF"
# Commit and push
git add TODO.md
git commit -m "chore: mark $TASK_ID complete ($PROOF) [skip ci]"
for i in 1 2 3; do
echo "Push attempt $i..."
git pull --rebase origin main || true
if git push; then
echo "Push succeeded on attempt $i"
break
fi
sleep $((i * 3))
done
- name: Sync PLANS.md status from TODO.md completions
if: steps.extract.outputs.task_id != ''
env:
TASK_ID: ${{ steps.extract.outputs.task_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# When a task is marked complete in TODO.md, check if it belongs to
# a plan in PLANS.md and update that plan's status if all its tasks
# are now complete. Plans are institutional memory — they stay in
# PLANS.md marked as Completed, never removed.
if [[ ! -f "todo/PLANS.md" ]] || [[ ! -f "TODO.md" ]]; then
echo "PLANS.md or TODO.md not found — skipping plan sync"
exit 0
fi
PLANS_CHANGED=false
# Find all plans that reference this task ID
# Plans reference tasks as: **TODO:** t1234 or **TODO:** t1234, t1235
PLAN_LINES=$(grep -n "^\*\*TODO:\*\*.*${TASK_ID}" todo/PLANS.md | cut -d: -f1 || true)
if [[ -z "$PLAN_LINES" ]]; then
echo "Task $TASK_ID not referenced in any PLANS.md plan — skipping"
exit 0
fi
echo "Task $TASK_ID found in PLANS.md at line(s): $PLAN_LINES"
for TODO_LINE in $PLAN_LINES; do
# Extract all task IDs from this plan's TODO line
TODO_CONTENT=$(sed -n "${TODO_LINE}p" todo/PLANS.md)
PLAN_TASKS=$(echo "$TODO_CONTENT" | grep -oE 't[0-9]+(\.[0-9]+)*' | sort -u)
if [[ -z "$PLAN_TASKS" ]]; then
echo "No task IDs found on line $TODO_LINE — skipping"
continue
fi
echo "Plan tasks: $PLAN_TASKS"
# Check if ALL tasks in this plan are complete in TODO.md
ALL_DONE=true
for PTASK in $PLAN_TASKS; do
if grep -qE "^[[:space:]]*- \[ \] ${PTASK}( |$)" TODO.md; then
echo "Task $PTASK is still open — plan not complete"
ALL_DONE=false
break
fi
# Also check if the task exists at all (might be a subtask range)
if ! grep -qE "^[[:space:]]*- \[.\] ${PTASK}( |$)" TODO.md; then
echo "Task $PTASK not found in TODO.md — assuming complete (may be subtask)"
fi
done
if [[ "$ALL_DONE" != "true" ]]; then
continue
fi
# Find the plan's Status line (within 5 lines above the TODO line)
STATUS_LINE=""
for OFFSET in 1 2 3 4 5; do
CHECK_LINE=$((TODO_LINE - OFFSET))
if [[ "$CHECK_LINE" -lt 1 ]]; then break; fi
LINE_CONTENT=$(sed -n "${CHECK_LINE}p" todo/PLANS.md)
if echo "$LINE_CONTENT" | grep -q '^\*\*Status:\*\*'; then
STATUS_LINE="$CHECK_LINE"
break
fi
done
if [[ -z "$STATUS_LINE" ]]; then
echo "Could not find Status line for plan near line $TODO_LINE — skipping"
continue
fi
CURRENT_STATUS=$(sed -n "${STATUS_LINE}p" todo/PLANS.md)
echo "Current status (line $STATUS_LINE): $CURRENT_STATUS"
# Skip if already completed
if echo "$CURRENT_STATUS" | grep -qi 'Completed'; then
echo "Plan already marked Completed — skipping"
continue
fi
# Update status to Completed
sed -i "${STATUS_LINE}s/\*\*Status:\*\*.*/\*\*Status:\*\* Completed/" todo/PLANS.md
# Add PR reference to the TODO line if not already present
if ! grep -q "PR.*#${PR_NUMBER}" todo/PLANS.md | head -1; then
# Find if there's already a **PR:** line after the TODO line
PR_LINE=""
for OFFSET in 1 2 3; do
CHECK_LINE=$((TODO_LINE + OFFSET))
LINE_CONTENT=$(sed -n "${CHECK_LINE}p" todo/PLANS.md)
if echo "$LINE_CONTENT" | grep -q '^\*\*PR'; then
PR_LINE="$CHECK_LINE"
break
fi
# Stop if we hit another field or section
if echo "$LINE_CONTENT" | grep -qE '^\*\*|^###'; then
break
fi
done
if [[ -z "$PR_LINE" ]]; then
# Insert a PR line after the TODO line
sed -i "${TODO_LINE}a\\**PR:** #${PR_NUMBER}" todo/PLANS.md
fi
fi
PLANS_CHANGED=true
echo "Updated plan status to Completed (line $STATUS_LINE)"
done
if [[ "$PLANS_CHANGED" == "true" ]]; then
git add todo/PLANS.md
if git diff --cached --quiet todo/PLANS.md 2>/dev/null; then
echo "No PLANS.md changes to commit"
else
git commit -m "chore: sync PLANS.md status for $TASK_ID (PR #${PR_NUMBER}) [skip ci]"
for i in 1 2 3; do
echo "Push attempt $i..."
git pull --rebase origin main || true
if git push; then
echo "PLANS.md sync pushed on attempt $i"
break
fi
sleep $((i * 3))
done
fi
fi
# =========================================================================
# t1339: Apply conventional-commit labels to PRs
# t3851: Fixed — uses pull_request_target for fork PR write permissions
# t3862: Added GH#NNNN: prefix stripping, graceful permission failure
# =========================================================================
# Parses the PR title prefix (feat:, fix:, docs:, etc.) and applies
# the corresponding GitHub label. Runs on PR open and title edit.
# Uses pull_request_target (not pull_request) so the GITHUB_TOKEN has
# write access even for PRs from forks.
# Strips task ID prefixes (t1234:, GH#1234:) before extracting the type.
label-pr:
name: Label PR from Conventional Commit
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.merged != true &&
(github.event.action == 'opened' || github.event.action == 'edited')
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
issues: write
pull-requests: write
steps:
# Security: This job uses pull_request_target to get write permissions for
# fork PRs. It ONLY reads event metadata (PR title, number) — it never
# checks out or executes code from the PR head branch.
- name: Apply label from PR title prefix
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Extract conventional commit prefix from PR title
# Supports: feat, fix, docs, refactor, chore, perf, test, ci, build, style
# Also supports task-prefixed titles: "t1234: feat: ..." or "GH#1234: Fix ..."
TITLE="$PR_TITLE"
# Strip task ID prefixes if present:
# - "t1234: " or "t1234.5: " (internal task IDs)
# - "GH#1234: " (GitHub issue references)
TITLE=$(echo "$TITLE" | sed -E 's/^(t[0-9]+(\.[0-9]+)*|GH#[0-9]+):[[:space:]]*//')
# Extract the conventional commit type (case-insensitive)
# Matches "feat:", "fix:", "Fix ", "Add ", etc. — colon or space after prefix
PREFIX=$(echo "$TITLE" | grep -oiE '^(feat|fix|docs|refactor|chore|perf|test|ci|build|style|hotfix|bugfix|add|update|enhance)[:(! ]' | sed 's/[:(! ]$//' | tr '[:upper:]' '[:lower:]' || true)
if [[ -z "$PREFIX" ]]; then
echo "No conventional commit prefix found in: $PR_TITLE"
exit 0
fi
# Map prefix to GitHub label
case "$PREFIX" in
feat|add) LABEL="enhancement" ;;
fix|bugfix|hotfix) LABEL="bug" ;;
docs) LABEL="documentation" ;;
refactor) LABEL="refactor" ;;
chore) LABEL="chore" ;;
perf) LABEL="performance" ;;
test) LABEL="testing" ;;
ci|build) LABEL="ci" ;;
style) LABEL="style" ;;
update|enhance) LABEL="enhancement" ;;
*) LABEL="" ;;
esac
if [[ -n "$LABEL" ]]; then
# Create label if it doesn't exist (idempotent)
gh label create "$LABEL" --repo "$REPO" --force 2>/dev/null || true
# Apply label — graceful failure if token lacks write permissions
# (e.g., stale runs from before pull_request_target migration)
if ! gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "$LABEL" 2>&1; then
echo "::warning::Could not apply label '$LABEL' to PR #$PR_NUMBER (insufficient permissions). Re-run this check to pick up the latest workflow permissions."
exit 0
fi
echo "Applied label '$LABEL' to PR #$PR_NUMBER (prefix: $PREFIX)"
fi
check-issue-link:
name: Check PR-Issue Linkage
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.merged != true &&
(github.event.action == 'opened' || github.event.action == 'edited')
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
issues: read
pull-requests: write
steps:
# Security: This job uses pull_request_target to get write permissions for
# fork PRs. It ONLY reads event metadata (PR title, body, number) — it never
# checks out or executes code from the PR head branch.
- name: Check for issue reference in PR body
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Skip PRs that don't need issue linkage
if echo "$PR_TITLE" | grep -qiE '^(chore\(release\)|release:)'; then
echo "Release PR — issue link not required"
exit 0
fi
# Check for GitHub closing keywords or ref:GH# pattern
if echo "$PR_BODY" | grep -qiE '(closes|fixes|resolves)[[:space:]]+#[0-9]+'; then
echo "PR body contains issue closing keyword"
exit 0
fi
if echo "$PR_BODY" | grep -qE 'ref:GH#[0-9]+'; then
echo "PR body contains ref:GH# reference"
exit 0
fi
# Check if a task ID or GH# reference in the title has a matching open issue
TASK_ID=$(echo "$PR_TITLE" | grep -oE '^t[0-9]+(\.[0-9]+)*' || true)
GH_REF=$(echo "$PR_TITLE" | grep -oE '^GH#[0-9]+' | sed 's/GH#//' || true)
if [[ -n "$GH_REF" ]]; then
# GH#NNNN: prefix directly references the issue number
ISSUE_NUM="$GH_REF"
elif [[ -n "$TASK_ID" ]]; then
ISSUE_NUM=$(gh issue list --repo "$REPO" --state open --search "${TASK_ID}:" --json number --jq '.[0].number' 2>/dev/null || true)
else
ISSUE_NUM=""
fi
if [[ -n "$ISSUE_NUM" && "$ISSUE_NUM" != "null" ]]; then
echo "::warning::PR references issue #$ISSUE_NUM but no closing keyword in body. Add 'Closes #$ISSUE_NUM' to the PR body for automatic linkage."
# Post a one-time comment (check if we already commented)
# Graceful failure if token lacks write permissions
EXISTING=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json comments --jq '[.comments[] | select(.body | contains("issue-link-check"))] | length' 2>/dev/null || echo "0")
if [[ "$EXISTING" == "0" ]]; then
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "<!-- issue-link-check -->
**Missing issue link.** This PR references issue #$ISSUE_NUM, but the PR body doesn't contain a closing keyword.
Add \`Closes #$ISSUE_NUM\` to the PR description so GitHub auto-links and auto-closes the issue on merge." 2>/dev/null || echo "::warning::Could not post comment (insufficient permissions)"
fi
exit 0
fi
echo "No issue reference found — this is acceptable for chore/docs PRs without tracked issues"
guard-persistent-issues:
name: Reopen Persistent Issues
if: >-
github.event_name == 'issues' &&
github.event.action == 'closed'
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
issues: write
steps:
- name: Reopen if persistent label
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ',') }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if echo "$ISSUE_LABELS" | grep -q 'persistent'; then
gh issue reopen "$ISSUE_NUMBER" --repo "$REPO" \
--comment "Auto-reopened: this issue has the \`persistent\` label and should not be closed. If closure was intentional, remove the \`persistent\` label first."
# Remove status:done if it was added
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label "status:done" 2>/dev/null || true
echo "Reopened persistent issue #$ISSUE_NUMBER"
else
echo "Issue #$ISSUE_NUMBER is not persistent — closure allowed"
fi
label-closure-reason:
name: Label Issue Closure Reason
if: >-
github.event_name == 'issues' &&
github.event.action == 'closed'
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
issues: write
steps:
- name: Apply closure reason label
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ',') }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Fetch state_reason from the API (not available in event payload)
STATE_REASON=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}" --jq '.state_reason // "completed"' 2>/dev/null || echo "completed")
echo "Issue #$ISSUE_NUMBER closed with reason: $STATE_REASON"
# Skip if already closed by a merged PR (sync-on-pr-merge handles those)
if echo "$ISSUE_LABELS" | grep -q 'status:done'; then
echo "Issue already has status:done — closed by merged PR, skipping"
exit 0
fi
# Determine label based on state_reason and existing labels
LABEL=""
case "$STATE_REASON" in
not_planned)
# Check if already labelled with a specific reason
if echo "$ISSUE_LABELS" | grep -q 'duplicate'; then
echo "Already labelled as duplicate"
LABEL=""
elif echo "$ISSUE_LABELS" | grep -q 'wontfix'; then
echo "Already labelled as wontfix"
LABEL=""
else
# Check closing comment for hints about the reason
LAST_COMMENT=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" \
--jq 'last | .body // ""' 2>/dev/null || echo "")
if echo "$LAST_COMMENT" | grep -qiE 'duplicate|dupe|already exists'; then
LABEL="duplicate"
elif echo "$LAST_COMMENT" | grep -qiE 'already.*(fixed|resolved|done|merged|implemented)'; then
LABEL="already-fixed"
elif echo "$LAST_COMMENT" | grep -qiE 'wontfix|won'\''t fix|will not'; then
LABEL="wontfix"
else
LABEL="not-planned"
fi
fi
;;
completed)
# Completed but no status:done = closed manually without a PR
# Check if there's a linked merged PR
LINKED_PR=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/timeline" \
--jq '[.[] | select(.event == "cross-referenced" and .source.issue.pull_request != null and .source.issue.state == "closed")] | length' 2>/dev/null || echo "0")
if [[ "$LINKED_PR" == "0" ]]; then
# Check closing comment for already-fixed hints
LAST_COMMENT=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" \
--jq 'last | .body // ""' 2>/dev/null || echo "")
if echo "$LAST_COMMENT" | grep -qiE 'already.*(fixed|resolved|done|merged|implemented)'; then
LABEL="already-fixed"
fi
# Otherwise leave unlabelled — could be manually verified as done
fi
;;
esac
if [[ -n "$LABEL" ]]; then
# Ensure label exists
gh label create "$LABEL" --repo "$REPO" --force \
--description "$(
case "$LABEL" in
duplicate) echo "This issue or pull request already exists" ;;
not-planned) echo "Closed without implementation — not planned" ;;
already-fixed) echo "Already fixed by another change" ;;
wontfix) echo "This will not be worked on" ;;
esac
)" \
--color "$(
case "$LABEL" in
duplicate) echo "cfd3d7" ;;
not-planned) echo "ffffff" ;;
already-fixed) echo "e4e669" ;;
wontfix) echo "ffffff" ;;
esac
)" 2>/dev/null || true
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label "$LABEL" 2>/dev/null || true
echo "Applied label '$LABEL' to issue #$ISSUE_NUMBER"
else
echo "No closure reason label needed for issue #$ISSUE_NUMBER"
fi