diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 69512faf09..4262dcac61 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -29,7 +29,7 @@ # - shared/github-queries-mcp-script.md # - shared/reporting.md # -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"b7ace3a0bae816ff9001b09294262e8a7367f7232f18b3b9918abf9d9d27253d","strict":true} +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"ac22de56a42248c4c1d4fe5d88e120d05e15d25a6950d881e5b5941040c35672","strict":true} name: "Smoke Copilot" "on": @@ -2424,7 +2424,7 @@ jobs: GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} GH_AW_SAFE_OUTPUT_JOBS: "{\"send_slack_message\":\"\"}" - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"allowed_repos\":[\"github/gh-aw\"],\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-copilot\"],\"allowed_repos\":[\"github/gh-aw\"]},\"create_discussion\":{\"category\":\"announcements\",\"close_older_discussions\":true,\"expires\":2,\"fallback_to_issue\":true,\"labels\":[\"ai-generated\"],\"max\":1},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\"},\"dispatch_workflow\":{\"max\":1,\"workflow_files\":{\"haiku-printer\":\".yml\"},\"workflows\":[\"haiku-printer\"]},\"missing_data\":{},\"missing_tool\":{},\"remove_labels\":{\"allowed\":[\"smoke\"]},\"reply_to_pull_request_review_comment\":{\"max\":5},\"set_issue_type\":{},\"submit_pull_request_review\":{\"max\":1}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"allowed_repos\":[\"github/gh-aw\"],\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-copilot\"],\"allowed_repos\":[\"github/gh-aw\"]},\"create_discussion\":{\"category\":\"announcements\",\"close_older_discussions\":true,\"close_older_key\":\"smoke-copilot\",\"expires\":2,\"fallback_to_issue\":true,\"labels\":[\"ai-generated\"],\"max\":1},\"create_issue\":{\"close_older_issues\":true,\"close_older_key\":\"smoke-copilot\",\"expires\":2,\"group\":true,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"create_pull_request_review_comment\":{\"max\":5,\"side\":\"RIGHT\"},\"dispatch_workflow\":{\"max\":1,\"workflow_files\":{\"haiku-printer\":\".yml\"},\"workflows\":[\"haiku-printer\"]},\"missing_data\":{},\"missing_tool\":{},\"remove_labels\":{\"allowed\":[\"smoke\"]},\"reply_to_pull_request_review_comment\":{\"max\":5},\"set_issue_type\":{},\"submit_pull_request_review\":{\"max\":1}}" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index 9e54e64339..5748392c71 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -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 diff --git a/actions/setup/js/close_older_discussions.cjs b/actions/setup/js/close_older_discussions.cjs index 39b809b22a..1af26e6ed5 100644 --- a/actions/setup/js/close_older_discussions.cjs +++ b/actions/setup/js/close_older_discussions.cjs @@ -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"); @@ -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>} 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( @@ -84,9 +100,9 @@ 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; @@ -94,8 +110,6 @@ async function searchOlderDiscussions(github, owner, repo, workflowId, categoryI let closedCount = 0; let markerMismatchCount = 0; - const exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId); - const filtered = result.search.nodes .filter( /** @param {any} d */ d => { @@ -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>} 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, @@ -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, diff --git a/actions/setup/js/close_older_discussions.test.cjs b/actions/setup/js/close_older_discussions.test.cjs index 52521da688..219cb34f6f 100644 --- a/actions/setup/js/close_older_discussions.test.cjs +++ b/actions/setup/js/close_older_discussions.test.cjs @@ -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: "\n", + 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: "", + 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: "", + 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", () => { diff --git a/actions/setup/js/close_older_issues.cjs b/actions/setup/js/close_older_issues.cjs index f4d6d1639f..e630caf554 100644 --- a/actions/setup/js/close_older_issues.cjs +++ b/actions/setup/js/close_older_issues.cjs @@ -1,7 +1,7 @@ // @ts-check /// -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"); @@ -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}>>} 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({ @@ -63,9 +78,9 @@ 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; @@ -73,8 +88,6 @@ async function searchOlderIssues(github, owner, repo, workflowId, excludeNumber, let excludedCount = 0; let markerMismatchCount = 0; - const exactMarker = callerWorkflowId ? generateWorkflowCallIdMarker(callerWorkflowId) : generateWorkflowIdMarker(workflowId); - const filtered = result.data.items .filter(item => { // Exclude pull requests @@ -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>} 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, diff --git a/actions/setup/js/close_older_issues.test.cjs b/actions/setup/js/close_older_issues.test.cjs index 742de2214e..ace3ea284e 100644 --- a/actions/setup/js/close_older_issues.test.cjs +++ b/actions/setup/js/close_older_issues.test.cjs @@ -210,6 +210,105 @@ describe("close_older_issues", () => { expect(results).toHaveLength(1); expect(results[0].number).toBe(123); }); + + it("should use close-key marker as primary search term when closeOlderKey is provided", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Has close-key marker - should be included", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + body: "\n", + }, + { + number: 124, + title: "Missing close-key marker - should be excluded", + html_url: "https://github.com/owner/repo/issues/124", + labels: [], + body: "", + }, + ], + }, + }); + + const results = await searchOlderIssues(mockGithub, "owner", "repo", "some-workflow", 999, undefined, "my-stable-key"); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + // Should search by the close-key marker, not the workflow-id marker + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: 'repo:owner/repo is:issue is:open "gh-aw-close-key: my-stable-key" in:body', + per_page: 50, + }); + }); + + it("should work with close-key when workflowId is empty", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Has close-key marker", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + body: "", + }, + ], + }, + }); + + const results = await searchOlderIssues(mockGithub, "owner", "repo", "", 999, undefined, "team-report"); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: 'repo:owner/repo is:issue is:open "gh-aw-close-key: team-report" in:body', + per_page: 50, + }); + }); + + it("should return empty array when neither workflowId nor closeOlderKey is provided", async () => { + const results = await searchOlderIssues(mockGithub, "owner", "repo", "", 999, undefined, undefined); + + expect(results).toHaveLength(0); + expect(mockGithub.rest.search.issuesAndPullRequests).not.toHaveBeenCalled(); + }); + + it("should prefer close-key marker over callerWorkflowId when both are provided", async () => { + mockGithub.rest.search.issuesAndPullRequests.mockResolvedValue({ + data: { + items: [ + { + number: 123, + title: "Has both markers - close-key should win", + html_url: "https://github.com/owner/repo/issues/123", + labels: [], + body: "\n", + }, + { + number: 124, + title: "Has only caller marker - should be excluded (close-key takes priority)", + html_url: "https://github.com/owner/repo/issues/124", + labels: [], + body: "", + }, + ], + }, + }); + + // Both callerWorkflowId and closeOlderKey provided - close-key should take priority + const results = await searchOlderIssues(mockGithub, "owner", "repo", "my-workflow", 999, "owner/repo/CallerA", "shared-key"); + + expect(results).toHaveLength(1); + expect(results[0].number).toBe(123); + // close-key should be used for search query + expect(mockGithub.rest.search.issuesAndPullRequests).toHaveBeenCalledWith({ + q: 'repo:owner/repo is:issue is:open "gh-aw-close-key: shared-key" in:body', + per_page: 50, + }); + }); }); describe("addIssueComment", () => { diff --git a/actions/setup/js/create_discussion.cjs b/actions/setup/js/create_discussion.cjs index c6be8450db..c0fd46975a 100644 --- a/actions/setup/js/create_discussion.cjs +++ b/actions/setup/js/create_discussion.cjs @@ -16,10 +16,11 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { createExpirationLine, generateFooterWithExpiration } = require("./ephemerals.cjs"); -const { generateWorkflowIdMarker, generateWorkflowCallIdMarker } = require("./generate_footer.cjs"); +const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs"); const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); const { tryEnforceArrayLimit } = require("./limit_enforcement_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); +const { closeOlderDiscussions: closeOlderDiscussionsFunc } = require("./close_older_discussions.cjs"); const { parseBoolTemplatable } = require("./templatable.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { generateHistoryLink } = require("./generate_history_link.cjs"); @@ -310,7 +311,12 @@ 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 = parseBoolTemplatable(config.close_older_discussions, false); + const closeOlderDiscussionsEnabled = parseBoolTemplatable(config.close_older_discussions, false); + const rawCloseOlderKey = config.close_older_key ? String(config.close_older_key) : ""; + const closeOlderKey = rawCloseOlderKey ? normalizeCloseOlderKey(rawCloseOlderKey) : ""; + if (rawCloseOlderKey && !closeOlderKey) { + throw new Error(`close-older-key "${rawCloseOlderKey}" is invalid: it must contain at least one alphanumeric character after normalization`); + } const includeFooter = parseBoolTemplatable(config.footer, true); // Create an authenticated GitHub client. Uses config["github-token"] when set @@ -337,8 +343,11 @@ 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) { + if (closeOlderDiscussionsEnabled) { core.info("Close older discussions enabled: will close older discussions/issues with same workflow-id marker"); + if (closeOlderKey) { + core.info(` Using explicit close-older-key: "${closeOlderKey}"`); + } } // Track state @@ -356,7 +365,8 @@ async function main(config = {}) { max: maxCount, expires: expiresHours, // Map close_older_discussions to close_older_issues for fallback issues - close_older_issues: closeOlderDiscussions, + close_older_issues: closeOlderDiscussionsEnabled, + close_older_key: closeOlderKey, }); } @@ -548,6 +558,10 @@ async function main(config = {}) { if (callerWorkflowId) { bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId)); } + // Add explicit close-key marker when a custom deduplication key is provided + if (closeOlderKey) { + bodyLines.push(generateCloseKeyMarker(closeOlderKey)); + } bodyLines.push(""); const body = bodyLines.join("\n").trim(); @@ -607,6 +621,36 @@ async function main(config = {}) { core.info(`Created discussion ${qualifiedItemRepo}#${discussion.number}: ${discussion.url}`); + // Close older discussions if enabled + if (closeOlderDiscussionsEnabled) { + if (workflowId || closeOlderKey) { + const searchKey = closeOlderKey ? `close-older-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`; + core.info(`Attempting to close older discussions for ${qualifiedItemRepo}#${discussion.number} using ${searchKey}`); + try { + const closedDiscussions = await closeOlderDiscussionsFunc( + github, + repoParts.owner, + repoParts.repo, + workflowId, + categoryId || undefined, + { number: discussion.number, url: discussion.url }, + workflowName, + runUrl, + callerWorkflowId, + closeOlderKey + ); + if (closedDiscussions.length > 0) { + core.info(`Closed ${closedDiscussions.length} older discussion(s)`); + } + } catch (error) { + // Log error but don't fail the workflow + core.warning(`Failed to close older discussions: ${getErrorMessage(error)}`); + } + } else { + core.warning("Close older discussions enabled but GH_AW_WORKFLOW_ID environment variable not set - skipping"); + } + } + // Apply labels if configured if (discussionLabels.length > 0) { core.info(`Applying ${discussionLabels.length} labels to discussion: ${discussionLabels.join(", ")}`); diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index d4e05e704a..19a3383ab1 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -28,7 +28,7 @@ function resetIssuesToAssignCopilot() { const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); const { generateFooterWithMessages } = require("./messages_footer.cjs"); -const { generateWorkflowIdMarker, generateWorkflowCallIdMarker } = require("./generate_footer.cjs"); +const { generateWorkflowIdMarker, generateWorkflowCallIdMarker, generateCloseKeyMarker, normalizeCloseOlderKey } = require("./generate_footer.cjs"); const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { getTrackerID } = require("./get_tracker_id.cjs"); const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, getOrGenerateTemporaryId, replaceTemporaryIdReferences } = require("./temporary_id.cjs"); @@ -216,6 +216,11 @@ async function main(config = {}) { const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const groupEnabled = parseBoolTemplatable(config.group, false); const closeOlderIssuesEnabled = parseBoolTemplatable(config.close_older_issues, false); + const rawCloseOlderKey = config.close_older_key ? String(config.close_older_key) : ""; + const closeOlderKey = rawCloseOlderKey ? normalizeCloseOlderKey(rawCloseOlderKey) : ""; + if (rawCloseOlderKey && !closeOlderKey) { + throw new Error(`close-older-key "${rawCloseOlderKey}" is invalid: it must contain at least one alphanumeric character after normalization`); + } const includeFooter = parseBoolTemplatable(config.footer, true); // Create an authenticated GitHub client. Uses config["github-token"] when set @@ -250,6 +255,9 @@ async function main(config = {}) { } if (closeOlderIssuesEnabled) { core.info(`Close older issues enabled: older issues with same workflow-id marker will be closed`); + if (closeOlderKey) { + core.info(` Using explicit close-older-key: "${closeOlderKey}"`); + } } // Track how many items we've processed for max limit @@ -475,6 +483,10 @@ async function main(config = {}) { if (callerWorkflowId) { bodyLines.push(generateWorkflowCallIdMarker(callerWorkflowId)); } + // Add explicit close-key marker when a custom deduplication key is provided + if (closeOlderKey) { + bodyLines.push(generateCloseKeyMarker(closeOlderKey)); + } bodyLines.push(""); const body = bodyLines.join("\n").trim(); @@ -531,10 +543,11 @@ async function main(config = {}) { // Close older issues if enabled if (closeOlderIssuesEnabled) { - if (workflowId) { - core.info(`Attempting to close older issues for ${qualifiedItemRepo}#${issue.number} using workflow-id: ${workflowId}`); + if (workflowId || closeOlderKey) { + const searchKey = closeOlderKey ? `close-older-key: ${closeOlderKey}` : `workflow-id: ${workflowId}`; + core.info(`Attempting to close older issues for ${qualifiedItemRepo}#${issue.number} using ${searchKey}`); try { - const closedIssues = await closeOlderIssues(github, repoParts.owner, repoParts.repo, workflowId, { number: issue.number, html_url: issue.html_url }, workflowName, runUrl, callerWorkflowId); + const closedIssues = await closeOlderIssues(github, repoParts.owner, repoParts.repo, workflowId, { number: issue.number, html_url: issue.html_url }, workflowName, runUrl, callerWorkflowId, closeOlderKey); if (closedIssues.length > 0) { core.info(`Closed ${closedIssues.length} older issue(s)`); } diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs index 062223c38d..e806ef45e0 100644 --- a/actions/setup/js/generate_footer.cjs +++ b/actions/setup/js/generate_footer.cjs @@ -122,10 +122,56 @@ function generateWorkflowCallIdMarker(callerWorkflowId) { return ``; } +/** + * Normalizes a user-supplied close-older-key to identifier style. + * Converts to lowercase, replaces runs of non-alphanumeric/dash/underscore characters + * with a single dash, then trims leading and trailing dashes and underscores. + * + * Examples: "My Key!" → "my-key", " hello world " → "hello-world" + * + * @param {string} key - Raw user-supplied key + * @returns {string} Normalized identifier-style key, or empty string if nothing remains + */ +function normalizeCloseOlderKey(key) { + return key + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); +} + +/** + * Generates a standalone close-key XML comment marker for close-older matching. + * When a user provides an explicit `close-older-key`, this marker is embedded in + * the issue/discussion body and used as the primary deduplication key instead of + * the workflow-id or workflow-call-id markers. + * + * @param {string} closeKey - Normalized close-older deduplication key + * @returns {string} Standalone close-key XML comment marker + */ +function generateCloseKeyMarker(closeKey) { + return ``; +} + +/** + * Gets the close-key marker content (without XML comment wrapper) for searching. + * This is used when searching for issues/discussions by close-older-key. + * + * @param {string} closeKey - Normalized close-older deduplication key + * @returns {string} Close-key marker content for search queries + */ +function getCloseKeyMarkerContent(closeKey) { + return `gh-aw-close-key: ${closeKey}`; +} + module.exports = { generateXMLMarker, generateWorkflowIdMarker, generateWorkflowCallIdMarker, getWorkflowIdMarkerContent, generateExpiredEntityFooter, + normalizeCloseOlderKey, + generateCloseKeyMarker, + getCloseKeyMarkerContent, }; diff --git a/actions/setup/js/generate_footer.test.cjs b/actions/setup/js/generate_footer.test.cjs index b7e88fa562..d7109e39cb 100644 --- a/actions/setup/js/generate_footer.test.cjs +++ b/actions/setup/js/generate_footer.test.cjs @@ -36,6 +36,7 @@ describe("generate_footer.cjs", () => { let generateWorkflowIdMarker; let generateWorkflowCallIdMarker; let getWorkflowIdMarkerContent; + let normalizeCloseOlderKey; beforeEach(async () => { // Reset mocks @@ -54,6 +55,7 @@ describe("generate_footer.cjs", () => { generateWorkflowIdMarker = module.generateWorkflowIdMarker; generateWorkflowCallIdMarker = module.generateWorkflowCallIdMarker; getWorkflowIdMarkerContent = module.getWorkflowIdMarkerContent; + normalizeCloseOlderKey = module.normalizeCloseOlderKey; }); describe("generateXMLMarker", () => { @@ -286,6 +288,50 @@ describe("generate_footer.cjs", () => { }); }); + describe("normalizeCloseOlderKey", () => { + it("should return an already-valid identifier unchanged", () => { + expect(normalizeCloseOlderKey("my-key")).toBe("my-key"); + expect(normalizeCloseOlderKey("smoke_copilot")).toBe("smoke_copilot"); + expect(normalizeCloseOlderKey("abc123")).toBe("abc123"); + }); + + it("should convert to lowercase", () => { + expect(normalizeCloseOlderKey("MyKey")).toBe("mykey"); + expect(normalizeCloseOlderKey("SMOKE-COPILOT")).toBe("smoke-copilot"); + }); + + it("should replace spaces with dashes", () => { + expect(normalizeCloseOlderKey("my key")).toBe("my-key"); + expect(normalizeCloseOlderKey("hello world foo")).toBe("hello-world-foo"); + }); + + it("should replace special characters with dashes", () => { + expect(normalizeCloseOlderKey("My Key!")).toBe("my-key"); + expect(normalizeCloseOlderKey("foo@bar#baz")).toBe("foo-bar-baz"); + }); + + it("should collapse multiple consecutive dashes into one", () => { + expect(normalizeCloseOlderKey("a b")).toBe("a-b"); + expect(normalizeCloseOlderKey("foo---bar")).toBe("foo-bar"); + }); + + it("should trim leading and trailing dashes and underscores", () => { + expect(normalizeCloseOlderKey(" hello ")).toBe("hello"); + expect(normalizeCloseOlderKey("!hello!")).toBe("hello"); + expect(normalizeCloseOlderKey("-foo-")).toBe("foo"); + }); + + it("should return empty string for whitespace-only input", () => { + expect(normalizeCloseOlderKey(" ")).toBe(""); + expect(normalizeCloseOlderKey("\t\n")).toBe(""); + }); + + it("should return empty string for input with only special characters", () => { + expect(normalizeCloseOlderKey("!!!")).toBe(""); + expect(normalizeCloseOlderKey("@#$%")).toBe(""); + }); + }); + describe("generateExpiredEntityFooter", () => { let generateExpiredEntityFooter; diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b551d22d6e..bf72e65b56 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4245,6 +4245,12 @@ "description": "When true, automatically close older issues with the same workflow-id marker as 'not planned' with a comment linking to the new issue. Searches for issues containing the workflow-id marker in their body. Maximum 10 issues will be closed. Only runs if issue creation succeeds.", "default": false }, + "close-older-key": { + "type": "string", + "description": "Optional explicit deduplication key for close-older matching. When set, a `` marker is embedded in the issue body and used as the primary key for searching and filtering older issues instead of the workflow-id markers. This gives deterministic isolation across caller workflows and is stable across workflow renames. The value is normalized to identifier style (lowercase alphanumeric, dashes, underscores).", + "minLength": 1, + "pattern": "\\S" + }, "footer": { "type": "boolean", "description": "Controls whether AI-generated footer is added to the issue. When false, the visible footer content is omitted but XML markers (workflow-id, tracker-id, metadata) are still included for searchability. Defaults to true.", @@ -4724,6 +4730,12 @@ "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 }, + "close-older-key": { + "type": "string", + "description": "Optional explicit deduplication key for close-older matching. When set, a `` marker is embedded in the discussion body and used as the primary key for searching and filtering older discussions instead of the workflow-id markers. This gives deterministic isolation across caller workflows and is stable across workflow renames. The value is normalized to identifier style (lowercase alphanumeric, dashes, underscores).", + "minLength": 1, + "pattern": "\\S" + }, "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.", diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 941b755cd2..d8e55fed4b 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -149,6 +149,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddTemplatableBool("group", c.Group). AddTemplatableBool("close_older_issues", c.CloseOlderIssues). + AddIfNotEmpty("close_older_key", c.CloseOlderKey). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). Build() @@ -181,6 +182,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_labels", c.AllowedLabels). AddStringSlice("allowed_repos", c.AllowedRepos). AddTemplatableBool("close_older_discussions", c.CloseOlderDiscussions). + AddIfNotEmpty("close_older_key", c.CloseOlderKey). AddIfNotEmpty("required_category", c.RequiredCategory). AddIfPositive("expires", c.Expires). AddBoolPtr("fallback_to_issue", c.FallbackToIssue). diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index b4b8ef52ac..2223eb7d3b 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -20,6 +20,7 @@ type CreateDiscussionsConfig struct { TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository discussions AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that discussions can be created in CloseOlderDiscussions *string `yaml:"close-older-discussions,omitempty"` // When true, close older discussions with same title prefix or labels as outdated + CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. RequiredCategory string `yaml:"required-category,omitempty"` // Required category for matching when close-older-discussions is enabled Expires int `yaml:"expires,omitempty"` // Hours until the discussion expires and should be automatically closed FallbackToIssue *bool `yaml:"fallback-to-issue,omitempty"` // When true (default), fallback to create-issue if discussion creation fails due to permissions. diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index 6e94d40aff..278f3ddc6e 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -18,6 +18,7 @@ type CreateIssuesConfig struct { 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 *string `yaml:"close-older-issues,omitempty"` // When true, close older issues with same title prefix or labels as "not planned" + CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed Group *string `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept.