From d7c46721c7192d12c95ac3073f0f77fe5a0114aa Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 26 Aug 2026 08:57:17 -0400 Subject: [PATCH 1/2] feat(ci): sync project issue statuses with PR merges and releases Add a project-status workflow that keeps GitHub Projects (v2) statuses in step with delivery: merging a PR into the default branch moves its linked issues from "In Progress"/"In Review" to "Merged", and publishing a release moves this repo's "Merged" issues to "Released". Projects are discovered dynamically from the issues/repository, so no project number is hard-coded. Requires a PROJECT_TOKEN secret with Projects read/write, since GITHUB_TOKEN cannot access org Projects v2. Co-Authored-By: Claude Fable 5 --- .github/workflows/project-status.yaml | 204 ++++++++++++++++++++++++++ README.md | 7 + 2 files changed, 211 insertions(+) create mode 100644 .github/workflows/project-status.yaml diff --git a/.github/workflows/project-status.yaml b/.github/workflows/project-status.yaml new file mode 100644 index 0000000..441d290 --- /dev/null +++ b/.github/workflows/project-status.yaml @@ -0,0 +1,204 @@ +name: Project Status + +# Keeps GitHub Projects (v2) issue statuses in sync with the delivery +# pipeline: +# - PR merged to the default branch -> linked issues move from +# "In Progress" / "In Review" to "Merged". +# - Release published -> every issue from this repo sitting in +# "Merged" moves to "Released". +# +# Requires a PROJECT_TOKEN secret (classic PAT with `project` scope, or a +# GitHub App token with Projects read/write) — the built-in GITHUB_TOKEN +# cannot access organization Projects v2. + +on: + pull_request_target: + types: [closed] + release: + types: [published] + +permissions: + contents: read + +jobs: + pr-merged: + if: >- + github.event_name == 'pull_request_target' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == github.event.repository.default_branch + runs-on: ubuntu-latest + steps: + - name: Move linked issues to "Merged" + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const { owner, repo } = context.repo; + const FROM = ['in progress', 'in review']; + const TO = 'Merged'; + + const result = await github.graphql(` + query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + closingIssuesReferences(first: 50) { + nodes { + number + projectItems(first: 20, includeArchived: false) { + nodes { + id + project { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + } + } + `, { owner, repo, pr: context.payload.pull_request.number }); + + const issues = result.repository.pullRequest.closingIssuesReferences.nodes; + if (issues.length === 0) { + core.info('PR has no linked issues; nothing to do.'); + return; + } + + for (const issue of issues) { + for (const item of issue.projectItems.nodes) { + const field = item.project.field; + if (!field?.options) { + core.warning(`Project "${item.project.title}" has no single-select "Status" field; skipping.`); + continue; + } + const current = item.fieldValueByName?.name ?? '(none)'; + if (!FROM.includes(current.toLowerCase())) { + core.info(`Issue #${issue.number} in "${item.project.title}" is "${current}", not one of [${FROM.join(', ')}]; skipping.`); + continue; + } + const option = field.options.find(o => o.name.toLowerCase() === TO.toLowerCase()); + if (!option) { + core.warning(`Project "${item.project.title}" has no "${TO}" status option; skipping.`); + continue; + } + await github.graphql(` + mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $field, + value: { singleSelectOptionId: $option } + }) { projectV2Item { id } } + } + `, { project: item.project.id, item: item.id, field: field.id, option: option.id }); + core.info(`Issue #${issue.number}: "${current}" -> "${TO}" in project "${item.project.title}".`); + } + } + + release-published: + if: github.event_name == 'release' + runs-on: ubuntu-latest + steps: + - name: Move "Merged" issues to "Released" + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.PROJECT_TOKEN }} + script: | + const { owner, repo } = context.repo; + const FROM = 'merged'; + const TO = 'Released'; + + const result = await github.graphql(` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + projectsV2(first: 10) { + nodes { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + } + } + } + } + `, { owner, repo }); + + const projects = result.repository.projectsV2.nodes; + if (projects.length === 0) { + core.info('Repository is linked to no Projects v2; nothing to do.'); + return; + } + + for (const project of projects) { + const field = project.field; + if (!field?.options) { + core.warning(`Project "${project.title}" has no single-select "Status" field; skipping.`); + continue; + } + const option = field.options.find(o => o.name.toLowerCase() === TO.toLowerCase()); + if (!option) { + core.warning(`Project "${project.title}" has no "${TO}" status option; skipping.`); + continue; + } + + let cursor = null; + let moved = 0; + do { + const page = await github.graphql(` + query($project: ID!, $cursor: String) { + node(id: $project) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + content { + ... on Issue { + number + repository { nameWithOwner } + } + } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + } + `, { project: project.id, cursor }); + + for (const item of page.node.items.nodes) { + // Only issues from this repository, currently in FROM status. + if (item.content?.repository?.nameWithOwner !== `${owner}/${repo}`) continue; + if ((item.fieldValueByName?.name ?? '').toLowerCase() !== FROM) continue; + await github.graphql(` + mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $project, itemId: $item, fieldId: $field, + value: { singleSelectOptionId: $option } + }) { projectV2Item { id } } + } + `, { project: project.id, item: item.id, field: field.id, option: option.id }); + core.info(`Issue #${item.content.number}: "Merged" -> "${TO}" in project "${project.title}".`); + moved++; + } + + cursor = page.node.items.pageInfo.hasNextPage ? page.node.items.pageInfo.endCursor : null; + } while (cursor); + + core.info(`Project "${project.title}": moved ${moved} issue(s) to "${TO}".`); + } diff --git a/README.md b/README.md index d81ef94..13287a3 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,13 @@ helm install nebari-apps oci://ghcr.io/nebari-dev/apps-pack/charts/nebari-apps \ --set keycloak.url=https://keycloak./auth ``` +Issue tracking follows along automatically +([`project-status.yaml`](.github/workflows/project-status.yaml)): merging a PR into `main` +moves its linked issues' project status from **In Progress** / **In Review** to **Merged**, +and publishing a release moves every **Merged** issue to **Released**. This needs a +`PROJECT_TOKEN` repository secret (a PAT or GitHub App token with Projects read/write — +the built-in `GITHUB_TOKEN` cannot access organization Projects v2). + ## Documentation The user guide lives at **[packs.nebari.dev/nebari-apps-pack](https://packs.nebari.dev/nebari-apps-pack/)** From c98353bd94cffaf4eafd9a7670bbe18f9e9963d6 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Wed, 26 Aug 2026 10:18:57 -0400 Subject: [PATCH 2/2] chore(ci): use the existing ADD_TO_PROJECT_PAT secret for project sync Reuse the org's ADD_TO_PROJECT_PAT secret (already used by the nebari-dev/.github sync-project-priority workflow) instead of introducing a new PROJECT_TOKEN secret. Co-Authored-By: Claude Fable 5 --- .github/workflows/project-status.yaml | 6 +++--- README.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/project-status.yaml b/.github/workflows/project-status.yaml index 441d290..47be13f 100644 --- a/.github/workflows/project-status.yaml +++ b/.github/workflows/project-status.yaml @@ -7,7 +7,7 @@ name: Project Status # - Release published -> every issue from this repo sitting in # "Merged" moves to "Released". # -# Requires a PROJECT_TOKEN secret (classic PAT with `project` scope, or a +# Requires an ADD_TO_PROJECT_PAT secret (classic PAT with `project` scope, or a # GitHub App token with Projects read/write) — the built-in GITHUB_TOKEN # cannot access organization Projects v2. @@ -31,7 +31,7 @@ jobs: - name: Move linked issues to "Merged" uses: actions/github-script@v7 with: - github-token: ${{ secrets.PROJECT_TOKEN }} + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} script: | const { owner, repo } = context.repo; const FROM = ['in progress', 'in review']; @@ -111,7 +111,7 @@ jobs: - name: Move "Merged" issues to "Released" uses: actions/github-script@v7 with: - github-token: ${{ secrets.PROJECT_TOKEN }} + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} script: | const { owner, repo } = context.repo; const FROM = 'merged'; diff --git a/README.md b/README.md index 13287a3..0504123 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ Issue tracking follows along automatically ([`project-status.yaml`](.github/workflows/project-status.yaml)): merging a PR into `main` moves its linked issues' project status from **In Progress** / **In Review** to **Merged**, and publishing a release moves every **Merged** issue to **Released**. This needs a -`PROJECT_TOKEN` repository secret (a PAT or GitHub App token with Projects read/write — +`ADD_TO_PROJECT_PAT` repository secret (a PAT or GitHub App token with Projects read/write — the built-in `GITHUB_TOKEN` cannot access organization Projects v2). ## Documentation