diff --git a/actions/setup/js/close_older_issues.cjs b/actions/setup/js/close_older_issues.cjs index e7b51be8d98..014107e80c1 100644 --- a/actions/setup/js/close_older_issues.cjs +++ b/actions/setup/js/close_older_issues.cjs @@ -29,9 +29,11 @@ const API_DELAY_MS = 500; * @param {string} [closeOlderKey] - Optional explicit deduplication key. When set, the * `gh-aw-close-key` marker is used as the primary search term and exact filter instead * of the workflow-id / workflow-call-id markers. + * @param {Set} [additionalExcludeNumbers] - Optional set of additional issue numbers + * to exclude from the results (e.g. all issues created in the current run). * @returns {Promise, created_at: string}>>} Matching issues */ -async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, callerWorkflowId, closeOlderKey) { +async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, callerWorkflowId, closeOlderKey, additionalExcludeNumbers) { core.info(`Starting search for older issues in ${owner}/${repo}`); core.info(` Workflow ID: ${workflowId || "(none)"}`); core.info(` Exclude issue number: ${excludeNumber}`); @@ -68,6 +70,7 @@ async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, const { filtered: filteredItems, counters } = filterByMarker({ items: result.data.items, excludeNumber, + additionalExcludeNumbers, exactMarker, entityType: "issue", additionalFilter: (item, extra) => { @@ -183,16 +186,19 @@ function getCloseOlderIssueMessage({ newIssueUrl, newIssueNumber, workflowName, * @param {string} runUrl - URL of the workflow run * @param {string} [callerWorkflowId] - Optional calling workflow identity for precise filtering * @param {string} [closeOlderKey] - Optional explicit deduplication key for close-older matching + * @param {Set} [currentRunIssueNumbers] - Optional set of issue numbers created in the + * current run. When provided, these issues are excluded from the close-older search so that + * issues created earlier in the same run are never incorrectly closed. * @returns {Promise>} List of closed issues */ -async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl, callerWorkflowId, closeOlderKey) { +async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl, callerWorkflowId, closeOlderKey, currentRunIssueNumbers) { const result = await closeOlderEntities(github, owner, repo, workflowId, newIssue, workflowName, runUrl, { entityType: "issue", entityTypePlural: "issues", - // Use a closure so callerWorkflowId and closeOlderKey are forwarded to searchOlderIssues - // without going through the closeOlderEntities extraArgs mechanism (which appends - // excludeNumber last) - searchOlderEntities: (gh, o, r, wid, excludeNumber) => searchOlderIssues(gh, o, r, wid, excludeNumber, callerWorkflowId, closeOlderKey), + // Use a closure so callerWorkflowId, closeOlderKey, and currentRunIssueNumbers are + // forwarded to searchOlderIssues without going through the closeOlderEntities + // extraArgs mechanism (which appends excludeNumber last) + searchOlderEntities: (gh, o, r, wid, excludeNumber) => searchOlderIssues(gh, o, r, wid, excludeNumber, callerWorkflowId, closeOlderKey, currentRunIssueNumbers), getCloseMessage: params => getCloseOlderIssueMessage({ newIssueUrl: params.newEntityUrl, diff --git a/actions/setup/js/close_older_issues.test.cjs b/actions/setup/js/close_older_issues.test.cjs index ace3ea284ec..3490f18c110 100644 --- a/actions/setup/js/close_older_issues.test.cjs +++ b/actions/setup/js/close_older_issues.test.cjs @@ -90,6 +90,44 @@ describe("close_older_issues", () => { expect(results[0].number).toBe(123); }); + it("should exclude issues created in the same run via additionalExcludeNumbers", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 100, + title: "Old Report", + html_url: "https://github.com/owner/repo/issues/100", + labels: [], + body: "", + }, + { + number: 123, + title: "New Report A (same run)", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + body: "", + }, + { + number: 124, + title: "New Report B (current issue)", + html_url: "https://github.com/owner/repo/issues/124", + labels: [], + body: "", + }, + ], + }, + }); + + // Issue 124 is the newly created issue; issue 123 was also created in the + // same run and must not be closed. Only issue 100 (from an older run) should + // be returned. + const results = await searchOlderIssues(mockGithub, "owner", "repo", "test-workflow", 124, undefined, undefined, new Set([123])); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(100); + }); + it("should return empty array if no workflow-id provided", async () => { const results = await searchOlderIssues(mockGithub, "owner", "repo", "", 125); diff --git a/actions/setup/js/close_older_search_helpers.cjs b/actions/setup/js/close_older_search_helpers.cjs index d37fe1c9095..536e47f4ce7 100644 --- a/actions/setup/js/close_older_search_helpers.cjs +++ b/actions/setup/js/close_older_search_helpers.cjs @@ -62,6 +62,8 @@ function buildMarkerSearchQuery({ owner, repo, workflowId, callerWorkflowId, clo * @param {object} params * @param {Array} params.items - Raw search result items * @param {number} params.excludeNumber - Entity number to exclude (the newly created one) + * @param {Set} [params.additionalExcludeNumbers] - Optional set of additional entity + * numbers to exclude (e.g. all issues created in the current run) * @param {string} params.exactMarker - Exact marker string that must appear in the body * @param {string} params.entityType - Entity type name for logging (e.g. "issue", "discussion") * @param {(item: any, counters: Record) => boolean} [params.additionalFilter] - @@ -69,7 +71,7 @@ function buildMarkerSearchQuery({ owner, repo, workflowId, callerWorkflowId, clo * The `counters` object can be mutated to track extra exclusion reasons. * @returns {{ filtered: Array, counters: FilterCounters & Record }} */ -function filterByMarker({ items, excludeNumber, exactMarker, entityType, additionalFilter }) { +function filterByMarker({ items, excludeNumber, additionalExcludeNumbers, exactMarker, entityType, additionalFilter }) { let filteredCount = 0; let excludedCount = 0; let markerMismatchCount = 0; @@ -81,13 +83,19 @@ function filterByMarker({ items, excludeNumber, exactMarker, entityType, additio return false; } - // Exclude the newly created entity before running any other filters so - // counters/logs consistently attribute this item to the dedicated exclusion. + // Exclude the newly created entity and any other entities created in the + // same run before running any other filters, so counters/logs consistently + // attribute these items to the dedicated exclusion. if (item.number === excludeNumber) { excludedCount++; core.info(` Excluding ${entityType} #${item.number} (the newly created ${entityType})`); return false; } + if (additionalExcludeNumbers?.has(item.number)) { + excludedCount++; + core.info(` Excluding ${entityType} #${item.number} (created earlier in the same run)`); + return false; + } // Run entity-specific filters next (e.g. pull_request, closed, category) if (additionalFilter && !additionalFilter(item, extraCounters)) { diff --git a/actions/setup/js/close_older_search_helpers.test.cjs b/actions/setup/js/close_older_search_helpers.test.cjs index b79ef3a935f..6553b18b6e4 100644 --- a/actions/setup/js/close_older_search_helpers.test.cjs +++ b/actions/setup/js/close_older_search_helpers.test.cjs @@ -128,6 +128,28 @@ describe("close_older_search_helpers", () => { expect(counters.excludedCount).toBe(1); }); + it("should exclude items in additionalExcludeNumbers (same-run issues)", () => { + const items = [ + { number: 1, body: "", title: "Item 1" }, + { number: 2, body: "", title: "Item 2" }, + { number: 3, body: "", title: "Item 3 (old)" }, + ]; + + const { filtered, counters } = filterByMarker({ + items, + excludeNumber: 2, + additionalExcludeNumbers: new Set([1]), + exactMarker: "", + entityType: "issue", + }); + + // Items 1 and 2 are excluded (both created in the current run); + // only item 3 (created in an earlier run) should be returned. + expect(filtered).toHaveLength(1); + expect(filtered[0].number).toBe(3); + expect(counters.excludedCount).toBe(2); + }); + it("should exclude items without exact marker in body", () => { const items = [ { number: 1, body: "", title: "Match" }, diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 7daf568e257..411b0b12aba 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -1114,7 +1114,10 @@ async function main(config = {}) { const searchKey = closeOlderKey ? `close-older-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`; core.info(`Attempting to close older issues for ${qualifiedItemRepo}#${issue.number} using ${searchKey}`); try { - const closedIssues = await closeOlderIssues(github, repoParts.owner, repoParts.repo, workflowId, { number: issue.number, html_url: issue.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey); + // Build the set of all issue numbers created in this run (including the current + // one) so that previously-created issues are not incorrectly closed. + const currentRunIssueNumbers = new Set(createdIssues.filter(i => i._repo === qualifiedItemRepo).map(i => i.number)); + const closedIssues = await closeOlderIssues(github, repoParts.owner, repoParts.repo, workflowId, { number: issue.number, html_url: issue.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey, currentRunIssueNumbers); if (closedIssues.length > 0) { core.info(`Closed ${closedIssues.length} older issue(s)`); }