Skip to content
Closed
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
199 changes: 199 additions & 0 deletions .github/scripts/__tests__/jest/bot-assignment-fresh-issue.test.js
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);
});
});
Comment on lines +82 to +199

Copy link
Copy Markdown
Contributor

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 addAssignees is called with the correct parameters.

♻️ Suggested positive test cases
+  it('GFI bot assigns when fresh issue is open, labeled, and unassigned', async () => {
+    const context = createContext({
+      labelName: 'Good First Issue',
+      payloadAssignees: [],
+      freshAssignees: [],
+    });
+    const { github, calls } = createGithubMock(context);
+
+    await runGfiAssignBot({ github, context });
+
+    expect(calls.assignees.length).toBe(1);
+    expect(calls.assignees[0].assignees).toEqual(['new-contributor']);
+  });
+
+  it('beginner bot assigns when fresh issue is open, labeled, and unassigned', async () => {
+    const context = createContext({
+      labelName: 'skill: beginner',
+      payloadAssignees: [],
+      freshAssignees: [],
+    });
+    const { github, calls } = createGithubMock(context);
+
+    await runBeginnerAssignBot({ github, context });
+
+    expect(calls.assignees.length).toBe(1);
+    expect(calls.assignees[0].assignees).toEqual(['new-contributor']);
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
});
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);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, worth adding

45 changes: 42 additions & 3 deletions .github/scripts/bot-beginner-assign-on-comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,
Expand Down Expand Up @@ -401,4 +440,4 @@ Please try a GFI first, then come back — we’ll be happy to assign this! 😊
comment: context.payload?.comment?.id
});
}
};
};
52 changes: 47 additions & 5 deletions .github/scripts/bot-gfi-assign-on-comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,19 +305,61 @@ module.exports = async ({ github, context }) => {
const requesterUsername = comment.user.login;
const issueNumber = issue.number;

// -------------------------------
// REFETCH ISSUE STATE
// -------------------------------
// context.payload.issue is a snapshot from the original issue_comment
// webhook. In a queued/concurrent run, an earlier run may have already
// assigned the issue by the time this run executes, so we refetch the
// current issue state from the API instead of trusting the stale payload.
let freshIssue;
try {
const { data } = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber,
});
freshIssue = data;
} catch (error) {
console.error('[gfi-assign] Failed to refetch issue state, aborting to avoid a stale assignment decision:', {
message: error.message,
status: error.status,
issueNumber,
});
return;
}

console.log('[gfi-assign] Fresh issue snapshot:', {
state: freshIssue.state,
assignees: freshIssue.assignees?.map(a => a.login),
labels: freshIssue.labels?.map(l => l.name ?? l),
});

// Reject if the issue was closed between the comment and now
if (freshIssue.state === 'closed') {
console.log('[gfi-assign] Exit: issue is closed');
return;
}

// Reject if the Good First Issue label was removed between the comment and now
if (!issueIsGoodFirstIssue(freshIssue)) {
console.log('[gfi-assign] Exit: issue no longer labeled Good First Issue');
return;
}

console.log('[gfi-assign] Requester:', requesterUsername);
console.log('[gfi-assign] Current assignees:', issue.assignees?.map(a => a.login));
console.log('[gfi-assign] Current assignees (fresh):', freshIssue.assignees?.map(a => a.login));

// Reject if issue is already assigned
// Reject if issue is already assigned (checked against fresh state)
// Comment failure to the requester
if (issue.assignees?.length > 0) {
if (freshIssue.assignees?.length > 0) {
console.log('[gfi-assign] Exit: issue already assigned');

await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentAlreadyAssigned(requesterUsername, issue),
body: commentAlreadyAssigned(requesterUsername, freshIssue),
});

console.log('[gfi-assign] Posted already-assigned comment');
Expand Down Expand Up @@ -433,4 +475,4 @@ module.exports = async ({ github, context }) => {
});
throw error;
}
};
};