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
37 changes: 9 additions & 28 deletions actions/setup/js/generate_workflow_overview.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { jsonObjectToMarkdown } = require("./json_object_to_markdown.cjs");

/**
* Generate workflow overview step that writes an agentic workflow run overview
* to the GitHub step summary. This reads from aw_info.json that was created by
Expand All @@ -16,36 +18,15 @@ async function generateWorkflowOverview(core) {
// Load aw_info.json
const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8"));

let networkDetails = "";
if (awInfo.allowed_domains && awInfo.allowed_domains.length > 0) {
networkDetails = awInfo.allowed_domains
.slice(0, 10)
.map(d => ` - ${d}`)
.join("\n");
if (awInfo.allowed_domains.length > 10) {
networkDetails += `\n - ... and ${awInfo.allowed_domains.length - 10} more`;
}
}
// Build the collapsible summary label with engine_id and version
const engineLabel = [awInfo.engine_id, awInfo.version].filter(Boolean).join(" ");
const summaryLabel = engineLabel ? `Run details - ${engineLabel}` : "Run details";

// Render all aw_info fields as markdown bullet points
const details = jsonObjectToMarkdown(awInfo);

// Build summary using string concatenation to avoid YAML parsing issues with template literals
const summary =
"<details>\n" +
"<summary>Run details</summary>\n\n" +
"#### Engine Configuration\n" +
"| Property | Value |\n" +
"|----------|-------|\n" +
`| Engine ID | ${awInfo.engine_id} |\n` +
`| Engine Name | ${awInfo.engine_name} |\n` +
`| Model | ${awInfo.model || "(default)"} |\n` +
"\n" +
"#### Network Configuration\n" +
"| Property | Value |\n" +
"|----------|-------|\n" +
`| Firewall | ${awInfo.firewall_enabled ? "✅ Enabled" : "❌ Disabled"} |\n` +
`| Firewall Version | ${awInfo.awf_version || "(latest)"} |\n` +
"\n" +
(networkDetails ? `##### Allowed Domains\n${networkDetails}\n` : "") +
"</details>";
const summary = "<details>\n" + `<summary>${summaryLabel}</summary>\n\n` + details + "\n" + "</details>";
Comment on lines +21 to +29

await core.summary.addRaw(summary).write();
console.log("Generated workflow overview in step summary");
Expand Down
81 changes: 30 additions & 51 deletions actions/setup/js/generate_workflow_overview.test.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";

// Mock the global objects that GitHub Actions provides
const mockCore = {
Expand All @@ -22,9 +20,7 @@ global.core = mockCore;

describe("generate_workflow_overview.cjs", () => {
let generateWorkflowOverview;
let tmpDir;
let awInfoPath;
let originalRequireCache;

beforeEach(async () => {
// Reset mocks
Expand Down Expand Up @@ -54,6 +50,7 @@ describe("generate_workflow_overview.cjs", () => {
engine_id: "copilot",
engine_name: "GitHub Copilot",
model: "gpt-4",
version: "v1.2.3",
firewall_enabled: true,
awf_version: "1.0.0",
allowed_domains: [],
Expand All @@ -67,19 +64,22 @@ describe("generate_workflow_overview.cjs", () => {

const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryArg).toContain("<details>");
expect(summaryArg).toContain("<summary>Run details</summary>");
expect(summaryArg).toContain("#### Engine Configuration");
expect(summaryArg).toContain("| Engine ID | copilot |");
expect(summaryArg).toContain("| Engine Name | GitHub Copilot |");
expect(summaryArg).toContain("| Model | gpt-4 |");
expect(summaryArg).toContain("#### Network Configuration");
expect(summaryArg).toContain("| Firewall | ✅ Enabled |");
expect(summaryArg).toContain("| Firewall Version | 1.0.0 |");
// engine_id and version should appear in the summary label
expect(summaryArg).toContain("<summary>Run details - copilot v1.2.3</summary>");
// All fields should be rendered as bullet points with humanified keys
expect(summaryArg).toContain("- **engine id**: copilot");
expect(summaryArg).toContain("- **engine name**: GitHub Copilot");
expect(summaryArg).toContain("- **model**: gpt-4");
expect(summaryArg).toContain("- **version**: v1.2.3");
expect(summaryArg).toContain("- **firewall enabled**: true");
expect(summaryArg).toContain("- **awf version**: 1.0.0");
expect(summaryArg).toContain("</details>");
// Ensure no table syntax is present
expect(summaryArg).not.toContain("| Property | Value |");
expect(summaryArg).not.toContain("|----------|-------|");
});

it("should handle missing optional fields with defaults", async () => {
// Create test aw_info.json with minimal fields
it("should show only engine_id in summary label when version is missing", async () => {
const awInfo = {
engine_id: "claude",
engine_name: "Claude",
Expand All @@ -90,62 +90,41 @@ describe("generate_workflow_overview.cjs", () => {
await generateWorkflowOverview(mockCore);

const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryArg).toContain("| Model | (default) |");
expect(summaryArg).toContain("| Firewall | ❌ Disabled |");
expect(summaryArg).toContain("| Firewall Version | (latest) |");
});

it("should include allowed domains when present (up to 10)", async () => {
const awInfo = {
engine_id: "copilot",
engine_name: "GitHub Copilot",
firewall_enabled: true,
allowed_domains: ["example.com", "github.com", "api.github.com"],
};
fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));

await generateWorkflowOverview(mockCore);

const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryArg).toContain("##### Allowed Domains");
expect(summaryArg).toContain(" - example.com");
expect(summaryArg).toContain(" - github.com");
expect(summaryArg).toContain(" - api.github.com");
expect(summaryArg).toContain("<summary>Run details - claude</summary>");
expect(summaryArg).toContain("- **engine id**: claude");
expect(summaryArg).toContain("- **firewall enabled**: false");
});

it("should truncate allowed domains list when more than 10", async () => {
const domains = Array.from({ length: 15 }, (_, i) => `domain${i + 1}.com`);
it("should show plain 'Run details' in summary label when both engine_id and version are missing", async () => {
const awInfo = {
engine_id: "copilot",
engine_name: "GitHub Copilot",
firewall_enabled: true,
allowed_domains: domains,
engine_name: "Unknown Engine",
};
fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));

await generateWorkflowOverview(mockCore);

const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryArg).toContain("##### Allowed Domains");
expect(summaryArg).toContain(" - domain1.com");
expect(summaryArg).toContain(" - domain10.com");
expect(summaryArg).toContain(" - ... and 5 more");
expect(summaryArg).not.toContain("domain11.com");
expect(summaryArg).toContain("<summary>Run details</summary>");
});

it("should not include Allowed Domains section when empty", async () => {
it("should render all fields from aw_info including nested objects and arrays", async () => {
const awInfo = {
engine_id: "copilot",
engine_name: "GitHub Copilot",
firewall_enabled: false,
allowed_domains: [],
version: "v2.0.0",
allowed_domains: ["example.com", "github.com"],
steps: { firewall: "iptables" },
};
fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));

await generateWorkflowOverview(mockCore);

const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
expect(summaryArg).not.toContain("##### Allowed Domains");
expect(summaryArg).toContain("- **engine id**: copilot");
expect(summaryArg).toContain("- **allowed domains**:");
expect(summaryArg).toContain(" - example.com");
expect(summaryArg).toContain(" - github.com");
expect(summaryArg).toContain("- **steps**:");
expect(summaryArg).toContain(" - **firewall**: iptables");
});

it("should log success message", async () => {
Expand Down
81 changes: 81 additions & 0 deletions actions/setup/js/json_object_to_markdown.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// @ts-check

/**
* JSON Object to Markdown Converter
*
* Converts a plain JavaScript object to a Markdown bullet list.
* Handles nested objects (with indentation), arrays, and primitive values.
*/

/**
* Humanify a JSON key by replacing underscores and hyphens with spaces.
* e.g. "engine_id" → "engine id", "awf-version" → "awf version"
* @param {string} key - The raw object key
* @returns {string} - Human-readable key
*/
function humanifyKey(key) {
return key.replace(/[_-]/g, " ");
}

/**
* Format a single value as a readable string for Markdown output.
* @param {unknown} value - The value to format
* @returns {string} - String representation of the value
*/
function formatValue(value) {
if (value === null || value === undefined || value === "") {
return "(none)";
}
if (Array.isArray(value)) {
return value.length === 0 ? "(none)" : "";
}
if (typeof value === "object") {
return "";
}
return String(value);
}

/**
* Convert a plain JavaScript object to Markdown bullet points.
* Nested objects and arrays are rendered as indented sub-lists.
*
* @param {Record<string, unknown>} obj - The object to render
* @param {number} [depth=0] - Current indentation depth
* @returns {string} - Markdown bullet list string
*/
function jsonObjectToMarkdown(obj, depth = 0) {
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
return "";
}

const indent = " ".repeat(depth);
const lines = [];

for (const [key, value] of Object.entries(obj)) {
const label = humanifyKey(key);
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${indent}- **${label}**: (none)`);
} else {
lines.push(`${indent}- **${label}**:`);
for (const item of value) {
if (typeof item === "object" && item !== null) {
lines.push(jsonObjectToMarkdown(/** @type {Record<string, unknown>} */ item, depth + 1));
} else {
lines.push(`${" ".repeat(depth + 1)}- ${String(item)}`);
}
Comment on lines +61 to +66
}
}
} else if (typeof value === "object" && value !== null) {
lines.push(`${indent}- **${label}**:`);
lines.push(jsonObjectToMarkdown(/** @type {Record<string, unknown>} */ value, depth + 1));
} else {
const formatted = formatValue(value);
lines.push(`${indent}- **${label}**: ${formatted}`);
}
}

return lines.join("\n");
}

module.exports = { humanifyKey, jsonObjectToMarkdown };
79 changes: 79 additions & 0 deletions actions/setup/js/json_object_to_markdown.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";

const { humanifyKey, jsonObjectToMarkdown } = await import("./json_object_to_markdown.cjs");

describe("json_object_to_markdown.cjs", () => {
describe("humanifyKey", () => {
it("should replace underscores with spaces", () => {
expect(humanifyKey("engine_id")).toBe("engine id");
expect(humanifyKey("firewall_enabled")).toBe("firewall enabled");
expect(humanifyKey("awf_version")).toBe("awf version");
});

it("should replace hyphens with spaces", () => {
expect(humanifyKey("run-id")).toBe("run id");
expect(humanifyKey("awf-version")).toBe("awf version");
});

it("should leave keys without separators unchanged", () => {
expect(humanifyKey("version")).toBe("version");
expect(humanifyKey("model")).toBe("model");
});
});

it("should render flat key-value pairs with humanified keys", () => {
const obj = { engine_id: "copilot", version: "v1.0.0" };
const result = jsonObjectToMarkdown(obj);
expect(result).toContain("- **engine id**: copilot");
expect(result).toContain("- **version**: v1.0.0");
});

it("should render boolean values as true/false strings", () => {
const obj = { firewall_enabled: true, staged: false };
const result = jsonObjectToMarkdown(obj);
expect(result).toContain("- **firewall enabled**: true");
expect(result).toContain("- **staged**: false");
});

it("should render null/undefined/empty string values as (none)", () => {
const obj = { model: "", awf_version: null, agent_version: undefined };
const result = jsonObjectToMarkdown(obj);
expect(result).toContain("- **model**: (none)");
expect(result).toContain("- **awf version**: (none)");
expect(result).toContain("- **agent version**: (none)");
});

it("should render non-empty arrays as sub-bullet lists", () => {
const obj = { allowed_domains: ["example.com", "github.com"] };
const result = jsonObjectToMarkdown(obj);
expect(result).toContain("- **allowed domains**:");
expect(result).toContain(" - example.com");
expect(result).toContain(" - github.com");
});

it("should render empty arrays as (none)", () => {
const obj = { allowed_domains: [] };
const result = jsonObjectToMarkdown(obj);
expect(result).toContain("- **allowed domains**: (none)");
});

it("should render nested objects as indented sub-bullet lists", () => {
const obj = { steps: { firewall: "iptables" } };
const result = jsonObjectToMarkdown(obj);
expect(result).toContain("- **steps**:");
expect(result).toContain(" - **firewall**: iptables");
});

it("should return empty string for null or non-object input", () => {
expect(jsonObjectToMarkdown(null)).toBe("");
expect(jsonObjectToMarkdown(undefined)).toBe("");
expect(jsonObjectToMarkdown([])).toBe("");
});

it("should handle numeric values", () => {
const obj = { run_id: 12345, run_number: 7 };
const result = jsonObjectToMarkdown(obj);
expect(result).toContain("- **run id**: 12345");
expect(result).toContain("- **run number**: 7");
});
});
Loading