@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 +18 -3
- package/dist/cli.js +115 -6
- 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
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
|
-
|
|
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
|
|
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
|
|
2240
|
-
const
|
|
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 ?
|
|
2251
|
-
const newModel = newXml ?
|
|
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
|
+
};
|
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
|
|
@@ -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
|
|
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
|
-
"
|
|
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": "^
|
|
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
|
+
}
|