Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/AUTOMATION_SETUP.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 90 additions & 0 deletions .github/workflows/lib/approval.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions .github/workflows/lib/approval.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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"];
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/pipeline_proposals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -84,3 +96,4 @@ jobs:

await approvalManager.updateStatusComment(statusBody);
await approvalManager.updateIssueStatus(status);
await approvalManager.updateProjectStatus(PROJECT_NUMBER, PROJECT_STATUS[status]);
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading