-
-
Notifications
You must be signed in to change notification settings - Fork 409
fix(#11324): recognise closing keywords on non-default-branch PRs #11332
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,7 +95,86 @@ const matchesLicense = (prBody, template) => { | |
| return bodyLicenseSections.every(section => section.content.startsWith(templateLicenseSection.content)); | ||
| }; | ||
|
|
||
| const getLinkedIssues = async (github, context) => { | ||
| const MAX_LINKED_ISSUES = 20; | ||
|
|
||
| // GitHub's documented closing keywords, and the three reference forms they accept: | ||
| // `#123`, `owner/repo#123`, and a full issue URL. | ||
| // https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue | ||
| const CLOSING_KEYWORDS = 'close[sd]?|fix(?:e[sd])?|resolve[sd]?'; | ||
| const REPO_NAME = String.raw`[\w.-]+`; | ||
| const CLOSING_REFERENCE_REGEX = new RegExp( | ||
| String.raw`\b(?:${CLOSING_KEYWORDS})\b\s*:?\s+` + | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (non-blocking): Both quantifiers match whitespace, so the alternation is ambiguous. Measured against this exact regex, with GitHub's body limit is 65536, this runs under Same line, much smaller: |
||
| String.raw`(?:https?://github\.com/(${REPO_NAME})/(${REPO_NAME})/issues/(\d+)` + | ||
| String.raw`|(?:(${REPO_NAME})/(${REPO_NAME}))?#(\d+))`, | ||
| 'gi' | ||
| ); | ||
|
|
||
| // GitHub only populates closingIssuesReferences from keywords when the PR targets the | ||
| // repository's default branch; on any other base the field is empty even though the | ||
| // contributor linked the issue correctly. Parsing the body covers that case. | ||
| const parseClosingReferences = (body, context) => { | ||
| const matches = stripComments(body || '').matchAll(CLOSING_REFERENCE_REGEX); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (blocking): code spans aren't excluded, so the gate can pass an unlinked PR
The first three are prose inside backticks. GitHub links none of them (checked against Since Side note: blockquotes are stripped, but GitHub does linkify inside them. Ignoring quoted text seems deliberate and it's defensible, it just isn't stated anywhere. |
||
| const references = [...matches].map(([, urlOwner, urlRepo, urlNumber, owner, repo, number]) => ({ | ||
| owner: urlOwner || owner || context.repo.owner, | ||
| repo: urlRepo || repo || context.repo.repo, | ||
| number: Number(urlNumber || number), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (non-blocking): Two verified behaviours: What I did not verify is what GitHub returns for a non-integer path segment. If it's anything other than 404/410 the status isn't in |
||
| })); | ||
|
|
||
| // Owner and repo names are case-insensitive on GitHub, so the key is too. | ||
| const seen = new Set(); | ||
| return references.filter(reference => { | ||
| const key = `${reference.owner}/${reference.repo}#${reference.number.toString()}`.toLowerCase(); | ||
| if (seen.has(key)) { | ||
| return false; | ||
| } | ||
| seen.add(key); | ||
| return true; | ||
| }); | ||
| }; | ||
|
|
||
| // Shapes a REST issue like a closingIssuesReferences node so the callers can't tell them apart. | ||
| // The names come from repository_url where possible, so they carry GitHub's canonical casing | ||
| // rather than whatever the contributor typed. | ||
| const toIssueNode = (issue, reference) => { | ||
| const [, owner, repo] = /\/repos\/([^/]+)\/([^/]+)$/.exec(issue.repository_url || '') || []; | ||
| const nameWithOwner = `${owner || reference.owner}/${repo || reference.repo}`; | ||
| return { | ||
| number: issue.number, | ||
| repository: { nameWithOwner, owner: { login: owner || reference.owner } }, | ||
| assignees: { nodes: (issue.assignees || []).map(assignee => ({ login: assignee.login })) }, | ||
| }; | ||
| }; | ||
|
|
||
| // A referenced issue that does not exist is not a link, so 404 (and 410, for issues that were | ||
| // transferred or deleted) means the reference simply does not count. Anything else is left to | ||
| // throw: the job goes red without a comment or a label change, exactly as it already does for | ||
| // every other API call here, and the next `synchronize` re-runs it. Swallowing those would be | ||
| // the one path that can hand a genuinely unlinked PR its `Ready for review` label. | ||
| const MISSING_ISSUE_STATUSES = new Set([404, 410]); | ||
|
|
||
| const resolveReferencedIssues = async (github, context, core, references) => { | ||
| const issues = await Promise.all(references.map(async (reference) => { | ||
| try { | ||
| const { data } = await github.rest.issues.get({ | ||
| owner: reference.owner, | ||
| repo: reference.repo, | ||
| issue_number: reference.number, | ||
| }); | ||
| // A pull request is also an issue on this endpoint, but referencing one is not a link. | ||
| return data.pull_request ? null : toIssueNode(data, reference); | ||
| } catch (err) { | ||
| if (!MISSING_ISSUE_STATUSES.has(err.status)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question (non-blocking): does 404 always mean "doesn't exist"? GitHub also returns 404 when the token can't see the repository, and I didn't test this, since it needs a private in-org repo. If it holds, surfacing dropped references in the comment rather than only the log would make it diagnosable. |
||
| throw err; | ||
| } | ||
| const name = `${reference.owner}/${reference.repo}#${reference.number.toString()}`; | ||
| core.info(`Ignoring referenced issue ${name}: not found.`); | ||
| return null; | ||
| } | ||
| })); | ||
| return issues.filter(Boolean); | ||
| }; | ||
|
|
||
| const getLinkedIssues = async (github, context, core) => { | ||
| const query = ` | ||
| query ($owner: String!, $repo: String!, $number: Int!) { | ||
| repository(owner: $owner, name: $repo) { | ||
|
|
@@ -121,9 +200,20 @@ const getLinkedIssues = async (github, context) => { | |
| number: context.payload.pull_request.number, | ||
| }); | ||
| // Issues linked from repositories outside the org don't count as linked. | ||
| return result.repository.pullRequest.closingIssuesReferences.nodes.filter( | ||
| issue => issue.repository.owner.login === context.repo.owner | ||
| ); | ||
| const isInOrg = owner => owner.toLowerCase() === context.repo.owner.toLowerCase(); | ||
| const linkedIssues = result.repository.pullRequest.closingIssuesReferences.nodes | ||
| .filter(issue => isInOrg(issue.repository.owner.login)); | ||
| if (linkedIssues.length) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (blocking): the fallback exits one condition too early This short-circuits on The field has two independent feeds: closing keywords, which don't register on non-default branches, and the Development sidebar, which works on any branch. So on a non-default-branch PR a sidebar-linked epic makes this non-empty while the contributor's Being straight about evidence: this is a mechanism, not something I've seen happen. Switching the condition costs nothing in the common case: The body is then read only when the bot is about to fail the PR anyway. Needs |
||
| return linkedIssues; | ||
| } | ||
|
|
||
| const references = parseClosingReferences(context.payload.pull_request.body, context) | ||
| .filter(reference => isInOrg(reference.owner)) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question (non-blocking): should the org gate run before resolution? This filters on the owner string the contributor typed, whereas the GraphQL path at I couldn't test this: it needs a repo that has actually transferred out of |
||
| .slice(0, MAX_LINKED_ISSUES); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (non-blocking): truncation is silent and untested References past the 20th are dropped with no log line. If the issue the author is assigned to sits 21st in document order they get "You are not assigned to the linked issue" listing 20 issues they aren't assigned to, and only reordering the body clears it. Nothing exercises this either, so the |
||
| if (!references.length) { | ||
| return []; | ||
| } | ||
| return resolveReferencedIssues(github, context, core, references); | ||
| }; | ||
|
|
||
| const getLinkedIssueFailure = (pr, linkedIssues) => { | ||
|
|
@@ -144,7 +234,7 @@ const getLinkedIssueFailure = (pr, linkedIssues) => { | |
| return getMessage('not-assigned', { issueList }); | ||
| }; | ||
|
|
||
| const getFailures = async (github, context) => { | ||
| const getFailures = async (github, context, core) => { | ||
| const pr = context.payload.pull_request; | ||
| const failures = []; | ||
|
|
||
|
|
@@ -157,7 +247,7 @@ const getFailures = async (github, context) => { | |
| failures.push(getMessage('license-changed')); | ||
| } | ||
|
|
||
| const linkedIssues = await getLinkedIssues(github, context); | ||
| const linkedIssues = await getLinkedIssues(github, context, core); | ||
| const linkedIssueFailure = getLinkedIssueFailure(pr, linkedIssues); | ||
| if (linkedIssueFailure) { | ||
| failures.push(linkedIssueFailure); | ||
|
|
@@ -278,7 +368,7 @@ const runAndraBot = async ({ github, context, core }) => { | |
| return; | ||
| } | ||
|
|
||
| const failures = await getFailures(github, context); | ||
| const failures = await getFailures(github, context, core); | ||
| const existingComment = await findExistingComment(github, context); | ||
|
|
||
| if (!failures.length) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,6 +64,14 @@ describe('AndraBot', () => { | |
| .replace('<!-- DESCRIPTION -->', 'Fixes the date conversion by using the local format.') | ||
| .replace('<!-- ISSUE NUMBER -->', 'Closes #1234'); | ||
|
|
||
| // Same as filledTemplate but with no issue reference at all, for the cases that need a body | ||
| // the closing-keyword fallback cannot resolve. | ||
| const bodyWithoutIssue = TEMPLATE | ||
| .replace('<!-- DESCRIPTION -->', 'Fixes the date conversion by using the local format.') | ||
| .replace('<!-- ISSUE NUMBER -->', 'No issue for this one.'); | ||
|
|
||
| const withIssueReference = (reference) => filledTemplate.replace('Closes #1234', reference); | ||
|
|
||
| const linkedIssue = (number, assigneeLogins, repo = 'medic/cht-core') => ({ | ||
| number, | ||
| repository: { nameWithOwner: repo, owner: { login: repo.split('/')[0] } }, | ||
|
|
@@ -76,6 +84,26 @@ describe('AndraBot', () => { | |
| }); | ||
| }; | ||
|
|
||
| // The REST shape returned by issues.get, which the fallback normalizes. | ||
| // GitHub resolves owner/repo case-insensitively, so the stub matches the same way — the | ||
| // action passes through whatever the contributor typed. | ||
| const matchesIssue = (owner, repo, number) => sinon.match(args => { | ||
| return args.owner.toLowerCase() === owner.toLowerCase() && | ||
| args.repo.toLowerCase() === repo.toLowerCase() && | ||
| args.issue_number === number; | ||
| }); | ||
|
|
||
| const setReferencedIssue = ({ owner = 'medic', repo = 'cht-core', number, assignees = [], isPr = false }) => { | ||
| return github.rest.issues.get | ||
| .withArgs(matchesIssue(owner, repo, number)) | ||
| .resolves({ data: { | ||
| number, | ||
| repository_url: `https://api.github.com/repos/${owner}/${repo}`, | ||
| assignees: assignees.map(login => ({ login })), | ||
| ...(isPr ? { pull_request: { url: 'https://api.github.com/pulls/1' } } : {}), | ||
| } }); | ||
| }; | ||
|
|
||
| const setComments = (comments) => github.paginate | ||
| .withArgs(github.rest.issues.listComments) | ||
| .resolves(comments); | ||
|
|
@@ -98,6 +126,8 @@ describe('AndraBot', () => { | |
| listLabelsOnIssue: sinon.stub(), | ||
| addLabels: sinon.stub().resolves(), | ||
| removeLabel: sinon.stub().resolves(), | ||
| // Not found by default; tests that exercise the fallback opt in via setReferencedIssue. | ||
| get: sinon.stub().rejects(Object.assign(new Error('Not Found'), { status: 404 })), | ||
| }, | ||
| }, | ||
| }; | ||
|
|
@@ -305,14 +335,15 @@ describe('AndraBot', () => { | |
|
|
||
| describe('linked issue check', () => { | ||
| it('should fail when no issue is linked', async () => { | ||
| await run(getPr({ body: filledTemplate })); | ||
| await run(getPr({ body: bodyWithoutIssue })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| expect(commentBody).to.not.contain(templateMismatchMessage()); | ||
| }); | ||
|
|
||
|
|
||
| it('should query the PR from the event payload', async () => { | ||
| await run(getPr({ body: filledTemplate })); | ||
|
|
||
|
|
@@ -334,12 +365,182 @@ describe('AndraBot', () => { | |
|
|
||
| it('should not count an issue linked from a repo outside the org', async () => { | ||
| setLinkedIssues([linkedIssue(1234, ['external-dev'], 'external-dev/cht-core')]); | ||
| await run(getPr({ body: bodyWithoutIssue })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| }); | ||
| }); | ||
|
|
||
| // GitHub only populates closingIssuesReferences for PRs targeting the default branch, so a | ||
| // correctly keyword-linked PR on any other base arrives here with an empty list. | ||
| describe('closing-keyword fallback for non-default-branch PRs', () => { | ||
| it('should accept a keyword-linked issue when GitHub reports no linkage', async () => { | ||
| setReferencedIssue({ number: 1234, assignees: ['external-dev'] }); | ||
|
|
||
| await run(getPr({ body: filledTemplate })); | ||
|
|
||
| expect(core.setFailed.called).to.be.false; | ||
| expect(github.rest.issues.get.calledOnceWithExactly({ | ||
| owner: 'medic', | ||
| repo: 'cht-core', | ||
| issue_number: 1234, | ||
| })).to.be.true; | ||
| }); | ||
|
|
||
| it('should still report the assignee failure for a keyword-linked issue', async () => { | ||
| setReferencedIssue({ number: 1234, assignees: ['someone-else'] }); | ||
|
|
||
| await run(getPr({ body: filledTemplate })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('not-assigned', { issueList: '#1234' })); | ||
| }); | ||
|
|
||
| ['Closes #1234', 'closes: #1234', 'Fixes #1234', 'resolved #1234'].forEach(reference => { | ||
| it(`should recognise "${reference}"`, async () => { | ||
| setReferencedIssue({ number: 1234, assignees: ['external-dev'] }); | ||
|
|
||
| await run(getPr({ body: withIssueReference(reference) })); | ||
|
|
||
| expect(core.setFailed.called).to.be.false; | ||
| }); | ||
| }); | ||
|
|
||
| it('should recognise an owner/repo#number reference in the same org', async () => { | ||
| setReferencedIssue({ repo: 'cht-android', number: 99, assignees: ['external-dev'] }); | ||
|
|
||
| await run(getPr({ body: withIssueReference('Closes medic/cht-android#99') })); | ||
|
|
||
| expect(core.setFailed.called).to.be.false; | ||
| }); | ||
|
|
||
| it('should recognise a full issue URL', async () => { | ||
| setReferencedIssue({ repo: 'cht-android', number: 99, assignees: ['external-dev'] }); | ||
|
|
||
| await run(getPr({ body: withIssueReference('Closes https://github.com/medic/cht-android/issues/99') })); | ||
|
|
||
| expect(core.setFailed.called).to.be.false; | ||
| }); | ||
|
|
||
| it('should ignore a reference to a repo outside the org', async () => { | ||
| await run(getPr({ body: withIssueReference('Closes external-dev/cht-core#1234') })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| // Reaching out to a repo outside the org is itself the thing to avoid, not just an | ||
| // implementation detail — the token has no business reading it. | ||
| expect(github.rest.issues.get.called).to.be.false; | ||
| }); | ||
|
|
||
| it('should ignore references inside HTML comments', async () => { | ||
| await run(getPr({ body: withIssueReference('<!-- Closes #1234 -->') })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| }); | ||
|
|
||
| it('should ignore the example reference in the unfilled template', async () => { | ||
| // The template's own comment block contains "feat(#1234): add hat wobble"; an empty | ||
| // template must not read as a linked PR. | ||
| setReferencedIssue({ number: 1234, assignees: ['external-dev'] }); | ||
|
|
||
| await run(getPr({ body: TEMPLATE })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| }); | ||
|
|
||
| it('should ignore a reference that points at a pull request', async () => { | ||
| setReferencedIssue({ number: 1234, assignees: ['external-dev'], isPr: true }); | ||
|
|
||
| await run(getPr({ body: filledTemplate })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| }); | ||
|
|
||
| it('should ignore a reference to an issue that does not exist', async () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (non-blocking): this test and the 410 one pass vacuously Both rely on the I checked by mutation: at PR head the suite is 67 passing; making That's the same class as the |
||
| await run(getPr({ body: filledTemplate })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| }); | ||
|
|
||
| // GitHub owner and repo names are case-insensitive, so a reference that differs only in | ||
| // case is still a valid link and must not be dropped. | ||
| ['Closes Medic/cht-core#1234', 'Closes MEDIC/CHT-Core#1234'].forEach(reference => { | ||
| it(`should accept "${reference}" regardless of case`, async () => { | ||
| setReferencedIssue({ number: 1234, assignees: ['external-dev'] }); | ||
|
|
||
| await run(getPr({ body: withIssueReference(reference) })); | ||
|
|
||
| expect(core.setFailed.called).to.be.false; | ||
| }); | ||
| }); | ||
|
|
||
| it('should report a same-repo issue as #number even when referenced with different case', async () => { | ||
| setReferencedIssue({ number: 1234, assignees: ['someone-else'] }); | ||
|
|
||
| await run(getPr({ body: withIssueReference('Closes MEDIC/CHT-Core#1234') })); | ||
|
|
||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('not-assigned', { issueList: '#1234' })); | ||
| }); | ||
|
|
||
| it('should ignore a reference to an issue that was deleted or transferred', async () => { | ||
| github.rest.issues.get.rejects(Object.assign(new Error('Gone'), { status: 410 })); | ||
|
|
||
| await run(getPr({ body: filledTemplate })); | ||
|
|
||
| expect(core.setFailed.calledOnce).to.be.true; | ||
| const commentBody = github.rest.issues.createComment.args[0][0].body; | ||
| expect(commentBody).to.contain(getMessage('missing-linked-issue')); | ||
| }); | ||
|
|
||
| /* | ||
| * Anything other than "the issue is not there" is left to throw, so the job goes red with | ||
| * no comment and no label change and the next synchronize re-runs it. Swallowing these is | ||
| * the one path that could hand a genuinely unlinked PR its Ready for review label. | ||
| */ | ||
| describe('when the issue lookup fails for another reason', () => { | ||
| [500, 403].forEach(status => { | ||
| it(`should propagate a ${status.toString()} rather than treat it as unlinked`, async () => { | ||
| const err = Object.assign(new Error('Server Error'), { status }); | ||
| github.rest.issues.get.rejects(err); | ||
|
|
||
| await expect(run(getPr({ body: filledTemplate }))).to.be.rejectedWith('Server Error'); | ||
|
|
||
| expect(github.rest.issues.createComment.called).to.be.false; | ||
| expect(github.rest.issues.addLabels.called).to.be.false; | ||
| expect(github.rest.issues.removeLabel.called).to.be.false; | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| it('should look each referenced issue up only once', async () => { | ||
| setReferencedIssue({ number: 1234, assignees: ['external-dev'] }); | ||
|
|
||
| await run(getPr({ body: withIssueReference('Closes #1234, closes #1234') })); | ||
|
|
||
| expect(github.rest.issues.get.calledOnce).to.be.true; | ||
| }); | ||
|
|
||
| it('should not fall back when GitHub already reports a linked issue', async () => { | ||
| setLinkedIssues([linkedIssue(1234, ['external-dev'])]); | ||
|
|
||
| await run(getPr({ body: filledTemplate })); | ||
|
|
||
| expect(github.rest.issues.get.called).to.be.false; | ||
| expect(core.setFailed.called).to.be.false; | ||
| }); | ||
| }); | ||
|
|
||
| describe('labelling', () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question (if-minor): should
REPO_NAMEaccept.and..?[\w.-]+matches.., soCloses medic/..#1parses asowner=medic repo=.. num=1(verified) and passesisInOrg, since the owner really ismedic. It then reachesissues.getwithrepo: '..'.I haven't checked what the client does with that path, so I'm not claiming an impact beyond a stray 404. It's contributor-controlled text going into an API path in a privileged workflow, so requiring at least one non-dot character seems worth the two characters it costs.