From d3fe3489707c3366dd50045d51f8f9d8ad6d2941 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:36:55 +0000 Subject: [PATCH 1/2] Initial plan From 007039ba692a5f5085b4d9daebed5d9e38687e66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:32:48 +0000 Subject: [PATCH 2/2] Harden `create_issue` concurrency: atomic max enforcement and in-flight parent deduplication Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c7796b68-262b-4937-b83e-cf609bee6b08 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_issue.cjs | 67 ++++++++------ actions/setup/js/create_issue.test.cjs | 122 +++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 27 deletions(-) diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index ed66c1f0250..02f73b9d7a4 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -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; @@ -322,15 +324,6 @@ async function main(config = {}) { * @returns {Promise} 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)) { @@ -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}`); @@ -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) { diff --git a/actions/setup/js/create_issue.test.cjs b/actions/setup/js/create_issue.test.cjs index d936961fb19..eb393e780d0 100644 --- a/actions/setup/js/create_issue.test.cjs +++ b/actions/setup/js/create_issue.test.cjs @@ -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"); @@ -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", () => { @@ -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", () => {