diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index af4d1ad15a0..bfc9cbc6df0 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: @@ -144,15 +167,15 @@ 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 * @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 +194,7 @@ 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); @@ -186,15 +209,15 @@ 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 * @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 +247,7 @@ 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,15 +267,15 @@ 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) { - core.info("No workflow ID available, skipping hide-older-comments"); +async function hideOlderComments(github, owner, repo, itemNumber, workflowIds, isDiscussion, reason = "outdated", allowedReasons = null) { + if (!Array.isArray(workflowIds) || workflowIds.length === 0) { + core.info("No workflow IDs provided, skipping hide-older-comments"); return 0; } @@ -268,13 +291,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 +398,13 @@ async function commentOnDiscussion(github, owner, repo, discussionNumber, messag */ async function main(config = {}) { // Extract configuration - const hideOlderCommentsEnabled = parseBoolTemplatable(config.hide_older_comments, false); + 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) + ? normalizeWorkflowIdList(hideOlderCommentsConfig.match) + : Array.isArray(config.hide_older_comments_match) + ? normalizeWorkflowIdList(config.hide_older_comments_match) + : []; const commentTarget = config.target || "triggering"; const maxCount = config.max || 20; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); @@ -406,6 +435,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 +808,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 = normalizeWorkflowIdList([workflowId, ...hideOlderCommentsMatch]); + 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..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"); @@ -3248,6 +3255,182 @@ 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; + 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 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; + 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/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.* 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..1e99956d10a 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" ) @@ -13,15 +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 - 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 @@ -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 configuration: %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,44 @@ 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 { + 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 + } + + 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).