From bb81f15a55202304482c88442ce4a3e9c6ed9ce1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:09:03 +0000 Subject: [PATCH 1/7] Initial plan From c3fd001adcf83c3f2ff5f367e0fa6ed6607681e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:21:36 +0000 Subject: [PATCH 2/7] Add hide-older-comments match handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_comment.cjs | 45 ++++++---- actions/setup/js/add_comment.test.cjs | 85 +++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 25 +++++- pkg/workflow/add_comment.go | 42 +++++++++ pkg/workflow/add_comment_target_repo_test.go | 47 ++++++++++ pkg/workflow/safe_outputs_handler_registry.go | 1 + 6 files changed, 228 insertions(+), 17 deletions(-) diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index af4d1ad15a0..d706ee9b3e8 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -149,10 +149,10 @@ async function minimizeComment(github, nodeId, reason = "outdated") { * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {number} issueNumber - Issue/PR number - * @param {string} workflowId - Workflow ID to search for + * @param {string[]} workflowIds - Workflow IDs to search for * @returns {Promise>} */ -async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { +async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowIds) { const comments = []; let page = 1; const perPage = 100; @@ -171,7 +171,9 @@ async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workf break; } - const filteredComments = data.filter(comment => matchesWorkflowId(comment.body, workflowId)).map(({ id, node_id, body }) => ({ id, node_id, body })); + const filteredComments = data + .filter(comment => workflowIds.some(id => matchesWorkflowId(comment.body, id))) + .map(({ id, node_id, body }) => ({ id, node_id, body })); comments.push(...filteredComments); @@ -191,10 +193,10 @@ async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workf * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {number} discussionNumber - Discussion number - * @param {string} workflowId - Workflow ID to search for + * @param {string[]} workflowIds - Workflow IDs to search for * @returns {Promise>} */ -async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { +async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowIds) { const query = /* GraphQL */ ` query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -224,7 +226,9 @@ async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussi break; } - const filteredComments = result.repository.discussion.comments.nodes.filter(comment => matchesWorkflowId(comment.body, workflowId)).map(({ id, body }) => ({ id, body })); + const filteredComments = result.repository.discussion.comments.nodes + .filter(comment => workflowIds.some(id => matchesWorkflowId(comment.body, id))) + .map(({ id, body }) => ({ id, body })); comments.push(...filteredComments); @@ -244,14 +248,14 @@ async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussi * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {number} itemNumber - Issue/PR/Discussion number - * @param {string} workflowId - Workflow ID to match + * @param {string[]} workflowIds - Workflow IDs to match * @param {boolean} isDiscussion - Whether this is a discussion * @param {string} reason - Reason for hiding (default: outdated) * @param {string[] | null} allowedReasons - List of allowed reasons (default: null for all) * @returns {Promise} Number of comments hidden */ -async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { +async function hideOlderComments(github, owner, repo, itemNumber, workflowIds, isDiscussion, reason = "outdated", allowedReasons = null) { + if (!Array.isArray(workflowIds) || workflowIds.length === 0) { core.info("No workflow ID available, skipping hide-older-comments"); return 0; } @@ -268,13 +272,13 @@ async function hideOlderComments(github, owner, repo, itemNumber, workflowId, is } } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); + core.info(`Searching for previous comments with workflow IDs: ${workflowIds.join(", ")}`); let comments; if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); + comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowIds); } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); + comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowIds); } if (comments.length === 0) { @@ -375,7 +379,14 @@ async function commentOnDiscussion(github, owner, repo, discussionNumber, messag */ async function main(config = {}) { // Extract configuration - const hideOlderCommentsEnabled = parseBoolTemplatable(config.hide_older_comments, false); + const hideOlderCommentsConfig = + config.hide_older_comments && typeof config.hide_older_comments === "object" && !Array.isArray(config.hide_older_comments) ? config.hide_older_comments : null; + const hideOlderCommentsEnabled = parseBoolTemplatable(hideOlderCommentsConfig ? (hideOlderCommentsConfig.enabled ?? true) : config.hide_older_comments, false); + const hideOlderCommentsMatch = Array.isArray(hideOlderCommentsConfig?.match) + ? [...new Set(hideOlderCommentsConfig.match.filter(id => typeof id === "string").map(id => id.trim()).filter(Boolean))] + : Array.isArray(config.hide_older_comments_match) + ? [...new Set(config.hide_older_comments_match.filter(id => typeof id === "string").map(id => id.trim()).filter(Boolean))] + : []; const commentTarget = config.target || "triggering"; const maxCount = config.max || 20; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); @@ -406,6 +417,9 @@ async function main(config = {}) { if (requiredTitlePrefix) core.info(`Required title prefix: ${requiredTitlePrefix}`); if (hideOlderCommentsEnabled) { core.info("Hide-older-comments is enabled"); + if (hideOlderCommentsMatch.length > 0) { + core.info(`Hide-older-comments additional workflow matches: ${hideOlderCommentsMatch.join(", ")}`); + } } if (appendOnlyComments) { core.info("Append-only-comments is enabled - will not hide older comments"); @@ -776,8 +790,9 @@ async function main(config = {}) { core.info("Skipping hide-older-comments because an existing comment is being updated"); } else if (appendOnlyComments) { core.info("Skipping hide-older-comments because append-only-comments is enabled"); - } else if (workflowId) { - await hideOlderComments(githubClient, repoParts.owner, repoParts.repo, itemNumber, workflowId, isDiscussion); + } else { + const hideWorkflowIds = [...new Set([workflowId, ...hideOlderCommentsMatch].filter(Boolean))]; + await hideOlderComments(githubClient, repoParts.owner, repoParts.repo, itemNumber, hideWorkflowIds, isDiscussion); } } diff --git a/actions/setup/js/add_comment.test.cjs b/actions/setup/js/add_comment.test.cjs index 22b4aa7419c..867c6e1e33f 100644 --- a/actions/setup/js/add_comment.test.cjs +++ b/actions/setup/js/add_comment.test.cjs @@ -3248,6 +3248,91 @@ describe("add_comment", () => { } } }); + + it("should hide comments matching hide-older-comments.match values", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + const originalWorkflowId = process.env.GH_AW_WORKFLOW_ID; + process.env.GH_AW_WORKFLOW_ID = "current-workflow"; + + try { + let hiddenNodeIds = []; + mockGithub.rest.issues.listComments = async () => ({ + data: [ + { + id: 10, + node_id: "IC_kwDOTest10", + body: "Current\n\n", + }, + { + id: 11, + node_id: "IC_kwDOTest11", + body: "Other\n\n", + }, + { + id: 12, + node_id: "IC_kwDOTest12", + body: "Unrelated\n\n", + }, + ], + }); + mockGithub.graphql = async (query, variables) => { + if (query.includes("minimizeComment")) hiddenNodeIds.push(variables.nodeId); + return { minimizeComment: { minimizedComment: { isMinimized: true } } }; + }; + mockGithub.rest.issues.createComment = async () => ({ + data: { id: 1, html_url: "https://github.com/owner/repo/issues/8535#issuecomment-1" }, + }); + + const handler = await eval( + `(async () => { ${addCommentScript}; return await main({ hide_older_comments: { match: ["other_workflow"] } }); })()` + ); + + const result = await handler({ type: "add_comment", body: "Comment with match list" }, {}); + + expect(result.success).toBe(true); + expect(hiddenNodeIds).toContain("IC_kwDOTest10"); + expect(hiddenNodeIds).toContain("IC_kwDOTest11"); + expect(hiddenNodeIds).not.toContain("IC_kwDOTest12"); + } finally { + if (originalWorkflowId === undefined) { + delete process.env.GH_AW_WORKFLOW_ID; + } else { + process.env.GH_AW_WORKFLOW_ID = originalWorkflowId; + } + } + }); + + it("should respect hide_older_comments.enabled when object config is used", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + const originalWorkflowId = process.env.GH_AW_WORKFLOW_ID; + process.env.GH_AW_WORKFLOW_ID = "current-workflow"; + + try { + let listCommentsCalled = false; + mockGithub.rest.issues.listComments = async () => { + listCommentsCalled = true; + return { data: [] }; + }; + mockGithub.rest.issues.createComment = async () => ({ + data: { id: 1, html_url: "https://github.com/owner/repo/issues/8535#issuecomment-1" }, + }); + + const handler = await eval( + `(async () => { ${addCommentScript}; return await main({ hide_older_comments: { enabled: false, match: ["other_workflow"] } }); })()` + ); + + const result = await handler({ type: "add_comment", body: "Comment with disabled hide" }, {}); + + expect(result.success).toBe(true); + expect(listCommentsCalled).toBe(false); + } finally { + if (originalWorkflowId === undefined) { + delete process.env.GH_AW_WORKFLOW_ID; + } else { + process.env.GH_AW_WORKFLOW_ID = originalWorkflowId; + } + } + }); }); let enforceCommentLimits; diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index c6d017c09f6..cd155d572cf 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6266,8 +6266,29 @@ ] }, "hide-older-comments": { - "$ref": "#/$defs/templatable_boolean", - "description": "When true, minimizes/hides all previous comments from the same agentic workflow (identified by tracker-id) before creating the new comment. Supports literal boolean or GitHub Actions expression (e.g. '${{ inputs.hide-older-comments }}'). Default: false." + "description": "When true, minimizes/hides all previous comments from the same agentic workflow (identified by tracker-id) before creating the new comment. Supports literal boolean, GitHub Actions expression (e.g. '${{ inputs.hide-older-comments }}'), or object form for advanced matching. Default: false.", + "oneOf": [ + { + "$ref": "#/$defs/templatable_boolean" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "$ref": "#/$defs/templatable_boolean", + "description": "Enable or disable hide-older-comments when using object form. Defaults to true when omitted." + }, + "match": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional workflow-id values to fully match when selecting older comments to hide." + } + } + } + ] }, "allowed-reasons": { "type": "array", diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index 61f49e69f2e..5ab8394d758 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -1,6 +1,8 @@ package workflow import ( + "fmt" + "github.com/github/gh-aw/pkg/logger" ) @@ -17,6 +19,7 @@ type AddCommentsConfig struct { TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository comments AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that comments can be added to (additionally to the target-repo) HideOlderComments *string `yaml:"hide-older-comments,omitempty"` // When true, minimizes/hides all previous comments from the same workflow before creating the new comment + HideOlderCommentsMatch []string `yaml:"hide-older-comments-match,omitempty"` AllowedReasons []string `yaml:"allowed-reasons,omitempty"` // List of allowed reasons for hiding older comments (default: all reasons allowed) Issues *bool `yaml:"issues,omitempty"` // When false, excludes issues:write permission and issues from event condition. Default (nil or true) includes issues:write. PullRequests *bool `yaml:"pull-requests,omitempty"` // When false, excludes pull-requests:write permission and PRs from event condition. Default (nil or true) includes pull-requests:write. @@ -34,6 +37,11 @@ func (c *Compiler) parseCommentsConfig(outputMap map[string]any) *AddCommentsCon // Get config data for pre-processing before YAML unmarshaling configData, _ := outputMap["add-comment"].(map[string]any) + if err := preprocessHideOlderCommentsConfig(configData, addCommentLog); err != nil { + addCommentLog.Printf("Invalid hide-older-comments value: %v", err) + return nil + } + // Pre-process templatable bool fields if err := preprocessBoolFieldAsString(configData, "hide-older-comments", addCommentLog); err != nil { addCommentLog.Printf("Invalid hide-older-comments value: %v", err) @@ -73,6 +81,40 @@ func (c *Compiler) parseCommentsConfig(outputMap map[string]any) *AddCommentsCon return config } +func preprocessHideOlderCommentsConfig(configData map[string]any, debugLog *logger.Logger) error { + if configData == nil { + return nil + } + + raw, exists := configData["hide-older-comments"] + if !exists || raw == nil { + return nil + } + + objectConfig, ok := raw.(map[string]any) + if !ok { + return nil + } + + if enabledRaw, hasEnabled := objectConfig["enabled"]; hasEnabled { + enabledMap := map[string]any{"enabled": enabledRaw} + if err := preprocessBoolFieldAsString(enabledMap, "enabled", debugLog); err != nil { + return fmt.Errorf("field %q must be a boolean or a GitHub Actions expression", "hide-older-comments.enabled") + } + if enabled, ok := enabledMap["enabled"]; ok { + configData["hide-older-comments"] = enabled + } + } else { + configData["hide-older-comments"] = "true" + } + + if matchRaw, hasMatch := objectConfig["match"]; hasMatch { + configData["hide-older-comments-match"] = parseStringSliceAny(matchRaw, debugLog) + } + + return nil +} + // buildAddCommentPermissions computes the permissions for the add_comment job based on config. // Issues: nil or true → issues:write (default: true) // PullRequests: nil or true → pull-requests:write (default: true) diff --git a/pkg/workflow/add_comment_target_repo_test.go b/pkg/workflow/add_comment_target_repo_test.go index ed283c165f0..848bdf2d78a 100644 --- a/pkg/workflow/add_comment_target_repo_test.go +++ b/pkg/workflow/add_comment_target_repo_test.go @@ -103,6 +103,7 @@ func TestAddCommentsConfigHideOlderComments(t *testing.T) { name string configMap map[string]any expectedHideOlderComments *string + expectedMatch []string }{ { name: "hide-older-comments enabled", @@ -113,6 +114,7 @@ func TestAddCommentsConfigHideOlderComments(t *testing.T) { }, }, expectedHideOlderComments: new("true"), + expectedMatch: nil, }, { name: "hide-older-comments disabled", @@ -123,6 +125,7 @@ func TestAddCommentsConfigHideOlderComments(t *testing.T) { }, }, expectedHideOlderComments: new("false"), + expectedMatch: nil, }, { name: "hide-older-comments not specified (default nil)", @@ -132,6 +135,7 @@ func TestAddCommentsConfigHideOlderComments(t *testing.T) { }, }, expectedHideOlderComments: nil, + expectedMatch: nil, }, { name: "hide-older-comments with other fields", @@ -144,6 +148,34 @@ func TestAddCommentsConfigHideOlderComments(t *testing.T) { }, }, expectedHideOlderComments: new("true"), + expectedMatch: nil, + }, + { + name: "hide-older-comments object form defaults enabled and parses match list", + configMap: map[string]any{ + "add-comment": map[string]any{ + "max": 1, + "hide-older-comments": map[string]any{ + "match": []any{"other_workflow", "yet-another"}, + }, + }, + }, + expectedHideOlderComments: new("true"), + expectedMatch: []string{"other_workflow", "yet-another"}, + }, + { + name: "hide-older-comments object form supports explicit enabled false", + configMap: map[string]any{ + "add-comment": map[string]any{ + "max": 1, + "hide-older-comments": map[string]any{ + "enabled": false, + "match": []any{"other_workflow"}, + }, + }, + }, + expectedHideOlderComments: new("false"), + expectedMatch: []string{"other_workflow"}, }, } @@ -166,6 +198,21 @@ func TestAddCommentsConfigHideOlderComments(t *testing.T) { t.Errorf("Expected HideOlderComments = %v, got %v", *tt.expectedHideOlderComments, *config.HideOlderComments) } } + + if tt.expectedMatch == nil { + if len(config.HideOlderCommentsMatch) != 0 { + t.Errorf("Expected HideOlderCommentsMatch to be empty, got %v", config.HideOlderCommentsMatch) + } + } else { + if len(config.HideOlderCommentsMatch) != len(tt.expectedMatch) { + t.Fatalf("Expected %d hide-older match values, got %d", len(tt.expectedMatch), len(config.HideOlderCommentsMatch)) + } + for i := range tt.expectedMatch { + if config.HideOlderCommentsMatch[i] != tt.expectedMatch[i] { + t.Errorf("Expected HideOlderCommentsMatch[%d] = %q, got %q", i, tt.expectedMatch[i], config.HideOlderCommentsMatch[i]) + } + } + } }) } } diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index d5a497b068d..120212d0f06 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -40,6 +40,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("target", c.Target). AddTemplatableBool("hide_older_comments", c.HideOlderComments). + AddStringSlice("hide_older_comments_match", c.HideOlderCommentsMatch). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). From 93c08cbdf3b2195257d451d96bc0ebbd2fb143d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:23:36 +0000 Subject: [PATCH 3/7] Support hide-older-comments match list Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_comment.cjs | 29 ++++++++++++++++++--------- actions/setup/js/add_comment.test.cjs | 8 ++------ pkg/workflow/add_comment.go | 26 ++++++++++++++---------- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index d706ee9b3e8..a9e06d00a11 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -171,9 +171,7 @@ async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workf break; } - const filteredComments = data - .filter(comment => workflowIds.some(id => matchesWorkflowId(comment.body, id))) - .map(({ id, node_id, body }) => ({ id, node_id, body })); + const filteredComments = data.filter(comment => workflowIds.some(id => matchesWorkflowId(comment.body, id))).map(({ id, node_id, body }) => ({ id, node_id, body })); comments.push(...filteredComments); @@ -226,9 +224,7 @@ async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussi break; } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => workflowIds.some(id => matchesWorkflowId(comment.body, id))) - .map(({ id, body }) => ({ id, body })); + const filteredComments = result.repository.discussion.comments.nodes.filter(comment => workflowIds.some(id => matchesWorkflowId(comment.body, id))).map(({ id, body }) => ({ id, body })); comments.push(...filteredComments); @@ -379,13 +375,26 @@ async function commentOnDiscussion(github, owner, repo, discussionNumber, messag */ async function main(config = {}) { // Extract configuration - const hideOlderCommentsConfig = - config.hide_older_comments && typeof config.hide_older_comments === "object" && !Array.isArray(config.hide_older_comments) ? config.hide_older_comments : null; + const hideOlderCommentsConfig = config.hide_older_comments && typeof config.hide_older_comments === "object" && !Array.isArray(config.hide_older_comments) ? config.hide_older_comments : null; const hideOlderCommentsEnabled = parseBoolTemplatable(hideOlderCommentsConfig ? (hideOlderCommentsConfig.enabled ?? true) : config.hide_older_comments, false); const hideOlderCommentsMatch = Array.isArray(hideOlderCommentsConfig?.match) - ? [...new Set(hideOlderCommentsConfig.match.filter(id => typeof id === "string").map(id => id.trim()).filter(Boolean))] + ? [ + ...new Set( + hideOlderCommentsConfig.match + .filter(id => typeof id === "string") + .map(id => id.trim()) + .filter(Boolean) + ), + ] : Array.isArray(config.hide_older_comments_match) - ? [...new Set(config.hide_older_comments_match.filter(id => typeof id === "string").map(id => id.trim()).filter(Boolean))] + ? [ + ...new Set( + config.hide_older_comments_match + .filter(id => typeof id === "string") + .map(id => id.trim()) + .filter(Boolean) + ), + ] : []; const commentTarget = config.target || "triggering"; const maxCount = config.max || 20; diff --git a/actions/setup/js/add_comment.test.cjs b/actions/setup/js/add_comment.test.cjs index 867c6e1e33f..23d7577907c 100644 --- a/actions/setup/js/add_comment.test.cjs +++ b/actions/setup/js/add_comment.test.cjs @@ -3283,9 +3283,7 @@ describe("add_comment", () => { data: { id: 1, html_url: "https://github.com/owner/repo/issues/8535#issuecomment-1" }, }); - const handler = await eval( - `(async () => { ${addCommentScript}; return await main({ hide_older_comments: { match: ["other_workflow"] } }); })()` - ); + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ hide_older_comments: { match: ["other_workflow"] } }); })()`); const result = await handler({ type: "add_comment", body: "Comment with match list" }, {}); @@ -3317,9 +3315,7 @@ describe("add_comment", () => { data: { id: 1, html_url: "https://github.com/owner/repo/issues/8535#issuecomment-1" }, }); - const handler = await eval( - `(async () => { ${addCommentScript}; return await main({ hide_older_comments: { enabled: false, match: ["other_workflow"] } }); })()` - ); + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ hide_older_comments: { enabled: false, match: ["other_workflow"] } }); })()`); const result = await handler({ type: "add_comment", body: "Comment with disabled hide" }, {}); diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index 5ab8394d758..1ce99e057e5 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -20,11 +20,11 @@ type AddCommentsConfig struct { AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that comments can be added to (additionally to the target-repo) HideOlderComments *string `yaml:"hide-older-comments,omitempty"` // When true, minimizes/hides all previous comments from the same workflow before creating the new comment HideOlderCommentsMatch []string `yaml:"hide-older-comments-match,omitempty"` - AllowedReasons []string `yaml:"allowed-reasons,omitempty"` // List of allowed reasons for hiding older comments (default: all reasons allowed) - Issues *bool `yaml:"issues,omitempty"` // When false, excludes issues:write permission and issues from event condition. Default (nil or true) includes issues:write. - PullRequests *bool `yaml:"pull-requests,omitempty"` // When false, excludes pull-requests:write permission and PRs from event condition. Default (nil or true) includes pull-requests:write. - Discussions *bool `yaml:"discussions,omitempty"` // When false, excludes discussions:write permission. Default (nil or true) includes discussions:write. - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. + AllowedReasons []string `yaml:"allowed-reasons,omitempty"` // List of allowed reasons for hiding older comments (default: all reasons allowed) + Issues *bool `yaml:"issues,omitempty"` // When false, excludes issues:write permission and issues from event condition. Default (nil or true) includes issues:write. + PullRequests *bool `yaml:"pull-requests,omitempty"` // When false, excludes pull-requests:write permission and PRs from event condition. Default (nil or true) includes pull-requests:write. + Discussions *bool `yaml:"discussions,omitempty"` // When false, excludes discussions:write permission. Default (nil or true) includes discussions:write. + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseCommentsConfig handles add-comment configuration @@ -97,15 +97,19 @@ func preprocessHideOlderCommentsConfig(configData map[string]any, debugLog *logg } if enabledRaw, hasEnabled := objectConfig["enabled"]; hasEnabled { - enabledMap := map[string]any{"enabled": enabledRaw} - if err := preprocessBoolFieldAsString(enabledMap, "enabled", debugLog); err != nil { - return fmt.Errorf("field %q must be a boolean or a GitHub Actions expression", "hide-older-comments.enabled") - } - if enabled, ok := enabledMap["enabled"]; ok { + switch enabled := enabledRaw.(type) { + case bool: configData["hide-older-comments"] = enabled + case string: + if !isExpression(enabled) { + return fmt.Errorf("field %q must be a boolean or a GitHub Actions expression", "hide-older-comments.enabled") + } + configData["hide-older-comments"] = enabled + default: + return fmt.Errorf("field %q must be a boolean or a GitHub Actions expression", "hide-older-comments.enabled") } } else { - configData["hide-older-comments"] = "true" + configData["hide-older-comments"] = true } if matchRaw, hasMatch := objectConfig["match"]; hasMatch { From c92f3a87f39f6f63cf92f8e4d7273439a304f770 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:24:42 +0000 Subject: [PATCH 4/7] Refactor hide-older-comments match normalization Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_comment.cjs | 45 +++++++++++++++++++------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index a9e06d00a11..2a10a88e92f 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -49,6 +49,29 @@ function deduplicateCaseInsensitive(aliases) { }); } +/** + * @param {unknown} value + * @returns {value is { enabled?: boolean | string, match?: unknown[] }} + */ +function isHideOlderCommentsObject(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +/** + * @param {unknown[]} ids + * @returns {string[]} + */ +function normalizeWorkflowIdList(ids) { + return [ + ...new Set( + ids + .filter(id => typeof id === "string") + .map(id => id.trim()) + .filter(Boolean) + ), + ]; +} + /** * Resolve effective event name/payload for native and forwarded contexts. * Supports: @@ -375,26 +398,12 @@ async function commentOnDiscussion(github, owner, repo, discussionNumber, messag */ async function main(config = {}) { // Extract configuration - const hideOlderCommentsConfig = config.hide_older_comments && typeof config.hide_older_comments === "object" && !Array.isArray(config.hide_older_comments) ? config.hide_older_comments : null; + const hideOlderCommentsConfig = isHideOlderCommentsObject(config.hide_older_comments) ? config.hide_older_comments : null; const hideOlderCommentsEnabled = parseBoolTemplatable(hideOlderCommentsConfig ? (hideOlderCommentsConfig.enabled ?? true) : config.hide_older_comments, false); const hideOlderCommentsMatch = Array.isArray(hideOlderCommentsConfig?.match) - ? [ - ...new Set( - hideOlderCommentsConfig.match - .filter(id => typeof id === "string") - .map(id => id.trim()) - .filter(Boolean) - ), - ] + ? normalizeWorkflowIdList(hideOlderCommentsConfig.match) : Array.isArray(config.hide_older_comments_match) - ? [ - ...new Set( - config.hide_older_comments_match - .filter(id => typeof id === "string") - .map(id => id.trim()) - .filter(Boolean) - ), - ] + ? normalizeWorkflowIdList(config.hide_older_comments_match) : []; const commentTarget = config.target || "triggering"; const maxCount = config.max || 20; @@ -800,7 +809,7 @@ async function main(config = {}) { } else if (appendOnlyComments) { core.info("Skipping hide-older-comments because append-only-comments is enabled"); } else { - const hideWorkflowIds = [...new Set([workflowId, ...hideOlderCommentsMatch].filter(Boolean))]; + const hideWorkflowIds = normalizeWorkflowIdList([workflowId, ...hideOlderCommentsMatch]); await hideOlderComments(githubClient, repoParts.owner, repoParts.repo, itemNumber, hideWorkflowIds, isDiscussion); } } From e4552c0325ac6dcb30518d892610db9ee695a636 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:26:08 +0000 Subject: [PATCH 5/7] Polish hide-older-comments messaging Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_comment.cjs | 2 +- pkg/workflow/add_comment.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index 2a10a88e92f..cdd7ce8a610 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -275,7 +275,7 @@ async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussi */ async function hideOlderComments(github, owner, repo, itemNumber, workflowIds, isDiscussion, reason = "outdated", allowedReasons = null) { if (!Array.isArray(workflowIds) || workflowIds.length === 0) { - core.info("No workflow ID available, skipping hide-older-comments"); + core.info("No workflow IDs provided, skipping hide-older-comments"); return 0; } diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index 1ce99e057e5..972f6116a28 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -38,7 +38,7 @@ func (c *Compiler) parseCommentsConfig(outputMap map[string]any) *AddCommentsCon configData, _ := outputMap["add-comment"].(map[string]any) if err := preprocessHideOlderCommentsConfig(configData, addCommentLog); err != nil { - addCommentLog.Printf("Invalid hide-older-comments value: %v", err) + addCommentLog.Printf("Invalid hide-older-comments configuration: %v", err) return nil } From 0f9c0501bc854eb3cb1d4ed53d459f9edec53d59 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:11:17 +0000 Subject: [PATCH 6/7] Add draft ADR for hide-older-comments match list (Design Decision Gate) --- ...orm-hide-older-comments-with-match-list.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/adr/37977-object-form-hide-older-comments-with-match-list.md diff --git a/docs/adr/37977-object-form-hide-older-comments-with-match-list.md b/docs/adr/37977-object-form-hide-older-comments-with-match-list.md new file mode 100644 index 00000000000..6d7cb168709 --- /dev/null +++ b/docs/adr/37977-object-form-hide-older-comments-with-match-list.md @@ -0,0 +1,40 @@ +# ADR-37977: Object Form for `hide-older-comments` with Multi-Workflow Match List + +**Date**: 2026-06-09 +**Status**: Draft + +## Context + +The `add-comment` safe output supports a `hide-older-comments` option that minimizes previous comments from the *same* agentic workflow before posting a new one, identified by a tracker-id embedded in the comment body. Some workflows emit multiple distinct comments per run, and other workflows post comments that conceptually belong to the same logical cleanup set. Because the existing boolean form only ever matches the currently-running workflow's ID, those additional comments cannot be minimized in the same pass, forcing brittle downstream cleanup logic. We need a way to declare additional workflow IDs whose older comments should also be hidden, without breaking the widely-used boolean configuration. + +## Decision + +We will extend `hide-older-comments` to accept an **object form** alongside the existing boolean form. The object supports two keys: `enabled` (templatable boolean, defaulting to `true` when omitted) and `match` (an array of workflow-id strings). At runtime the handler hides older comments whose tracker-id exactly matches any ID in the set composed of the current workflow ID plus the configured `match` entries. Match entries are normalized (trimmed, empty-filtered, de-duplicated) before use. The existing boolean form is preserved unchanged for backward compatibility, and the JSON schema becomes a `oneOf` of the templatable boolean and the object. Matching remains **exact, full-string** — not substring or pattern based — so only intentionally named workflows are affected. + +## Alternatives Considered + +### Alternative 1: Flat top-level `hide-older-comments-match` field +Expose the match list as a sibling top-level field rather than nesting it inside an object. This avoids the `oneOf` schema and keeps the boolean form untouched, but it scatters related configuration across two keys, allows nonsensical states (a match list with hiding disabled), and reads less cohesively. The object form groups the toggle and its modifiers together, so it was preferred. (Internally the compiler still flattens `match` to a `hide-older-comments-match` field for handler plumbing, but the authored surface stays grouped.) + +### Alternative 2: Substring or pattern matching of workflow IDs +Allow `match` entries to match workflow IDs by prefix, substring, or glob. This would be more flexible for families of related workflows, but it risks silently minimizing unrelated comments whose IDs happen to overlap, which is hard to debug and irreversible from the user's view. Exact full-string matching was chosen to keep the behavior predictable and safe. + +## Consequences + +### Positive +- A single `add-comment` run can minimize older comments from multiple explicitly-named workflows, removing brittle downstream cleanup. +- Fully backward compatible: existing boolean `hide-older-comments` configurations are unchanged in meaning and schema validity. +- Exact full-string matching keeps hiding behavior predictable and limits blast radius to intentionally listed workflows. + +### Negative +- Configuration and parsing complexity increases: the compiler must preprocess an object-or-boolean union, and the JS handler must branch on object vs. boolean shape. +- The schema is now a `oneOf`, which produces less precise validation error messages than a single concrete type. +- `match` values are maintained by hand and can drift if a referenced workflow is renamed, silently reducing what gets hidden. + +### Neutral +- The compiled handler config gains a `hide_older_comments_match` string-slice field, and the search functions now accept a list of workflow IDs instead of a single ID. +- Normalization (trim/dedup) happens both at compile time (Go) and runtime (JS), so behavior is consistent regardless of which path supplies the list. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27177131882) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* From 1125206edb1eb5ee3813c24843d38c1bde05f50d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:49:09 +0000 Subject: [PATCH 7/7] test: cover add-comment workflow match review feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/add_comment.cjs | 4 +- actions/setup/js/add_comment.test.cjs | 102 ++++++++++++++++++++++++++ pkg/workflow/add_comment.go | 20 ++--- 3 files changed, 114 insertions(+), 12 deletions(-) diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index cdd7ce8a610..bfc9cbc6df0 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -167,7 +167,7 @@ async function minimizeComment(github, nodeId, reason = "outdated") { } /** - * Find comments on an issue/PR with a specific tracker-id + * Find comments on an issue/PR with any matching workflow ID marker * @param {any} github - GitHub REST API instance * @param {string} owner - Repository owner * @param {string} repo - Repository name @@ -209,7 +209,7 @@ async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workf } /** - * Find comments on a discussion with a specific workflow ID + * Find comments on a discussion with any matching workflow ID marker * @param {any} github - GitHub GraphQL instance * @param {string} owner - Repository owner * @param {string} repo - Repository name diff --git a/actions/setup/js/add_comment.test.cjs b/actions/setup/js/add_comment.test.cjs index 23d7577907c..d347f662b51 100644 --- a/actions/setup/js/add_comment.test.cjs +++ b/actions/setup/js/add_comment.test.cjs @@ -3185,6 +3185,13 @@ describe("add_comment", () => { }); describe("hide-older-comments behavior", () => { + it("should normalize workflow ID match lists", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + const exports = await eval(`(async () => { ${addCommentScript}; return { normalizeWorkflowIdList }; })()`); + + expect(exports.normalizeWorkflowIdList([" wf-a ", "wf-a", "", " ", "wf-b "])).toEqual(["wf-a", "wf-b"]); + }); + it("should skip hiding when no workflow ID is set", async () => { const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); @@ -3249,6 +3256,57 @@ describe("add_comment", () => { } }); + it("should hide comments matching compiled hide_older_comments_match values", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + const originalWorkflowId = process.env.GH_AW_WORKFLOW_ID; + process.env.GH_AW_WORKFLOW_ID = "current-workflow"; + + try { + let hiddenNodeIds = []; + mockGithub.rest.issues.listComments = async () => ({ + data: [ + { + id: 10, + node_id: "IC_kwDOTest10", + body: "Current\n\n", + }, + { + id: 11, + node_id: "IC_kwDOTest11", + body: "Other\n\n", + }, + { + id: 12, + node_id: "IC_kwDOTest12", + body: "Unrelated\n\n", + }, + ], + }); + mockGithub.graphql = async (query, variables) => { + if (query.includes("minimizeComment")) hiddenNodeIds.push(variables.nodeId); + return { minimizeComment: { minimizedComment: { isMinimized: true } } }; + }; + mockGithub.rest.issues.createComment = async () => ({ + data: { id: 1, html_url: "https://github.com/owner/repo/issues/8535#issuecomment-1" }, + }); + + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ hide_older_comments: "true", hide_older_comments_match: [" other_workflow ", "other_workflow", "", " "] }); })()`); + + const result = await handler({ type: "add_comment", body: "Comment with compiled match list" }, {}); + + expect(result.success).toBe(true); + expect(hiddenNodeIds).toContain("IC_kwDOTest10"); + expect(hiddenNodeIds).toContain("IC_kwDOTest11"); + expect(hiddenNodeIds).not.toContain("IC_kwDOTest12"); + } finally { + if (originalWorkflowId === undefined) { + delete process.env.GH_AW_WORKFLOW_ID; + } else { + process.env.GH_AW_WORKFLOW_ID = originalWorkflowId; + } + } + }); + it("should hide comments matching hide-older-comments.match values", async () => { const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); const originalWorkflowId = process.env.GH_AW_WORKFLOW_ID; @@ -3300,6 +3358,50 @@ describe("add_comment", () => { } }); + it("should hide match-list comments when workflow ID is not set", async () => { + const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + const originalWorkflowId = process.env.GH_AW_WORKFLOW_ID; + delete process.env.GH_AW_WORKFLOW_ID; + + try { + let hiddenNodeIds = []; + mockGithub.rest.issues.listComments = async () => ({ + data: [ + { + id: 11, + node_id: "IC_kwDOTest11", + body: "Target\n\n", + }, + { + id: 12, + node_id: "IC_kwDOTest12", + body: "Unrelated\n\n", + }, + ], + }); + mockGithub.graphql = async (query, variables) => { + if (query.includes("minimizeComment")) hiddenNodeIds.push(variables.nodeId); + return { minimizeComment: { minimizedComment: { isMinimized: true } } }; + }; + mockGithub.rest.issues.createComment = async () => ({ + data: { id: 1, html_url: "https://github.com/owner/repo/issues/8535#issuecomment-1" }, + }); + + const handler = await eval(`(async () => { ${addCommentScript}; return await main({ hide_older_comments: { match: ["target-wf"] } }); })()`); + + const result = await handler({ type: "add_comment", body: "Comment with match list only" }, {}); + + expect(result.success).toBe(true); + expect(hiddenNodeIds).toEqual(["IC_kwDOTest11"]); + } finally { + if (originalWorkflowId === undefined) { + delete process.env.GH_AW_WORKFLOW_ID; + } else { + process.env.GH_AW_WORKFLOW_ID = originalWorkflowId; + } + } + }); + it("should respect hide_older_comments.enabled when object config is used", async () => { const addCommentScript = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); const originalWorkflowId = process.env.GH_AW_WORKFLOW_ID; diff --git a/pkg/workflow/add_comment.go b/pkg/workflow/add_comment.go index 972f6116a28..1e99956d10a 100644 --- a/pkg/workflow/add_comment.go +++ b/pkg/workflow/add_comment.go @@ -15,16 +15,16 @@ type AddCommentConfig = AddCommentsConfig type AddCommentsConfig struct { BaseSafeOutputConfig `yaml:",inline"` SafeOutputFilterConfig `yaml:",inline"` - Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any issue), or explicit issue number - TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository comments - AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that comments can be added to (additionally to the target-repo) - HideOlderComments *string `yaml:"hide-older-comments,omitempty"` // When true, minimizes/hides all previous comments from the same workflow before creating the new comment - HideOlderCommentsMatch []string `yaml:"hide-older-comments-match,omitempty"` - AllowedReasons []string `yaml:"allowed-reasons,omitempty"` // List of allowed reasons for hiding older comments (default: all reasons allowed) - Issues *bool `yaml:"issues,omitempty"` // When false, excludes issues:write permission and issues from event condition. Default (nil or true) includes issues:write. - PullRequests *bool `yaml:"pull-requests,omitempty"` // When false, excludes pull-requests:write permission and PRs from event condition. Default (nil or true) includes pull-requests:write. - Discussions *bool `yaml:"discussions,omitempty"` // When false, excludes discussions:write permission. Default (nil or true) includes discussions:write. - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. + Target string `yaml:"target,omitempty"` // Target for comments: "triggering" (default), "*" (any issue), or explicit issue number + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository comments + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that comments can be added to (additionally to the target-repo) + HideOlderComments *string `yaml:"hide-older-comments,omitempty"` // When true, minimizes/hides all previous comments from the same workflow before creating the new comment + HideOlderCommentsMatch []string `yaml:"hide-older-comments-match,omitempty"` // Internal list populated from hide-older-comments.match and passed to the JS handler as exact workflow ID matches + AllowedReasons []string `yaml:"allowed-reasons,omitempty"` // List of allowed reasons for hiding older comments (default: all reasons allowed) + Issues *bool `yaml:"issues,omitempty"` // When false, excludes issues:write permission and issues from event condition. Default (nil or true) includes issues:write. + PullRequests *bool `yaml:"pull-requests,omitempty"` // When false, excludes pull-requests:write permission and PRs from event condition. Default (nil or true) includes pull-requests:write. + Discussions *bool `yaml:"discussions,omitempty"` // When false, excludes discussions:write permission. Default (nil or true) includes discussions:write. + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseCommentsConfig handles add-comment configuration