@syntax-syllogism/flow-delta 0.2.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/dist/cli.js +733 -317
- package/dist/github-report.js +342 -0
- package/dist/gitlab-report.js +122 -40
- package/examples/cloudflare-worker/src/index.ts +63 -0
- package/examples/cloudflare-worker/wrangler.toml +8 -0
- package/examples/github-actions.yml +56 -0
- package/examples/gitlab-ci.yml +23 -0
- package/package.json +8 -4
- package/scripts/r2-publish.mjs +132 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/ci/github-report.ts
|
|
4
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
// src/util/is-main-module.ts
|
|
8
|
+
import { realpathSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
function isMainModule(metaUrl, argvPath = process.argv[1]) {
|
|
11
|
+
if (!argvPath) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(argvPath);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/ci/report-core.ts
|
|
22
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
// src/render/snapshot-panel.ts
|
|
26
|
+
var COLLECTION_KEY_NAMES = [
|
|
27
|
+
"assignmentItems",
|
|
28
|
+
"choiceReferences",
|
|
29
|
+
"choices",
|
|
30
|
+
"conditions",
|
|
31
|
+
"customErrorMessages",
|
|
32
|
+
"dataTypeMappings",
|
|
33
|
+
"fields",
|
|
34
|
+
"filters",
|
|
35
|
+
"inputAssignments",
|
|
36
|
+
"inputParameters",
|
|
37
|
+
"outputParameters",
|
|
38
|
+
"outputAssignments",
|
|
39
|
+
"processMetadataValues",
|
|
40
|
+
"rules",
|
|
41
|
+
"scheduledPaths",
|
|
42
|
+
"sortOptions",
|
|
43
|
+
"stageSteps",
|
|
44
|
+
"steps",
|
|
45
|
+
"storeOutputParameters",
|
|
46
|
+
"transformValues",
|
|
47
|
+
"valueMappings",
|
|
48
|
+
"waitEvents"
|
|
49
|
+
];
|
|
50
|
+
var COLLECTION_KEYS = new Set(COLLECTION_KEY_NAMES);
|
|
51
|
+
function humanizePath(path) {
|
|
52
|
+
const names = {
|
|
53
|
+
assignmentItems: "Assignment item",
|
|
54
|
+
choiceReferences: "Choice reference",
|
|
55
|
+
choices: "Choice",
|
|
56
|
+
conditions: "Condition",
|
|
57
|
+
customErrorMessages: "Custom error message",
|
|
58
|
+
dataTypeMappings: "Data type mapping",
|
|
59
|
+
fields: "Field",
|
|
60
|
+
filters: "Filter",
|
|
61
|
+
inputAssignments: "Input assignment",
|
|
62
|
+
inputParameters: "Input parameter",
|
|
63
|
+
outputParameters: "Output parameter",
|
|
64
|
+
processMetadataValues: "Process metadata value",
|
|
65
|
+
rules: "Rule",
|
|
66
|
+
scheduledPaths: "Scheduled path",
|
|
67
|
+
storeOutputParameters: "Store output parameter",
|
|
68
|
+
transformValues: "Transform value",
|
|
69
|
+
valueMappings: "Value mapping",
|
|
70
|
+
waitEvents: "Wait event"
|
|
71
|
+
};
|
|
72
|
+
return path.split(".").map((segment) => {
|
|
73
|
+
const match = segment.match(/^([^[]+)(?:\[(\d+)\])?$/);
|
|
74
|
+
if (!match) return segment;
|
|
75
|
+
const name = names[match[1]] || match[1].replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
|
|
76
|
+
return match[2] === void 0 ? name : name + " " + (Number(match[2]) + 1);
|
|
77
|
+
}).join(" > ");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/util/file-name.ts
|
|
81
|
+
function safeFileName(value) {
|
|
82
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/ci/report-core.ts
|
|
86
|
+
var DEFAULT_MARKER = "<!-- FlowDelta:report -->";
|
|
87
|
+
function buildComment(results, opts = {}) {
|
|
88
|
+
const marker = opts.marker ?? DEFAULT_MARKER;
|
|
89
|
+
const sorted = [...results].sort((left, right) => left.flowName.localeCompare(right.flowName));
|
|
90
|
+
const lines = [marker, "", `## \u{1F50D} FlowDelta \u2014 ${sorted.length} flow(s) changed`, ""];
|
|
91
|
+
if (sorted.length === 0) {
|
|
92
|
+
lines.push("_No Flow changes in this MR._");
|
|
93
|
+
} else {
|
|
94
|
+
lines.push("| Flow | +nodes | -nodes | ~nodes | +/-edges | Flow | Diff |");
|
|
95
|
+
lines.push("| --- | ---: | ---: | ---: | ---: | --- | --- |");
|
|
96
|
+
for (const result of sorted) {
|
|
97
|
+
const flowSummary = escapeTableCell(formatFlowChanges(result.flowChanges));
|
|
98
|
+
lines.push(
|
|
99
|
+
`| ${escapeTableCell(result.flowName)} | ${result.summary.addedNodes} | ${result.summary.removedNodes} | ${result.summary.modifiedNodes} | ${result.summary.addedEdges} / ${result.summary.removedEdges} | ${flowSummary} | [Open interactive diff](${result.artifactUrl}) |`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (opts.commitSha) {
|
|
104
|
+
lines.push("", `Commit: \`${opts.commitSha.slice(0, 7)}\``);
|
|
105
|
+
}
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
function readResults(inputDir) {
|
|
109
|
+
return readdirSync(inputDir).filter((file) => file.endsWith(".diff.json")).map((file) => {
|
|
110
|
+
const diff = JSON.parse(readFileSync(join(inputDir, file), "utf8"));
|
|
111
|
+
if (isZeroSummary(diff.summary)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
flowName: diff.flowName,
|
|
116
|
+
summary: diff.summary,
|
|
117
|
+
flowChanges: diff.flowChanges,
|
|
118
|
+
stem: safeFileName(diff.flowName)
|
|
119
|
+
};
|
|
120
|
+
}).filter((value) => value !== null);
|
|
121
|
+
}
|
|
122
|
+
function loadArtifactUrls(path) {
|
|
123
|
+
if (!path || !existsSync(path)) {
|
|
124
|
+
return {};
|
|
125
|
+
}
|
|
126
|
+
const raw = readFileSync(path, "utf8").trim();
|
|
127
|
+
if (!raw) {
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(raw);
|
|
132
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
return Object.fromEntries(
|
|
136
|
+
Object.entries(parsed).filter(
|
|
137
|
+
(entry) => typeof entry[1] === "string" && (entry[1].startsWith("https://") || entry[1].startsWith("http://"))
|
|
138
|
+
)
|
|
139
|
+
);
|
|
140
|
+
} catch {
|
|
141
|
+
return {};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function resolveArtifactUrl(stem, urls, fallback) {
|
|
145
|
+
return urls[`${stem}.html`] ?? fallback;
|
|
146
|
+
}
|
|
147
|
+
function isZeroSummary(summary) {
|
|
148
|
+
return summary.addedNodes === 0 && summary.removedNodes === 0 && summary.modifiedNodes === 0 && summary.addedEdges === 0 && summary.removedEdges === 0 && summary.changedFlowAttributes === 0;
|
|
149
|
+
}
|
|
150
|
+
function findStickyNote(notes, marker, authorId) {
|
|
151
|
+
return notes.find((note) => note.body.includes(marker) && (authorId === void 0 || note.author?.id === authorId));
|
|
152
|
+
}
|
|
153
|
+
function escapeTableCell(value) {
|
|
154
|
+
return value.replaceAll("|", "\\|").replaceAll("`", "\\`").replace(/\r?\n/g, "<br>");
|
|
155
|
+
}
|
|
156
|
+
function formatFlowChanges(flowChanges) {
|
|
157
|
+
if (!flowChanges || flowChanges.length === 0) {
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
const status = flowChanges.find((change) => change.path === "status");
|
|
161
|
+
if (status) {
|
|
162
|
+
return formatStatusChange(status.before, status.after);
|
|
163
|
+
}
|
|
164
|
+
return flowChanges.map((change) => humanizePath(change.path)).join(", ");
|
|
165
|
+
}
|
|
166
|
+
function formatStatusChange(before, after) {
|
|
167
|
+
if (before === "Active" && after !== "Active") {
|
|
168
|
+
return `Deactivated (${String(before)} -> ${String(after)})`;
|
|
169
|
+
}
|
|
170
|
+
if (before !== "Active" && after === "Active") {
|
|
171
|
+
return `Activated (${String(before)} -> ${String(after)})`;
|
|
172
|
+
}
|
|
173
|
+
return `Status: ${String(before)} -> ${String(after)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/ci/github-report.ts
|
|
177
|
+
var REPORTER_USAGE = `Usage:
|
|
178
|
+
flow-delta-github --in <flow-delta-out> [--api-url <url>] [--repo <owner/repo>] [--pr <number>]
|
|
179
|
+
[--server-url <url>] [--run-id <id>] [--token <token>] [--commit-sha <sha>]
|
|
180
|
+
[--artifact-urls <manifest.json>]
|
|
181
|
+
`;
|
|
182
|
+
function artifactUrl(env) {
|
|
183
|
+
const base = env.serverUrl.replace(/\/+$/, "");
|
|
184
|
+
return `${base}/${env.owner}/${env.repo}/actions/runs/${env.runId}`;
|
|
185
|
+
}
|
|
186
|
+
async function upsertComment(env, body, fetchImpl = fetch) {
|
|
187
|
+
const headers = {
|
|
188
|
+
Authorization: `Bearer ${env.token}`,
|
|
189
|
+
Accept: "application/vnd.github+json",
|
|
190
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
191
|
+
"Content-Type": "application/json"
|
|
192
|
+
};
|
|
193
|
+
const marker = env.marker ?? DEFAULT_MARKER;
|
|
194
|
+
const comments = await listComments(fetchImpl, env, headers);
|
|
195
|
+
const sticky = findStickyNote(
|
|
196
|
+
comments.map((comment) => ({ id: comment.id, body: comment.body })),
|
|
197
|
+
marker
|
|
198
|
+
);
|
|
199
|
+
if (sticky) {
|
|
200
|
+
await requestJson(fetchImpl, `${env.apiUrl}/repos/${env.owner}/${env.repo}/issues/comments/${sticky.id}`, {
|
|
201
|
+
method: "PATCH",
|
|
202
|
+
headers,
|
|
203
|
+
body: JSON.stringify({ body })
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
await requestJson(fetchImpl, `${env.apiUrl}/repos/${env.owner}/${env.repo}/issues/${env.prNumber}/comments`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers,
|
|
209
|
+
body: JSON.stringify({ body })
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function main(argv = process.argv.slice(2)) {
|
|
214
|
+
try {
|
|
215
|
+
const parsed = parseArgs({
|
|
216
|
+
args: argv,
|
|
217
|
+
options: {
|
|
218
|
+
in: { type: "string" },
|
|
219
|
+
"api-url": { type: "string" },
|
|
220
|
+
repo: { type: "string" },
|
|
221
|
+
pr: { type: "string" },
|
|
222
|
+
"server-url": { type: "string" },
|
|
223
|
+
"run-id": { type: "string" },
|
|
224
|
+
token: { type: "string" },
|
|
225
|
+
"commit-sha": { type: "string" },
|
|
226
|
+
"artifact-urls": { type: "string" },
|
|
227
|
+
marker: { type: "string" },
|
|
228
|
+
help: { type: "boolean", short: "h" }
|
|
229
|
+
},
|
|
230
|
+
allowPositionals: false
|
|
231
|
+
});
|
|
232
|
+
if (parsed.values.help) {
|
|
233
|
+
console.log(REPORTER_USAGE.trimEnd());
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const inputDir = parsed.values.in ?? process.env.FLOW_LENS_OUT_DIR ?? "./flow-delta-out";
|
|
237
|
+
const records = readResults(inputDir);
|
|
238
|
+
if (records.length === 0) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const prNumber = resolvePrNumber(parsed.values);
|
|
242
|
+
if (prNumber === void 0) {
|
|
243
|
+
console.error("No pull request number resolved (not a pull_request event); skipping GitHub comment.");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const env = resolveEnv(parsed.values, prNumber);
|
|
247
|
+
const urls = loadArtifactUrls(stringValueOptional(parsed.values["artifact-urls"]));
|
|
248
|
+
const fallbackUrl = artifactUrl(env);
|
|
249
|
+
const results = records.map((record) => ({
|
|
250
|
+
flowName: record.flowName,
|
|
251
|
+
summary: record.summary,
|
|
252
|
+
flowChanges: record.flowChanges,
|
|
253
|
+
artifactUrl: resolveArtifactUrl(record.stem, urls, fallbackUrl)
|
|
254
|
+
}));
|
|
255
|
+
const body = buildComment(results, {
|
|
256
|
+
marker: env.marker,
|
|
257
|
+
commitSha: env.commitSha
|
|
258
|
+
});
|
|
259
|
+
await upsertComment(env, body);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.error(error.message);
|
|
262
|
+
process.exitCode = 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function resolvePrNumber(values) {
|
|
266
|
+
const fromFlag = stringValueOptional(values.pr);
|
|
267
|
+
if (fromFlag) {
|
|
268
|
+
return fromFlag;
|
|
269
|
+
}
|
|
270
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
271
|
+
if (!eventPath) {
|
|
272
|
+
return void 0;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const event = JSON.parse(readFileSync2(eventPath, "utf8"));
|
|
276
|
+
const number = event.pull_request?.number;
|
|
277
|
+
return typeof number === "number" ? String(number) : void 0;
|
|
278
|
+
} catch {
|
|
279
|
+
return void 0;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function resolveEnv(values, prNumber) {
|
|
283
|
+
const apiUrl = stringValueOptional(values["api-url"] ?? process.env.GITHUB_API_URL) ?? "https://api.github.com";
|
|
284
|
+
const repoFull = stringValue(values.repo ?? process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY");
|
|
285
|
+
const [owner, repo] = repoFull.split("/");
|
|
286
|
+
if (!owner || !repo) {
|
|
287
|
+
throw new Error(`Invalid repo (expected owner/repo): ${repoFull}`);
|
|
288
|
+
}
|
|
289
|
+
const serverUrl = stringValueOptional(values["server-url"] ?? process.env.GITHUB_SERVER_URL) ?? "https://github.com";
|
|
290
|
+
const runId = stringValue(values["run-id"] ?? process.env.GITHUB_RUN_ID, "GITHUB_RUN_ID");
|
|
291
|
+
const token = stringValue(values.token ?? process.env.GITHUB_TOKEN, "GITHUB_TOKEN");
|
|
292
|
+
const commitSha = stringValueOptional(values["commit-sha"] ?? process.env.GITHUB_SHA);
|
|
293
|
+
const marker = stringValueOptional(values.marker) ?? DEFAULT_MARKER;
|
|
294
|
+
return { apiUrl, owner, repo, prNumber, serverUrl, runId, token, commitSha, marker };
|
|
295
|
+
}
|
|
296
|
+
function stringValue(value, name) {
|
|
297
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
298
|
+
throw new Error(`Missing required value: ${name}`);
|
|
299
|
+
}
|
|
300
|
+
return value;
|
|
301
|
+
}
|
|
302
|
+
function stringValueOptional(value) {
|
|
303
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
304
|
+
}
|
|
305
|
+
async function listComments(fetchImpl, env, headers) {
|
|
306
|
+
const comments = [];
|
|
307
|
+
let page = 1;
|
|
308
|
+
while (true) {
|
|
309
|
+
const response = await fetchImpl(
|
|
310
|
+
`${env.apiUrl}/repos/${env.owner}/${env.repo}/issues/${env.prNumber}/comments?per_page=100&page=${page}`,
|
|
311
|
+
{ headers }
|
|
312
|
+
);
|
|
313
|
+
const pageComments = await parseResponse(response, "list PR comments");
|
|
314
|
+
comments.push(...pageComments);
|
|
315
|
+
if (pageComments.length < 100) {
|
|
316
|
+
return comments;
|
|
317
|
+
}
|
|
318
|
+
page += 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function requestJson(fetchImpl, url, init, label = url) {
|
|
322
|
+
const response = await fetchImpl(url, init);
|
|
323
|
+
return parseResponse(response, label);
|
|
324
|
+
}
|
|
325
|
+
async function parseResponse(response, label) {
|
|
326
|
+
const body = await response.text();
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
throw new Error(`${label} failed: ${response.status} ${body}`);
|
|
329
|
+
}
|
|
330
|
+
return body.length > 0 ? JSON.parse(body) : void 0;
|
|
331
|
+
}
|
|
332
|
+
if (isMainModule(import.meta.url)) {
|
|
333
|
+
void main();
|
|
334
|
+
}
|
|
335
|
+
export {
|
|
336
|
+
artifactUrl,
|
|
337
|
+
buildComment,
|
|
338
|
+
findStickyNote,
|
|
339
|
+
isZeroSummary,
|
|
340
|
+
main,
|
|
341
|
+
upsertComment
|
|
342
|
+
};
|
package/dist/gitlab-report.js
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/ci/gitlab-report.ts
|
|
4
|
-
import { readFileSync, readdirSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
4
|
import { parseArgs } from "node:util";
|
|
7
5
|
|
|
8
|
-
// src/util/file-name.ts
|
|
9
|
-
function safeFileName(value) {
|
|
10
|
-
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
11
|
-
}
|
|
12
|
-
|
|
13
6
|
// src/util/is-main-module.ts
|
|
14
7
|
import { realpathSync } from "node:fs";
|
|
15
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -24,12 +17,72 @@ function isMainModule(metaUrl, argvPath = process.argv[1]) {
|
|
|
24
17
|
}
|
|
25
18
|
}
|
|
26
19
|
|
|
27
|
-
// src/ci/
|
|
20
|
+
// src/ci/report-core.ts
|
|
21
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
// src/render/snapshot-panel.ts
|
|
25
|
+
var COLLECTION_KEY_NAMES = [
|
|
26
|
+
"assignmentItems",
|
|
27
|
+
"choiceReferences",
|
|
28
|
+
"choices",
|
|
29
|
+
"conditions",
|
|
30
|
+
"customErrorMessages",
|
|
31
|
+
"dataTypeMappings",
|
|
32
|
+
"fields",
|
|
33
|
+
"filters",
|
|
34
|
+
"inputAssignments",
|
|
35
|
+
"inputParameters",
|
|
36
|
+
"outputParameters",
|
|
37
|
+
"outputAssignments",
|
|
38
|
+
"processMetadataValues",
|
|
39
|
+
"rules",
|
|
40
|
+
"scheduledPaths",
|
|
41
|
+
"sortOptions",
|
|
42
|
+
"stageSteps",
|
|
43
|
+
"steps",
|
|
44
|
+
"storeOutputParameters",
|
|
45
|
+
"transformValues",
|
|
46
|
+
"valueMappings",
|
|
47
|
+
"waitEvents"
|
|
48
|
+
];
|
|
49
|
+
var COLLECTION_KEYS = new Set(COLLECTION_KEY_NAMES);
|
|
50
|
+
function humanizePath(path) {
|
|
51
|
+
const names = {
|
|
52
|
+
assignmentItems: "Assignment item",
|
|
53
|
+
choiceReferences: "Choice reference",
|
|
54
|
+
choices: "Choice",
|
|
55
|
+
conditions: "Condition",
|
|
56
|
+
customErrorMessages: "Custom error message",
|
|
57
|
+
dataTypeMappings: "Data type mapping",
|
|
58
|
+
fields: "Field",
|
|
59
|
+
filters: "Filter",
|
|
60
|
+
inputAssignments: "Input assignment",
|
|
61
|
+
inputParameters: "Input parameter",
|
|
62
|
+
outputParameters: "Output parameter",
|
|
63
|
+
processMetadataValues: "Process metadata value",
|
|
64
|
+
rules: "Rule",
|
|
65
|
+
scheduledPaths: "Scheduled path",
|
|
66
|
+
storeOutputParameters: "Store output parameter",
|
|
67
|
+
transformValues: "Transform value",
|
|
68
|
+
valueMappings: "Value mapping",
|
|
69
|
+
waitEvents: "Wait event"
|
|
70
|
+
};
|
|
71
|
+
return path.split(".").map((segment) => {
|
|
72
|
+
const match = segment.match(/^([^[]+)(?:\[(\d+)\])?$/);
|
|
73
|
+
if (!match) return segment;
|
|
74
|
+
const name = names[match[1]] || match[1].replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
|
|
75
|
+
return match[2] === void 0 ? name : name + " " + (Number(match[2]) + 1);
|
|
76
|
+
}).join(" > ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/util/file-name.ts
|
|
80
|
+
function safeFileName(value) {
|
|
81
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/ci/report-core.ts
|
|
28
85
|
var DEFAULT_MARKER = "<!-- FlowDelta:report -->";
|
|
29
|
-
var REPORTER_USAGE = `Usage:
|
|
30
|
-
flow-delta-gitlab --in <flow-delta-out> [--api-url <url>] [--project-id <id>] [--mr-iid <iid>]
|
|
31
|
-
[--project-url <url>] [--job-id <id>] [--token <token>] [--commit-sha <sha>]
|
|
32
|
-
`;
|
|
33
86
|
function buildComment(results, opts = {}) {
|
|
34
87
|
const marker = opts.marker ?? DEFAULT_MARKER;
|
|
35
88
|
const sorted = [...results].sort((left, right) => left.flowName.localeCompare(right.flowName));
|
|
@@ -37,11 +90,12 @@ function buildComment(results, opts = {}) {
|
|
|
37
90
|
if (sorted.length === 0) {
|
|
38
91
|
lines.push("_No Flow changes in this MR._");
|
|
39
92
|
} else {
|
|
40
|
-
lines.push("| Flow | +nodes | -nodes | ~nodes | +/-edges | Diff |");
|
|
41
|
-
lines.push("| --- | ---: | ---: | ---: | ---: | --- |");
|
|
93
|
+
lines.push("| Flow | +nodes | -nodes | ~nodes | +/-edges | Flow | Diff |");
|
|
94
|
+
lines.push("| --- | ---: | ---: | ---: | ---: | --- | --- |");
|
|
42
95
|
for (const result of sorted) {
|
|
96
|
+
const flowSummary = escapeTableCell(formatFlowChanges(result.flowChanges));
|
|
43
97
|
lines.push(
|
|
44
|
-
`| ${escapeTableCell(result.flowName)} | ${result.summary.addedNodes} | ${result.summary.removedNodes} | ${result.summary.modifiedNodes} | ${result.summary.addedEdges} / ${result.summary.removedEdges} | [Open interactive diff
|
|
98
|
+
`| ${escapeTableCell(result.flowName)} | ${result.summary.addedNodes} | ${result.summary.removedNodes} | ${result.summary.modifiedNodes} | ${result.summary.addedEdges} / ${result.summary.removedEdges} | ${flowSummary} | [Open interactive diff](${result.artifactUrl}) |`
|
|
45
99
|
);
|
|
46
100
|
}
|
|
47
101
|
}
|
|
@@ -50,15 +104,63 @@ function buildComment(results, opts = {}) {
|
|
|
50
104
|
}
|
|
51
105
|
return lines.join("\n");
|
|
52
106
|
}
|
|
107
|
+
function readResults(inputDir) {
|
|
108
|
+
return readdirSync(inputDir).filter((file) => file.endsWith(".diff.json")).map((file) => {
|
|
109
|
+
const diff = JSON.parse(readFileSync(join(inputDir, file), "utf8"));
|
|
110
|
+
if (isZeroSummary(diff.summary)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
flowName: diff.flowName,
|
|
115
|
+
summary: diff.summary,
|
|
116
|
+
flowChanges: diff.flowChanges,
|
|
117
|
+
stem: safeFileName(diff.flowName)
|
|
118
|
+
};
|
|
119
|
+
}).filter((value) => value !== null);
|
|
120
|
+
}
|
|
121
|
+
function isZeroSummary(summary) {
|
|
122
|
+
return summary.addedNodes === 0 && summary.removedNodes === 0 && summary.modifiedNodes === 0 && summary.addedEdges === 0 && summary.removedEdges === 0 && summary.changedFlowAttributes === 0;
|
|
123
|
+
}
|
|
124
|
+
function findStickyNote(notes, marker, authorId) {
|
|
125
|
+
return notes.find((note) => note.body.includes(marker) && (authorId === void 0 || note.author?.id === authorId));
|
|
126
|
+
}
|
|
127
|
+
function normalizePath(value) {
|
|
128
|
+
return value.replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
129
|
+
}
|
|
130
|
+
function escapeTableCell(value) {
|
|
131
|
+
return value.replaceAll("|", "\\|").replaceAll("`", "\\`").replace(/\r?\n/g, "<br>");
|
|
132
|
+
}
|
|
133
|
+
function formatFlowChanges(flowChanges) {
|
|
134
|
+
if (!flowChanges || flowChanges.length === 0) {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
const status = flowChanges.find((change) => change.path === "status");
|
|
138
|
+
if (status) {
|
|
139
|
+
return formatStatusChange(status.before, status.after);
|
|
140
|
+
}
|
|
141
|
+
return flowChanges.map((change) => humanizePath(change.path)).join(", ");
|
|
142
|
+
}
|
|
143
|
+
function formatStatusChange(before, after) {
|
|
144
|
+
if (before === "Active" && after !== "Active") {
|
|
145
|
+
return `Deactivated (${String(before)} -> ${String(after)})`;
|
|
146
|
+
}
|
|
147
|
+
if (before !== "Active" && after === "Active") {
|
|
148
|
+
return `Activated (${String(before)} -> ${String(after)})`;
|
|
149
|
+
}
|
|
150
|
+
return `Status: ${String(before)} -> ${String(after)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/ci/gitlab-report.ts
|
|
154
|
+
var REPORTER_USAGE = `Usage:
|
|
155
|
+
flow-delta-gitlab --in <flow-delta-out> [--api-url <url>] [--project-id <id>] [--mr-iid <iid>]
|
|
156
|
+
[--project-url <url>] [--job-id <id>] [--token <token>] [--commit-sha <sha>]
|
|
157
|
+
`;
|
|
53
158
|
function artifactUrl(env, relPath, stem) {
|
|
54
159
|
const base = env.projectUrl.replace(/\/+$/, "");
|
|
55
160
|
const relativePath = normalizePath(relPath);
|
|
56
161
|
const encodedPath = [relativePath, `${stem}.html`].filter(Boolean).flatMap((segment) => segment.split("/").filter(Boolean)).map(encodeURIComponent).join("/");
|
|
57
162
|
return `${base}/-/jobs/${env.jobId}/artifacts/file/${encodedPath}`;
|
|
58
163
|
}
|
|
59
|
-
function findStickyNote(notes, marker, authorId) {
|
|
60
|
-
return notes.find((note) => note.body.includes(marker) && (authorId === void 0 || note.author?.id === authorId));
|
|
61
|
-
}
|
|
62
164
|
async function upsertComment(env, body, fetchImpl = fetch) {
|
|
63
165
|
const headers = {
|
|
64
166
|
"PRIVATE-TOKEN": env.token,
|
|
@@ -109,6 +211,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
109
211
|
const results = records.map((record) => ({
|
|
110
212
|
flowName: record.flowName,
|
|
111
213
|
summary: record.summary,
|
|
214
|
+
flowChanges: record.flowChanges,
|
|
112
215
|
artifactUrl: artifactUrl(env, inputDir, record.stem)
|
|
113
216
|
}));
|
|
114
217
|
const body = buildComment(results, {
|
|
@@ -121,22 +224,6 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
121
224
|
process.exitCode = 1;
|
|
122
225
|
}
|
|
123
226
|
}
|
|
124
|
-
function readResults(inputDir) {
|
|
125
|
-
return readdirSync(inputDir).filter((file) => file.endsWith(".diff.json")).map((file) => {
|
|
126
|
-
const diff = JSON.parse(readFileSync(join(inputDir, file), "utf8"));
|
|
127
|
-
if (isZeroSummary(diff.summary)) {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
flowName: diff.flowName,
|
|
132
|
-
summary: diff.summary,
|
|
133
|
-
stem: safeFileName(diff.flowName)
|
|
134
|
-
};
|
|
135
|
-
}).filter((value) => value !== null);
|
|
136
|
-
}
|
|
137
|
-
function isZeroSummary(summary) {
|
|
138
|
-
return summary.addedNodes === 0 && summary.removedNodes === 0 && summary.modifiedNodes === 0 && summary.addedEdges === 0 && summary.removedEdges === 0;
|
|
139
|
-
}
|
|
140
227
|
function resolveEnv(values) {
|
|
141
228
|
const apiUrl = stringValue(values["api-url"] ?? process.env.CI_API_V4_URL, "CI_API_V4_URL");
|
|
142
229
|
const projectId = stringValue(values["project-id"] ?? process.env.CI_PROJECT_ID, "CI_PROJECT_ID");
|
|
@@ -184,12 +271,6 @@ async function parseResponse(response, label) {
|
|
|
184
271
|
}
|
|
185
272
|
return body.length > 0 ? JSON.parse(body) : void 0;
|
|
186
273
|
}
|
|
187
|
-
function normalizePath(value) {
|
|
188
|
-
return value.replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
189
|
-
}
|
|
190
|
-
function escapeTableCell(value) {
|
|
191
|
-
return value.replaceAll("|", "\\|").replaceAll("`", "\\`").replace(/\r?\n/g, "<br>");
|
|
192
|
-
}
|
|
193
274
|
if (isMainModule(import.meta.url)) {
|
|
194
275
|
void main();
|
|
195
276
|
}
|
|
@@ -197,6 +278,7 @@ export {
|
|
|
197
278
|
artifactUrl,
|
|
198
279
|
buildComment,
|
|
199
280
|
findStickyNote,
|
|
281
|
+
isZeroSummary,
|
|
200
282
|
main,
|
|
201
283
|
upsertComment
|
|
202
284
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export interface Env {
|
|
2
|
+
BUCKET: R2Bucket;
|
|
3
|
+
ARTIFACT_HMAC_KEY?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const worker = {
|
|
7
|
+
async fetch(req: Request, env: Env): Promise<Response> {
|
|
8
|
+
const url = new URL(req.url);
|
|
9
|
+
const key = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
|
|
10
|
+
if (!key || key.includes("..")) {
|
|
11
|
+
return new Response("Not found", { status: 404 });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (env.ARTIFACT_HMAC_KEY) {
|
|
15
|
+
const exp = Number(url.searchParams.get("exp"));
|
|
16
|
+
const sig = url.searchParams.get("sig") ?? "";
|
|
17
|
+
if (!exp || Math.floor(Date.now() / 1000) > exp) {
|
|
18
|
+
return new Response("Link expired", { status: 403 });
|
|
19
|
+
}
|
|
20
|
+
const expected = await hmacHex(env.ARTIFACT_HMAC_KEY, `${key}:${exp}`);
|
|
21
|
+
if (!timingSafeEqual(expected, sig)) {
|
|
22
|
+
return new Response("Bad signature", { status: 403 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const obj = await env.BUCKET.get(key);
|
|
27
|
+
if (!obj) {
|
|
28
|
+
return new Response("Not found", { status: 404 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const headers = new Headers();
|
|
32
|
+
obj.writeHttpMetadata(headers);
|
|
33
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
34
|
+
headers.set("cache-control", "private, max-age=300");
|
|
35
|
+
return new Response(obj.body, { headers });
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default worker;
|
|
40
|
+
|
|
41
|
+
async function hmacHex(secret: string, message: string): Promise<string> {
|
|
42
|
+
const enc = new TextEncoder();
|
|
43
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
44
|
+
"raw",
|
|
45
|
+
enc.encode(secret),
|
|
46
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
47
|
+
false,
|
|
48
|
+
["sign"],
|
|
49
|
+
);
|
|
50
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message));
|
|
51
|
+
return [...new Uint8Array(sig)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
55
|
+
if (a.length !== b.length) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
let out = 0;
|
|
59
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
60
|
+
out |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
61
|
+
}
|
|
62
|
+
return out === 0;
|
|
63
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: FlowDelta
|
|
2
|
+
|
|
3
|
+
on: pull_request
|
|
4
|
+
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
pull-requests: write
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
flowdelta:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
with:
|
|
15
|
+
fetch-depth: 0
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: 24
|
|
19
|
+
- run: npm install --no-save @syntax-syllogism/flow-delta@^0.3
|
|
20
|
+
- run: |
|
|
21
|
+
./node_modules/.bin/flow-delta \
|
|
22
|
+
--repo . \
|
|
23
|
+
--from "${{ github.event.pull_request.base.sha }}" \
|
|
24
|
+
--to "${{ github.sha }}" \
|
|
25
|
+
--path 'force-app/**/*.flow-meta.xml' \
|
|
26
|
+
--changed-only \
|
|
27
|
+
--out flow-delta-out --json
|
|
28
|
+
- uses: actions/upload-artifact@v4
|
|
29
|
+
with:
|
|
30
|
+
name: flow-delta-out
|
|
31
|
+
path: flow-delta-out
|
|
32
|
+
# Optional live-render path for private repos using your own R2 bucket and Worker.
|
|
33
|
+
# See docs/ci.md for the required secrets, Worker template, and presigned alternative.
|
|
34
|
+
- name: Publish to R2 + sign
|
|
35
|
+
if: ${{ vars.ARTIFACT_BASE_URL != '' }}
|
|
36
|
+
env:
|
|
37
|
+
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
38
|
+
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
39
|
+
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
|
40
|
+
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
|
41
|
+
ARTIFACT_BASE_URL: ${{ vars.ARTIFACT_BASE_URL }}
|
|
42
|
+
ARTIFACT_HMAC_KEY: ${{ secrets.ARTIFACT_HMAC_KEY }}
|
|
43
|
+
run: node ./node_modules/@syntax-syllogism/flow-delta/scripts/r2-publish.mjs flow-delta-out "$GITHUB_REPOSITORY/${{ github.event.number }}/$GITHUB_SHA" > flow-delta-out/urls.json
|
|
44
|
+
continue-on-error: true
|
|
45
|
+
- name: Report with live-render links
|
|
46
|
+
if: ${{ vars.ARTIFACT_BASE_URL != '' }}
|
|
47
|
+
env:
|
|
48
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
49
|
+
run: ./node_modules/.bin/flow-delta-github --in flow-delta-out --artifact-urls flow-delta-out/urls.json
|
|
50
|
+
continue-on-error: true
|
|
51
|
+
- name: Report with baseline artifact link
|
|
52
|
+
if: ${{ vars.ARTIFACT_BASE_URL == '' }}
|
|
53
|
+
env:
|
|
54
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
55
|
+
run: ./node_modules/.bin/flow-delta-github --in flow-delta-out
|
|
56
|
+
continue-on-error: true
|