@syntax-syllogism/flow-delta 0.3.0 → 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 CHANGED
@@ -5,10 +5,12 @@ A semantic, visual diff for Salesforce Flows: parse two versions of a
5
5
  interactive HTML artifact (plus `diff.json`) that shows added / deleted /
6
6
  modified / unchanged nodes and edges with per-property deltas.
7
7
 
8
- Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with
9
- a focus on making it work with GitLab's pipelines. We also opted for our own HTML output over plantuml, graphviz, or mermaid.
8
+ Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with a
9
+ focus on CI review workflows for GitLab merge requests and GitHub pull requests.
10
+ We also opted for our own HTML output over plantuml, graphviz, or mermaid.
10
11
 
11
- [Sample Gitlab Project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/1)
12
+ - [Sample GitLab project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/)
13
+ - [Sample GitHub project with artifacts](https://github.com/Syntax-Syllogism/flow-delta-example/pulls)
12
14
 
13
15
  ## Usage
14
16
 
@@ -19,6 +21,19 @@ for full file-mode and git-mode options.
19
21
  npx tsx src/cli.ts --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out --json
20
22
  ```
21
23
 
24
+ CI reporters read the generated `flow-delta-out/*.diff.json` files and post a
25
+ sticky review comment:
26
+
27
+ ```bash
28
+ flow-delta-gitlab --in flow-delta-out
29
+ flow-delta-github --in flow-delta-out
30
+ ```
31
+
32
+ The GitHub reporter can also consume `--artifact-urls <manifest.json>` for
33
+ user-owned live-render links, such as R2 presigned URLs or Cloudflare
34
+ Worker-backed URLs. See [docs/ci.md](docs/ci.md) for GitLab/GitHub workflow
35
+ recipes, private-repo artifact viewing, and smoke harnesses.
36
+
22
37
  ## Development
23
38
 
24
39
  ```bash
package/dist/cli.js CHANGED
@@ -628,6 +628,38 @@ function isPlainObject(value) {
628
628
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
629
629
  }
630
630
 
631
+ // src/model/flow-header.ts
632
+ import { Parser as Parser2 } from "xml2js";
633
+ var FLOW_HEADER_KEYS = [
634
+ "status",
635
+ "processType",
636
+ "runInMode",
637
+ "apiVersion",
638
+ "triggerOrder",
639
+ "description",
640
+ "interviewLabel",
641
+ "isTemplate"
642
+ ];
643
+ async function extractFlowHeader(xml) {
644
+ try {
645
+ const parsed = await new Parser2({ explicitArray: false }).parseStringPromise(xml);
646
+ const flow = parsed.Flow;
647
+ if (!flow) {
648
+ return {};
649
+ }
650
+ const header = {};
651
+ for (const key of FLOW_HEADER_KEYS) {
652
+ const value = flow[key];
653
+ if (value !== void 0 && (value === null || typeof value !== "object")) {
654
+ header[key] = String(value);
655
+ }
656
+ }
657
+ return header;
658
+ } catch {
659
+ return {};
660
+ }
661
+ }
662
+
631
663
  // src/diff/deep-diff.ts
632
664
  function deepDiff(before, after, path = "") {
633
665
  if (Object.is(before, after)) {
@@ -685,21 +717,32 @@ function diffModel(oldModel, newModel) {
685
717
  const edgeIds = [.../* @__PURE__ */ new Set([...oldEdges.keys(), ...newEdges.keys()])].sort();
686
718
  const nodes = nodeIds.map((id) => classifyNode(oldNodes.get(id), newNodes.get(id)));
687
719
  const edges = edgeIds.map((id) => classifyEdge(oldEdges.get(id), newEdges.get(id)));
720
+ const flowChanges = diffFlowHeaders(oldModel, newModel);
688
721
  const summary = {
689
722
  addedNodes: nodes.filter((node) => node.status === "added").length,
690
723
  removedNodes: nodes.filter((node) => node.status === "deleted").length,
691
724
  modifiedNodes: nodes.filter((node) => node.status === "modified").length,
692
725
  unchangedNodes: nodes.filter((node) => node.status === "unchanged").length,
693
726
  addedEdges: edges.filter((edge) => edge.status === "added").length,
694
- removedEdges: edges.filter((edge) => edge.status === "deleted").length
727
+ removedEdges: edges.filter((edge) => edge.status === "deleted").length,
728
+ changedFlowAttributes: flowChanges.length
695
729
  };
696
730
  return {
697
731
  flowName: selectFlowName(oldModel, newModel),
698
732
  summary,
733
+ ...flowChanges.length > 0 ? { flowChanges } : {},
699
734
  nodes,
700
735
  edges
701
736
  };
702
737
  }
738
+ function diffFlowHeaders(oldModel, newModel) {
739
+ if (!oldModel.header || !newModel.header) {
740
+ return [];
741
+ }
742
+ const changes = deepDiff(oldModel.header, newModel.header);
743
+ const order = new Map(FLOW_HEADER_KEYS.map((key, index) => [key, index]));
744
+ return changes.sort((left, right) => (order.get(left.path) ?? 999) - (order.get(right.path) ?? 999));
745
+ }
703
746
  function selectFlowName(oldModel, newModel) {
704
747
  if (newModel.nodes.length > 0 || newModel.edges.length > 0) {
705
748
  return newModel.flowName;
@@ -1532,6 +1575,7 @@ function renderHtml(layout) {
1532
1575
  const edgeStat = s.addedEdges + s.removedEdges > 0 ? `<span class="sep">\xB7</span><span class="stat edges">+${s.addedEdges}/\u2212${s.removedEdges} edges</span>` : "";
1533
1576
  const metaHtml = `${stat(s.addedNodes, "added", "added")}<span class="sep">\xB7</span>${stat(s.removedNodes, "deleted", "deleted")}<span class="sep">\xB7</span>${stat(s.modifiedNodes, "modified", "modified")}${edgeStat}`;
1534
1577
  const viewBox = fitViewBox(layout.width, layout.height);
1578
+ const flowBannerHtml = renderFlowBanner(layout.diff.flowChanges);
1535
1579
  return `<!doctype html>
1536
1580
  <html lang="en">
1537
1581
  <head>
@@ -1678,6 +1722,15 @@ function renderHtml(layout) {
1678
1722
  .group-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
1679
1723
  .group-label { font-weight: 700; font-size: 12.5px; }
1680
1724
  .outcome-group .changes { margin-bottom: 8px; }
1725
+ .flow-banner { border-bottom: 1px solid var(--border); background: #fffaf0; padding: 12px 20px; }
1726
+ .flow-banner-callout { margin-bottom: 8px; font-weight: 750; }
1727
+ .flow-banner-callout.deactivated { color: var(--deleted); }
1728
+ .flow-banner-callout.activated { color: var(--added); }
1729
+ .flow-banner-callout.neutral { color: var(--modified); }
1730
+ .flow-banner-list { display: grid; gap: 7px; max-width: 960px; }
1731
+ .flow-change-row { display: grid; grid-template-columns: minmax(120px, 190px) minmax(0, 1fr); align-items: start; gap: 10px; }
1732
+ .flow-change-label { color: var(--muted); font-size: 12px; font-weight: 700; }
1733
+ .flow-change-value { min-width: 0; max-height: 180px; overflow: auto; }
1681
1734
  .edge { fill: none; }
1682
1735
  .edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
1683
1736
  .edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
@@ -1734,6 +1787,7 @@ function renderHtml(layout) {
1734
1787
  </div>
1735
1788
  </div>
1736
1789
  </header>
1790
+ ${flowBannerHtml}
1737
1791
  <main>
1738
1792
  <div class="canvas">
1739
1793
  <svg id="flow-svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Flow diff">
@@ -2073,6 +2127,48 @@ function renderHtml(layout) {
2073
2127
  </body>
2074
2128
  </html>`;
2075
2129
  }
2130
+ function renderFlowBanner(changes) {
2131
+ if (!changes || changes.length === 0) {
2132
+ return "";
2133
+ }
2134
+ const statusChange = changes.find((change) => change.path === "status");
2135
+ const callout = statusChange ? renderStatusCallout(statusChange.before, statusChange.after) : "";
2136
+ return `<section class="flow-banner" aria-label="Flow-level changes">
2137
+ ${callout}
2138
+ <div class="flow-banner-list">
2139
+ ${changes.map((change) => `<div class="flow-change-row">
2140
+ <div class="flow-change-label">${escapeHtml2(humanizePath(change.path))}</div>
2141
+ <div class="flow-change-value">${renderValueDelta(change.before, change.after)}</div>
2142
+ </div>`).join("")}
2143
+ </div>
2144
+ </section>`;
2145
+ }
2146
+ function renderStatusCallout(before, after) {
2147
+ const beforeText = String(before);
2148
+ const afterText = String(after);
2149
+ if (beforeText === "Active" && afterText !== "Active") {
2150
+ return `<div class="flow-banner-callout deactivated">Deactivated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
2151
+ }
2152
+ if (beforeText !== "Active" && afterText === "Active") {
2153
+ return `<div class="flow-banner-callout activated">Activated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
2154
+ }
2155
+ return `<div class="flow-banner-callout neutral">Status: ${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)}</div>`;
2156
+ }
2157
+ function renderValueDelta(before, after) {
2158
+ if (before === void 0) {
2159
+ return `<span class="val ins">${escapeHtml2(formatValue(after))}</span>`;
2160
+ }
2161
+ if (after === void 0) {
2162
+ return `<span class="val del">${escapeHtml2(formatValue(before))}</span>`;
2163
+ }
2164
+ return `<span class="val del">${escapeHtml2(formatValue(before))}</span><span class="arrow">-></span><span class="val ins">${escapeHtml2(formatValue(after))}</span>`;
2165
+ }
2166
+ function formatValue(value) {
2167
+ if (value === void 0) {
2168
+ return "(missing)";
2169
+ }
2170
+ return String(value);
2171
+ }
2076
2172
  function fitViewBox(contentWidth, contentHeight) {
2077
2173
  const minWidth = 720;
2078
2174
  const minHeight = 540;
@@ -2236,8 +2332,10 @@ async function main(argv = process.argv.slice(2)) {
2236
2332
  }
2237
2333
  }
2238
2334
  async function runFileMode(oldPath, newPath, outDir, writeJson) {
2239
- const oldModel = buildModel(await parseXml(readFlowFromFile(oldPath)));
2240
- const newModel = buildModel(await parseXml(readFlowFromFile(newPath)));
2335
+ const oldXml = readFlowFromFile(oldPath);
2336
+ const newXml = readFlowFromFile(newPath);
2337
+ const oldModel = await buildModelWithHeader(oldXml);
2338
+ const newModel = await buildModelWithHeader(newXml);
2241
2339
  await writeArtifacts(oldModel, newModel, outDir, writeJson);
2242
2340
  }
2243
2341
  async function runGitMode(repo, from, to, pattern, outDir, writeJson, changedOnly) {
@@ -2247,8 +2345,8 @@ async function runGitMode(repo, from, to, pattern, outDir, writeJson, changedOnl
2247
2345
  try {
2248
2346
  const oldXml = readFlowFromGit(repo, from, filePath);
2249
2347
  const newXml = readFlowFromGit(repo, to, filePath);
2250
- const oldModel = oldXml ? buildModel(await parseXml(oldXml)) : buildEmptyModel();
2251
- const newModel = newXml ? buildModel(await parseXml(newXml)) : buildEmptyModel();
2348
+ const oldModel = oldXml ? await buildModelWithHeader(oldXml) : buildEmptyModel();
2349
+ const newModel = newXml ? await buildModelWithHeader(newXml) : buildEmptyModel();
2252
2350
  await writeArtifacts(oldModel, newModel, outDir, writeJson, filePath);
2253
2351
  } catch (error) {
2254
2352
  hadFailure = true;
@@ -2263,6 +2361,11 @@ async function parseXml(xml) {
2263
2361
  const parser = new FlowParser(xml);
2264
2362
  return parser.generateFlowDefinition();
2265
2363
  }
2364
+ async function buildModelWithHeader(xml) {
2365
+ const model = buildModel(await parseXml(xml));
2366
+ model.header = await extractFlowHeader(xml);
2367
+ return model;
2368
+ }
2266
2369
  function buildEmptyModel() {
2267
2370
  return {
2268
2371
  flowName: "(unknown)",
@@ -2281,9 +2384,15 @@ async function writeArtifacts(oldModel, newModel, outDir, writeJson, sourcePath)
2281
2384
  writeFileSync(join(outDir, `${fileStem}.diff.json`), JSON.stringify(diff, null, 2), "utf8");
2282
2385
  }
2283
2386
  console.log(
2284
- `${diff.flowName}: nodes ${diff.summary.addedNodes} added, ${diff.summary.removedNodes} deleted, ${diff.summary.modifiedNodes} modified; edges ${diff.summary.addedEdges} added, ${diff.summary.removedEdges} deleted`
2387
+ `${diff.flowName}: nodes ${diff.summary.addedNodes} added, ${diff.summary.removedNodes} deleted, ${diff.summary.modifiedNodes} modified; edges ${diff.summary.addedEdges} added, ${diff.summary.removedEdges} deleted${formatFlowAttributeSummary(diff.flowChanges)}`
2285
2388
  );
2286
2389
  }
2390
+ function formatFlowAttributeSummary(flowChanges) {
2391
+ if (!flowChanges || flowChanges.length === 0) {
2392
+ return "";
2393
+ }
2394
+ return `; flow attributes: ${flowChanges.length} changed (${flowChanges.map((change) => change.path).join(", ")})`;
2395
+ }
2287
2396
  function discoverGitFiles(repo, from, to, pattern, changedOnly) {
2288
2397
  const matcher = createPathMatcher(pattern);
2289
2398
  if (!changedOnly) {
@@ -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
@@ -0,0 +1,23 @@
1
+ FlowDelta:
2
+ image: node:24-alpine
3
+ rules:
4
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
5
+ variables:
6
+ GIT_DEPTH: 0
7
+ before_script:
8
+ - apk add --no-cache git
9
+ script:
10
+ - |
11
+ # see /docs/ci.md for details on how this works
12
+ npx --yes -p @syntax-syllogism/flow-delta@latest flow-delta \
13
+ --repo . \
14
+ --from "$CI_MERGE_REQUEST_DIFF_BASE_SHA" \
15
+ --to "${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA:-$CI_COMMIT_SHA}" \
16
+ --path 'force-app/**/*.flow-meta.xml' \
17
+ --changed-only \
18
+ --out flow-delta-out --json
19
+ - npx --yes -p @syntax-syllogism/flow-delta@latest flow-delta-gitlab --in flow-delta-out
20
+ artifacts:
21
+ paths: [flow-delta-out]
22
+ expire_in: 30 days
23
+ allow_failure: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/flow-delta",
3
- "version": "0.3.0",
3
+ "version": "0.4.3",
4
4
  "type": "module",
5
5
  "description": "Semantic visual diff for Salesforce Flows",
6
6
  "license": "MIT",
@@ -27,14 +27,15 @@
27
27
  "scripts": {
28
28
  "build": "node scripts/build.mjs",
29
29
  "smoke:gitlab": "node --import tsx scripts/smoke-gitlab.ts",
30
- "test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/gitlab-report.test.ts test/smoke-gitlab.test.ts",
30
+ "smoke:github": "node --import tsx scripts/smoke-github.ts",
31
+ "test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/report-core.test.ts test/gitlab-report.test.ts test/github-report.test.ts test/smoke-gitlab.test.ts test/r2-publish.test.ts test/cloudflare-worker.test.ts",
31
32
  "test:parser": "node --import tsx --test test/parser.test.ts",
32
33
  "prepublishOnly": "npm run build && npm test",
33
34
  "render:fixtures": "bash bin/render-fixtures.sh"
34
35
  },
35
36
  "devDependencies": {
36
37
  "esbuild": "^0.28.0",
37
- "@types/node": "^25.9.2",
38
+ "@types/node": "^26.1.0",
38
39
  "@types/xml2js": "^0.4.14",
39
40
  "tsx": "^4.19.0"
40
41
  },
@@ -44,10 +45,13 @@
44
45
  },
45
46
  "bin": {
46
47
  "flow-delta": "dist/cli.js",
47
- "flow-delta-gitlab": "dist/gitlab-report.js"
48
+ "flow-delta-gitlab": "dist/gitlab-report.js",
49
+ "flow-delta-github": "dist/github-report.js"
48
50
  },
49
51
  "files": [
50
52
  "dist",
53
+ "examples",
54
+ "scripts/r2-publish.mjs",
51
55
  "NOTICE",
52
56
  "LICENSE",
53
57
  "LICENSE-APACHE",
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ import { createHmac } from "node:crypto";
3
+ import { readFileSync, readdirSync } from "node:fs";
4
+ import { basename, join } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
9
+ const [outDir = "flow-delta-out", keyPrefix = defaultKeyPrefix()] = process.argv.slice(2);
10
+
11
+ try {
12
+ const urls = publishDirectory(outDir, keyPrefix);
13
+ process.stdout.write(`${JSON.stringify(urls, null, 2)}\n`);
14
+ } catch (error) {
15
+ console.error(error instanceof Error ? error.message : String(error));
16
+ process.exitCode = 1;
17
+ }
18
+ }
19
+
20
+ export function publishDirectory(outDir, keyPrefix, env = process.env) {
21
+ const bucket = required(env.R2_BUCKET, "R2_BUCKET");
22
+ const endpoint = r2Endpoint(env);
23
+ const htmlFiles = readdirSync(outDir).filter((file) => file.endsWith(".html")).sort();
24
+ const urls = {};
25
+
26
+ for (const file of htmlFiles) {
27
+ const key = joinKey(keyPrefix, file);
28
+ uploadHtml(join(outDir, file), bucket, key, endpoint);
29
+ urls[file] = resolveUrl(bucket, key, endpoint, env);
30
+ }
31
+
32
+ return urls;
33
+ }
34
+
35
+ export function signedWorkerUrl(baseUrl, key, secret, exp) {
36
+ const base = baseUrl.replace(/\/+$/, "");
37
+ const encodedKey = key.split("/").map(encodeURIComponent).join("/");
38
+ const sig = hmacHex(secret, `${key}:${exp}`);
39
+ return `${base}/${encodedKey}?exp=${exp}&sig=${sig}`;
40
+ }
41
+
42
+ export function hmacHex(secret, message) {
43
+ return createHmac("sha256", secret).update(message).digest("hex");
44
+ }
45
+
46
+ function resolveUrl(bucket, key, endpoint, env) {
47
+ if (env.ARTIFACT_BASE_URL) {
48
+ const secret = required(env.ARTIFACT_HMAC_KEY, "ARTIFACT_HMAC_KEY");
49
+ const exp = String(Math.floor(Date.now() / 1000) + expirySeconds(env));
50
+ return signedWorkerUrl(env.ARTIFACT_BASE_URL, key, secret, exp);
51
+ }
52
+
53
+ return presignUrl(bucket, key, endpoint, expirySeconds(env));
54
+ }
55
+
56
+ function uploadHtml(path, bucket, key, endpoint) {
57
+ runAws([
58
+ "s3",
59
+ "cp",
60
+ path,
61
+ `s3://${bucket}/${key}`,
62
+ "--endpoint-url",
63
+ endpoint,
64
+ "--content-type",
65
+ "text/html",
66
+ ]);
67
+ }
68
+
69
+ function presignUrl(bucket, key, endpoint, expiresIn) {
70
+ return runAws([
71
+ "s3",
72
+ "presign",
73
+ `s3://${bucket}/${key}`,
74
+ "--endpoint-url",
75
+ endpoint,
76
+ "--expires-in",
77
+ String(expiresIn),
78
+ ]);
79
+ }
80
+
81
+ function runAws(args) {
82
+ const result = spawnSync("aws", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
83
+ if (result.status !== 0) {
84
+ throw new Error(result.stderr.trim() || `aws ${args.join(" ")} failed`);
85
+ }
86
+ return result.stdout.trim();
87
+ }
88
+
89
+ function r2Endpoint(env) {
90
+ const accountId = required(env.R2_ACCOUNT_ID, "R2_ACCOUNT_ID");
91
+ return `https://${accountId}.r2.cloudflarestorage.com`;
92
+ }
93
+
94
+ function expirySeconds(env) {
95
+ const raw = env.ARTIFACT_EXPIRES_IN_SECONDS ?? "86400";
96
+ const value = Number(raw);
97
+ if (!Number.isInteger(value) || value <= 0) {
98
+ throw new Error("ARTIFACT_EXPIRES_IN_SECONDS must be a positive integer");
99
+ }
100
+ return Math.min(value, 604800);
101
+ }
102
+
103
+ function joinKey(prefix, file) {
104
+ return [prefix.replace(/^\/+|\/+$/g, ""), basename(file)].filter(Boolean).join("/");
105
+ }
106
+
107
+ function defaultKeyPrefix() {
108
+ const repo = required(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY");
109
+ const sha = required(process.env.GITHUB_SHA, "GITHUB_SHA");
110
+ const pr = process.env.GITHUB_PR_NUMBER ?? process.env.PR_NUMBER ?? readGitHubEventPrNumber() ?? "unknown-pr";
111
+ return `${repo}/${pr}/${sha}`;
112
+ }
113
+
114
+ function readGitHubEventPrNumber() {
115
+ const eventPath = process.env.GITHUB_EVENT_PATH;
116
+ if (!eventPath) {
117
+ return undefined;
118
+ }
119
+ try {
120
+ const event = JSON.parse(readFileSync(eventPath, "utf8"));
121
+ return typeof event?.number === "number" ? String(event.number) : undefined;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ function required(value, name) {
128
+ if (!value) {
129
+ throw new Error(`Missing required value: ${name}`);
130
+ }
131
+ return value;
132
+ }