From 7f6efb46855f1c54bf0715a0ab23be4eb1c8d855 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Sat, 17 Oct 2026 09:42:03 -0700 Subject: [PATCH 1/6] Structure failure issues with a normalized report AST Render failure summaries, diagnostics, attribution, expiration, and machine-readable markers through a single escaping and normalization pipeline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- actions/setup/README.md | 24 ++ actions/setup/js/failure_report.cjs | 43 +++ actions/setup/js/failure_report.test.cjs | 122 +++++++ actions/setup/js/handle_agent_failure.cjs | 141 ++++---- .../setup/js/handle_agent_failure.test.cjs | 141 ++++---- actions/setup/js/messages_footer.cjs | 66 ++-- actions/setup/js/report_ast.cjs | 330 ++++++++++++++++++ actions/setup/js/report_ast.test.cjs | 207 +++++++++++ actions/setup/js/report_footer.cjs | 41 +++ actions/setup/js/report_footer.test.cjs | 106 ++++++ actions/setup/js/report_markdown.cjs | 201 +++++++++++ actions/setup/md/agent_failure_issue.md | 62 ++-- 12 files changed, 1295 insertions(+), 189 deletions(-) create mode 100644 actions/setup/js/failure_report.cjs create mode 100644 actions/setup/js/failure_report.test.cjs create mode 100644 actions/setup/js/report_ast.cjs create mode 100644 actions/setup/js/report_ast.test.cjs create mode 100644 actions/setup/js/report_footer.cjs create mode 100644 actions/setup/js/report_footer.test.cjs create mode 100644 actions/setup/js/report_markdown.cjs diff --git a/actions/setup/README.md b/actions/setup/README.md index 259d25f24cc..48ad358fef8 100644 --- a/actions/setup/README.md +++ b/actions/setup/README.md @@ -73,6 +73,30 @@ All files are copied from the committed `js/` and `sh/` directories which contai ## Development +### Structured failure reports + +Failure issues are built as a report AST before publication. +`js/report_ast.cjs` defines the typed inline and block nodes, normalization, and +GitHub Markdown renderer. The renderer owns escaping, HTTPS link validation, +heading levels, balanced details, code fences, and whitespace. It rejects raw +HTML/Markdown nodes and redacts diagnostic secrets before choosing delimiters. + +`js/failure_report.cjs` builds reports from `md/agent_failure_issue.md`. +`js/report_markdown.cjs` imports the runtime template dialect: headings, +paragraphs, emphasis, links, code, lists, tables, quotes, alerts, and details. +Templates are parsed **before** scalar metadata is substituted. Diagnostic +fragments enter through explicit block fields, with headings below the report +sections; unsupported HTML remains literal text. New programmatic report content +should use AST nodes rather than concatenated Markdown. Empty optional blocks +are omitted and rendering is deterministic. + +`js/report_footer.cjs` builds the footer from the shared attribution template and +model/cost metadata. Attribution, expiration, provenance, failure-match markers, +and the detection preamble are rendered with the document, not appended afterward. +Marker names and fields are validated; marker values cannot close an HTML comment +or inject another metadata field. Expiration remains a checked task-list item so +existing cleanup and deduplication readers continue to work. + ### JSONL runtime helpers Use `js/jsonl_helpers.cjs` instead of splitting files or maintaining chunk buffers: diff --git a/actions/setup/js/failure_report.cjs b/actions/setup/js/failure_report.cjs new file mode 100644 index 00000000000..7eea0200b63 --- /dev/null +++ b/actions/setup/js/failure_report.cjs @@ -0,0 +1,43 @@ +// @ts-check + +const { parseReportMarkdown } = require("./report_markdown.cjs"); + +/** @typedef {import("./report_ast.cjs").Report} Report */ +/** @typedef {import("./report_ast.cjs").ReportInline} ReportInline */ +/** @typedef {import("./report_ast.cjs").ReportBlock} ReportBlock */ + +/** + * Keep legacy diagnostic templates at an explicit import boundary. Scalar metadata + * stays literal, and diagnostic headings cannot become top-level report sections. + * @param {string} template + * @param {string} title + * @param {Record} values + * @returns {Report} + */ +function buildFailureReport(template, title, values) { + /** @type {import("./report_markdown.cjs").ReportContext} */ + const context = {}; + for (const [key, value] of Object.entries(values)) { + context[key] = key.endsWith("_context") ? parseReportMarkdown(value) : value; + } + + /** @type {ReportInline[]} */ + const links = []; + const addLink = (label, url) => { + if (links.length) links.push({ type: "text", value: " · " }); + links.push({ type: "link", url, children: [{ type: "text", value: label }] }); + }; + if (values.run_url) addLink(/^\d+$/.test(values.run_id) ? `View run #${values.run_id}` : "View run", values.run_url); + else links.push({ type: "text", value: "Run link unavailable: run URL was not recorded." }); + if (values.workflow_source_url && values.workflow_source_url !== "#") addLink("Workflow source", values.workflow_source_url); + if (values.history_url) addLink("Previous failures", values.history_url); + context.run_links_context = [{ type: "paragraph", children: links }]; + context.pull_request_context = parseReportMarkdown(values.pull_request_info || ""); + + const evidence = [context.report_incomplete_context, context.failure_diagnostics_context].flatMap(value => (Array.isArray(value) ? value : [])); + context.runtime_evidence_context = evidence.length ? [{ type: "details", summary: "Runtime evidence", children: evidence }] : []; + + return { type: "report", title, children: parseReportMarkdown(template, { context, minimumHeadingLevel: 2 }) }; +} + +module.exports = { buildFailureReport }; diff --git a/actions/setup/js/failure_report.test.cjs b/actions/setup/js/failure_report.test.cjs new file mode 100644 index 00000000000..778e74fb941 --- /dev/null +++ b/actions/setup/js/failure_report.test.cjs @@ -0,0 +1,122 @@ +// @ts-check + +import { describe, it, expect } from "vitest"; +import { createRequire } from "module"; +import { readFileSync } from "node:fs"; + +const require = createRequire(import.meta.url); +const { buildFailureReport } = require("./failure_report.cjs"); +const { normalizeReport, renderReport } = require("./report_ast.cjs"); +const template = readFileSync(new URL("../md/agent_failure_issue.md", import.meta.url), "utf8"); + +function values(overrides = {}) { + return { + ...Object.fromEntries(Array.from(template.matchAll(/\{(\w+)\}/g), match => [match[1], ""])), + workflow_name: "Smoke Codex", + workflow_id: "smoke-codex", + failure_summary: "is missing required tools", + run_id: "39046125927", + run_url: "https://github.com/github/gh-aw/actions/runs/39046125927", + workflow_source_url: "https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-codex.md", + history_url: "https://github.com/search?q=repo%3Agithub%2Fgh-aw&type=issues", + branch: "copilot/bump-mcp-gateway-v0435", + pull_request_info: "**Pull Request:** [#69581](https://github.com/github/gh-aw/pull/69581)", + ...overrides, + }; +} + +describe("failure report structure", () => { + it("has a fixed overview, next step, and details order with one debugging prompt", () => { + const ast = buildFailureReport( + template, + "[aw] Smoke Codex: is missing required tools", + values({ + missing_tool_context: "> [!WARNING]\n> **Missing tools:** The GitHub MCP interface is unavailable.\n\n- **report\\_progress**: not exposed", + }) + ); + const { title, body } = renderReport(ast); + expect(title).toBe("[aw] Smoke Codex: is missing required tools"); + expect( + normalizeReport(ast) + .children.filter(node => node.type === "heading") + .map(node => node.children) + ).toEqual([[{ type: "text", value: "What happened" }], [{ type: "text", value: "Next step" }], [{ type: "text", value: "Failure details" }]]); + expect(body).toContain("**Smoke Codex** is missing required tools."); + expect(body).toContain("View run \\#39046125927"); + expect(body).toContain("[Workflow source]"); + expect(body).toContain("[Previous failures]"); + expect(body).toContain("**Branch:** `copilot/bump-mcp-gateway-v0435`"); + expect(body).toContain("[\\#69581](https://github.com/github/gh-aw/pull/69581)"); + expect(body).toContain("**report\\_progress**"); + expect(body.match(/Copy a debugging prompt<\/summary>/g)).toHaveLength(1); + expect(body).not.toContain("Manually invoke the agent"); + expect(body).not.toContain("Runtime evidence"); + expect(body).not.toMatch(/\{\w+\}/); + }); + + it("keeps a classified cause visible while hiding secondary runtime evidence", () => { + const { body } = renderReport( + buildFailureReport( + template, + "[aw] AOAI: has no AI credits pricing", + values({ + workflow_name: "Smoke Copilot - AOAI (Entra)", + failure_summary: "has no AI credits pricing for model (o4-mini-aw)", + missing_model_pricing_context: "> [!WARNING]\n> **Model has no AI credits pricing:** `o4-mini-aw` was rejected.\n\nThis is a configuration issue; retrying will not help.", + report_incomplete_context: "> [!WARNING]\n> Task completion could not be confirmed.", + failure_diagnostics_context: "### Failure Diagnostics\n\n**Failing step:** `Execute GitHub Copilot CLI`\n\n```text\nDriver exit code: 1\n```", + }) + ) + ); + const evidencePosition = body.indexOf("Runtime evidence"); + expect(body.indexOf("Model has no AI credits pricing")).toBeLessThan(evidencePosition); + expect(body.indexOf("retrying will not help")).toBeLessThan(evidencePosition); + expect(body.indexOf("Task completion could not be confirmed")).toBeGreaterThan(evidencePosition); + expect(body.indexOf("Driver exit code: 1")).toBeGreaterThan(evidencePosition); + expect(body).not.toContain("did not record a specific cause"); + }); + + it("retains uncertainty and never promotes diagnostic headings into report sections", () => { + const ast = buildFailureReport( + template, + "[aw] Smoke Gemini: failed (cause unknown)", + values({ + workflow_name: "Smoke Gemini", + failure_summary: "failed (cause unknown)", + failure_diagnostics_context: "## Unexpected heading\n\nThe runtime did not record a specific cause.", + }) + ); + const { body } = renderReport(ast); + expect(body).toContain("**Smoke Gemini** failed (cause unknown)."); + expect(body).toContain("### Unexpected heading"); + expect(body.match(/^## /gm)).toHaveLength(3); + }); + + it("treats hostile workflow, branch, and prompt fields as data", () => { + const ast = buildFailureReport( + template, + "[aw] Smoke: failed", + values({ + workflow_name: "Smoke**\n\n## Injected\n", + workflow_id: "smoke\n```\n\n# Injected", + branch: "feature/`\n", + }) + ); + const { body } = renderReport(ast); + expect(body.match(/^## /gm)).toHaveLength(3); + expect(body).toContain("<img src=x>"); + expect(body).toContain("``feature/` ``"); + expect(body).toContain("````text\nDebug"); + expect(body).not.toMatch(/^\{|\{\w+\}/m); + expect(body).toContain('````text\nDebug the agentic workflow "smoke\n```\n\n# Injected" failure.'); + expect(body.match(/^
$/gm)).toHaveLength(2); + }); + + it("omits absent optional links and reports missing run metadata explicitly", () => { + const { body } = renderReport(buildFailureReport(template, "[aw] Smoke: failed", values({ workflow_source_url: "#", history_url: "", pull_request_info: "", run_url: "" }))); + expect(body).not.toContain("[Workflow source]"); + expect(body).not.toContain("[Previous failures]"); + expect(body).not.toContain("Pull Request"); + expect(body).toContain("Run link unavailable: run URL was not recorded."); + }); +}); diff --git a/actions/setup/js/handle_agent_failure.cjs b/actions/setup/js/handle_agent_failure.cjs index ef2c641c596..6b3076e13a2 100644 --- a/actions/setup/js/handle_agent_failure.cjs +++ b/actions/setup/js/handle_agent_failure.cjs @@ -5,8 +5,12 @@ const { iterateJsonl, readJsonlFileEvents, iterateLines } = require("./jsonl_hel const { getErrorMessage } = require("./error_helpers.cjs"); const { sanitizeContent } = require("./sanitize_content.cjs"); const { redactAndBoundDiagnostics } = require("./diagnostic_sanitization.cjs"); -const { getDetectionCautionAlert, getFooterAgentFailureIssueMessage, getFooterAgentFailureCommentMessage, generateXMLMarker } = require("./messages.cjs"); +const { getDetectionCautionAlert, getFooterAgentFailureCommentMessage } = require("./messages.cjs"); const { renderTemplate, renderTemplateFromFile, getPromptPath, renderFilesList } = require("./messages_core.cjs"); +const { buildFailureReport } = require("./failure_report.cjs"); +const { normalizeReportTitle, renderReport } = require("./report_ast.cjs"); +const { buildFailureReportFooter } = require("./report_footer.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); const { getCurrentBranch } = require("./get_current_branch.cjs"); const { createExpirationLine, extractExpirationDate, generateFooterWithExpiration } = require("./ephemerals.cjs"); const { MAX_SUB_ISSUES, getSubIssueCount } = require("./sub_issue_helpers.cjs"); @@ -459,51 +463,55 @@ function buildModelEndpointMismatchContext(mismatch, detectionJobResult = proces * @returns {string} */ function buildFailureIssueTitle(options) { - const { workflowName } = options; - if (options.hasDailyAICExceeded) return `[aw] ${workflowName} exceeded daily AI credits budget`; - if (options.hasDailyAICGuardrailError) return `[aw] ${workflowName} could not verify daily AI credits`; - if (options.maxAICreditsExceeded) return `[aw] ${workflowName} exceeded max AI credits`; - if (options.aiCreditsRateLimitError) return `[aw] ${workflowName} hit AI credits rate limit`; - if (options.hasEngineRateLimit429) return `[aw] ${workflowName} hit engine rate limit (HTTP 429)`; + return normalizeReportTitle(`[aw] ${options.workflowName}: ${buildFailureIssueSummary(options)}`); +} + +/** @param {Parameters[0]} options */ +function buildFailureIssueSummary(options) { + if (options.hasDailyAICExceeded) return "exceeded daily AI credits budget"; + if (options.hasDailyAICGuardrailError) return "could not verify daily AI credits"; + if (options.maxAICreditsExceeded) return "exceeded max AI credits"; + if (options.aiCreditsRateLimitError) return "hit AI credits rate limit"; + if (options.hasEngineRateLimit429) return "hit engine rate limit (HTTP 429)"; // Missing model pricing is surfaced by the proxy as HTTP 400, so prefer the // specialized title before falling back to the generic transport-level error. if (options.missingModelPricingError) { const modelSuffix = options.missingModelPricingModelName ? ` (${options.missingModelPricingModelName})` : ""; - return `[aw] ${workflowName} has no AI credits pricing for model${modelSuffix}`; + return `has no AI credits pricing for model${modelSuffix}`; } - if (options.hasModelEndpointMismatch) return `[aw] ${workflowName} has model/endpoint mismatch`; - if (options.modelNotSupportedError) return `[aw] ${workflowName} has an unavailable configured model`; + if (options.hasModelEndpointMismatch) return "has model/endpoint mismatch"; + if (options.modelNotSupportedError) return "has an unavailable configured model"; // Keep HTTP 400 below AI-credits signals: quota/rate-limit indicates an account-level // budget state that should take precedence when both classes are detected. - if (options.http400ResponseError) return `[aw] ${workflowName} hit HTTP 400 bad request`; + if (options.http400ResponseError) return "hit HTTP 400 bad request"; // Unknown model pricing is a configuration error that may also trigger a timeout; // report it explicitly so the title is not misleadingly "timed out". - if (options.unknownModelAICredits) return `[aw] ${workflowName} has unknown model pricing`; - if (options.hasAppTokenMintingFailed) return `[aw] ${workflowName} failed to mint GitHub App token`; - if (options.hasLockdownCheckFailed) return `[aw] ${workflowName} failed lockdown check`; - if (options.hasOAuthTokenCheckFailed) return `[aw] ${workflowName} has OAuth token misconfiguration`; - if (options.hasStaleLockFileFailed) return `[aw] ${workflowName} has stale lock file`; - if (options.shellExpansionGuardRejected) return `[aw] ${workflowName} hit shell expansion guard rejection`; - if (options.copilotOrgBillingError) return `[aw] ${workflowName} hit Copilot organization billing error`; + if (options.unknownModelAICredits) return "has unknown model pricing"; + if (options.hasAppTokenMintingFailed) return "failed to mint GitHub App token"; + if (options.hasLockdownCheckFailed) return "failed lockdown check"; + if (options.hasOAuthTokenCheckFailed) return "has OAuth token misconfiguration"; + if (options.hasStaleLockFileFailed) return "has stale lock file"; + if (options.shellExpansionGuardRejected) return "hit shell expansion guard rejection"; + if (options.copilotOrgBillingError) return "hit Copilot organization billing error"; if (options.copilotAgentNotFound) { - const agentName = sanitizeContent(options.copilotAgentNotFound, COPILOT_AGENT_NOT_FOUND_AGENT_MAX_LENGTH).replace(/\s+/g, " ").trim(); - return `[aw] ${workflowName} could not find configured Copilot agent "${agentName}"`; + const agentName = Array.from(normalizeReportTitle(options.copilotAgentNotFound)).slice(0, COPILOT_AGENT_NOT_FOUND_AGENT_MAX_LENGTH).join(""); + return `could not find configured Copilot agent "${agentName}"`; } - if (options.transportWedge) return `[aw] ${workflowName} stalled on an MCP tool call`; - if (options.isTimedOut) return `[aw] ${workflowName} timed out`; - if (options.hasToolDenialsExceeded) return `[aw] ${workflowName} exceeded tool denial limit`; - if (options.hasCacheMissMisconfiguration) return `[aw] ${workflowName} has cache-memory miss misconfiguration`; + if (options.transportWedge) return "stalled on an MCP tool call"; + if (options.isTimedOut) return "timed out"; + if (options.hasToolDenialsExceeded) return "exceeded tool denial limit"; + if (options.hasCacheMissMisconfiguration) return "has cache-memory miss misconfiguration"; const terminalOutputFailureTitle = typeof options.terminalOutputFailureCause === "string" && Object.hasOwn(EMPTY_OUTPUT_FAILURE_CAUSES, options.terminalOutputFailureCause) ? EMPTY_OUTPUT_FAILURE_CAUSES[options.terminalOutputFailureCause] : ""; - if (terminalOutputFailureTitle) return `[aw] ${workflowName} ${terminalOutputFailureTitle}`; + if (terminalOutputFailureTitle) return options.terminalOutputFailureCause === "unknown" ? "failed (cause unknown)" : terminalOutputFailureTitle; const emptyOutputCauseTitle = typeof options.emptyOutputCause === "string" && Object.prototype.hasOwnProperty.call(EMPTY_OUTPUT_CAUSES, options.emptyOutputCause) ? EMPTY_OUTPUT_CAUSES[options.emptyOutputCause] : ""; - if (emptyOutputCauseTitle) return `[aw] ${workflowName} ${emptyOutputCauseTitle}`; - if (options.hasReportIncomplete) return `[aw] ${workflowName} reported incomplete result`; - if (options.hasMissingSafeOutputs) return `[aw] ${workflowName} produced no safe outputs`; - if (options.hasMissingTool) return `[aw] ${workflowName} is missing required tool`; - if (options.hasMissingData) return `[aw] ${workflowName} is missing required data`; - if (options.hasAssignmentErrors) return `[aw] ${workflowName} failed to assign agent`; - return `[aw] ${workflowName} failed`; + if (emptyOutputCauseTitle) return emptyOutputCauseTitle; + if (options.hasReportIncomplete) return "reported incomplete result"; + if (options.hasMissingSafeOutputs) return "produced no safe outputs"; + if (options.hasMissingTool) return "is missing required tools"; + if (options.hasMissingData) return "is missing required data"; + if (options.hasAssignmentErrors) return "failed to assign agent"; + return "failed"; } /** @@ -517,17 +525,28 @@ function buildFailureIssueTitle(options) { * @returns {string} HTML comment marker */ function generateFailureMatchMarker(options) { + return ``; +} + +/** @param {Parameters[0]} options */ +function buildFailureMatchMetadata(options) { const { workflowId, branch, pullRequestNumber, failureCategories } = options; - const parts = ["gh-aw-failure-issue: true", `workflow_id: ${workflowId}`, `branch: ${branch || ""}`, `failure_categories: ${failureCategories.join("|")}`]; + const parts = [ + { key: "workflow_id", value: workflowId }, + { key: "branch", value: branch || "" }, + { key: "failure_categories", value: failureCategories.join("|") }, + ]; if (typeof options.failureCause === "string" && Object.hasOwn(EMPTY_OUTPUT_FAILURE_CAUSES, options.failureCause)) { - parts.push(`failure_cause: ${options.failureCause}`); + parts.push({ key: "failure_cause", value: options.failureCause }); } if (pullRequestNumber) { - parts.push(`pull_request: ${pullRequestNumber}`); + parts.push({ key: "pull_request", value: String(pullRequestNumber) }); } - return ``; + return parts; } /** @@ -1903,7 +1922,7 @@ function buildFailureDiagnosticsContext({ failureCategories, failingStep, agentC } else if (checkoutError) { context += "Runtime checkout validation above is the failure cause.\n\n"; } else { - context += "The runtime did not record a specific cause. Check the linked run for failure details.\n\n"; + context += "No additional engine error was captured. Check the reported failure signals and linked run for details.\n\n"; } if (attributionUnavailable) { context += `
\nWhy step details are unavailable\n\n${attributionUnavailable}\n\n
\n\n`; @@ -4385,8 +4404,7 @@ async function main() { }); const actionFailureIssueExpiresHours = getActionFailureIssueExpiresHours(); - // Sanitize workflow name for title - const sanitizedWorkflowName = sanitizeContent(workflowName, { maxLength: 100 }); + const normalizedWorkflowName = Array.from(normalizeReportTitle(workflowName)).slice(0, 100).join(""); let transportWedge = false; if (agentConclusion === "failure") { try { @@ -4412,7 +4430,7 @@ async function main() { ); const hasReportedIncomplete = hasReportIncomplete && reportItems.some(item => item?.type === "report_incomplete"); const issueTitle = buildFailureIssueTitle({ - workflowName: sanitizedWorkflowName, + workflowName: normalizedWorkflowName, hasModelEndpointMismatch, modelNotSupportedError, transportWedge, @@ -4980,11 +4998,13 @@ async function main() { // Build optimize token consumption context (shown when a guardrail was the failure root cause) const optimizeTokenConsumptionContext = buildOptimizeTokenConsumptionContext({ maxAICreditsExceeded, hasDailyAICExceeded, hasToolDenialsExceeded, isTimedOut, runUrl }); - // Create template context with sanitized workflow name const templateContext = { - workflow_name: sanitizedWorkflowName, + workflow_name: workflowName, + failure_summary: issueTitle.slice(`[aw] ${normalizedWorkflowName}: `.length), workflow_id: workflowID, run_url: runUrl, + run_id: extractRunId(runUrl) || "logs", + history_url: historyUrl || "", workflow_source_url: workflowSourceURL || "#", branch: currentBranch, pull_request_info: pullRequest ? ` \n**Pull Request:** [#${pullRequest.number}](${pullRequest.html_url})` : "", @@ -5032,10 +5052,8 @@ async function main() { optimize_token_consumption_context: optimizeTokenConsumptionContext, }; - // Render the issue template - const issueBodyContent = renderTemplate(issueTemplate, templateContext); + const report = buildFailureReport(issueTemplate, issueTitle, templateContext); - // Generate footer for the issue using templated message const ctx = { workflowName, runUrl, @@ -5045,28 +5063,20 @@ async function main() { aiCredits, }; core.info(`Generating failure issue footer with aiCredits context: ${aiCredits || "(none)"}`); - const footer = getFooterAgentFailureIssueMessage(ctx); - const failureMatchMarker = generateFailureMatchMarker({ - workflowId: workflowID, - branch: currentBranch, - pullRequestNumber: pullRequest?.number, - failureCategories, - failureCause: terminalOutputFailureCause, - }); - - // Add expiration marker inside the quoted footer section using helper - const footerWithExpires = generateFooterWithExpiration({ - footerText: footer, + report.footer = buildFailureReportFooter(ctx, { expiresHours: actionFailureIssueExpiresHours, - suffix: `\n\n${generateXMLMarker(workflowName, runUrl)}\n${failureMatchMarker}`, + failureFields: buildFailureMatchMetadata({ + workflowId: workflowID, + branch: currentBranch, + pullRequestNumber: pullRequest?.number, + failureCategories, + failureCause: terminalOutputFailureCause, + }), }); - // Prepend detection caution alert (when present) so it appears first in the issue body const detectionCaution = getDetectionCautionAlert(workflowName, runUrl); - - // Combine issue body with footer - const bodyLines = detectionCaution ? [detectionCaution, "", issueBodyContent, "", footerWithExpires] : [issueBodyContent, "", footerWithExpires]; - const issueBody = bodyLines.join("\n"); + report.preamble = parseReportMarkdown(detectionCaution, { allowFlagMarkers: true }); + const { title: reportTitle, body: issueBody } = renderReport(report); let newIssue; if (failureIssue) { @@ -5075,7 +5085,7 @@ async function main() { owner, repo, issue_number: failureIssue.number, - title: issueTitle, + title: reportTitle, body: issueBody, labels, state: "open", @@ -5086,7 +5096,7 @@ async function main() { newIssue = await github.rest.issues.create({ owner, repo, - title: issueTitle, + title: reportTitle, body: issueBody, labels: ["agentic-workflows"], headers: { "X-GitHub-Api-Version": GITHUB_API_VERSION }, @@ -5141,6 +5151,7 @@ module.exports = { buildTimeoutContext, shouldBuildEngineFailureContext, isReusableFailureIssue, + parseHTMLCommentMetadata, isIssueWritePermissionError, setFailureIssueOutputs, buildAssignCopilotFailureContext, diff --git a/actions/setup/js/handle_agent_failure.test.cjs b/actions/setup/js/handle_agent_failure.test.cjs index eafce4f2499..0ef8a58f3f1 100644 --- a/actions/setup/js/handle_agent_failure.test.cjs +++ b/actions/setup/js/handle_agent_failure.test.cjs @@ -472,7 +472,7 @@ describe("handle_agent_failure", () => { expect(rendered).toContain("### Runtime Checkout Rejected"); expect(rendered).toContain("org/b is not permitted by allowed-repos"); expect(rendered).toContain("Update the explicit repository list"); - expect(rendered).not.toContain("The runtime did not record a specific cause."); + expect(rendered).not.toContain("No additional engine error was captured."); }); it("includes the last failed agent step when available", async () => { @@ -579,7 +579,7 @@ describe("handle_agent_failure", () => { expect(rendered).toContain("Failing step:** `Run agent`"); expect(rendered).not.toContain("agent-generated root cause"); expect(rendered).not.toContain("log excerpt"); - expect(rendered).toContain("The runtime did not record a specific cause."); + expect(rendered).toContain("No additional engine error was captured."); }); it("includes the first failing startup step for a recorded not_started agent", async () => { @@ -687,7 +687,7 @@ describe("handle_agent_failure", () => { expect(result).toContain("Failing step:** `Run agent`"); expect(result).toContain("**Driver exit code:** `1`"); - expect(result).not.toContain("The runtime did not record a specific cause."); + expect(result).not.toContain("No additional engine error was captured."); }); it("states when no cause was captured", () => { @@ -697,7 +697,7 @@ describe("handle_agent_failure", () => { engineFailureContext: "", }); - expect(result).toContain("The runtime did not record a specific cause."); + expect(result).toContain("No additional engine error was captured."); }); it("keeps the job, step, and exit code together in one compact section", () => { @@ -781,7 +781,7 @@ describe("handle_agent_failure", () => { try { await main(); - expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[aw] unknown hit AI credits rate limit" })); + expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[aw] unknown: hit AI credits rate limit" })); } finally { fs.rmSync(path.dirname(agentOutputPath), { recursive: true, force: true }); delete process.env.GH_AW_AGENT_OUTPUT; @@ -819,26 +819,26 @@ describe("handle_agent_failure", () => { }; const cases = [ - { flag: "hasDailyAICExceeded", expected: "[aw] Test Workflow exceeded daily AI credits budget" }, - { flag: "hasDailyAICGuardrailError", expected: "[aw] Test Workflow could not verify daily AI credits" }, - { flag: "maxAICreditsExceeded", expected: "[aw] Test Workflow exceeded max AI credits" }, - { flag: "aiCreditsRateLimitError", expected: "[aw] Test Workflow hit AI credits rate limit" }, - { flag: "hasEngineRateLimit429", expected: "[aw] Test Workflow hit engine rate limit (HTTP 429)" }, - { flag: "http400ResponseError", expected: "[aw] Test Workflow hit HTTP 400 bad request" }, - { flag: "unknownModelAICredits", expected: "[aw] Test Workflow has unknown model pricing" }, - { flag: "hasAppTokenMintingFailed", expected: "[aw] Test Workflow failed to mint GitHub App token" }, - { flag: "hasLockdownCheckFailed", expected: "[aw] Test Workflow failed lockdown check" }, - { flag: "hasStaleLockFileFailed", expected: "[aw] Test Workflow has stale lock file" }, - { flag: "shellExpansionGuardRejected", expected: "[aw] Test Workflow hit shell expansion guard rejection" }, - { flag: "isTimedOut", expected: "[aw] Test Workflow timed out" }, - { flag: "hasToolDenialsExceeded", expected: "[aw] Test Workflow exceeded tool denial limit" }, - { flag: "hasCacheMissMisconfiguration", expected: "[aw] Test Workflow has cache-memory miss misconfiguration" }, - { flag: "hasReportIncomplete", expected: "[aw] Test Workflow reported incomplete result" }, - { flag: "hasMissingSafeOutputs", expected: "[aw] Test Workflow produced no safe outputs" }, - { flag: "hasMissingTool", expected: "[aw] Test Workflow is missing required tool" }, - { flag: "hasMissingData", expected: "[aw] Test Workflow is missing required data" }, - { flag: "hasAssignmentErrors", expected: "[aw] Test Workflow failed to assign agent" }, - { flag: "copilotOrgBillingError", expected: "[aw] Test Workflow hit Copilot organization billing error" }, + { flag: "hasDailyAICExceeded", expected: "[aw] Test Workflow: exceeded daily AI credits budget" }, + { flag: "hasDailyAICGuardrailError", expected: "[aw] Test Workflow: could not verify daily AI credits" }, + { flag: "maxAICreditsExceeded", expected: "[aw] Test Workflow: exceeded max AI credits" }, + { flag: "aiCreditsRateLimitError", expected: "[aw] Test Workflow: hit AI credits rate limit" }, + { flag: "hasEngineRateLimit429", expected: "[aw] Test Workflow: hit engine rate limit (HTTP 429)" }, + { flag: "http400ResponseError", expected: "[aw] Test Workflow: hit HTTP 400 bad request" }, + { flag: "unknownModelAICredits", expected: "[aw] Test Workflow: has unknown model pricing" }, + { flag: "hasAppTokenMintingFailed", expected: "[aw] Test Workflow: failed to mint GitHub App token" }, + { flag: "hasLockdownCheckFailed", expected: "[aw] Test Workflow: failed lockdown check" }, + { flag: "hasStaleLockFileFailed", expected: "[aw] Test Workflow: has stale lock file" }, + { flag: "shellExpansionGuardRejected", expected: "[aw] Test Workflow: hit shell expansion guard rejection" }, + { flag: "isTimedOut", expected: "[aw] Test Workflow: timed out" }, + { flag: "hasToolDenialsExceeded", expected: "[aw] Test Workflow: exceeded tool denial limit" }, + { flag: "hasCacheMissMisconfiguration", expected: "[aw] Test Workflow: has cache-memory miss misconfiguration" }, + { flag: "hasReportIncomplete", expected: "[aw] Test Workflow: reported incomplete result" }, + { flag: "hasMissingSafeOutputs", expected: "[aw] Test Workflow: produced no safe outputs" }, + { flag: "hasMissingTool", expected: "[aw] Test Workflow: is missing required tools" }, + { flag: "hasMissingData", expected: "[aw] Test Workflow: is missing required data" }, + { flag: "hasAssignmentErrors", expected: "[aw] Test Workflow: failed to assign agent" }, + { flag: "copilotOrgBillingError", expected: "[aw] Test Workflow: hit Copilot organization billing error" }, ]; it.each(cases)("returns expected title for isolated $flag", ({ flag, expected }) => { @@ -846,36 +846,36 @@ describe("handle_agent_failure", () => { }); it("falls back to generic failed title when no specific condition matches", () => { - expect(buildFailureIssueTitle(baseOptions)).toBe("[aw] Test Workflow failed"); - expect(buildFailureIssueTitle({ ...baseOptions, transportWedge: true })).toBe("[aw] Test Workflow stalled on an MCP tool call"); + expect(buildFailureIssueTitle(baseOptions)).toBe("[aw] Test Workflow: failed"); + expect(buildFailureIssueTitle({ ...baseOptions, transportWedge: true })).toBe("[aw] Test Workflow: stalled on an MCP tool call"); }); it.each([ - ["agent_startup_failure", "[aw] Test Workflow failed before agent execution started"], - ["engine_driver_failure", "[aw] Test Workflow engine driver failed before emitting a terminal safe output"], - ["safeoutputs_cli_error", "[aw] Test Workflow failed to invoke safeoutputs CLI"], - ["missing_terminal_safe_output", "[aw] Test Workflow finished without a terminal safe output"], - ["invalid_safe_outputs", "[aw] Test Workflow produced no valid safe outputs"], + ["agent_startup_failure", "[aw] Test Workflow: failed before agent execution started"], + ["engine_driver_failure", "[aw] Test Workflow: engine driver failed before emitting a terminal safe output"], + ["safeoutputs_cli_error", "[aw] Test Workflow: failed to invoke safeoutputs CLI"], + ["missing_terminal_safe_output", "[aw] Test Workflow: finished without a terminal safe output"], + ["invalid_safe_outputs", "[aw] Test Workflow: produced no valid safe outputs"], ])("distinguishes %s from generic incompletion", (emptyOutputCause, title) => { expect(buildFailureIssueTitle({ ...baseOptions, hasReportIncomplete: true, emptyOutputCause })).toBe(title); }); it.each([ - ["agent_startup_failure", "[aw] Test Workflow failed before agent execution started"], - ["engine_outage", "[aw] Test Workflow experienced an engine outage"], - ["request_rejection", "[aw] Test Workflow had a request rejected"], - ["prompt_exhaustion", "[aw] Test Workflow reported incomplete result"], - ["missing_terminal_safe_output", "[aw] Test Workflow finished without a terminal safe output"], - ["unknown", "[aw] Test Workflow finished without a clear failure cause"], + ["agent_startup_failure", "[aw] Test Workflow: failed before agent execution started"], + ["engine_outage", "[aw] Test Workflow: experienced an engine outage"], + ["request_rejection", "[aw] Test Workflow: had a request rejected"], + ["prompt_exhaustion", "[aw] Test Workflow: reported incomplete result"], + ["missing_terminal_safe_output", "[aw] Test Workflow: finished without a terminal safe output"], + ["unknown", "[aw] Test Workflow: failed (cause unknown)"], ])("uses the classified terminal-output cause %s in the issue title", (terminalOutputFailureCause, title) => { expect(buildFailureIssueTitle({ ...baseOptions, terminalOutputFailureCause })).toBe(title); }); it.each([ - [1, [], [], "[aw] Test Workflow finished without a clear failure cause"], - [137, [], [], "[aw] Test Workflow finished without a clear failure cause"], - [139, [], [], "[aw] Test Workflow finished without a clear failure cause"], - [1, ["capi_server_error"], [502], "[aw] Test Workflow experienced an engine outage"], + [1, [], [], "[aw] Test Workflow: failed (cause unknown)"], + [137, [], [], "[aw] Test Workflow: failed (cause unknown)"], + [139, [], [], "[aw] Test Workflow: failed (cause unknown)"], + [1, ["capi_server_error"], [502], "[aw] Test Workflow: experienced an engine outage"], ])("uses runtime evidence rather than a bare driver exit %s to choose an outage title", (exitCode, categories, errorCodes, title) => { const { buildEmptyOutputOutcome } = require("./empty_output_outcome.cjs"); const { tmpdir } = require("os"); @@ -898,43 +898,43 @@ describe("handle_agent_failure", () => { emptyOutputCause: "missing_terminal_safe_output", terminalOutputFailureCause: "prompt_exhaustion", }) - ).toBe("[aw] Test Workflow reported incomplete result"); + ).toBe("[aw] Test Workflow: reported incomplete result"); }); it("prefers unknownModelAICredits over isTimedOut when both are true", () => { - expect(buildFailureIssueTitle({ ...baseOptions, unknownModelAICredits: true, isTimedOut: true })).toBe("[aw] Test Workflow has unknown model pricing"); + expect(buildFailureIssueTitle({ ...baseOptions, unknownModelAICredits: true, isTimedOut: true })).toBe("[aw] Test Workflow: has unknown model pricing"); }); it("prefers shellExpansionGuardRejected over isTimedOut when both are true", () => { - expect(buildFailureIssueTitle({ ...baseOptions, shellExpansionGuardRejected: true, isTimedOut: true })).toBe("[aw] Test Workflow hit shell expansion guard rejection"); + expect(buildFailureIssueTitle({ ...baseOptions, shellExpansionGuardRejected: true, isTimedOut: true })).toBe("[aw] Test Workflow: hit shell expansion guard rejection"); }); it("returns missing model pricing title with model name when missingModelPricingError is true", () => { - expect(buildFailureIssueTitle({ ...baseOptions, missingModelPricingError: true, missingModelPricingModelName: "claude-opus-5" })).toBe("[aw] Test Workflow has no AI credits pricing for model (claude-opus-5)"); + expect(buildFailureIssueTitle({ ...baseOptions, missingModelPricingError: true, missingModelPricingModelName: "claude-opus-5" })).toBe("[aw] Test Workflow: has no AI credits pricing for model (claude-opus-5)"); }); it("returns missing model pricing title without model name when model name is empty", () => { - expect(buildFailureIssueTitle({ ...baseOptions, missingModelPricingError: true, missingModelPricingModelName: "" })).toBe("[aw] Test Workflow has no AI credits pricing for model"); + expect(buildFailureIssueTitle({ ...baseOptions, missingModelPricingError: true, missingModelPricingModelName: "" })).toBe("[aw] Test Workflow: has no AI credits pricing for model"); }); it("sanitizes Copilot agent-not-found title content", () => { - expect(buildFailureIssueTitle({ ...baseOptions, copilotAgentNotFound: "agent` @octo\npayload" })).toBe('[aw] Test Workflow could not find configured Copilot agent "agent` ``@octo`` payload"'); + expect(buildFailureIssueTitle({ ...baseOptions, copilotAgentNotFound: "agent` @octo\npayload" })).toBe('[aw] Test Workflow: could not find configured Copilot agent "agent` @octo payload"'); }); it("prefers missingModelPricingError over unknownModelAICredits when both are true", () => { expect(buildFailureIssueTitle({ ...baseOptions, missingModelPricingError: true, missingModelPricingModelName: "claude-opus-5", unknownModelAICredits: true })).toBe( - "[aw] Test Workflow has no AI credits pricing for model (claude-opus-5)" + "[aw] Test Workflow: has no AI credits pricing for model (claude-opus-5)" ); }); it("prefers missingModelPricingError over http400ResponseError when both are true", () => { expect(buildFailureIssueTitle({ ...baseOptions, missingModelPricingError: true, missingModelPricingModelName: "claude-opus-5", http400ResponseError: true })).toBe( - "[aw] Test Workflow has no AI credits pricing for model (claude-opus-5)" + "[aw] Test Workflow: has no AI credits pricing for model (claude-opus-5)" ); }); it("aiCreditsRateLimitError takes precedence over hasEngineRateLimit429 when both are true", () => { - expect(buildFailureIssueTitle({ ...baseOptions, aiCreditsRateLimitError: true, hasEngineRateLimit429: true })).toBe("[aw] Test Workflow hit AI credits rate limit"); + expect(buildFailureIssueTitle({ ...baseOptions, aiCreditsRateLimitError: true, hasEngineRateLimit429: true })).toBe("[aw] Test Workflow: hit AI credits rate limit"); }); }); @@ -1784,7 +1784,7 @@ describe("handle_agent_failure", () => { expect(updateIssueMock).toHaveBeenCalledWith( expect.objectContaining({ issue_number: 42, - title: "[aw] Test Workflow produced no safe outputs", + title: "[aw] Test Workflow: produced no safe outputs", labels: ["agentic-workflows"], state: "open", }) @@ -1932,7 +1932,7 @@ describe("handle_agent_failure", () => { expect(body.match(/\*\*Driver exit code:\*\*/g)).toHaveLength(1); expect(body.match(/### Failure Diagnostics/g)).toHaveLength(1); expect(body).toContain("Completion signal: agent reported incomplete (reason and details excluded)"); - expect(body).toContain("_Only runtime metadata is shown; agent-generated text is excluded._"); + expect(body).toContain(`${existingIssue ? "_" : "*"}Only runtime metadata is shown; agent-generated text is excluded.${existingIssue ? "_" : "*"}`); expect(body).toContain("The workflow recorded a `report_incomplete` signal"); expect(body).not.toContain("agent-generated diagnostic"); expect(body).not.toContain("infrastructure_error"); @@ -1962,7 +1962,7 @@ describe("handle_agent_failure", () => { items: [ { number: 42, - title: "[aw] Test Workflow failed", + title: "[aw] Test Workflow: failed", html_url: "https://github.com/owner/repo/issues/42", body: buildWorkflowMarkerOnlyIssueBody(), }, @@ -2161,7 +2161,7 @@ describe("handle_agent_failure", () => { items: [ { number: 42, - title: "[aw] Test Workflow failed", + title: "[aw] Test Workflow: failed", html_url: "https://github.com/owner/repo/issues/42", body: buildExistingIssueBody({ workflowId, @@ -2216,13 +2216,13 @@ describe("handle_agent_failure", () => { items: [ { number: 42, - title: "[aw] Test Workflow failed", + title: "[aw] Test Workflow: failed", html_url: "https://github.com/owner/repo/issues/42", body: buildExistingIssueBody({ branch: "feature/other", categories: ["missing_safe_outputs"] }), }, { number: 43, - title: "[aw] Test Workflow failed", + title: "[aw] Test Workflow: failed", html_url: "https://github.com/owner/repo/issues/43", body: buildExistingIssueBody({ branch: "feature/current", categories: ["agent_failure"] }), }, @@ -2369,7 +2369,7 @@ describe("handle_agent_failure", () => { expect(createIssueMock).toHaveBeenCalledOnce(); const createCall = createIssueMock.mock.calls[0][0]; - expect(createCall.title).toBe("[aw] Test Workflow timed out"); + expect(createCall.title).toBe("[aw] Test Workflow: timed out"); }); it("uses shell expansion guard title instead of timeout when both flags are present", async () => { @@ -2406,7 +2406,7 @@ describe("handle_agent_failure", () => { expect(createIssueMock).toHaveBeenCalledOnce(); const createCall = createIssueMock.mock.calls[0][0]; - expect(createCall.title).toBe("[aw] Test Workflow hit shell expansion guard rejection"); + expect(createCall.title).toBe("[aw] Test Workflow: hit shell expansion guard rejection"); expect(createCall.body).toContain("SHELL GUARD TEMPLATE"); expect(createCall.body).not.toContain("TIMEOUT TEMPLATE"); }); @@ -2439,7 +2439,7 @@ describe("handle_agent_failure", () => { expect(createIssueMock).toHaveBeenCalledOnce(); const createCall = createIssueMock.mock.calls[0][0]; - expect(createCall.title).toBe("[aw] Test Workflow produced no safe outputs"); + expect(createCall.title).toBe("[aw] Test Workflow: produced no safe outputs"); }); it("uses a precise report incomplete title", async () => { @@ -2472,7 +2472,7 @@ describe("handle_agent_failure", () => { expect(createIssueMock).toHaveBeenCalledOnce(); const createCall = createIssueMock.mock.calls[0][0]; - expect(createCall.title).toBe("[aw] Test Workflow reported incomplete result"); + expect(createCall.title).toBe("[aw] Test Workflow: reported incomplete result"); }); it.each([ @@ -2517,13 +2517,13 @@ describe("handle_agent_failure", () => { expect(createIssueMock).toHaveBeenCalledOnce(); const { title, body } = createIssueMock.mock.calls[0][0]; if (budgetExceeded) { - expect(title).toBe("[aw] Test Workflow exceeded max AI credits"); + expect(title).toBe("[aw] Test Workflow: exceeded max AI credits"); expect(body).toContain("AI Credits Budget Exceeded"); expect(body).toContain("failure_categories: max_ai_credits_exceeded"); expect(body).not.toContain("Copilot organization billing is unavailable"); expect(body).not.toContain("copilot_org_billing_error"); } else { - expect(title).toBe("[aw] Test Workflow hit Copilot organization billing error"); + expect(title).toBe("[aw] Test Workflow: hit Copilot organization billing error"); expect(body).toContain("Copilot organization billing is unavailable"); expect(body).not.toContain("max_ai_credits_exceeded"); } @@ -2610,7 +2610,7 @@ describe("handle_agent_failure", () => { items: [ { number: 42, - title: "[aw] Test Workflow failed", + title: "[aw] Test Workflow: failed", html_url: "https://github.com/owner/repo/issues/42", body: buildExistingIssueBody({ branch: "feature/current", @@ -2941,8 +2941,11 @@ describe("handle_agent_failure", () => { const commentTemplate = fs.readFileSync(path.join(__dirname, "../md/agent_failure_comment.md"), "utf8"); const issueTemplate = fs.readFileSync(path.join(__dirname, "../md/agent_failure_issue.md"), "utf8"); - for (const template of [commentTemplate, issueTemplate]) { - const rendered = renderTemplate(template, templateContext); + const { buildFailureReport } = require("./failure_report.cjs"); + const { renderReport } = require("./report_ast.cjs"); + const issueContext = Object.fromEntries(Array.from(issueTemplate.matchAll(/\{(\w+)\}/g), match => [match[1], ""])); + const issueReport = buildFailureReport(issueTemplate, "[aw] Test Workflow: reported incomplete result", { ...issueContext, ...templateContext }); + for (const rendered of [renderTemplate(commentTemplate, templateContext), renderReport(issueReport).body]) { expect(rendered).not.toContain(reportIncompleteMarker); expect(rendered).toContain("The workflow recorded a `report_incomplete` signal"); expect(rendered).toContain("completion could not be confirmed"); @@ -6893,7 +6896,7 @@ describe("handle_agent_failure", () => { }; const { main: mainFn } = require("./handle_agent_failure.cjs"); await mainFn(); - expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: `[aw] Test Workflow ${titleSuffix}` })); + expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: `[aw] Test Workflow: ${titleSuffix}` })); const categories = JSON.parse(fs.readFileSync("/tmp/gh-aw/failure_categories.json", "utf8")); expect(categories).toContain(reason); expect(categories).not.toContain("agent_failure"); @@ -6971,7 +6974,7 @@ describe("handle_agent_failure", () => { }; const { main: mainFn } = require("./handle_agent_failure.cjs"); await mainFn(); - expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[aw] Test Workflow reported incomplete result" })); + expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ title: "[aw] Test Workflow: reported incomplete result" })); const categories = JSON.parse(fs.readFileSync("/tmp/gh-aw/failure_categories.json", "utf8")); expect(categories).toContain("report_incomplete"); expect(categories).not.toContain(reason); @@ -7011,7 +7014,7 @@ describe("handle_agent_failure", () => { }; const { main: mainFn } = require("./handle_agent_failure.cjs"); await mainFn(); - expect(create).toHaveBeenCalledWith(expect.objectContaining({ title: "[aw] Test Workflow reported incomplete result" })); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ title: "[aw] Test Workflow: reported incomplete result" })); const body = create.mock.calls[0][0].body; expect(body).toContain("Failing step:** `Post Run agent`"); expect(body).not.toContain("Failing step:** `Start MCP Gateway`"); diff --git a/actions/setup/js/messages_footer.cjs b/actions/setup/js/messages_footer.cjs index 4e999c251de..5177e72fbe9 100644 --- a/actions/setup/js/messages_footer.cjs +++ b/actions/setup/js/messages_footer.cjs @@ -422,6 +422,16 @@ function getFooterWorkflowRecompileCommentMessage(ctx) { * @returns {string} Footer message for agent failure tracking issues */ function getFooterAgentFailureIssueMessage(ctx) { + const { template, context } = getFooterAgentFailureIssueTemplate(ctx); + return renderTemplate(template, context); +} + +/** + * Expose the template separately so structured renderers can substitute literal + * metadata after parsing, rather than interpreting metadata as Markdown. + * @param {AgentFailureContext} ctx + */ +function getFooterAgentFailureIssueTemplate(ctx) { const messages = getMessages(); // Pre-compute history_link as a ready-to-use markdown suffix (empty string when unavailable) @@ -483,29 +493,23 @@ function getFooterAgentFailureIssueMessage(ctx) { ambientContext, ambientContextFormatted, ambientContextSuffix, + failureMetricsSuffix: `${aiCredits ? aiCreditsSuffix : ""}${ambientContext ? ambientContextSuffix : ""}`, }); // Use custom agent failure issue footer if configured, otherwise use default footer - let footer; + let template; if (messages?.agentFailureIssue) { - footer = renderTemplate(messages.agentFailureIssue, templateContext); + template = messages.agentFailureIssue; } else { // Default footer template with link to workflow run - let defaultFooter = "> Generated from [{workflow_name}]({run_url})"; - if (aiCredits) { - defaultFooter += aiCreditsSuffix; - } - if (ambientContext) { - defaultFooter += ambientContextSuffix; - } + template = "> Generated from [{workflow_name}]({run_url}){failure_metrics_suffix}"; // Append history link when available if (ctx.historyUrl) { - defaultFooter += " · [◷]({history_url})"; + template += " · [◷]({history_url})"; } - footer = renderTemplate(defaultFooter, templateContext); } - return footer; + return { template, context: templateContext }; } /** @@ -612,6 +616,17 @@ function getFooterAgentFailureCommentMessage(ctx) { * @returns {string} XML comment marker with workflow metadata */ function generateXMLMarker(workflowName, runUrl) { + return ``; +} + +/** + * @param {string} workflowName + * @param {string} runUrl + * @returns {{key: string, value: string}[]} + */ +function getAgenticWorkflowMetadata(workflowName, runUrl) { // Read engine metadata from environment variables const engineId = process.env.GH_AW_ENGINE_ID || ""; const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; @@ -625,49 +640,48 @@ function generateXMLMarker(workflowName, runUrl) { const parts = []; // Always include agentic-workflow name - parts.push(`gh-aw-agentic-workflow: ${workflowName}`); + parts.push({ key: "gh-aw-agentic-workflow", value: workflowName }); // Add tracker-id if available (for searchability and tracing) if (trackerId) { - parts.push(`gh-aw-tracker-id: ${trackerId}`); + parts.push({ key: "gh-aw-tracker-id", value: trackerId }); } // Add engine ID if available if (engineId) { - parts.push(`engine: ${engineId}`); + parts.push({ key: "engine", value: engineId }); } // Add version if available if (engineVersion) { - parts.push(`version: ${engineVersion}`); + parts.push({ key: "version", value: engineVersion }); } // Add model if available if (engineModel) { - parts.push(`model: ${engineModel}`); + parts.push({ key: "model", value: engineModel }); } if (attribution.routing?.status === "selected") { - if (attribution.effort) parts.push(`effort: ${attribution.effort}`); - parts.push("routed: true"); + if (attribution.effort) parts.push({ key: "effort", value: attribution.effort }); + parts.push({ key: "routed", value: "true" }); } else if (attribution.routing) { - parts.push(`routed: ${attribution.routing.status}`); + parts.push({ key: "routed", value: attribution.routing.status }); } // Add numeric run ID if available if (runId) { - parts.push(`id: ${runId}`); + parts.push({ key: "id", value: runId }); } // Add workflow identifier if available if (workflowId) { - parts.push(`workflow_id: ${workflowId}`); + parts.push({ key: "workflow_id", value: workflowId }); } // Always include run URL - parts.push(`run: ${runUrl}`); + parts.push({ key: "run", value: runUrl }); - // Return the XML comment marker - return ``; + return parts; } /** @@ -800,6 +814,8 @@ module.exports = { getFooterWorkflowRecompileMessage, getFooterWorkflowRecompileCommentMessage, getFooterAgentFailureIssueMessage, + getFooterAgentFailureIssueTemplate, + getAgenticWorkflowMetadata, getFooterAgentFailureCommentMessage, generateFooterWithMessages, generateXMLMarker, diff --git a/actions/setup/js/report_ast.cjs b/actions/setup/js/report_ast.cjs new file mode 100644 index 00000000000..f3ecdf8ff2d --- /dev/null +++ b/actions/setup/js/report_ast.cjs @@ -0,0 +1,330 @@ +// @ts-check + +const { hardenUnicodeText } = require("./sanitize_content_core.cjs"); +const { redactDiagnosticText } = require("./diagnostic_sanitization.cjs"); +const { escapeMarkdown } = require("./missing_info_formatter.cjs"); +const { createExpirationLine } = require("./ephemerals.cjs"); + +/** + * @typedef {{type: "text" | "code", value: string} | {type: "strong" | "emphasis", children: ReportInline[]} | {type: "link", url: string, children: ReportInline[]}} ReportInline + * @typedef {{type: "paragraph", children: ReportInline[]} | + * {type: "heading", level: number, children: ReportInline[]} | + * {type: "code_block", value: string, language: string} | + * {type: "list", ordered: boolean, items: ReportBlock[][]} | + * {type: "quote", children: ReportBlock[]} | + * {type: "alert", kind: string, children: ReportBlock[]} | + * {type: "details", summary: string, children: ReportBlock[]} | + * {type: "table", headers: ReportInline[][], rows: ReportInline[][][]} | + * {type: "marker", name: "gh-aw-threat-detected" | "gh-aw-threat-engine-error"}} ReportBlock + * @typedef {{name: "gh-aw-agentic-workflow" | "gh-aw-failure-issue", value: string, fields: {key: string, value: string}[]}} ReportMarker + * @typedef {{type: "footer", children: ReportBlock[], expiresAt?: string, markers: ReportMarker[]}} ReportFooter + * @typedef {{type: "report", title: string, children: ReportBlock[], preamble?: ReportBlock[], footer?: ReportFooter}} Report + */ + +const ALERT_KINDS = new Set(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]); +const MAX_DEPTH = 16; + +/** @param {string} value */ +function normalizeReportText(value) { + if (typeof value !== "string") throw new TypeError("Report text must be a string"); + let current = redactDiagnosticText(value); + for (let pass = 0; pass < 16; pass++) { + const normalized = hardenUnicodeText(current) + .replace(/\r\n?/g, "\n") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); + const redacted = redactDiagnosticText(normalized); + if (redacted === current) return redacted; + current = redacted; + } + throw new TypeError("Report text contains excessively nested encodings"); +} + +/** @param {string} value */ +function normalizeReportTitle(value) { + return Array.from(normalizeReportText(value).replace(/\s+/g, " ").trim()).slice(0, 256).join(""); +} + +/** @param {string} value */ +function normalizeReportURL(value) { + const normalized = normalizeReportText(value); + if (normalized !== value || /[\s\\]/.test(value)) throw new TypeError("Report links must contain a canonical HTTPS URL"); + let url; + try { + url = new URL(value); + } catch (error) { + throw new TypeError("Report link is not a valid HTTPS URL", { cause: error }); + } + if (url.protocol !== "https:" || url.username || url.password) throw new TypeError("Report links must use HTTPS without credentials"); + return url.href; +} + +/** @param {number} depth */ +function checkDepth(depth) { + if (depth > MAX_DEPTH) throw new TypeError("Report nesting exceeds the maximum depth"); +} + +/** + * @param {ReportInline[]} nodes + * @param {number} depth + * @returns {ReportInline[]} + */ +function normalizeInlines(nodes, depth, inLink = false) { + checkDepth(depth); + /** @type {ReportInline[]} */ + const result = []; + for (const node of nodes) { + switch (node.type) { + case "text": + case "code": { + const value = normalizeReportText(node.value).replace(/\s+/g, " "); + if (!value) break; + const previous = result.at(-1); + if ((node.type === "text" || node.type === "code") && previous?.type === node.type) previous.value = (previous.value + value).replace(/\s+/g, " "); + else result.push({ type: node.type, value }); + break; + } + case "strong": + case "emphasis": + case "link": { + if (node.type === "link" && inLink) throw new TypeError("Report links cannot contain other links"); + const children = normalizeInlines(node.children, depth + 1, inLink || node.type === "link"); + if (!children.length) break; + result.push(node.type === "link" ? { type: "link", url: normalizeReportURL(node.url), children } : { type: node.type, children }); + break; + } + default: + throw new TypeError("Unsupported report inline node"); + } + } + return result; +} + +/** + * @param {ReportBlock[]} nodes + * @param {number} depth + * @returns {ReportBlock[]} + */ +function normalizeBlocks(nodes, depth) { + checkDepth(depth); + /** @type {ReportBlock[]} */ + const result = []; + for (const node of nodes) { + switch (node.type) { + case "paragraph": + case "heading": { + const children = normalizeInlines(node.children, depth + 1); + if (!children.length || children.every(child => child.type === "text" && !child.value.trim())) break; + if (node.type === "heading") { + if (!Number.isInteger(node.level)) throw new TypeError("Report heading level must be an integer"); + result.push({ type: "heading", level: Math.max(depth ? 3 : 2, Math.min(6, node.level)), children }); + } else result.push({ type: "paragraph", children }); + break; + } + case "code_block": { + if (!/^[a-zA-Z0-9_+-]*$/.test(node.language)) throw new TypeError("Invalid report code language"); + const value = normalizeReportText(node.value); + if (value.trim()) result.push({ type: "code_block", language: node.language, value }); + break; + } + case "list": { + const items = node.items.map(item => normalizeBlocks(item, depth + 1)).filter(item => item.length); + if (items.length) result.push({ type: "list", ordered: Boolean(node.ordered), items }); + break; + } + case "details": + case "alert": + case "quote": { + const children = normalizeBlocks(node.children, depth + 1); + if (node.type === "alert" && !ALERT_KINDS.has(node.kind)) throw new TypeError("Unsupported report alert kind"); + if (!children.length) break; + if (node.type === "details") { + const summary = normalizeReportText(node.summary).replace(/\s+/g, " ").trim(); + if (!summary) throw new TypeError("Report details must have a summary"); + result.push({ type: "details", summary, children }); + } else if (node.type === "alert") result.push({ type: "alert", kind: node.kind, children }); + else result.push({ type: "quote", children }); + break; + } + case "table": { + if (!node.headers.length || node.rows.some(row => row.length !== node.headers.length)) throw new TypeError("Report table rows must match the header width"); + result.push({ + type: "table", + headers: node.headers.map(cell => normalizeInlines(cell, depth + 1)), + rows: node.rows.map(row => row.map(cell => normalizeInlines(cell, depth + 1))), + }); + break; + } + case "marker": + if (node.name !== "gh-aw-threat-detected" && node.name !== "gh-aw-threat-engine-error") throw new TypeError("Unsupported report flag marker"); + result.push({ type: "marker", name: node.name }); + break; + default: + throw new TypeError("Unsupported report block node"); + } + } + return result; +} + +/** @param {string} value */ +function normalizeMarkerValue(value) { + return normalizeReportText(value).replace(/\s+/g, " ").trim().replace(/--/g, "%2D%2D").replace(//g, "%3E").replace(/,/g, "%2C"); +} + +/** + * @param {ReportFooter} footer + * @returns {ReportFooter} + */ +function normalizeFooter(footer) { + if (footer.type !== "footer") throw new TypeError("Expected a report footer"); + const markers = footer.markers.map(marker => { + if (marker.name !== "gh-aw-agentic-workflow" && marker.name !== "gh-aw-failure-issue") throw new TypeError("Unsupported report metadata marker"); + /** @type {Set} */ + const keys = new Set([marker.name]); + const fields = marker.fields.map(field => { + if (!/^[a-zA-Z0-9_-]+$/.test(field.key) || keys.has(field.key)) throw new TypeError("Invalid or duplicate report metadata field"); + keys.add(field.key); + return { key: field.key, value: normalizeMarkerValue(field.value) }; + }); + return { name: marker.name, value: normalizeMarkerValue(marker.value), fields }; + }); + if (new Set(markers.map(marker => marker.name)).size !== markers.length) throw new TypeError("Duplicate report metadata marker"); + /** @type {ReportFooter} */ + const result = { type: "footer", children: normalizeBlocks(footer.children, 0), markers }; + if (footer.expiresAt !== undefined) { + const date = new Date(footer.expiresAt); + if (Number.isNaN(date.getTime())) throw new TypeError("Invalid report expiration date"); + return { ...result, expiresAt: date.toISOString() }; + } + return result; +} + +/** + * Normalize before rendering; reject raw Markdown/HTML nodes rather than bypassing escaping. + * @param {Report} report + * @returns {Report} + */ +function normalizeReport(report) { + if (report.type !== "report") throw new TypeError("Expected a report document"); + const title = normalizeReportTitle(report.title); + if (!title) throw new TypeError("Report title must not be empty"); + return { + type: "report", + title, + children: normalizeBlocks(report.children, 0), + ...(report.preamble ? { preamble: normalizeBlocks(report.preamble, 0) } : {}), + ...(report.footer ? { footer: normalizeFooter(report.footer) } : {}), + }; +} + +/** @param {string} value */ +function escapeReportText(value) { + return value + .split(/(@[a-zA-Z0-9][a-zA-Z0-9_/-]*)/g) + .map(part => + /^@[a-zA-Z0-9]/.test(part) + ? `\`${part}\`` + : escapeMarkdown(part.replace(/&/g, "&")) + .replace(/\\([()])/g, "$1") + .replace(/[|#!~]/g, "\\$&") + .replace(/^([-+])(?= )/, "\\$1") + .replace(/^(\d+)\.(?= )/, "$1\\.") + ) + .join(""); +} + +/** @param {string} value */ +function codeFence(value) { + return "`".repeat(Array.from(value.matchAll(/`+/g)).reduce((length, match) => Math.max(length, match[0].length + 1), 3)); +} + +/** @param {ReportInline[]} nodes */ +function renderInlines(nodes) { + return nodes + .map(node => { + switch (node.type) { + case "text": + return escapeReportText(node.value); + case "code": { + const fence = "`".repeat(Array.from(node.value.matchAll(/`+/g)).reduce((length, match) => Math.max(length, match[0].length + 1), 1)); + const padding = /^[` ]|[` ]$/.test(node.value) && !/^ +$/.test(node.value) ? " " : ""; + return `${fence}${padding}${node.value}${padding}${fence}`; + } + case "strong": + return `**${renderInlines(node.children)}**`; + case "emphasis": + return `*${renderInlines(node.children)}*`; + case "link": + return `[${renderInlines(node.children)}](${node.url.replace(/[()<>"'`]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)})`; + } + }) + .join(""); +} + +/** @param {ReportBlock[]} nodes */ +function renderBlocks(nodes) { + return nodes + .map(node => { + switch (node.type) { + case "paragraph": + return renderInlines(node.children).trim(); + case "heading": + return `${"#".repeat(node.level)} ${renderInlines(node.children).trim()}`; + case "code_block": { + const fence = codeFence(node.value); + return `${fence}${node.language}\n${node.value}${node.value.endsWith("\n") ? "" : "\n"}${fence}`; + } + case "list": + return node.items + .map((item, index) => { + const marker = node.ordered ? `${index + 1}. ` : "- "; + return marker + renderBlocks(item).replace(/\n/g, "\n" + " ".repeat(marker.length)); + }) + .join("\n"); + case "quote": + case "alert": + return (node.type === "alert" ? `[!${node.kind}]\n` : "") + renderBlocks(node.children); + case "details": + return `
\n${node.summary + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/@[a-zA-Z0-9][a-zA-Z0-9_/-]*/g, mention => `${mention}`)}\n\n${renderBlocks(node.children)}\n\n
`; + case "table": { + const row = cells => `| ${cells.map(cell => renderInlines(cell).replace(/(? "---").join(" | ")} |`, ...node.rows.map(row)].join("\n"); + } + case "marker": + return ``; + } + }) + .map((value, index) => + nodes[index].type === "quote" || nodes[index].type === "alert" + ? value + .split("\n") + .map(line => (line ? `> ${line}` : ">")) + .join("\n") + : value + ) + .join("\n\n"); +} + +/** @param {ReportFooter} footer */ +function renderFooter(footer) { + const attribution = renderBlocks(footer.children); + const expiration = footer.expiresAt ? `> ${createExpirationLine(new Date(footer.expiresAt))}` : ""; + const markers = footer.markers.map(marker => ``).join("\n"); + return [[attribution, expiration].filter(Boolean).join("\n"), markers].filter(Boolean).join("\n\n"); +} + +/** + * @param {Report} report + * @returns {{title: string, body: string}} + */ +function renderReport(report) { + const normalized = normalizeReport(report); + const parts = [normalized.preamble ? renderBlocks(normalized.preamble) : "", renderBlocks(normalized.children), normalized.footer ? renderFooter(normalized.footer) : ""]; + return { title: normalized.title, body: parts.filter(Boolean).join("\n\n").trim() + "\n" }; +} + +module.exports = { normalizeReport, normalizeReportText, normalizeReportTitle, normalizeReportURL, renderReport }; diff --git a/actions/setup/js/report_ast.test.cjs b/actions/setup/js/report_ast.test.cjs new file mode 100644 index 00000000000..10c38aaae3b --- /dev/null +++ b/actions/setup/js/report_ast.test.cjs @@ -0,0 +1,207 @@ +// @ts-check + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { normalizeReport, normalizeReportTitle, renderReport } = require("./report_ast.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); + +/** @param {import("./report_ast.cjs").ReportBlock[]} children */ +const report = children => ({ type: /** @type {const} */ "report", title: " [aw] Smoke:\r\nfailed ", children }); +const paragraph = value => ({ type: /** @type {const} */ "paragraph", children: [{ type: /** @type {const} */ "text", value }] }); + +afterEach(() => vi.unstubAllEnvs()); + +describe("report AST", () => { + it("normalizes deterministically without mutating the input", () => { + const input = report([ + paragraph(""), + { type: "heading", level: 1, children: [{ type: "text", value: "What happened" }] }, + { type: "details", summary: " Runtime\n evidence ", children: [paragraph(" e\u0301vidence\r\nhere ")] }, + { type: "details", summary: "Empty", children: [] }, + ]); + const copy = structuredClone(input); + const normalized = normalizeReport(input); + expect(input).toEqual(copy); + expect(normalizeReport(normalized)).toEqual(normalized); + expect(renderReport(input)).toEqual({ + title: "[aw] Smoke: failed", + body: "## What happened\n\n
\nRuntime evidence\n\n\u00e9vidence here\n\n
\n", + }); + }); + + it("escapes literal metadata and HTML rather than allowing new sections or mentions", () => { + const { body } = renderReport(report([paragraph("# injected\n
**bold** [click](javascript:alert(1)) @octocat & text")])); + expect(body).not.toContain("\n# injected"); + expect(body).not.toContain(""); + expect(body).toContain("\\# injected </details><img src=x>"); + expect(body).toContain("\\*\\*bold\\*\\*"); + expect(body).toContain("\\[click\\]"); + expect(body).toContain("`@octocat` & text"); + }); + + it("chooses fences after normalization and redaction", () => { + vi.stubEnv("GH_AW_SECRET_NAMES", "REPORT_TEST"); + vi.stubEnv("SECRET_REPORT_TEST", "report-secret-fixture"); + const { body } = renderReport( + report([ + { type: "code_block", language: "text", value: "report-secret-fixture\r\n````\n\n# injected" }, + { type: "paragraph", children: [{ type: "code", value: "`edge`" }] }, + ]) + ); + expect(body).not.toContain("report-secret-fixture"); + expect(body).toContain("`````text\n"); + expect(body).toContain("\n````\n\n# injected\n`````"); + expect(body).toContain("`` `edge` ``"); + }); + + it("escapes HTML summaries without Markdown-specific backslashes", () => { + const { body } = renderReport(report([{ type: "details", summary: 'Evidence [1]
& data', children: [paragraph("details")] }])); + expect(body).toContain('Evidence [1] </summary><img src="x"> & data'); + }); + + it("renders only explicit links and encodes Markdown destination delimiters", () => { + const { body } = renderReport(report([{ type: "paragraph", children: [{ type: "link", url: "https://example.com/run(a)?q='value'", children: [{ type: "text", value: "Run [1]" }] }] }])); + expect(body).toBe("[Run \\[1\\]](https://example.com/run%28a%29?q=%27value%27)\n"); + }); + + it.each(["javascript:alert(1)", "data:text/html,test", "file:///tmp/test", "http://example.com", "https://user:password@example.com", "https://example.com/\n#injected", "//example.com"])("rejects unsafe link %s", url => { + expect(() => renderReport(report([{ type: "paragraph", children: [{ type: "link", url, children: [{ type: "text", value: "Run" }] }] }]))).toThrow(); + }); + + it("normalizes lists, alerts, tables, and nested heading levels", () => { + const { body } = renderReport( + report([ + { type: "list", ordered: true, items: [[paragraph("First")], [], [paragraph("Second")]] }, + { type: "alert", kind: "WARNING", children: [paragraph("Failure"), { type: "heading", level: 1, children: [{ type: "text", value: "Evidence" }] }] }, + { type: "table", headers: [[{ type: "text", value: "Tool" }]], rows: [[[{ type: "code", value: "a|b" }]], [[{ type: "text", value: "c|d" }]]] }, + ]) + ); + expect(body).toContain("1. First\n2. Second"); + expect(body).toContain("> [!WARNING]\n> Failure\n>\n> ### Evidence"); + expect(body).toContain("| `a\\|b` |"); + expect(body).toContain("| c\\|d |"); + }); + + it("rejects raw nodes, malformed tables, invalid languages, and excessive nesting", () => { + expect(() => renderReport(report([/** @type {never} */ { type: "raw", value: "" }]))).toThrow("Unsupported report block"); + expect(() => renderReport(report([{ type: "table", headers: [[{ type: "text", value: "One" }]], rows: [[]] }]))).toThrow("header width"); + expect(() => renderReport(report([{ type: "code_block", language: "text\n", value: "payload" }]))).toThrow("code language"); + expect(() => renderReport(report([{ type: "alert", kind: "INVALID", children: [paragraph("data")] }]))).toThrow("alert kind"); + const children = []; + children.push({ type: "details", summary: "Cycle", children }); + expect(() => renderReport(report(children))).toThrow("maximum depth"); + }); + + it("normalizes and bounds titles without Markdown escaping or broken Unicode", () => { + expect(normalizeReportTitle("[aw]\u202e Smoke\r\n[model]:\x1b[31m failed\x1b[0m")).toBe("[aw] Smoke [model]: failed"); + expect(Array.from(normalizeReportTitle("\u{1f680}".repeat(300)))).toHaveLength(256); + }); + + it("stabilizes entity decoding, merged inline whitespace, and secrets exposed by controls", () => { + vi.stubEnv("GH_AW_SECRET_NAMES", "REPORT_TEST"); + vi.stubEnv("SECRET_REPORT_TEST", "report-secret-fixture"); + const input = report([ + { + type: "paragraph", + children: [ + { type: "text", value: "&amp;amp;amp;amp;lt;tag&amp;amp;amp;amp;gt; " }, + { type: "text", value: " report-secret-\u0000fixture" }, + { type: "code", value: "one " }, + { type: "code", value: " two" }, + ], + }, + ]); + const normalized = normalizeReport(input); + expect(normalizeReport(normalized)).toEqual(normalized); + expect(renderReport(input).body).not.toContain("report-secret-fixture"); + expect(renderReport(input).body).toContain("<tag>"); + expect(renderReport(input).body).toContain("`one two`"); + }); + + it("escapes ordered-list syntax in literal paragraphs and refuses nested links", () => { + expect(renderReport(report([paragraph("1. not a list")])).body).toBe("1\\. not a list\n"); + expect(() => + renderReport( + report([ + { + type: "paragraph", + children: [{ type: "link", url: "https://example.com", children: [{ type: "link", url: "https://example.org", children: [{ type: "text", value: "nested" }] }] }], + }, + ]) + ) + ).toThrow("cannot contain other links"); + }); +}); + +describe("report Markdown import boundary", () => { + it("parses template structure before substituting hostile scalar values", () => { + const children = parseReportMarkdown("## What happened\n\n**{workflow}** failed.\n\n**Branch:** `{branch}`\n\n[Run]({url})", { + minimumHeadingLevel: 2, + context: { workflow: "Smoke\n\n## Injected\n[evil](javascript:x)", branch: "`\n", url: "https://example.com/run(test)" }, + }); + const { body } = renderReport(report(children)); + expect(body.match(/^## /gm)).toEqual(["## "]); + expect(body).toContain("Smoke \\#\\# Injected"); + expect(body).toContain("`` ` ``"); + expect(body).toContain("[Run](https://example.com/run%28test%29)"); + }); + + it("preserves nested details, code, alerts, lists, tables, and inline formatting", () => { + const markdown = [ + "
", + "Runtime", + "", + "> [!WARNING]", + "> **Missing tool:** `github.read`", + "", + "### Evidence", + "", + "- First", + " - Nested", + "- Second", + "", + "| Tool | Result |", + "| --- | --- |", + "| `gh` | *missing* |", + "", + "````text", + "
", + "```", + "````", + "", + "", + ].join("\n"); + const { body } = renderReport(report(parseReportMarkdown(markdown))); + expect(body).toContain("> [!WARNING]\n> **Missing tool:** `github.read`"); + expect(body).toContain("- First\n \n - Nested\n- Second"); + expect(body).toContain("| `gh` | *missing* |"); + expect(body).toContain("````text\n\n```\n````"); + expect(body.endsWith("\n\n")).toBe(true); + }); + + it("does not promote diagnostic headings to report sections or permit arbitrary HTML", () => { + const { body } = renderReport(report(parseReportMarkdown("## Injected\n\n\n\n\n\n[unsafe](javascript:alert)\n\n[Run](https://example.com) · [Source](https://example.org)"))); + expect(body).toContain("### Injected"); + expect(body).not.toContain(""); + expect(body).toContain("link unavailable: invalid HTTPS URL"); + expect(body).toContain("[Run](https://example.com/) · [Source](https://example.org/)"); + }); + + it("imports block fields explicitly and refuses missing fields or blocks in scalar positions", () => { + expect(() => parseReportMarkdown("{missing}", { context: {} })).toThrow("Missing report template field"); + expect(() => parseReportMarkdown("{toString}", { context: {} })).toThrow("Missing report template field"); + expect(() => parseReportMarkdown("**{blocks}**", { context: { blocks: [paragraph("data")] } })).toThrow("cannot appear inline"); + const { body } = renderReport(report(parseReportMarkdown("{one}{two}", { context: { one: [paragraph("First")], two: [] } }))); + expect(body).toBe("First\n"); + }); + + it("bounds recursive details and preserves unmatched inline delimiters as literal text", () => { + expect(() => parseReportMarkdown("
\nNested\n".repeat(20) + "payload")).toThrow("maximum depth"); + const { body } = renderReport(report(parseReportMarkdown("An unmatched `` delimiter and a_b_c identifier"))); + expect(body).toContain("An unmatched \\`\\` delimiter and a\\_b\\_c identifier"); + }); +}); diff --git a/actions/setup/js/report_footer.cjs b/actions/setup/js/report_footer.cjs new file mode 100644 index 00000000000..f7de21dd1ee --- /dev/null +++ b/actions/setup/js/report_footer.cjs @@ -0,0 +1,41 @@ +// @ts-check + +const { getFooterAgentFailureIssueTemplate, getAgenticWorkflowMetadata } = require("./messages_footer.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); + +/** + * @param {import("./messages_footer.cjs").AgentFailureContext} ctx + * @param {{expiresHours: number, failureFields: {key: string, value: string}[], now?: Date}} options + * @returns {import("./report_ast.cjs").ReportFooter} + */ +function buildFailureReportFooter(ctx, { expiresHours, failureFields, now = new Date() }) { + if (!Number.isFinite(expiresHours) || expiresHours < 0) throw new TypeError("Report expiration hours must be nonnegative"); + const { template, context: values } = getFooterAgentFailureIssueTemplate(ctx); + /** @type {import("./report_markdown.cjs").ReportContext} */ + const context = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value ?? ""])); + /** @type {import("./report_markdown.cjs").ReportInlineSlot} */ + const history = { + type: "inline_slot", + children: ctx.historyUrl + ? [ + { type: "text", value: " · " }, + { type: "link", url: ctx.historyUrl, children: [{ type: "text", value: "◷" }] }, + ] + : [], + }; + context.history_link = history; + context.historyLink = history; + const [workflow, ...fields] = getAgenticWorkflowMetadata(ctx.workflowName, ctx.runUrl); + /** @type {import("./report_ast.cjs").ReportFooter} */ + const footer = { + type: "footer", + children: parseReportMarkdown(template, { context }), + markers: [ + { name: "gh-aw-agentic-workflow", value: workflow.value, fields }, + { name: "gh-aw-failure-issue", value: "true", fields: failureFields }, + ], + }; + return expiresHours ? { ...footer, expiresAt: new Date(now.getTime() + expiresHours * 60 * 60 * 1000).toISOString() } : footer; +} + +module.exports = { buildFailureReportFooter }; diff --git a/actions/setup/js/report_footer.test.cjs b/actions/setup/js/report_footer.test.cjs new file mode 100644 index 00000000000..6661f9222d3 --- /dev/null +++ b/actions/setup/js/report_footer.test.cjs @@ -0,0 +1,106 @@ +// @ts-check + +import { describe, it, expect, afterEach, vi } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { buildFailureReportFooter } = require("./report_footer.cjs"); +const { normalizeReport, renderReport } = require("./report_ast.cjs"); +const { parseReportMarkdown } = require("./report_markdown.cjs"); +const { extractExpirationDate } = require("./ephemerals.cjs"); +const { parseHTMLCommentMetadata, isReusableFailureIssue } = require("./handle_agent_failure.cjs"); + +const ctx = { + workflowName: "Smoke Copilot", + runUrl: "https://github.com/github/gh-aw/actions/runs/123", + historyUrl: "https://github.com/search?q=smoke", + aiCredits: "1.25", +}; +const fields = [ + { key: "workflow_id", value: "smoke-copilot" }, + { key: "branch", value: "main" }, + { key: "failure_categories", value: "missing_tool|report_incomplete" }, +]; +const now = new Date("2099-01-01T12:00:00.000Z"); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function document(footer) { + return { type: /** @type {const} */ "report", title: "[aw] Smoke Copilot: failed", children: parseReportMarkdown("## What happened\n\nFailure"), footer }; +} + +describe("structured report footer", () => { + it("renders attribution, model/cost context, expiration, and machine markers in one document", () => { + vi.stubEnv("GH_AW_WORKFLOW_ID", "smoke-copilot"); + vi.stubEnv("GITHUB_RUN_ID", "123"); + vi.stubEnv("GH_AW_ENGINE_ID", "copilot"); + vi.stubEnv("GH_AW_ENGINE_MODEL", "claude-sonnet-4.6"); + vi.stubEnv("GH_AW_AMBIENT_CONTEXT", "900"); + const ast = document(buildFailureReportFooter(ctx, { expiresHours: 12, failureFields: fields, now })); + const { body } = renderReport(ast); + expect(normalizeReport(normalizeReport(ast))).toEqual(normalizeReport(ast)); + expect(renderReport(ast).body).toBe(body); + expect(body).toContain("> Generated from [Smoke Copilot](https://github.com/github/gh-aw/actions/runs/123) · copilot · sonnet46 · 1.25 AIC · ⊞ 900"); + expect(body).toContain("> - [x] expires "); + expect(extractExpirationDate(body)?.toISOString()).toBe("2099-01-02T00:00:00.000Z"); + expect(parseHTMLCommentMetadata(body, "gh-aw-agentic-workflow")).toMatchObject({ "gh-aw-agentic-workflow": "Smoke Copilot", workflow_id: "smoke-copilot", id: "123", engine: "copilot" }); + expect(parseHTMLCommentMetadata(body, "gh-aw-failure-issue")).toMatchObject({ workflow_id: "smoke-copilot", branch: "main", failure_categories: "missing_tool|report_incomplete" }); + expect(isReusableFailureIssue(body, { workflowId: "smoke-copilot", failureCategories: ["missing_tool", "report_incomplete"] })).toBe(true); + expect(body.indexOf("Generated from")).toBeGreaterThan(body.indexOf("What happened")); + expect(body.indexOf("gh-aw-failure-issue")).toBeGreaterThan(body.indexOf("gh-aw-expires")); + }); + + it("preserves custom footer templates and both history-link field spellings", () => { + vi.stubEnv("GH_AW_SAFE_OUTPUT_MESSAGES", JSON.stringify({ agentFailureIssue: "> Report by **{workflow_name}**{history_link}{historyLink} · {ai_credits_formatted} {ai_credits_unit}" })); + const { body } = renderReport(document(buildFailureReportFooter(ctx, { expiresHours: 0, failureFields: fields }))); + expect(body).toContain("> Report by **Smoke Copilot** · [◷](https://github.com/search?q=smoke) · [◷](https://github.com/search?q=smoke) · 1.25 AIC"); + expect(body).not.toContain("gh-aw-expires"); + expect(extractExpirationDate(body)).toBeNull(); + }); + + it("escapes hostile attribution and marker values without accepting spoofed metadata fields", () => { + vi.stubEnv("GH_AW_WORKFLOW_ID", "smoke-copilot"); + const hostile = "Smoke](https://evil.example)\n, workflow_id: forged"; + const ast = document( + buildFailureReportFooter( + { ...ctx, workflowName: hostile }, + { + expiresHours: 0, + failureFields: fields.map(field => (field.key === "branch" ? { ...field, value: "feature/--> , failure_cause: forged" } : field)), + } + ) + ); + const { body } = renderReport(ast); + expect(body).not.toContain(""); + const attribution = body.split("\n\n"; + report.preamble = parseReportMarkdown(caution, { allowFlagMarkers: true }); + const { body } = renderReport(report); + expect(body.startsWith("> [!CAUTION]\n")).toBe(true); + expect(body).toContain("> "); + expect(body.indexOf("gh-aw-threat-detected")).toBeLessThan(body.indexOf("## What happened")); + expect(renderReport({ ...report, preamble: parseReportMarkdown(caution) }).body).not.toContain(""); + }); + + it("rejects invalid expiration, duplicate fields, and unknown markers", () => { + expect(() => buildFailureReportFooter(ctx, { expiresHours: -1, failureFields: fields })).toThrow("nonnegative"); + const footer = buildFailureReportFooter(ctx, { expiresHours: 0, failureFields: fields }); + expect(() => renderReport(document({ ...footer, expiresAt: "not a date" }))).toThrow("expiration date"); + expect(() => renderReport(document({ ...footer, markers: [...footer.markers, ...footer.markers] }))).toThrow("Duplicate report metadata marker"); + expect(() => renderReport(document({ ...footer, markers: [{ name: "unrecognized", value: "", fields: [] }] }))).toThrow("Unsupported report metadata marker"); + expect(() => renderReport(document({ ...footer, markers: [{ name: "gh-aw-failure-issue", value: "true", fields: [fields[0], fields[0]] }] }))).toThrow("duplicate report metadata field"); + }); +}); diff --git a/actions/setup/js/report_markdown.cjs b/actions/setup/js/report_markdown.cjs new file mode 100644 index 00000000000..25140324e7d --- /dev/null +++ b/actions/setup/js/report_markdown.cjs @@ -0,0 +1,201 @@ +// @ts-check + +const { normalizeReportURL } = require("./report_ast.cjs"); + +/** @typedef {import("./report_ast.cjs").ReportInline} ReportInline */ +/** @typedef {import("./report_ast.cjs").ReportBlock} ReportBlock */ +/** @typedef {{type: "inline_slot", children: ReportInline[]}} ReportInlineSlot */ +/** @typedef {Record} ReportContext */ + +/** + * Import the small Markdown dialect used by runtime report templates into typed nodes. + * Templates are parsed before scalar substitution; values cannot introduce markup. + * Existing diagnostic fragments are imported separately, never passed through as raw HTML. + * @param {string} markdown + * @param {{context?: ReportContext, minimumHeadingLevel?: number, depth?: number, allowFlagMarkers?: boolean}} [options] + * @returns {ReportBlock[]} + */ +function parseReportMarkdown(markdown, { context, minimumHeadingLevel = 3, depth = 0, allowFlagMarkers = false } = {}) { + if (depth > 16) throw new TypeError("Report template nesting exceeds the maximum depth"); + /** @param {string} key */ + function valueFor(key) { + const value = context && Object.hasOwn(context, key) ? context[key] : undefined; + if (context && value === undefined) throw new TypeError("Missing report template field"); + return value; + } + /** @param {string} source */ + function substitute(source) { + return source.replace(/\{(\w+)\}/g, (match, key) => { + const value = valueFor(key); + if (typeof value === "object") throw new TypeError("Report nodes cannot appear in scalar fields"); + return value === undefined ? match : String(value); + }); + } + /** + * @param {string} source + * @param {number} [inlineDepth] + * @returns {ReportInline[]} + */ + function inline(source, inlineDepth = 0) { + if (inlineDepth > 16) throw new TypeError("Report inline nesting exceeds the maximum depth"); + const original = source; + /** @type {ReportInline[]} */ + const nodes = []; + const text = value => { + const previous = nodes.at(-1); + if (previous?.type === "text") previous.value += value; + else nodes.push({ type: "text", value }); + }; + while (source) { + let match; + if ((match = /^\\([\\`*_[\]{}()#+.!|>~-])/.exec(source))) { + text(match[1]); + } else if ((match = /^\{(\w+)\}/.exec(source))) { + const value = valueFor(match[1]); + if (Array.isArray(value)) throw new TypeError("Report blocks cannot appear inline"); + if (typeof value === "object") nodes.push(...value.children); + else text(value === undefined ? match[0] : String(value)); + } else if ((match = /^(`+)(?!`)([\s\S]*?)(?\s]+)>/.exec(source))) { + const url = substitute(match[1]); + try { + nodes.push({ type: "link", url: normalizeReportURL(url), children: [{ type: "text", value: url }] }); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + text("Link unavailable: invalid HTTPS URL"); + } + } else if ((match = /^(\*\*|__)(?=\S)([\s\S]*?\S)\1/.exec(source))) { + nodes.push({ type: "strong", children: inline(match[2], inlineDepth + 1) }); + } else if ((match = /^([*_])(?=\S)([^*_\n]*?\S)\1/.exec(source)) && (match[1] !== "_" || !/\w/.test(original[original.length - source.length - 1] || ""))) { + nodes.push({ type: "emphasis", children: inline(match[2], inlineDepth + 1) }); + } else { + text(source[0]); + source = source.slice(1); + continue; + } + source = source.slice(match[0].length); + } + return nodes; + } + + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + let index = 0; + const nested = source => parseReportMarkdown(source, { context, minimumHeadingLevel: Math.max(3, minimumHeadingLevel), depth: depth + 1, allowFlagMarkers }); + const isFieldLine = line => /^(?:\{\w+\}\s*)+$/.test(line.trim()); + const isBlockStart = line => + /^(?:#{1,6}\s|```|~~~|>|
\s*$|<\/details>\s*$|[-*+]\s|\d+\.\s)/.test(line) || isFieldLine(line) || (allowFlagMarkers && /^$/.test(line.trim())); + /** @param {string} line */ + function cells(line) { + return line + .trim() + .replace(/^\|/, "") + .replace(/(? inline(cell.trim())); + } + /** + * @param {boolean} inDetails + * @param {number} detailsDepth + * @returns {ReportBlock[]} + */ + function blocks(inDetails = false, detailsDepth = depth) { + if (detailsDepth > 16) throw new TypeError("Report details nesting exceeds the maximum depth"); + /** @type {ReportBlock[]} */ + const nodes = []; + while (index < lines.length) { + const line = lines[index]; + if (!line.trim()) { + index++; + continue; + } + if (inDetails && line.trim() === "
") { + index++; + break; + } + let match; + if (context && isFieldLine(line)) { + for (const field of line.matchAll(/\{(\w+)\}/g)) { + const value = valueFor(field[1]); + if (Array.isArray(value)) nodes.push(...value); + else if (typeof value === "object") nodes.push({ type: "paragraph", children: value.children }); + else if (String(value).trim()) nodes.push({ type: "paragraph", children: [{ type: "text", value: String(value) }] }); + } + index++; + } else if (allowFlagMarkers && (match = /^$/.exec(line.trim()))) { + nodes.push({ type: "marker", name: match[1] === "gh-aw-threat-detected" ? "gh-aw-threat-detected" : "gh-aw-threat-engine-error" }); + index++; + } else if ((match = /^\s*(`{3,}|~{3,})(.*)$/.exec(line))) { + const fence = match[1]; + const language = match[2].trim(); + const content = []; + index++; + while (index < lines.length) { + const closing = /^\s*(`+|~+)\s*$/.exec(lines[index]); + if (closing && closing[1][0] === fence[0] && closing[1].length >= fence.length) break; + content.push(lines[index++]); + } + if (index < lines.length) index++; + nodes.push({ type: "code_block", language: /^[a-zA-Z0-9_+-]*$/.test(language) ? language : "text", value: substitute(content.join("\n")) }); + } else if ((match = /^(#{1,6})\s+(.+)$/.exec(line))) { + nodes.push({ type: "heading", level: Math.max(minimumHeadingLevel, match[1].length), children: inline(match[2]) }); + index++; + } else if (line.trim() === "
" && /^.*<\/summary>$/.test(lines[index + 1]?.trim() || "")) { + const summary = lines[index + 1].trim().slice("".length, -"".length); + index += 2; + nodes.push({ type: "details", summary: substitute(summary), children: blocks(true, detailsDepth + 1) }); + } else if (/^>/.test(line)) { + const quoted = []; + while (index < lines.length && /^>/.test(lines[index])) quoted.push(lines[index++].replace(/^> ?/, "")); + const alert = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$/.exec(quoted[0]); + nodes.push(alert ? { type: "alert", kind: alert[1], children: nested(quoted.slice(1).join("\n")) } : { type: "quote", children: nested(quoted.join("\n")) }); + } else if ((match = /^([-*+]|\d+\.)\s+(.+)$/.exec(line))) { + const ordered = /^\d/.test(match[1]); + /** @type {ReportBlock[][]} */ + const items = []; + while (index < lines.length) { + const item = /^([-*+]|\d+\.)\s+(.+)$/.exec(lines[index]); + if (!item || /^\d/.test(item[1]) !== ordered) break; + const content = [item[2]]; + index++; + while (index < lines.length && /^\s{2,}\S/.test(lines[index])) content.push(lines[index++].replace(/^ {2,4}/, "")); + items.push(nested(content.join("\n"))); + } + nodes.push({ type: "list", ordered, items }); + } else if (line.includes("|") && /^\|?[\s:|-]+\|[\s:|-]*$/.test(lines[index + 1] || "")) { + const tableLines = [line, lines[index + 1]]; + const headers = cells(line); + /** @type {ReportInline[][][]} */ + const rows = []; + index += 2; + while (index < lines.length && lines[index].includes("|") && lines[index].trim()) { + tableLines.push(lines[index]); + rows.push(cells(lines[index++])); + } + if (rows.every(row => row.length === headers.length)) nodes.push({ type: "table", headers, rows }); + else nodes.push({ type: "paragraph", children: [{ type: "text", value: substitute(tableLines.join("\n")) }] }); + } else { + const content = [line]; + index++; + while (index < lines.length && lines[index].trim() && !isBlockStart(lines[index])) { + if (lines[index].includes("|") && /^\|?[\s:|-]+\|[\s:|-]*$/.test(lines[index + 1] || "")) break; + content.push(lines[index++]); + } + nodes.push({ type: "paragraph", children: inline(content.join("\n")) }); + } + } + return nodes; + } + return blocks(); +} + +module.exports = { parseReportMarkdown }; diff --git a/actions/setup/md/agent_failure_issue.md b/actions/setup/md/agent_failure_issue.md index 5b2d677d979..e7c8c12f894 100644 --- a/actions/setup/md/agent_failure_issue.md +++ b/actions/setup/md/agent_failure_issue.md @@ -1,48 +1,50 @@ -### Workflow Failure +## What happened -**Workflow:** [{workflow_name}]({workflow_source_url}) -**Branch:** {branch} -**Run:** {run_url}{pull_request_info} +**{workflow_name}** {failure_summary}. -{secret_verification_context}{copilot_org_billing_error_context}{copilot_agent_not_found_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{model_endpoint_mismatch_context}{http_400_response_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{missing_model_pricing_context}{shell_expansion_guard_rejected_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{oauth_token_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{daily_ai_credits_guardrail_error_context}{assignment_errors_context}{assign_copilot_failure_context}{skill_install_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{report_incomplete_context}{failure_diagnostics_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} +{run_links_context} -### Action Required +**Branch:** `{branch}` -**Assign this issue to an agent** to debug and fix the issue. +{pull_request_context} + +## Next step + +Review the failure details below for a targeted fix, or **assign this issue to a coding agent** to investigate. + +## Failure details + +{secret_verification_context}{copilot_org_billing_error_context}{copilot_agent_not_found_context}{credential_auth_error_context}{inference_access_error_context}{mcp_policy_error_context}{model_not_supported_error_context}{model_endpoint_mismatch_context}{http_400_response_error_context}{ai_credits_rate_limit_error_context}{unknown_model_ai_credits_context}{missing_model_pricing_context}{shell_expansion_guard_rejected_context}{app_token_minting_failed_context}{lockdown_check_failed_context}{oauth_token_check_failed_context}{stale_lock_file_failed_context}{daily_ai_credits_exceeded_context}{daily_ai_credits_guardrail_error_context}{assignment_errors_context}{assign_copilot_failure_context}{skill_install_failure_context}{create_discussion_errors_context}{code_push_failure_context}{repo_memory_validation_context}{push_repo_memory_failure_context}{missing_data_context}{missing_tool_context}{permission_denied_context}{tool_denials_exceeded_context}{missing_safe_outputs_context}{engine_failure_context}{timeout_context}{fork_context} + +{runtime_evidence_context} {optimize_token_consumption_context} +
-Debug with any coding agent +Copy a debugging prompt -Use this prompt with any coding agent (GitHub Copilot, Claude, Gemini, etc.): +Paste this into your coding agent: -```` -Debug the agentic workflow failure using https://raw.githubusercontent.com/github/gh-aw/main/debug.md +```text +Debug the agentic workflow "{workflow_id}" failure. +Follow https://raw.githubusercontent.com/github/gh-aw/main/debug.md +Failed run: {run_url} +``` -The failed workflow run is at {run_url} -```` +If the repository includes the `agentic-workflows` skill, load it before investigating.
-Manually invoke the agent +Manage failure notifications -Debug this workflow failure using your favorite Agent CLI and the `agentic-workflows` prompt. +To stop creating failure issues for this workflow, set this in its frontmatter and recompile: -- Start your agent -- Load the `agentic-workflows` skill from `.github/skills/agentic-workflows/SKILL.md` or -- Type `debug the agentic workflow {workflow_id} failure in {run_url}` +```yaml +safe-outputs: + report-failure-as-issue: false +``` -
+This only disables issue reporting; it does not fix the failure. -> [!TIP] ->
-> Stop reporting this workflow as a failure -> -> To stop a workflow from creating failure issues, set `report-failure-as-issue: false` in its frontmatter: -> ```yaml -> safe-outputs: -> report-failure-as-issue: false -> ``` -> ->
+
From 5d64732bc951ea646d5c9b7ec5c81442f3172149 Mon Sep 17 00:00:00 2001 From: pelikhan Date: Sat, 17 Oct 2026 10:21:22 -0700 Subject: [PATCH 2/6] Enforce report AST across JavaScript issue creation Add semantic closing references, structured data, and safe hidden metadata nodes. Route REST, GraphQL, and provider issue creation through shared report normalization with compatibility regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_aw_issue_clustering_publish.cjs | 10 +- actions/setup/README.md | 34 ++- actions/setup/js/check_version_updates.cjs | 4 +- .../js/check_workflow_recompile_needed.cjs | 4 +- actions/setup/js/create_forecast_issue.cjs | 4 +- actions/setup/js/create_issue.cjs | 46 ++-- actions/setup/js/create_issue.test.cjs | 15 +- actions/setup/js/create_pull_request.cjs | 4 +- actions/setup/js/create_pull_request.test.cjs | 2 +- actions/setup/js/create_steering_issue.cjs | 4 +- actions/setup/js/failure_report.test.cjs | 2 +- actions/setup/js/handle_agent_failure.cjs | 27 ++- .../setup/js/handle_agent_failure.test.cjs | 2 +- actions/setup/js/handle_create_pr_error.cjs | 4 +- actions/setup/js/handle_detection_runs.cjs | 4 +- actions/setup/js/handle_noop_message.cjs | 4 +- actions/setup/js/index.test.cjs | 15 +- actions/setup/js/issue_report.cjs | 42 ++++ actions/setup/js/issue_report.test.cjs | 200 ++++++++++++++++++ actions/setup/js/jira_handlers.cjs | 17 +- actions/setup/js/jira_handlers.test.cjs | 25 +++ actions/setup/js/linear_create_issue.cjs | 13 +- actions/setup/js/linear_safe_outputs.test.cjs | 11 + actions/setup/js/missing_issue_helpers.cjs | 4 +- .../setup/js/push_to_pull_request_branch.cjs | 4 +- actions/setup/js/report_ast.cjs | 165 +++++++++++++-- actions/setup/js/report_ast.test.cjs | 135 ++++++++++++ actions/setup/js/report_failed_jobs.cjs | 4 +- actions/setup/js/report_failed_jobs.test.cjs | 5 +- actions/setup/js/report_footer.cjs | 4 +- actions/setup/js/report_footer.test.cjs | 2 +- actions/setup/js/report_markdown.cjs | 61 +++++- actions/setup/js/run_activity_report.cjs | 4 +- .../setup/js/run_operation_update_upgrade.cjs | 6 +- actions/setup/js/run_validate_workflows.cjs | 4 +- actions/setup/js/sanitize_content.cjs | 48 ++--- actions/setup/js/work_queue_issue_api.cjs | 4 +- .../work_queue_issues_acceptance_checks.cjs | 2 +- actions/setup/js/work_queue_issues_checks.cjs | 2 +- 39 files changed, 832 insertions(+), 115 deletions(-) create mode 100644 actions/setup/js/issue_report.cjs create mode 100644 actions/setup/js/issue_report.test.cjs diff --git a/.github/scripts/test_aw_issue_clustering_publish.cjs b/.github/scripts/test_aw_issue_clustering_publish.cjs index 2e4b4e979bc..7b6da4c5598 100644 --- a/.github/scripts/test_aw_issue_clustering_publish.cjs +++ b/.github/scripts/test_aw_issue_clustering_publish.cjs @@ -240,6 +240,7 @@ test("live queue refuses an eleventh issue", async () => { test("new summaries use attributed issue creation and preserve managed metadata", async () => { const { setupGlobals, createIssue } = require("../../actions/setup/js/index.cjs"); + const { renderIssueParameters } = require("../../actions/setup/js/issue_report.cjs"); const data = corpus(); const calls = []; const github = { @@ -261,6 +262,13 @@ test("new summaries use attributed issue creation and preserve managed metadata" GH_AW_CALLER_WORKFLOW_ID: "github/gh-aw/aw-issue-clustering", GH_AW_PROMPTS_DIR: path.resolve(__dirname, "../../actions/setup/md"), GH_AW_SAFE_OUTPUTS_STAGED: "false", + GITHUB_REPOSITORY: "github/gh-aw", + GITHUB_REPOSITORY_ID: "123", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_RUN_ID: "123", + GITHUB_RUN_ATTEMPT: "1", + GITHUB_JOB: "safe_outputs", + GH_AW_JOB_CHECK_RUN_ID: "456", }; const previousEnv = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]])); const globalKeys = ["core", "github", "context", "exec", "io", "getOctokit", "runtimeFeatures", "hasRuntimeFeature", "getRuntimeFeatureValue"]; @@ -271,7 +279,7 @@ test("new summaries use attributed issue creation and preserve managed metadata" setupGlobals(core, github, { repo: { owner: "github", repo: "gh-aw" }, runId: 123, payload: {} }, {}, {}, () => github); const result = await reconcile(github, "github", "gh-aw", [cluster()], data, false, core, createIssue); assert.equal(calls.length, 1); - assert.ok(calls[0].body.includes(island(cluster(), 1, repo))); + assert.ok(calls[0].body.includes(renderIssueParameters({ title: "Summary", body: island(cluster(), 1, repo) }).body.trimEnd())); assert.match(calls[0].body, /> Generated by \[AW Essential Issue Clustering\]/); assert.match(calls[0].body, /gh-aw-agentic-workflow: AW Essential Issue Clustering/); assert.ok(calls[0].body.includes("https://github.com/github/gh-aw/actions/runs/123")); diff --git a/actions/setup/README.md b/actions/setup/README.md index 48ad358fef8..2f295bf6bcc 100644 --- a/actions/setup/README.md +++ b/actions/setup/README.md @@ -73,9 +73,16 @@ All files are copied from the committed `js/` and `sh/` directories which contai ## Development -### Structured failure reports +### Structured issue reports + +All JavaScript GitHub issue creators publish through `js/issue_report.cjs`. +`createReportIssue(client, parameters, report?)` renders the report AST before +calling the supplied authenticated client's issue API. Existing issue Markdown +is imported when no document is supplied. GraphQL work-queue creation uses the +same `renderIssueParameters` boundary, as do staged safe-output previews. +Linear uses the Markdown renderer; Jira normalizes a literal-text report AST +before converting its description to ADF. -Failure issues are built as a report AST before publication. `js/report_ast.cjs` defines the typed inline and block nodes, normalization, and GitHub Markdown renderer. The renderer owns escaping, HTTPS link validation, heading levels, balanced details, code fences, and whitespace. It rejects raw @@ -83,7 +90,8 @@ HTML/Markdown nodes and redacts diagnostic secrets before choosing delimiters. `js/failure_report.cjs` builds reports from `md/agent_failure_issue.md`. `js/report_markdown.cjs` imports the runtime template dialect: headings, -paragraphs, emphasis, links, code, lists, tables, quotes, alerts, and details. +paragraphs, emphasis, links, code, lists, task lists, tables, quotes, alerts, +separators, and details. Templates are parsed **before** scalar metadata is substituted. Diagnostic fragments enter through explicit block fields, with headings below the report sections; unsupported HTML remains literal text. New programmatic report content @@ -97,6 +105,26 @@ Marker names and fields are validated; marker values cannot close an HTML commen or inject another metadata field. Expiration remains a checked task-list item so existing cleanup and deduplication readers continue to work. +The issue publication importer explicitly enables typed metadata comments to +preserve grouping, execution provenance, managed islands, and work-queue IDs. +Diagnostic imports do not enable them. JSON metadata is serialized without HTML +delimiters while preserving decoded values. Configured mentions use explicit +mention nodes after the existing safe-output allowlist and budget checks; other +text cannot create notifications. Body limits apply after rendering, including +escaping and attribution. A repository-wide regression guards against direct +REST issue-creation calls outside the shared publisher. + +Special syntax has semantic nodes rather than interpolated strings: +`closing_reference` records the closing action, issue number, optional +`owner/repo`, and an explicit `enabled` flag. Disabled references render an +escaped `#`, including in PR fallback issues; scalar text never becomes an active +closing directive. `structured_data` accepts JSON values and renders a safely +fenced JSON block. `xml_marker` holds a validated name, scalar value, and optional +key/value fields; `json_marker` holds an actual data object, not serialized JSON. +Footer provenance uses the same XML-marker node and renderer. Structured data +preserves entity-looking strings, redacts secrets, and rejects cycles, getters, +non-finite numbers, and other values JSON would silently drop or coerce. + ### JSONL runtime helpers Use `js/jsonl_helpers.cjs` instead of splitting files or maintaining chunk buffers: diff --git a/actions/setup/js/check_version_updates.cjs b/actions/setup/js/check_version_updates.cjs index 2d3b9856e0d..0dbd86e857d 100644 --- a/actions/setup/js/check_version_updates.cjs +++ b/actions/setup/js/check_version_updates.cjs @@ -16,6 +16,8 @@ * Fails the activation job when validation fails. */ +const { createReportIssue } = require("./issue_report.cjs"); + const { withRetry, isTransientError } = require("./error_recovery.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { renderTemplateFromFile, getPromptPath } = require("./messages_core.cjs"); @@ -188,7 +190,7 @@ async function reportBlockedVersionIssue(compiledVersion) { const newIssue = await withRetry( () => - github.rest.issues.create({ + createReportIssue(github, { owner, repo, title, diff --git a/actions/setup/js/check_workflow_recompile_needed.cjs b/actions/setup/js/check_workflow_recompile_needed.cjs index 19cb159e96c..39ca3300e3a 100644 --- a/actions/setup/js/check_workflow_recompile_needed.cjs +++ b/actions/setup/js/check_workflow_recompile_needed.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { createReportIssue } = require("./issue_report.cjs"); + const { getErrorMessage } = require("./error_helpers.cjs"); const { getFooterWorkflowRecompileMessage, getFooterWorkflowRecompileCommentMessage, generateXMLMarker, getDetectionCautionAlert } = require("./messages_footer.cjs"); const fs = require("fs"); @@ -420,7 +422,7 @@ async function main() { issueBody += "\n\n---\n" + footer + "\n\n" + xmlMarker + "\n"; try { - const newIssue = await github.rest.issues.create({ + const newIssue = await createReportIssue(github, { owner, repo, title: RECOMPILE_ISSUE_TITLE, diff --git a/actions/setup/js/create_forecast_issue.cjs b/actions/setup/js/create_forecast_issue.cjs index 753f99ea93d..275360d1162 100644 --- a/actions/setup/js/create_forecast_issue.cjs +++ b/actions/setup/js/create_forecast_issue.cjs @@ -2,6 +2,8 @@ // @safe-outputs-exempt SEC-004 /// +const { createReportIssue } = require("./issue_report.cjs"); + const fs = require("node:fs"); const { getPromptPath, renderTemplateFromFile } = require("./messages_core.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); @@ -348,7 +350,7 @@ async function main() { await core.summary.addRaw(summary).write(); } - const createdIssue = await github.rest.issues.create({ + const createdIssue = await createReportIssue(github, { owner: context.repo.owner, repo: context.repo.repo, title: isErrorOutcome ? FORECAST_ERROR_ISSUE_TITLE : FORECAST_ISSUE_TITLE, diff --git a/actions/setup/js/create_issue.cjs b/actions/setup/js/create_issue.cjs index 1f7bfe00ef3..9face5db450 100644 --- a/actions/setup/js/create_issue.cjs +++ b/actions/setup/js/create_issue.cjs @@ -1,9 +1,12 @@ // @ts-check /// +const { createReportIssue, renderIssueParameters, buildIssueReport } = require("./issue_report.cjs"); +const { normalizeReportTitle } = require("./report_ast.cjs"); + const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); const { sanitizeTitle, applyTitlePrefix } = require("./sanitize_title.cjs"); -const { sanitizeContent } = require("./sanitize_content.cjs"); +const { sanitizeContent, expandAllowedAliases } = require("./sanitize_content.cjs"); const { normalizeCloseOlderKey } = require("./generate_footer.cjs"); const { appendExecutionProvenance } = require("./execution_provenance.cjs"); const { formatIssueBody } = require("./issue_body.cjs"); @@ -87,19 +90,17 @@ async function createIssue(parameters) { if (issueArrayLimitError) { throw new Error(`${ERR_VALIDATION}: ${issueArrayLimitError}`); } - const body = formatIssueBody(parameters.body, { repo: { owner, repo } }); - if (body.length > MAX_GITHUB_BODY_LENGTH) { - throw new Error(`${ERR_VALIDATION}: Issue body exceeds GitHub's maximum length of ${MAX_GITHUB_BODY_LENGTH} characters including attribution`); - } - /** @type {IssueParameters} */ - const request = { ...parameters, owner, repo, body }; + const report = buildIssueReport(parameters.title, formatIssueBody(parameters.body, { repo: { owner, repo } })); + const rendered = renderIssueParameters({ title: parameters.title }, report); + /** @type {IssueParameters & {title: string, body: string}} */ + const request = { ...parameters, owner, repo, ...rendered }; if (labels) request.labels = labels; if (assignees) request.assignees = assignees; if (isStagedMode()) { - core.info(`Staged: would create issue in ${owner}/${repo} with title: ${parameters.title}`); + core.info(`Staged: would create issue in ${owner}/${repo} with title: ${request.title}`); return { staged: true, preview: request }; } - const { data: issue } = await withRetry(() => github.rest.issues.create(request), RATE_LIMIT_RETRY_CONFIG, `create_issue in ${owner}/${repo}`); + const { data: issue } = await withRetry(() => createReportIssue(github, request, report), RATE_LIMIT_RETRY_CONFIG, `create_issue in ${owner}/${repo}`); core.info(`Created issue ${owner}/${repo}#${issue.number}: ${issue.html_url}`); return { staged: false, issue }; } @@ -237,7 +238,7 @@ async function findOrCreateParentIssue({ githubClient, groupId, owner, repo, tit const template = createParentIssueTemplate(groupId, titlePrefix, workflowName, workflowSourceURL, expiresHours); const { data: parentIssue } = await withRetry( () => - githubClient.rest.issues.create({ + createReportIssue(githubClient, { owner, repo, title: template.title, @@ -1021,7 +1022,7 @@ async function main(config = {}) { } // Apply title prefix (only if it doesn't already exist) - title = applyTitlePrefix(title, titlePrefix); + title = normalizeReportTitle(applyTitlePrefix(title, titlePrefix)); const normalizedTitle = normalizeTitleForDedup(title); @@ -1107,13 +1108,15 @@ async function main(config = {}) { const workflowId = process.env.GH_AW_WORKFLOW_ID ?? ""; const callerWorkflowId = process.env.GH_AW_CALLER_WORKFLOW_ID ?? ""; const runUrl = buildWorkflowRunUrl(context, context.repo); - const body = formatIssueBody(bodyLines.join("\n"), { + const formattedBody = formatIssueBody(bodyLines.join("\n"), { repo: repoParts, footer: includeFooter, bodyFooter: config.body_footer, expiresHours, closeOlderKey, }); + const report = buildIssueReport(title, formattedBody, { allowedMentions: expandAllowedAliases(allowedMentionAliases) }); + const { body } = renderIssueParameters({ title }, report); // Reserve a max-count slot synchronously before any async pre-creation work. // There is no await between check and increment, so concurrent invocations @@ -1216,14 +1219,17 @@ async function main(config = {}) { } const { data: issue } = await withRetry( () => - githubClient.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title, - body, - labels, - assignees, - }), + createReportIssue( + githubClient, + { + owner: repoParts.owner, + repo: repoParts.repo, + title, + labels, + assignees, + }, + report + ), RATE_LIMIT_RETRY_CONFIG, `create_issue in ${qualifiedItemRepo}` ); diff --git a/actions/setup/js/create_issue.test.cjs b/actions/setup/js/create_issue.test.cjs index 22c1e681d95..975f7484d8c 100644 --- a/actions/setup/js/create_issue.test.cjs +++ b/actions/setup/js/create_issue.test.cjs @@ -163,7 +163,7 @@ describe("create_issue", () => { title: "Tracker regression", }) ); - expect(request.body).toContain("Issue content with safe HTML"); + expect(request.body).toContain("Issue content with <b>safe HTML</b>"); expect(request.body).not.toContain("