From a7e10010a4761c76a79b21deb50791e88bae1d80 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Tue, 27 Oct 2026 04:24:54 -0700 Subject: [PATCH 01/10] Refactor issue and comment Markdown into report trees Compose typed bodies, diagnostics, recovery instructions, and attribution before publication rendering. Preserve metadata, protected footers, managed memory, and literal Jira ADF contracts. Add an issue-markdown skill and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/skills/issue-markdown/SKILL.md | 127 ++ AGENTS.md | 1 + actions/setup/js/add_comment.cjs | 101 +- actions/setup/js/add_comment.test.cjs | 8 +- .../js/add_reaction_and_edit_comment.cjs | 29 +- actions/setup/js/add_workflow_run_comment.cjs | 19 +- .../js/add_workflow_run_comment.test.cjs | 25 +- actions/setup/js/approve_workflow_run.cjs | 16 +- actions/setup/js/assign_to_agent.cjs | 21 +- actions/setup/js/assign_to_agent.test.cjs | 2 +- actions/setup/js/body_footer.cjs | 31 +- actions/setup/js/body_footer.test.cjs | 2 +- .../js/cancel_work_queue_closed_issue.cjs | 33 +- .../cancel_work_queue_closed_issue.test.cjs | 1 + actions/setup/js/check_version_updates.cjs | 104 +- .../js/check_workflow_recompile_needed.cjs | 74 +- .../js/cleanup_stale_steering_issues.cjs | 12 +- .../js/cleanup_stale_steering_issues.test.cjs | 6 + .../js/close_agentic_workflows_issues.cjs | 14 +- actions/setup/js/close_discussion.cjs | 17 +- actions/setup/js/close_entity_helpers.cjs | 20 +- .../setup/js/close_entity_helpers.test.cjs | 22 +- actions/setup/js/close_older_issues.cjs | 36 +- .../setup/js/close_older_pull_requests.cjs | 36 +- actions/setup/js/comment_memory.cjs | 83 +- actions/setup/js/comment_memory_helpers.cjs | 6 +- .../setup/js/comment_memory_helpers.test.cjs | 16 + actions/setup/js/complete_steering_issue.cjs | 10 +- .../setup/js/complete_steering_issue.test.cjs | 2 +- actions/setup/js/create_discussion.cjs | 20 +- .../js/create_discussion_fallback.test.cjs | 10 + actions/setup/js/create_forecast_issue.cjs | 180 ++- .../setup/js/create_forecast_issue.test.cjs | 22 +- actions/setup/js/create_issue.cjs | 97 +- actions/setup/js/create_issue.test.cjs | 4 +- actions/setup/js/create_pr_review_comment.cjs | 18 +- .../js/create_pr_review_comment.test.cjs | 2 +- actions/setup/js/create_pull_request.cjs | 359 +++-- .../setup/js/create_pull_request_helpers.cjs | 145 +- .../create_report_incomplete_issue.test.cjs | 2 +- actions/setup/js/create_steering_issue.cjs | 38 +- .../setup/js/create_steering_issue.test.cjs | 2 +- actions/setup/js/disable_agentic_workflow.cjs | 105 +- .../js/disable_agentic_workflow.test.cjs | 14 + actions/setup/js/execution_provenance.cjs | 35 +- .../setup/js/execution_provenance.test.cjs | 54 +- .../js/expired_entity_handler_factory.cjs | 35 +- actions/setup/js/failure_report.cjs | 43 +- actions/setup/js/failure_report.test.cjs | 11 + actions/setup/js/firewall_blocked_domains.cjs | 42 +- actions/setup/js/gateway_difc_filtered.cjs | 78 +- actions/setup/js/handle_agent_failure.cjs | 1176 +++++++++-------- .../setup/js/handle_agent_failure.test.cjs | 101 +- ...ent_failure_ai_credits_rate_limit.test.cjs | 6 +- ..._agent_failure_daily_aic_workflow.test.cjs | 15 +- ...t_failure_max_ai_credits_exceeded.test.cjs | 6 +- actions/setup/js/handle_create_pr_error.cjs | 105 +- actions/setup/js/handle_detection_runs.cjs | 55 +- actions/setup/js/handle_noop_message.cjs | 69 +- actions/setup/js/handle_noop_message.test.cjs | 8 +- actions/setup/js/issue_body.cjs | 76 +- .../js/issue_body_report_builder.test.cjs | 2 +- actions/setup/js/issue_report.cjs | 4 +- actions/setup/js/jira_handlers.cjs | 25 +- actions/setup/js/label_apply_safe_outputs.cjs | 105 +- .../js/label_apply_safe_outputs.test.cjs | 13 + actions/setup/js/linear_create_issue.cjs | 8 +- actions/setup/js/linear_safe_outputs.test.cjs | 2 +- .../mark_pull_request_as_ready_for_review.cjs | 15 +- actions/setup/js/markdown_body_helpers.cjs | 34 +- actions/setup/js/messages.test.cjs | 43 +- actions/setup/js/messages_core.cjs | 28 + actions/setup/js/messages_footer.cjs | 219 ++- .../js/messages_footer_work_queue.test.cjs | 52 +- actions/setup/js/messages_header.cjs | 21 +- actions/setup/js/messages_run_status.cjs | 43 +- actions/setup/js/missing_issue_helpers.cjs | 110 +- .../setup/js/missing_issue_helpers.test.cjs | 4 +- actions/setup/js/missing_messages_helper.cjs | 62 +- .../setup/js/missing_messages_helper.test.cjs | 21 + actions/setup/js/notify_comment_error.cjs | 60 +- .../setup/js/notify_comment_error.test.cjs | 31 +- actions/setup/js/pr_review_buffer.cjs | 127 +- actions/setup/js/pr_review_buffer.test.cjs | 34 +- .../setup/js/push_to_pull_request_branch.cjs | 27 +- .../js/push_to_pull_request_branch.test.cjs | 2 +- .../setup/js/reply_to_pr_review_comment.cjs | 21 +- .../js/reply_to_pr_review_comment.test.cjs | 4 +- actions/setup/js/report_ast.cjs | 31 +- actions/setup/js/report_ast.test.cjs | 45 + actions/setup/js/report_failed_jobs.cjs | 120 +- actions/setup/js/report_failed_jobs.test.cjs | 2 +- actions/setup/js/report_footer.test.cjs | 2 +- .../setup/js/report_html_fidelity.test.cjs | 2 +- actions/setup/js/report_markdown.cjs | 28 +- actions/setup/js/report_size.cjs | 2 +- actions/setup/js/report_size.test.cjs | 1 + actions/setup/js/route_slash_command.cjs | 92 +- actions/setup/js/route_slash_command.test.cjs | 16 +- actions/setup/js/run_activity_report.cjs | 88 +- .../setup/js/run_operation_update_upgrade.cjs | 75 +- actions/setup/js/run_validate_workflows.cjs | 123 +- .../setup/js/safe_output_handler_manager.cjs | 78 +- .../js/safe_output_handler_manager.test.cjs | 28 +- .../js/safe_output_routing_parity.test.cjs | 2 +- .../setup/js/safe_output_type_validator.cjs | 15 +- .../js/safe_output_type_validator.test.cjs | 2 +- .../setup/js/update_activation_comment.cjs | 56 +- .../js/update_activation_comment.test.cjs | 15 +- .../js/update_pr_description_helpers.cjs | 95 +- .../js/update_pr_description_helpers.test.cjs | 8 +- .../setup/js/update_pull_request_branches.cjs | 10 +- actions/setup/js/work_queue_issue_api.cjs | 4 +- .../setup/js/work_queue_issue_api.test.cjs | 22 + .../setup/js/work_queue_issue_messages.cjs | 74 +- ...work_queue_issue_messages_bounded.test.cjs | 63 +- actions/setup/js/work_queue_issues.cjs | 85 +- actions/setup/js/work_queue_issues_checks.cjs | 2 +- actions/setup/md/firewall_blocked_domains.md | 6 +- .../setup/md/pr_permission_denied_fallback.md | 4 +- 120 files changed, 3981 insertions(+), 2139 deletions(-) create mode 100644 .github/skills/issue-markdown/SKILL.md diff --git a/.github/skills/issue-markdown/SKILL.md b/.github/skills/issue-markdown/SKILL.md new file mode 100644 index 00000000000..b2c92ae41bb --- /dev/null +++ b/.github/skills/issue-markdown/SKILL.md @@ -0,0 +1,127 @@ +--- +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. +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` includes a terminal newline: callers that trim it can reserve +`maxLength + 1`, then check the final publication limit. Never truncate the +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..ac552e5d46d 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), { minimumHeadingLevel: 2, allowMetadata: true, 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 + 1).body.trimEnd(); 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..e4b1d7b09d0 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); } @@ -2704,7 +2704,7 @@ describe("add_comment", () => { expect(request.issue_number).toBe(8535); expect(request.body).toContain("Review complete."); expect(request.body).not.toContain(""); } 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..364b6452721 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.trimEnd(); 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..f7ff4d5c70f 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.trimEnd(); } /** 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..51cdacf651b 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.trimEnd(); } /** diff --git a/actions/setup/js/assign_to_agent.cjs b/actions/setup/js/assign_to_agent.cjs index 6d4d77ea0b6..d45aeafc950 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.trimEnd(), }); 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..9018262b397 100644 --- a/actions/setup/js/body_footer.cjs +++ b/actions/setup/js/body_footer.cjs @@ -1,9 +1,10 @@ // @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"); /** * Append a configured deterministic body footer using the current workflow context. @@ -16,17 +17,23 @@ const { fitMarkdownBody } = require("./report_size.cjs"); */ function appendConfiguredBodyFooter(body, template, options = {}) { const { workflowRepo, maxLength } = options || {}; - if (!template) return maxLength === undefined ? body : fitMarkdownBody(body, { maxLength }); + if (!template && maxLength === undefined) return body; + const report = appendConfiguredReportFooter({ type: "report", title: "Comment", children: parseReportMarkdown(body, { minimumHeadingLevel: 2, allowMetadata: true, agentTainted: true }) }, template, { workflowRepo }); + return renderSizedReport(report, maxLength === undefined ? undefined : maxLength + 1).body.trimEnd(); +} + +/** + * 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..0f7be23684c 100644 --- a/actions/setup/js/body_footer.test.cjs +++ b/actions/setup/js/body_footer.test.cjs @@ -26,7 +26,7 @@ describe("appendConfiguredBodyFooter", () => { }); 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..ce7a43c576c 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.trimEnd(); 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..a5c4bcb6187 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.trimEnd(); 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..46ce4da86c3 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.trimEnd(); + 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..2dc35b7aa85 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.trimEnd(); + 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..c99201adc46 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.trimEnd(); } /** 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("