From 5b55a0a51a31883bfff0eac77d7f70ac06ed8aec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:15:07 +0000 Subject: [PATCH 1/4] Initial plan From 574613f2ebe348de0248761d421468c84b82557f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:29:26 +0000 Subject: [PATCH 2/4] Implement close-older-issues for discussion fallbacks - Pass close_older_discussions flag to create_issue handler - Map close_older_discussions to close_older_issues config - Add comprehensive tests for fallback scenario Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_discussion.cjs | 6 + .../js/create_discussion_fallback.test.cjs | 235 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 actions/setup/js/create_discussion_fallback.test.cjs diff --git a/actions/setup/js/create_discussion.cjs b/actions/setup/js/create_discussion.cjs index ef49543d01..07bea7b435 100644 --- a/actions/setup/js/create_discussion.cjs +++ b/actions/setup/js/create_discussion.cjs @@ -172,6 +172,7 @@ async function main(config = {}) { const maxCount = config.max || 10; const expiresHours = config.expires ? parseInt(String(config.expires), 10) : 0; const fallbackToIssue = config.fallback_to_issue !== false; // Default to true + const closeOlderDiscussions = config.close_older_discussions === true || config.close_older_discussions === "true"; // Parse labels from config const labelsConfig = config.labels || []; @@ -190,6 +191,9 @@ async function main(config = {}) { if (fallbackToIssue) { core.info("Fallback to issue enabled: will create an issue if discussion creation fails due to permissions"); } + if (closeOlderDiscussions) { + core.info("Close older discussions enabled: will close older discussions/issues with same workflow-id marker"); + } // Track state let processedCount = 0; @@ -205,6 +209,8 @@ async function main(config = {}) { title_prefix: titlePrefix, max: maxCount, expires: expiresHours, + // Map close_older_discussions to close_older_issues for fallback issues + close_older_issues: closeOlderDiscussions, }); } diff --git a/actions/setup/js/create_discussion_fallback.test.cjs b/actions/setup/js/create_discussion_fallback.test.cjs new file mode 100644 index 0000000000..f7793ab79c --- /dev/null +++ b/actions/setup/js/create_discussion_fallback.test.cjs @@ -0,0 +1,235 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { main: createDiscussionMain } = require("./create_discussion.cjs"); +const { resetIssuesToAssignCopilot } = require("./create_issue.cjs"); + +describe("create_discussion fallback with close_older_discussions", () => { + let mockGithub; + let mockCore; + let mockContext; + let mockExec; + let originalEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Reset copilot assignment tracking + resetIssuesToAssignCopilot(); + + // Mock GitHub API + mockGithub = { + rest: { + issues: { + create: vi.fn().mockResolvedValue({ + data: { + number: 456, + html_url: "https://github.com/owner/repo/issues/456", + title: "Test Issue (Fallback)", + }, + }), + createComment: vi.fn().mockResolvedValue({ + data: { + id: 999, + html_url: "https://github.com/owner/repo/issues/123#issuecomment-999", + }, + }), + update: vi.fn().mockResolvedValue({ + data: { + number: 123, + html_url: "https://github.com/owner/repo/issues/123", + }, + }), + }, + search: { + issuesAndPullRequests: vi.fn().mockResolvedValue({ + data: { + total_count: 1, + items: [ + { + number: 123, + title: "Old Discussion Report", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + state: "open", + }, + ], + }, + }), + }, + }, + graphql: vi.fn().mockRejectedValue(new Error("Resource not accessible by personal access token")), + }; + + // Mock Core + mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setOutput: vi.fn(), + }; + + // Mock Context + mockContext = { + repo: { owner: "test-owner", repo: "test-repo" }, + runId: 12345, + payload: { + repository: { + html_url: "https://github.com/test-owner/test-repo", + }, + }, + }; + + // Mock Exec + mockExec = { + exec: vi.fn().mockResolvedValue(0), + }; + + // Set globals + global.github = mockGithub; + global.core = mockCore; + global.context = mockContext; + global.exec = mockExec; + + // Set required environment variables + process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; + process.env.GH_AW_WORKFLOW_ID = "test-workflow"; + process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo/blob/main/workflow.md"; + process.env.GITHUB_SERVER_URL = "https://github.com"; + }); + + afterEach(() => { + // Restore environment + process.env = originalEnv; + vi.clearAllMocks(); + }); + + it("should close older issues when close_older_discussions is enabled and fallback to issue occurs", async () => { + // Create handler with close_older_discussions enabled + const handler = await createDiscussionMain({ + max: 5, + fallback_to_issue: true, + close_older_discussions: true, + }); + + // Call handler with a discussion message + const result = await handler( + { + title: "Test Discussion Report", + body: "This is a test discussion content.", + }, + {} + ); + + // Verify fallback to issue occurred + expect(result.success).toBe(true); + expect(result.fallback).toBe("issue"); + expect(result.number).toBe(456); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Close older discussions enabled")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Falling back to create-issue")); + + // Verify issue was created + expect(mockGithub.rest.issues.create).toHaveBeenCalledWith( + expect.objectContaining({ + owner: "test-owner", + repo: "test-repo", + title: "Test Discussion Report", + body: expect.stringContaining("This was intended to be a discussion"), + }) + ); + + // Verify search for older issues was performed + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: 'repo:test-owner/test-repo is:issue is:open "gh-aw-workflow-id: test-workflow" in:body', + per_page: 50, + }); + + // Verify comment was added to older issue + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + body: expect.stringContaining("This issue is being closed as outdated"), + }); + + // Verify older issue was closed as "not planned" + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + issue_number: 123, + state: "closed", + state_reason: "not_planned", + }); + }); + + it("should not close older issues when close_older_discussions is disabled", async () => { + // Create handler WITHOUT close_older_discussions + const handler = await createDiscussionMain({ + max: 5, + fallback_to_issue: true, + close_older_discussions: false, + }); + + // Call handler with a discussion message + const result = await handler( + { + title: "Test Discussion Report", + body: "This is a test discussion content.", + }, + {} + ); + + // Verify fallback to issue occurred + expect(result.success).toBe(true); + expect(result.fallback).toBe("issue"); + expect(result.number).toBe(456); + + // Verify issue was created + expect(mockGithub.rest.issues.create).toHaveBeenCalled(); + + // Verify search for older issues was NOT performed + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + + // Verify no comments were added + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + + // Verify no issues were closed + expect(mockGithub.rest.issues.update).not.toHaveBeenCalled(); + }); + + it("should handle permissions error during discussion fetch and apply close-older-issues", async () => { + // Create handler with close_older_discussions enabled + const handler = await createDiscussionMain({ + max: 5, + fallback_to_issue: true, + close_older_discussions: true, + title_prefix: "[Report] ", + }); + + // Call handler with a discussion message + const result = await handler( + { + title: "Weekly Report", + body: "This is the weekly report content.", + }, + {} + ); + + // Verify fallback to issue occurred with permissions error + expect(result.success).toBe(true); + expect(result.fallback).toBe("issue"); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("permissions")); + + // Verify older issue cleanup was triggered + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalled(); + expect(mockGithub.rest.issues.update).toHaveBeenCalledWith( + expect.objectContaining({ + state: "closed", + state_reason: "not_planned", + }) + ); + }); +}); From 6677f5535ce9b8890519f1559076c47968e7a036 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:31:54 +0000 Subject: [PATCH 3/4] Add fallback-to-issue schema definition - Add fallback-to-issue to create-discussion schema - Update close-older-discussions description to mention fallback behavior - Rebuild binary with updated schema Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schemas/main_workflow_schema.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 750a56de55..10916a7fbc 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4183,9 +4183,14 @@ }, "close-older-discussions": { "type": "boolean", - "description": "When true, automatically close older discussions matching the same title prefix or labels as 'outdated' with a comment linking to the new discussion. Requires title-prefix or labels to be set. Maximum 10 discussions will be closed. Only runs if discussion creation succeeds.", + "description": "When true, automatically close older discussions matching the same title prefix or labels as 'outdated' with a comment linking to the new discussion. Requires title-prefix or labels to be set. Maximum 10 discussions will be closed. Only runs if discussion creation succeeds. When fallback-to-issue is enabled and discussion creation fails, older issues will be closed instead.", "default": false }, + "fallback-to-issue": { + "type": "boolean", + "description": "When true (default), fallback to creating an issue if discussion creation fails due to permissions. The fallback issue will include a note indicating it was intended to be a discussion. If close-older-discussions is enabled, the close-older-issues logic will be applied to the fallback issue.", + "default": true + }, "expires": { "oneOf": [ { From 4915ea41d9c857842c4a654005fdea725567bb01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:38:35 +0000 Subject: [PATCH 4/4] Complete close-older-issues for discussion fallbacks feature All checks passing: - Unit tests pass (3 new tests) - Related tests pass (create_issue, close_older_issues) - Go tests pass - Linter passes - All 145 workflows recompile successfully Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- docs/src/content/docs/reference/frontmatter-full.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 1387af92f1..85943c765a 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2164,10 +2164,18 @@ safe-outputs: # When true, automatically close older discussions matching the same title prefix # or labels as 'outdated' with a comment linking to the new discussion. Requires # title-prefix or labels to be set. Maximum 10 discussions will be closed. Only - # runs if discussion creation succeeds. + # runs if discussion creation succeeds. When fallback-to-issue is enabled and + # discussion creation fails, older issues will be closed instead. # (optional) close-older-discussions: true + # When true (default), fallback to creating an issue if discussion creation fails + # due to permissions. The fallback issue will include a note indicating it was + # intended to be a discussion. If close-older-discussions is enabled, the + # close-older-issues logic will be applied to the fallback issue. + # (optional) + fallback-to-issue: true + # Time until the discussion expires and should be automatically closed. Supports # integer (days), relative time format like '2h' (2 hours), '7d' (7 days), '2w' (2 # weeks), '1m' (1 month), '1y' (1 year), or false to disable expiration. Minimum