From d382e63719fb1ffaf389075c0a4d595754d1ccc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:37:06 +0000 Subject: [PATCH 1/4] Initial plan From f1ddb51d492686cc439fa8b36c3576b2c4f9da56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:48:12 +0000 Subject: [PATCH 2/4] Add Go backend and schema for close-older-issues feature Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_older_issues.cjs | 259 +++++++++++++ actions/setup/js/close_older_issues.test.cjs | 379 +++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 10 + pkg/workflow/compiler_safe_outputs_config.go | 4 + pkg/workflow/create_issue.go | 7 + 5 files changed, 659 insertions(+) create mode 100644 actions/setup/js/close_older_issues.cjs create mode 100644 actions/setup/js/close_older_issues.test.cjs diff --git a/actions/setup/js/close_older_issues.cjs b/actions/setup/js/close_older_issues.cjs new file mode 100644 index 0000000000..7a94033040 --- /dev/null +++ b/actions/setup/js/close_older_issues.cjs @@ -0,0 +1,259 @@ +// @ts-check +/// + +const { getErrorMessage } = require("./error_helpers.cjs"); + +/** + * Maximum number of older issues to close + */ +const MAX_CLOSE_COUNT = 10; + +/** + * Delay between API calls in milliseconds to avoid rate limiting + */ +const API_DELAY_MS = 500; + +/** + * Delay execution for a specified number of milliseconds + * @param {number} ms - Milliseconds to delay + * @returns {Promise} + */ +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Search for open issues with a matching title prefix and/or labels + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) + * @param {string[]} labels - Labels to match (empty array to skip label matching) + * @param {number} excludeNumber - Issue number to exclude (the newly created one) + * @returns {Promise}>>} Matching issues + */ +async function searchOlderIssues(github, owner, repo, titlePrefix, labels, excludeNumber) { + // Build REST API search query + // Search for open issues, optionally with title prefix or labels + let searchQuery = `repo:${owner}/${repo} is:issue is:open`; + + if (titlePrefix) { + // Escape quotes in title prefix to prevent query injection + const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); + searchQuery += ` in:title "${escapedPrefix}"`; + } + + // Add label filters to the search query + // Note: GitHub search uses AND logic for multiple labels, so issues must have ALL labels. + // We add each label as a separate filter and also validate client-side for extra safety. + if (labels && labels.length > 0) { + for (const label of labels) { + // Escape quotes in label names to prevent query injection + const escapedLabel = label.replace(/"/g, '\\"'); + searchQuery += ` label:"${escapedLabel}"`; + } + } + + core.info(`Searching with query: ${searchQuery}`); + + const result = await github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 50, + }); + + if (!result || !result.data || !result.data.items) { + return []; + } + + // Filter results: + // 1. Must not be the excluded issue (newly created one) + // 2. Must not be a pull request + // 3. If titlePrefix is specified, must have title starting with the prefix + // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) + return result.data.items + .filter(item => { + // Exclude pull requests + if (item.pull_request) { + return false; + } + + // Exclude the newly created issue + if (item.number === excludeNumber) { + return false; + } + + // Check title prefix if specified + if (titlePrefix && item.title && !item.title.startsWith(titlePrefix)) { + return false; + } + + // Check labels if specified - requires ALL labels to match (AND logic) + // This is intentional: we only want to close issues that have ALL the specified labels + if (labels && labels.length > 0) { + const issueLabels = item.labels?.map(l => l.name) || []; + const hasAllLabels = labels.every(label => issueLabels.includes(label)); + if (!hasAllLabels) { + return false; + } + } + + return true; + }) + .map(item => ({ + number: item.number, + title: item.title, + html_url: item.html_url, + labels: item.labels || [], + })); +} + +/** + * Add comment to a GitHub Issue using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} issueNumber - Issue number + * @param {string} message - Comment body + * @returns {Promise<{id: number, html_url: string}>} Comment details + */ +async function addIssueComment(github, owner, repo, issueNumber, message) { + const result = await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: message, + }); + + return { + id: result.data.id, + html_url: result.data.html_url, + }; +} + +/** + * Close a GitHub Issue as "not planned" using REST API + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {number} issueNumber - Issue number + * @returns {Promise<{number: number, html_url: string}>} Issue details + */ +async function closeIssueAsNotPlanned(github, owner, repo, issueNumber) { + const result = await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: "not_planned", + }); + + return { + number: result.data.number, + html_url: result.data.html_url, + }; +} + +/** + * Generate closing message for older issues + * @param {object} params - Parameters for the message + * @param {string} params.newIssueUrl - URL of the new issue + * @param {number} params.newIssueNumber - Number of the new issue + * @param {string} params.workflowName - Name of the workflow + * @param {string} params.runUrl - URL of the workflow run + * @returns {string} Closing message + */ +function getCloseOlderIssueMessage({ newIssueUrl, newIssueNumber, workflowName, runUrl }) { + return `This issue is being closed as outdated. A newer issue has been created: #${newIssueNumber} + +[View newer issue](${newIssueUrl}) + +--- + +*This action was performed automatically by the [\`${workflowName}\`](${runUrl}) workflow.*`; +} + +/** + * Close older issues that match the title prefix and/or labels + * @param {any} github - GitHub REST API instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {string} titlePrefix - Title prefix to match (empty string to skip) + * @param {string[]} labels - Labels to match (empty array to skip) + * @param {{number: number, html_url: string}} newIssue - The newly created issue + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @returns {Promise>} List of closed issues + */ +async function closeOlderIssues(github, owner, repo, titlePrefix, labels, newIssue, workflowName, runUrl) { + // Build search criteria description for logging + const searchCriteria = []; + if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); + if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); + core.info(`Searching for older issues with ${searchCriteria.join(" and ")}`); + + const olderIssues = await searchOlderIssues(github, owner, repo, titlePrefix, labels, newIssue.number); + + if (olderIssues.length === 0) { + core.info("No older issues found to close"); + return []; + } + + core.info(`Found ${olderIssues.length} older issue(s) to close`); + + // Limit to MAX_CLOSE_COUNT issues + const issuesToClose = olderIssues.slice(0, MAX_CLOSE_COUNT); + + if (olderIssues.length > MAX_CLOSE_COUNT) { + core.warning(`Found ${olderIssues.length} older issues, but only closing the first ${MAX_CLOSE_COUNT}`); + } + + const closedIssues = []; + + for (let i = 0; i < issuesToClose.length; i++) { + const issue = issuesToClose[i]; + try { + // Generate closing message + const closingMessage = getCloseOlderIssueMessage({ + newIssueUrl: newIssue.html_url, + newIssueNumber: newIssue.number, + workflowName, + runUrl, + }); + + // Add comment first + core.info(`Adding closing comment to issue #${issue.number}`); + await addIssueComment(github, owner, repo, issue.number, closingMessage); + + // Then close the issue as "not planned" + core.info(`Closing issue #${issue.number} as not planned`); + await closeIssueAsNotPlanned(github, owner, repo, issue.number); + + closedIssues.push({ + number: issue.number, + html_url: issue.html_url, + }); + + core.info(`✓ Closed issue #${issue.number}: ${issue.html_url}`); + } catch (error) { + core.error(`✗ Failed to close issue #${issue.number}: ${getErrorMessage(error)}`); + // Continue with other issues even if one fails + } + + // Add delay between API operations to avoid rate limiting (except for the last item) + if (i < issuesToClose.length - 1) { + await delay(API_DELAY_MS); + } + } + + return closedIssues; +} + +module.exports = { + closeOlderIssues, + searchOlderIssues, + addIssueComment, + closeIssueAsNotPlanned, + getCloseOlderIssueMessage, + MAX_CLOSE_COUNT, + API_DELAY_MS, +}; diff --git a/actions/setup/js/close_older_issues.test.cjs b/actions/setup/js/close_older_issues.test.cjs new file mode 100644 index 0000000000..c47f6a2393 --- /dev/null +++ b/actions/setup/js/close_older_issues.test.cjs @@ -0,0 +1,379 @@ +// @ts-check + +const { describe, it, expect, beforeEach } = require("@jest/globals"); +const { + closeOlderIssues, + searchOlderIssues, + addIssueComment, + closeIssueAsNotPlanned, + getCloseOlderIssueMessage, + MAX_CLOSE_COUNT, +} = require("./close_older_issues.cjs"); + +// Mock globals +global.core = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +describe("close_older_issues", () => { + let mockGithub; + + beforeEach(() => { + jest.clearAllMocks(); + mockGithub = { + rest: { + search: { + issuesAndPullRequests: jest.fn(), + }, + issues: { + createComment: jest.fn(), + update: jest.fn(), + }, + }, + }; + }); + + describe("searchOlderIssues", () => { + it("should search for issues with title prefix", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Weekly Report - 2024-01", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + }, + { + number: 124, + title: "Weekly Report - 2024-02", + html_url: "https://github.com/owner/repo/issues/124", + labels: [], + }, + ], + }, + }); + + const results = await searchOlderIssues(mockGithub, "owner", "repo", "Weekly Report", [], 125); + + expect(results).toHaveLength(2); + expect(results[0].number).toBe(123); + expect(results[1].number).toBe(124); + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: 'repo:owner/repo is:issue is:open in:title "Weekly Report"', + per_page: 50, + }); + }); + + it("should exclude the newly created issue", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Weekly Report - 2024-01", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + }, + { + number: 124, + title: "Weekly Report - 2024-02", + html_url: "https://github.com/owner/repo/issues/124", + labels: [], + }, + ], + }, + }); + + const results = await searchOlderIssues(mockGithub, "owner", "repo", "Weekly Report", [], 124); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + }); + + it("should filter issues by labels (AND logic)", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Report 1", + html_url: "https://github.com/owner/repo/issues/123", + labels: [{ name: "report" }, { name: "automation" }], + }, + { + number: 124, + title: "Report 2", + html_url: "https://github.com/owner/repo/issues/124", + labels: [{ name: "report" }], // Missing "automation" label + }, + ], + }, + }); + + const results = await searchOlderIssues(mockGithub, "owner", "repo", "", ["report", "automation"], 125); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); // Only issue with both labels + }); + + it("should exclude pull requests", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Issue", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + }, + { + number: 124, + title: "Pull Request", + html_url: "https://github.com/owner/repo/pull/124", + labels: [], + pull_request: {}, + }, + ], + }, + }); + + const results = await searchOlderIssues(mockGithub, "owner", "repo", "", [], 125); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + }); + + it("should return empty array if no results", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [], + }, + }); + + const results = await searchOlderIssues(mockGithub, "owner", "repo", "Prefix", [], 125); + + expect(results).toHaveLength(0); + }); + }); + + describe("addIssueComment", () => { + it("should add comment to issue", async () => { + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { + id: 456, + html_url: "https://github.com/owner/repo/issues/123#issuecomment-456", + }, + }); + + const result = await addIssueComment(mockGithub, "owner", "repo", 123, "Test comment"); + + expect(result).toEqual({ + id: 456, + html_url: "https://github.com/owner/repo/issues/123#issuecomment-456", + }); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + issue_number: 123, + body: "Test comment", + }); + }); + }); + + describe("closeIssueAsNotPlanned", () => { + it("should close issue as not planned", async () => { + mockGithub.rest.issues.update.mockResolvedValue({ + data: { + number: 123, + html_url: "https://github.com/owner/repo/issues/123", + }, + }); + + const result = await closeIssueAsNotPlanned(mockGithub, "owner", "repo", 123); + + expect(result).toEqual({ + number: 123, + html_url: "https://github.com/owner/repo/issues/123", + }); + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: "owner", + repo: "repo", + issue_number: 123, + state: "closed", + state_reason: "not_planned", + }); + }); + }); + + describe("getCloseOlderIssueMessage", () => { + it("should generate closing message", () => { + const message = getCloseOlderIssueMessage({ + newIssueUrl: "https://github.com/owner/repo/issues/125", + newIssueNumber: 125, + workflowName: "Test Workflow", + runUrl: "https://github.com/owner/repo/actions/runs/123", + }); + + expect(message).toContain("newer issue has been created: #125"); + expect(message).toContain("https://github.com/owner/repo/issues/125"); + expect(message).toContain("Test Workflow"); + expect(message).toContain("https://github.com/owner/repo/actions/runs/123"); + }); + }); + + describe("closeOlderIssues", () => { + it("should close older issues successfully", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Old Issue", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + }, + ], + }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { id: 456, html_url: "https://github.com/owner/repo/issues/123#issuecomment-456" }, + }); + + mockGithub.rest.issues.update.mockResolvedValue({ + data: { number: 123, html_url: "https://github.com/owner/repo/issues/123" }, + }); + + const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" }; + const results = await closeOlderIssues( + mockGithub, + "owner", + "repo", + "Prefix", + [], + newIssue, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123" + ); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); + expect(mockGithub.rest.issues.update).toHaveBeenCalled(); + }); + + it("should limit to MAX_CLOSE_COUNT issues", async () => { + const items = []; + for (let i = 1; i <= 15; i++) { + items.push({ + number: i, + title: `Issue ${i}`, + html_url: `https://github.com/owner/repo/issues/${i}`, + labels: [], + }); + } + + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { items }, + }); + + mockGithub.rest.issues.createComment.mockResolvedValue({ + data: { id: 456, html_url: "https://github.com/owner/repo/issues/1#issuecomment-456" }, + }); + + mockGithub.rest.issues.update.mockResolvedValue({ + data: { number: 1, html_url: "https://github.com/owner/repo/issues/1" }, + }); + + const newIssue = { number: 20, html_url: "https://github.com/owner/repo/issues/20" }; + const results = await closeOlderIssues( + mockGithub, + "owner", + "repo", + "", + [], + newIssue, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123" + ); + + expect(results).toHaveLength(MAX_CLOSE_COUNT); + expect(global.core.warning).toHaveBeenCalledWith( + `Found 15 older issues, but only closing the first ${MAX_CLOSE_COUNT}` + ); + }); + + it("should continue on error for individual issues", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Issue 1", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + }, + { + number: 124, + title: "Issue 2", + html_url: "https://github.com/owner/repo/issues/124", + labels: [], + }, + ], + }, + }); + + // First issue fails + mockGithub.rest.issues.createComment.mockRejectedValueOnce(new Error("API Error")); + + // Second issue succeeds + mockGithub.rest.issues.createComment.mockResolvedValueOnce({ + data: { id: 456, html_url: "https://github.com/owner/repo/issues/124#issuecomment-456" }, + }); + + mockGithub.rest.issues.update.mockResolvedValue({ + data: { number: 124, html_url: "https://github.com/owner/repo/issues/124" }, + }); + + const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" }; + const results = await closeOlderIssues( + mockGithub, + "owner", + "repo", + "", + [], + newIssue, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123" + ); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(124); + expect(global.core.error).toHaveBeenCalledWith(expect.stringContaining("Failed to close issue #123")); + }); + + it("should return empty array if no older issues found", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { items: [] }, + }); + + const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" }; + const results = await closeOlderIssues( + mockGithub, + "owner", + "repo", + "Prefix", + [], + newIssue, + "Test Workflow", + "https://github.com/owner/repo/actions/runs/123" + ); + + expect(results).toHaveLength(0); + expect(global.core.info).toHaveBeenCalledWith("No older issues found to close"); + }); + }); +}); diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 93fb80678d..4a120db377 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3690,6 +3690,11 @@ "type": "boolean", "description": "If true, group issues as sub-issues under a parent issue. The workflow ID is used as the group identifier. Parent issues are automatically created and managed, with a maximum of 64 sub-issues per parent.", "default": false + }, + "close-older-issues": { + "type": "boolean", + "description": "When true, automatically close older issues matching the same title prefix or labels as 'not planned' with a comment linking to the new issue. Requires title-prefix or labels to be set. Maximum 10 issues will be closed. Only runs if issue creation succeeds.", + "default": false } }, "additionalProperties": false, @@ -3707,6 +3712,11 @@ { "allowed-repos": ["org/other-repo", "org/another-repo"], "title-prefix": "[cross-repo] " + }, + { + "title-prefix": "[weekly-report] ", + "labels": ["report", "automation"], + "close-older-issues": true } ] }, diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index a592ef272e..4c7bed53fc 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -48,6 +48,10 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow if cfg.Group { handlerConfig["group"] = true } + // Add close-older-issues flag to config + if cfg.CloseOlderIssues { + handlerConfig["close_older_issues"] = true + } config["create_issue"] = handlerConfig } diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index d83aceb8ff..d9ad67862d 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -17,6 +17,7 @@ type CreateIssuesConfig struct { Assignees []string `yaml:"assignees,omitempty"` // List of users/bots to assign the issue to TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository issues AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that issues can be created in + CloseOlderIssues bool `yaml:"close-older-issues,omitempty"` // When true, close older issues with same title prefix or labels as "not planned" Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed Group bool `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) } @@ -155,6 +156,12 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str createIssueLog.Print("Issue grouping enabled - issues will be grouped as sub-issues under parent") } + // Add close-older-issues flag if enabled + if data.SafeOutputs.CreateIssues.CloseOlderIssues { + customEnvVars = append(customEnvVars, " GH_AW_CLOSE_OLDER_ISSUES: \"true\"\n") + createIssueLog.Print("Close older issues enabled - older issues with same title prefix or labels will be closed") + } + // Add standard environment variables (metadata + staged/target repo) customEnvVars = append(customEnvVars, c.buildStandardSafeOutputEnvVars(data, data.SafeOutputs.CreateIssues.TargetRepoSlug)...) From 1572dd8a340ca7bdd3a1045d9f8403e71d3adac1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:52:05 +0000 Subject: [PATCH 3/4] Integrate close_older_issues into create_issue.cjs handler Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_older_issues.test.cjs | 57 +++----------------- actions/setup/js/create_issue.cjs | 21 ++++++++ pkg/workflow/create_issue.go | 12 ++--- 3 files changed, 33 insertions(+), 57 deletions(-) diff --git a/actions/setup/js/close_older_issues.test.cjs b/actions/setup/js/close_older_issues.test.cjs index c47f6a2393..1a39035610 100644 --- a/actions/setup/js/close_older_issues.test.cjs +++ b/actions/setup/js/close_older_issues.test.cjs @@ -1,14 +1,7 @@ // @ts-check const { describe, it, expect, beforeEach } = require("@jest/globals"); -const { - closeOlderIssues, - searchOlderIssues, - addIssueComment, - closeIssueAsNotPlanned, - getCloseOlderIssueMessage, - MAX_CLOSE_COUNT, -} = require("./close_older_issues.cjs"); +const { closeOlderIssues, searchOlderIssues, addIssueComment, closeIssueAsNotPlanned, getCloseOlderIssueMessage, MAX_CLOSE_COUNT } = require("./close_older_issues.cjs"); // Mock globals global.core = { @@ -248,16 +241,7 @@ describe("close_older_issues", () => { }); const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" }; - const results = await closeOlderIssues( - mockGithub, - "owner", - "repo", - "Prefix", - [], - newIssue, - "Test Workflow", - "https://github.com/owner/repo/actions/runs/123" - ); + const results = await closeOlderIssues(mockGithub, "owner", "repo", "Prefix", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123"); expect(results).toHaveLength(1); expect(results[0].number).toBe(123); @@ -289,21 +273,10 @@ describe("close_older_issues", () => { }); const newIssue = { number: 20, html_url: "https://github.com/owner/repo/issues/20" }; - const results = await closeOlderIssues( - mockGithub, - "owner", - "repo", - "", - [], - newIssue, - "Test Workflow", - "https://github.com/owner/repo/actions/runs/123" - ); + const results = await closeOlderIssues(mockGithub, "owner", "repo", "", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123"); expect(results).toHaveLength(MAX_CLOSE_COUNT); - expect(global.core.warning).toHaveBeenCalledWith( - `Found 15 older issues, but only closing the first ${MAX_CLOSE_COUNT}` - ); + expect(global.core.warning).toHaveBeenCalledWith(`Found 15 older issues, but only closing the first ${MAX_CLOSE_COUNT}`); }); it("should continue on error for individual issues", async () => { @@ -339,16 +312,7 @@ describe("close_older_issues", () => { }); const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" }; - const results = await closeOlderIssues( - mockGithub, - "owner", - "repo", - "", - [], - newIssue, - "Test Workflow", - "https://github.com/owner/repo/actions/runs/123" - ); + const results = await closeOlderIssues(mockGithub, "owner", "repo", "", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123"); expect(results).toHaveLength(1); expect(results[0].number).toBe(124); @@ -361,16 +325,7 @@ describe("close_older_issues", () => { }); const newIssue = { number: 125, html_url: "https://github.com/owner/repo/issues/125" }; - const results = await closeOlderIssues( - mockGithub, - "owner", - "repo", - "Prefix", - [], - newIssue, - "Test Workflow", - "https://github.com/owner/repo/actions/runs/123" - ); + const results = await closeOlderIssues(mockGithub, "owner", "repo", "Prefix", [], newIssue, "Test Workflow", "https://github.com/owner/repo/actions/runs/123"); expect(results).toHaveLength(0); expect(global.core.info).toHaveBeenCalledWith("No older issues found to close"); diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 9c21ea165e..908492bea4 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -11,6 +11,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { renderTemplate } = require("./messages_core.cjs"); const { createExpirationLine, addExpirationToFooter } = require("./ephemerals.cjs"); const { MAX_SUB_ISSUES, getSubIssueCount } = require("./sub_issue_helpers.cjs"); +const { closeOlderIssues } = require("./close_older_issues.cjs"); const fs = require("fs"); /** @@ -171,6 +172,7 @@ async function main(config = {}) { const allowedRepos = parseAllowedRepos(config.allowed_repos); const defaultTargetRepo = getDefaultTargetRepo(config); const groupEnabled = config.group === true || config.group === "true"; + const closeOlderIssuesEnabled = config.close_older_issues === true || config.close_older_issues === "true"; core.info(`Default target repo: ${defaultTargetRepo}`); if (allowedRepos.size > 0) { @@ -192,6 +194,9 @@ async function main(config = {}) { if (groupEnabled) { core.info(`Issue grouping enabled: issues will be grouped as sub-issues`); } + if (closeOlderIssuesEnabled) { + core.info(`Close older issues enabled: older issues with same title prefix or labels will be closed`); + } // Track how many items we've processed for max limit let processedCount = 0; @@ -415,6 +420,22 @@ async function main(config = {}) { temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: qualifiedItemRepo, number: issue.number }); core.info(`Stored temporary ID mapping: ${temporaryId} -> ${qualifiedItemRepo}#${issue.number}`); + // Close older issues if enabled + if (closeOlderIssuesEnabled && (titlePrefix || envLabels.length > 0)) { + core.info(`Attempting to close older issues for ${qualifiedItemRepo}#${issue.number}`); + try { + const closedIssues = await closeOlderIssues(github, repoParts.owner, repoParts.repo, titlePrefix, envLabels, { number: issue.number, html_url: issue.html_url }, workflowName, runUrl); + if (closedIssues.length > 0) { + core.info(`Closed ${closedIssues.length} older issue(s)`); + } + } catch (error) { + // Log error but don't fail the workflow + core.warning(`Failed to close older issues: ${getErrorMessage(error)}`); + } + } else if (closeOlderIssuesEnabled) { + core.warning("Close older issues enabled but no title-prefix or labels configured - skipping"); + } + // Handle grouping - find or create parent issue and link sub-issue if (groupEnabled && !effectiveParentIssueNumber) { // Use workflow name as the group ID diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index d9ad67862d..c9d59a53b3 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -13,13 +13,13 @@ type CreateIssuesConfig struct { BaseSafeOutputConfig `yaml:",inline"` TitlePrefix string `yaml:"title-prefix,omitempty"` Labels []string `yaml:"labels,omitempty"` - AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). - Assignees []string `yaml:"assignees,omitempty"` // List of users/bots to assign the issue to - TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository issues - AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that issues can be created in + AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). + Assignees []string `yaml:"assignees,omitempty"` // List of users/bots to assign the issue to + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository issues + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that issues can be created in CloseOlderIssues bool `yaml:"close-older-issues,omitempty"` // When true, close older issues with same title prefix or labels as "not planned" - Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed - Group bool `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) + Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed + Group bool `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) } // parseIssuesConfig handles create-issue configuration From 3ca38d94c5fc33768b3b6dd4c4f0edc07a754a9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 20 Jan 2026 18:23:00 +0000 Subject: [PATCH 4/4] Enable close-older-issues on smoke-claude workflow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-claude.lock.yml | 2 +- .github/workflows/smoke-claude.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 9ee21dc29c..af31622b21 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -1636,7 +1636,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":1},\"add_labels\":{\"allowed\":[\"smoke-claude\"]},\"create_issue\":{\"expires\":2,\"group\":true,\"max\":1},\"missing_data\":{},\"missing_tool\":{}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":1},\"add_labels\":{\"allowed\":[\"smoke-claude\"]},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"group\":true,\"max\":1},\"missing_data\":{},\"missing_tool\":{}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index 5b5f6cd2e0..cf95484814 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -51,6 +51,7 @@ safe-outputs: create-issue: expires: 2h group: true + close-older-issues: true add-labels: allowed: [smoke-claude] messages: