From 41d373168d5ee74fff8520c639e3d41a978a59bf Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Mon, 28 Sep 2026 20:05:05 +0300 Subject: [PATCH 1/9] Act on the issue a community pull request closes A community pull request got the same reply whether or not it linked an issue, and whether or not that issue was already assigned to someone else. Maintainers closed the wrong ones by hand and requested review by hand. The reply now branches on the linked issues. A pull request on an issue assigned to someone else gets a closing message and is closed. One on an issue assigned to its author gets rtibblesbot requested and the community-review label. Anything else keeps the previous behaviour. Links come from closingIssuesReferences, so only a closing keyword counts. A failed lookup falls back to the plain reply rather than closing anything. The rtibblesbot and community review notes are folded into the opening reply, so a contributor reads one comment instead of three. review-requested and pull-request-label still post their note when a person requests the review or adds the label, and skip it when the bot did. --- docs/community-automations.md | 13 +- scripts/constants.js | 6 +- scripts/contributor-pr-reply.js | 50 +++++++- scripts/contributor-pr-reply.test.js | 185 +++++++++++++++++++++++++++ scripts/pull-request-label.js | 11 +- scripts/pull-request-label.test.js | 55 ++++++++ scripts/review-requested.js | 11 +- scripts/review-requested.test.js | 55 ++++++++ scripts/utils.js | 45 +++++++ 9 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 scripts/contributor-pr-reply.test.js create mode 100644 scripts/pull-request-label.test.js create mode 100644 scripts/review-requested.test.js diff --git a/docs/community-automations.md b/docs/community-automations.md index 3bb48da..e7b5eac 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -61,10 +61,19 @@ In `scripts/constants.js` set: # `contributor-pr-reply` -Sends reply to a community pull requests. +Sends reply to a community pull requests, and acts on the issues the pull request closes. -In `scripts/contants.js` set: +Linked issues come from the GraphQL `closingIssuesReferences` field, so only a closing keyword counts. A bare `#123` links nothing, and the pull request falls to the last case below. + +- A linked issue is assigned to someone else, and none is assigned to the author: sends the closing message, closes the pull request, and notifies Slack. The reply is not sent +- A linked issue is assigned to the author: requests `rtibblesbot` for review, adds the `community-review` label, then sends the reply +- Anything else: sends the reply + +`review-requested` and `pull-request-label` post their own notes when a person requests the review or adds the label. They skip the note when the bot did it, because the reply already carries that text. + +In `scripts/constants.js` set: - `BOT_MESSAGE_PULL_REQUEST`: Message text +- `BOT_MESSAGE_PULL_REQUEST_CLOSED`: Closing message text # `holiday-message` diff --git a/scripts/constants.js b/scripts/constants.js index 60120dc..7517248 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -141,7 +141,10 @@ const BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE = ` to learn about the contributing process. 😊${GSOC_NOTE}`; const BOT_MESSAGE_PULL_REQUEST = author => - `👋 Hi @${author}, thanks for contributing! \n\n **For the review process to begin, please verify that the following is satisfied:**\n\n- [ ] **Contribution is aligned with our [contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base)**\n- [ ] **Pull request description has correctly filled _AI usage_ section & follows our AI guidance:**\n\n
\n AI guidance\n\n
\n\n **State explicitly whether you didn't use or used AI & how.**\n\n If you used it, ensure that the PR is aligned with [Using AI](https://learningequality.org/contributing-to-our-open-code-base/#using-generative-ai) as well as our DEEP framework. DEEP asks you:\n\n - **Disclose** — Be open about when you've used AI for support.\n - **Engage critically** — Question what is generated. Review code for correctness and unnecessary complexity.\n - **Edit** — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.\n - **Process sharing** — Explain how you used the AI so others can learn.\n\n
\n\n Examples of good disclosures:\n\n > "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."\n\n > "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."\n\n
\n\nAlso check that issue requirements are satisfied & you ran \`pre-commit\` locally. \n\n**Pull requests that don't follow the guidelines will be closed.**\n\n**Reviewer assignment can take up to 2 weeks.**`; + `👋 Hi @${author}, thanks for contributing! \n\n **For the review process to begin, please verify that the following is satisfied:**\n\n- [ ] **Contribution is aligned with our [contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base)**\n- [ ] **Pull request description has correctly filled _AI usage_ section & follows our AI guidance:**\n\n
\n AI guidance\n\n
\n\n **State explicitly whether you didn't use or used AI & how.**\n\n If you used it, ensure that the PR is aligned with [Using AI](https://learningequality.org/contributing-to-our-open-code-base/#using-generative-ai) as well as our DEEP framework. DEEP asks you:\n\n - **Disclose** — Be open about when you've used AI for support.\n - **Engage critically** — Question what is generated. Review code for correctness and unnecessary complexity.\n - **Edit** — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.\n - **Process sharing** — Explain how you used the AI so others can learn.\n\n
\n\n Examples of good disclosures:\n\n > "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."\n\n > "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."\n\n
\n\n**📢✨ Before we assign a reviewer:**\n\n- **\`@rtibblesbot\` will pre-review this pull request.** Its comments are generated by an LLM, and should be evaluated accordingly.\n- **We'll also invite community pre-review.** See the [community review guidance](https://github.com/learningequality/.github/blob/main/docs/community-review.md) for both authors and reviewers.\n\nAlso check that issue requirements are satisfied & you ran \`pre-commit\` locally.\n\n**Pull requests that don't follow the guidelines will be closed.**\n\n**Reviewer assignment can take up to 2 weeks.**`; + +const BOT_MESSAGE_PULL_REQUEST_CLOSED = (author, issueNumber) => + `👋 Hi @${author}, thanks for your interest! \n\n **We're closing this pull request because #${issueNumber} is assigned to someone else.** Visit [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base) to learn about the contributing process and how to find suitable issues. If there are no unassigned 'help wanted' issues available, please wait until new ones are added. \n\n We really appreciate your willingness to help. 😊${GSOC_NOTE}`; const HOLIDAY_MESSAGE = `Season's greetings! 👋 \n\n We'd like to thank everyone for another year of fruitful collaborations, engaging discussions, and for the continued support of our work. **Learning Equality will be on holidays from December 22 to January 5.** We look forward to much more in the new year and wish you a very happy holiday season!${GSOC_NOTE}`; @@ -180,6 +183,7 @@ module.exports = { BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE, BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE, BOT_MESSAGE_PULL_REQUEST, + BOT_MESSAGE_PULL_REQUEST_CLOSED, BOT_MESSAGE_RTIBBLESBOT_REVIEW, RTIBBLESBOT_USERNAME, LABEL_COMMUNITY_REVIEW, diff --git a/scripts/contributor-pr-reply.js b/scripts/contributor-pr-reply.js index 1d5081f..e5b9942 100644 --- a/scripts/contributor-pr-reply.js +++ b/scripts/contributor-pr-reply.js @@ -1,14 +1,60 @@ -const { BOT_MESSAGE_PULL_REQUEST } = require('./constants'); -const { sendBotMessage } = require('./utils'); +const { + BOT_MESSAGE_PULL_REQUEST, + BOT_MESSAGE_PULL_REQUEST_CLOSED, + LABEL_COMMUNITY_REVIEW, + RTIBBLESBOT_USERNAME, +} = require('./constants'); +const { sendBotMessage, getLinkedIssues } = require('./utils'); module.exports = async ({ github, context, core }) => { try { + const owner = context.repo.owner; const repo = context.repo.repo; const number = context.payload.pull_request.number; const url = context.payload.pull_request.html_url; const title = context.payload.pull_request.title; const author = context.payload.pull_request.user.login; + const linkedIssues = await getLinkedIssues(number, { github, context, core }); + const authorAssigned = linkedIssues.some(issue => issue.assignees.includes(author)); + const assignedElsewhere = linkedIssues.find( + issue => issue.assignees.length > 0 && !issue.assignees.includes(author), + ); + + if (!authorAssigned && assignedElsewhere) { + const botMessageUrl = await sendBotMessage( + number, + BOT_MESSAGE_PULL_REQUEST_CLOSED(author, assignedElsewhere.number), + { github, context, core }, + ); + await github.rest.pulls.update({ + owner, + repo, + pull_number: number, + state: 'closed', + }); + core.setOutput( + 'slack_notification', + `*[${repo}] <${botMessageUrl}|Closed> pull request on assigned issue #${assignedElsewhere.number}: <${url}|${title}>*`, + ); + return; + } + + if (authorAssigned) { + await github.rest.pulls.requestReviewers({ + owner, + repo, + pull_number: number, + reviewers: [RTIBBLESBOT_USERNAME], + }); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: number, + labels: [LABEL_COMMUNITY_REVIEW], + }); + } + const botMessageUrl = await sendBotMessage(number, BOT_MESSAGE_PULL_REQUEST(author), { github, context, diff --git a/scripts/contributor-pr-reply.test.js b/scripts/contributor-pr-reply.test.js new file mode 100644 index 0000000..ebc7344 --- /dev/null +++ b/scripts/contributor-pr-reply.test.js @@ -0,0 +1,185 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const contributorPrReply = require('./contributor-pr-reply'); +const { LABEL_COMMUNITY_REVIEW, RTIBBLESBOT_USERNAME } = require('./constants'); + +const AUTHOR = 'outsider'; + +function fakeContext(author = AUTHOR) { + return { + repo: { owner: 'learningequality', repo: 'kolibri' }, + payload: { + pull_request: { + number: 42, + html_url: 'https://github.com/learningequality/kolibri/pull/42', + title: 'Fix it', + user: { login: author }, + }, + }, + }; +} + +function fakeCore() { + const outputs = {}; + const warnings = []; + const failures = []; + return { + outputs, + warnings, + failures, + info() {}, + warning(message) { + warnings.push(message); + }, + setFailed(message) { + failures.push(message); + }, + setOutput(name, value) { + outputs[name] = value; + }, + }; +} + +function fakeGithub(linkedIssues = [], { graphqlError } = {}) { + const calls = { comments: [], updates: [], reviewers: [], labels: [] }; + return { + calls, + graphql: async () => { + if (graphqlError) { + throw new Error(graphqlError); + } + return { + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: linkedIssues.map(issue => ({ + number: issue.number, + title: `Issue ${issue.number}`, + url: `https://github.com/learningequality/kolibri/issues/${issue.number}`, + assignees: { nodes: issue.assignees.map(login => ({ login })) }, + })), + }, + }, + }, + }; + }, + rest: { + issues: { + createComment: async ({ issue_number, body }) => { + calls.comments.push({ issue_number, body }); + return { data: { html_url: 'https://github.com/learningequality/kolibri/pull/42#c1' } }; + }, + addLabels: async ({ labels }) => { + calls.labels.push(...labels); + }, + }, + pulls: { + update: async ({ pull_number, state }) => { + calls.updates.push({ pull_number, state }); + }, + requestReviewers: async ({ reviewers }) => { + calls.reviewers.push(...reviewers); + }, + }, + }, + }; +} + +async function run(github, context = fakeContext()) { + const core = fakeCore(); + await contributorPrReply({ github, context, core }); + return core; +} + +test('a linked issue assigned to someone else closes the pull request', async () => { + const github = fakeGithub([{ number: 7, assignees: ['someone-else'] }]); + const core = await run(github); + + assert.deepEqual(github.calls.updates, [{ pull_number: 42, state: 'closed' }]); + assert.equal(github.calls.comments.length, 1); + assert.match(github.calls.comments[0].body, /#7 is assigned to someone else/); + assert.match(core.outputs.slack_notification, /Closed/); + assert.deepEqual(github.calls.reviewers, []); + assert.deepEqual(github.calls.labels, []); + assert.deepEqual(core.failures, []); +}); + +test('the closing message replaces the standard reply', async () => { + const github = fakeGithub([{ number: 7, assignees: ['someone-else'] }]); + await run(github); + + assert.doesNotMatch(github.calls.comments[0].body, /For the review process to begin/); +}); + +test('a linked issue assigned to the author requests review and adds the label', async () => { + const github = fakeGithub([{ number: 7, assignees: [AUTHOR] }]); + const core = await run(github); + + assert.deepEqual(github.calls.reviewers, [RTIBBLESBOT_USERNAME]); + assert.deepEqual(github.calls.labels, [LABEL_COMMUNITY_REVIEW]); + assert.deepEqual(github.calls.updates, []); + assert.match(github.calls.comments[0].body, /For the review process to begin/); + assert.match(core.outputs.slack_notification, /Reply sent/); +}); + +test('the standard reply carries the review language', async () => { + const github = fakeGithub([{ number: 7, assignees: [AUTHOR] }]); + await run(github); + + const body = github.calls.comments[0].body; + assert.match(body, /Before we assign a reviewer/); + assert.match(body, /@rtibblesbot` will pre-review/); + assert.match(body, /We'll also invite community pre-review/); +}); + +test('the author counts as assigned alongside other assignees', async () => { + const github = fakeGithub([{ number: 7, assignees: ['someone-else', AUTHOR] }]); + await run(github); + + assert.deepEqual(github.calls.updates, []); + assert.deepEqual(github.calls.reviewers, [RTIBBLESBOT_USERNAME]); +}); + +test('an issue assigned to the author outweighs another assigned elsewhere', async () => { + const github = fakeGithub([ + { number: 7, assignees: ['someone-else'] }, + { number: 8, assignees: [AUTHOR] }, + ]); + await run(github); + + assert.deepEqual(github.calls.updates, []); + assert.deepEqual(github.calls.reviewers, [RTIBBLESBOT_USERNAME]); +}); + +test('no linked issue leaves the pull request alone', async () => { + const github = fakeGithub([]); + const core = await run(github); + + assert.deepEqual(github.calls.updates, []); + assert.deepEqual(github.calls.reviewers, []); + assert.deepEqual(github.calls.labels, []); + assert.equal(github.calls.comments.length, 1); + assert.match(core.outputs.slack_notification, /Reply sent/); +}); + +test('a linked issue with no assignee leaves the pull request alone', async () => { + const github = fakeGithub([{ number: 7, assignees: [] }]); + await run(github); + + assert.deepEqual(github.calls.updates, []); + assert.deepEqual(github.calls.reviewers, []); + assert.deepEqual(github.calls.labels, []); +}); + +test('a failed lookup falls back to the standard reply', async () => { + const github = fakeGithub([], { graphqlError: 'API down' }); + const core = await run(github); + + assert.deepEqual(github.calls.updates, []); + assert.deepEqual(github.calls.reviewers, []); + assert.equal(github.calls.comments.length, 1); + assert.equal(core.warnings.length, 1); + assert.match(core.warnings[0], /API down/); + assert.deepEqual(core.failures, []); +}); diff --git a/scripts/pull-request-label.js b/scripts/pull-request-label.js index bcf9fe7..cc5e64e 100644 --- a/scripts/pull-request-label.js +++ b/scripts/pull-request-label.js @@ -1,6 +1,10 @@ // Send info message when community-review label is added -const { BOT_MESSAGE_COMMUNITY_REVIEW, LABEL_COMMUNITY_REVIEW } = require('./constants'); +const { + BOT_MESSAGE_COMMUNITY_REVIEW, + LABEL_COMMUNITY_REVIEW, + LE_BOT_USERNAME, +} = require('./constants'); const { sendBotMessage } = require('./utils'); module.exports = async ({ github, context, core }) => { @@ -9,6 +13,11 @@ module.exports = async ({ github, context, core }) => { if (label !== LABEL_COMMUNITY_REVIEW) { return; } + // Actions taken with the bot app's token trigger this event, unlike GITHUB_TOKEN. The + // automatic path in contributor-pr-reply already carries this text in its own message. + if (context.payload.sender?.login === LE_BOT_USERNAME) { + return; + } const prNumber = context.payload.pull_request.number; await sendBotMessage(prNumber, BOT_MESSAGE_COMMUNITY_REVIEW, { github, context }); } catch (error) { diff --git a/scripts/pull-request-label.test.js b/scripts/pull-request-label.test.js new file mode 100644 index 0000000..6b6513b --- /dev/null +++ b/scripts/pull-request-label.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const pullRequestLabel = require('./pull-request-label'); +const { LE_BOT_USERNAME, LABEL_COMMUNITY_REVIEW } = require('./constants'); + +function fakeGithub() { + const comments = []; + return { + comments, + rest: { + issues: { + createComment: async ({ body }) => { + comments.push(body); + return { data: { html_url: 'https://github.com/learningequality/kolibri/pull/42#c1' } }; + }, + }, + }, + }; +} + +function fakeContext({ label = LABEL_COMMUNITY_REVIEW, sender = 'maintainer' } = {}) { + return { + repo: { owner: 'learningequality', repo: 'kolibri' }, + payload: { + label: { name: label }, + sender: { login: sender }, + pull_request: { number: 42 }, + }, + }; +} + +const core = { info() {}, warning() {}, setFailed() {} }; + +test('a label added by a person posts the note', async () => { + const github = fakeGithub(); + await pullRequestLabel({ github, context: fakeContext(), core }); + + assert.equal(github.comments.length, 1); + assert.match(github.comments[0], /community pre-review/); +}); + +test('a label added by the bot posts nothing', async () => { + const github = fakeGithub(); + await pullRequestLabel({ github, context: fakeContext({ sender: LE_BOT_USERNAME }), core }); + + assert.deepEqual(github.comments, []); +}); + +test('any other label posts nothing', async () => { + const github = fakeGithub(); + await pullRequestLabel({ github, context: fakeContext({ label: 'bug' }), core }); + + assert.deepEqual(github.comments, []); +}); diff --git a/scripts/review-requested.js b/scripts/review-requested.js index e3c63a4..36007b0 100644 --- a/scripts/review-requested.js +++ b/scripts/review-requested.js @@ -1,6 +1,10 @@ // Send info message about rtibblesbot review -const { BOT_MESSAGE_RTIBBLESBOT_REVIEW, RTIBBLESBOT_USERNAME } = require('./constants'); +const { + BOT_MESSAGE_RTIBBLESBOT_REVIEW, + RTIBBLESBOT_USERNAME, + LE_BOT_USERNAME, +} = require('./constants'); const { sendBotMessage } = require('./utils'); module.exports = async ({ github, context, core }) => { @@ -9,6 +13,11 @@ module.exports = async ({ github, context, core }) => { if (reviewer !== RTIBBLESBOT_USERNAME) { return; } + // Actions taken with the bot app's token trigger this event, unlike GITHUB_TOKEN. The + // automatic path in contributor-pr-reply already carries this text in its own message. + if (context.payload.sender?.login === LE_BOT_USERNAME) { + return; + } const prNumber = context.payload.pull_request.number; await sendBotMessage(prNumber, BOT_MESSAGE_RTIBBLESBOT_REVIEW, { github, context }); } catch (error) { diff --git a/scripts/review-requested.test.js b/scripts/review-requested.test.js new file mode 100644 index 0000000..e74d1ba --- /dev/null +++ b/scripts/review-requested.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const reviewRequested = require('./review-requested'); +const { LE_BOT_USERNAME, RTIBBLESBOT_USERNAME } = require('./constants'); + +function fakeGithub() { + const comments = []; + return { + comments, + rest: { + issues: { + createComment: async ({ body }) => { + comments.push(body); + return { data: { html_url: 'https://github.com/learningequality/kolibri/pull/42#c1' } }; + }, + }, + }, + }; +} + +function fakeContext({ reviewer = RTIBBLESBOT_USERNAME, sender = 'maintainer' } = {}) { + return { + repo: { owner: 'learningequality', repo: 'kolibri' }, + payload: { + requested_reviewer: { login: reviewer }, + sender: { login: sender }, + pull_request: { number: 42 }, + }, + }; +} + +const core = { info() {}, warning() {}, setFailed() {} }; + +test('a review requested by a person posts the note', async () => { + const github = fakeGithub(); + await reviewRequested({ github, context: fakeContext(), core }); + + assert.equal(github.comments.length, 1); + assert.match(github.comments[0], /pre-review/); +}); + +test('a review requested by the bot posts nothing', async () => { + const github = fakeGithub(); + await reviewRequested({ github, context: fakeContext({ sender: LE_BOT_USERNAME }), core }); + + assert.deepEqual(github.comments, []); +}); + +test('a review requested for anyone else posts nothing', async () => { + const github = fakeGithub(); + await reviewRequested({ github, context: fakeContext({ reviewer: 'someone-else' }), core }); + + assert.deepEqual(github.comments, []); +}); diff --git a/scripts/utils.js b/scripts/utils.js index 80f263d..b4df60e 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -195,6 +195,50 @@ async function hasLabel(name, owner, repo, issueNumber, github, core) { return labels.includes(name.toLowerCase()); } +/** + * Fetches the issues a pull request is linked to. GitHub only records a link here + * when the description uses a closing keyword, so a bare '#123' returns nothing. + */ +async function getLinkedIssues(prNumber, { github, context, core }) { + const query = ` + query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 10) { + nodes { + number + title + url + assignees(first: 10) { + nodes { + login + } + } + } + } + } + } + }`; + + try { + const { repository } = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo, + number: prNumber, + }); + const nodes = repository?.pullRequest?.closingIssuesReferences?.nodes || []; + return nodes.map(issue => ({ + number: issue.number, + title: issue.title, + url: issue.url, + assignees: (issue.assignees?.nodes || []).map(assignee => assignee.login), + })); + } catch (error) { + core.warning(`Failed to fetch linked issues for #${prNumber}: ${error.message}`); + return []; + } +} + /** * Fetches issues assigned to an assignee in given repositories. */ @@ -343,6 +387,7 @@ module.exports = { hasRecentBotComment, getLabels, hasLabel, + getLinkedIssues, getIssues, getPullRequests, deleteBotComments, From 1e7f33a53f5f47ad108ad07a1860456f55668f6a Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 12:52:46 +0300 Subject: [PATCH 2/9] Ask an unlinked pull request to link its issue A pull request with no linked issue got the plain reply and nothing else, so a maintainer had to ask for the link by hand before anything could happen. The reply now says to add one under References, with Fixes #123 or Closes #123 as examples, since a plain mention does not link an issue. The community-automations page now leads with the three cases and states what counts as a linked issue after them, rather than before. --- docs/community-automations.md | 15 ++++++++++----- scripts/constants.js | 3 +++ scripts/contributor-pr-reply.js | 5 ++++- scripts/contributor-pr-reply.test.js | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/community-automations.md b/docs/community-automations.md index e7b5eac..85549f8 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -61,18 +61,23 @@ In `scripts/constants.js` set: # `contributor-pr-reply` -Sends reply to a community pull requests, and acts on the issues the pull request closes. +Sends reply to a community pull request, and acts on the issue that pull request closes. -Linked issues come from the GraphQL `closingIssuesReferences` field, so only a closing keyword counts. A bare `#123` links nothing, and the pull request falls to the last case below. +What happens depends on who the linked issue is assigned to: -- A linked issue is assigned to someone else, and none is assigned to the author: sends the closing message, closes the pull request, and notifies Slack. The reply is not sent -- A linked issue is assigned to the author: requests `rtibblesbot` for review, adds the `community-review` label, then sends the reply -- Anything else: sends the reply +- assigned to someone else: posts a closing message instead of the reply, closes the pull request, and notifies Slack +- assigned to the author: requests `rtibblesbot` for review, adds the `community-review` label, then sends the reply +- not assigned, or no issue linked: sends the reply + +An issue counts as linked only when the description closes it, for example `Fixes #123`. Assignments on any other issue are ignored. A pull request with no linked issue is asked to add one under `## References`. + +If a pull request links more than one issue, only the one assigned to the author is considered, so the pull request is not closed. `review-requested` and `pull-request-label` post their own notes when a person requests the review or adds the label. They skip the note when the bot did it, because the reply already carries that text. In `scripts/constants.js` set: - `BOT_MESSAGE_PULL_REQUEST`: Message text +- `BOT_MESSAGE_LINK_ISSUE`: Text added when no issue is linked - `BOT_MESSAGE_PULL_REQUEST_CLOSED`: Closing message text # `holiday-message` diff --git a/scripts/constants.js b/scripts/constants.js index 7517248..b77862e 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -143,6 +143,8 @@ const BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE = const BOT_MESSAGE_PULL_REQUEST = author => `👋 Hi @${author}, thanks for contributing! \n\n **For the review process to begin, please verify that the following is satisfied:**\n\n- [ ] **Contribution is aligned with our [contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base)**\n- [ ] **Pull request description has correctly filled _AI usage_ section & follows our AI guidance:**\n\n
\n AI guidance\n\n
\n\n **State explicitly whether you didn't use or used AI & how.**\n\n If you used it, ensure that the PR is aligned with [Using AI](https://learningequality.org/contributing-to-our-open-code-base/#using-generative-ai) as well as our DEEP framework. DEEP asks you:\n\n - **Disclose** — Be open about when you've used AI for support.\n - **Engage critically** — Question what is generated. Review code for correctness and unnecessary complexity.\n - **Edit** — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.\n - **Process sharing** — Explain how you used the AI so others can learn.\n\n
\n\n Examples of good disclosures:\n\n > "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."\n\n > "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."\n\n
\n\n**📢✨ Before we assign a reviewer:**\n\n- **\`@rtibblesbot\` will pre-review this pull request.** Its comments are generated by an LLM, and should be evaluated accordingly.\n- **We'll also invite community pre-review.** See the [community review guidance](https://github.com/learningequality/.github/blob/main/docs/community-review.md) for both authors and reviewers.\n\nAlso check that issue requirements are satisfied & you ran \`pre-commit\` locally.\n\n**Pull requests that don't follow the guidelines will be closed.**\n\n**Reviewer assignment can take up to 2 weeks.**`; +const BOT_MESSAGE_LINK_ISSUE = `\n\n**📎 We couldn't find an issue linked to this pull request.** Please edit the description and link one under **References**, for example \`Fixes #123\` or \`Closes #123\`. A plain \`#123\` mention doesn't link it.`; + const BOT_MESSAGE_PULL_REQUEST_CLOSED = (author, issueNumber) => `👋 Hi @${author}, thanks for your interest! \n\n **We're closing this pull request because #${issueNumber} is assigned to someone else.** Visit [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base) to learn about the contributing process and how to find suitable issues. If there are no unassigned 'help wanted' issues available, please wait until new ones are added. \n\n We really appreciate your willingness to help. 😊${GSOC_NOTE}`; @@ -183,6 +185,7 @@ module.exports = { BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE, BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE, BOT_MESSAGE_PULL_REQUEST, + BOT_MESSAGE_LINK_ISSUE, BOT_MESSAGE_PULL_REQUEST_CLOSED, BOT_MESSAGE_RTIBBLESBOT_REVIEW, RTIBBLESBOT_USERNAME, diff --git a/scripts/contributor-pr-reply.js b/scripts/contributor-pr-reply.js index e5b9942..c21c3da 100644 --- a/scripts/contributor-pr-reply.js +++ b/scripts/contributor-pr-reply.js @@ -1,5 +1,6 @@ const { BOT_MESSAGE_PULL_REQUEST, + BOT_MESSAGE_LINK_ISSUE, BOT_MESSAGE_PULL_REQUEST_CLOSED, LABEL_COMMUNITY_REVIEW, RTIBBLESBOT_USERNAME, @@ -55,7 +56,9 @@ module.exports = async ({ github, context, core }) => { }); } - const botMessageUrl = await sendBotMessage(number, BOT_MESSAGE_PULL_REQUEST(author), { + const reply = + BOT_MESSAGE_PULL_REQUEST(author) + (linkedIssues.length ? '' : BOT_MESSAGE_LINK_ISSUE); + const botMessageUrl = await sendBotMessage(number, reply, { github, context, core, diff --git a/scripts/contributor-pr-reply.test.js b/scripts/contributor-pr-reply.test.js index ebc7344..51f71ea 100644 --- a/scripts/contributor-pr-reply.test.js +++ b/scripts/contributor-pr-reply.test.js @@ -172,6 +172,20 @@ test('a linked issue with no assignee leaves the pull request alone', async () = assert.deepEqual(github.calls.labels, []); }); +test('an unlinked pull request is asked to link an issue', async () => { + const github = fakeGithub([]); + await run(github); + + assert.match(github.calls.comments[0].body, /link one under \*\*References\*\*/); +}); + +test('a linked pull request is not asked to link an issue', async () => { + const github = fakeGithub([{ number: 7, assignees: [] }]); + await run(github); + + assert.doesNotMatch(github.calls.comments[0].body, /link one under/); +}); + test('a failed lookup falls back to the standard reply', async () => { const github = fakeGithub([], { graphqlError: 'API down' }); const core = await run(github); From 7e6b058625f27bb5706c9b8e9197f1ece681a4d5 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 12:59:31 +0300 Subject: [PATCH 3/9] Join the link guidance in the script, not inside the constant --- scripts/constants.js | 2 +- scripts/contributor-pr-reply.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/constants.js b/scripts/constants.js index b77862e..04496c1 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -143,7 +143,7 @@ const BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE = const BOT_MESSAGE_PULL_REQUEST = author => `👋 Hi @${author}, thanks for contributing! \n\n **For the review process to begin, please verify that the following is satisfied:**\n\n- [ ] **Contribution is aligned with our [contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base)**\n- [ ] **Pull request description has correctly filled _AI usage_ section & follows our AI guidance:**\n\n
\n AI guidance\n\n
\n\n **State explicitly whether you didn't use or used AI & how.**\n\n If you used it, ensure that the PR is aligned with [Using AI](https://learningequality.org/contributing-to-our-open-code-base/#using-generative-ai) as well as our DEEP framework. DEEP asks you:\n\n - **Disclose** — Be open about when you've used AI for support.\n - **Engage critically** — Question what is generated. Review code for correctness and unnecessary complexity.\n - **Edit** — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.\n - **Process sharing** — Explain how you used the AI so others can learn.\n\n
\n\n Examples of good disclosures:\n\n > "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."\n\n > "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."\n\n
\n\n**📢✨ Before we assign a reviewer:**\n\n- **\`@rtibblesbot\` will pre-review this pull request.** Its comments are generated by an LLM, and should be evaluated accordingly.\n- **We'll also invite community pre-review.** See the [community review guidance](https://github.com/learningequality/.github/blob/main/docs/community-review.md) for both authors and reviewers.\n\nAlso check that issue requirements are satisfied & you ran \`pre-commit\` locally.\n\n**Pull requests that don't follow the guidelines will be closed.**\n\n**Reviewer assignment can take up to 2 weeks.**`; -const BOT_MESSAGE_LINK_ISSUE = `\n\n**📎 We couldn't find an issue linked to this pull request.** Please edit the description and link one under **References**, for example \`Fixes #123\` or \`Closes #123\`. A plain \`#123\` mention doesn't link it.`; +const BOT_MESSAGE_LINK_ISSUE = `**📎 We couldn't find an issue linked to this pull request.** Please edit the description and link one under **References**, for example \`Fixes #123\` or \`Closes #123\`. A plain \`#123\` mention doesn't link it.`; const BOT_MESSAGE_PULL_REQUEST_CLOSED = (author, issueNumber) => `👋 Hi @${author}, thanks for your interest! \n\n **We're closing this pull request because #${issueNumber} is assigned to someone else.** Visit [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base) to learn about the contributing process and how to find suitable issues. If there are no unassigned 'help wanted' issues available, please wait until new ones are added. \n\n We really appreciate your willingness to help. 😊${GSOC_NOTE}`; diff --git a/scripts/contributor-pr-reply.js b/scripts/contributor-pr-reply.js index c21c3da..928814a 100644 --- a/scripts/contributor-pr-reply.js +++ b/scripts/contributor-pr-reply.js @@ -56,8 +56,9 @@ module.exports = async ({ github, context, core }) => { }); } - const reply = - BOT_MESSAGE_PULL_REQUEST(author) + (linkedIssues.length ? '' : BOT_MESSAGE_LINK_ISSUE); + const reply = linkedIssues.length + ? BOT_MESSAGE_PULL_REQUEST(author) + : `${BOT_MESSAGE_PULL_REQUEST(author)}\n\n${BOT_MESSAGE_LINK_ISSUE}`; const botMessageUrl = await sendBotMessage(number, reply, { github, context, From 06370d97b8cf208ebbd5d609f7bd9179491f34fa Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 13:28:49 +0300 Subject: [PATCH 4/9] Send the reply before requesting review and adding the label A failure requesting rtibblesbot or adding the label threw before the reply was sent, so a contributor could lose the welcome message entirely over an API error. The reply goes first now. The wording still fits, because it says what will happen rather than what has happened. A failed lookup returned an empty list, which the reply could not tell apart from a pull request with nothing linked. It returns null now, so a contributor who linked an issue correctly is not asked to link one when the lookup breaks. The linked issue title and url were mapped but never read, so they are gone. --- scripts/contributor-pr-reply.js | 36 +++++++++++++++------------- scripts/contributor-pr-reply.test.js | 31 +++++++++++++++++++++++- scripts/utils.js | 11 ++++----- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/scripts/contributor-pr-reply.js b/scripts/contributor-pr-reply.js index 928814a..70c42c3 100644 --- a/scripts/contributor-pr-reply.js +++ b/scripts/contributor-pr-reply.js @@ -16,7 +16,8 @@ module.exports = async ({ github, context, core }) => { const title = context.payload.pull_request.title; const author = context.payload.pull_request.user.login; - const linkedIssues = await getLinkedIssues(number, { github, context, core }); + const lookup = await getLinkedIssues(number, { github, context, core }); + const linkedIssues = lookup || []; const authorAssigned = linkedIssues.some(issue => issue.assignees.includes(author)); const assignedElsewhere = linkedIssues.find( issue => issue.assignees.length > 0 && !issue.assignees.includes(author), @@ -41,6 +42,23 @@ module.exports = async ({ github, context, core }) => { return; } + const reply = + lookup && linkedIssues.length === 0 + ? `${BOT_MESSAGE_PULL_REQUEST(author)}\n\n${BOT_MESSAGE_LINK_ISSUE}` + : BOT_MESSAGE_PULL_REQUEST(author); + const botMessageUrl = await sendBotMessage(number, reply, { + github, + context, + core, + }); + + if (botMessageUrl) { + const slackMessage = `*[${repo}] <${botMessageUrl}|Reply sent> on pull request: <${url}|${title}>*`; + core.setOutput('slack_notification', slackMessage); + } else { + core.setOutput('slack_notification', ''); + } + if (authorAssigned) { await github.rest.pulls.requestReviewers({ owner, @@ -55,22 +73,6 @@ module.exports = async ({ github, context, core }) => { labels: [LABEL_COMMUNITY_REVIEW], }); } - - const reply = linkedIssues.length - ? BOT_MESSAGE_PULL_REQUEST(author) - : `${BOT_MESSAGE_PULL_REQUEST(author)}\n\n${BOT_MESSAGE_LINK_ISSUE}`; - const botMessageUrl = await sendBotMessage(number, reply, { - github, - context, - core, - }); - - if (botMessageUrl) { - const slackMessage = `*[${repo}] <${botMessageUrl}|Reply sent> on pull request: <${url}|${title}>*`; - core.setOutput('slack_notification', slackMessage); - } else { - core.setOutput('slack_notification', ''); - } } catch (error) { core.setOutput('slack_notification', ''); core.setFailed(`Action failed with error: ${error.message}`); diff --git a/scripts/contributor-pr-reply.test.js b/scripts/contributor-pr-reply.test.js index 51f71ea..ee39656 100644 --- a/scripts/contributor-pr-reply.test.js +++ b/scripts/contributor-pr-reply.test.js @@ -42,7 +42,7 @@ function fakeCore() { } function fakeGithub(linkedIssues = [], { graphqlError } = {}) { - const calls = { comments: [], updates: [], reviewers: [], labels: [] }; + const calls = { comments: [], updates: [], reviewers: [], labels: [], order: [] }; return { calls, graphql: async () => { @@ -68,18 +68,22 @@ function fakeGithub(linkedIssues = [], { graphqlError } = {}) { issues: { createComment: async ({ issue_number, body }) => { calls.comments.push({ issue_number, body }); + calls.order.push('comment'); return { data: { html_url: 'https://github.com/learningequality/kolibri/pull/42#c1' } }; }, addLabels: async ({ labels }) => { calls.labels.push(...labels); + calls.order.push('label'); }, }, pulls: { update: async ({ pull_number, state }) => { calls.updates.push({ pull_number, state }); + calls.order.push('close'); }, requestReviewers: async ({ reviewers }) => { calls.reviewers.push(...reviewers); + calls.order.push('review'); }, }, }, @@ -123,6 +127,24 @@ test('a linked issue assigned to the author requests review and adds the label', assert.match(core.outputs.slack_notification, /Reply sent/); }); +test('the reply is sent before the review request and the label', async () => { + const github = fakeGithub([{ number: 7, assignees: [AUTHOR] }]); + await run(github); + + assert.deepEqual(github.calls.order, ['comment', 'review', 'label']); +}); + +test('a failed review request still leaves the reply in place', async () => { + const github = fakeGithub([{ number: 7, assignees: [AUTHOR] }]); + github.rest.pulls.requestReviewers = async () => { + throw new Error('Reviewer cannot be requested'); + }; + const core = await run(github); + + assert.equal(github.calls.comments.length, 1); + assert.equal(core.failures.length, 1); +}); + test('the standard reply carries the review language', async () => { const github = fakeGithub([{ number: 7, assignees: [AUTHOR] }]); await run(github); @@ -197,3 +219,10 @@ test('a failed lookup falls back to the standard reply', async () => { assert.match(core.warnings[0], /API down/); assert.deepEqual(core.failures, []); }); + +test('a failed lookup does not ask the author to link an issue', async () => { + const github = fakeGithub([], { graphqlError: 'API down' }); + await run(github); + + assert.doesNotMatch(github.calls.comments[0].body, /link one under/); +}); diff --git a/scripts/utils.js b/scripts/utils.js index b4df60e..0122a5a 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -196,8 +196,9 @@ async function hasLabel(name, owner, repo, issueNumber, github, core) { } /** - * Fetches the issues a pull request is linked to. GitHub only records a link here - * when the description uses a closing keyword, so a bare '#123' returns nothing. + * Fetches the issues a pull request is linked to, or null when the lookup fails. + * GitHub only records a link here when the description uses a closing keyword, + * so a bare '#123' returns nothing. */ async function getLinkedIssues(prNumber, { github, context, core }) { const query = ` @@ -207,8 +208,6 @@ async function getLinkedIssues(prNumber, { github, context, core }) { closingIssuesReferences(first: 10) { nodes { number - title - url assignees(first: 10) { nodes { login @@ -229,13 +228,11 @@ async function getLinkedIssues(prNumber, { github, context, core }) { const nodes = repository?.pullRequest?.closingIssuesReferences?.nodes || []; return nodes.map(issue => ({ number: issue.number, - title: issue.title, - url: issue.url, assignees: (issue.assignees?.nodes || []).map(assignee => assignee.login), })); } catch (error) { core.warning(`Failed to fetch linked issues for #${prNumber}: ${error.message}`); - return []; + return null; } } From 658d4d363f2e58cbd0bf030434f12796a9fca1de Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 15:10:56 +0300 Subject: [PATCH 5/9] Say what a plain mention fails to link --- scripts/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/constants.js b/scripts/constants.js index 04496c1..dbe520d 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -143,7 +143,7 @@ const BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE = const BOT_MESSAGE_PULL_REQUEST = author => `👋 Hi @${author}, thanks for contributing! \n\n **For the review process to begin, please verify that the following is satisfied:**\n\n- [ ] **Contribution is aligned with our [contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base)**\n- [ ] **Pull request description has correctly filled _AI usage_ section & follows our AI guidance:**\n\n
\n AI guidance\n\n
\n\n **State explicitly whether you didn't use or used AI & how.**\n\n If you used it, ensure that the PR is aligned with [Using AI](https://learningequality.org/contributing-to-our-open-code-base/#using-generative-ai) as well as our DEEP framework. DEEP asks you:\n\n - **Disclose** — Be open about when you've used AI for support.\n - **Engage critically** — Question what is generated. Review code for correctness and unnecessary complexity.\n - **Edit** — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.\n - **Process sharing** — Explain how you used the AI so others can learn.\n\n
\n\n Examples of good disclosures:\n\n > "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."\n\n > "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."\n\n
\n\n**📢✨ Before we assign a reviewer:**\n\n- **\`@rtibblesbot\` will pre-review this pull request.** Its comments are generated by an LLM, and should be evaluated accordingly.\n- **We'll also invite community pre-review.** See the [community review guidance](https://github.com/learningequality/.github/blob/main/docs/community-review.md) for both authors and reviewers.\n\nAlso check that issue requirements are satisfied & you ran \`pre-commit\` locally.\n\n**Pull requests that don't follow the guidelines will be closed.**\n\n**Reviewer assignment can take up to 2 weeks.**`; -const BOT_MESSAGE_LINK_ISSUE = `**📎 We couldn't find an issue linked to this pull request.** Please edit the description and link one under **References**, for example \`Fixes #123\` or \`Closes #123\`. A plain \`#123\` mention doesn't link it.`; +const BOT_MESSAGE_LINK_ISSUE = `**📎 We couldn't find an issue linked to this pull request.** Please edit the description and link one under **References**, for example \`Fixes #123\` or \`Closes #123\`. A plain \`#123\` mention doesn't link the PR to the issue.`; const BOT_MESSAGE_PULL_REQUEST_CLOSED = (author, issueNumber) => `👋 Hi @${author}, thanks for your interest! \n\n **We're closing this pull request because #${issueNumber} is assigned to someone else.** Visit [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base) to learn about the contributing process and how to find suitable issues. If there are no unassigned 'help wanted' issues available, please wait until new ones are added. \n\n We really appreciate your willingness to help. 😊${GSOC_NOTE}`; From f511df38f2bed08d0343d169465e1f5b3dd17fc1 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 15:40:06 +0300 Subject: [PATCH 6/9] Describe each automation on its own, and in the order it acts The community-automations page described how review-requested and pull-request-label react to contributor-pr-reply, which belongs in those scripts rather than on a page that covers one automation per section. The reply is also sent before the review request and the label now, so the order in the bullet was wrong. --- docs/community-automations.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/community-automations.md b/docs/community-automations.md index 85549f8..d1fc650 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -66,15 +66,13 @@ Sends reply to a community pull request, and acts on the issue that pull request What happens depends on who the linked issue is assigned to: - assigned to someone else: posts a closing message instead of the reply, closes the pull request, and notifies Slack -- assigned to the author: requests `rtibblesbot` for review, adds the `community-review` label, then sends the reply +- assigned to the author: sends the reply, then requests `rtibblesbot` for review and adds the `community-review` label - not assigned, or no issue linked: sends the reply An issue counts as linked only when the description closes it, for example `Fixes #123`. Assignments on any other issue are ignored. A pull request with no linked issue is asked to add one under `## References`. If a pull request links more than one issue, only the one assigned to the author is considered, so the pull request is not closed. -`review-requested` and `pull-request-label` post their own notes when a person requests the review or adds the label. They skip the note when the bot did it, because the reply already carries that text. - In `scripts/constants.js` set: - `BOT_MESSAGE_PULL_REQUEST`: Message text - `BOT_MESSAGE_LINK_ISSUE`: Text added when no issue is linked From 71d65a59170ad4887c0d4e67e9f6c93a3428d8e1 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 15:44:36 +0300 Subject: [PATCH 7/9] Keep the Slack notice when the review request or the label fails The reply is sent before the review request and the label, so the Slack output is already set when those calls run. A failure there reached the outer catch, which blanks the output, so the reply went out but nobody was told. The two calls now handle their own failure and still report it. The check for a failed lookup reads lookup directly, rather than leaning on an empty array being truthy while taking the length from another variable. --- scripts/contributor-pr-reply.js | 30 ++++++++++++++++------------ scripts/contributor-pr-reply.test.js | 1 + 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/scripts/contributor-pr-reply.js b/scripts/contributor-pr-reply.js index 70c42c3..4b61fe8 100644 --- a/scripts/contributor-pr-reply.js +++ b/scripts/contributor-pr-reply.js @@ -43,7 +43,7 @@ module.exports = async ({ github, context, core }) => { } const reply = - lookup && linkedIssues.length === 0 + lookup !== null && lookup.length === 0 ? `${BOT_MESSAGE_PULL_REQUEST(author)}\n\n${BOT_MESSAGE_LINK_ISSUE}` : BOT_MESSAGE_PULL_REQUEST(author); const botMessageUrl = await sendBotMessage(number, reply, { @@ -60,18 +60,22 @@ module.exports = async ({ github, context, core }) => { } if (authorAssigned) { - await github.rest.pulls.requestReviewers({ - owner, - repo, - pull_number: number, - reviewers: [RTIBBLESBOT_USERNAME], - }); - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: number, - labels: [LABEL_COMMUNITY_REVIEW], - }); + try { + await github.rest.pulls.requestReviewers({ + owner, + repo, + pull_number: number, + reviewers: [RTIBBLESBOT_USERNAME], + }); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: number, + labels: [LABEL_COMMUNITY_REVIEW], + }); + } catch (error) { + core.setFailed(`Failed to request review or add the label: ${error.message}`); + } } } catch (error) { core.setOutput('slack_notification', ''); diff --git a/scripts/contributor-pr-reply.test.js b/scripts/contributor-pr-reply.test.js index ee39656..f299208 100644 --- a/scripts/contributor-pr-reply.test.js +++ b/scripts/contributor-pr-reply.test.js @@ -143,6 +143,7 @@ test('a failed review request still leaves the reply in place', async () => { assert.equal(github.calls.comments.length, 1); assert.equal(core.failures.length, 1); + assert.match(core.outputs.slack_notification, /Reply sent/); }); test('the standard reply carries the review language', async () => { From fcb3eb6e52683a9ae52bc9e3ff0b021aac4adf30 Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 21:18:59 +0300 Subject: [PATCH 8/9] Name the linked issue by url, and report a failed close A close that failed left the contributor reading "we're closing this pull request" on an open pull request, and the outer catch blanked the Slack output so nobody was told. The close now reports its own failure and still notifies. A linked issue in another repo was named as a local #N, which points at a different issue here. The message and the Slack line use the issue url instead, so a cross-repo link reads correctly. The docs sentence about several linked issues only held when the author was assigned to one of them. The rule is simpler stated from the author's side. The getLinkedIssues docstring said a closing keyword is the only way to link an issue. The Development sidebar also counts. Dropped the test that asserted three phrases of the reply. It restated the constant, so any copy edit broke it while no logic change would. --- docs/community-automations.md | 2 +- scripts/constants.js | 4 ++-- scripts/contributor-pr-reply.js | 30 ++++++++++++++++---------- scripts/contributor-pr-reply.test.js | 32 ++++++++++++++++++---------- scripts/utils.js | 6 ++++-- 5 files changed, 47 insertions(+), 27 deletions(-) diff --git a/docs/community-automations.md b/docs/community-automations.md index d1fc650..87153e9 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -71,7 +71,7 @@ What happens depends on who the linked issue is assigned to: An issue counts as linked only when the description closes it, for example `Fixes #123`. Assignments on any other issue are ignored. A pull request with no linked issue is asked to add one under `## References`. -If a pull request links more than one issue, only the one assigned to the author is considered, so the pull request is not closed. +If the author is assigned to any linked issue, the pull request is not closed. In `scripts/constants.js` set: - `BOT_MESSAGE_PULL_REQUEST`: Message text diff --git a/scripts/constants.js b/scripts/constants.js index dbe520d..41479a4 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -145,8 +145,8 @@ const BOT_MESSAGE_PULL_REQUEST = author => const BOT_MESSAGE_LINK_ISSUE = `**📎 We couldn't find an issue linked to this pull request.** Please edit the description and link one under **References**, for example \`Fixes #123\` or \`Closes #123\`. A plain \`#123\` mention doesn't link the PR to the issue.`; -const BOT_MESSAGE_PULL_REQUEST_CLOSED = (author, issueNumber) => - `👋 Hi @${author}, thanks for your interest! \n\n **We're closing this pull request because #${issueNumber} is assigned to someone else.** Visit [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base) to learn about the contributing process and how to find suitable issues. If there are no unassigned 'help wanted' issues available, please wait until new ones are added. \n\n We really appreciate your willingness to help. 😊${GSOC_NOTE}`; +const BOT_MESSAGE_PULL_REQUEST_CLOSED = (author, issueUrl) => + `👋 Hi @${author}, thanks for your interest! \n\n **We're closing this pull request because ${issueUrl} is assigned to someone else.** Visit [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base) to learn about the contributing process and how to find suitable issues. If there are no unassigned 'help wanted' issues available, please wait until new ones are added. \n\n We really appreciate your willingness to help. 😊${GSOC_NOTE}`; const HOLIDAY_MESSAGE = `Season's greetings! 👋 \n\n We'd like to thank everyone for another year of fruitful collaborations, engaging discussions, and for the continued support of our work. **Learning Equality will be on holidays from December 22 to January 5.** We look forward to much more in the new year and wish you a very happy holiday season!${GSOC_NOTE}`; diff --git a/scripts/contributor-pr-reply.js b/scripts/contributor-pr-reply.js index 4b61fe8..613e707 100644 --- a/scripts/contributor-pr-reply.js +++ b/scripts/contributor-pr-reply.js @@ -26,19 +26,27 @@ module.exports = async ({ github, context, core }) => { if (!authorAssigned && assignedElsewhere) { const botMessageUrl = await sendBotMessage( number, - BOT_MESSAGE_PULL_REQUEST_CLOSED(author, assignedElsewhere.number), + BOT_MESSAGE_PULL_REQUEST_CLOSED(author, assignedElsewhere.url), { github, context, core }, ); - await github.rest.pulls.update({ - owner, - repo, - pull_number: number, - state: 'closed', - }); - core.setOutput( - 'slack_notification', - `*[${repo}] <${botMessageUrl}|Closed> pull request on assigned issue #${assignedElsewhere.number}: <${url}|${title}>*`, - ); + try { + await github.rest.pulls.update({ + owner, + repo, + pull_number: number, + state: 'closed', + }); + core.setOutput( + 'slack_notification', + `*[${repo}] <${botMessageUrl}|Closed> pull request on assigned issue <${assignedElsewhere.url}|#${assignedElsewhere.number}>: <${url}|${title}>*`, + ); + } catch (error) { + core.setOutput( + 'slack_notification', + `*[${repo}] Failed to close pull request <${url}|${title}>, but the closing message was posted*`, + ); + core.setFailed(`Failed to close the pull request: ${error.message}`); + } return; } diff --git a/scripts/contributor-pr-reply.test.js b/scripts/contributor-pr-reply.test.js index f299208..94593ee 100644 --- a/scripts/contributor-pr-reply.test.js +++ b/scripts/contributor-pr-reply.test.js @@ -102,7 +102,7 @@ test('a linked issue assigned to someone else closes the pull request', async () assert.deepEqual(github.calls.updates, [{ pull_number: 42, state: 'closed' }]); assert.equal(github.calls.comments.length, 1); - assert.match(github.calls.comments[0].body, /#7 is assigned to someone else/); + assert.match(github.calls.comments[0].body, /issues\/7 is assigned to someone else/); assert.match(core.outputs.slack_notification, /Closed/); assert.deepEqual(github.calls.reviewers, []); assert.deepEqual(github.calls.labels, []); @@ -116,6 +116,26 @@ test('the closing message replaces the standard reply', async () => { assert.doesNotMatch(github.calls.comments[0].body, /For the review process to begin/); }); +test('a failed close still reports to Slack', async () => { + const github = fakeGithub([{ number: 7, assignees: ['someone-else'] }]); + github.rest.pulls.update = async () => { + throw new Error('Resource not accessible'); + }; + const core = await run(github); + + assert.equal(github.calls.comments.length, 1); + assert.match(core.outputs.slack_notification, /Failed to close/); + assert.equal(core.failures.length, 1); +}); + +test('a cross-repo linked issue is named by its url', async () => { + const github = fakeGithub([{ number: 7, assignees: ['someone-else'] }]); + const core = await run(github); + + assert.match(github.calls.comments[0].body, /https:\/\/github\.com\/.+\/issues\/7/); + assert.match(core.outputs.slack_notification, /issues\/7/); +}); + test('a linked issue assigned to the author requests review and adds the label', async () => { const github = fakeGithub([{ number: 7, assignees: [AUTHOR] }]); const core = await run(github); @@ -146,16 +166,6 @@ test('a failed review request still leaves the reply in place', async () => { assert.match(core.outputs.slack_notification, /Reply sent/); }); -test('the standard reply carries the review language', async () => { - const github = fakeGithub([{ number: 7, assignees: [AUTHOR] }]); - await run(github); - - const body = github.calls.comments[0].body; - assert.match(body, /Before we assign a reviewer/); - assert.match(body, /@rtibblesbot` will pre-review/); - assert.match(body, /We'll also invite community pre-review/); -}); - test('the author counts as assigned alongside other assignees', async () => { const github = fakeGithub([{ number: 7, assignees: ['someone-else', AUTHOR] }]); await run(github); diff --git a/scripts/utils.js b/scripts/utils.js index 0122a5a..33b9011 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -197,8 +197,8 @@ async function hasLabel(name, owner, repo, issueNumber, github, core) { /** * Fetches the issues a pull request is linked to, or null when the lookup fails. - * GitHub only records a link here when the description uses a closing keyword, - * so a bare '#123' returns nothing. + * GitHub records a link from a closing keyword in the description, or from the + * Development sidebar. A bare '#123' is not one. */ async function getLinkedIssues(prNumber, { github, context, core }) { const query = ` @@ -208,6 +208,7 @@ async function getLinkedIssues(prNumber, { github, context, core }) { closingIssuesReferences(first: 10) { nodes { number + url assignees(first: 10) { nodes { login @@ -228,6 +229,7 @@ async function getLinkedIssues(prNumber, { github, context, core }) { const nodes = repository?.pullRequest?.closingIssuesReferences?.nodes || []; return nodes.map(issue => ({ number: issue.number, + url: issue.url, assignees: (issue.assignees?.nodes || []).map(assignee => assignee.login), })); } catch (error) { From 90f0bd12619428e92ff48aa14179ee3724186dbf Mon Sep 17 00:00:00 2001 From: Samson Akol Date: Tue, 29 Sep 2026 21:34:52 +0300 Subject: [PATCH 9/9] Let the Slack step run after a partial failure The step condition held no status function, so Actions treated it as success() && the output check. A failed close, or a failed review request, calls setFailed on the script step, which skipped the Slack step and dropped the notice it had just set. A crash still posts nothing, because the outer catch clears the output first. --- .github/workflows/contributor-pr-reply.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/contributor-pr-reply.yml b/.github/workflows/contributor-pr-reply.yml index 5ce122d..9276a55 100644 --- a/.github/workflows/contributor-pr-reply.yml +++ b/.github/workflows/contributor-pr-reply.yml @@ -54,7 +54,7 @@ jobs: const script = require('./scripts/contributor-pr-reply.js'); return await script({ github, context, core }); - name: Send Slack notification - if: ${{ steps.script.outputs.slack_notification != ''}} + if: ${{ !cancelled() && steps.script.outputs.slack_notification != '' }} uses: slackapi/slack-github-action@v4.0.0 with: webhook-type: incoming-webhook