Skip to content

Commit 62f815e

Browse files
authored
Improve run details step summary: bullet points, aw version, and full aw_info rendering (#20989)
1 parent 9079cb1 commit 62f815e

4 files changed

Lines changed: 199 additions & 79 deletions

File tree

actions/setup/js/generate_workflow_overview.cjs

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// @ts-check
22
/// <reference types="@actions/github-script" />
33

4+
const { jsonObjectToMarkdown } = require("./json_object_to_markdown.cjs");
5+
46
/**
57
* Generate workflow overview step that writes an agentic workflow run overview
68
* to the GitHub step summary. This reads from aw_info.json that was created by
@@ -16,36 +18,15 @@ async function generateWorkflowOverview(core) {
1618
// Load aw_info.json
1719
const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8"));
1820

19-
let networkDetails = "";
20-
if (awInfo.allowed_domains && awInfo.allowed_domains.length > 0) {
21-
networkDetails = awInfo.allowed_domains
22-
.slice(0, 10)
23-
.map(d => ` - ${d}`)
24-
.join("\n");
25-
if (awInfo.allowed_domains.length > 10) {
26-
networkDetails += `\n - ... and ${awInfo.allowed_domains.length - 10} more`;
27-
}
28-
}
21+
// Build the collapsible summary label with engine_id and version
22+
const engineLabel = [awInfo.engine_id, awInfo.version].filter(Boolean).join(" ");
23+
const summaryLabel = engineLabel ? `Run details - ${engineLabel}` : "Run details";
24+
25+
// Render all aw_info fields as markdown bullet points
26+
const details = jsonObjectToMarkdown(awInfo);
2927

3028
// Build summary using string concatenation to avoid YAML parsing issues with template literals
31-
const summary =
32-
"<details>\n" +
33-
"<summary>Run details</summary>\n\n" +
34-
"#### Engine Configuration\n" +
35-
"| Property | Value |\n" +
36-
"|----------|-------|\n" +
37-
`| Engine ID | ${awInfo.engine_id} |\n` +
38-
`| Engine Name | ${awInfo.engine_name} |\n` +
39-
`| Model | ${awInfo.model || "(default)"} |\n` +
40-
"\n" +
41-
"#### Network Configuration\n" +
42-
"| Property | Value |\n" +
43-
"|----------|-------|\n" +
44-
`| Firewall | ${awInfo.firewall_enabled ? "✅ Enabled" : "❌ Disabled"} |\n` +
45-
`| Firewall Version | ${awInfo.awf_version || "(latest)"} |\n` +
46-
"\n" +
47-
(networkDetails ? `##### Allowed Domains\n${networkDetails}\n` : "") +
48-
"</details>";
29+
const summary = "<details>\n" + `<summary>${summaryLabel}</summary>\n\n` + details + "\n" + "</details>";
4930

5031
await core.summary.addRaw(summary).write();
5132
console.log("Generated workflow overview in step summary");

actions/setup/js/generate_workflow_overview.test.cjs

Lines changed: 30 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22
import fs from "fs";
3-
import os from "os";
4-
import path from "path";
53

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

2321
describe("generate_workflow_overview.cjs", () => {
2422
let generateWorkflowOverview;
25-
let tmpDir;
2623
let awInfoPath;
27-
let originalRequireCache;
2824

2925
beforeEach(async () => {
3026
// Reset mocks
@@ -54,6 +50,7 @@ describe("generate_workflow_overview.cjs", () => {
5450
engine_id: "copilot",
5551
engine_name: "GitHub Copilot",
5652
model: "gpt-4",
53+
version: "v1.2.3",
5754
firewall_enabled: true,
5855
awf_version: "1.0.0",
5956
allowed_domains: [],
@@ -67,19 +64,22 @@ describe("generate_workflow_overview.cjs", () => {
6764

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

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

9292
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
93-
expect(summaryArg).toContain("| Model | (default) |");
94-
expect(summaryArg).toContain("| Firewall | ❌ Disabled |");
95-
expect(summaryArg).toContain("| Firewall Version | (latest) |");
96-
});
97-
98-
it("should include allowed domains when present (up to 10)", async () => {
99-
const awInfo = {
100-
engine_id: "copilot",
101-
engine_name: "GitHub Copilot",
102-
firewall_enabled: true,
103-
allowed_domains: ["example.com", "github.com", "api.github.com"],
104-
};
105-
fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));
106-
107-
await generateWorkflowOverview(mockCore);
108-
109-
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
110-
expect(summaryArg).toContain("##### Allowed Domains");
111-
expect(summaryArg).toContain(" - example.com");
112-
expect(summaryArg).toContain(" - github.com");
113-
expect(summaryArg).toContain(" - api.github.com");
93+
expect(summaryArg).toContain("<summary>Run details - claude</summary>");
94+
expect(summaryArg).toContain("- **engine id**: claude");
95+
expect(summaryArg).toContain("- **firewall enabled**: false");
11496
});
11597

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

126104
await generateWorkflowOverview(mockCore);
127105

128106
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
129-
expect(summaryArg).toContain("##### Allowed Domains");
130-
expect(summaryArg).toContain(" - domain1.com");
131-
expect(summaryArg).toContain(" - domain10.com");
132-
expect(summaryArg).toContain(" - ... and 5 more");
133-
expect(summaryArg).not.toContain("domain11.com");
107+
expect(summaryArg).toContain("<summary>Run details</summary>");
134108
});
135109

136-
it("should not include Allowed Domains section when empty", async () => {
110+
it("should render all fields from aw_info including nested objects and arrays", async () => {
137111
const awInfo = {
138112
engine_id: "copilot",
139-
engine_name: "GitHub Copilot",
140-
firewall_enabled: false,
141-
allowed_domains: [],
113+
version: "v2.0.0",
114+
allowed_domains: ["example.com", "github.com"],
115+
steps: { firewall: "iptables" },
142116
};
143117
fs.writeFileSync(awInfoPath, JSON.stringify(awInfo));
144118

145119
await generateWorkflowOverview(mockCore);
146120

147121
const summaryArg = mockCore.summary.addRaw.mock.calls[0][0];
148-
expect(summaryArg).not.toContain("##### Allowed Domains");
122+
expect(summaryArg).toContain("- **engine id**: copilot");
123+
expect(summaryArg).toContain("- **allowed domains**:");
124+
expect(summaryArg).toContain(" - example.com");
125+
expect(summaryArg).toContain(" - github.com");
126+
expect(summaryArg).toContain("- **steps**:");
127+
expect(summaryArg).toContain(" - **firewall**: iptables");
149128
});
150129

151130
it("should log success message", async () => {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// @ts-check
2+
3+
/**
4+
* JSON Object to Markdown Converter
5+
*
6+
* Converts a plain JavaScript object to a Markdown bullet list.
7+
* Handles nested objects (with indentation), arrays, and primitive values.
8+
*/
9+
10+
/**
11+
* Humanify a JSON key by replacing underscores and hyphens with spaces.
12+
* e.g. "engine_id" → "engine id", "awf-version" → "awf version"
13+
* @param {string} key - The raw object key
14+
* @returns {string} - Human-readable key
15+
*/
16+
function humanifyKey(key) {
17+
return key.replace(/[_-]/g, " ");
18+
}
19+
20+
/**
21+
* Format a single value as a readable string for Markdown output.
22+
* @param {unknown} value - The value to format
23+
* @returns {string} - String representation of the value
24+
*/
25+
function formatValue(value) {
26+
if (value === null || value === undefined || value === "") {
27+
return "(none)";
28+
}
29+
if (Array.isArray(value)) {
30+
return value.length === 0 ? "(none)" : "";
31+
}
32+
if (typeof value === "object") {
33+
return "";
34+
}
35+
return String(value);
36+
}
37+
38+
/**
39+
* Convert a plain JavaScript object to Markdown bullet points.
40+
* Nested objects and arrays are rendered as indented sub-lists.
41+
*
42+
* @param {Record<string, unknown>} obj - The object to render
43+
* @param {number} [depth=0] - Current indentation depth
44+
* @returns {string} - Markdown bullet list string
45+
*/
46+
function jsonObjectToMarkdown(obj, depth = 0) {
47+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
48+
return "";
49+
}
50+
51+
const indent = " ".repeat(depth);
52+
const lines = [];
53+
54+
for (const [key, value] of Object.entries(obj)) {
55+
const label = humanifyKey(key);
56+
if (Array.isArray(value)) {
57+
if (value.length === 0) {
58+
lines.push(`${indent}- **${label}**: (none)`);
59+
} else {
60+
lines.push(`${indent}- **${label}**:`);
61+
for (const item of value) {
62+
if (typeof item === "object" && item !== null) {
63+
lines.push(jsonObjectToMarkdown(/** @type {Record<string, unknown>} */ item, depth + 1));
64+
} else {
65+
lines.push(`${" ".repeat(depth + 1)}- ${String(item)}`);
66+
}
67+
}
68+
}
69+
} else if (typeof value === "object" && value !== null) {
70+
lines.push(`${indent}- **${label}**:`);
71+
lines.push(jsonObjectToMarkdown(/** @type {Record<string, unknown>} */ value, depth + 1));
72+
} else {
73+
const formatted = formatValue(value);
74+
lines.push(`${indent}- **${label}**: ${formatted}`);
75+
}
76+
}
77+
78+
return lines.join("\n");
79+
}
80+
81+
module.exports = { humanifyKey, jsonObjectToMarkdown };
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
const { humanifyKey, jsonObjectToMarkdown } = await import("./json_object_to_markdown.cjs");
4+
5+
describe("json_object_to_markdown.cjs", () => {
6+
describe("humanifyKey", () => {
7+
it("should replace underscores with spaces", () => {
8+
expect(humanifyKey("engine_id")).toBe("engine id");
9+
expect(humanifyKey("firewall_enabled")).toBe("firewall enabled");
10+
expect(humanifyKey("awf_version")).toBe("awf version");
11+
});
12+
13+
it("should replace hyphens with spaces", () => {
14+
expect(humanifyKey("run-id")).toBe("run id");
15+
expect(humanifyKey("awf-version")).toBe("awf version");
16+
});
17+
18+
it("should leave keys without separators unchanged", () => {
19+
expect(humanifyKey("version")).toBe("version");
20+
expect(humanifyKey("model")).toBe("model");
21+
});
22+
});
23+
24+
it("should render flat key-value pairs with humanified keys", () => {
25+
const obj = { engine_id: "copilot", version: "v1.0.0" };
26+
const result = jsonObjectToMarkdown(obj);
27+
expect(result).toContain("- **engine id**: copilot");
28+
expect(result).toContain("- **version**: v1.0.0");
29+
});
30+
31+
it("should render boolean values as true/false strings", () => {
32+
const obj = { firewall_enabled: true, staged: false };
33+
const result = jsonObjectToMarkdown(obj);
34+
expect(result).toContain("- **firewall enabled**: true");
35+
expect(result).toContain("- **staged**: false");
36+
});
37+
38+
it("should render null/undefined/empty string values as (none)", () => {
39+
const obj = { model: "", awf_version: null, agent_version: undefined };
40+
const result = jsonObjectToMarkdown(obj);
41+
expect(result).toContain("- **model**: (none)");
42+
expect(result).toContain("- **awf version**: (none)");
43+
expect(result).toContain("- **agent version**: (none)");
44+
});
45+
46+
it("should render non-empty arrays as sub-bullet lists", () => {
47+
const obj = { allowed_domains: ["example.com", "github.com"] };
48+
const result = jsonObjectToMarkdown(obj);
49+
expect(result).toContain("- **allowed domains**:");
50+
expect(result).toContain(" - example.com");
51+
expect(result).toContain(" - github.com");
52+
});
53+
54+
it("should render empty arrays as (none)", () => {
55+
const obj = { allowed_domains: [] };
56+
const result = jsonObjectToMarkdown(obj);
57+
expect(result).toContain("- **allowed domains**: (none)");
58+
});
59+
60+
it("should render nested objects as indented sub-bullet lists", () => {
61+
const obj = { steps: { firewall: "iptables" } };
62+
const result = jsonObjectToMarkdown(obj);
63+
expect(result).toContain("- **steps**:");
64+
expect(result).toContain(" - **firewall**: iptables");
65+
});
66+
67+
it("should return empty string for null or non-object input", () => {
68+
expect(jsonObjectToMarkdown(null)).toBe("");
69+
expect(jsonObjectToMarkdown(undefined)).toBe("");
70+
expect(jsonObjectToMarkdown([])).toBe("");
71+
});
72+
73+
it("should handle numeric values", () => {
74+
const obj = { run_id: 12345, run_number: 7 };
75+
const result = jsonObjectToMarkdown(obj);
76+
expect(result).toContain("- **run id**: 12345");
77+
expect(result).toContain("- **run number**: 7");
78+
});
79+
});

0 commit comments

Comments
 (0)