diff --git a/actions/setup/js/generate_footer.cjs b/actions/setup/js/generate_footer.cjs index 7c1413d7c64..016e7e2b3a8 100644 --- a/actions/setup/js/generate_footer.cjs +++ b/actions/setup/js/generate_footer.cjs @@ -172,6 +172,63 @@ function generateWorkflowCallIdMarker(callerWorkflowId) { return ``; } +/** + * Generates a non-rendered Markdown reference definition carrying a workflow-call ID. + * Unlike HTML comments, GitHub preserves this form in pull request review bodies. + * + * @param {string} callerWorkflowId - Calling workflow identifier + * @returns {string} Markdown reference definition for review provenance + */ +function generateWorkflowCallIdReviewMarker(callerWorkflowId) { + return `[gh-aw-workflow-call-id]: # "${encodeURIComponent(callerWorkflowId)}"`; +} + +/** + * Check whether any trimmed line of a body satisfies a marker predicate. + * + * @param {string|null|undefined} body - Body to scan + * @param {(line: string) => boolean} predicate - Predicate applied to each trimmed line + * @returns {boolean} Whether any line matches + */ +function someMarkerLine(body, predicate) { + if (!body) return false; + return body.split(/\r?\n/).some(line => predicate(line.trim())); +} + +/** + * Check whether a body contains a line that exactly equals the given marker. + * + * @param {string|null|undefined} body - Body to scan + * @param {string} marker - Complete marker line + * @returns {boolean} Whether an exact marker line is present + */ +function matchesExactMarkerLine(body, marker) { + if (!marker) return false; + return someMarkerLine(body, line => line === marker); +} + +/** + * Check whether a review body has an exact workflow-call ID marker line. + * Supports the legacy HTML comment and the durable Markdown reference marker. + * + * @param {string|null|undefined} body - Review body + * @param {string} callerWorkflowId - Calling workflow identifier + * @returns {boolean} Whether the review belongs to the calling workflow + */ +function matchesWorkflowCallId(body, callerWorkflowId) { + if (!body || !callerWorkflowId) return false; + if (matchesExactMarkerLine(body, generateWorkflowCallIdMarker(callerWorkflowId))) return true; + return someMarkerLine(body, trimmedLine => { + const durableMatch = trimmedLine.match(/^\[gh-aw-workflow-call-id\]: # "([^"]+)"$/); + if (!durableMatch) return false; + try { + return decodeURIComponent(durableMatch[1]) === callerWorkflowId; + } catch { + return false; + } + }); +} + /** * Normalizes a user-supplied close-older-key to identifier style. * Converts to lowercase, replaces runs of non-alphanumeric/dash/underscore characters @@ -294,6 +351,8 @@ module.exports = { generateXMLMarker, generateWorkflowIdMarker, generateWorkflowCallIdMarker, + generateWorkflowCallIdReviewMarker, + matchesWorkflowCallId, getWorkflowIdMarkerContent, matchesWorkflowId, isValidWorkflowId, diff --git a/actions/setup/js/generate_footer.test.cjs b/actions/setup/js/generate_footer.test.cjs index 618ee17f1c2..0b01befbbc4 100644 --- a/actions/setup/js/generate_footer.test.cjs +++ b/actions/setup/js/generate_footer.test.cjs @@ -37,6 +37,8 @@ describe("generate_footer.cjs", () => { let generateXMLMarker; let generateWorkflowIdMarker; let generateWorkflowCallIdMarker; + let generateWorkflowCallIdReviewMarker; + let matchesWorkflowCallId; let getWorkflowIdMarkerContent; let normalizeCloseOlderKey; @@ -58,6 +60,8 @@ describe("generate_footer.cjs", () => { generateXMLMarker = module.generateXMLMarker; generateWorkflowIdMarker = module.generateWorkflowIdMarker; generateWorkflowCallIdMarker = module.generateWorkflowCallIdMarker; + generateWorkflowCallIdReviewMarker = module.generateWorkflowCallIdReviewMarker; + matchesWorkflowCallId = module.matchesWorkflowCallId; getWorkflowIdMarkerContent = module.getWorkflowIdMarkerContent; normalizeCloseOlderKey = module.normalizeCloseOlderKey; }); @@ -246,6 +250,27 @@ describe("generate_footer.cjs", () => { }); }); + describe("generateWorkflowCallIdReviewMarker", () => { + it("should generate an encoded non-rendered marker", () => { + const result = generateWorkflowCallIdReviewMarker('owner/repo/Workflow "with" spaces'); + + expect(result).toBe('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FWorkflow%20%22with%22%20spaces"'); + }); + }); + + describe("matchesWorkflowCallId", () => { + it("matches exact legacy and durable marker lines only", () => { + const callerWorkflowId = "owner/repo/Caller"; + + expect(matchesWorkflowCallId("Review\n", callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"\nReview', callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2frepo%2fCaller"', callerWorkflowId)).toBe(true); + expect(matchesWorkflowCallId('Quoted [gh-aw-workflow-call-id]: # "owner%2Frepo%2FCaller"', callerWorkflowId)).toBe(false); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerB"', callerWorkflowId)).toBe(false); + expect(matchesWorkflowCallId('[gh-aw-workflow-call-id]: # ""', "")).toBe(false); + }); + }); + describe("getWorkflowIdMarkerContent", () => { it("should return marker content without XML wrapper", () => { const result = getWorkflowIdMarkerContent("test-workflow"); diff --git a/actions/setup/js/messages.test.cjs b/actions/setup/js/messages.test.cjs index 880c97bbf3c..a8e35ab3fd0 100644 --- a/actions/setup/js/messages.test.cjs +++ b/actions/setup/js/messages.test.cjs @@ -36,6 +36,7 @@ describe("messages.cjs", () => { delete process.env.GH_AW_TRACKER_ID; delete process.env.GITHUB_RUN_ID; delete process.env.GH_AW_WORKFLOW_ID; + delete process.env.GH_AW_CALLER_WORKFLOW_ID; delete process.env.GH_AW_DEPRECATED_COST; delete process.env.GH_AW_AIC; delete process.env.GH_AW_AMBIENT_CONTEXT; @@ -270,7 +271,7 @@ describe("messages.cjs", () => { expect(result).toBe("> Test (Test)"); }); - it("should append history link when historyUrl is provided", async () => { + it("should append provenance on its own line when historyUrl is provided", async () => { const { getFooterMessage } = await import("./messages.cjs"); const result = getFooterMessage({ @@ -279,7 +280,20 @@ describe("messages.cjs", () => { historyUrl: "https://github.com/search?q=repo:test/repo+is:issue&type=issues", }); - expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); + expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); + }); + + it("should use the caller workflow ID as the provenance identifier", async () => { + process.env.GH_AW_CALLER_WORKFLOW_ID = "owner/repo/test-workflow"; + const { getFooterMessage } = await import("./messages.cjs"); + + const result = getFooterMessage({ + workflowName: "Test Workflow", + runUrl: "https://github.com/test/repo/actions/runs/123", + historyUrl: "https://github.com/search?q=repo:test/repo+is:issue&type=issues", + }); + + expect(result).toContain("> Provenance: [owner/repo/test-workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); }); it("should include both triggering number and history link when both are provided", async () => { @@ -292,7 +306,7 @@ describe("messages.cjs", () => { historyUrl: "https://github.com/search?q=repo:test/repo+is:issue&type=issues", }); - expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) for #42 · [◷](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); + expect(result).toBe("> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) for #42\n> Provenance: [Test Workflow](https://github.com/search?q=repo:test/repo+is:issue&type=issues)"); }); it("should not append history link when historyUrl is not provided", async () => { @@ -307,7 +321,7 @@ describe("messages.cjs", () => { expect(result).not.toContain("◷"); }); - it("should expose {history_link} placeholder in custom footer templates", async () => { + it("should replace the legacy {history_link} placeholder with standardized provenance", async () => { process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> 🤖 *Generated by [{workflow_name}]({run_url})*{history_link}", }); @@ -321,7 +335,8 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> 🤖 *Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)* · [◷](${historyUrl})`); + expect(result).toBe(`> 🤖 *Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)*\n> Provenance: [Test Workflow](${historyUrl})`); + expect(result).not.toContain("◷"); }); it("should render empty string for {history_link} when historyUrl is not provided", async () => { @@ -410,7 +425,7 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})`); + expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})`); }); it("should include AI Credits without AI Credits when GH_AW_AIC is set", async () => { @@ -666,7 +681,7 @@ describe("messages.cjs", () => { slashCommand: "deploy", }); - expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})\n> Comment /deploy to run again`); + expect(result).toBe(`> Generated by [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})\n> Comment /deploy to run again`); }); it("should include slash command hint in custom footer templates", async () => { @@ -1214,7 +1229,7 @@ describe("messages.cjs", () => { expect(result).toBe("> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)"); }); - it("should include history link without AI Credits in default footer", async () => { + it("should include provenance without AI Credits in default footer", async () => { process.env.GH_AW_DEPRECATED_COST = "5000"; const historyUrl = "https://github.com/search?q=repo:test/repo+is:issue&type=issues"; @@ -1226,7 +1241,7 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})`); + expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})`); }); it("should include AIC and ambient context in default footer when available", async () => { @@ -1316,7 +1331,7 @@ describe("messages.cjs", () => { expect(result).toBe("> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)"); }); - it("should include history link without AI Credits in default footer", async () => { + it("should include provenance without AI Credits in default footer", async () => { process.env.GH_AW_DEPRECATED_COST = "5000"; const historyUrl = "https://github.com/search?q=repo:test/repo+is:issue&type=issues"; @@ -1328,7 +1343,7 @@ describe("messages.cjs", () => { historyUrl, }); - expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123) · [◷](${historyUrl})`); + expect(result).toBe(`> Generated from [Test Workflow](https://github.com/test/repo/actions/runs/123)\n> Provenance: [Test Workflow](${historyUrl})`); }); it("should include explicit context AIC in the default footer", async () => { diff --git a/actions/setup/js/messages_footer.cjs b/actions/setup/js/messages_footer.cjs index 8f6314855b8..072af30b2c7 100644 --- a/actions/setup/js/messages_footer.cjs +++ b/actions/setup/js/messages_footer.cjs @@ -161,8 +161,8 @@ function getAICFromEnv() { * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow * @property {"issue"|"PR"|"discussion"} [triggeringType] - Triggering item type used in the default footer - * @property {string} [historyUrl] - GitHub search URL for items created by this workflow (for the history link) - * @property {string} [historyLink] - Pre-formatted markdown history link (e.g. " · [◷](url)"), or "" if unavailable + * @property {string} [historyUrl] - GitHub search URL for items created by this workflow + * @property {string} [historyLink] - Deprecated history link placeholder, always "" * @property {number|string} [aiCredits] - Total AI Credits cost for the run (1 AIC == 0.01 USD) * @property {string} [emoji] - Optional emoji representing the workflow (from frontmatter) * @property {string} [slashCommand] - Slash command name (without leading slash) for the run-again hint, when applicable @@ -170,6 +170,18 @@ function getAICFromEnv() { * @property {string} [labelCommand] - Label command name for the run-again hint, when applicable */ +/** + * Append a standardized provenance line when a history search is available. + * @param {string} footer - Rendered footer text + * @param {FooterContext} ctx - Context for footer generation + * @returns {string} Footer text with provenance + */ +function appendFooterProvenance(footer, ctx) { + if (!ctx.historyUrl) return footer; + const identifier = process.env.GH_AW_CALLER_WORKFLOW_ID || process.env.GH_AW_WORKFLOW_ID || ctx.workflowName; + return `${footer}\n> Provenance: [${identifier}](${ctx.historyUrl})`; +} + /** * Get the footer message, using custom template if configured. * @param {FooterContext} ctx - Context for footer generation @@ -203,8 +215,8 @@ function getFooterMessage(ctx) { const detectionConclusion = process.env.GH_AW_DETECTION_CONCLUSION || undefined; const detectionReason = process.env.GH_AW_DETECTION_REASON || undefined; - // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) - const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : ""; + // Keep the legacy placeholder empty; provenance is rendered on its own line below. + const historyLink = ""; // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); @@ -265,7 +277,8 @@ function getFooterMessage(ctx) { // Use custom footer template if configured if (messages?.footer) { const renderedCustomFooter = renderTemplate(messages.footer, templateContext); - return renderedCustomFooter + getRunAgainHints(renderedCustomFooter); + const footer = appendFooterProvenance(renderedCustomFooter, ctx); + return footer + getRunAgainHints(footer); } // Default footer template - includes emoji prefix when available @@ -285,12 +298,9 @@ function getFooterMessage(ctx) { if (metricSuffixes.length > 0) { defaultFooter += metricSuffixes.join(""); } - // Append history link when available - if (ctx.historyUrl) { - defaultFooter += " · [◷]({history_url})"; - } const renderedDefaultFooter = renderTemplate(defaultFooter, templateContext); - return renderedDefaultFooter + getRunAgainHints(renderedDefaultFooter); + const footer = appendFooterProvenance(renderedDefaultFooter, ctx); + return footer + getRunAgainHints(footer); } /** @@ -422,8 +432,8 @@ function getFooterWorkflowRecompileCommentMessage(ctx) { function getFooterAgentFailureIssueMessage(ctx) { const messages = getMessages(); - // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) - const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : ""; + // Keep the legacy placeholder empty; provenance is rendered on its own line below. + const historyLink = ""; // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); @@ -496,14 +506,10 @@ function getFooterAgentFailureIssueMessage(ctx) { if (ambientContext) { defaultFooter += ambientContextSuffix; } - // Append history link when available - if (ctx.historyUrl) { - defaultFooter += " · [◷]({history_url})"; - } footer = renderTemplate(defaultFooter, templateContext); } - return footer; + return appendFooterProvenance(footer, ctx); } /** @@ -514,8 +520,8 @@ function getFooterAgentFailureIssueMessage(ctx) { function getFooterAgentFailureCommentMessage(ctx) { const messages = getMessages(); - // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) - const historyLink = ctx.historyUrl ? ` · [◷](${ctx.historyUrl})` : ""; + // Keep the legacy placeholder empty; provenance is rendered on its own line below. + const historyLink = ""; // Pre-compute agentic_workflow_url as the direct link to the agentic workflow page const agenticWorkflowUrl = ctx.agenticWorkflowUrl || (ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : ""); @@ -588,14 +594,10 @@ function getFooterAgentFailureCommentMessage(ctx) { if (ambientContext) { defaultFooter += ambientContextSuffix; } - // Append history link when available - if (ctx.historyUrl) { - defaultFooter += " · [◷]({history_url})"; - } footer = renderTemplate(defaultFooter, templateContext); } - return footer; + return appendFooterProvenance(footer, ctx); } /** diff --git a/actions/setup/js/pr_review_buffer.cjs b/actions/setup/js/pr_review_buffer.cjs index 3dfb6975fdb..bf2c3f6ef34 100644 --- a/actions/setup/js/pr_review_buffer.cjs +++ b/actions/setup/js/pr_review_buffer.cjs @@ -22,7 +22,7 @@ const { generateFooterWithMessages, getBodyFooterMessage, getDetectionCautionAlert } = require("./messages_footer.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); -const { generateWorkflowCallIdMarker, matchesWorkflowId } = require("./generate_footer.cjs"); +const { generateWorkflowCallIdMarker, generateWorkflowCallIdReviewMarker, matchesWorkflowCallId, matchesWorkflowId } = require("./generate_footer.cjs"); const { attachExecutionState, fetchPullRequestReviewState } = require("./safe_output_execution_metadata.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG, isTransientError, sleep } = require("./error_recovery.cjs"); const { ERR_API } = require("./error_codes.cjs"); @@ -365,11 +365,6 @@ function createReviewBuffer() { undefined, { skipDetectionCaution: true } ); - - const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || ""; - if (callerWorkflowId) { - body += "\n" + generateWorkflowCallIdMarker(callerWorkflowId); - } } if (footerContext) { const bodyFooter = getBodyFooterMessage(footerContext.bodyFooter, footerContext); @@ -378,6 +373,17 @@ function createReviewBuffer() { } } + // Always embed caller provenance, even when the visible footer is disabled, so + // supersede-older-reviews can identify this review in later runs. The legacy HTML + // marker is kept for consistency with other safe outputs and existing readers; + // the Markdown reference marker is the durable form because GitHub strips HTML + // comments from submitted review bodies. + const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID || ""; + if (callerWorkflowId) { + const provenance = generateWorkflowCallIdMarker(callerWorkflowId) + "\n" + generateWorkflowCallIdReviewMarker(callerWorkflowId); + body = body.trim() ? body.trimEnd() + "\n\n" + provenance : provenance; + } + // Build comments array for the API let comments = bufferedComments.map(comment => { /** @type {any} */ @@ -531,7 +537,6 @@ function createReviewBuffer() { core.warning("supersede-older-reviews is enabled but neither GH_AW_WORKFLOW_ID nor GH_AW_CALLER_WORKFLOW_ID is set. Skipping stale review dismissal."); return; } - const workflowCallMarker = workflowCallId ? generateWorkflowCallIdMarker(workflowCallId) : ""; try { /** @type {any[]} */ const reviews = []; @@ -563,8 +568,8 @@ function createReviewBuffer() { if (!review || review.id === currentReviewId) return false; if (review.state !== "CHANGES_REQUESTED") return false; if (review.user?.type !== "Bot") return false; - if (workflowCallMarker) { - return review.body?.includes(workflowCallMarker) || false; + if (workflowCallId) { + return matchesWorkflowCallId(review.body, workflowCallId); } return matchesWorkflowId(review.body, workflowId); }); diff --git a/actions/setup/js/pr_review_buffer.test.cjs b/actions/setup/js/pr_review_buffer.test.cjs index 47fdc0fb4ed..c80e47659c1 100644 --- a/actions/setup/js/pr_review_buffer.test.cjs +++ b/actions/setup/js/pr_review_buffer.test.cjs @@ -800,7 +800,7 @@ describe("pr_review_buffer (factory pattern)", () => { expect(body.indexOf("Generated by")).toBeLessThan(body.indexOf("Policy for")); }); - it("should append workflow-call-id marker to review body when available", async () => { + it("should append durable workflow-call-id provenance to review body when available", async () => { const previousCallerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID; process.env.GH_AW_CALLER_WORKFLOW_ID = "owner/repo/CallerA"; try { @@ -831,6 +831,49 @@ describe("pr_review_buffer (factory pattern)", () => { const callArgs = mockGithub.rest.pulls.createReview.mock.calls[0][0]; expect(callArgs.body).toContain(""); + expect(callArgs.body).toContain('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"'); + } finally { + if (previousCallerWorkflowId === undefined) { + delete process.env.GH_AW_CALLER_WORKFLOW_ID; + } else { + process.env.GH_AW_CALLER_WORKFLOW_ID = previousCallerWorkflowId; + } + } + }); + + it("should append workflow-call-id provenance even when footer is disabled", async () => { + const previousCallerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID; + process.env.GH_AW_CALLER_WORKFLOW_ID = "owner/repo/CallerA"; + try { + buffer.addComment({ path: "test.js", line: 1, body: "comment" }); + buffer.setReviewMetadata("", "REQUEST_CHANGES"); + buffer.setReviewContext({ + repo: "owner/repo", + repoParts: { owner: "owner", repo: "repo" }, + pullRequestNumber: 42, + pullRequest: { head: { sha: "abc123" } }, + }); + buffer.setFooterContext({ + workflowName: "test-workflow", + runUrl: "https://github.com/owner/repo/actions/runs/123", + workflowSource: "owner/repo/workflows/test.md@v1", + workflowSourceURL: "https://github.com/owner/repo/blob/main/test.md", + }); + buffer.setFooterMode("none"); + + mockGithub.rest.pulls.createReview.mockResolvedValue({ + data: { + id: 406, + html_url: "https://github.com/owner/repo/pull/42#pullrequestreview-406", + }, + }); + + const result = await buffer.submitReview(); + expect(result.success).toBe(true); + + const callArgs = mockGithub.rest.pulls.createReview.mock.calls[0][0]; + expect(callArgs.body).not.toContain("test-workflow"); + expect(callArgs.body).toBe('\n[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"'); } finally { if (previousCallerWorkflowId === undefined) { delete process.env.GH_AW_CALLER_WORKFLOW_ID; @@ -1240,7 +1283,7 @@ describe("pr_review_buffer (factory pattern)", () => { } }); - it("should dismiss older reviews matching workflow-call-id when supersede mode is enabled", async () => { + it("should dismiss only older blocking reviews with durable workflow-call-id provenance", async () => { const previousWorkflowId = process.env.GH_AW_WORKFLOW_ID; const previousCallerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID; process.env.GH_AW_WORKFLOW_ID = "test-workflow"; @@ -1248,6 +1291,10 @@ describe("pr_review_buffer (factory pattern)", () => { try { buffer.setSupersedeOlderReviews(true); buffer.setReviewMetadata("Updated review", "COMMENT"); + buffer.setFooterContext({ + workflowName: "test-workflow", + runUrl: "https://github.com/owner/repo/actions/runs/123", + }); buffer.setReviewContext({ repo: "owner/repo", repoParts: { owner: "owner", repo: "repo" }, @@ -1263,10 +1310,10 @@ describe("pr_review_buffer (factory pattern)", () => { }); mockGithub.rest.pulls.listReviews.mockResolvedValue({ data: [ - { id: 100, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: "\nOld blocking review" }, + { id: 100, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: '[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"\nOld blocking review' }, { id: 101, state: "CHANGES_REQUESTED", user: { login: "human-user", type: "User" }, body: "" }, { id: 102, state: "APPROVED", user: { login: "github-actions[bot]", type: "Bot" }, body: "" }, - { id: 103, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: "" }, + { id: 103, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: '[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerB"' }, { id: 104, state: "CHANGES_REQUESTED", user: { login: "github-actions[bot]", type: "Bot" }, body: "" }, ], }); @@ -1275,6 +1322,7 @@ describe("pr_review_buffer (factory pattern)", () => { const result = await buffer.submitReview(); expect(result.success).toBe(true); + expect(mockGithub.rest.pulls.createReview.mock.calls[0][0].body).toContain('[gh-aw-workflow-call-id]: # "owner%2Frepo%2FCallerA"'); // submitReview() reads reviews before superseding, during supersede // candidate selection, and again after review creation for after-state. expect(mockGithub.rest.pulls.listReviews).toHaveBeenCalledTimes(3); diff --git a/docs/src/content/docs/reference/footers.md b/docs/src/content/docs/reference/footers.md index e0748186d61..b84196809ce 100644 --- a/docs/src/content/docs/reference/footers.md +++ b/docs/src/content/docs/reference/footers.md @@ -75,6 +75,10 @@ safe-outputs: The global body footer is added to any handler-specific `body-footer`; both are appended even when `footer: false` and remain separate from the generated attribution footer. Body footers from imported agentic workflows are additive, in import order. The template supports `{workflow_name}` and `{run_url}` placeholders. +## Provenance + +When a generated footer has a history search, it includes a separate subscript provenance line. The caller workflow ID is the link text and links to the same search previously exposed by the clock icon. + ## PR Review Footer Control For PR reviews (`submit-pull-request-review`), the `footer` field supports conditional control over when the footer is added to the review body: @@ -104,7 +108,7 @@ safe-outputs: title-prefix: "[bot] " ``` -The `messages.footer` template supports variables like `{workflow_name}`, `{agentic_workflow_url}`, `{run_url}`, `{triggering_number}`, `{triggering_type}`, `{ai_credits_suffix}`, and more. `{triggering_type}` is `issue`, `PR`, or `discussion`, matching the type of item that triggered the run. `{agentic_workflow_url}` links directly to the agentic workflow file view for the run (equivalent to `{run_url}/agentic_workflow`), while `{run_url}` links to the plain Actions run page. `{ai_credits_suffix}` is a pre-formatted, always-safe suffix (e.g. `" · sonnet46 12.4 AIC"` or `""`) that you can place directly before `{history_link}`. When the run's engine model is known, the suffix is prefixed with a deterministic compact model identifier (`sonnet46`, `gpt55`, `opus47`, `haiku45`, `gem25`, …), and direct short aliases like `opus`, `sonnet`, and `haiku` are preserved. Individual cost components are also exposed: `{ai_model}` (full model name, e.g. `claude-sonnet-4.6`), `{ai_model_short}` (compact identifier, e.g. `sonnet46`), `{ai_credits}` (raw numeric cost), `{ai_credits_formatted}` (formatted cost), `{ai_credits_unit}` (always `AIC`), `{agent_ai_credits_formatted}`, `{evals_ai_credits_formatted}`, `{threat_detection_ai_credits_formatted}`, `{detection_conclusion}`, and `{detection_reason}`. See [Custom Messages](/gh-aw/reference/safe-outputs/#custom-messages-messages) for complete documentation on message templates and available variables. +The `messages.footer` template supports variables like `{workflow_name}`, `{agentic_workflow_url}`, `{run_url}`, `{triggering_number}`, `{triggering_type}`, `{ai_credits_suffix}`, and more. `{triggering_type}` is `issue`, `PR`, or `discussion`, matching the type of item that triggered the run. `{agentic_workflow_url}` links directly to the agentic workflow file view for the run (equivalent to `{run_url}/agentic_workflow`), while `{run_url}` links to the plain Actions run page. `{ai_credits_suffix}` is a pre-formatted, always-safe suffix (e.g. `" · sonnet46 12.4 AIC"` or `""`). The legacy `{history_link}` placeholder is retained for compatibility but now renders as an empty string; the standardized provenance line is appended automatically. When the run's engine model is known, the suffix is prefixed with a deterministic compact model identifier (`sonnet46`, `gpt55`, `opus47`, `haiku45`, `gem25`, …), and direct short aliases like `opus`, `sonnet`, and `haiku` are preserved. Individual cost components are also exposed: `{ai_model}` (full model name, e.g. `claude-sonnet-4.6`), `{ai_model_short}` (compact identifier, e.g. `sonnet46`), `{ai_credits}` (raw numeric cost), `{ai_credits_formatted}` (formatted cost), `{ai_credits_unit}` (always `AIC`), `{agent_ai_credits_formatted}`, `{evals_ai_credits_formatted}`, `{threat_detection_ai_credits_formatted}`, `{detection_conclusion}`, and `{detection_reason}`. See [Custom Messages](/gh-aw/reference/safe-outputs/#custom-messages-messages) for complete documentation on message templates and available variables. ## Learn More