From e0473a00573b02dd91bf41d72514204af6be2d68 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sat, 5 Sep 2026 18:00:40 +0200 Subject: [PATCH] feat: sync project board status with /assign and linked PRs The /assign bot moved GitHub assignees but never touched the Project v2 board itself, so the "In progress" column stayed empty. Same for PRs: opening one with `Closes #N` closed the issue on merge but never moved the card to "Ready to review". - assign-command.yml: on /assign, move the linked board item to "In progress"; on /unassign, move it back to "Backlog". Uses PROJECT_TOKEN since the default GITHUB_TOKEN has no Projects v2 scope. - pr-board-sync.yml (new): on PR opened/edited/ready_for_review, parse closing keywords (Closes/Fixes/Resolves #N) from the description and move each linked issue's card to "Ready to review". Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YTLv6fJFA5MotMqWgStGrf --- .github/workflows/assign-command.yml | 92 ++++++++++++++++++++++++++++ .github/workflows/pr-board-sync.yml | 76 +++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 .github/workflows/pr-board-sync.yml diff --git a/.github/workflows/assign-command.yml b/.github/workflows/assign-command.yml index 205e7a53..96c868f6 100644 --- a/.github/workflows/assign-command.yml +++ b/.github/workflows/assign-command.yml @@ -7,12 +7,19 @@ on: permissions: issues: write +env: + PROJECT_ID: PVT_kwHOA8O2Dc4BX1OY + STATUS_FIELD_ID: PVTSSF_lAHOA8O2Dc4BX1OYzhS_ZbU + STATUS_IN_PROGRESS: "98236657" + STATUS_BACKLOG: "f75ad846" + jobs: assign: if: ${{ !github.event.issue.pull_request && (github.event.comment.body == '/assign' || github.event.comment.body == '/unassign') }} runs-on: ubuntu-latest steps: - name: Handle /assign + id: do_assign if: github.event.comment.body == '/assign' uses: actions/github-script@v7 with: @@ -29,6 +36,7 @@ jobs: owner, repo, issue_number, body: `⚠️ This issue is already assigned to ${names}. Ask them to comment \`/unassign\` first if they're no longer working on it.`, }); + core.setOutput('assigned', 'false'); return; } @@ -37,8 +45,50 @@ jobs: owner, repo, issue_number, body: `✅ Assigned to @${commenter}. Comment \`/unassign\` if you can no longer work on this. Open a PR that includes \`Closes #${issue_number}\` in its description when you're ready for review.`, }); + core.setOutput('assigned', 'true'); + + - name: Move to In progress + if: github.event.comment.body == '/assign' && steps.do_assign.outputs.assigned == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + }); + + const { node } = await github.graphql( + `query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { projectItems(first: 10) { nodes { id project { id } } } } + } + }`, + { issueId: issue.node_id } + ); + + const item = node.projectItems.nodes.find(n => n.project.id === process.env.PROJECT_ID); + if (!item) { + console.log('Issue is not on the project board; skipping status update.'); + return; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + }`, + { + projectId: process.env.PROJECT_ID, + itemId: item.id, + fieldId: process.env.STATUS_FIELD_ID, + optionId: process.env.STATUS_IN_PROGRESS, + } + ); - name: Handle /unassign + id: do_unassign if: github.event.comment.body == '/unassign' uses: actions/github-script@v7 with: @@ -55,6 +105,7 @@ jobs: owner, repo, issue_number, body: `⚠️ @${commenter}, you're not currently assigned to this issue.`, }); + core.setOutput('unassigned', 'false'); return; } @@ -63,3 +114,44 @@ jobs: owner, repo, issue_number, body: `Unassigned @${commenter}. This issue is open again — comment \`/assign\` to pick it up.`, }); + core.setOutput('unassigned', 'true'); + + - name: Move to Backlog + if: github.event.comment.body == '/unassign' && steps.do_unassign.outputs.unassigned == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + }); + + const { node } = await github.graphql( + `query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { projectItems(first: 10) { nodes { id project { id } } } } + } + }`, + { issueId: issue.node_id } + ); + + const item = node.projectItems.nodes.find(n => n.project.id === process.env.PROJECT_ID); + if (!item) { + console.log('Issue is not on the project board; skipping status update.'); + return; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + }`, + { + projectId: process.env.PROJECT_ID, + itemId: item.id, + fieldId: process.env.STATUS_FIELD_ID, + optionId: process.env.STATUS_BACKLOG, + } + ); diff --git a/.github/workflows/pr-board-sync.yml b/.github/workflows/pr-board-sync.yml new file mode 100644 index 00000000..4f47529c --- /dev/null +++ b/.github/workflows/pr-board-sync.yml @@ -0,0 +1,76 @@ +name: PR board sync + +on: + pull_request: + types: [opened, edited, ready_for_review] + +permissions: + contents: read + +env: + PROJECT_ID: PVT_kwHOA8O2Dc4BX1OY + STATUS_FIELD_ID: PVTSSF_lAHOA8O2Dc4BX1OYzhS_ZbU + STATUS_READY_TO_REVIEW: c6aa22db + +jobs: + move-linked-issues: + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Move linked issues to Ready to review + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const body = context.payload.pull_request.body || ''; + const keywords = '(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)'; + const re = new RegExp(`${keywords}\\s+#(\\d+)`, 'gi'); + const issueNumbers = [...new Set([...body.matchAll(re)].map(m => Number(m[1])))]; + + if (issueNumbers.length === 0) { + console.log('No closing keyword found in the PR description; nothing to move.'); + return; + } + + for (const issue_number of issueNumbers) { + let issue; + try { + ({ data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, repo: context.repo.repo, issue_number, + })); + } catch (err) { + console.log(`Issue #${issue_number} not found in this repo, skipping.`); + continue; + } + + const { node } = await github.graphql( + `query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { projectItems(first: 10) { nodes { id project { id } } } } + } + }`, + { issueId: issue.node_id } + ); + + const item = node.projectItems.nodes.find(n => n.project.id === process.env.PROJECT_ID); + if (!item) { + console.log(`Issue #${issue_number} is not on the project board, skipping.`); + continue; + } + + await github.graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, + value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } + }`, + { + projectId: process.env.PROJECT_ID, + itemId: item.id, + fieldId: process.env.STATUS_FIELD_ID, + optionId: process.env.STATUS_READY_TO_REVIEW, + } + ); + console.log(`Moved issue #${issue_number} to Ready to review.`); + }