Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions actions/setup/js/create_discussion.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand All @@ -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;
Expand All @@ -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,
});
}

Expand Down
235 changes: 235 additions & 0 deletions actions/setup/js/create_discussion_fallback.test.cjs
Original file line number Diff line number Diff line change
@@ -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",
})
);
});
});
10 changes: 9 additions & 1 deletion docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
Loading