-
Notifications
You must be signed in to change notification settings - Fork 418
add-comment: support hide-older-comments.match for exact multi-workflow comment minimization
#37977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bb81f15
c3fd001
93c08cb
c92f3a8
e4552c0
0f9c050
1125206
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Array<{id: number, node_id: string, body: string}>>} | ||
| */ | ||
| async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { | ||
| async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowIds) { | ||
|
Comment on lines
174
to
+178
|
||
| 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<Array<{id: string, body: string}>>} | ||
| */ | ||
| async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { | ||
| async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowIds) { | ||
|
Comment on lines
216
to
+220
|
||
| 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>} 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The 💡 Suggested test (compiled config form)// mirrors what the Go registry actually sends to the JS handler
const handler = await eval(`(async () => {
${addCommentScript};
return await main({
hide_older_comments: "true",
hide_older_comments_match: ["other_workflow"]
});
})()`);
const result = await handler({ type: "add_comment", body: "compiled path test" }, {});
expect(hiddenNodeIds).toContain("IC_kwDOTest11"); // other_workflowThis mirrors the actual runtime values set by |
||
| : []; | ||
| 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]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] When 💡 Suggested testit("should hide match-list comments when GH_AW_WORKFLOW_ID is not set", async () => {
delete process.env.GH_AW_WORKFLOW_ID;
// ... setup mocks ...
const handler = await eval(`... main({ hide_older_comments: { match: ["target-wf"] } }) ...`);
const result = await handler({ type: "add_comment", body: "test" }, {});
expect(hiddenNodeIds).toContain(/* target-wf comment node */);
expect(hiddenNodeIds).not.toContain(/* unrelated comment node */);
}); |
||
| await hideOlderComments(githubClient, repoParts.owner, repoParts.repo, itemNumber, hideWorkflowIds, isDiscussion); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.* |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd]
normalizeWorkflowIdListtrims whitespace and deduplicates entries, but neither behaviour is exercised by any test. A user authoringmatch: [" wf-a ", "wf-a"]should get["wf-a"]— both the dedup and the trim are load-bearing for correctness.💡 Suggested unit-level assertions
Since this is a pure function, it can be tested directly (or via a small dedicated test block without needing a full mock setup):