diff --git a/.github/scripts/__tests__/jest/issue-assign.test.js b/.github/scripts/__tests__/jest/issue-assign.test.js index 3453ca4de..9a97a6040 100644 --- a/.github/scripts/__tests__/jest/issue-assign.test.js +++ b/.github/scripts/__tests__/jest/issue-assign.test.js @@ -10,6 +10,7 @@ jest.mock('../../shared/api/github-api', () => ({ isRepoCollaborator: jest.fn(), postIssueComment: jest.fn(), fetchAllComments: jest.fn(), + getIssue: jest.fn(), assignIssue: jest.fn(), })); @@ -38,6 +39,7 @@ jest.mock('../../shared/helpers/spam', () => ({ const { runAssignmentFlow } = require('../../shared/core/issue-assign'); const githubApi = require('../../shared/api/github-api'); +const comment = require('../../shared/helpers/comment'); const spam = require('../../shared/helpers/spam'); const { @@ -90,6 +92,12 @@ beforeEach(() => { githubApi.isRepoCollaborator.mockResolvedValue(false); githubApi.fetchAllComments.mockResolvedValue([]); githubApi.postIssueComment.mockResolvedValue(); + githubApi.getIssue.mockResolvedValue({ + number: 10, + state: 'open', + assignees: [], + labels: [{ name: 'skill: beginner' }], + }); githubApi.assignIssue.mockResolvedValue(); spam.isSpamUser.mockReturnValue(false); @@ -436,6 +444,13 @@ describe('runAssignmentFlow - prerequisites', () => { }, }); + githubApi.getIssue.mockResolvedValue({ + number: 20, + state: 'open', + assignees: [], + labels: [{ name: 'skill: intermediate' }], + }); + await runAssignmentFlow({ github, context }); expect(githubApi.countCompletedIssuesWithLabel).toHaveBeenCalled(); @@ -638,7 +653,11 @@ describe('runAssignmentFlow - assignment', () => { github, 'hiero-ledger', 'hiero-sdk-python', - context.payload.issue + expect.objectContaining({ + number: 10, + state: 'open', + assignees: [], + }) ); expect(githubApi.postIssueComment).not.toHaveBeenCalled(); @@ -677,6 +696,97 @@ describe('runAssignmentFlow - assignment', () => { }); }); +describe('runAssignmentFlow - fresh issue state', () => { + test('does not assign when the webhook payload is stale but the issue is now assigned', async () => { + githubApi.getIssue.mockResolvedValue({ + number: 10, + state: 'open', + assignees: [{ login: 'current-assignee' }], + labels: [{ name: 'skill: beginner' }], + }); + + const github = createGithub(); + const context = createContext(); + + await runAssignmentFlow({ github, context }); + + expect(githubApi.getIssue).toHaveBeenCalledWith({ + github, + owner: 'hiero-ledger', + repo: 'hiero-sdk-python', + issueNumber: 10, + }); + expect(githubApi.assignIssue).not.toHaveBeenCalled(); + expect(comment.buildAlreadyAssignedComment).toHaveBeenCalledWith( + 'parv', + expect.objectContaining({ + assignees: [{ login: 'current-assignee' }], + }), + expect.objectContaining({ + owner: 'hiero-ledger', + repo: 'hiero-sdk-python', + label: 'skill: beginner', + }) + ); + expect(githubApi.postIssueComment).toHaveBeenCalledWith( + expect.objectContaining({ + issueNumber: 10, + body: 'already assigned', + }), + 'already-assigned notice' + ); + expect(triggerCodeRabbitPlan).not.toHaveBeenCalled(); + }); + + test('does not assign when the current issue cannot be fetched', async () => { + githubApi.getIssue.mockRejectedValue( + Object.assign(new Error('GitHub API unavailable'), { status: 503 }) + ); + + const github = createGithub(); + const context = createContext(); + + await runAssignmentFlow({ github, context }); + + expect(githubApi.assignIssue).not.toHaveBeenCalled(); + expect(githubApi.postIssueComment).not.toHaveBeenCalled(); + }); + + test('does not assign when the fresh issue no longer has the skill label', async () => { + githubApi.getIssue.mockResolvedValue({ + number: 10, + state: 'open', + assignees: [], + labels: [{ name: 'help wanted' }], + }); + + const github = createGithub(); + const context = createContext(); + + await runAssignmentFlow({ github, context }); + + expect(githubApi.assignIssue).not.toHaveBeenCalled(); + expect(githubApi.postIssueComment).not.toHaveBeenCalled(); + }); + + test('does not assign when the fresh issue is closed', async () => { + githubApi.getIssue.mockResolvedValue({ + number: 10, + state: 'closed', + assignees: [], + labels: [{ name: 'skill: beginner' }], + }); + + const github = createGithub(); + const context = createContext(); + + await runAssignmentFlow({ github, context }); + + expect(githubApi.assignIssue).not.toHaveBeenCalled(); + expect(githubApi.postIssueComment).not.toHaveBeenCalled(); + }); +}); + describe('runAssignmentFlow - error handling', () => { test('does not assign when counting completed issues throws', async () => { githubApi.countCompletedIssuesWithLabel.mockRejectedValue( diff --git a/.github/scripts/shared/api/github-api.js b/.github/scripts/shared/api/github-api.js index cb4aa34bb..f86d8eb6a 100644 --- a/.github/scripts/shared/api/github-api.js +++ b/.github/scripts/shared/api/github-api.js @@ -251,6 +251,26 @@ async function fetchAllComments({ github, owner, repo, issueNumber }) { }); } +/** + * Fetches the current issue from the GitHub API. + * + * @param {object} params + * @param {import('@actions/github').GitHub} params.github + * @param {string} params.owner - Repo owner. + * @param {string} params.repo - Repo name. + * @param {number} params.issueNumber - Issue number. + * @returns {Promise} Current issue payload from the GitHub API. + * @throws {Error} Re-throws the Octokit error on API failure. + */ +async function getIssue({ github, owner, repo, issueNumber }) { + const { data } = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + return data; +} + /** * Assigns a contributor to an issue. * @@ -285,5 +305,6 @@ module.exports = { isRepoCollaborator, postIssueComment, fetchAllComments, + getIssue, assignIssue, }; diff --git a/.github/scripts/shared/core/issue-assign.js b/.github/scripts/shared/core/issue-assign.js index 45eb250de..855592cda 100644 --- a/.github/scripts/shared/core/issue-assign.js +++ b/.github/scripts/shared/core/issue-assign.js @@ -32,6 +32,7 @@ const { isRepoCollaborator, postIssueComment, fetchAllComments, + getIssue, assignIssue, } = require('../api/github-api.js'); @@ -276,6 +277,46 @@ async function runAssignmentFlow({ github, context }) { } } + // Refetch immediately before the write. Concurrency queues later + // /assign runs, but their webhook payload stays stale. + let currentIssue; + try { + currentIssue = await getIssue({ github, owner, repo: repoName, issueNumber }); + } catch (error) { + console.error('[assign-bot] Failed to fetch current issue state before assignment:', { + message: error.message, + status: error.status, + owner, + repo: repoName, + issueNumber, + commenter, + }); + return; + } + + if (resolveLevelKey(currentIssue, repoConfig) !== levelKey) { + console.log('[assign-bot] Exit: current issue no longer matches the original skill level', { + issueNumber, + levelKey, + }); + return; + } + + if (currentIssue.state !== 'open') { + console.log('[assign-bot] Exit: current issue state is not open', { + issueNumber, + state: currentIssue.state, + }); + return; + } + + if (Array.isArray(currentIssue.assignees) && currentIssue.assignees.length > 0) { + console.log('[assign-bot] Exit: current issue state is already assigned', { issueNumber }); + const body = buildAlreadyAssignedComment(commenter, currentIssue, { owner, repo: repoName, label }); + await postIssueComment({ github, owner, repo: repoName, issueNumber, body }, 'already-assigned notice'); + return; + } + // Assign try { await assignIssue({ @@ -306,7 +347,7 @@ async function runAssignmentFlow({ github, context }) { github, owner, repoName, - issue + currentIssue ); } else if (planExists === true) { console.log(