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
4 changes: 2 additions & 2 deletions .github/workflows/smoke-copilot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions .github/workflows/smoke-copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ safe-outputs:
expires: 2h
group: true
close-older-issues: true
close-older-key: "smoke-copilot"
labels: [automation, testing]
create-discussion:
category: announcements
labels: [ai-generated]
expires: 2h
close-older-discussions: true
close-older-key: "smoke-copilot"
max: 1
create-pull-request-review-comment:
max: 5
Expand Down
58 changes: 37 additions & 21 deletions actions/setup/js/close_older_discussions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

const { getCloseOlderDiscussionMessage } = require("./messages_close_discussion.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { getWorkflowIdMarkerContent, generateWorkflowIdMarker, generateWorkflowCallIdMarker } = require("./generate_footer.cjs");
const { getWorkflowIdMarkerContent, generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, getCloseKeyMarkerContent } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs");

Expand All @@ -29,26 +29,42 @@ const GRAPHQL_DELAY_MS = 500;
* When set, filters by the `gh-aw-workflow-call-id` marker so callers sharing the same
* reusable workflow do not close each other's discussions. Falls back to `gh-aw-workflow-id`
* when not provided (backward compat for discussions created before this fix).
* @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.
* @returns {Promise<Array<{id: string, number: number, title: string, url: string}>>} Matching discussions
*/
async function searchOlderDiscussions(github, owner, repo, workflowId, categoryId, excludeNumber, callerWorkflowId) {
async function searchOlderDiscussions(github, owner, repo, workflowId, categoryId, excludeNumber, callerWorkflowId, closeOlderKey) {
core.info(`Starting search for older discussions in ${owner}/${repo}`);
core.info(` Workflow ID: ${workflowId || "(none)"}`);
core.info(` Exclude discussion number: ${excludeNumber}`);

if (!workflowId) {
core.info("No workflow ID provided - cannot search for older discussions");
if (!workflowId && !closeOlderKey) {
core.info("No workflow ID or close-older-key provided - cannot search for older discussions");
return [];
}

// Build GraphQL search query
// Search for open discussions with the workflow-id marker in the body
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
// Escape quotes in workflow ID to prevent query injection
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
let searchQuery = `repo:${owner}/${repo} is:open "${escapedMarker}" in:body`;

core.info(` Added workflow ID marker filter to query: "${escapedMarker}" in:body`);
// Build GraphQL search query.
// When a close-older-key is provided it becomes the primary search term; otherwise
// fall back to the workflow-id marker.
let searchQuery;
let exactMarker;
if (closeOlderKey) {
const closeKeyMarkerContent = getCloseKeyMarkerContent(closeOlderKey);
const escapedMarker = closeKeyMarkerContent.replace(/"/g, '\\"');
searchQuery = `repo:${owner}/${repo} is:open "${escapedMarker}" in:body`;
exactMarker = generateCloseKeyMarker(closeOlderKey);
core.info(` Using close-older-key for search: "${escapedMarker}" in:body`);
} else {
// Build GraphQL search query
// Search for open discussions with the workflow-id marker in the body
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
// Escape quotes in workflow ID to prevent query injection
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
searchQuery = `repo:${owner}/${repo} is:open "${escapedMarker}" in:body`;
exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);
core.info(` Added workflow ID marker filter to query: "${escapedMarker}" in:body`);
}
core.info(`Executing GitHub search with query: ${searchQuery}`);

const result = await github.graphql(
Expand Down Expand Up @@ -84,18 +100,16 @@ async function searchOlderDiscussions(github, owner, repo, workflowId, categoryI
// 1. Must not be the excluded discussion (newly created one)
// 2. Must not be already closed
// 3. If categoryId is specified, must match
// 4. Body must contain the exact marker for this workflow.
// When callerWorkflowId is set, match `gh-aw-workflow-call-id` so that callers
// sharing the same reusable workflow do not close each other's discussions.
// 4. Body must contain the exact marker. When closeOlderKey is set the close-key marker
// is used. Otherwise, when callerWorkflowId is set, match `gh-aw-workflow-call-id` so
// that callers sharing the same reusable workflow do not close each other's discussions.
// Fall back to `gh-aw-workflow-id` for backward compat with older discussions.
core.info("Filtering search results...");
let filteredCount = 0;
let excludedCount = 0;
let closedCount = 0;
let markerMismatchCount = 0;

const exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);

const filtered = result.search.nodes
.filter(
/** @param {any} d */ d => {
Expand Down Expand Up @@ -215,9 +229,10 @@ async function closeDiscussionAsOutdated(github, owner, repo, discussionId) {
* @param {string} workflowName - Name of the workflow
* @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
* @returns {Promise<Array<{number: number, url: string}>>} List of closed discussions
*/
async function closeOlderDiscussions(github, owner, repo, workflowId, categoryId, newDiscussion, workflowName, runUrl, callerWorkflowId) {
async function closeOlderDiscussions(github, owner, repo, workflowId, categoryId, newDiscussion, workflowName, runUrl, callerWorkflowId, closeOlderKey) {
const result = await closeOlderEntities(
github,
owner,
Expand All @@ -229,9 +244,10 @@ async function closeOlderDiscussions(github, owner, repo, workflowId, categoryId
{
entityType: "discussion",
entityTypePlural: "discussions",
// Use a closure so callerWorkflowId is forwarded to searchOlderDiscussions without going
// through the closeOlderEntities extraArgs mechanism (which appends excludeNumber last)
searchOlderEntities: (gh, o, r, wid, categoryId, excludeNumber) => searchOlderDiscussions(gh, o, r, wid, categoryId, excludeNumber, callerWorkflowId),
// Use a closure so callerWorkflowId and closeOlderKey are forwarded to
// searchOlderDiscussions without going through the closeOlderEntities extraArgs
// mechanism (which appends excludeNumber last)
searchOlderEntities: (gh, o, r, wid, categoryId, excludeNumber) => searchOlderDiscussions(gh, o, r, wid, categoryId, excludeNumber, callerWorkflowId, closeOlderKey),
getCloseMessage: params =>
getCloseOlderDiscussionMessage({
newDiscussionUrl: params.newEntityUrl,
Expand Down
81 changes: 81 additions & 0 deletions actions/setup/js/close_older_discussions.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,87 @@ describe("close_older_discussions.cjs", () => {
expect(result).toHaveLength(1);
expect(result[0].number).toBe(5);
});

it("should use close-key marker as primary search term when closeOlderKey is provided", async () => {
const { searchOlderDiscussions } = await import("./close_older_discussions.cjs");

mockGithub.graphql.mockResolvedValueOnce({
search: {
nodes: [
{
id: "D_with_key",
number: 5,
title: "Has close-key marker - should be included",
url: "https://github.com/testowner/testrepo/discussions/5",
body: "<!-- gh-aw-workflow-id: some-workflow -->\n<!-- gh-aw-close-key: my-stable-key -->",
category: { id: "DIC_test123" },
closed: false,
},
{
id: "D_no_key",
number: 6,
title: "Missing close-key marker - should be excluded",
url: "https://github.com/testowner/testrepo/discussions/6",
body: "<!-- gh-aw-workflow-id: some-workflow -->",
category: { id: "DIC_test123" },
closed: false,
},
],
},
});

const result = await searchOlderDiscussions(mockGithub, "testowner", "testrepo", "some-workflow", "DIC_test123", 99, undefined, "my-stable-key");

expect(result).toHaveLength(1);
expect(result[0].number).toBe(5);
// Should search by the close-key marker, not the workflow-id marker
expect(mockGithub.graphql).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
searchTerms: expect.stringContaining("gh-aw-close-key: my-stable-key"),
})
);
});

it("should work with close-key when workflowId is empty", async () => {
const { searchOlderDiscussions } = await import("./close_older_discussions.cjs");

mockGithub.graphql.mockResolvedValueOnce({
search: {
nodes: [
{
id: "D_with_key",
number: 5,
title: "Has close-key marker",
url: "https://github.com/testowner/testrepo/discussions/5",
body: "<!-- gh-aw-close-key: team-report -->",
category: { id: "DIC_test123" },
closed: false,
},
],
},
});

const result = await searchOlderDiscussions(mockGithub, "testowner", "testrepo", "", "DIC_test123", 99, undefined, "team-report");

expect(result).toHaveLength(1);
expect(result[0].number).toBe(5);
expect(mockGithub.graphql).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
searchTerms: expect.stringContaining("gh-aw-close-key: team-report"),
})
);
});

it("should return empty array when neither workflowId nor closeOlderKey is provided", async () => {
const { searchOlderDiscussions } = await import("./close_older_discussions.cjs");

const result = await searchOlderDiscussions(mockGithub, "testowner", "testrepo", "", "DIC_test123", 99, undefined, undefined);

expect(result).toHaveLength(0);
expect(mockGithub.graphql).not.toHaveBeenCalled();
});
});

describe("closeOlderDiscussions", () => {
Expand Down
57 changes: 36 additions & 21 deletions actions/setup/js/close_older_issues.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { getWorkflowIdMarkerContent, generateWorkflowIdMarker, generateWorkflowCallIdMarker } = require("./generate_footer.cjs");
const { getWorkflowIdMarkerContent, generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, getCloseKeyMarkerContent } = require("./generate_footer.cjs");
const { sanitizeContent } = require("./sanitize_content.cjs");
const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs");

Expand All @@ -26,26 +26,41 @@ const API_DELAY_MS = 500;
* When set, filters by the `gh-aw-workflow-call-id` marker so callers sharing the same
* reusable workflow do not close each other's issues. Falls back to `gh-aw-workflow-id`
* when not provided (backward compat for issues created before this fix).
* @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.
* @returns {Promise<Array<{number: number, title: string, html_url: string, labels: Array<{name: string}>}>>} Matching issues
*/
async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, callerWorkflowId) {
async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, callerWorkflowId, closeOlderKey) {
core.info(`Starting search for older issues in ${owner}/${repo}`);
core.info(` Workflow ID: ${workflowId || "(none)"}`);
core.info(` Exclude issue number: ${excludeNumber}`);

if (!workflowId) {
core.info("No workflow ID provided - cannot search for older issues");
if (!workflowId && !closeOlderKey) {
core.info("No workflow ID or close-older-key provided - cannot search for older issues");
return [];
}

// Build REST API search query
// Search for open issues with the workflow-id marker in the body
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
// Escape quotes in workflow ID to prevent query injection
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
const searchQuery = `repo:${owner}/${repo} is:issue is:open "${escapedMarker}" in:body`;

core.info(` Added workflow-id marker filter to query: "${escapedMarker}" in:body`);
// Build REST API search query.
// When a close-older-key is provided it becomes the primary search term; otherwise
// fall back to the workflow-id marker.
let searchQuery;
let exactMarker;
if (closeOlderKey) {
const closeKeyMarkerContent = getCloseKeyMarkerContent(closeOlderKey);
const escapedMarker = closeKeyMarkerContent.replace(/"/g, '\\"');
searchQuery = `repo:${owner}/${repo} is:issue is:open "${escapedMarker}" in:body`;
exactMarker = generateCloseKeyMarker(closeOlderKey);
core.info(` Using close-older-key for search: "${escapedMarker}" in:body`);
} else {
// Search for open issues with the workflow-id marker in the body
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
// Escape quotes in workflow ID to prevent query injection
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
searchQuery = `repo:${owner}/${repo} is:issue is:open "${escapedMarker}" in:body`;
exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);
core.info(` Added workflow-id marker filter to query: "${escapedMarker}" in:body`);
}
core.info(`Executing GitHub search with query: ${searchQuery}`);

const result = await github.rest.search.issuesAndPullRequests({
Expand All @@ -63,18 +78,16 @@ async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber,
// Filter results:
// 1. Must not be the excluded issue (newly created one)
// 2. Must not be a pull request
// 3. Body must contain the exact marker for this workflow.
// When callerWorkflowId is set, match `gh-aw-workflow-call-id` so that callers
// sharing the same reusable workflow do not close each other's issues.
// 3. Body must contain the exact marker. When closeOlderKey is set the close-key marker
// is used. Otherwise, when callerWorkflowId is set, match `gh-aw-workflow-call-id` so
// that callers sharing the same reusable workflow do not close each other's issues.
// Fall back to `gh-aw-workflow-id` for backward compat with older issues.
core.info("Filtering search results...");
let filteredCount = 0;
let pullRequestCount = 0;
let excludedCount = 0;
let markerMismatchCount = 0;

const exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);

const filtered = result.data.items
.filter(item => {
// Exclude pull requests
Expand Down Expand Up @@ -205,15 +218,17 @@ function getCloseOlderIssueMessage({ newIssueUrl, newIssueNumber, workflowName,
* @param {string} workflowName - Name of the workflow
* @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
* @returns {Promise<Array<{number: number, html_url: string}>>} List of closed issues
*/
async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl, callerWorkflowId) {
async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl, callerWorkflowId, closeOlderKey) {
const result = await closeOlderEntities(github, owner, repo, workflowId, newIssue, workflowName, runUrl, {
entityType: "issue",
entityTypePlural: "issues",
// Use a closure so callerWorkflowId is 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),
// 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),
getCloseMessage: params =>
getCloseOlderIssueMessage({
newIssueUrl: params.newEntityUrl,
Expand Down
Loading
Loading