Skip to content

Commit 12a6436

Browse files
authored
feat: support explicit custom key for close-older matching (#21076)
1 parent 050484d commit 12a6436

14 files changed

+430
-52
lines changed

.github/workflows/smoke-copilot.lock.yml

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-copilot.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ safe-outputs:
5656
expires: 2h
5757
group: true
5858
close-older-issues: true
59+
close-older-key: "smoke-copilot"
5960
labels: [automation, testing]
6061
create-discussion:
6162
category: announcements
6263
labels: [ai-generated]
6364
expires: 2h
6465
close-older-discussions: true
66+
close-older-key: "smoke-copilot"
6567
max: 1
6668
create-pull-request-review-comment:
6769
max: 5

actions/setup/js/close_older_discussions.cjs

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
const { getCloseOlderDiscussionMessage } = require("./messages_close_discussion.cjs");
55
const { getErrorMessage } = require("./error_helpers.cjs");
6-
const { getWorkflowIdMarkerContent, generateWorkflowIdMarker, generateWorkflowCallIdMarker } = require("./generate_footer.cjs");
6+
const { getWorkflowIdMarkerContent, generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, getCloseKeyMarkerContent } = require("./generate_footer.cjs");
77
const { sanitizeContent } = require("./sanitize_content.cjs");
88
const { closeOlderEntities, MAX_CLOSE_COUNT: SHARED_MAX_CLOSE_COUNT } = require("./close_older_entities.cjs");
99

@@ -29,26 +29,42 @@ const GRAPHQL_DELAY_MS = 500;
2929
* When set, filters by the `gh-aw-workflow-call-id` marker so callers sharing the same
3030
* reusable workflow do not close each other's discussions. Falls back to `gh-aw-workflow-id`
3131
* when not provided (backward compat for discussions created before this fix).
32+
* @param {string} [closeOlderKey] - Optional explicit deduplication key. When set, the
33+
* `gh-aw-close-key` marker is used as the primary search term and exact filter instead
34+
* of the workflow-id / workflow-call-id markers.
3235
* @returns {Promise<Array<{id: string, number: number, title: string, url: string}>>} Matching discussions
3336
*/
34-
async function searchOlderDiscussions(github, owner, repo, workflowId, categoryId, excludeNumber, callerWorkflowId) {
37+
async function searchOlderDiscussions(github, owner, repo, workflowId, categoryId, excludeNumber, callerWorkflowId, closeOlderKey) {
3538
core.info(`Starting search for older discussions in ${owner}/${repo}`);
3639
core.info(` Workflow ID: ${workflowId || "(none)"}`);
3740
core.info(` Exclude discussion number: ${excludeNumber}`);
3841

39-
if (!workflowId) {
40-
core.info("No workflow ID provided - cannot search for older discussions");
42+
if (!workflowId && !closeOlderKey) {
43+
core.info("No workflow ID or close-older-key provided - cannot search for older discussions");
4144
return [];
4245
}
4346

44-
// Build GraphQL search query
45-
// Search for open discussions with the workflow-id marker in the body
46-
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
47-
// Escape quotes in workflow ID to prevent query injection
48-
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
49-
let searchQuery = `repo:${owner}/${repo} is:open "${escapedMarker}" in:body`;
50-
51-
core.info(` Added workflow ID marker filter to query: "${escapedMarker}" in:body`);
47+
// Build GraphQL search query.
48+
// When a close-older-key is provided it becomes the primary search term; otherwise
49+
// fall back to the workflow-id marker.
50+
let searchQuery;
51+
let exactMarker;
52+
if (closeOlderKey) {
53+
const closeKeyMarkerContent = getCloseKeyMarkerContent(closeOlderKey);
54+
const escapedMarker = closeKeyMarkerContent.replace(/"/g, '\\"');
55+
searchQuery = `repo:${owner}/${repo} is:open "${escapedMarker}" in:body`;
56+
exactMarker = generateCloseKeyMarker(closeOlderKey);
57+
core.info(` Using close-older-key for search: "${escapedMarker}" in:body`);
58+
} else {
59+
// Build GraphQL search query
60+
// Search for open discussions with the workflow-id marker in the body
61+
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
62+
// Escape quotes in workflow ID to prevent query injection
63+
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
64+
searchQuery = `repo:${owner}/${repo} is:open "${escapedMarker}" in:body`;
65+
exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);
66+
core.info(` Added workflow ID marker filter to query: "${escapedMarker}" in:body`);
67+
}
5268
core.info(`Executing GitHub search with query: ${searchQuery}`);
5369

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

97-
const exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);
98-
99113
const filtered = result.search.nodes
100114
.filter(
101115
/** @param {any} d */ d => {
@@ -215,9 +229,10 @@ async function closeDiscussionAsOutdated(github, owner, repo, discussionId) {
215229
* @param {string} workflowName - Name of the workflow
216230
* @param {string} runUrl - URL of the workflow run
217231
* @param {string} [callerWorkflowId] - Optional calling workflow identity for precise filtering
232+
* @param {string} [closeOlderKey] - Optional explicit deduplication key for close-older matching
218233
* @returns {Promise<Array<{number: number, url: string}>>} List of closed discussions
219234
*/
220-
async function closeOlderDiscussions(github, owner, repo, workflowId, categoryId, newDiscussion, workflowName, runUrl, callerWorkflowId) {
235+
async function closeOlderDiscussions(github, owner, repo, workflowId, categoryId, newDiscussion, workflowName, runUrl, callerWorkflowId, closeOlderKey) {
221236
const result = await closeOlderEntities(
222237
github,
223238
owner,
@@ -229,9 +244,10 @@ async function closeOlderDiscussions(github, owner, repo, workflowId, categoryId
229244
{
230245
entityType: "discussion",
231246
entityTypePlural: "discussions",
232-
// Use a closure so callerWorkflowId is forwarded to searchOlderDiscussions without going
233-
// through the closeOlderEntities extraArgs mechanism (which appends excludeNumber last)
234-
searchOlderEntities: (gh, o, r, wid, categoryId, excludeNumber) => searchOlderDiscussions(gh, o, r, wid, categoryId, excludeNumber, callerWorkflowId),
247+
// Use a closure so callerWorkflowId and closeOlderKey are forwarded to
248+
// searchOlderDiscussions without going through the closeOlderEntities extraArgs
249+
// mechanism (which appends excludeNumber last)
250+
searchOlderEntities: (gh, o, r, wid, categoryId, excludeNumber) => searchOlderDiscussions(gh, o, r, wid, categoryId, excludeNumber, callerWorkflowId, closeOlderKey),
235251
getCloseMessage: params =>
236252
getCloseOlderDiscussionMessage({
237253
newDiscussionUrl: params.newEntityUrl,

actions/setup/js/close_older_discussions.test.cjs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,87 @@ describe("close_older_discussions.cjs", () => {
320320
expect(result).toHaveLength(1);
321321
expect(result[0].number).toBe(5);
322322
});
323+
324+
it("should use close-key marker as primary search term when closeOlderKey is provided", async () => {
325+
const { searchOlderDiscussions } = await import("./close_older_discussions.cjs");
326+
327+
mockGithub.graphql.mockResolvedValueOnce({
328+
search: {
329+
nodes: [
330+
{
331+
id: "D_with_key",
332+
number: 5,
333+
title: "Has close-key marker - should be included",
334+
url: "https://github.com/testowner/testrepo/discussions/5",
335+
body: "<!-- gh-aw-workflow-id: some-workflow -->\n<!-- gh-aw-close-key: my-stable-key -->",
336+
category: { id: "DIC_test123" },
337+
closed: false,
338+
},
339+
{
340+
id: "D_no_key",
341+
number: 6,
342+
title: "Missing close-key marker - should be excluded",
343+
url: "https://github.com/testowner/testrepo/discussions/6",
344+
body: "<!-- gh-aw-workflow-id: some-workflow -->",
345+
category: { id: "DIC_test123" },
346+
closed: false,
347+
},
348+
],
349+
},
350+
});
351+
352+
const result = await searchOlderDiscussions(mockGithub, "testowner", "testrepo", "some-workflow", "DIC_test123", 99, undefined, "my-stable-key");
353+
354+
expect(result).toHaveLength(1);
355+
expect(result[0].number).toBe(5);
356+
// Should search by the close-key marker, not the workflow-id marker
357+
expect(mockGithub.graphql).toHaveBeenCalledWith(
358+
expect.any(String),
359+
expect.objectContaining({
360+
searchTerms: expect.stringContaining("gh-aw-close-key: my-stable-key"),
361+
})
362+
);
363+
});
364+
365+
it("should work with close-key when workflowId is empty", async () => {
366+
const { searchOlderDiscussions } = await import("./close_older_discussions.cjs");
367+
368+
mockGithub.graphql.mockResolvedValueOnce({
369+
search: {
370+
nodes: [
371+
{
372+
id: "D_with_key",
373+
number: 5,
374+
title: "Has close-key marker",
375+
url: "https://github.com/testowner/testrepo/discussions/5",
376+
body: "<!-- gh-aw-close-key: team-report -->",
377+
category: { id: "DIC_test123" },
378+
closed: false,
379+
},
380+
],
381+
},
382+
});
383+
384+
const result = await searchOlderDiscussions(mockGithub, "testowner", "testrepo", "", "DIC_test123", 99, undefined, "team-report");
385+
386+
expect(result).toHaveLength(1);
387+
expect(result[0].number).toBe(5);
388+
expect(mockGithub.graphql).toHaveBeenCalledWith(
389+
expect.any(String),
390+
expect.objectContaining({
391+
searchTerms: expect.stringContaining("gh-aw-close-key: team-report"),
392+
})
393+
);
394+
});
395+
396+
it("should return empty array when neither workflowId nor closeOlderKey is provided", async () => {
397+
const { searchOlderDiscussions } = await import("./close_older_discussions.cjs");
398+
399+
const result = await searchOlderDiscussions(mockGithub, "testowner", "testrepo", "", "DIC_test123", 99, undefined, undefined);
400+
401+
expect(result).toHaveLength(0);
402+
expect(mockGithub.graphql).not.toHaveBeenCalled();
403+
});
323404
});
324405

325406
describe("closeOlderDiscussions", () => {

actions/setup/js/close_older_issues.cjs

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @ts-check
22
/// <reference types="@actions/github-script" />
33

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

@@ -26,26 +26,41 @@ const API_DELAY_MS = 500;
2626
* When set, filters by the `gh-aw-workflow-call-id` marker so callers sharing the same
2727
* reusable workflow do not close each other's issues. Falls back to `gh-aw-workflow-id`
2828
* when not provided (backward compat for issues created before this fix).
29+
* @param {string} [closeOlderKey] - Optional explicit deduplication key. When set, the
30+
* `gh-aw-close-key` marker is used as the primary search term and exact filter instead
31+
* of the workflow-id / workflow-call-id markers.
2932
* @returns {Promise<Array<{number: number, title: string, html_url: string, labels: Array<{name: string}>}>>} Matching issues
3033
*/
31-
async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, callerWorkflowId) {
34+
async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, callerWorkflowId, closeOlderKey) {
3235
core.info(`Starting search for older issues in ${owner}/${repo}`);
3336
core.info(` Workflow ID: ${workflowId || "(none)"}`);
3437
core.info(` Exclude issue number: ${excludeNumber}`);
3538

36-
if (!workflowId) {
37-
core.info("No workflow ID provided - cannot search for older issues");
39+
if (!workflowId && !closeOlderKey) {
40+
core.info("No workflow ID or close-older-key provided - cannot search for older issues");
3841
return [];
3942
}
4043

41-
// Build REST API search query
42-
// Search for open issues with the workflow-id marker in the body
43-
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
44-
// Escape quotes in workflow ID to prevent query injection
45-
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
46-
const searchQuery = `repo:${owner}/${repo} is:issue is:open "${escapedMarker}" in:body`;
47-
48-
core.info(` Added workflow-id marker filter to query: "${escapedMarker}" in:body`);
44+
// Build REST API search query.
45+
// When a close-older-key is provided it becomes the primary search term; otherwise
46+
// fall back to the workflow-id marker.
47+
let searchQuery;
48+
let exactMarker;
49+
if (closeOlderKey) {
50+
const closeKeyMarkerContent = getCloseKeyMarkerContent(closeOlderKey);
51+
const escapedMarker = closeKeyMarkerContent.replace(/"/g, '\\"');
52+
searchQuery = `repo:${owner}/${repo} is:issue is:open "${escapedMarker}" in:body`;
53+
exactMarker = generateCloseKeyMarker(closeOlderKey);
54+
core.info(` Using close-older-key for search: "${escapedMarker}" in:body`);
55+
} else {
56+
// Search for open issues with the workflow-id marker in the body
57+
const workflowIdMarker = getWorkflowIdMarkerContent(workflowId);
58+
// Escape quotes in workflow ID to prevent query injection
59+
const escapedMarker = workflowIdMarker.replace(/"/g, '\\"');
60+
searchQuery = `repo:${owner}/${repo} is:issue is:open "${escapedMarker}" in:body`;
61+
exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);
62+
core.info(` Added workflow-id marker filter to query: "${escapedMarker}" in:body`);
63+
}
4964
core.info(`Executing GitHub search with query: ${searchQuery}`);
5065

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

76-
const exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId);
77-
7891
const filtered = result.data.items
7992
.filter(item => {
8093
// Exclude pull requests
@@ -205,15 +218,17 @@ function getCloseOlderIssueMessage({ newIssueUrl, newIssueNumber, workflowName,
205218
* @param {string} workflowName - Name of the workflow
206219
* @param {string} runUrl - URL of the workflow run
207220
* @param {string} [callerWorkflowId] - Optional calling workflow identity for precise filtering
221+
* @param {string} [closeOlderKey] - Optional explicit deduplication key for close-older matching
208222
* @returns {Promise<Array<{number: number, html_url: string}>>} List of closed issues
209223
*/
210-
async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl, callerWorkflowId) {
224+
async function closeOlderIssues(github, owner, repo, workflowId, newIssue, workflowName, runUrl, callerWorkflowId, closeOlderKey) {
211225
const result = await closeOlderEntities(github, owner, repo, workflowId, newIssue, workflowName, runUrl, {
212226
entityType: "issue",
213227
entityTypePlural: "issues",
214-
// Use a closure so callerWorkflowId is forwarded to searchOlderIssues without going
215-
// through the closeOlderEntities extraArgs mechanism (which appends excludeNumber last)
216-
searchOlderEntities: (gh, o, r, wid, excludeNumber) => searchOlderIssues(gh, o, r, wid, excludeNumber, callerWorkflowId),
228+
// Use a closure so callerWorkflowId and closeOlderKey are forwarded to searchOlderIssues
229+
// without going through the closeOlderEntities extraArgs mechanism (which appends
230+
// excludeNumber last)
231+
searchOlderEntities: (gh, o, r, wid, excludeNumber) => searchOlderIssues(gh, o, r, wid, excludeNumber, callerWorkflowId, closeOlderKey),
217232
getCloseMessage: params =>
218233
getCloseOlderIssueMessage({
219234
newIssueUrl: params.newEntityUrl,

0 commit comments

Comments
 (0)