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,
};