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
67 changes: 40 additions & 27 deletions actions/setup/js/create_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ async function main(config = {}) {

// Cache for parent issue per group ID
const parentIssueCache = new Map();
// Track in-flight parent issue lookups/creations by group ID to dedupe concurrent requests
const parentIssuePending = new Map();

// Extract triggering context for footer generation
const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
Expand All @@ -322,15 +324,6 @@ async function main(config = {}) {
* @returns {Promise<Object>} Result with success/error status and issue details
*/
return async function handleCreateIssue(message, resolvedTemporaryIds) {
// Check if we've hit the max limit
if (processedCount >= maxCount) {
core.warning(`Skipping create_issue: max count of ${maxCount} reached`);
return {
success: false,
error: `Max count of ${maxCount} reached`,
};
}

// Merge external resolved temp IDs with our local map
if (resolvedTemporaryIds) {
for (const [tempId, resolved] of Object.entries(resolvedTemporaryIds)) {
Expand Down Expand Up @@ -568,8 +561,16 @@ async function main(config = {}) {
}
}

// Increment processed count only when we are about to create an issue
// Atomically enforce max count for issue creation paths.
// Keep the check and increment adjacent with no await between them.
// (group-by-day comment paths return above without consuming a slot)
if (processedCount >= maxCount) {
core.warning(`Skipping create_issue: max count of ${maxCount} reached`);
return {
success: false,
error: `Max count of ${maxCount} reached`,
};
}
processedCount++;

core.info(`Creating issue in ${qualifiedItemRepo} with title: ${title}`);
Expand Down Expand Up @@ -679,25 +680,37 @@ async function main(config = {}) {
let groupParentNumber = parentIssueCache.get(groupId);

if (!groupParentNumber) {
// Not in cache, find or create parent
// Parent issue expires 1 day (24 hours) after sub-issues
const parentExpiresHours = expiresHours > 0 ? expiresHours + 24 : 0;
groupParentNumber = await findOrCreateParentIssue({
githubClient: githubClient,
groupId,
owner: repoParts.owner,
repo: repoParts.repo,
titlePrefix,
labels,
workflowName,
workflowSourceURL,
expiresHours: parentExpiresHours,
});
let pendingParentLookup = parentIssuePending.get(groupId);
if (!pendingParentLookup) {
// Parent issue expires 1 day (24 hours) after sub-issues
const parentExpiresHours = expiresHours > 0 ? expiresHours + 24 : 0;
pendingParentLookup = findOrCreateParentIssue({
githubClient: githubClient,
groupId,
owner: repoParts.owner,
repo: repoParts.repo,
titlePrefix,
labels,
workflowName,
workflowSourceURL,
expiresHours: parentExpiresHours,
}).then(parentNumber => {
if (parentNumber) {
// Cache the parent issue number for this group
parentIssueCache.set(groupId, parentNumber);
}
return parentNumber;
});

if (groupParentNumber) {
// Cache the parent issue number for this group
parentIssueCache.set(groupId, groupParentNumber);
parentIssuePending.set(
groupId,
pendingParentLookup.finally(() => {
parentIssuePending.delete(groupId);
})
);
}

groupParentNumber = await parentIssuePending.get(groupId);
}

if (groupParentNumber) {
Expand Down
122 changes: 122 additions & 0 deletions actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createRequire } from "module";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

const require = createRequire(import.meta.url);
const { main, getIssuesToAssignCopilot, resetIssuesToAssignCopilot } = require("./create_issue.cjs");
Expand Down Expand Up @@ -300,6 +303,42 @@ describe("create_issue", () => {
expect(result3.success).toBe(false);
expect(result3.error).toContain("Max count of 2 reached");
});

it("should enforce max count under concurrent calls", async () => {
const handler = await main({ max: 1 });

let releaseCreate;
const createGate = new Promise(resolve => {
releaseCreate = resolve;
});
mockGithub.rest.issues.create.mockImplementation(async () => {
await createGate;
return {
data: {
number: 123,
html_url: "https://github.com/owner/repo/issues/123",
title: "Test Issue",
},
};
});

const pendingCalls = Array.from({ length: 5 }, () =>
handler({
title: "Concurrent Test Issue",
body: "Body",
})
);

releaseCreate();
const results = await Promise.all(pendingCalls);

const successes = results.filter(result => result.success);
const failures = results.filter(result => !result.success);
expect(successes).toHaveLength(1);
expect(failures).toHaveLength(4);
expect(failures.every(result => result.error.includes("Max count of 1 reached"))).toBe(true);
expect(mockGithub.rest.issues.create).toHaveBeenCalledTimes(1);
});
});

describe("title prefix", () => {
Expand Down Expand Up @@ -478,6 +517,89 @@ describe("create_issue", () => {
const createCall = mockGithub.rest.issues.create.mock.calls[0][0];
expect(createCall.body).toContain("Related to #456");
});

it("should deduplicate parent issue creation for concurrent grouped calls", async () => {
const originalRunnerTemp = process.env.RUNNER_TEMP;
const runnerTemp = mkdtempSync(join(tmpdir(), "gh-aw-create-issue-"));
const promptsDir = join(runnerTemp, "gh-aw", "prompts");
mkdirSync(promptsDir, { recursive: true });
writeFileSync(join(promptsDir, "issue_group_parent.md"), "Parent issue for {{group_id}}");
process.env.RUNNER_TEMP = runnerTemp;

try {
const handler = await main({ group: true });
mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({
data: {
total_count: 0,
items: [],
},
});

let parentIssueNumber = 900;
let childIssueNumber = 100;
let parentCreateCallCount = 0;
let releaseParentCreate;
const parentCreateGate = new Promise(resolve => {
releaseParentCreate = resolve;
});

mockGithub.rest.issues.create.mockImplementation(async request => {
if (request.title.includes("Issue Group")) {
parentCreateCallCount++;
await parentCreateGate;
return {
data: {
number: parentIssueNumber++,
html_url: `https://github.com/${request.owner}/${request.repo}/issues/${parentIssueNumber - 1}`,
title: request.title,
},
};
}

return {
data: {
number: childIssueNumber++,
html_url: `https://github.com/${request.owner}/${request.repo}/issues/${childIssueNumber - 1}`,
title: request.title,
},
};
});

mockGithub.graphql.mockResolvedValue({
repository: {
issue: {
id: "ISSUE_NODE_ID",
subIssues: {
totalCount: 0,
},
},
},
addSubIssue: {
subIssue: {
id: "SUB_ISSUE_ID",
number: 1,
},
},
});

const callA = handler({ title: "Grouped issue A", body: "Body A" });
const callB = handler({ title: "Grouped issue B", body: "Body B" });

releaseParentCreate();
const [resultA, resultB] = await Promise.all([callA, callB]);

expect(resultA.success).toBe(true);
expect(resultB.success).toBe(true);
expect(parentCreateCallCount).toBe(1);
} finally {
if (originalRunnerTemp === undefined) {
delete process.env.RUNNER_TEMP;
} else {
process.env.RUNNER_TEMP = originalRunnerTemp;
}
rmSync(runnerTemp, { recursive: true, force: true });
}
});
});

describe("max limit enforcement", () => {
Expand Down