Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/patch-add-safe-output-summaries.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const { getErrorMessage } = require("./error_helpers.cjs");
const { hasUnresolvedTemporaryIds, replaceTemporaryIdReferences, normalizeTemporaryId } = require("./temporary_id.cjs");
const { generateMissingInfoSections } = require("./missing_info_formatter.cjs");
const { setCollectedMissings } = require("./missing_messages_helper.cjs");
const { writeSafeOutputSummaries } = require("./safe_output_summary.cjs");

const DEFAULT_AGENTIC_CAMPAIGN_LABEL = "agentic-campaign";

Expand Down Expand Up @@ -835,6 +836,9 @@ async function main() {
syntheticUpdateCount = await processSyntheticUpdates(github, context, processingResult.outputsWithUnresolvedIds, temporaryIdMap);
}

// Write step summaries for all processed safe-outputs
await writeSafeOutputSummaries(processingResult.results, agentOutput.items);

// Log summary
const successCount = processingResult.results.filter(r => r.success).length;
const failureCount = processingResult.results.filter(r => !r.success && !r.deferred && !r.skipped).length;
Expand Down
4 changes: 4 additions & 0 deletions actions/setup/js/safe_output_project_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

const { loadAgentOutput } = require("./load_agent_output.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { writeSafeOutputSummaries } = require("./safe_output_summary.cjs");

/**
* Handler map configuration for project-related safe outputs
Expand Down Expand Up @@ -226,6 +227,9 @@ async function main() {
// Process messages
const { results, processedCount, temporaryProjectMap } = await processMessages(messageHandlers, messages);

// Write step summaries for all processed safe-outputs
await writeSafeOutputSummaries(results, messages);

// Set outputs
core.setOutput("processed_count", processedCount);

Expand Down
131 changes: 131 additions & 0 deletions actions/setup/js/safe_output_summary.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* Safe Output Summary Generator
*
* This module provides functionality to generate step summaries for safe-output messages.
* Each processed safe-output generates a summary enclosed in a <details> section.
*/

/**
* Generate a step summary for a single safe-output message
* @param {Object} options - Summary generation options
* @param {string} options.type - The safe-output type (e.g., "create_issue", "create_project")
* @param {number} options.messageIndex - The message index (1-based)
* @param {boolean} options.success - Whether the message was processed successfully
* @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
* @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(" ");

// Choose emoji and status based on success
const emoji = success ? "✅" : "❌";
const status = success ? "Success" : "Failed";

// Start building the summary
let summary = `<details>\n<summary>${emoji} ${displayType} - ${status} (Message ${messageIndex})</summary>\n\n`;

// Add message details
summary += `### ${displayType}\n\n`;

if (success && result) {
// Add result-specific information based on type
if (result.url) {
summary += `**URL:** ${result.url}\n\n`;
}
if (result.repo && result.number) {
summary += `**Location:** ${result.repo}#${result.number}\n\n`;
}
if (result.projectUrl) {
summary += `**Project URL:** ${result.projectUrl}\n\n`;
}
if (result.temporaryId) {
summary += `**Temporary ID:** \`${result.temporaryId}\`\n\n`;
}

// Add original message details if available
if (message) {
if (message.title) {
summary += `**Title:** ${message.title}\n\n`;
}
if (message.body && typeof message.body === "string") {
// Truncate body if too long
const maxBodyLength = 500;
const bodyPreview = message.body.length > maxBodyLength ? message.body.substring(0, maxBodyLength) + "..." : message.body;
summary += `**Body Preview:**\n\`\`\`\n${bodyPreview}\n\`\`\`\n\n`;
}
if (message.labels && Array.isArray(message.labels)) {
summary += `**Labels:** ${message.labels.join(", ")}\n\n`;
}
}
} else if (error) {
// Show error information
summary += `**Error:** ${error}\n\n`;

// Add original message details for debugging
if (message) {
summary += `**Message Details:**\n\`\`\`json\n${JSON.stringify(message, null, 2).substring(0, 1000)}\n\`\`\`\n\n`;
}
}

summary += `</details>\n\n`;

return summary;
}

/**
* Write safe-output summaries to the GitHub Actions step summary
* @param {Array<Object>} results - Array of processing results
* @param {Array<Object>} messages - Array of original messages
* @returns {Promise<void>}
*/
async function writeSafeOutputSummaries(results, messages) {
if (!results || results.length === 0) {
return;
}

let summaryContent = `## Safe Output Processing Summary\n\n`;
summaryContent += `Processed ${results.length} safe-output message(s).\n\n`;

// Generate summary for each result
for (const result of results) {
// Skip if this was handled by a standalone step
if (result.skipped) {
continue;
}

// Get the original message
const message = messages[result.messageIndex];

summaryContent += generateSafeOutputSummary({
type: result.type,
messageIndex: result.messageIndex + 1, // Convert to 1-based
success: result.success,
result: result.result,
message: message,
error: result.error,
});
}

try {
await core.summary.addRaw(summaryContent).write();
core.info(`📝 Safe output summaries written to step summary`);
} catch (error) {
core.warning(`Failed to write safe output summaries: ${error instanceof Error ? error.message : String(error)}`);
}
}

module.exports = {
generateSafeOutputSummary,
writeSafeOutputSummaries,
};
211 changes: 211 additions & 0 deletions actions/setup/js/safe_output_summary.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, vi } from "vitest";

// Mock the global objects that GitHub Actions provides
const mockCore = {
info: vi.fn(),
warning: vi.fn(),
summary: {
addRaw: vi.fn().mockReturnThis(),
write: vi.fn().mockResolvedValue(undefined),
},
};

// Set up global mocks before importing the module
globalThis.core = mockCore;

const { generateSafeOutputSummary, writeSafeOutputSummaries } = await import("./safe_output_summary.cjs");

describe("safe_output_summary", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("generateSafeOutputSummary", () => {
it("should generate summary for successful create_issue", () => {
const options = {
type: "create_issue",
messageIndex: 1,
success: true,
result: {
repo: "owner/repo",
number: 123,
url: "https://github.com/owner/repo/issues/123",
temporaryId: "issue-1",
},
message: {
title: "Test Issue",
body: "This is a test issue body",
labels: ["bug", "enhancement"],
},
};

const summary = generateSafeOutputSummary(options);

expect(summary).toContain("<details>");
expect(summary).toContain("</details>");
expect(summary).toContain("✅");
expect(summary).toContain("Create Issue");
expect(summary).toContain("Message 1");
expect(summary).toContain("owner/repo#123");
expect(summary).toContain("https://github.com/owner/repo/issues/123");
expect(summary).toContain("issue-1");
expect(summary).toContain("Test Issue");
expect(summary).toContain("bug, enhancement");
});

it("should generate summary for failed message with error", () => {
const options = {
type: "create_project",
messageIndex: 2,
success: false,
result: null,
message: {
title: "Test Project",
},
error: "Failed to create project: permission denied",
};

const summary = generateSafeOutputSummary(options);

expect(summary).toContain("❌");
expect(summary).toContain("Failed");
expect(summary).toContain("Create Project");
expect(summary).toContain("Message 2");
expect(summary).toContain("permission denied");
});

it("should truncate long body content", () => {
const longBody = "a".repeat(1000);

const options = {
type: "create_discussion",
messageIndex: 3,
success: true,
result: {
repo: "owner/repo",
number: 456,
},
message: {
title: "Test Discussion",
body: longBody,
},
};

const summary = generateSafeOutputSummary(options);

expect(summary).toContain("Body Preview");
expect(summary).toContain("...");
expect(summary.length).toBeLessThan(longBody.length + 1000);
});

it("should handle project-specific results", () => {
const options = {
type: "create_project",
messageIndex: 4,
success: true,
result: {
projectUrl: "https://github.com/orgs/owner/projects/123",
},
message: {
title: "Test Project",
},
};

const summary = generateSafeOutputSummary(options);

expect(summary).toContain("Project URL");
expect(summary).toContain("https://github.com/orgs/owner/projects/123");
});
});

describe("writeSafeOutputSummaries", () => {
it("should write summaries for multiple results", async () => {
const results = [
{
type: "create_issue",
messageIndex: 0,
success: true,
result: {
repo: "owner/repo",
number: 123,
url: "https://github.com/owner/repo/issues/123",
},
},
{
type: "create_project",
messageIndex: 1,
success: true,
result: {
projectUrl: "https://github.com/orgs/owner/projects/456",
},
},
];

const messages = [{ title: "Issue 1", body: "Body 1" }, { title: "Project 1" }];

await writeSafeOutputSummaries(results, messages);

expect(mockCore.summary.addRaw).toHaveBeenCalledTimes(1);
expect(mockCore.summary.write).toHaveBeenCalledTimes(1);
expect(mockCore.info).toHaveBeenCalledWith("📝 Safe output summaries written to step summary");

const summaryContent = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryContent).toContain("Safe Output Processing Summary");
expect(summaryContent).toContain("Processed 2 safe-output message(s)");
expect(summaryContent).toContain("Create Issue");
expect(summaryContent).toContain("Create Project");
});

it("should skip results handled by standalone steps", async () => {
const results = [
{
type: "create_issue",
messageIndex: 0,
success: true,
result: { repo: "owner/repo", number: 123 },
},
{
type: "noop",
messageIndex: 1,
success: false,
skipped: true,
reason: "Handled by standalone step",
},
];

const messages = [{ title: "Issue 1" }, { message: "Noop message" }];

await writeSafeOutputSummaries(results, messages);

const summaryContent = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryContent).toContain("Create Issue");
expect(summaryContent).not.toContain("Noop");
});

it("should handle empty results", async () => {
await writeSafeOutputSummaries([], []);

expect(mockCore.summary.addRaw).not.toHaveBeenCalled();
expect(mockCore.summary.write).not.toHaveBeenCalled();
});

it("should handle write failures gracefully", async () => {
mockCore.summary.write.mockRejectedValueOnce(new Error("Write failed"));

const results = [
{
type: "create_issue",
messageIndex: 0,
success: true,
result: { repo: "owner/repo", number: 123 },
},
];

const messages = [{ title: "Issue 1" }];

await writeSafeOutputSummaries(results, messages);

expect(mockCore.warning).toHaveBeenCalledWith("Failed to write safe output summaries: Write failed");
});
});
});
Loading