diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0de6524..98c709c 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,2 @@ github: learningequality custom: ["https://learningequality.org/donate/", "https://www.every.org/learningequality"] - diff --git a/.github/workflows/call-manage-issue-header.yml b/.github/workflows/call-issue-label.yml similarity index 54% rename from .github/workflows/call-manage-issue-header.yml rename to .github/workflows/call-issue-label.yml index 845a679..c7b5a50 100644 --- a/.github/workflows/call-manage-issue-header.yml +++ b/.github/workflows/call-issue-label.yml @@ -1,11 +1,11 @@ -name: Manage issue header +name: Handle issue label events on: issues: - types: [opened, reopened, labeled, unlabeled] + types: [labeled, unlabeled] jobs: call-workflow: name: Call shared workflow - uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main + uses: learningequality/.github/.github/workflows/issue-label.yml@main secrets: LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/call-issue-open.yml b/.github/workflows/call-issue-open.yml new file mode 100644 index 0000000..beae47b --- /dev/null +++ b/.github/workflows/call-issue-open.yml @@ -0,0 +1,11 @@ +name: Handle issue open events +on: + issues: + types: [opened, reopened] +jobs: + call-workflow: + name: Call shared workflow + uses: learningequality/.github/.github/workflows/issue-open.yml@main + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/good-first-issue-comment.yml b/.github/workflows/good-first-issue-comment.yml new file mode 100644 index 0000000..5d50c3d --- /dev/null +++ b/.github/workflows/good-first-issue-comment.yml @@ -0,0 +1,40 @@ +name: Post good first issue guidance comment +on: + workflow_call: + secrets: + LE_BOT_APP_ID: + description: 'GitHub App ID for authentication' + required: true + LE_BOT_PRIVATE_KEY: + description: 'GitHub App Private Key for authentication' + required: true +jobs: + good-first-issue-comment: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub token + id: generate-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.LE_BOT_APP_ID }} + private-key: ${{ secrets.LE_BOT_PRIVATE_KEY }} + - name: Checkout .github repository + uses: actions/checkout@v6 + with: + repository: learningequality/.github + ref: main + token: ${{ steps.generate-token.outputs.token }} + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: 'yarn' + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Run script + uses: actions/github-script@v8 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const script = require('./scripts/good-first-issue-comment.js'); + await script({github, context, core}); diff --git a/.github/workflows/issue-label.yml b/.github/workflows/issue-label.yml new file mode 100644 index 0000000..5d26670 --- /dev/null +++ b/.github/workflows/issue-label.yml @@ -0,0 +1,27 @@ +name: Handle issue label events +on: + workflow_call: + secrets: + LE_BOT_APP_ID: + description: 'GitHub App ID for authentication' + required: true + LE_BOT_PRIVATE_KEY: + description: 'GitHub App Private Key for authentication' + required: true +jobs: + manage-issue-header: + name: Manage issue header + if: >- + (github.event.action == 'labeled' && github.event.label.name == 'help wanted') || (github.event.action == 'unlabeled' && github.event.label.name == 'help wanted') + uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} + good-first-issue-comment: + name: Post good first issue guidance comment + if: >- + github.event.action == 'labeled' && github.event.label.name == 'good first issue' + uses: learningequality/.github/.github/workflows/good-first-issue-comment.yml@main + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/issue-open.yml b/.github/workflows/issue-open.yml new file mode 100644 index 0000000..ae1ea4f --- /dev/null +++ b/.github/workflows/issue-open.yml @@ -0,0 +1,17 @@ +name: Handle issue open events +on: + workflow_call: + secrets: + LE_BOT_APP_ID: + description: 'GitHub App ID for authentication' + required: true + LE_BOT_PRIVATE_KEY: + description: 'GitHub App Private Key for authentication' + required: true +jobs: + manage-issue-header: + name: Manage issue header + uses: learningequality/.github/.github/workflows/manage-issue-header.yml@main + secrets: + LE_BOT_APP_ID: ${{ secrets.LE_BOT_APP_ID }} + LE_BOT_PRIVATE_KEY: ${{ secrets.LE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/manage-issue-header.yml b/.github/workflows/manage-issue-header.yml index 48d7fcd..fe03654 100644 --- a/.github/workflows/manage-issue-header.yml +++ b/.github/workflows/manage-issue-header.yml @@ -11,11 +11,6 @@ on: jobs: manage-issue-header: runs-on: ubuntu-latest - if: | - github.event.action == 'opened' || - github.event.action == 'reopened' || - (github.event.action == 'labeled' && github.event.label.name == 'help wanted') || - (github.event.action == 'unlabeled' && github.event.label.name == 'help wanted') steps: - name: Generate GitHub token id: generate-token diff --git a/docs/community-automations.md b/docs/community-automations.md index 2a51041..2cdf762 100644 --- a/docs/community-automations.md +++ b/docs/community-automations.md @@ -3,23 +3,61 @@ Manages GitHub issue comments. Sends Slack notifications and GitHub bot replies. | Contributor type | Issue type | Comment type | #support-dev | #support-dev-notifications | GitHub bot | GitHub bot message | -|------------------|------------|--------------|--------------|---------------------------|------------------|-------------| +|------------------|------------|--------------|--------------|---------------------------|------------|-------------------| | **Core team** | Any | Any | No | No | No | - | -| **Close contributor** | Any | Any | **Yes** | No | No | - | -| **Issue creator** | `help-wanted` | Any | **Yes** | No | No | - | -| **Issue creator** | Private | Any | No | Yes | No | - | -| **Other** | Private | Regular | No | Yes | No | - | -| **Other** | Private | Assignment request | No | Yes | Yes`*` | `BOT_MESSAGE_ISSUE_NOT_OPEN` | -| **Other** | Unassigned `help-wanted` | Any | **Yes** | No | No | - | -| **Other** | `help-wanted` assigned to the comment author | Any | **Yes** | No | No | - | -| **Other** | `help-wanted` assigned to someone else | Regular | No | Yes | No | - | -| **Other** | `help-wanted` assigned to someone else | Assignment request | No | Yes | Yes`*` | `BOT_MESSAGE_ALREADY_ASSIGNED` | +| **Close contributor** | Any | regular, [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | **Yes** | No | No | - | +| **Issue creator** | `help-wanted` | regular, [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | **Yes** | No | No | - | +| **Issue creator** | Private | regular, [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | No | Yes | No | - | +| **Other** | Private | regular | No | Yes | No | - | +| **Other** | Private | [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | No | Yes | Yes`*` | `BOT_MESSAGE_ISSUE_NOT_OPEN` | +| **Other** | Unassigned `help-wanted` (not good first issue) | regular, [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | **Yes** | No | No | - | +| **Other** | Unassigned `help-wanted` + good first issue | regular | **Yes** | No | No | - | +| **Other** | Unassigned `help-wanted` + good first issue | [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | **Yes** | No | Yes`*` | `BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE` | +| **Other** | `help-wanted` assigned to commenter | regular, [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | **Yes** | No | No | - | +| **Other** | `help-wanted` assigned to someone else | regular | No | Yes | No | - | +| **Other** | `help-wanted` assigned to someone else | [assign keyword](https://github.com/learningequality/.github/blob/main/scripts/constants.js#L44) | No | Yes | Yes`*` | `BOT_MESSAGE_ALREADY_ASSIGNED` | +| **Close contributor** | Private | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ISSUE_NOT_OPEN` | +| **Close contributor** | `help-wanted` assigned to commenter | `/assign` command | No | No | No | - (silent no-op) | +| **Close contributor** | `help-wanted` assigned to someone else | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ALREADY_ASSIGNED` | +| **Close contributor** | Unassigned `help-wanted` (not good first issue) | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE` | +| **Close contributor** | Unassigned `help-wanted` + good first issue, under limit | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ASSIGN_SUCCESS` | +| **Close contributor** | Unassigned `help-wanted` + good first issue, at limit | `/assign` command | No | Yes | Yes | Dynamic at-limit message | +| **Issue creator** | Private | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ISSUE_NOT_OPEN` | +| **Issue creator** | `help-wanted` assigned to commenter | `/assign` command | No | No | No | - (silent no-op) | +| **Issue creator** | `help-wanted` assigned to someone else | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ALREADY_ASSIGNED` | +| **Issue creator** | Unassigned `help-wanted` (not good first issue) | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE` | +| **Issue creator** | Unassigned `help-wanted` + good first issue, under limit | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ASSIGN_SUCCESS` | +| **Issue creator** | Unassigned `help-wanted` + good first issue, at limit | `/assign` command | No | Yes | Yes | Dynamic at-limit message | +| **Other** | Private | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ISSUE_NOT_OPEN` | +| **Other** | `help-wanted` assigned to commenter | `/assign` command | No | No | No | - (silent no-op) | +| **Other** | `help-wanted` assigned to someone else | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ALREADY_ASSIGNED` | +| **Other** | Unassigned `help-wanted` (not good first issue) | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE` | +| **Other** | Unassigned `help-wanted` + good first issue, under limit | `/assign` command | No | Yes | Yes | `BOT_MESSAGE_ASSIGN_SUCCESS` | +| **Other** | Unassigned `help-wanted` + good first issue, at limit | `/assign` command | No | Yes | Yes | Dynamic at-limit message | `*` There is an additional optimization that prevents more than one bot message per hour to not overwhelm issue comment section -In `scripts/contants.js` set: +**`/assign` command** applies to all external contributors (close contributors, issue creators, and others). Core team members never reach the script. `/assign` must be the entire comment (trimmed, case-insensitive). All `/assign` Slack activity goes to `#support-dev-notifications` only. + +**`/assign` cross-repo limit:** Contributors can have up to 2 assigned issues across all community repos (`COMMUNITY_REPOS`). Issues unassigned within the last 7 days count toward the limit (`currentAssignments + recentUnassignments >= MAX_ASSIGNED_ISSUES`). + +In `scripts/constants.js` set: - `BOT_MESSAGE_ISSUE_NOT_OPEN`: _Issue not open for contribution_ message text - `BOT_MESSAGE_ALREADY_ASSIGNED`: _Issue already assigned_ message text +- `BOT_MESSAGE_ASSIGN_SUCCESS`: Assignment confirmation message +- `BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE`: Decline message for non-good-first-issue issues +- `BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE`: Keyword reply with `/assign` guidance + +# `good-first-issue-comment` + +Posts a guidance comment when the `good first issue` label is applied to an issue (triggered via the `issue-label` workflow). Explains the `/assign` command, issue limits, cooldown, and links to contributing guidelines. + +- Only posts if the issue also has `help wanted` label +- Deletes any previous guidance comment from the bot before posting a new one +- Identified by the `` HTML comment marker + +In `scripts/constants.js` set: +- `BOT_MESSAGE_GOOD_FIRST_ISSUE_GUIDANCE`: Guidance message text # `contributor-pr-reply` diff --git a/scripts/constants.js b/scripts/constants.js index 851349e..60120dc 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -82,6 +82,10 @@ const KEYWORDS_DETECT_ASSIGNMENT_REQUEST = [ ]; const ISSUE_LABEL_HELP_WANTED = 'help wanted'; +const ISSUE_LABEL_GOOD_FIRST_ISSUE = 'good first issue'; +const MAX_ASSIGNED_ISSUES = 2; +const ASSIGN_COOLDOWN_DAYS = 7; +const ASSIGN_GUIDANCE_MARKER = ''; const LABEL_COMMUNITY_REVIEW = 'community-review'; // Will be attached to bot messages when not empty @@ -92,7 +96,52 @@ const BOT_MESSAGE_ISSUE_NOT_OPEN = `Hi! šŸ‘‹ \n\n Thanks so much for your intere const BOT_MESSAGE_ALREADY_ASSIGNED = `Hi! šŸ‘‹ \n\n Thanks so much for your interest! **This issue is already assigned. 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 = (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.**`; +const BOT_MESSAGE_GOOD_FIRST_ISSUE_GUIDANCE = + `${ASSIGN_GUIDANCE_MARKER}\n\n` + + `Hi! šŸ‘‹\n\n` + + `This issue is available for contribution and supports ` + + `**self-assignment**. Here's how to get started:\n\n` + + `- **Comment \`/assign\` to assign yourself** to this issue\n` + + `- You can have up to **${MAX_ASSIGNED_ISSUES} issues** assigned ` + + `at a time across all community repos\n` + + `- Dropping an issue has a **${ASSIGN_COOLDOWN_DAYS}-day cooldown** ` + + `before the slot opens up\n` + + `- **Link your pull request** to this issue when you submit it` + + `\n\nšŸ“– **Read the [Contributing guidelines]` + + `(https://learningequality.org/contributing-to-our-open-code-base/)` + + ` before starting.**${GSOC_NOTE}`; + +const BOT_MESSAGE_ASSIGN_SUCCESS = + `Hi! šŸ‘‹\n\n` + + `You've been assigned to this issue. Here's what to do next:\n\n` + + `- **Read the issue description** carefully and make sure ` + + `you understand the requirements\n` + + `- **Link your pull request** to this issue when you submit it\n` + + `- If you can no longer work on this, **unassign yourself** ` + + `so others can pick it up\n\n` + + `Good luck! 😊${GSOC_NOTE}`; + +const BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE = + `Hi! šŸ‘‹\n\n` + + `Self-assignment via \`/assign\` is only available for issues ` + + `labeled **\`good first issue\`**. This issue does not have ` + + `that label.\n\n` + + `Visit [Contributing guidelines]` + + `(https://learningequality.org/contributing-to-our-open-code-base/)` + + ` to learn about the contributing process and how to find ` + + `suitable issues. 😊${GSOC_NOTE}`; + +const BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE = + `Hi! šŸ‘‹\n\n` + + `Thanks for your interest! This issue supports ` + + `**self-assignment**. **Comment \`/assign\` to assign ` + + `yourself.**\n\n` + + `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 = 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.**`; 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}`; @@ -119,8 +168,17 @@ module.exports = { CLOSE_CONTRIBUTORS, KEYWORDS_DETECT_ASSIGNMENT_REQUEST, ISSUE_LABEL_HELP_WANTED, + GSOC_NOTE, + ISSUE_LABEL_GOOD_FIRST_ISSUE, + MAX_ASSIGNED_ISSUES, + ASSIGN_COOLDOWN_DAYS, + ASSIGN_GUIDANCE_MARKER, BOT_MESSAGE_ISSUE_NOT_OPEN, BOT_MESSAGE_ALREADY_ASSIGNED, + BOT_MESSAGE_GOOD_FIRST_ISSUE_GUIDANCE, + BOT_MESSAGE_ASSIGN_SUCCESS, + BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE, + BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE, BOT_MESSAGE_PULL_REQUEST, BOT_MESSAGE_RTIBBLESBOT_REVIEW, RTIBBLESBOT_USERNAME, diff --git a/scripts/contributor-issue-comment.js b/scripts/contributor-issue-comment.js index 2f00bf3..b364460 100644 --- a/scripts/contributor-issue-comment.js +++ b/scripts/contributor-issue-comment.js @@ -4,8 +4,15 @@ const { LE_BOT_USERNAME, KEYWORDS_DETECT_ASSIGNMENT_REQUEST, ISSUE_LABEL_HELP_WANTED, + ISSUE_LABEL_GOOD_FIRST_ISSUE, + MAX_ASSIGNED_ISSUES, + ASSIGN_COOLDOWN_DAYS, + GSOC_NOTE, BOT_MESSAGE_ISSUE_NOT_OPEN, BOT_MESSAGE_ALREADY_ASSIGNED, + BOT_MESSAGE_ASSIGN_SUCCESS, + BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE, + BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE, COMMUNITY_REPOS, } = require('./constants'); const { @@ -13,9 +20,10 @@ const { sendBotMessage, escapeIssueTitleForSlackMessage, hasRecentBotComment, - hasLabel, + getLabels, getIssues, getPullRequests, + getRecentUnassignments, } = require('./utils'); // Format information about author's assigned open issues @@ -40,6 +48,152 @@ function formatAuthorActivity(issues, pullRequests) { return `(${parts.join(' | ')})`; } +function formatAssignAtLimitMessage(assignedIssues, recentUnassignments) { + let message = + `Hi! šŸ‘‹\n\n` + + `You can't be assigned to this issue right now because ` + + `you've reached the **${MAX_ASSIGNED_ISSUES}-issue limit**.`; + + if (assignedIssues.length > 0) { + message += '\n\n**Your current assignments:**\n'; + message += assignedIssues.map(i => `- ${i.html_url}`).join('\n'); + } + + if (recentUnassignments.length > 0) { + const cooldownMs = ASSIGN_COOLDOWN_DAYS * 24 * 60 * 60 * 1000; + message += '\n\n**Recently dropped issues (cooldown):**\n'; + message += recentUnassignments + .map(u => { + const daysRemaining = Math.ceil( + (cooldownMs - (Date.now() - new Date(u.unassignedAt).getTime())) / (24 * 60 * 60 * 1000), + ); + return `- ${u.issueUrl} (${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining)`; + }) + .join('\n'); + } + + message += `\n\nOnce a slot opens up, come back and comment \`/assign\` again. 😊${GSOC_NOTE}`; + return message; +} + +async function sendAssignReplyAndNotify(issueNumber, message, slackSuffix, ctx) { + const { repo, issueUrl, issueTitle, github, context, core } = ctx; + const url = await sendBotMessage(issueNumber, message, { github, context, core }); + if (url) { + core.setOutput( + 'support_dev_notifications_bot', + `*[${repo}] <${url}|${slackSuffix}> on issue: <${issueUrl}|${issueTitle}>*`, + ); + } +} + +async function handleAssignCommand({ + issueNumber, + issueUrl, + issueTitle, + commentAuthor, + commentId, + issueAssignees, + isHelpWanted, + isGoodFirstIssue, + repo, + owner, + github, + context, + core, +}) { + const ctx = { repo, issueUrl, issueTitle, github, context, core }; + const slackRequest = + `*[${repo}] ` + + `<${issueUrl}#issuecomment-${commentId}|/assign comment> ` + + `on issue: <${issueUrl}|${issueTitle}> ` + + `by _${commentAuthor}_*`; + + // Not help wanted + if (!isHelpWanted) { + core.setOutput('support_dev_notifications_message', slackRequest); + await sendAssignReplyAndNotify( + issueNumber, + BOT_MESSAGE_ISSUE_NOT_OPEN, + '/assign rejected - not open', + ctx, + ); + return; + } + + // Already assigned to commenter — silent no-op, no Slack + if (issueAssignees.includes(commentAuthor)) { + core.info(`${commentAuthor} already assigned to #${issueNumber}`); + return; + } + + // Slack notification: /assign requested (after no-op checks) + core.setOutput('support_dev_notifications_message', slackRequest); + + // Assigned to someone else + if (issueAssignees.length > 0) { + await sendAssignReplyAndNotify( + issueNumber, + BOT_MESSAGE_ALREADY_ASSIGNED, + '/assign rejected - already assigned', + ctx, + ); + return; + } + + if (!isGoodFirstIssue) { + await sendAssignReplyAndNotify( + issueNumber, + BOT_MESSAGE_ASSIGN_NOT_GOOD_FIRST_ISSUE, + '/assign rejected - not good first issue', + ctx, + ); + return; + } + + // --- Limit check and assignment --- + + // Check cross-repo limits and cooldown + const [assignedIssues, recentUnassignments] = await Promise.all([ + getIssues(commentAuthor, 'open', owner, COMMUNITY_REPOS, github, core), + getRecentUnassignments( + commentAuthor, + ASSIGN_COOLDOWN_DAYS, + owner, + COMMUNITY_REPOS, + github, + core, + ), + ]); + + // Filter unassignments to exclude currently assigned issues + const assignedUrls = new Set(assignedIssues.map(i => i.html_url)); + const filteredUnassignments = recentUnassignments.filter(u => !assignedUrls.has(u.issueUrl)); + + const totalSlots = assignedIssues.length + filteredUnassignments.length; + + if (totalSlots >= MAX_ASSIGNED_ISSUES) { + const message = formatAssignAtLimitMessage(assignedIssues, filteredUnassignments); + await sendAssignReplyAndNotify(issueNumber, message, '/assign rejected - at limit', ctx); + return; + } + + // Assign the contributor + await github.rest.issues.addAssignees({ + owner, + repo, + issue_number: issueNumber, + assignees: [commentAuthor], + }); + + await sendAssignReplyAndNotify( + issueNumber, + BOT_MESSAGE_ASSIGN_SUCCESS, + `/assign approved - assigned _${commentAuthor}_`, + ctx, + ); +} + function shouldSendBotReply( issueCreator, commentAuthor, @@ -47,6 +201,8 @@ function shouldSendBotReply( isHelpWanted, isAssignmentRequest, isIssueAssignedToSomeoneElse, + isGoodFirstIssue, + isUnassigned, ) { if (commentAuthorIsCloseContributor) { return [false, null]; @@ -64,6 +220,11 @@ function shouldSendBotReply( return [true, BOT_MESSAGE_ISSUE_NOT_OPEN]; } + // Keyword on unassigned GFI → guide to /assign + if (isHelpWanted && isGoodFirstIssue && isUnassigned && isAssignmentRequest) { + return [true, BOT_MESSAGE_KEYWORD_GOOD_FIRST_ISSUE]; + } + return [false, null]; } @@ -105,20 +266,38 @@ module.exports = async ({ github, context, core }) => { const isAssignmentRequest = keywordRegexes.find(regex => regex.test(commentBody)); const isIssueAssignedToSomeoneElse = issueAssignees && issueAssignees.length > 0 && !issueAssignees.includes(commentAuthor); - const isHelpWanted = await hasLabel( - ISSUE_LABEL_HELP_WANTED, - owner, - repo, - issueNumber, - github, - core, - ); - const commentAuthorIsCloseContributor = await isCloseContributor(commentAuthor, { - github, - context, - core, - }); + const [labels, commentAuthorIsCloseContributor] = await Promise.all([ + getLabels(owner, repo, issueNumber, github, core), + isCloseContributor(commentAuthor, { github, context, core }), + ]); + const isHelpWanted = labels.includes(ISSUE_LABEL_HELP_WANTED.toLowerCase()); + const isGoodFirstIssue = labels.includes(ISSUE_LABEL_GOOD_FIRST_ISSUE.toLowerCase()); + + // Handle /assign command — early return skips normal flow. + // This intentionally bypasses shouldContactSupport so + // /assign activity never reaches #support-dev (only + // #support-dev-notifications). See docs/community-automations.md. + const isAssignCommand = commentBody.trim().toLowerCase() === '/assign'; + if (isAssignCommand) { + await handleAssignCommand({ + issueNumber, + issueUrl, + issueTitle, + commentAuthor, + commentId, + issueAssignees, + isHelpWanted, + isGoodFirstIssue, + repo, + owner, + github, + context, + core, + }); + return; + } + const isUnassigned = issueAssignees.length === 0; const [shouldPostBot, botMessage] = shouldSendBotReply( issueCreator, commentAuthor, @@ -126,6 +305,8 @@ module.exports = async ({ github, context, core }) => { isHelpWanted, isAssignmentRequest, isIssueAssignedToSomeoneElse, + isGoodFirstIssue, + isUnassigned, ); if (shouldPostBot) { // post bot reply only when there are no same bot comments diff --git a/scripts/good-first-issue-comment.js b/scripts/good-first-issue-comment.js new file mode 100644 index 0000000..047e4e2 --- /dev/null +++ b/scripts/good-first-issue-comment.js @@ -0,0 +1,50 @@ +// See docs/community-automations.md + +const { + LE_BOT_USERNAME, + ISSUE_LABEL_HELP_WANTED, + ASSIGN_GUIDANCE_MARKER, + BOT_MESSAGE_GOOD_FIRST_ISSUE_GUIDANCE, +} = require('./constants'); +const { sendBotMessage, deleteBotComments, hasLabel } = require('./utils'); + +module.exports = async ({ github, context, core }) => { + try { + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.payload.issue.number; + + // Only post guidance if the issue is also help wanted + const isHelpWanted = await hasLabel( + ISSUE_LABEL_HELP_WANTED, + owner, + repo, + issueNumber, + github, + core, + ); + + if (!isHelpWanted) { + core.info(`Issue #${issueNumber} is not help wanted, skipping guidance comment`); + return; + } + + // Delete any previous guidance comments + await deleteBotComments(issueNumber, LE_BOT_USERNAME, ASSIGN_GUIDANCE_MARKER, { + github, + context, + core, + }); + + // Post new guidance comment + await sendBotMessage(issueNumber, BOT_MESSAGE_GOOD_FIRST_ISSUE_GUIDANCE, { + github, + context, + core, + }); + + core.info(`Posted guidance comment on issue #${issueNumber}`); + } catch (error) { + core.setFailed(`Error: ${error.message}`); + } +}; diff --git a/scripts/utils.js b/scripts/utils.js index c9e4510..5a7a002 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -171,22 +171,28 @@ async function hasRecentBotComment( } /** - * Checks if an issue has a label with the given name (case-insensitive). + * Fetches all label names for an issue. Returns an array of lowercase label strings. */ -async function hasLabel(name, owner, repo, issueNumber, github, core) { - let labels = []; +async function getLabels(owner, repo, issueNumber, github, core) { try { const allLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, { owner, repo, issue_number: issueNumber, }); - labels = allLabels.map(label => label.name); + return allLabels.map(label => label.name.toLowerCase()); } catch (error) { core.warning(`Failed to fetch labels on issue #${issueNumber}: ${error.message}`); - labels = []; + return []; } - return labels.some(label => label.toLowerCase() === name.toLowerCase()); +} + +/** + * Checks if an issue has a label with the given name (case-insensitive). + */ +async function hasLabel(name, owner, repo, issueNumber, github, core) { + const labels = await getLabels(owner, repo, issueNumber, github, core); + return labels.includes(name.toLowerCase()); } /** @@ -234,6 +240,99 @@ async function getPullRequests(author, state, owner, repos, github, core) { return results.flat(); } +/** + * Deletes bot comments on an issue that contain a specific marker string. + */ +async function deleteBotComments(issueNumber, botUsername, marker, { github, context, core }) { + const owner = context.repo.owner; + const repo = context.repo.repo; + + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + }); + + const toDelete = comments.filter( + c => c.user?.login === botUsername && c.body?.includes(marker), + ); + await Promise.all( + toDelete.map(async comment => { + await github.rest.issues.deleteComment({ owner, repo, comment_id: comment.id }); + core.info(`Deleted bot comment ${comment.id} on issue #${issueNumber}`); + }), + ); + } catch (error) { + core.warning(`Failed to delete bot comments on #${issueNumber}: ` + error.message); + } +} + +/** + * Finds recent unassignment events for a user across repos. + * Uses the search API to find candidate issues, then checks + * timeline events for unassigned events within the cutoff. + */ +async function getRecentUnassignments(username, daysAgo, owner, repos, github, core) { + const cutoff = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); + const since = cutoff.toISOString().split('T')[0]; + + const promises = repos.map(async repo => { + const repoUnassignments = []; + try { + const q = `involves:${username} repo:${owner}/${repo} is:issue updated:>=${since}`; + const { data } = await github.rest.search.issuesAndPullRequests({ q }); + + for (const issue of data.items || []) { + try { + const events = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: issue.number, + per_page: 100, + }); + + for (const event of events) { + if ( + event.event === 'unassigned' && + event.assignee?.login?.toLowerCase() === username.toLowerCase() && + new Date(event.created_at) >= cutoff + ) { + repoUnassignments.push({ + repo, + issueNumber: issue.number, + issueUrl: issue.html_url, + issueTitle: issue.title, + unassignedAt: event.created_at, + }); + } + } + } catch (tlError) { + core.warning(`Failed to fetch timeline for ${repo}#${issue.number}: ${tlError.message}`); + } + } + } catch (error) { + core.warning(`Failed to search issues in ${repo}: ${error.message}`); + } + return repoUnassignments; + }); + + const results = await Promise.all(promises); + const unassignments = results.flat(); + + // Deduplicate by issueUrl, keeping the most recent unassignment event. + // A user could be assigned/unassigned multiple times on the same issue + // within the cooldown window, and each event should only count once. + const byIssue = new Map(); + for (const u of unassignments) { + const existing = byIssue.get(u.issueUrl); + if (!existing || new Date(u.unassignedAt) > new Date(existing.unassignedAt)) { + byIssue.set(u.issueUrl, u); + } + } + return [...byIssue.values()]; +} + module.exports = { isContributor, isCloseContributor, @@ -241,7 +340,10 @@ module.exports = { sendBotMessage, escapeIssueTitleForSlackMessage, hasRecentBotComment, + getLabels, hasLabel, getIssues, getPullRequests, + deleteBotComments, + getRecentUnassignments, };