-
Notifications
You must be signed in to change notification settings - Fork 287
fix: refetch issue state before final assignment decision #2431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| describe('assignment bots use fresh issue state before assigning', () => { | ||
| let runGfiAssignBot; | ||
| let runBeginnerAssignBot; | ||
|
|
||
| beforeAll(() => { | ||
| runGfiAssignBot = require('../../bot-gfi-assign-on-comment.js'); | ||
| runBeginnerAssignBot = require('../../bot-beginner-assign-on-comment.js'); | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| function createContext({ labelName, payloadAssignees = [], freshLabels, freshAssignees = [], freshState = 'open' }) { | ||
| const labels = [{ name: labelName }]; | ||
|
|
||
| return { | ||
| repo: { | ||
| owner: 'hiero-ledger', | ||
| repo: 'hiero-sdk-python', | ||
| }, | ||
| payload: { | ||
| repository: { | ||
| owner: { login: 'hiero-ledger' }, | ||
| name: 'hiero-sdk-python', | ||
| }, | ||
| issue: { | ||
| number: 123, | ||
| labels, | ||
| assignees: payloadAssignees, | ||
| }, | ||
| comment: { | ||
| body: '/assign', | ||
| user: { | ||
| login: 'new-contributor', | ||
| type: 'User', | ||
| }, | ||
| }, | ||
| }, | ||
| freshIssue: { | ||
| number: 123, | ||
| title: 'Example issue', | ||
| state: freshState, | ||
| labels: freshLabels ?? labels, | ||
| assignees: freshAssignees, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function createGithubMock(context, { freshIssueError } = {}) { | ||
| const calls = { | ||
| comments: [], | ||
| assignees: [], | ||
| }; | ||
|
|
||
| const github = { | ||
| rest: { | ||
| issues: { | ||
| get: async () => { | ||
| if (freshIssueError) { | ||
| throw freshIssueError; | ||
| } | ||
| return { data: context.freshIssue }; | ||
| }, | ||
| createComment: async (params) => { | ||
| calls.comments.push(params); | ||
| return { data: {} }; | ||
| }, | ||
| addAssignees: async (params) => { | ||
| calls.assignees.push(params); | ||
| return { data: {} }; | ||
| }, | ||
| }, | ||
| }, | ||
| paginate: async () => [], | ||
| graphql: async () => ({ search: { issueCount: 1 } }), | ||
| }; | ||
|
|
||
| return { github, calls }; | ||
| } | ||
|
|
||
| it('GFI bot does not assign when webhook payload is stale but issue is now assigned', async () => { | ||
| const context = createContext({ | ||
| labelName: 'Good First Issue', | ||
| payloadAssignees: [], | ||
| freshAssignees: [{ login: 'current-assignee' }], | ||
| }); | ||
| const { github, calls } = createGithubMock(context); | ||
|
|
||
| await runGfiAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(1); | ||
| expect(calls.comments[0].body).toMatch(/already assigned/i); | ||
| expect(calls.comments[0].body).toMatch(/@current-assignee/); | ||
| }); | ||
|
|
||
| it('GFI bot exits safely when fresh issue fetch fails before assignment', async () => { | ||
| const context = createContext({ | ||
| labelName: 'Good First Issue', | ||
| payloadAssignees: [], | ||
| }); | ||
| const { github, calls } = createGithubMock(context, { | ||
| freshIssueError: Object.assign(new Error('GitHub API unavailable'), { status: 503 }), | ||
| }); | ||
|
|
||
| await runGfiAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(0); | ||
| }); | ||
|
|
||
| it('GFI bot exits when the fresh issue is no longer a Good First Issue', async () => { | ||
| const context = createContext({ | ||
| labelName: 'Good First Issue', | ||
| payloadAssignees: [], | ||
| freshLabels: [{ name: 'help wanted' }], | ||
| }); | ||
| const { github, calls } = createGithubMock(context); | ||
|
|
||
| await runGfiAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(0); | ||
| }); | ||
|
|
||
| it('GFI bot exits when the fresh issue is closed before assignment', async () => { | ||
| const context = createContext({ | ||
| labelName: 'Good First Issue', | ||
| payloadAssignees: [], | ||
| freshState: 'closed', | ||
| }); | ||
| const { github, calls } = createGithubMock(context); | ||
|
|
||
| await runGfiAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(0); | ||
| }); | ||
|
|
||
| it('beginner bot does not assign when webhook payload is stale but issue is now assigned', async () => { | ||
| const context = createContext({ | ||
| labelName: 'skill: beginner', | ||
| payloadAssignees: [], | ||
| freshAssignees: [{ login: 'current-assignee' }], | ||
| }); | ||
| const { github, calls } = createGithubMock(context); | ||
|
|
||
| await runBeginnerAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(1); | ||
| expect(calls.comments[0].body).toMatch(/already assigned/i); | ||
| expect(calls.comments[0].body).toMatch(/@current-assignee/); | ||
| }); | ||
|
|
||
| it('beginner bot exits safely when fresh issue fetch fails before assignment', async () => { | ||
| const context = createContext({ | ||
| labelName: 'skill: beginner', | ||
| payloadAssignees: [], | ||
| }); | ||
| const { github, calls } = createGithubMock(context, { | ||
| freshIssueError: Object.assign(new Error('GitHub API unavailable'), { status: 503 }), | ||
| }); | ||
|
|
||
| await runBeginnerAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(0); | ||
| }); | ||
|
|
||
| it('beginner bot exits when the fresh issue no longer has the beginner label', async () => { | ||
| const context = createContext({ | ||
| labelName: 'skill: beginner', | ||
| payloadAssignees: [], | ||
| freshLabels: [{ name: 'help wanted' }], | ||
| }); | ||
| const { github, calls } = createGithubMock(context); | ||
|
|
||
| await runBeginnerAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(0); | ||
| }); | ||
|
|
||
| it('beginner bot exits when the fresh issue is closed before assignment', async () => { | ||
| const context = createContext({ | ||
| labelName: 'skill: beginner', | ||
| payloadAssignees: [], | ||
| freshState: 'closed', | ||
| }); | ||
| const { github, calls } = createGithubMock(context); | ||
|
|
||
| await runBeginnerAssignBot({ github, context }); | ||
|
|
||
| expect(calls.assignees.length).toBe(0); | ||
| expect(calls.comments.length).toBe(0); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -256,9 +256,48 @@ Please try a GFI first, then come back — we’ll be happy to assign this! 😊 | |
| } | ||
|
|
||
| // --- ASSIGNMENT LOGIC --- | ||
| if (issue.assignees && issue.assignees.length > 0) { | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new "refetch → check closed → check label → check assignees" block (~25-30 lines) is added twice....once here, once in bot-gfi-assign-on-comment.js (lines 309-355)...nearly identical in structure. A shared helper (e.g. refetchIssueOrAbort(github, owner, repo, issueNumber, logPrefix), following the existing shared/labels.js pattern already used by both files) would collapse this into one implementation instead of two. The two copies have already diverged slightly: the GFI bot's catch-block logs {message, status, issueNumber} (line 324), this one only logs {issue, message}, no status (line 272). Small, but it's the kind of drift that happens specifically because the logic is duplicated rather than shared. |
||
| // Refetch current issue state to avoid race conditions from a stale | ||
| // webhook payload: an earlier queued run may have already assigned | ||
| // this issue by the time this run executes. | ||
| let freshIssue; | ||
| try { | ||
| const { data } = await github.rest.issues.get({ | ||
| owner: repo.owner.login, | ||
| repo: repo.name, | ||
| issue_number: issue.number, | ||
| }); | ||
| freshIssue = data; | ||
| } catch (error) { | ||
| console.error("[Beginner Bot] Failed to refetch issue state, aborting to avoid a stale assignment decision:", { | ||
| issue: issue.number, | ||
| message: error.message, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| console.log("[Beginner Bot] Fresh issue snapshot:", { | ||
| state: freshIssue.state, | ||
| assignees: freshIssue.assignees?.map((a) => a.login), | ||
| }); | ||
|
|
||
| if (freshIssue.state === "closed") { | ||
| console.log(`[Beginner Bot] Issue #${issue.number} is closed. Skipping assignment.`); | ||
| return; | ||
| } | ||
|
|
||
| const freshHasBeginnerLabel = | ||
| Array.isArray(freshIssue.labels) && | ||
| freshIssue.labels.some((label) => label.name?.toLowerCase() === beginnerLabel); | ||
|
|
||
| if (!freshHasBeginnerLabel) { | ||
| console.log(`[Beginner Bot] Issue #${issue.number} no longer has '${BEGINNER_LABEL}' label. Exiting.`); | ||
| return; | ||
| } | ||
|
|
||
| if (freshIssue.assignees && freshIssue.assignees.length > 0) { | ||
| try{ | ||
| const currentAssignee = issue.assignees[0]?.login ?? "another contributor"; | ||
| const currentAssignee = freshIssue.assignees[0]?.login ?? "another contributor"; | ||
| await github.rest.issues.createComment({ | ||
| owner: repo.owner.login, | ||
| repo: repo.name, | ||
|
|
@@ -401,4 +440,4 @@ Please try a GFI first, then come back — we’ll be happy to assign this! 😊 | |
| comment: context.payload?.comment?.id | ||
| }); | ||
| } | ||
| }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Add a positive test case verifying the bot assigns when all conditions are valid.
All 8 test cases cover negative scenarios (bot should NOT assign). A positive case — fresh issue is open, labeled, unassigned, and under the assignment limit — would verify the refetch logic doesn't break the happy path and that
addAssigneesis called with the correct parameters.♻️ Suggested positive test cases
📝 Committable suggestion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1, worth adding