diff --git a/.changeset/patch-canonical-report-rendering.md b/.changeset/patch-canonical-report-rendering.md new file mode 100644 index 00000000000..9404a5dfeea --- /dev/null +++ b/.changeset/patch-canonical-report-rendering.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Preserve trusted metadata, markers, footers, and size limits when rendering issue and comment Markdown through typed report trees. diff --git a/.github/skills/issue-markdown/SKILL.md b/.github/skills/issue-markdown/SKILL.md new file mode 100644 index 00000000000..3a77a33660a --- /dev/null +++ b/.github/skills/issue-markdown/SKILL.md @@ -0,0 +1,133 @@ +--- +name: issue-markdown +description: Build and refactor issue, comment, and review bodies in actions/setup/js using typed report trees and a single publication render. +--- + +# Issue and comment Markdown + +Use this skill when generating or changing Markdown published to issues, comments, +or reviews, especially in `actions/setup/js/`. Build a `Report` tree first; render +only the complete document. Do not assemble Markdown using interpolation, +concatenation, `body +=`, or arrays of Markdown strings joined with newlines. +Parsing an assembled string afterward is not a tree-first refactor. + +## Find publication paths + +Search production `.cjs` files for `issues.create`, `issues.update`, +`createComment`, `updateComment`, `createReview`, `createReplyForReviewComment`, +and GraphQL body variables. Trace each body back through shared helpers, fallback +comments, retries, staged previews, and append/prepend/replace-island updates. +Exclude test fixtures, HTTP payload assembly, and step-summary-only output. +Jira ADF text is not Markdown: preserve its literal entities, Unicode, and +paragraphs instead of routing it through the Markdown parser or renderer. + +## Use the existing tree + +`actions/setup/js/report_ast.cjs` defines `Report`, `ReportBlock`, and `ReportInline`. +Use JSDoc annotations so `npm run typecheck` verifies node kinds: + +```javascript +/** @type {import("./report_ast.cjs").Report} */ +const report = { + type: "report", + title: "Workflow result", + children: [ + { + type: "paragraph", + agentTainted: false, + children: [ + { type: "text", value: "Created related issue: ", agentTainted: false }, + { type: "issue_reference", number: String(issue.number), agentTainted: false }, + ], + }, + { type: "code_block", language: "text", value: diagnostic, agentTainted: true }, + ], +}; +``` + +Use `heading`, `paragraph`, `list`, `table`, `details`, `quote`, `alert`, +`separator`, `code_block`, and typed inline nodes instead of Markdown punctuation. +GitHub `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` +map to `alert` nodes with the matching `kind`; the renderer emits their markers. +Keep alerts separate rather than nesting them inside another alert, which GitHub +does not support. Preserve supported `` formatting in details summaries. +Use `xml_marker`, `json_marker`, `expiration`, and `closing_reference` for machine +metadata and closing links. Preserve `xml_marker.compact` for identifiers whose +exact spelling is used by cleanup. Use `review_marker` for the durable, +URI-encoded review caller reference; keep the authoritative marker last. +Dynamic labels, paths, errors, and identifiers belong +in text/code nodes; link destinations belong in `link.url`. + +Import existing agent/user Markdown with `parseReportMarkdown`, specifying +`agentTainted: true`, the correct `allowedMentions`, and the existing heading and +metadata policy. Parse configured templates before scalar substitution using +`context`; use typed inline/block slots for inserted nodes. Never interpolate +untrusted values into a template before parsing it. A literal code fence in +imported content must not swallow attribution or adjacent generated blocks. + +For managed comment memory, set `code_block.language` to +`gh-aw-comment-memory:` and `fenceLength: 6`; rendering lengthens the fence +past embedded backticks. Do not construct or slice fences by hand. + +## Compose shared attribution + +Use the typed variants instead of rendering a helper and reparsing its output: + +| Purpose | Tree-first API | +| --- | --- | +| Generated attribution and guard notices | `buildGeneratedFooterBlocks` / `assembleReportBodyParts` in `markdown_body_helpers.cjs` | +| Deferred footer templates and diagnostic sections | `buildFooterBlocks(getFooterMessageParts(context))` in `messages_footer.cjs` | +| Configured policy footer | `getBodyFooterBlocks` or `appendConfiguredReportFooter` | +| Detection warning | `getDetectionCautionBlocks` | +| Failure-comment attribution | `getFooterAgentFailureCommentBlocks` | +| Authoritative execution metadata | `appendReportExecutionProvenance` | + +Keep custom templates and their scalar context separate until composition. +Headers and footers may jointly open/close `
`; use +`buildIssueBodyReport` to parse these wrappers together around body slots. +Allocate unique slot names against both source text and previously allocated +slots, and reject wrappers that swallow slots inside code fences. +Internal framework notes carried between handlers use non-JSON-forgeable Symbols +such as `COMMENT_PREAMBLE` and `ISSUE_BODY_SUFFIX`, never agent-settable flags. + +## Preserve publication contracts + +Framework-authored nodes use `agentTainted: false`; agent-owned descendants retain +their taint even inside framework wrappers. Keep required footer diagnostics, +metadata, and policy text protected during size fitting. Do not mark an entire +agent body trusted to keep it from being shortened. + +For attributed safe-output issues and comments, use `buildIssueBodyReport` in +`issue_body.cjs`; it accepts Markdown or typed body blocks and retains configured +wrappers, attribution, expiration, and execution provenance. Comment callers set +the appropriate `itemType` and `workflowIdMarker: false`. Pass the original +workflow `runUrl` when the target repository differs from the execution repository. +Fallback issues must import the original agent body and compose recovery nodes, +not reparse an already assembled or rendered PR/discussion description. + +For other issue producers, pass a typed report to +`renderIssueParameters`/`createReportIssue` in `issue_report.cjs`, preserving the +existing authentication and retry boundary. `buildIssueReport` accepts typed +blocks as well as imported Markdown. For comments, render the complete tree with +`renderSizedReport` from `report_size.cjs` and pass its body to the existing API. +Do not render fragments and concatenate or reparse them. Keep one logical render +boundary, including metadata and trusted footers in its size budget. +`renderSizedReport` trims trailing whitespace automatically and measures the +publication body against the exact `maxLength`. Do not trim its result or reserve +an extra character for a terminal newline. Low-level `renderReport` retains its +terminal newline for Markdown serialization. Never truncate rendered Markdown +or trusted policy text to fit. + +## Validate the migration + +Preserve footer on/off behavior, marker identity and order, mention/link limits, +expiration, cross-repository attribution, structured state recovery, managed +memory extraction, review fallbacks, and append/prepend/island semantics. +Assert tree shape as well as rendered output. Cover hostile backticks and HTML +delimiters, Unicode, escaped table cells, and the exact publication size limit. +Update formatting expectations only for intentional canonical rendering, not to +hide lost content or metadata. + +Run the matching Vitest suites from `actions/setup/js`, `npm run typecheck`, +and the repository JavaScript formatting/lint gates. Follow the developer skill +for final repository validation and publication. diff --git a/AGENTS.md b/AGENTS.md index 17b84a5f168..2297a6a223f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ When the relevant skill is not obvious, first discover candidates locally and th - Query helpers for issues/PRs/workflows/discussions/labels → matching `.github/skills/github-*-query/SKILL.md` - Security-focused reviews of agentic workflow changes (compile/scans/audit/optimization) → `.github/skills/review-agentic-workflows/SKILL.md` - Doc-writing conventions → `.github/skills/documentation/SKILL.md` +- Issue/comment Markdown construction and refactoring → `.github/skills/issue-markdown/SKILL.md` - Visual changes to the docs site or landing page (layout, widths, spacing, colour, header nav) → `.github/skills/docs-design/SKILL.md` - Reviewing or writing `git`/`gh`/remote operations against checkouts (per-checkout credentials, sparse/shallow monorepos, safe-outputs MCP runs without credentials) → `.github/skills/checkout-credential-review/SKILL.md` - Authoring, validating, or debugging canvas extensions (loopback servers, actions, iframe rendering, state model, theme tokens) → `.github/skills/create-canvas/SKILL.md` diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index 00c7df7c6ef..1eea2512538 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -5,31 +5,27 @@ * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction */ -const { assembleMarkdownBodyParts } = require("./markdown_body_helpers.cjs"); -const { appendExecutionProvenance } = require("./execution_provenance.cjs"); -const { getBodyFooterMessage } = require("./messages_footer.cjs"); +const { buildIssueBodyReport, COMMENT_PREAMBLE } = require("./issue_body.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { stripExecutionProvenance } = require("./execution_provenance.cjs"); const { generateWorkflowCallIdMarker, matchesWorkflowId } = require("./generate_footer.cjs"); const { getRepositoryUrl } = require("./get_repository_url.cjs"); const { replaceTemporaryIdReferences, resolveSafeOutputIssueTarget } = require("./temporary_id.cjs"); -const { getTrackerID } = require("./get_tracker_id.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { parseBoolTemplatable, parseIntTemplatable } = require("./templatable.cjs"); const { resolveTarget, isStagedMode } = require("./safe_output_helpers.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); -const { getMissingInfoSections } = require("./missing_messages_helper.cjs"); const { getMessages } = require("./messages_core.cjs"); -const { getBodyHeader, getDisclosureHeader } = require("./messages_header.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { MAX_COMMENT_LENGTH, MAX_COMMENT_INPUT_LENGTH, MAX_MENTIONS, MAX_LINKS, enforceCommentLimits } = require("./comment_limit_helpers.cjs"); -const { fitMarkdownBody } = require("./report_size.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { createDiscussionComment, resolveTopLevelDiscussionCommentId } = require("./github_api_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { ERR_CONFIG, ERR_NOT_FOUND } = require("./error_codes.cjs"); const { isPayloadUserBot } = require("./resolve_mentions.cjs"); const { getMentionsGithubClient, resolveMentionsForItem } = require("./resolve_mentions_from_payload.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); -const { generateHistoryUrl } = require("./generate_history_link.cjs"); const { resolveInvocationContext } = require("./invocation_context_helpers.cjs"); /** @type {string} Safe output type handled by this module */ @@ -829,92 +825,21 @@ async function main(config = {}) { } const linkBody = processedBody; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; const runUrl = buildWorkflowRunUrl(context, effectiveEventContext.workflowRepo ?? context.repo); - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE ?? ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL ?? ""; - - // Compute caution first so prefix assembly preserves the original execution order. - const detectionCaution = assembleMarkdownBodyParts({ - includeFooter: false, - workflowName, - runUrl, - }).detectionCaution; - - // Inject body header and disclosure header if configured (placed after caution, before user content) - const bodyHeader = getBodyHeader({ workflowName, runUrl }); - const disclosureHeader = getDisclosureHeader({ workflowName, runUrl }); - - // Build prefix: caution (if any) → disclosure header (if any) → body header (if any) → user content - const prefixParts = [detectionCaution, disclosureHeader, bodyHeader].filter(Boolean); - const bodyPrefix = prefixParts.length > 0 ? prefixParts.join("\n\n") + "\n\n" : ""; - let bodySuffix = ""; - - // Add tracker ID and footer - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodySuffix += "\n\n" + trackerIDComment; - } - - // Get triggering context for footer - const triggeringIssueNumber = context.payload.issue?.number; - const triggeringPRNumber = context.payload.pull_request?.number; - const triggeringDiscussionNumber = context.payload.discussion?.number; - - // Generate history URL with type= based on execution context - const historyUrl = - generateHistoryUrl({ - owner: repoParts.owner, - repo: repoParts.repo, - itemType: isDiscussion ? "discussion_comment" : "comment", - workflowCallId: callerWorkflowId, - workflowId, - serverUrl: context.serverUrl, - }) || undefined; - - const markdownParts = assembleMarkdownBodyParts({ - includeFooter, - workflowName, + const bodyNodes = parseReportMarkdown(stripExecutionProvenance(linkBody, { stripGroupingMarkers: true }), { minimumHeadingLevel: 2, allowMetadata: false, agentTainted: true, allowedMentions: allowedMentionAliases }); + const report = buildIssueBodyReport("Comment", [...(message[COMMENT_PREAMBLE] || []), ...bodyNodes], { + repo: repoParts, + footer: includeFooter, + bodyFooter: config.body_footer, + allowedMentions: allowedMentionAliases, + itemType: isDiscussion ? "discussion_comment" : "comment", runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber, - historyUrl, - markerWhenFooterDisabled: "xml", + workflowIdMarker: false, }); - - if (includeFooter) { - // When footer is enabled, add full footer with attribution and XML markers. - bodySuffix += "\n\n" + markdownParts.footer; - } else { - // When footer is disabled, only add XML marker for searchability (no visible attribution text) - bodySuffix += "\n\n" + markdownParts.noFooterMarker; - } - const bodyFooter = getBodyFooterMessage(config.body_footer, { workflowName, runUrl }); - if (bodyFooter) { - bodySuffix += "\n\n" + bodyFooter.trimEnd(); - } - - // Add workflow-call-id marker when available to allow close-older-comments to - // distinguish callers that share the same reusable workflow (and GH_AW_WORKFLOW_ID) - if (callerWorkflowId) { - bodySuffix += "\n" + generateWorkflowCallIdMarker(callerWorkflowId); - } - - const framedBody = bodyPrefix + linkBody + bodySuffix; - processedBody = appendExecutionProvenance(framedBody, workflowName, runUrl); // Enforce max limits again after adding footer and metadata // Length and mention limits include metadata; only agent content counts toward the link limit. try { - if (processedBody.length > MAX_COMMENT_LENGTH) { - // Provenance replaces reserved markers; reserve its actual net overhead, not duplicate markers. - const fittingLimit = MAX_COMMENT_LENGTH + framedBody.length - processedBody.length; - if (fittingLimit < 1) throw new Error(`E006: Protected execution metadata exceeds the maximum body length of ${MAX_COMMENT_LENGTH} characters`); - processedBody = fitMarkdownBody(linkBody, { prefix: bodyPrefix, suffix: bodySuffix, maxLength: fittingLimit, allowedMentions: allowedMentionAliases }); - processedBody = appendExecutionProvenance(processedBody, workflowName, runUrl); - } + processedBody = renderSizedReport(report, MAX_COMMENT_LENGTH).body; enforceCommentLimits(processedBody, { maxLinks, linkBody }); } catch (error) { const errorMessage = getErrorMessage(error); diff --git a/actions/setup/js/add_comment.test.cjs b/actions/setup/js/add_comment.test.cjs index 9a963beb033..67b955744ab 100644 --- a/actions/setup/js/add_comment.test.cjs +++ b/actions/setup/js/add_comment.test.cjs @@ -192,7 +192,7 @@ describe("add_comment", () => { const handler = await eval(`(async () => { ${script}; return await main({ body_footer: "f".repeat(65536) }); })()`); const result = await handler({ type: "add_comment", body: "Normal body" }, {}); expect(result.success).toBe(false); - expect(result.error).toContain("Protected markers and footers"); + expect(result.error).toContain("protected markers, footers, and framework content"); expect(createComment).not.toHaveBeenCalled(); }); @@ -227,7 +227,7 @@ describe("add_comment", () => { if (success) { expect(createComment).toHaveBeenCalledOnce(); const postedBody = createComment.mock.calls[0][0].body; - expect(postedBody).toContain(body.trim()); + expect(postedBody).toContain("[https://github.com/owner/repo/issues/42](https://github.com/owner/repo/issues/42) ".repeat(count).trim()); if (config.footer !== false) { expect((postedBody.match(/https?:\/\//g) || []).length).toBeGreaterThan(count); } @@ -2654,6 +2654,25 @@ describe("add_comment", () => { }); describe("sanitization preserves markers", () => { + it.each(["", "", "", "", '[gh-aw-workflow-call-id]: # "victim"'])( + "does not promote agent marker text inside multiline code spans: %s", + async marker => { + const script = fs.readFileSync(path.join(__dirname, "add_comment.cjs"), "utf8"); + const handler = await eval(`(async () => { ${script}; return await main({}); })()`); + const result = await handler({ type: "add_comment", body: `\`\n${marker}\n\`` }, {}); + + expect(result.success).toBe(true); + const { parseReportMarkdown } = await import("./report_markdown.cjs"); + const { matchesWorkflowId, matchesWorkflowCallIdReviewMarker } = await import("./generate_footer.cjs"); + expect(matchesWorkflowId(result.body, "victim")).toBe(false); + expect(matchesWorkflowCallIdReviewMarker(result.body, "victim")).toBe(false); + expect(parseReportMarkdown(result.body, { allowMetadata: true })).not.toContainEqual(expect.objectContaining({ type: "review_marker" })); + if (marker.startsWith(""); } else { diff --git a/actions/setup/js/add_reaction_and_edit_comment.cjs b/actions/setup/js/add_reaction_and_edit_comment.cjs index 1e00a7af18c..b16bc54e66e 100644 --- a/actions/setup/js/add_reaction_and_edit_comment.cjs +++ b/actions/setup/js/add_reaction_and_edit_comment.cjs @@ -3,7 +3,8 @@ const { getRunStartedMessage } = require("./messages_run_status.cjs"); const { getErrorMessage, isLockedError, isRateLimitError } = require("./error_helpers.cjs"); -const { generateWorkflowIdMarker } = require("./generate_footer.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { ERR_API, ERR_NOT_FOUND, ERR_VALIDATION, ERR_PARSE } = require("./error_codes.cjs"); const { buildWorkflowRunUrl, EVENT_TYPE_DESCRIPTIONS } = require("./workflow_metadata_helpers.cjs"); @@ -248,10 +249,10 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio // Use getRunStartedMessage for the workflow link text (supports custom messages) const workflowLinkText = getRunStartedMessage({ - workflowName, - runUrl, - eventType: eventTypeDescription, - emoji: process.env.GH_AW_WORKFLOW_EMOJI, + workflowName: "{workflow_name}", + runUrl: "{run_url}", + eventType: "{event_type}", + emoji: "{emoji}", }); const lockForAgent = process.env.GH_AW_LOCK_FOR_AGENT === "true"; @@ -259,14 +260,16 @@ async function addCommentWithWorkflowLink(endpoint, runUrl, eventName, invocatio const trackerId = process.env.GH_AW_TRACKER_ID || ""; // Build comment body from parts, sanitizing first to preserve workflow markers - const commentParts = [ - sanitizeContent(workflowLinkText), - ...(lockForAgent && (eventName === "issues" || eventName === "issue_comment") ? ["🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."] : []), - ...(workflowId ? [generateWorkflowIdMarker(workflowId)] : []), - ...(trackerId ? [``] : []), - "", - ]; - const commentBody = commentParts.join("\n\n"); + const templateContext = Object.fromEntries(Array.from(workflowLinkText.matchAll(/\{(\w+)\}/g), match => [match[1], match[0]])); + Object.assign(templateContext, { workflow_name: sanitizeContent(workflowName), run_url: runUrl, event_type: eventTypeDescription, emoji: process.env.GH_AW_WORKFLOW_EMOJI?.trim() || "🚀" }); + const children = parseReportMarkdown(sanitizeContent(workflowLinkText), { context: templateContext, agentTainted: false }); + if (lockForAgent && (eventName === "issues" || eventName === "issue_comment")) { + children.push({ type: "paragraph", children: [{ type: "text", value: "🔒 This issue has been locked while the workflow is running to prevent concurrent modifications.", agentTainted: false }] }); + } + if (workflowId) children.push({ type: "xml_marker", name: "gh-aw-workflow-id", value: workflowId, agentTainted: false }); + if (trackerId) children.push({ type: "xml_marker", name: "gh-aw-tracker-id", value: trackerId, agentTainted: false }); + children.push({ type: "xml_marker", name: "gh-aw-comment-type", value: "reaction", agentTainted: false }); + const commentBody = renderSizedReport({ type: "report", title: "Workflow status", children }).body; if (eventName === "discussion" || eventName === "discussion_comment") { if (typeof endpoint !== "string") { diff --git a/actions/setup/js/add_workflow_run_comment.cjs b/actions/setup/js/add_workflow_run_comment.cjs index b622abd453b..fad1854aae2 100644 --- a/actions/setup/js/add_workflow_run_comment.cjs +++ b/actions/setup/js/add_workflow_run_comment.cjs @@ -3,7 +3,8 @@ const { getRunStartedMessage } = require("./messages_run_status.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { generateWorkflowIdMarker } = require("./generate_footer.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { ERR_NOT_FOUND, ERR_VALIDATION } = require("./error_codes.cjs"); const { getMessages } = require("./messages_core.cjs"); @@ -361,29 +362,31 @@ function buildCommentBody(eventName, runUrl, workflowNameOverride, workflowEmoji const eventTypeDescription = EVENT_TYPE_DESCRIPTIONS[eventName] ?? "event"; // Sanitize before adding markers (defense in depth for custom message templates) - let body = sanitizeContent(getRunStartedMessage({ workflowName, runUrl, eventType: eventTypeDescription, emoji: workflowEmoji })); + const template = getRunStartedMessage({ workflowName: "{workflow_name}", runUrl: "{run_url}", eventType: "{event_type}", emoji: "{emoji}" }); + const templateContext = Object.fromEntries(Array.from(template.matchAll(/\{(\w+)\}/g), match => [match[1], match[0]])); + Object.assign(templateContext, { workflow_name: sanitizeContent(workflowName), run_url: runUrl, event_type: eventTypeDescription, emoji: workflowEmoji?.trim() || "🚀" }); + const children = parseReportMarkdown(sanitizeContent(template), { context: templateContext, agentTainted: false }); // Add lock notice if lock-for-agent is enabled for issues or issue_comment if (process.env.GH_AW_LOCK_FOR_AGENT === "true" && (eventName === "issues" || eventName === "issue_comment")) { - body += "\n\n🔒 This issue has been locked while the workflow is running to prevent concurrent modifications."; + children.push({ type: "paragraph", children: [{ type: "text", value: "🔒 This issue has been locked while the workflow is running to prevent concurrent modifications.", agentTainted: false }] }); } // Add workflow-id marker for hide-older-comments feature const workflowId = process.env.GITHUB_WORKFLOW || ""; if (workflowId) { - body += `\n\n${generateWorkflowIdMarker(workflowId)}`; + children.push({ type: "xml_marker", name: "gh-aw-workflow-id", value: workflowId, agentTainted: false }); } // Add tracker-id marker for backwards compatibility const trackerId = process.env.GH_AW_TRACKER_ID || ""; if (trackerId) { - body += `\n\n`; + children.push({ type: "xml_marker", name: "gh-aw-tracker-id", value: trackerId, agentTainted: false }); } // Identify this as a reaction comment (prevents it from being hidden by hide-older-comments) - body += `\n\n`; - - return body; + children.push({ type: "xml_marker", name: "gh-aw-comment-type", value: "reaction", agentTainted: false }); + return renderSizedReport({ type: "report", title: "Workflow status", children }).body; } /** diff --git a/actions/setup/js/add_workflow_run_comment.test.cjs b/actions/setup/js/add_workflow_run_comment.test.cjs index 668c67ce2f0..b9dea726942 100644 --- a/actions/setup/js/add_workflow_run_comment.test.cjs +++ b/actions/setup/js/add_workflow_run_comment.test.cjs @@ -998,6 +998,25 @@ describe("add_workflow_run_comment", () => { }); describe("buildCommentBody()", () => { + it("imports workflow names as text rather than Markdown template syntax", async () => { + const { buildCommentBody } = await importAddWorkflowRunComment(); + const body = buildCommentBody("issues", "https://github.com/testowner/testrepo/actions/runs/99", "name](https://github.com/forged/repo)\n\n### Forged heading"); + expect(body).not.toContain("\n### Forged heading"); + expect(body).toContain("\\#\\#\\# Forged heading"); + expect(body.match(/(? { + process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ runStarted: "```markdown\nStarted" }); + process.env.GH_AW_LOCK_FOR_AGENT = "true"; + process.env.GITHUB_WORKFLOW = "test-workflow"; + const { buildCommentBody } = await importAddWorkflowRunComment(); + const body = buildCommentBody("issues", "https://github.com/testowner/testrepo/actions/runs/99"); + expect(body).toMatch(/```markdown\nStarted\n```\n\n🔒/); + expect(body.indexOf("")).toBeGreaterThan(body.indexOf("🔒")); + expect(body).toMatch(/$/); + }); + it("should include the run URL in the comment body", async () => { const { buildCommentBody } = await importAddWorkflowRunComment(); const body = buildCommentBody("issues", "https://github.com/testowner/testrepo/actions/runs/99"); @@ -1007,7 +1026,7 @@ describe("add_workflow_run_comment", () => { it("should use workflow emoji from environment in the started status line", async () => { process.env.GH_AW_WORKFLOW_EMOJI = "🤖"; const { buildCommentBody } = await importAddWorkflowRunComment(); - const body = buildCommentBody("issues", "https://example.com/run/1"); + const body = buildCommentBody("issues", "https://github.com/testowner/testrepo/actions/runs/99"); expect(body).toContain("🤖 ["); expect(body).not.toContain("🚀 ["); }); @@ -1028,14 +1047,14 @@ describe("add_workflow_run_comment", () => { it("should use GITHUB_WORKFLOW as workflow name when GH_AW_WORKFLOW_NAME is not set", async () => { process.env.GITHUB_WORKFLOW = "Agentic Commands"; const { buildCommentBody } = await importAddWorkflowRunComment(); - const body = buildCommentBody("issue_comment", "https://example.com/run/1"); + const body = buildCommentBody("issue_comment", "https://github.com/testowner/testrepo/actions/runs/99"); expect(body).toContain("[Agentic Commands]"); }); it("should prefer explicit workflow name override over environment defaults", async () => { process.env.GH_AW_WORKFLOW_NAME = "Agentic Commands"; const { buildCommentBody } = await importAddWorkflowRunComment(); - const body = buildCommentBody("issue_comment", "https://example.com/run/1", "archie"); + const body = buildCommentBody("issue_comment", "https://github.com/testowner/testrepo/actions/runs/99", "archie"); expect(body).toContain("[archie]"); expect(body).not.toContain("[Agentic Commands]"); }); diff --git a/actions/setup/js/approve_workflow_run.cjs b/actions/setup/js/approve_workflow_run.cjs index 37be805ca3e..25419effc22 100644 --- a/actions/setup/js/approve_workflow_run.cjs +++ b/actions/setup/js/approve_workflow_run.cjs @@ -15,7 +15,9 @@ const { isStagedMode } = require("./safe_output_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { checkFileProtectionPostApply } = require("./manifest_file_helpers.cjs"); const { getRunStartedMessage } = require("./messages_run_status.cjs"); -const { generateFooterWithMessages } = require("./messages_footer.cjs"); +const { assembleReportBodyParts } = require("./markdown_body_helpers.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); @@ -201,9 +203,15 @@ function buildApprovalCommentBody(runHtmlUrl, pullRequestNumber) { const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; const runUrl = buildWorkflowRunUrl(context, context.repo); - const message = sanitizeContent(getRunStartedMessage({ workflowName, runUrl: runHtmlUrl, eventType: "pull request" })); - const footer = generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, undefined, pullRequestNumber, undefined, undefined); - return `${message}\n\n${footer}`; + const message = sanitizeContent(getRunStartedMessage({ workflowName: "{workflow_name}", runUrl: "{run_url}", eventType: "pull request" })); + const templateContext = Object.fromEntries(Array.from(message.matchAll(/\{(\w+)\}/g), match => [match[1], match[0]])); + Object.assign(templateContext, { workflow_name: sanitizeContent(workflowName), run_url: runHtmlUrl }); + const { detectionCaution, footer } = assembleReportBodyParts({ includeFooter: true, workflowName, runUrl, workflowSource, workflowSourceURL, triggeringPRNumber: pullRequestNumber }); + return renderSizedReport({ + type: "report", + title: "Workflow approved", + children: [...parseReportMarkdown(message, { context: templateContext, agentTainted: false, minimumHeadingLevel: 2 }), ...detectionCaution, ...footer], + }).body; } /** diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 6d4d77ea0b6..e45cf088c19 100644 --- a/actions/setup/js/assign_to_agent.cjs +++ b/actions/setup/js/assign_to_agent.cjs @@ -9,7 +9,7 @@ const { isTemporaryId, normalizeTemporaryId, resolveRepoIssueTarget } = require( const { sleep } = require("./error_recovery.cjs"); const { parseAllowedRepos, validateRepo, resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { resolvePullRequestRepo } = require("./pr_helpers.cjs"); -const { sanitizeContent } = require("./sanitize_content.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { normalizeIssueIntentMetadata } = require("./issue_intents.cjs"); const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs"); @@ -663,7 +663,24 @@ async function main(config = {}) { owner: effectiveOwner, repo: effectiveRepo, issue_number: number, - body: sanitizeContent(`⚠️ **Assignment failed**: Failed to assign ${agentName} coding agent to this ${type}.\n\nError: ${errorMessage}`, { maxLength: 65000 }), + body: renderSizedReport( + { + type: "report", + title: "Assignment failed", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: "⚠️ ", agentTainted: false }, + { type: "strong", children: [{ type: "text", value: "Assignment failed", agentTainted: false }] }, + { type: "text", value: `: Failed to assign ${agentName} coding agent to this ${type}.`, agentTainted: false }, + ], + }, + { type: "paragraph", children: [{ type: "text", value: `Error: ${errorMessage}`, agentTainted: false }] }, + ], + }, + 65000 + ).body, }); core.info(`Posted failure comment on ${type} #${number} in ${effectiveOwner}/${effectiveRepo}`); } catch (commentError) { diff --git a/actions/setup/js/assign_to_agent.test.cjs b/actions/setup/js/assign_to_agent.test.cjs index 5439fb6d266..a1ef2b46b7a 100644 --- a/actions/setup/js/assign_to_agent.test.cjs +++ b/actions/setup/js/assign_to_agent.test.cjs @@ -487,7 +487,7 @@ describe("assign_to_agent", () => { expect(mockGithub.rest.issues.createComment).toHaveBeenCalledTimes(1); expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.stringContaining("credential source: GITHUB_TOKEN."), + body: expect.stringContaining("credential source: GITHUB\\_TOKEN."), }) ); }); diff --git a/actions/setup/js/body_footer.cjs b/actions/setup/js/body_footer.cjs index f96b3d1c2be..479112a791b 100644 --- a/actions/setup/js/body_footer.cjs +++ b/actions/setup/js/body_footer.cjs @@ -1,32 +1,45 @@ // @ts-check /// -const { getBodyFooterMessage } = require("./messages_footer.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); -const { fitMarkdownBody } = require("./report_size.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { getBodyFooterBlocks } = require("./messages_footer.cjs"); +const { expandAllowedAliases } = require("./sanitize_content.cjs"); /** * Append a configured deterministic body footer using the current workflow context. * When `maxLength` is provided, fit agent content to the remaining budget without * shortening or omitting the configured footer. + * Pass the same resolved mention allowlist used to sanitize the body. * @param {string} body * @param {string|undefined} template - * @param {{workflowRepo?: any, maxLength?: number}} [options] + * @param {{workflowRepo?: any, maxLength?: number, allowedMentions?: string[]}} [options] * @returns {string} */ function appendConfiguredBodyFooter(body, template, options = {}) { - const { workflowRepo, maxLength } = options || {}; - if (!template) return maxLength === undefined ? body : fitMarkdownBody(body, { maxLength }); + const { workflowRepo, maxLength, allowedMentions = [] } = options || {}; + if (!template && maxLength === undefined) return body; + const report = appendConfiguredReportFooter( + { type: "report", title: "Comment", children: parseReportMarkdown(body, { minimumHeadingLevel: 2, allowMetadata: true, agentTainted: true, allowedMentions: expandAllowedAliases(allowedMentions) }) }, + template, + { workflowRepo } + ); + return renderSizedReport(report, maxLength).body; +} + +/** + * Append trusted policy nodes without rendering or reparsing the document. + * @param {import("./report_ast.cjs").Report} report + * @param {string | undefined} template + * @param {{workflowRepo?: any}} [options] + * @returns {import("./report_ast.cjs").Report} + */ +function appendConfiguredReportFooter(report, template, { workflowRepo } = {}) { + if (!template) return report; const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; const runUrl = buildWorkflowRunUrl(context, workflowRepo || context.repo); - const bodyFooter = getBodyFooterMessage(template, { workflowName, runUrl }); - if (!bodyFooter) return maxLength === undefined ? body : fitMarkdownBody(body, { maxLength }); - const trimmedBody = body.trimEnd(); - const composed = trimmedBody ? `${trimmedBody}\n\n${bodyFooter.trimEnd()}` : bodyFooter.trimEnd(); - if (typeof maxLength === "number" && maxLength > 0 && composed.length > maxLength) { - return fitMarkdownBody(trimmedBody, { suffix: trimmedBody ? `\n\n${bodyFooter.trimEnd()}` : bodyFooter.trimEnd(), maxLength }); - } - return composed; + return { ...report, children: [...report.children, ...getBodyFooterBlocks(template, { workflowName, runUrl })] }; } -module.exports = { appendConfiguredBodyFooter }; +module.exports = { appendConfiguredBodyFooter, appendConfiguredReportFooter }; diff --git a/actions/setup/js/body_footer.test.cjs b/actions/setup/js/body_footer.test.cjs index d4c59fa229f..de0b5c9d6df 100644 --- a/actions/setup/js/body_footer.test.cjs +++ b/actions/setup/js/body_footer.test.cjs @@ -20,13 +20,30 @@ describe("appendConfiguredBodyFooter", () => { expect(appendConfiguredBodyFooter("Body", undefined)).toBe("Body"); }); + it.each([undefined, "Footer"])("preserves only explicitly allowed mentions while rendering with footer %s", footer => { + const body = appendConfiguredBodyFooter("Please ask @copilot and @someone-else", footer, { allowedMentions: ["copilot"], maxLength: 100 }); + expect(body).toBe(`Please ask @copilot and \`@someone-else\`${footer ? "\n\nFooter" : ""}`); + }); + + it("preserves the sanitizer's runtime bot mention aliases", () => { + expect(appendConfiguredBodyFooter("Please ask @copilot", "Footer", { allowedMentions: ["copilot-swe-agent"] })).toBe("Please ask @copilot\n\nFooter"); + }); + + it("neutralizes all mentions by default", () => { + expect(appendConfiguredBodyFooter("Please ask @copilot", "Footer")).toBe("Please ask `@copilot`\n\nFooter"); + }); + + it("does not reactivate mentions neutralized by the caller's mention limit", () => { + expect(appendConfiguredBodyFooter("Please ask @copilot and `@teammate`", "Footer", { allowedMentions: ["copilot", "teammate"] })).toBe("Please ask @copilot and `@teammate`\n\nFooter"); + }); + it("redacts oversized body text without shortening or omitting the footer", () => { const body = "x".repeat(100); expect(appendConfiguredBodyFooter(body, "Footer", { maxLength: 100 })).toBe("Content redacted to fit size limit.\n\nFooter"); }); it("fails explicitly when the footer alone cannot fit", () => { - expect(() => appendConfiguredBodyFooter("Body", "f".repeat(101), { maxLength: 100 })).toThrow("Protected markers and footers"); + expect(() => appendConfiguredBodyFooter("Body", "f".repeat(101), { maxLength: 100 })).toThrow("protected markers, footers, and framework content"); }); it("appends the footer when the composed body fits the destination limit", () => { diff --git a/actions/setup/js/cancel_work_queue_closed_issue.cjs b/actions/setup/js/cancel_work_queue_closed_issue.cjs index 4c00910affb..eff4a7382ca 100644 --- a/actions/setup/js/cancel_work_queue_closed_issue.cjs +++ b/actions/setup/js/cancel_work_queue_closed_issue.cjs @@ -13,6 +13,7 @@ const { workExpired } = require("./work_queue_expiration.cjs"); const { publishWorkQueueRequest } = require("./work_queue_store.cjs"); const { readCheckedQueue } = require("./work_queue_checked_transport.cjs"); const { expectedGitHubHost } = require("./work_queue_host.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const REASON = "related_issue_closed"; @@ -133,15 +134,37 @@ async function main(options = {}) { const terminal = finalWork?.state === "completed" ? finalWork.completion_id : finalWork?.state === "cancelled" ? finalWork.cancellation_commit_id || committedExpiration : undefined; if (!terminal || (result?.publishedNow === true && !ownReceipt)) return { status: "pending" }; const state = finalWork.state; - const marker = ``; + const identity = closureIdentity(resource, work.work_id); + const marker = ``; if (!(await (options.hasConfirmation || hasConfirmation)(githubClient, context, number, marker))) { const outcome = state === "cancelled" ? ownReceipt - ? "Cancellation was recorded in the work queue ledger. New state: **cancelled**. The work is now canceled." - : "The work was already cancelled in the work queue ledger; this Issue closure did not change it. New state: **cancelled**." - : "The work was already completed in the work queue ledger; this Issue closure did not cancel it. New state: **completed**."; - const body = `${outcome}\n\n[View the work queue ledger](https://github.com/${repository}/blob/work-queue/work-queue.jsonl).\n\n${marker}`; + ? "Cancellation was recorded in the work queue ledger. New state: " + : "The work was already cancelled in the work queue ledger; this Issue closure did not change it. New state: " + : "The work was already completed in the work queue ledger; this Issue closure did not cancel it. New state: "; + const body = renderSizedReport({ + type: "report", + title: "Work queue closure", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: outcome, agentTainted: false }, + { type: "strong", children: [{ type: "text", value: state, agentTainted: false }] }, + { type: "text", value: state === "cancelled" && ownReceipt ? ". The work is now canceled." : ".", agentTainted: false }, + ], + }, + { + type: "paragraph", + children: [ + { type: "link", url: `https://github.com/${repository}/blob/work-queue/work-queue.jsonl`, children: [{ type: "text", value: "View the work queue ledger", agentTainted: false }] }, + { type: "text", value: ".", agentTainted: false }, + ], + }, + { type: "xml_marker", name: "gh-aw-issue-closure", value: identity, compact: true, agentTainted: false }, + ], + }).body; try { await githubClient.rest.issues.createComment({ ...context.repo, issue_number: number, body, request: { retries: 0, timeout: 15000 } }); } catch (error) { diff --git a/actions/setup/js/cancel_work_queue_closed_issue.test.cjs b/actions/setup/js/cancel_work_queue_closed_issue.test.cjs index 5e5242d2656..3515aaaf3e6 100644 --- a/actions/setup/js/cancel_work_queue_closed_issue.test.cjs +++ b/actions/setup/js/cancel_work_queue_closed_issue.test.cjs @@ -278,6 +278,7 @@ describe("closed backing Issue cancellation", () => { expect(args.request.parameters.operations).toEqual([{ kind: "WorkCancellation", work_id: "work_1", reason: "related_issue_closed" }]); expect(githubClient.rest.issues.createComment).toHaveBeenCalledOnce(); expect(githubClient.rest.issues.createComment.mock.calls[0][0].body).toContain("Cancellation was recorded in the work queue ledger. New state: **cancelled**. The work is now canceled."); + expect(githubClient.rest.issues.createComment.mock.calls[0][0].body).toMatch(/\[View the work queue ledger\]\(https:\/\/github.com\/owner\/repo\/blob\/work-queue\/work-queue.jsonl\)\.\n\n$/); }); it("cancels enterprise Work bound to a closed backing Issue", async () => { diff --git a/actions/setup/js/check_version_updates.cjs b/actions/setup/js/check_version_updates.cjs index 3dfeff4846a..235358c090e 100644 --- a/actions/setup/js/check_version_updates.cjs +++ b/actions/setup/js/check_version_updates.cjs @@ -16,11 +16,14 @@ * Fails the activation job when validation fails. */ -const { createReportIssue } = require("./issue_report.cjs"); +const { createReportIssue, renderIssueParameters } = require("./issue_report.cjs"); +const fs = require("node:fs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { withRetry, isTransientError } = require("./error_recovery.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs"); +const { getPromptPath } = require("./messages_core.cjs"); const { ERR_COMPILED_WORKFLOW_PROVENANCE_REQUIRED, ERR_COMPILE_AGENTIC_VERSION_BLOCKED, ERR_COMPILE_AGENTIC_VERSION_OUTDATED } = require("./error_codes.cjs"); const CONFIG_URL = "https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json"; @@ -75,16 +78,6 @@ function buildBlockedVersionIssueTitle(compiledVersion) { return `${BLOCKED_VERSION_ISSUE_TITLE_PREFIX} ${compiledVersion}`; } -/** - * Return a Markdown-safe inline-code representation. - * - * @param {string} value - * @returns {string} - */ -function markdownCode(value) { - return `\`${String(value).replace(/`/g, "\\`")}\``; -} - /** * Build a link to the current workflow run when the GitHub Actions context is available. * @@ -107,14 +100,50 @@ function getRunUrl() { * @returns {string} */ function buildBlockedVersionIssueBody(compiledVersion) { + return renderSizedReport(buildBlockedVersionIssueReport(compiledVersion)).body; +} + +/** @param {string} compiledVersion @returns {import("./report_ast.cjs").Report} */ +function buildBlockedVersionIssueReport(compiledVersion) { const workflowName = process.env.GH_AW_WORKFLOW_NAME || (typeof context !== "undefined" ? context.workflow : "") || "unknown"; const runUrl = getRunUrl(); - return renderTemplateFromFile(getPromptPath("blocked_compiler_version_issue.md"), { - compiled_version: compiledVersion, - compiled_version_code: markdownCode(compiledVersion), - workflow_name_code: markdownCode(workflowName), - run_url_line: runUrl ? `- Run: ${runUrl}` : "", - }); + return { + type: "report", + title: buildBlockedVersionIssueTitle(compiledVersion), + agentTainted: false, + children: [ + { type: "xml_marker", name: "gh-aw-blocked-compiler-version", value: compiledVersion, agentTainted: false }, + ...parseReportMarkdown(fs.readFileSync(getPromptPath("blocked_compiler_version_issue.md"), "utf8").replace(/^\r?\n/, ""), { + context: { + compiled_version: compiledVersion, + compiled_version_code: { type: "inline_slot", children: [{ type: "code", value: compiledVersion, agentTainted: false }] }, + workflow_name_code: { type: "inline_slot", children: [{ type: "code", value: workflowName, agentTainted: false }] }, + run_url_line: runUrl + ? [ + { + type: "list", + ordered: false, + items: [ + [ + { + type: "paragraph", + children: [ + { type: "text", value: "Run: ", agentTainted: false }, + { type: "link", url: runUrl, children: [{ type: "text", value: runUrl, agentTainted: false }], agentTainted: false }, + ], + }, + ], + ], + agentTainted: false, + }, + ] + : [], + }, + agentTainted: false, + minimumHeadingLevel: 2, + }), + ], + }; } /** @@ -168,21 +197,25 @@ async function reportBlockedVersionIssue(compiledVersion) { const { owner, repo } = context.repo; const title = buildBlockedVersionIssueTitle(compiledVersion); - const body = buildBlockedVersionIssueBody(compiledVersion); + const report = buildBlockedVersionIssueReport(compiledVersion); try { const existing = await findExistingBlockedVersionIssue(owner, repo, compiledVersion); if (existing) { const updatedIssue = await withRetry( () => - github.rest.issues.update({ - owner, - repo, - issue_number: existing.number, - title, - body, - headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION }, - }), + github.rest.issues.update( + renderIssueParameters( + { + owner, + repo, + issue_number: existing.number, + title, + headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION }, + }, + report + ) + ), {}, "update blocked compiler version issue" ); @@ -192,13 +225,16 @@ async function reportBlockedVersionIssue(compiledVersion) { const newIssue = await withRetry( () => - createReportIssue(github, { - owner, - repo, - title, - body, - headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION }, - }), + createReportIssue( + github, + { + owner, + repo, + title, + headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION }, + }, + report + ), {}, "create blocked compiler version issue" ); diff --git a/actions/setup/js/check_workflow_recompile_needed.cjs b/actions/setup/js/check_workflow_recompile_needed.cjs index 39ca3300e3a..6588a186692 100644 --- a/actions/setup/js/check_workflow_recompile_needed.cjs +++ b/actions/setup/js/check_workflow_recompile_needed.cjs @@ -10,11 +10,42 @@ const { getGitAuthEnv } = require("./git_auth_helpers.cjs"); const { resolvePullRequestRepo } = require("./pr_helpers.cjs"); const { pushSignedCommits } = require("./push_signed_commits.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); +const { getMessages, toSnakeCase } = require("./messages_core.cjs"); +const { getAgenticWorkflowMetadata } = require("./messages_footer.cjs"); const RECOMPILE_ISSUE_TITLE = "[aw] agentic workflows out of sync"; const RECOMPILE_PR_TITLE = "[aw] recompile agentic workflows"; const RECOMPILE_PR_BRANCH = "aw/recompile-workflows"; +/** + * @param {string} title + * @param {import("./report_ast.cjs").ReportBlock[]} children + * @param {{workflowName: string, runUrl: string, repository: string}} ctx + * @param {boolean} [comment] + * @returns {import("./report_ast.cjs").Report} + */ +function buildRecompileReport(title, children, ctx, comment = false) { + const messages = getMessages(); + const template = comment ? messages?.footerWorkflowRecompileComment || "> Updated by [{workflow_name}]({run_url})" : messages?.footerWorkflowRecompile || "> Generated by [{workflow_name}]({run_url})"; + const footer = parseReportMarkdown(template, { context: toSnakeCase({ ...ctx, agenticWorkflowUrl: ctx.runUrl ? `${ctx.runUrl}/agentic_workflow` : "" }), agentTainted: false }); + const [workflow, ...fields] = getAgenticWorkflowMetadata(ctx.workflowName, ctx.runUrl); + const caution = getDetectionCautionAlert(ctx.workflowName, ctx.runUrl); + return { + type: "report", + title, + agentTainted: false, + children: [ + ...parseReportMarkdown(caution, { allowFlagMarkers: true, agentTainted: false }), + ...children, + { type: "separator", agentTainted: false }, + ...footer, + { type: "xml_marker", name: "gh-aw-agentic-workflow", value: workflow.value, fields, agentTainted: false }, + ], + }; +} + function shouldCreatePullRequest() { return getRecompileToken() !== ""; } @@ -350,15 +381,9 @@ async function main() { repository, }; - const footer = getFooterWorkflowRecompileCommentMessage(ctx); - const xmlMarker = generateXMLMarker(workflowName, runUrl); - - // Inject CAUTION at top of body if threat detection warning was raised - const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); - const cautionPrefix = detectionCaution ? detectionCaution + "\n\n" : ""; - - // Sanitize the message text but not the footer/marker which are system-generated - const commentBody = `${cautionPrefix}Workflows are still out of sync.\n\n---\n${footer}\n\n${xmlMarker}`; + const commentBody = renderSizedReport( + buildRecompileReport(RECOMPILE_ISSUE_TITLE, [{ type: "paragraph", children: [{ type: "text", value: "Workflows are still out of sync.", agentTainted: false }], agentTainted: false }], ctx, true) + ).body; await github.rest.issues.createComment({ owner, @@ -396,7 +421,7 @@ async function main() { const diffContent = detailedDiff.substring(0, 50000) + (detailedDiff.length > 50000 ? "\n\n... (diff truncated)" : ""); const repository = `${owner}/${repo}`; - let issueBody = issueTemplate.replace("{DIFF_CONTENT}", diffContent).replace("{REPOSITORY}", repository); + const issueNodes = parseReportMarkdown(issueTemplate, { context: { DIFF_CONTENT: diffContent, REPOSITORY: repository }, minimumHeadingLevel: 2, agentTainted: false }); // Get workflow metadata for footer const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Agentic Maintenance"; @@ -409,26 +434,19 @@ async function main() { }; // Use custom footer template if configured, with XML marker for traceability - const footer = getFooterWorkflowRecompileMessage(ctx); - const xmlMarker = generateXMLMarker(workflowName, runUrl); - - // Inject CAUTION at top of body if threat detection warning was raised - const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); - if (detectionCaution) { - issueBody = detectionCaution + "\n\n" + issueBody; - } - - // Note: issueBody is built from a template render, no user content to sanitize - issueBody += "\n\n---\n" + footer + "\n\n" + xmlMarker + "\n"; + const report = buildRecompileReport(RECOMPILE_ISSUE_TITLE, issueNodes, ctx); try { - const newIssue = await createReportIssue(github, { - owner, - repo, - title: RECOMPILE_ISSUE_TITLE, - body: issueBody, - labels: ["agentic-workflows", "maintenance"], - }); + const newIssue = await createReportIssue( + github, + { + owner, + repo, + title: RECOMPILE_ISSUE_TITLE, + labels: ["agentic-workflows", "maintenance"], + }, + report + ); core.info(`✓ Created issue #${newIssue.data.number}: ${newIssue.data.html_url}`); diff --git a/actions/setup/js/cleanup_stale_steering_issues.cjs b/actions/setup/js/cleanup_stale_steering_issues.cjs index 645de4cc7f5..e1963234099 100644 --- a/actions/setup/js/cleanup_stale_steering_issues.cjs +++ b/actions/setup/js/cleanup_stale_steering_issues.cjs @@ -3,13 +3,19 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { withRetry, RATE_LIMIT_RETRY_CONFIG } = require("./error_recovery.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const STEERING_ISSUE_TITLE_PREFIX = "[WIP] "; const STEERING_ISSUE_MAX_AGE_MS = 24 * 60 * 60 * 1000; const CLEANUP_COMMENT_MARKER = ""; -const CLEANUP_COMMENT = `This steering issue was left open after its linked workflow run completed successfully. Closing it during scheduled agentic maintenance. - -${CLEANUP_COMMENT_MARKER}`; +const CLEANUP_COMMENT = renderSizedReport({ + type: "report", + title: "Stale steering issue closed", + children: [ + { type: "paragraph", children: [{ type: "text", value: "This steering issue was left open after its linked workflow run completed successfully. Closing it during scheduled agentic maintenance.", agentTainted: false }] }, + { type: "xml_marker", name: "gh-aw", value: "stale-steering-issue-cleanup", compact: true, agentTainted: false }, + ], +}).body; const OPEN_ISSUES_QUERY = ` query($owner: String!, $repo: String!, $cursor: String) { diff --git a/actions/setup/js/cleanup_stale_steering_issues.test.cjs b/actions/setup/js/cleanup_stale_steering_issues.test.cjs index 11f0f53d7cb..d7bee80a632 100644 --- a/actions/setup/js/cleanup_stale_steering_issues.test.cjs +++ b/actions/setup/js/cleanup_stale_steering_issues.test.cjs @@ -8,6 +8,12 @@ const mockContext = { }; describe("cleanup_stale_steering_issues", () => { + it("preserves the compact cleanup marker identity after tree rendering", async () => { + const { CLEANUP_COMMENT, CLEANUP_COMMENT_MARKER } = await import("./cleanup_stale_steering_issues.cjs"); + expect(CLEANUP_COMMENT_MARKER).toBe(""); + expect(CLEANUP_COMMENT).toBe("This steering issue was left open after its linked workflow run completed successfully. Closing it during scheduled agentic maintenance.\n\n"); + }); + let mockGithub; const oldCreatedAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); const makeIssue = (overrides = {}) => ({ diff --git a/actions/setup/js/close_agentic_workflows_issues.cjs b/actions/setup/js/close_agentic_workflows_issues.cjs index cd52b536dbe..f01adb2f757 100644 --- a/actions/setup/js/close_agentic_workflows_issues.cjs +++ b/actions/setup/js/close_agentic_workflows_issues.cjs @@ -3,6 +3,7 @@ const { resolveExecutionOwnerRepo } = require("./repo_helpers.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const TARGET_LABEL = "agentic-workflows"; // Keep a conservative, explicit upper bound for comment sanitization. @@ -41,7 +42,18 @@ async function closeIssueAsNotPlanned(issueId) { * @returns {string} */ function getNoReproCommentBody(sanitize = sanitizeContent) { - const body = sanitize(NO_REPRO_MESSAGE, { maxLength: MAX_COMMENT_BODY_LENGTH }); + const generatedBody = renderSizedReport( + { + type: "report", + title: "Issue closed", + children: [ + { type: "paragraph", children: [{ type: "text", value: "Closing as no repro.", agentTainted: false }] }, + { type: "paragraph", children: [{ type: "text", value: "If this is still reproducible, please open a new issue with clear reproduction steps.", agentTainted: false }] }, + ], + }, + MAX_COMMENT_BODY_LENGTH + ).body; + const body = sanitize(generatedBody, { maxLength: MAX_COMMENT_BODY_LENGTH }); if (typeof body !== "string") { throw new Error("Close comment body sanitization must return a string"); } diff --git a/actions/setup/js/close_discussion.cjs b/actions/setup/js/close_discussion.cjs index a0c6492af1a..f20e33db8bf 100644 --- a/actions/setup/js/close_discussion.cjs +++ b/actions/setup/js/close_discussion.cjs @@ -7,7 +7,9 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); -const { getBodyFooterMessage } = require("./messages_footer.cjs"); +const { getBodyFooterBlocks } = require("./messages_footer.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); @@ -312,14 +314,15 @@ async function main(config = {}) { core.info("close_discussion: allow-body is false — closing without a comment"); } } else if (item.body) { - let sanitizedBody = sanitizeContent(item.body, { allowedAliases: allowedMentionAliases, maxMentions }); + const sanitizedBody = sanitizeContent(item.body, { allowedAliases: allowedMentionAliases, maxMentions }); const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; const runUrl = buildWorkflowRunUrl(context, context.repo); - const bodyFooter = getBodyFooterMessage(config.body_footer, { workflowName, runUrl }); - if (bodyFooter) { - sanitizedBody += "\n\n" + bodyFooter.trimEnd(); - } - const comment = await addDiscussionComment(githubClient, discussion.id, sanitizedBody); + const body = renderSizedReport({ + type: "report", + title: "Discussion closed", + children: [...parseReportMarkdown(sanitizedBody, { allowedMentions: allowedMentionAliases, agentTainted: true, minimumHeadingLevel: 2 }), ...getBodyFooterBlocks(config.body_footer, { workflowName, runUrl })], + }).body; + const comment = await addDiscussionComment(githubClient, discussion.id, body); core.info(`Added comment to discussion #${discussionNumber}: ${comment.url}`); commentUrl = comment.url; } diff --git a/actions/setup/js/close_entity_helpers.cjs b/actions/setup/js/close_entity_helpers.cjs index 26a8e6967b3..c339b9cb3a1 100644 --- a/actions/setup/js/close_entity_helpers.cjs +++ b/actions/setup/js/close_entity_helpers.cjs @@ -2,7 +2,10 @@ /// const { loadAgentOutput } = require("./load_agent_output.cjs"); -const { generateFooterWithMessages, getBodyFooterMessage, getDetectionCautionAlert } = require("./messages_footer.cjs"); +const { assembleReportBodyParts } = require("./markdown_body_helpers.cjs"); +const { appendConfiguredReportFooter } = require("./body_footer.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { renderSizedReport } = require("./report_size.cjs"); const { getTrackerID } = require("./get_tracker_id.cjs"); const { getRepositoryUrl } = require("./get_repository_url.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); @@ -56,15 +59,12 @@ function buildCommentBody(body, triggeringIssueNumber, triggeringPRNumber, bodyF // Inject CAUTION at top of body if threat detection warning was raised. // Caller is responsible for sanitizing body before passing it here. - const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); - const bodyWithCaution = detectionCaution ? detectionCaution + "\n\n" + body.trim() : body.trim(); - let finalBody = - bodyWithCaution + getTrackerID("markdown") + "\n\n" + generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, undefined, undefined, { skipDetectionCaution: true }); - const bodyFooter = getBodyFooterMessage(bodyFooterTemplate, { workflowName, runUrl }); - if (bodyFooter) { - finalBody += "\n\n" + bodyFooter.trimEnd(); - } - return finalBody; + const { detectionCaution, footer } = assembleReportBodyParts({ includeFooter: true, workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber }); + const children = [...detectionCaution, ...parseReportMarkdown(body, { minimumHeadingLevel: 2, agentTainted: true })]; + const trackerID = getTrackerID(); + if (trackerID) children.push({ type: "xml_marker", name: "gh-aw-tracker-id", value: trackerID, agentTainted: false }); + children.push(...footer); + return renderSizedReport(appendConfiguredReportFooter({ type: "report", title: "Entity closed", children }, bodyFooterTemplate)).body; } /** diff --git a/actions/setup/js/close_entity_helpers.test.cjs b/actions/setup/js/close_entity_helpers.test.cjs index 37fd4540fe4..dfe3403056d 100644 --- a/actions/setup/js/close_entity_helpers.test.cjs +++ b/actions/setup/js/close_entity_helpers.test.cjs @@ -195,8 +195,6 @@ describe("close_entity_helpers", () => { it("should sanitize user content while preserving system markers in buildCommentBody", () => { // Import sanitizeContent to test the actual behavior const { sanitizeContent } = require("./sanitize_content.cjs"); - const { getTrackerID } = require("./get_tracker_id.cjs"); - const { generateFooterWithMessages } = require("./messages_footer.cjs"); // Set up environment for tracker ID and footer process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; @@ -204,12 +202,9 @@ describe("close_entity_helpers", () => { process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/test/repo"; process.env.GH_AW_TRACKER_ID = "test-tracker-456"; - // Simulate what buildCommentBody does const userContent = "User comment with and "; const sanitizedBody = sanitizeContent(userContent); - const trackerID = getTrackerID("markdown"); - const footer = generateFooterWithMessages("Test Workflow", "https://github.com/test/repo/actions/runs/123", "test.md", "https://github.com/test/repo", 42, undefined, undefined); - const result = sanitizedBody.trim() + trackerID + footer; + const result = buildCommentBody(sanitizedBody, 42, undefined); // User content should be sanitized (tags converted) expect(result).not.toContain("