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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/automation-group-pull-request-target.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }}
contributor-pr-reply:
name: Contributor PR Reply
if: ${{ github.event_name == 'pull_request_target' && github.event.action == 'opened' }}
if: ${{ github.event_name == 'pull_request_target' && (github.event.action == 'opened' || (github.event.action == 'edited' && github.event.changes.body)) }}
uses: learningequality/.github/.github/workflows/contributor-pr-reply.yml@main
permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/automation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ on:
jobs:
pull-request-target:
name: Pull Request Target
if: ${{ (github.event_name == 'pull_request_target' && github.event.action == 'review_requested') || (github.event_name == 'pull_request_target' && github.event.action == 'labeled') || (github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]') || (github.event_name == 'pull_request_target' && github.event.action == 'opened') || (github.event_name == 'pull_request_target' && contains(fromJSON('["opened","edited","reopened"]'), github.event.action)) }}
if: ${{ (github.event_name == 'pull_request_target' && github.event.action == 'review_requested') || (github.event_name == 'pull_request_target' && github.event.action == 'labeled') || (github.event_name == 'pull_request_target' && github.event.action == 'opened' && github.event.pull_request.user.login == 'dependabot[bot]') || (github.event_name == 'pull_request_target' && (github.event.action == 'opened' || (github.event.action == 'edited' && github.event.changes.body))) || (github.event_name == 'pull_request_target' && contains(fromJSON('["opened","edited","reopened"]'), github.event.action)) }}
uses: learningequality/.github/.github/workflows/automation-group-pull-request-target.yml@main
permissions:
contents: read
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/contributor-pr-reply.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
needs: [check-if-contributor]
if: ${{ needs.check-if-contributor.outputs.is_contributor == 'true' }}
runs-on: ubuntu-latest
concurrency:
Comment thread
rtibblesbot marked this conversation as resolved.
Resolved
group: contributor-pr-reply-${{ github.event.pull_request.number }}-${{ github.event.action }}
Comment thread
rtibblesbot marked this conversation as resolved.
Resolved
cancel-in-progress: false
steps:
- name: Generate App Token
id: generate-token
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/unassign-inactive-issues.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }}
permission-issues: write
permission-members: read
permission-pull-requests: read
permission-pull-requests: write
- name: Checkout .github repository
uses: actions/checkout@v7
with:
Expand Down
4 changes: 2 additions & 2 deletions automation-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ automations:
leaf: contributor-pr-reply.yml
on:
pull_request_target:
types: [opened]
if: "github.event_name == 'pull_request_target' && github.event.action == 'opened'"
types: [opened, edited]
if: "github.event_name == 'pull_request_target' && (github.event.action == 'opened' || (github.event.action == 'edited' && github.event.changes.body))"
secrets:
LE_BOT_APP_ID: required
LE_BOT_PRIVATE_KEY: required
Expand Down
15 changes: 10 additions & 5 deletions docs/community-automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,23 @@ 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: sends the reply, then requests `rtibblesbot` for review and adds the `community-review` label
- not assigned, or no issue linked: sends the reply
- assigned to someone else, or to no one: posts a closing message instead of the reply, closes the pull request, and notifies Slack
- no issue linked: sends the reply, asks for an issue to be linked under `## References`, and adds the `unlinked` label

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`.
An issue counts as linked only when the description closes it, for example `Fixes #123`. Assignments on any other issue are ignored. If the author is assigned to any linked issue, the pull request is not closed. Close contributors are never closed or labelled `unlinked`.

If the author is assigned to any linked issue, the pull request is not closed.
It runs on `opened`, and again on `edited` when the description changes. An edit sends no reply: it closes, labels, or removes `unlinked` by the rules above, and starts review only on a pull request that carried `unlinked`.

The weekly `unassign-inactive-issues` run closes every open pull request labelled `unlinked` for longer than `PR_UNLINKED_MAX_DAYS` days, except a close contributor's. It re-reads the links first, because a link added from the Development sidebar fires no `edited` event: a pull request now linked to its author's issue loses the label and starts review instead.

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
- `BOT_MESSAGE_PULL_REQUEST_CLOSED`: Closing message when the issue is assigned to someone else
- `BOT_MESSAGE_PULL_REQUEST_UNASSIGNED`: Closing message when the issue is assigned to no one
- `BOT_MESSAGE_PULL_REQUEST_UNLINKED_CLOSED`: Closing message from the sweep when still no issue is linked
- `PR_UNLINKED_MAX_DAYS`: How long `unlinked` stays on before the sweep closes the pull request

# `pull-request-template`

Expand Down
17 changes: 16 additions & 1 deletion scripts/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,18 @@ 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 <details>\n <summary><b><i>AI guidance</i></b></summary>\n\n <br>\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 <br>\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 </details>\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 the PR to the issue.`;
const LABEL_UNLINKED = 'unlinked';
const PR_UNLINKED_MAX_DAYS = 7;

const BOT_MESSAGE_LINK_ISSUE = `**📎 We couldn't find an issue linked to this pull request.** Please edit the description and link the issue assigned to you under **References**, for example \`Fixes #123\` or \`Closes #123\`. A plain \`#123\` mention doesn't link the PR to the issue. **Pull requests with no linked issue are closed after ${PR_UNLINKED_MAX_DAYS} days.**`;

const BOT_MESSAGE_PULL_REQUEST_UNASSIGNED = (author, issueUrl) =>
`👋 Hi @${author}, thanks for your interest! \n\n **We're closing this pull request because ${issueUrl} isn't assigned to you.** We check and assign issues before work on them starts. Comment on the issue to ask to be assigned, and open a new pull request once you are. Visit [Contributing guidelines](https://learningequality.org/contributing-to-our-open-code-base) to learn about the contributing process.${GSOC_NOTE}`;

const BOT_MESSAGE_PULL_REQUEST_UNLINKED_CLOSED =
`**Closing this pull request because no issue has been linked to it after ${PR_UNLINKED_MAX_DAYS} days.** ` +
`Pull requests must close an issue assigned to their author. Once you're assigned to one, ` +
`open a new pull request that links it.`;

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}`;
Expand Down Expand Up @@ -244,6 +255,10 @@ module.exports = {
BOT_MESSAGE_PULL_REQUEST,
BOT_MESSAGE_LINK_ISSUE,
BOT_MESSAGE_PULL_REQUEST_CLOSED,
BOT_MESSAGE_PULL_REQUEST_UNASSIGNED,
BOT_MESSAGE_PULL_REQUEST_UNLINKED_CLOSED,
LABEL_UNLINKED,
PR_UNLINKED_MAX_DAYS,
BOT_MESSAGE_RTIBBLESBOT_REVIEW,
RTIBBLESBOT_USERNAME,
LABEL_COMMUNITY_REVIEW,
Expand Down
143 changes: 70 additions & 73 deletions scripts/contributor-pr-reply.js
Original file line number Diff line number Diff line change
@@ -1,88 +1,85 @@
const {
BOT_MESSAGE_PULL_REQUEST,
BOT_MESSAGE_LINK_ISSUE,
BOT_MESSAGE_PULL_REQUEST_CLOSED,
LABEL_COMMUNITY_REVIEW,
RTIBBLESBOT_USERNAME,
} = require('./constants');
const { BOT_MESSAGE_PULL_REQUEST, BOT_MESSAGE_LINK_ISSUE } = require('./constants');
const { sendBotMessage, getLinkedIssues, escapeIssueTitleForSlackMessage } = require('./utils');
const { linkStatus, planLink, applyLink } = require('./pull-request-link');

function slackLine(context, text) {
const { repo } = context.repo;
const { html_url: url, title } = context.payload.pull_request;
return `*[${repo}] ${text}: <${url}|${escapeIssueTitleForSlackMessage(title)}>*`;
}

async function sendReply({ github, context, core }, unlinked) {
const { number, user } = context.payload.pull_request;
const body = unlinked
? `${BOT_MESSAGE_PULL_REQUEST(user.login)}\n\n${BOT_MESSAGE_LINK_ISSUE}`
: BOT_MESSAGE_PULL_REQUEST(user.login);
const botMessageUrl = await sendBotMessage(number, body, { github, context, core });
core.setOutput(
'slack_notification',
slackLine(context, `<${botMessageUrl}|Reply sent> on pull request`),
);
}

async function act({ github, context, core }, plan, link) {
const { owner, repo } = context.repo;
const { number } = context.payload.pull_request;
try {
const closingUrl = await applyLink(owner, repo, number, plan, { github, core });
if (plan.action === 'close') {
const reason = link.status === 'unassigned' ? 'unassigned' : 'assigned';
core.setOutput(
'slack_notification',
slackLine(
context,
`<${closingUrl}|Closed> pull request on ${reason} issue <${link.issue.url}|#${link.issue.number}>`,
),
);
}
} catch (error) {
if (plan.action === 'close') {
core.setOutput('slack_notification', slackLine(context, 'Failed to close pull request'));
}
core.setFailed(`Failed to ${plan.action} the pull request: ${error.message}`);
}
}

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 = escapeIssueTitleForSlackMessage(context.payload.pull_request.title);
const author = context.payload.pull_request.user.login;
const { owner, repo } = context.repo;
const pullRequest = context.payload.pull_request;
const { number } = pullRequest;
const author = pullRequest.user.login;
const opening = context.payload.action !== 'edited';
core.setOutput('slack_notification', '');

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),
);
if (!opening && pullRequest.state !== 'open') {
core.info(`Pull request #${number} is ${pullRequest.state}.`);
return;
}

if (!authorAssigned && assignedElsewhere) {
const botMessageUrl = await sendBotMessage(
number,
BOT_MESSAGE_PULL_REQUEST_CLOSED(author, assignedElsewhere.url),
{ github, context, core },
);
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}`);
const plan = async link =>
planLink(owner, repo, pullRequest, link, { github, core }, { opening });

const lookup = await getLinkedIssues(number, { github, context, core });
if (lookup === null) {
if (opening) {
await sendReply({ github, context, core }, false);
}
return;
}

const reply =
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, {
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', '');
const link = linkStatus(lookup, author);
const first = await plan(link);
if (opening && first.action !== 'close') {
await sendReply({ github, context, core }, link.status === 'unlinked');
}
await act({ github, context, core }, first, link);

if (authorAssigned) {
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}`);
if (opening && first.action === 'label') {
const relookup = await getLinkedIssues(number, { github, context, core });
if (relookup?.length) {
const relink = linkStatus(relookup, author);
await act({ github, context, core }, await plan(relink), relink);
}
}
} catch (error) {
Expand Down
Loading
Loading