diff --git a/.github/AUTOMATION_SETUP.md b/.github/AUTOMATION_SETUP.md new file mode 100644 index 0000000..4977fcb --- /dev/null +++ b/.github/AUTOMATION_SETUP.md @@ -0,0 +1,71 @@ +# Proposal automation — one-time org setup + +The approval automation in `.github/workflows/` (pipeline / RFC / SIG) is +self-contained. The project-board `Status` automation below needs a one-time +configuration step from an **org owner**, because a GitHub Actions / app token +cannot grant itself project access. + +## 1. Issue types (`Pipeline`, `RFC`, `Special Interest Group`) — optional, not currently enabled + +Proposals are already distinguished by their template, their +`new-pipeline` / `new-rfc` / `new-special-interest-group` labels, and their +separate project boards, so native **issue types** are an optional extra rather +than a requirement. They are **not** wired up in this repo today. + +If you later decide you want them (e.g. for `type:Pipeline` filtering in issue +search): + +1. An org owner creates the types at **github.com/organizations/nf-core/settings/issue-types**, named exactly `Pipeline`, `RFC`, and `Special Interest Group`. +2. Add the matching `type:` key to each issue-form template, e.g. `type: "Pipeline"` in `new_pipeline.yml`. + +> Order matters: the types must exist **before** the templates reference them, +> otherwise opening an issue from a template with an unknown `type:` fails +> validation. (The bot token can't manage types itself — it returns +> `403 Resource not accessible by integration` — which is why this is manual.) + +## 2. Project board Status automation + +The pipeline workflow now mirrors the approval status onto the +[pipelines project board](https://github.com/orgs/nf-core/projects/104) — the +`Status` field is set automatically, replacing the manual curator step that was +previously documented in the repo README: + +| Approval status | Board `Status` option | +| --------------- | --------------------- | +| 🕐 Pending | `proposed` | +| ✅ Approved | `accepted` | +| ❌ Rejected | `turned-down` | +| ⏰ Timed Out | `timed-out` | + +For this to take effect, the bot token (`secrets.nf_core_bot_auth_token`) needs +**read & write access to organisation projects**: + +- Classic PAT: the `project` scope. +- Fine-grained PAT / app: organisation permission **Projects → Read and write**. + +The update is **best-effort**: if the token lacks project access, the `Status` +field option names don't match, or the issue isn't on the board, the workflow +logs the reason and continues — the labels and status comment are unaffected. +The board's `Status` options must be named `proposed`, `accepted`, +`turned-down`, `timed-out` (they already are); adjust the `PROJECT_STATUS` map +at the top of `pipeline_proposals.yml` if they ever change. + +### Enabling it for the RFC and SIG boards + +The same capability is available to the RFC (`nf-core/127`) and SIG +(`nf-core/105`) workflows. To enable, add the same block near the top of +`rfc_approval.yml` / `sig_approval.yml`: + +```js +const PROJECT_NUMBER = 127; // 105 for SIG +const PROJECT_STATUS = { + "🕐 Pending": "proposed", + "✅ Approved": "accepted", + "❌ Rejected": "turned-down", + "⏰ Timed Out": "timed-out", +}; +``` + +and call `await approvalManager.updateProjectStatus(PROJECT_NUMBER, PROJECT_STATUS[status]);` +alongside each existing `updateIssueStatus(...)` call. Verify the option names +on those two boards match first. diff --git a/.github/workflows/lib/approval.js b/.github/workflows/lib/approval.js index 7548278..5469d4f 100644 --- a/.github/workflows/lib/approval.js +++ b/.github/workflows/lib/approval.js @@ -177,6 +177,96 @@ class ApprovalManager { } } + // Update the proposal's Status field on its GitHub Project (v2) board. + // + // `projectNumber` is the org-level project number (e.g. 104 for pipelines). + // `statusOptionName` is the name of the single-select Status option to set; + // it is matched case-insensitively against the board's configured options. + // + // This is best-effort: any problem (missing project scope on the token, no + // Status field, an unknown option name, etc.) is logged and swallowed so it + // never breaks the core label/comment automation. + async updateProjectStatus(projectNumber, statusOptionName) { + if (!projectNumber || !statusOptionName) { + return; + } + try { + // Resolve the project, its Status field + options, and the issue node id. + const data = await this.github.graphql( + `query($org: String!, $number: Int!, $repo: String!, $issue: Int!) { + organization(login: $org) { + projectV2(number: $number) { + id + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + } + } + repository(owner: $org, name: $repo) { + issue(number: $issue) { id } + } + }`, + { org: this.org, number: projectNumber, repo: this.repo, issue: this.issueNumber }, + ); + + const project = data.organization && data.organization.projectV2; + const issueId = data.repository && data.repository.issue && data.repository.issue.id; + if (!project || !issueId) { + console.log(`Could not resolve project #${projectNumber} or issue node id; skipping Status update.`); + return; + } + + const statusField = project.field; + if (!statusField || !statusField.options) { + console.log(`Project #${projectNumber} has no single-select "Status" field; skipping Status update.`); + return; + } + + const option = statusField.options.find( + (o) => o.name.trim().toLowerCase() === statusOptionName.trim().toLowerCase(), + ); + if (!option) { + console.log( + `Project #${projectNumber} has no Status option matching "${statusOptionName}". ` + + `Available options: ${statusField.options.map((o) => o.name).join(", ")}. Skipping Status update.`, + ); + return; + } + + // Ensure the issue is on the board (idempotent - returns the existing item + // if it is already there), then set its Status. + const added = await this.github.graphql( + `mutation($project: ID!, $content: ID!) { + addProjectV2ItemById(input: { projectId: $project, contentId: $content }) { + item { id } + } + }`, + { project: project.id, content: issueId }, + ); + const itemId = added.addProjectV2ItemById.item.id; + + await this.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: itemId, field: statusField.id, option: option.id }, + ); + console.log(`Set project #${projectNumber} Status to "${option.name}".`); + } catch (err) { + console.error(`Failed to update project #${projectNumber} Status (continuing anyway):`, err.message || err); + } + } + // Helper to process comments and collect votes processComments() { // Reset all approval sets diff --git a/.github/workflows/lib/approval.test.js b/.github/workflows/lib/approval.test.js index 3fb071c..41f57e9 100644 --- a/.github/workflows/lib/approval.test.js +++ b/.github/workflows/lib/approval.test.js @@ -4,6 +4,7 @@ const ApprovalManager = require("./approval.js"); const mockGithub = { request: jest.fn(), paginate: jest.fn(), + graphql: jest.fn(), rest: { issues: { listComments: jest.fn(), @@ -334,6 +335,7 @@ describe("ApprovalManager", () => { repo: mockRepo, comment_id: 2, }); + expect(mockGithub.rest.issues.deleteComment).toHaveBeenCalledTimes(1); expect(mockGithub.rest.issues.updateComment).toHaveBeenCalledWith({ owner: mockOrg, repo: mockRepo, @@ -357,6 +359,71 @@ describe("ApprovalManager", () => { }); }); + describe("updateProjectStatus", () => { + const projectQueryResult = { + organization: { + projectV2: { + id: "PVT_1", + field: { + id: "FIELD_1", + options: [ + { id: "OPT_PROPOSED", name: "Proposed" }, + { id: "OPT_ACCEPTED", name: "Accepted" }, + ], + }, + }, + }, + repository: { issue: { id: "ISSUE_1" } }, + }; + + it("adds the issue to the board and sets the matching Status option", async () => { + mockGithub.graphql + .mockResolvedValueOnce(projectQueryResult) + .mockResolvedValueOnce({ addProjectV2ItemById: { item: { id: "ITEM_1" } } }) + .mockResolvedValueOnce({ updateProjectV2ItemFieldValue: { projectV2Item: { id: "ITEM_1" } } }); + + await approvalManager.updateProjectStatus(104, "Accepted"); + + expect(mockGithub.graphql).toHaveBeenCalledTimes(3); + const [, addVars] = mockGithub.graphql.mock.calls[1]; + expect(addVars).toEqual({ project: "PVT_1", content: "ISSUE_1" }); + const [, updateVars] = mockGithub.graphql.mock.calls[2]; + expect(updateVars).toEqual({ project: "PVT_1", item: "ITEM_1", field: "FIELD_1", option: "OPT_ACCEPTED" }); + }); + + it("matches the Status option case-insensitively", async () => { + mockGithub.graphql + .mockResolvedValueOnce(projectQueryResult) + .mockResolvedValueOnce({ addProjectV2ItemById: { item: { id: "ITEM_1" } } }) + .mockResolvedValueOnce({ updateProjectV2ItemFieldValue: { projectV2Item: { id: "ITEM_1" } } }); + + await approvalManager.updateProjectStatus(104, " proposed "); + + const [, updateVars] = mockGithub.graphql.mock.calls[2]; + expect(updateVars.option).toBe("OPT_PROPOSED"); + }); + + it("does nothing when the option name is missing", async () => { + await approvalManager.updateProjectStatus(104, undefined); + expect(mockGithub.graphql).not.toHaveBeenCalled(); + }); + + it("does not set a value when no option matches", async () => { + mockGithub.graphql.mockResolvedValueOnce(projectQueryResult); + + await approvalManager.updateProjectStatus(104, "Nonexistent"); + + // Only the lookup query runs; no add/update mutations. + expect(mockGithub.graphql).toHaveBeenCalledTimes(1); + }); + + it("swallows GraphQL errors so the core automation is unaffected", async () => { + mockGithub.graphql.mockRejectedValueOnce(new Error("Resource not accessible by integration")); + + await expect(approvalManager.updateProjectStatus(104, "Accepted")).resolves.toBeUndefined(); + }); + }); + describe("processComments", () => { beforeEach(() => { approvalManager.coreTeamMembers = ["core1", "core2", "core3"]; diff --git a/.github/workflows/pipeline_proposals.yml b/.github/workflows/pipeline_proposals.yml index 8598275..b0b249b 100644 --- a/.github/workflows/pipeline_proposals.yml +++ b/.github/workflows/pipeline_proposals.yml @@ -33,6 +33,18 @@ jobs: const org = context.repo.owner; const repo = context.repo.repo; + // Project board (GitHub Project v2) to mirror the approval status onto. + const PROJECT_NUMBER = 104; // nf-core pipelines proposals board + // Maps the internal approval status to the name of the Status + // single-select option on the project board. Update the option names + // on the right to match the board's configured Status options. + const PROJECT_STATUS = { + '🕐 Pending': 'proposed', + '✅ Approved': 'accepted', + '❌ Rejected': 'turned-down', + '⏰ Timed Out': 'timed-out', + }; + // Initialize approval manager const approvalManager = await new ApprovalManager(github, org, repo, issueNumber).initialize(); @@ -84,3 +96,4 @@ jobs: await approvalManager.updateStatusComment(statusBody); await approvalManager.updateIssueStatus(status); + await approvalManager.updateProjectStatus(PROJECT_NUMBER, PROJECT_STATUS[status]); diff --git a/README.md b/README.md index 29e41fd..53e6a07 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ To make a new proposal, please create a new issue in the repository following th - [x] Issue creation triggers automation that: - Adds the 'proposed' label - Creates a status comment tracking approvals + - Sets the [project board](https://github.com/orgs/nf-core/projects/104) `Status` to 'proposed' - [x] Team members use `/approve` or `/reject` commands in comments -- [x] Automation updates status comment and labels based on approvals +- [x] Automation updates status comment, labels and the project board `Status` based on approvals ('accepted' / 'turned-down' / 'timed-out') - [x] Acceptance requires either: - Two core team members - One core team member + one maintainer @@ -84,6 +85,11 @@ The curator workflow is as follows: ## Developer Documentation +> [!NOTE] +> The project-board `Status` automation requires a one-time org setup step +> (granting the bot project access). See +> [`.github/AUTOMATION_SETUP.md`](.github/AUTOMATION_SETUP.md). + ### Approval Automation Testing The repository includes automated testing for the approval workflows to ensure reliability and correctness.