Repository navigation
ci: require accepted issues for community pull requests #4340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| name: PR Issue Acceptance | ||
|
|
||
| on: | ||
| pull_request_target: | ||
| types: [opened, edited, reopened, synchronize] | ||
|
|
||
| permissions: | ||
| contents: read | ||
| issues: read | ||
| pull-requests: read | ||
|
|
||
| concurrency: | ||
| group: pr-issue-acceptance-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| issue-acceptance: | ||
| name: OpenShell / PR Issue Acceptance | ||
| timeout-minutes: 5 | ||
| if: github.repository_owner == 'NVIDIA' | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| # pull_request_target supplies the trusted default-branch SHA. Load | ||
| # only that helper; PR metadata is data, never executable code. | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| ref: ${{ github.sha }} | ||
| persist-credentials: false | ||
| sparse-checkout: tasks/scripts/check_issue_acceptance.cjs | ||
| sparse-checkout-cone-mode: false | ||
|
|
||
| - name: Check author access and linked issue acceptance | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | ||
| with: | ||
| script: | | ||
| const check = require('./tasks/scripts/check_issue_acceptance.cjs'); | ||
| await check({ github, context, core }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| module.exports = async function checkIssueAcceptance({ github, context, core }) { | ||
| const { owner, repo } = context.repo; | ||
| const { data: pr } = await github.rest.pulls.get({ | ||
|
elezar marked this conversation as resolved.
|
||
| owner, repo, pull_number: context.payload.pull_request.number, | ||
| }); | ||
| const author = pr.user.login; | ||
|
|
||
| async function evaluate() { | ||
| try { | ||
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner, repo, username: author, | ||
| }); | ||
| // The REST API maps maintain to write and triage to read. | ||
| if (['write', 'maintain', 'admin'].includes(data.permission)) { | ||
| core.info(`${author} has repository write access; acceptance gate passed.`); | ||
| return; | ||
| } | ||
| } catch (error) { | ||
| if (error.status !== 404) throw error; | ||
| } | ||
|
|
||
| const issues = []; | ||
| let cursor = null; | ||
| do { | ||
| const result = await github.graphql(` | ||
| query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { | ||
| repository(owner: $owner, name: $repo) { | ||
| pullRequest(number: $number) { | ||
| closingIssuesReferences(first: 100, after: $cursor) { | ||
| nodes { number url repository { name owner { login } } } | ||
| pageInfo { hasNextPage endCursor } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `, { owner, repo, number: pr.number, cursor }); | ||
| const linked = result.repository.pullRequest.closingIssuesReferences; | ||
| issues.push(...linked.nodes); | ||
| cursor = linked.pageInfo.hasNextPage ? linked.pageInfo.endCursor : null; | ||
| } while (cursor); | ||
|
|
||
| if (issues.length === 0) { | ||
| return 'Community PRs must link an issue with state:accepted using ' | ||
| + 'Fixes #NNN or Closes #NNN.'; | ||
| } | ||
|
|
||
| const unaccepted = []; | ||
| for (const issue of issues) { | ||
| if (`${issue.repository.owner.login}/${issue.repository.name}`.toLowerCase() | ||
| !== `${owner}/${repo}`.toLowerCase()) { | ||
| return `Acceptance must be recorded in ${owner}/${repo}, not ${issue.url}.`; | ||
| } | ||
| const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, { | ||
| owner: issue.repository.owner.login, | ||
| repo: issue.repository.name, | ||
| issue_number: issue.number, | ||
| per_page: 100, | ||
| }); | ||
| if (!labels.some(label => label.name === 'state:accepted')) { | ||
| unaccepted.push(issue.url); | ||
| } | ||
| } | ||
|
|
||
| if (unaccepted.length) { | ||
| return 'Community PRs require state:accepted on every linked issue. ' | ||
| + `Ask a maintainer to accept: ${unaccepted.join(', ')}. ` | ||
| + 'After acceptance, rerun this workflow.'; | ||
| } | ||
| core.info(`All ${issues.length} linked issue(s) have state:accepted.`); | ||
| } | ||
|
|
||
| const failure = await evaluate(); | ||
| if (failure) core.setFailed(failure); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| const assert = require('node:assert/strict'); | ||
| const test = require('node:test'); | ||
| const checkIssueAcceptance = require('./check_issue_acceptance.cjs'); | ||
|
|
||
| async function check({ | ||
| permission = 'read', issues = [], body = '', permissionError, | ||
| number = 7, headSha = 'pr-head', pullError, issueError, labelError, | ||
| } = {}) { | ||
| const failures = []; | ||
| const github = { | ||
| rest: { | ||
| pulls: { | ||
| get: async ({ pull_number }) => { | ||
| assert.equal(pull_number, number); | ||
| if (pullError) throw new Error('PR lookup failed'); | ||
| return { data: { | ||
| number, user: { login: 'contributor' }, head: { sha: headSha }, body, | ||
| } }; | ||
| }, | ||
| }, | ||
| repos: { | ||
| getCollaboratorPermissionLevel: async ({ username }) => { | ||
| assert.equal(username, 'contributor'); | ||
| if (permissionError) throw Object.assign(new Error('lookup failed'), { status: permissionError }); | ||
| return { data: { permission } }; | ||
| }, | ||
| createCommitStatus() { assert.fail('PR decisions must not publish commit statuses'); }, | ||
| }, | ||
| issues: { listLabelsOnIssue() {} }, | ||
| }, | ||
| graphql: async (_query, variables) => { | ||
| assert.equal(variables.number, number); | ||
| if (issueError) throw new Error('Issue lookup failed'); | ||
| return { repository: { pullRequest: { closingIssuesReferences: { | ||
| nodes: issues.map(issue => ({ | ||
| number: issue.number, | ||
| url: `https://github.com/${issue.owner || 'NVIDIA'}/OpenShell/issues/${issue.number}`, | ||
| repository: { name: 'OpenShell', owner: { login: issue.owner || 'NVIDIA' } }, | ||
| })), | ||
| pageInfo: { hasNextPage: false, endCursor: null }, | ||
| } } } }; | ||
| }, | ||
| paginate: async (_method, { issue_number }) => { | ||
| if (labelError) throw new Error('Label lookup failed'); | ||
| return issues.find(issue => issue.number === issue_number).labels || []; | ||
| }, | ||
| }; | ||
| await checkIssueAcceptance({ | ||
| github, | ||
| context: { | ||
| repo: { owner: 'NVIDIA', repo: 'OpenShell' }, | ||
| payload: { pull_request: { number } }, | ||
| actor: 'maintainer', sha: 'base-commit', serverUrl: 'https://github.com', runId: 42, | ||
| }, | ||
| core: { info() {}, setFailed: message => failures.push(message) }, | ||
| }); | ||
| return failures; | ||
| } | ||
|
|
||
| for (const permission of ['write', 'maintain', 'admin']) { | ||
| test(`${permission} access allows an issue-less PR`, async () => { | ||
| assert.deepEqual(await check({ permission }), []); | ||
| }); | ||
| } | ||
|
|
||
| for (const body of ['', 'No issue required: typo fix', '<!-- No issue required: example -->']) { | ||
| test(`community PR without linked issues fails for body ${JSON.stringify(body)}`, async () => { | ||
| const failures = await check({ body }); | ||
| assert.equal(failures.length, 1); | ||
| assert.match(failures[0], /must link an issue with state:accepted/); | ||
| }); | ||
| } | ||
|
|
||
| test('all linked issues must be accepted, regardless of PR body or triggering actor', async () => { | ||
| const failures = await check({ | ||
| body: 'No issue required: small fix', | ||
| issues: [{ number: 1, labels: [{ name: 'state:accepted' }] }, { number: 2 }], | ||
| }); | ||
| assert.equal(failures.length, 1); | ||
| assert.match(failures[0], /issues\/2/); | ||
| }); | ||
|
|
||
| test('accepted issues in the target repository pass', async () => { | ||
| assert.deepEqual(await check({ issues: [{ number: 1, labels: [{ name: 'state:accepted' }] }] }), []); | ||
| }); | ||
|
|
||
| test('accepted issues in another repository cannot bypass the gate', async () => { | ||
| const failures = await check({ issues: [{ number: 1, owner: 'contributor', labels: [{ name: 'state:accepted' }] }] }); | ||
| assert.equal(failures.length, 1); | ||
| assert.match(failures[0], /Acceptance must be recorded in NVIDIA\/OpenShell/); | ||
| }); | ||
|
|
||
| test('a non-collaborator still needs an accepted issue', async () => { | ||
| assert.equal((await check({ permissionError: 404, body: 'No issue required: typo fix' })).length, 1); | ||
| }); | ||
|
|
||
| test('permission lookup failures do not grant an exemption', async () => { | ||
| await assert.rejects(check({ permissionError: 403 }), /lookup failed/); | ||
| }); | ||
|
|
||
| for (const [option, message] of [ | ||
| ['pullError', /PR lookup failed/], | ||
| ['issueError', /Issue lookup failed/], | ||
| ['labelError', /Label lookup failed/], | ||
| ]) { | ||
| test(`${option} fails the workflow invocation`, async () => { | ||
| await assert.rejects(check({ [option]: true, issues: [{ number: 1 }] }), message); | ||
| }); | ||
| } | ||
|
|
||
| test('PRs sharing a head SHA evaluate their own issue references in either order', async () => { | ||
| const rejected = { number: 7, headSha: 'shared-head', issues: [{ number: 1 }] }; | ||
| const accepted = { | ||
| number: 8, headSha: 'shared-head', | ||
| issues: [{ number: 2, labels: [{ name: 'state:accepted' }] }], | ||
| }; | ||
| for (const pair of [[rejected, accepted], [accepted, rejected]]) { | ||
| const results = await Promise.all(pair.map(input => check(input))); | ||
| pair.forEach((input, index) => { | ||
| assert.equal(results[index].length, input.number === 7 ? 1 : 0); | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| test('a failed refresh rejects after an earlier successful evaluation', async () => { | ||
| assert.deepEqual(await check({ permission: 'write' }), []); | ||
| await assert.rejects(check({ permission: 'write', pullError: true }), /PR lookup failed/); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.