Skip to content

Commit 1385599

Browse files
authored
[Avocado] Render results as markdown to job summary (Azure#35679)
- Fixes Azure#35668
1 parent 52163cb commit 1385599

File tree

9 files changed

+734
-14
lines changed

9 files changed

+734
-14
lines changed

.github/package-lock.json

Lines changed: 22 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"debug": "^4.4.0",
1111
"js-yaml": "^4.1.0",
1212
"marked": "^16.0.0",
13-
"simple-git": "^3.27.0"
13+
"markdown-table": "^3.0.4",
14+
"simple-git": "^3.27.0",
15+
"zod": "^4.0.2"
1416
},
1517
"devDependencies": {
1618
"@actions/github-script": "github:actions/github-script",

.github/workflows/avocado-code.yaml

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,70 @@ jobs:
2121
uses: ./.github/actions/setup-node-install-deps
2222

2323
- name: Run Avocado
24+
id: run-avocado
2425
run: |
26+
AVOCADO_OUTPUT_FILE=$RUNNER_TEMP/avocado.ndjson
27+
28+
echo "output-file=$AVOCADO_OUTPUT_FILE" >> $GITHUB_OUTPUT
29+
2530
npm exec --no -- avocado \
26-
--excludePaths \
27-
"/common-types/" \
28-
"/scenarios/" \
29-
"/package.json" \
30-
"/package-lock.json" \
31-
"/cadl/examples/" \
32-
'(?=/examples/)(?!(?:/stable/|/preview/))' \
33-
"/\\.github/" \
34-
"/eng/" \
35-
--includePaths \
36-
"data-plane" \
37-
"resource-manager"
31+
--excludePaths \
32+
"/common-types/" \
33+
"/scenarios/" \
34+
"/package.json" \
35+
"/package-lock.json" \
36+
"/cadl/examples/" \
37+
'(?=/examples/)(?!(?:/stable/|/preview/))' \
38+
"/\\.github/" \
39+
"/eng/" \
40+
--includePaths \
41+
"data-plane" \
42+
"resource-manager" \
43+
--file \
44+
$AVOCADO_OUTPUT_FILE
45+
46+
TIME=$(node -p 'JSON.stringify(new Date())')
47+
48+
# Avocado doesn't write any output if it was successful, so we add some to simplify later processing
49+
[[ -e $AVOCADO_OUTPUT_FILE ]] || \
50+
echo "{\"type\":\"Raw\",\"level\":\"Info\",\"message\":\"success\",\"time\":$TIME}" > $AVOCADO_OUTPUT_FILE
3851
env:
3952
# Tells Avocado to analyze the files changed between the PR head (default checkout)
4053
# and the PR base branch.
4154
SYSTEM_PULLREQUEST_TARGETBRANCH: ${{ github.event.pull_request.base.ref }}
55+
# Avocado hardcodes these env vars to get repo and SHA
56+
TRAVIS_REPO_SLUG: ${{ github.repository }}
57+
TRAVIS_PULL_REQUEST_SHA: ${{ github.sha }}
58+
59+
- name: Setup Node 20 and install deps (under .github)
60+
if: ${{ always() && (steps.run-avocado.outputs.output-file) }}
61+
uses: ./.github/actions/setup-node-install-deps
62+
with:
63+
# actions/github-script@v7 uses Node 20
64+
node-version: 20.x
65+
# "--no-audit": improves performance
66+
# "--omit dev": not needed at runtime, improves performance
67+
install-command: "npm ci --no-audit --omit dev"
68+
working-directory: ./.github
69+
70+
- name: Generate job summary
71+
if: ${{ always() && (steps.run-avocado.outputs.output-file) }}
72+
id: generate-job-summary
73+
uses: actions/github-script@v7
74+
with:
75+
script: |
76+
const { default: generateJobSummary } =
77+
await import('${{ github.workspace }}/.github/workflows/src/avocado-code.js');
78+
return await generateJobSummary({ core });
79+
env:
80+
AVOCADO_OUTPUT_FILE: ${{ steps.run-avocado.outputs.output-file }}
81+
82+
# Used by other workflows like set-status
83+
- name: Set job-summary artifact
84+
if: ${{ always() && steps.generate-job-summary.outputs.summary }}
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: job-summary
88+
path: ${{ steps.generate-job-summary.outputs.summary }}
89+
# If the file doesn't exist, just don't add the artifact
90+
if-no-files-found: ignore

.github/workflows/src/avocado-code.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// @ts-check
2+
3+
import { readFile } from "fs/promises";
4+
import {
5+
generateMarkdownTable,
6+
MessageLevel,
7+
MessageRecordSchema,
8+
MessageType,
9+
} from "./message.js";
10+
import { parse } from "./ndjson.js";
11+
12+
/**
13+
* @param {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments
14+
* @returns {Promise<void>}
15+
*/
16+
export default async function generateJobSummary({ core }) {
17+
const avocadoOutputFile = process.env.AVOCADO_OUTPUT_FILE;
18+
core.info(`avocadoOutputFile: ${avocadoOutputFile}`);
19+
20+
if (!avocadoOutputFile) {
21+
throw new Error("Env var AVOCADO_OUTPUT_FILE must be set");
22+
}
23+
24+
/** @type {string} */
25+
let content;
26+
27+
try {
28+
core.info(`readfile(${avocadoOutputFile})`);
29+
content = await readFile(avocadoOutputFile, { encoding: "utf-8" });
30+
core.info(`content:\n${content}`);
31+
} catch (error) {
32+
// If we can't read the file, the previous step must have failed catastrophically.
33+
// generateJobSummary() should never fail, so just log the error and return
34+
core.info(`Error reading '${avocadoOutputFile}': ${error}`);
35+
return;
36+
}
37+
38+
const messages = parse(content).map((obj) => MessageRecordSchema.parse(obj));
39+
40+
if (messages.length === 0) {
41+
// Should never happen, but if it does, just log the error and return.
42+
core.notice(`No messages in '${avocadoOutputFile}'`);
43+
return;
44+
} else if (
45+
messages.length === 1 &&
46+
messages[0].type === MessageType.Raw &&
47+
messages[0].level === MessageLevel.Info &&
48+
messages[0].message.toLowerCase() === "success"
49+
) {
50+
// Special-case marker message for success
51+
core.summary.addRaw("Success");
52+
} else {
53+
core.summary.addRaw(generateMarkdownTable(messages));
54+
}
55+
56+
core.summary.write();
57+
core.setOutput("summary", process.env.GITHUB_STEP_SUMMARY);
58+
}

.github/workflows/src/message.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// @ts-check
2+
3+
// Ported from @azure/swagger-validation-common:src/types/message.ts
4+
5+
import { markdownTable } from "markdown-table";
6+
import * as z from "zod";
7+
8+
/**
9+
* @readonly
10+
* @enum {"Info" | "Warning" | "Error"}
11+
*/
12+
export const MessageLevel = Object.freeze({
13+
Info: "Info",
14+
Warning: "Warning",
15+
Error: "Error",
16+
});
17+
/** @type {import("zod").ZodType<MessageLevel>} */
18+
export const MessageLevelSchema = z.enum(Object.values(MessageLevel));
19+
20+
export const MessageContextSchema = z.object({
21+
toolVersion: z.string(),
22+
});
23+
/**
24+
* @typedef {import("zod").infer<typeof MessageContextSchema>} MessageContext
25+
*/
26+
27+
export const ExtraSchema = z.record(z.string(), z.any());
28+
/**
29+
* @typedef {import("zod").infer<typeof ExtraSchema>} Extra
30+
*/
31+
32+
export const BaseMessageRecordSchema = z.object({
33+
level: MessageLevelSchema,
34+
message: z.string(),
35+
time: z.iso.datetime(),
36+
context: z.optional(MessageContextSchema),
37+
group: z.optional(z.string()),
38+
extra: z.optional(ExtraSchema),
39+
groupName: z.optional(z.string()),
40+
});
41+
/**
42+
* @typedef {import("zod").infer<typeof BaseMessageRecordSchema>} BaseMessageRecord
43+
*/
44+
45+
/**
46+
* @readonly
47+
* @enum {"Raw" | "Result"}
48+
*/
49+
export const MessageType = Object.freeze({
50+
Raw: "Raw",
51+
Result: "Result",
52+
});
53+
/** @type {import("zod").ZodType<MessageType>} */
54+
export const MessageTypeSchema = z.enum(Object.values(MessageType));
55+
56+
export const RawMessageRecordSchema = BaseMessageRecordSchema.extend({
57+
type: z.literal(MessageType.Raw),
58+
});
59+
/**
60+
* @typedef {import("zod").infer<typeof RawMessageRecordSchema>} RawMessageRecord
61+
*/
62+
63+
export const JsonPathSchema = z.object({
64+
tag: z.string(),
65+
path: z.string(),
66+
jsonPath: z.optional(z.string()),
67+
});
68+
/**
69+
* @typedef {import("zod").infer<typeof JsonPathSchema>} JsonPathSchema
70+
*/
71+
72+
export const ResultMessageRecordSchema = BaseMessageRecordSchema.extend({
73+
type: z.literal(MessageType.Result),
74+
id: z.optional(z.string()),
75+
code: z.optional(z.string()),
76+
docUrl: z.optional(z.string()),
77+
paths: z.array(JsonPathSchema),
78+
});
79+
/**
80+
* @typedef {import("zod").infer<typeof ResultMessageRecordSchema>} ResultMessageRecord
81+
*/
82+
83+
export const MessageRecordSchema = z.discriminatedUnion("type", [
84+
RawMessageRecordSchema,
85+
ResultMessageRecordSchema,
86+
]);
87+
/**
88+
* @typedef {import("zod").infer<typeof MessageRecordSchema>} MessageRecord
89+
*/
90+
91+
/**
92+
* Adds table of messages to core.summary
93+
*
94+
* @param {MessageRecord[]} messages
95+
*/
96+
export function generateMarkdownTable(messages) {
97+
const header = ["Rule", "Message"];
98+
const rows = messages.map((m) => getMarkdownRow(m));
99+
return markdownTable([header, ...rows]);
100+
}
101+
102+
/**
103+
* @param {MessageRecord} record
104+
* @returns {string[]}
105+
*/
106+
function getMarkdownRow(record) {
107+
if (record.type === MessageType.Result) {
108+
return [
109+
getLevelMarkdown(record) + " " + getRuleMarkdown(record),
110+
getMessageMarkdown(record) + "<br>" + getLocationMarkdown(record),
111+
];
112+
} else {
113+
return [getLevelMarkdown(record) + " " + getMessageMarkdown(record), getExtraMarkdown(record)];
114+
}
115+
}
116+
117+
// Following ported from openapi-alps/reportGenerator.ts
118+
119+
/**
120+
* @param {MessageRecord} record
121+
* @returns {string}
122+
*/
123+
function getLevelMarkdown(record) {
124+
switch (record.level) {
125+
case "Error":
126+
return "❌";
127+
case "Info":
128+
return "ℹ️";
129+
case "Warning":
130+
return "⚠️";
131+
}
132+
}
133+
134+
/**
135+
* @param {ResultMessageRecord} result
136+
* @returns {string}
137+
*/
138+
function getRuleMarkdown(result) {
139+
let ruleName = [result.id, result.code].filter((s) => s).join(" - ");
140+
return `[${ruleName}](${result.docUrl})`;
141+
}
142+
143+
/**
144+
* @param {ResultMessageRecord} result
145+
* @returns {string}
146+
*/
147+
function getLocationMarkdown(result) {
148+
return result.paths
149+
.filter((p) => p.path)
150+
.map((p) => `${p.tag}: [${getPathSegment(p.path)}](${p.path})`)
151+
.join("<br>");
152+
}
153+
154+
/**
155+
* @param {string} path
156+
* @returns {string}
157+
*/
158+
function getPathSegment(path) {
159+
const idx = path.indexOf("path=");
160+
if (idx !== -1) {
161+
path = decodeURIComponent(path.substr(idx + 5).split("&")[0]);
162+
}
163+
// for github url
164+
return path.split("/").slice(-4).join("/").split("#")[0];
165+
}
166+
167+
/**
168+
* @param {MessageRecord} record
169+
* @returns {string}
170+
*/
171+
function getMessageMarkdown(record) {
172+
if (record.type === MessageType.Raw) {
173+
return record.message.replace(/\\n\\n/g, "\n").split("\n")[0];
174+
} else {
175+
// record.type === MessageType.Raw
176+
const re = /(\n|\t|\r)/gi;
177+
return record.message.replace(re, " ");
178+
}
179+
}
180+
/**
181+
* @param {MessageRecord} record
182+
* @returns {string}
183+
*/
184+
function getExtraMarkdown(record) {
185+
return JSON.stringify(record.extra || {})
186+
.replace(/[{}]/g, "")
187+
.replace(/,/g, ",<br>");
188+
}

0 commit comments

Comments
 (0)