@syntax-syllogism/flow-delta 0.3.0 → 0.5.0

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.
@@ -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
+ };
@@ -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/gitlab-report.ts
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 \u25B8](${result.artifactUrl}) |`
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,8 @@
1
+ name = "flowdelta-artifacts"
2
+ main = "src/index.ts"
3
+ compatibility_date = "2025-01-01"
4
+
5
+ [[r2_buckets]]
6
+ binding = "BUCKET"
7
+ bucket_name = "flow-delta-sample"
8
+
@@ -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