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: