diff --git a/actions/setup/js/add_comment.cjs b/actions/setup/js/add_comment.cjs index 6bb76ce6e8d..167ee8baaa9 100644 --- a/actions/setup/js/add_comment.cjs +++ b/actions/setup/js/add_comment.cjs @@ -565,6 +565,8 @@ async function main(config = {}) { return { success: false, skipped: true, + reasonCode: "MAX_COUNT_REACHED", + reason: "Max count reached", error: `Max count of ${maxCount} reached`, }; } @@ -682,6 +684,8 @@ async function main(config = {}) { return { success: false, skipped: true, + reasonCode: "NO_CONTEXT", + reason: "No target context available", error: targetResult.error, }; } @@ -696,6 +700,8 @@ async function main(config = {}) { return { success: false, skipped: true, + reasonCode: "NO_CONTEXT", + reason: "No target context available", error: targetResult.error, }; } @@ -716,14 +722,31 @@ async function main(config = {}) { }); if (requiredLabels.length > 0) { const itemLabels = (filterItem.labels || []).map(/** @param {any} l */ l => (typeof l === "string" ? l : l.name || "")); - if (!requiredLabels.every(r => itemLabels.includes(r))) { + const missingLabels = requiredLabels.filter(r => !itemLabels.includes(r)); + if (missingLabels.length > 0) { core.info(`Skipping add_comment for #${itemNumber}: does not match required-labels filter (${requiredLabels.join(", ")})`); - return { success: false, skipped: true, error: `Item does not match required-labels filter` }; + return { + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + error: "Item does not match required-labels filter", + target: { repo: itemRepo, number: itemNumber }, + safeDetails: { requiredLabels, missingLabels }, + }; } } if (requiredTitlePrefix && !filterItem.title?.startsWith(requiredTitlePrefix)) { core.info(`Skipping add_comment for #${itemNumber}: title does not start with required prefix "${requiredTitlePrefix}"`); - return { success: false, skipped: true, error: `Item title does not start with required prefix` }; + return { + success: false, + skipped: true, + reasonCode: "REQUIRED_TITLE_PREFIX_MISMATCH", + reason: "Required title prefix missing", + error: "Item title does not start with required prefix", + target: { repo: itemRepo, number: itemNumber }, + safeDetails: { requiredTitlePrefix }, + }; } } catch (err) { core.warning(`Could not fetch item #${itemNumber} to check filters: ${getErrorMessage(err)}`); @@ -1049,6 +1072,9 @@ async function main(config = {}) { success: true, warning: `Target not found: ${discussionErrorMessage}`, skipped: true, + reasonCode: "TARGET_NOT_FOUND", + reason: "Target not found", + target: { repo: itemRepo, number: itemNumber }, }; } @@ -1063,6 +1089,9 @@ async function main(config = {}) { return { success: false, skipped: true, + reasonCode: "DISCUSSIONS_TOKEN_SCOPE_MISMATCH", + reason: "GitHub token cannot add comments to discussions", + target: { repo: itemRepo, number: itemNumber }, error: warningMessage, }; } @@ -1083,6 +1112,9 @@ async function main(config = {}) { success: true, warning: `Target not found: ${errorMessage}`, skipped: true, + reasonCode: "TARGET_NOT_FOUND", + reason: "Target not found", + target: { repo: itemRepo, number: itemNumber }, }; } @@ -1093,6 +1125,9 @@ async function main(config = {}) { success: true, warning: `Target is locked: ${errorMessage}`, skipped: true, + reasonCode: "TARGET_LOCKED", + reason: "Target is locked", + target: { repo: itemRepo, number: itemNumber }, }; } diff --git a/actions/setup/js/add_labels.cjs b/actions/setup/js/add_labels.cjs index be2e54911b9..b184cee6fd0 100644 --- a/actions/setup/js/add_labels.cjs +++ b/actions/setup/js/add_labels.cjs @@ -232,14 +232,31 @@ const main = createCountGatedHandler({ }); if (requiredLabels.length > 0) { const itemLabels = (item.labels || []).map(/** @param {any} l */ l => (typeof l === "string" ? l : l.name || "")); - if (!requiredLabels.every(r => itemLabels.includes(r))) { + const missingLabels = requiredLabels.filter(r => !itemLabels.includes(r)); + if (missingLabels.length > 0) { core.info(`Skipping add_labels for ${contextType} #${itemNumber}: does not match required-labels filter (${requiredLabels.join(", ")})`); - return { success: false, skipped: true, error: `Item does not match required-labels filter` }; + return { + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + error: "Item does not match required-labels filter", + target: { repo: itemRepo, number: itemNumber }, + safeDetails: { requiredLabels, missingLabels }, + }; } } if (requiredTitlePrefix && !item.title?.startsWith(requiredTitlePrefix)) { core.info(`Skipping add_labels for ${contextType} #${itemNumber}: title does not start with required prefix "${requiredTitlePrefix}"`); - return { success: false, skipped: true, error: `Item title does not start with required prefix` }; + return { + success: false, + skipped: true, + reasonCode: "REQUIRED_TITLE_PREFIX_MISMATCH", + reason: "Required title prefix missing", + error: "Item title does not start with required prefix", + target: { repo: itemRepo, number: itemNumber }, + safeDetails: { requiredTitlePrefix }, + }; } } diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index ca152ba02da..6335ecd15fb 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -12,7 +12,7 @@ const { loadAgentOutput } = require("./load_agent_output.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); const { ERR_CONFIG, ERR_PARSE, ERR_VALIDATION } = require("./error_codes.cjs"); -const { computeSafeOutputsStatus, isFailedProcessingResult } = require("./safe_outputs_status.cjs"); +const { classifySafeOutputResult, computeSafeOutputsStatus, isFailedProcessingResult } = require("./safe_outputs_status.cjs"); const { hasUnresolvedTemporaryIds, replaceTemporaryIdReferences, replaceArtifactUrlReferences, normalizeTemporaryId } = require("./temporary_id.cjs"); const { generateMissingInfoSections } = require("./missing_info_formatter.cjs"); const { setCollectedMissings } = require("./missing_messages_helper.cjs"); @@ -623,9 +623,6 @@ function rollbackReviewResultsForPR(results, repo, prNumber, errorMessage) { * the skip must be back-propagated here so the Processing Summary reflects the actual * outcome (skipped) rather than a misleading success count. * - * Note: uses `skipReason` (not `reason`) so that the step-summary generator does not - * treat these entries as delegated-step skips and omit them from the output. - * * @param {Array<{type: string, success: boolean, skipped?: boolean, skipReason?: string}>} results - Processing results to mutate * @param {string} skipReason - Human-readable reason for the skip */ @@ -691,14 +688,41 @@ function partitionFailureResults(results) { /** * Export item-level safe-output status as GitHub Actions outputs. * - * @param {{itemsSucceeded: number, itemsFailed: number, status: string}} status + * @param {{itemsSucceeded: number, itemsApplied?: number, itemsSkipped?: number, itemsWarnings?: number, itemsCancelled?: number, itemsDeferred?: number, itemsFailed: number, status: string}} status */ function setSafeOutputsStatusOutputs(status) { core.setOutput("items_succeeded", String(status.itemsSucceeded)); + core.setOutput("items_applied", String(status.itemsApplied ?? status.itemsSucceeded)); + core.setOutput("items_skipped", String(status.itemsSkipped ?? 0)); + core.setOutput("items_warnings", String(status.itemsWarnings ?? 0)); + core.setOutput("items_cancelled", String(status.itemsCancelled ?? 0)); + core.setOutput("items_deferred", String(status.itemsDeferred ?? 0)); core.setOutput("items_failed", String(status.itemsFailed)); core.setOutput("status", status.status); } +/** + * @param {string} type + * @param {number} messageIndex + * @param {Record} result + * @returns {Record} + */ +function buildSkippedResult(type, messageIndex, result) { + const message = result.reason || result.warning || result.error || "Handler returned skipped: true"; + return { + type, + messageIndex, + success: result.success === true, + skipped: true, + ...(result.warning ? { warning: result.warning } : {}), + ...(result.reason ? { reason: result.reason } : {}), + ...(result.reasonCode ? { reasonCode: result.reasonCode } : {}), + ...(result.errorCode ? { errorCode: result.errorCode } : {}), + error: message, + result, + }; +} + /** * Process all messages from agent output in the order they appear * Dispatches each message to the appropriate handler while maintaining shared state (temporary ID map) @@ -808,6 +832,7 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) messageIndex: i, success: false, skipped: true, + delegated: true, reason: "Handled by standalone step", }); continue; @@ -842,6 +867,7 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) messageIndex: i, success: false, skipped: true, + delegated: true, reason: "Handled by custom safe output job", }); continue; @@ -908,18 +934,14 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) // Call the message handler with the individual message and resolved temp IDs const result = await messageHandler(effectiveMessage, resolvedTemporaryIds, temporaryIdMap); - // Check if the handler explicitly returned a skipped result (e.g. if_no_changes: warn/ignore). - // Skipped results should NOT trigger fail-fast cancellation of subsequent messages. - if (result && result.success === false && result.skipped === true && !result.deferred) { - const msg = result.error || "Handler returned success: false with skipped: true"; + // Check if the handler explicitly returned a skipped result (e.g. policy filters, + // no-op warnings, or if_no_changes: warn/ignore). Skipped results should NOT + // trigger fail-fast cancellation of subsequent messages, and any summary-safe + // diagnostics supplied by the handler must be preserved. + if (result && result.skipped === true && !result.deferred) { + const msg = result.reason || result.warning || result.error || "Handler returned skipped: true"; core.info(`⏭ Message ${i + 1} (${messageType}) skipped — ${msg}`); - results.push({ - type: messageType, - messageIndex: i, - success: false, - skipped: true, - error: msg, - }); + results.push(buildSkippedResult(messageType, i, result)); continue; } @@ -1093,15 +1115,31 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) // Call the handler again with updated temp ID map const result = await deferred.handler(deferred.message, resolvedTemporaryIds, temporaryIdMap); + if (result && result.skipped === true && !result.deferred) { + const msg = result.reason || result.warning || result.error || "Handler returned skipped: true"; + core.info(`⏭ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) skipped — ${msg}`); + const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex); + if (resultIndex >= 0) { + results[resultIndex] = buildSkippedResult(deferred.type, deferred.messageIndex, result); + } + continue; + } + // Check if the handler explicitly returned a failure if (result && result.success === false && !result.deferred) { const errorMsg = result.error || "Handler returned success: false"; core.error(`✗ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) failed: ${errorMsg}`); - // Update the result to error + // Replace the deferred record so terminal retry failures classify as failed. const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex); if (resultIndex >= 0) { - results[resultIndex].success = false; - results[resultIndex].error = errorMsg; + results[resultIndex] = { + type: deferred.type, + messageIndex: deferred.messageIndex, + success: false, + deferred: false, + error: errorMsg, + result: { ...result, success: false, deferred: false, error: errorMsg }, + }; } continue; } @@ -1157,11 +1195,19 @@ async function processMessages(messageHandlers, messages, onItemCreated = null) logCreatedItemFromResult(onItemCreated, deferred.type, result); } } catch (error) { - core.error(`✗ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) failed: ${getErrorMessage(error)}`); - // Update the result to error + const errorMsg = getErrorMessage(error); + core.error(`✗ Retry of message ${deferred.messageIndex + 1} (${deferred.type}) failed: ${errorMsg}`); + // Replace the deferred record so terminal retry exceptions classify as failed. const resultIndex = results.findIndex(r => r.messageIndex === deferred.messageIndex); if (resultIndex >= 0) { - results[resultIndex].error = getErrorMessage(error); + results[resultIndex] = { + type: deferred.type, + messageIndex: deferred.messageIndex, + success: false, + deferred: false, + error: errorMsg, + result: { success: false, deferred: false, error: errorMsg }, + }; } } } @@ -1606,10 +1652,10 @@ async function main() { const reportOnlyFailureCount = reportOnlyFailures.length; const cancelledCount = processingResult.results.filter(r => r.cancelled).length; const deferredCount = processingResult.results.filter(r => r.deferred).length; - const skippedStandaloneResults = processingResult.results.filter(r => r.skipped && r.reason === "Handled by standalone step"); - const skippedCustomJobResults = processingResult.results.filter(r => r.skipped && r.reason === "Handled by custom safe output job"); + const skippedStandaloneResults = processingResult.results.filter(r => r.delegated && r.reason === "Handled by standalone step"); + const skippedCustomJobResults = processingResult.results.filter(r => r.delegated && r.reason === "Handled by custom safe output job"); const skippedNoHandlerResults = processingResult.results.filter(r => !r.success && !r.skipped && r.error?.includes("No handler loaded")); - const skippedHandlerResults = processingResult.results.filter(r => r.skipped && !r.reason && !r.deferred && !r.cancelled); + const skippedHandlerResults = processingResult.results.filter(r => classifySafeOutputResult(r) === "skipped"); core.info(`\n=== Processing Summary ===`); core.info(`Total messages: ${processingResult.results.length}`); diff --git a/actions/setup/js/safe_output_handler_manager.test.cjs b/actions/setup/js/safe_output_handler_manager.test.cjs index cf32b6d1b53..12b57faf3dc 100644 --- a/actions/setup/js/safe_output_handler_manager.test.cjs +++ b/actions/setup/js/safe_output_handler_manager.test.cjs @@ -219,11 +219,53 @@ describe("Safe Output Handler Manager", () => { expect(status).toEqual({ itemsSucceeded: 2, + itemsApplied: 2, + itemsSkipped: 1, + itemsWarnings: 0, + itemsCancelled: 1, + itemsDeferred: 1, itemsFailed: 1, status: "partial_success", }); }); + it("computes skipped and warning counts without counting them as applied mutations", () => { + expect( + computeSafeOutputsStatus([ + { type: "add_comment", success: true, result: { success: true, skipped: true, warning: "Target locked" } }, + { type: "add_labels", success: false, skipped: true, result: { success: false, skipped: true, reasonCode: "REQUIRED_LABELS_MISMATCH" } }, + { type: "create_issue", success: true }, + ]) + ).toEqual({ + itemsSucceeded: 1, + itemsApplied: 1, + itemsSkipped: 2, + itemsWarnings: 0, + itemsCancelled: 0, + itemsDeferred: 0, + itemsFailed: 0, + status: "completed_with_skips", + }); + }); + + it("omits only explicitly delegated skips from outcome counts", () => { + expect( + computeSafeOutputsStatus([ + { type: "add_comment", success: false, skipped: true, reason: "Policy skipped this output" }, + { type: "noop", success: false, skipped: true, delegated: true, reason: "Handled by standalone step" }, + ]) + ).toEqual({ + itemsSucceeded: 0, + itemsApplied: 0, + itemsSkipped: 1, + itemsWarnings: 0, + itemsCancelled: 0, + itemsDeferred: 0, + itemsFailed: 0, + status: "completed_with_skips", + }); + }); + it("computes failure item status when all active results failed", () => { expect( computeSafeOutputsStatus([ @@ -232,6 +274,11 @@ describe("Safe Output Handler Manager", () => { ]) ).toEqual({ itemsSucceeded: 0, + itemsApplied: 0, + itemsSkipped: 0, + itemsWarnings: 0, + itemsCancelled: 0, + itemsDeferred: 0, itemsFailed: 2, status: "failure", }); @@ -245,6 +292,11 @@ describe("Safe Output Handler Manager", () => { }); expect(core.setOutput).toHaveBeenCalledWith("items_succeeded", "10"); + expect(core.setOutput).toHaveBeenCalledWith("items_applied", "10"); + expect(core.setOutput).toHaveBeenCalledWith("items_skipped", "0"); + expect(core.setOutput).toHaveBeenCalledWith("items_warnings", "0"); + expect(core.setOutput).toHaveBeenCalledWith("items_cancelled", "0"); + expect(core.setOutput).toHaveBeenCalledWith("items_deferred", "0"); expect(core.setOutput).toHaveBeenCalledWith("items_failed", "5"); expect(core.setOutput).toHaveBeenCalledWith("status", "partial_success"); }); @@ -614,6 +666,36 @@ describe("Safe Output Handler Manager", () => { expect(result.results[1].messageIndex).toBe(1); }); + it("preserves summary-safe diagnostics from skipped handler results", async () => { + const messages = [{ type: "add_comment", item_number: 123 }]; + const skippedResult = { + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + error: "Item does not match required-labels filter", + target: { repo: "owner/repo", number: 123 }, + safeDetails: { + requiredLabels: ["automation", "n-plus-1"], + missingLabels: ["automation"], + }, + }; + const handler = vi.fn().mockResolvedValue(skippedResult); + + const result = await processMessages(new Map([["add_comment", handler]]), messages); + + expect(result.success).toBe(true); + expect(result.results[0]).toMatchObject({ + type: "add_comment", + messageIndex: 0, + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + result: skippedResult, + }); + }); + it("should abort non-reviewable outputs in detection warning mode", async () => { process.env.GH_AW_DETECTION_CONCLUSION = "warning"; const messages = [{ type: "merge_pull_request" }, { type: "create_issue", title: "Review this", body: "Body" }]; @@ -1251,6 +1333,47 @@ describe("Safe Output Handler Manager", () => { expect(linkResult.deferred).toBe(false); }); + it.each([ + ["returned failure", () => ({ success: false, error: "Retry failed" })], + ["thrown error", () => Promise.reject(new Error("Retry failed"))], + ])("should classify a deferred retry %s as failed", async (_name, retryResult) => { + const handler = vi.fn().mockResolvedValueOnce({ deferred: true, error: "Unresolved temporary ID" }).mockImplementationOnce(retryResult); + const result = await processMessages(new Map([["link_sub_issue", handler]]), [{ type: "link_sub_issue", parent_issue_number: "aw_parent12", sub_issue_number: 42 }]); + + expect(result.results[0]).toMatchObject({ + type: "link_sub_issue", + success: false, + deferred: false, + error: "Retry failed", + result: { success: false, deferred: false, error: "Retry failed" }, + }); + expect(isFailedProcessingResult(result.results[0])).toBe(true); + }); + + it("preserves summary-safe diagnostics when a deferred retry is skipped", async () => { + const skippedResult = { + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + target: { repo: "owner/repo", number: 123 }, + safeDetails: { missingLabels: ["automation"] }, + }; + const handler = vi.fn().mockResolvedValueOnce({ deferred: true, error: "Unresolved temporary ID" }).mockResolvedValueOnce(skippedResult); + const result = await processMessages(new Map([["link_sub_issue", handler]]), [{ type: "link_sub_issue", parent_issue_number: "aw_parent12", sub_issue_number: 42 }]); + + expect(result.results[0]).toMatchObject({ + type: "link_sub_issue", + messageIndex: 0, + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + result: skippedResult, + }); + expect(isFailedProcessingResult(result.results[0])).toBe(false); + }); + it("should track outputs created during deferred retry with unresolved temp IDs", async () => { const messages = [ { diff --git a/actions/setup/js/safe_output_summary.cjs b/actions/setup/js/safe_output_summary.cjs index e3345c8265b..20361528a7d 100644 --- a/actions/setup/js/safe_output_summary.cjs +++ b/actions/setup/js/safe_output_summary.cjs @@ -10,7 +10,7 @@ const { displayFileContent } = require("./display_file_helpers.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { computeSafeOutputsStatus } = require("./safe_outputs_status.cjs"); +const { classifySafeOutputResult, computeSafeOutputsStatus, pickOutcomeField } = require("./safe_outputs_status.cjs"); const ERROR_CODES = require("./error_codes.cjs"); const { redactStepSummaryContent } = require("./redact_secrets.cjs"); @@ -25,6 +25,15 @@ const SUMMARY_SAFE_ERROR_CODES = new Set(Object.values(ERROR_CODES)); /** @type {string} Rendered when an error carries no allowlisted code prefix */ const UNCLASSIFIED_ERROR_CODE = "UNCLASSIFIED"; +const OUTCOME_DISPLAY = { + cancelled: { emoji: "🚫", status: "Cancelled" }, + deferred: { emoji: "⏸️", status: "Deferred" }, + skipped: { emoji: "⚠️", status: "Skipped" }, + warning: { emoji: "⚠️", status: "Warning" }, + success: { emoji: "✅", status: "Success" }, + failed: { emoji: "❌", status: "Failed" }, +}; + /** * Reduces an error message to an allowlisted error code so that raw exception text * (which may contain secrets) never reaches the step summary. @@ -162,6 +171,36 @@ function formatLabels(labels) { return names.length > 0 ? names.join(", ") : undefined; } +/** + * Render a list of safe detail values as inline code. + * @param {any} values + * @returns {string|undefined} + */ +function formatCodeList(values) { + if (!Array.isArray(values) || values.length === 0) return undefined; + return values.map(value => `\`${String(value)}\``).join(", "); +} + +/** + * Format a summary-safe diagnostic field without coupling the renderer to a handler. + * @param {string} key + * @returns {string} + */ +function formatSafeDetailLabel(key) { + const aliases = { requiredLabels: "Required", missingLabels: "Missing" }; + return aliases[key] || key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, character => character.toUpperCase()); +} + +/** + * @param {any} value + * @returns {string|undefined} + */ +function formatSafeDetailValue(value) { + if (Array.isArray(value)) return formatCodeList(value); + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return `\`${String(value)}\``; + return undefined; +} + /** * Build a canonical entity URL from a repository slug and number when a handler * did not report an explicit URL. GitHub redirects `/issues/` to the pull @@ -191,15 +230,58 @@ function buildEntityUrl(repo, number) { * @returns {string} Markdown for the target line (may be empty) */ function formatTargetLine(result, message) { - const number = pickFirstField(result, RESULT_NUMBER_FIELDS) ?? pickFirstField(message, RESULT_NUMBER_FIELDS); - const explicitRepo = pickFirstField(result, RESULT_REPO_FIELDS) ?? pickFirstField(message, MESSAGE_REPO_FIELDS); + const target = result?.target && typeof result.target === "object" ? result.target : undefined; + const number = pickFirstField(target, RESULT_NUMBER_FIELDS) ?? pickFirstField(result, RESULT_NUMBER_FIELDS) ?? pickFirstField(message, RESULT_NUMBER_FIELDS); + const explicitRepo = pickFirstField(target, RESULT_REPO_FIELDS) ?? pickFirstField(result, RESULT_REPO_FIELDS) ?? pickFirstField(message, MESSAGE_REPO_FIELDS); const fallbackRepo = explicitRepo ?? process.env.GITHUB_REPOSITORY; - const explicitUrl = pickFirstField(result, RESULT_URL_FIELDS) || pickFirstField(message, MESSAGE_URL_FIELDS); + const explicitUrl = pickFirstField(target, RESULT_URL_FIELDS) || pickFirstField(result, RESULT_URL_FIELDS) || pickFirstField(message, MESSAGE_URL_FIELDS); const url = explicitUrl || buildEntityUrl(fallbackRepo, number); const link = formatLink(url, formatEntityRef(explicitUrl ? explicitRepo : fallbackRepo, number)); return link ? `**Target:** ${link}\n\n` : ""; } +/** + * Render summary-safe diagnostics supplied by a handler. + * @param {any} result + * @param {any} message + * @param {string|undefined} error + * @param {string} outcome + * @returns {string} + */ +function formatOutcomeDiagnostics(result, message, error, outcome) { + let diagnostics = formatTargetLine(result, message); + const reasonCode = result?.reasonCode || result?.errorCode; + const reason = result?.reason || (outcome === "warning" ? result?.warning : undefined); + if (reasonCode) { + diagnostics += `**Reason Code:** \`${reasonCode}\`\n\n`; + } + if (reason) { + diagnostics += `**Reason:** ${reason}\n\n`; + } + const safeDetails = result?.safeDetails && typeof result.safeDetails === "object" ? result.safeDetails : undefined; + for (const [key, value] of Object.entries(safeDetails || {})) { + const formattedValue = formatSafeDetailValue(value); + if (formattedValue) { + diagnostics += `**${formatSafeDetailLabel(key)}:** ${formattedValue}\n\n`; + } + } + if (!reason && !reasonCode && error) { + diagnostics += `**Error:** \`${toSummarySafeErrorCode(error)}\` (see the job logs for details)\n\n`; + } + return diagnostics; +} + +/** + * @param {string} type + * @returns {string} + */ +function formatDisplayType(type) { + return type + .split("_") + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + /** * Generate a step summary for a single safe-output message * @param {Object} options - Summary generation options @@ -209,16 +291,17 @@ function formatTargetLine(result, message) { * @param {any} options.result - The result from the handler * @param {any} options.message - The original message * @param {string} [options.error] - Error message if processing failed + * @param {boolean} [options.skipped] - Whether the message was skipped + * @param {boolean} [options.cancelled] - Whether the message was cancelled + * @param {boolean} [options.deferred] - Whether the message was deferred + * @param {string} [options.warning] - Warning message if processing produced a warning * @returns {string} - Markdown content for the step summary */ function generateSafeOutputSummary(options) { const { type, messageIndex, success, result, message, error } = options; // Format the type for display (e.g., "create_issue" -> "Create Issue") - const displayType = type - .split("_") - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); + const displayType = formatDisplayType(type); // Detect fallback outcomes for code-push types. // Prefer explicit fallback_type when available; infer only for backward compatibility. @@ -227,9 +310,12 @@ function generateSafeOutputSummary(options) { const inferredFallbackType = isFallback && (result.pull_request_url || result.pull_request_number != null) ? "pull_request" : "issue"; const fallbackType = isFallback && result?.fallback_type ? result.fallback_type : inferredFallbackType; - // Choose emoji and status based on success and fallback - const emoji = isDuplicateDrop ? "⚠️" : isFallback ? "⚠️" : success ? "✅" : "❌"; - const status = isDuplicateDrop ? "Duplicate Dropped" : isFallback ? (fallbackType === "pull_request" ? "Fallback Pull Request Created" : "Fallback Issue Created") : success ? "Success" : "Failed"; + const outcome = classifySafeOutputResult(options); + const outcomeDisplay = OUTCOME_DISPLAY[outcome] || OUTCOME_DISPLAY.failed; + + // Choose emoji and status based on normalized outcome and fallback + const emoji = isDuplicateDrop ? "⚠️" : isFallback ? "⚠️" : outcomeDisplay.emoji; + const status = isDuplicateDrop ? "Duplicate Dropped" : isFallback ? (fallbackType === "pull_request" ? "Fallback Pull Request Created" : "Fallback Issue Created") : outcomeDisplay.status; // Start building the summary let summary = `
\n${emoji} ${displayType} - ${status} (Message ${messageIndex})\n\n`; @@ -277,7 +363,7 @@ function generateSafeOutputSummary(options) { summary += `**Title:** ${message.title}\n\n`; } } - } else if (success && result) { + } else if (outcome === "success" && result) { // Add a regular link to the entity the handler acted upon summary += formatTargetLine(result, message); if (result.temporaryId) { @@ -293,10 +379,8 @@ function generateSafeOutputSummary(options) { if (labels) { summary += `**Labels:** ${labels}\n\n`; } - } else if (error) { - // Show only an allowlisted error code; raw exception text and message content are - // omitted because they can embed URLs, payloads, or credentials. - summary += `**Error:** \`${toSummarySafeErrorCode(error)}\` (see the job logs for details)\n\n`; + } else if (outcome !== "success") { + summary += formatOutcomeDiagnostics(result, message, error, outcome); } // Display secrecy and integrity security metadata fields if present in the message. @@ -307,6 +391,7 @@ function generateSafeOutputSummary(options) { if (message.secrecy !== undefined && message.secrecy !== null) { summary += `**Secrecy:** \`${message.secrecy}\`\n\n`; } + if (message.integrity !== undefined && message.integrity !== null) { summary += `**Integrity:** \`${message.integrity}\`\n\n`; } @@ -317,6 +402,40 @@ function generateSafeOutputSummary(options) { return summary; } +/** + * Generate a compact grouped overview table for processed safe-output results. + * @param {Array} results + * @returns {string} + */ +function generateOutcomeOverview(results) { + const groups = new Map(); + for (const result of results) { + const outcome = classifySafeOutputResult(result); + if (outcome === "delegated") continue; + const handlerResult = result?.result && typeof result.result === "object" ? result.result : {}; + const reason = handlerResult.reason || result.reason || handlerResult.reasonCode || result.reasonCode || handlerResult.errorCode || result.errorCode || (outcome === "failed" ? toSummarySafeErrorCode(result.error) : ""); + const key = `${outcome}\0${result.type}\0${reason}`; + const current = groups.get(key) || { + outcome: OUTCOME_DISPLAY[outcome]?.status || outcome, + type: formatDisplayType(result.type || "unknown"), + count: 0, + reason, + }; + current.count += 1; + groups.set(key, current); + } + if (groups.size === 0) return ""; + let table = "| Outcome | Type | Count | Reason |\n|---|---|---:|---|\n"; + for (const group of groups.values()) { + const reason = String(group.reason || "") + .replace(/\\/g, "\\\\") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, "
"); + table += `| ${group.outcome} | ${group.type} | ${group.count} | ${reason} |\n`; + } + return `${table}\n`; +} + /** * Write safe-output summaries to the GitHub Actions step summary * @param {Array} results - Array of processing results @@ -347,15 +466,15 @@ async function writeSafeOutputSummaries(results, messages) { } const status = computeSafeOutputsStatus(results); - const statusEmoji = status.status === "success" ? "✅" : status.status === "partial_success" ? "⚠️" : "❌"; + const statusEmoji = status.itemsFailed > 0 ? "❌" : status.itemsSkipped > 0 || status.itemsWarnings > 0 || status.itemsCancelled > 0 || status.itemsDeferred > 0 ? "⚠️" : "✅"; // Lead with a collapsible section so this block matches the look of the other // run-summary sections (e.g. threat detection). - let summaryContent = `
\n${statusEmoji} Safe Output Processing Summary (${status.itemsSucceeded} succeeded, ${status.itemsFailed} failed)\n\n`; + let summaryContent = `
\n${statusEmoji} Safe Output Processing Summary (${status.itemsApplied} applied, ${status.itemsSkipped} skipped, ${status.itemsFailed} failed)\n\n`; summaryContent += `Processed ${results.length} safe-output message(s).\n\n`; summaryContent += `Status: **${status.status}**\n\n`; - summaryContent += `Items succeeded: **${status.itemsSucceeded}**\n\n`; - summaryContent += `Items failed: **${status.itemsFailed}**\n\n`; + summaryContent += `Applied: **${status.itemsApplied}** · Skipped: **${status.itemsSkipped}** · Warnings: **${status.itemsWarnings}** · Failed: **${status.itemsFailed}** · Cancelled: **${status.itemsCancelled}** · Deferred: **${status.itemsDeferred}**\n\n`; + summaryContent += generateOutcomeOverview(results); // Generate summary for each result for (const result of results) { @@ -365,7 +484,7 @@ async function writeSafeOutputSummaries(results, messages) { // handler itself returns { success: false, skipped: true } for a handler-side condition // (e.g. "no issue fields available"). Handler-returned skips still appear in the summary // so their diagnostic signal is preserved without the job failing. - if (result.skipped && result.reason) { + if (classifySafeOutputResult(result) === "delegated") { continue; } @@ -379,6 +498,10 @@ async function writeSafeOutputSummaries(results, messages) { result: result.result, message: message, error: result.error, + skipped: result.skipped, + cancelled: result.cancelled, + deferred: result.deferred, + warning: result.warning, }); } @@ -393,6 +516,7 @@ async function writeSafeOutputSummaries(results, messages) { } module.exports = { + generateOutcomeOverview, generateSafeOutputSummary, writeSafeOutputSummaries, }; diff --git a/actions/setup/js/safe_output_summary.test.cjs b/actions/setup/js/safe_output_summary.test.cjs index 7e710d08849..20a22e5adae 100644 --- a/actions/setup/js/safe_output_summary.test.cjs +++ b/actions/setup/js/safe_output_summary.test.cjs @@ -211,6 +211,80 @@ describe("safe_output_summary", () => { expect(summary).toContain("https://github.com/orgs/owner/projects/123"); }); + it("renders skipped policy diagnostics without classifying the item as failed", () => { + const summary = generateSafeOutputSummary({ + type: "add_comment", + messageIndex: 1, + success: false, + skipped: true, + result: { + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + target: { + repo: "github/github", + number: 434183, + url: "https://github.com/github/github/issues/434183", + }, + safeDetails: { + requiredLabels: ["automation", "n-plus-1"], + missingLabels: ["automation", "n-plus-1"], + }, + }, + message: {}, + }); + + expect(summary).toContain("⚠️ Add Comment - Skipped (Message 1)"); + expect(summary).not.toContain("Failed"); + expect(summary).toContain("[github/github#434183](https://github.com/github/github/issues/434183)"); + expect(summary).toContain("**Reason Code:** `REQUIRED_LABELS_MISMATCH`"); + expect(summary).toContain("**Reason:** Required labels missing"); + expect(summary).toContain("**Required:** `automation`, `n-plus-1`"); + expect(summary).toContain("**Missing:** `automation`, `n-plus-1`"); + }); + + it("renders success true skipped warning outcomes as skipped rather than success", () => { + const summary = generateSafeOutputSummary({ + type: "add_comment", + messageIndex: 2, + success: true, + result: { + success: true, + skipped: true, + warning: "Target is locked: raw API details are omitted", + reasonCode: "TARGET_LOCKED", + reason: "Target is locked", + target: { repo: "owner/repo", number: 5 }, + }, + message: {}, + }); + + expect(summary).toContain("⚠️ Add Comment - Skipped (Message 2)"); + expect(summary).not.toContain("- Success"); + expect(summary).not.toContain("- Failed"); + expect(summary).toContain("**Reason:** Target is locked"); + expect(summary).not.toContain("raw API details"); + }); + + it("renders handler-independent safe detail fields", () => { + const summary = generateSafeOutputSummary({ + type: "dispatch_workflow", + messageIndex: 3, + success: false, + result: { + success: false, + skipped: true, + reason: "Branch is not allowed", + safeDetails: { allowedBranches: ["main", "release"], protected: true }, + }, + message: {}, + }); + + expect(summary).toContain("**Allowed Branches:** `main`, `release`"); + expect(summary).toContain("**Protected:** `true`"); + }); + it("should display secrecy field when present in message", () => { const options = { type: "create_issue", @@ -842,8 +916,7 @@ describe("safe_output_summary", () => { expect(summaryContent).toContain("Safe Output Processing Summary"); expect(summaryContent).toContain("Processed 2 safe-output message(s)"); expect(summaryContent).toContain("Status: **success**"); - expect(summaryContent).toContain("Items succeeded: **2**"); - expect(summaryContent).toContain("Items failed: **0**"); + expect(summaryContent).toContain("Applied: **2** · Skipped: **0** · Warnings: **0** · Failed: **0** · Cancelled: **0** · Deferred: **0**"); expect(summaryContent).toContain("Create Issue"); expect(summaryContent).toContain("Create Project"); }); @@ -887,8 +960,83 @@ describe("safe_output_summary", () => { const summaryContent = mockCore.summary.addRaw.mock.calls[0][0]; expect(summaryContent).toContain("Status: **partial_success**"); - expect(summaryContent).toContain("Items succeeded: **1**"); - expect(summaryContent).toContain("Items failed: **1**"); + expect(summaryContent).toContain("Applied: **1** · Skipped: **0** · Warnings: **0** · Failed: **1** · Cancelled: **0** · Deferred: **0**"); + }); + + it("writes aggregate counts and grouped overview matching per-item classifications", async () => { + const results = [ + { + type: "add_comment", + messageIndex: 0, + success: true, + result: { success: true, repo: "owner/repo", number: 1 }, + }, + { + type: "add_comment", + messageIndex: 1, + success: false, + skipped: true, + error: "Required labels missing", + result: { + success: false, + skipped: true, + reasonCode: "REQUIRED_LABELS_MISMATCH", + reason: "Required labels missing", + target: { repo: "owner/repo", number: 2 }, + safeDetails: { requiredLabels: ["automation"], missingLabels: ["automation"] }, + }, + }, + { + type: "add_labels", + messageIndex: 2, + success: true, + skipped: true, + warning: "Target locked", + result: { + success: true, + skipped: true, + reasonCode: "TARGET_LOCKED", + reason: "Target is locked", + target: { repo: "owner/repo", number: 3 }, + }, + }, + { + type: "dispatch_workflow", + messageIndex: 3, + success: false, + error: "ERR_PERMISSION: denied", + result: null, + }, + { + type: "upload_artifact", + messageIndex: 4, + success: false, + deferred: true, + result: { success: false, deferred: true }, + }, + { + type: "merge_pull_request", + messageIndex: 5, + success: false, + cancelled: true, + errorCode: "THREAT_DETECTED", + reason: "Threat policy cancelled the output | blocked\nby policy", + }, + ]; + const messages = [{}, {}, {}, {}, {}, {}]; + + await writeSafeOutputSummaries(results, messages); + + const summaryContent = mockCore.summary.addRaw.mock.calls[0][0]; + expect(summaryContent).toContain("Status: **partial_success**"); + expect(summaryContent).toContain("Applied: **1** · Skipped: **2** · Warnings: **0** · Failed: **1** · Cancelled: **1** · Deferred: **1**"); + expect(summaryContent).toContain("| Skipped | Add Comment | 1 | Required labels missing |"); + expect(summaryContent).toContain("| Failed | Dispatch Workflow | 1 | ERR_PERMISSION |"); + expect(summaryContent).toContain("| Cancelled | Merge Pull Request | 1 | Threat policy cancelled the output \\| blocked
by policy |"); + expect(summaryContent).toContain("⚠️ Add Comment - Skipped (Message 2)"); + expect(summaryContent).toContain("❌ Dispatch Workflow - Failed (Message 4)"); + expect(summaryContent).toContain("⏸️ Upload Artifact - Deferred (Message 5)"); + expect(summaryContent).toContain("🚫 Merge Pull Request - Cancelled (Message 6)"); }); it("should skip results handled by standalone steps", async () => { @@ -904,6 +1052,7 @@ describe("safe_output_summary", () => { messageIndex: 1, success: false, skipped: true, + delegated: true, reason: "Handled by standalone step", }, ]; diff --git a/actions/setup/js/safe_outputs_status.cjs b/actions/setup/js/safe_outputs_status.cjs index ce2ca35567a..249a7156cb9 100644 --- a/actions/setup/js/safe_outputs_status.cjs +++ b/actions/setup/js/safe_outputs_status.cjs @@ -1,31 +1,92 @@ // @ts-check +/** + * @typedef {"delegated"|"cancelled"|"deferred"|"skipped"|"warning"|"success"|"failed"} SafeOutputOutcome + */ + +/** + * Read an outcome field from either the processing record or its nested handler result. + * + * @param {any} result + * @param {string} field + * @returns {any} + */ +function pickOutcomeField(result, field) { + if (result && typeof result === "object" && result[field] !== undefined) { + return result[field]; + } + const handlerResult = result?.result; + if (handlerResult && typeof handlerResult === "object" && handlerResult[field] !== undefined) { + return handlerResult[field]; + } + return undefined; +} + +/** + * Classify a processing result using the shared safe-output outcome precedence. + * + * @param {any} result + * @returns {SafeOutputOutcome} + */ +function classifySafeOutputResult(result) { + if (result?.delegated === true) return "delegated"; + if (pickOutcomeField(result, "cancelled")) return "cancelled"; + if (pickOutcomeField(result, "deferred")) return "deferred"; + // A skipped operation did not apply a mutation, even when it carries a warning. + if (pickOutcomeField(result, "skipped")) return "skipped"; + if (pickOutcomeField(result, "warning")) return "warning"; + if (pickOutcomeField(result, "success")) return "success"; + return "failed"; +} + /** * Determine whether a processing result is a non-skipped, non-deferred, non-cancelled failure. * - * @param {{success?: boolean, deferred?: boolean, skipped?: boolean, cancelled?: boolean}|null|undefined} result + * @param {any} result * @returns {boolean} */ function isFailedProcessingResult(result) { - return Boolean(result?.success === false && !result?.deferred && !result?.skipped && !result?.cancelled); + return classifySafeOutputResult(result) === "failed"; } /** * Compute item-level safe-output status for logs, step summary, and GitHub Actions outputs. * - * @param {Array<{success?: boolean, deferred?: boolean, skipped?: boolean, cancelled?: boolean}>|null|undefined} results - * @returns {{itemsSucceeded: number, itemsFailed: number, status: "success" | "partial_success" | "failure"}} + * @param {Array|null|undefined} results + * @returns {{itemsSucceeded: number, itemsApplied: number, itemsSkipped: number, itemsWarnings: number, itemsCancelled: number, itemsDeferred: number, itemsFailed: number, status: "success" | "completed_with_skips" | "completed_with_warnings" | "cancelled" | "deferred" | "partial_success" | "failure"}} */ function computeSafeOutputsStatus(results) { const safeResults = Array.isArray(results) ? results : []; - const itemsSucceeded = safeResults.filter(r => r?.success).length; - const itemsFailed = safeResults.filter(isFailedProcessingResult).length; - const status = itemsFailed === 0 ? "success" : itemsSucceeded > 0 ? "partial_success" : "failure"; + const outcomes = safeResults.map(classifySafeOutputResult).filter(outcome => outcome !== "delegated"); + const count = outcome => outcomes.filter(itemOutcome => itemOutcome === outcome).length; + const itemsApplied = count("success"); + const itemsSkipped = count("skipped"); + const itemsWarnings = count("warning"); + const itemsCancelled = count("cancelled"); + const itemsDeferred = count("deferred"); + const itemsFailed = count("failed"); + const itemsSucceeded = itemsApplied; + const status = + itemsFailed > 0 + ? itemsApplied > 0 || itemsSkipped > 0 || itemsWarnings > 0 || itemsCancelled > 0 || itemsDeferred > 0 + ? "partial_success" + : "failure" + : itemsCancelled > 0 + ? "cancelled" + : itemsDeferred > 0 + ? "deferred" + : itemsSkipped > 0 + ? "completed_with_skips" + : itemsWarnings > 0 + ? "completed_with_warnings" + : "success"; - return { itemsSucceeded, itemsFailed, status }; + return { itemsSucceeded, itemsApplied, itemsSkipped, itemsWarnings, itemsCancelled, itemsDeferred, itemsFailed, status }; } module.exports = { + classifySafeOutputResult, computeSafeOutputsStatus, isFailedProcessingResult, + pickOutcomeField, }; diff --git a/actions/setup/js/types/handler-factory.d.ts b/actions/setup/js/types/handler-factory.d.ts index b98e602bb8c..67db2503040 100644 --- a/actions/setup/js/types/handler-factory.d.ts +++ b/actions/setup/js/types/handler-factory.d.ts @@ -48,6 +48,16 @@ interface ResolvedTemporaryIds { interface HandlerSuccessResult { /** Indicates the operation was successful */ success: true; + /** True when the handler intentionally skipped/no-oped without applying a mutation */ + skipped?: boolean; + /** Machine-readable summary-safe reason code */ + reasonCode?: string; + /** Human-readable summary-safe reason */ + reason?: string; + /** Summary-safe target metadata */ + target?: { repo?: string; number?: number; url?: string }; + /** Additional summary-safe diagnostic fields */ + safeDetails?: Record; /** Additional result properties (number, url, temporaryId, etc.) */ [key: string]: any; } @@ -59,7 +69,21 @@ interface HandlerErrorResult { /** Indicates the operation failed */ success: false; /** Error message describing what went wrong */ - error: string; + error?: string; + /** True when the handler intentionally skipped/no-oped without applying a mutation */ + skipped?: boolean; + /** True when the handler deferred processing for a later retry */ + deferred?: boolean; + /** True when processing was cancelled by policy or upstream failure */ + cancelled?: boolean; + /** Machine-readable summary-safe reason code */ + reasonCode?: string; + /** Human-readable summary-safe reason */ + reason?: string; + /** Summary-safe target metadata */ + target?: { repo?: string; number?: number; url?: string }; + /** Additional summary-safe diagnostic fields */ + safeDetails?: Record; /** Additional result properties (skipped, etc.) */ [key: string]: any; } diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 7e8afb21a53..1e171a9641a 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -393,6 +393,11 @@ func addHandlerManagerOutputs(data *WorkflowData, outputs map[string]string) { "process_safe_outputs_temporary_id_map": "${{ steps.process_safe_outputs.outputs.temporary_id_map }}", "process_safe_outputs_processed_count": "${{ steps.process_safe_outputs.outputs.processed_count }}", "process_safe_outputs_items_succeeded": "${{ steps.process_safe_outputs.outputs.items_succeeded }}", + "process_safe_outputs_items_applied": "${{ steps.process_safe_outputs.outputs.items_applied }}", + "process_safe_outputs_items_skipped": "${{ steps.process_safe_outputs.outputs.items_skipped }}", + "process_safe_outputs_items_warnings": "${{ steps.process_safe_outputs.outputs.items_warnings }}", + "process_safe_outputs_items_cancelled": "${{ steps.process_safe_outputs.outputs.items_cancelled }}", + "process_safe_outputs_items_deferred": "${{ steps.process_safe_outputs.outputs.items_deferred }}", "process_safe_outputs_items_failed": "${{ steps.process_safe_outputs.outputs.items_failed }}", "process_safe_outputs_status": "${{ steps.process_safe_outputs.outputs.status }}", "create_discussion_errors": "${{ steps.process_safe_outputs.outputs.create_discussion_errors }}", diff --git a/pkg/workflow/compiler_safe_outputs_job_test.go b/pkg/workflow/compiler_safe_outputs_job_test.go index 10f6d82eccd..54bf89ebcd4 100644 --- a/pkg/workflow/compiler_safe_outputs_job_test.go +++ b/pkg/workflow/compiler_safe_outputs_job_test.go @@ -247,6 +247,11 @@ func TestAddHandlerManagerOutputs(t *testing.T) { addNamedSafeOutputHandlerOutputs(data, outputs) assert.Equal(t, "${{ steps.process_safe_outputs.outputs.processed_count }}", outputs["process_safe_outputs_processed_count"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_applied }}", outputs["process_safe_outputs_items_applied"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_skipped }}", outputs["process_safe_outputs_items_skipped"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_warnings }}", outputs["process_safe_outputs_items_warnings"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_cancelled }}", outputs["process_safe_outputs_items_cancelled"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_deferred }}", outputs["process_safe_outputs_items_deferred"]) assert.Equal(t, "${{ steps.process_safe_outputs.outputs.assign_to_agent_assigned }}", outputs["assign_to_agent_assigned"]) assert.Equal(t, "${{ steps.process_safe_outputs.outputs.assign_to_agent_assignment_errors }}", outputs["assign_to_agent_assignment_errors"]) assert.Equal(t, "${{ steps.process_safe_outputs.outputs.assign_to_agent_assignment_error_count }}", outputs["assign_to_agent_assignment_error_count"]) @@ -960,12 +965,22 @@ func TestJobOutputs(t *testing.T) { assert.Contains(t, job.Outputs, "process_safe_outputs_temporary_id_map") assert.Contains(t, job.Outputs, "process_safe_outputs_processed_count") assert.Contains(t, job.Outputs, "process_safe_outputs_items_succeeded") + assert.Contains(t, job.Outputs, "process_safe_outputs_items_applied") + assert.Contains(t, job.Outputs, "process_safe_outputs_items_skipped") + assert.Contains(t, job.Outputs, "process_safe_outputs_items_warnings") + assert.Contains(t, job.Outputs, "process_safe_outputs_items_cancelled") + assert.Contains(t, job.Outputs, "process_safe_outputs_items_deferred") assert.Contains(t, job.Outputs, "process_safe_outputs_items_failed") assert.Contains(t, job.Outputs, "process_safe_outputs_status") // Check output format assert.Contains(t, job.Outputs["process_safe_outputs_temporary_id_map"], "steps.process_safe_outputs.outputs") assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_succeeded }}", job.Outputs["process_safe_outputs_items_succeeded"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_applied }}", job.Outputs["process_safe_outputs_items_applied"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_skipped }}", job.Outputs["process_safe_outputs_items_skipped"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_warnings }}", job.Outputs["process_safe_outputs_items_warnings"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_cancelled }}", job.Outputs["process_safe_outputs_items_cancelled"]) + assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_deferred }}", job.Outputs["process_safe_outputs_items_deferred"]) assert.Equal(t, "${{ steps.process_safe_outputs.outputs.items_failed }}", job.Outputs["process_safe_outputs_items_failed"]) assert.Equal(t, "${{ steps.process_safe_outputs.outputs.status }}", job.Outputs["process_safe_outputs_status"]) }