@syntax-syllogism/flow-delta 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/ci/gitlab-report.ts
4
+ import { readFileSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+
8
+ // src/util/file-name.ts
9
+ function safeFileName(value) {
10
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
11
+ }
12
+
13
+ // src/ci/gitlab-report.ts
14
+ var DEFAULT_MARKER = "<!-- FlowDelta:report -->";
15
+ var REPORTER_USAGE = `Usage:
16
+ flow-delta-gitlab --in <flow-delta-out> [--api-url <url>] [--project-id <id>] [--mr-iid <iid>]
17
+ [--project-url <url>] [--job-id <id>] [--token <token>] [--commit-sha <sha>]
18
+ `;
19
+ function buildComment(results, opts = {}) {
20
+ const marker = opts.marker ?? DEFAULT_MARKER;
21
+ const sorted = [...results].sort((left, right) => left.flowName.localeCompare(right.flowName));
22
+ const lines = [marker, "", `## \u{1F50D} FlowDelta \u2014 ${sorted.length} flow(s) changed`, ""];
23
+ if (sorted.length === 0) {
24
+ lines.push("_No Flow changes in this MR._");
25
+ } else {
26
+ lines.push("| Flow | +nodes | -nodes | ~nodes | +/-edges | Diff |");
27
+ lines.push("| --- | ---: | ---: | ---: | ---: | --- |");
28
+ for (const result of sorted) {
29
+ lines.push(
30
+ `| ${escapeTableCell(result.flowName)} | ${result.summary.addedNodes} | ${result.summary.removedNodes} | ${result.summary.modifiedNodes} | ${result.summary.addedEdges} / ${result.summary.removedEdges} | [Open interactive diff \u25B8](${result.artifactUrl}) |`
31
+ );
32
+ }
33
+ }
34
+ if (opts.commitSha) {
35
+ lines.push("", `Commit: \`${opts.commitSha.slice(0, 7)}\``);
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+ function artifactUrl(env, relPath, stem) {
40
+ const base = env.projectUrl.replace(/\/+$/, "");
41
+ const relativePath = normalizePath(relPath);
42
+ const encodedPath = [relativePath, `${stem}.html`].filter(Boolean).flatMap((segment) => segment.split("/").filter(Boolean)).map(encodeURIComponent).join("/");
43
+ return `${base}/-/jobs/${env.jobId}/artifacts/file/${encodedPath}`;
44
+ }
45
+ function findStickyNote(notes, marker, authorId) {
46
+ return notes.find((note) => note.body.includes(marker) && (authorId === void 0 || note.author?.id === authorId));
47
+ }
48
+ async function upsertComment(env, body, fetchImpl = fetch) {
49
+ const headers = {
50
+ "PRIVATE-TOKEN": env.token,
51
+ "Content-Type": "application/json"
52
+ };
53
+ const marker = env.marker ?? DEFAULT_MARKER;
54
+ const user = await requestJson(fetchImpl, `${env.apiUrl}/user`, {
55
+ headers
56
+ });
57
+ const notes = await listNotes(fetchImpl, env, headers);
58
+ const sticky = findStickyNote(notes, marker, user.id);
59
+ const notePath = sticky ? `/notes/${sticky.id}` : "/notes";
60
+ const method = sticky ? "PUT" : "POST";
61
+ await requestJson(fetchImpl, `${env.apiUrl}/projects/${env.projectId}/merge_requests/${env.mergeRequestIid}${notePath}`, {
62
+ method,
63
+ headers,
64
+ body: JSON.stringify({ body })
65
+ });
66
+ }
67
+ async function main(argv = process.argv.slice(2)) {
68
+ try {
69
+ const parsed = parseArgs({
70
+ args: argv,
71
+ options: {
72
+ in: { type: "string" },
73
+ "api-url": { type: "string" },
74
+ "project-id": { type: "string" },
75
+ "mr-iid": { type: "string" },
76
+ "project-url": { type: "string" },
77
+ "job-id": { type: "string" },
78
+ token: { type: "string" },
79
+ "commit-sha": { type: "string" },
80
+ marker: { type: "string" },
81
+ help: { type: "boolean", short: "h" }
82
+ },
83
+ allowPositionals: false
84
+ });
85
+ if (parsed.values.help) {
86
+ console.log(REPORTER_USAGE.trimEnd());
87
+ return;
88
+ }
89
+ const inputDir = parsed.values.in ?? process.env.FLOW_LENS_OUT_DIR ?? "./flow-delta-out";
90
+ const records = readResults(inputDir);
91
+ if (records.length === 0) {
92
+ return;
93
+ }
94
+ const env = resolveEnv(parsed.values);
95
+ const results = records.map((record) => ({
96
+ flowName: record.flowName,
97
+ summary: record.summary,
98
+ artifactUrl: artifactUrl(env, inputDir, record.stem)
99
+ }));
100
+ const body = buildComment(results, {
101
+ marker: env.marker,
102
+ commitSha: env.commitSha
103
+ });
104
+ await upsertComment(env, body);
105
+ } catch (error) {
106
+ console.error(error.message);
107
+ process.exitCode = 1;
108
+ }
109
+ }
110
+ function readResults(inputDir) {
111
+ return readdirSync(inputDir).filter((file) => file.endsWith(".diff.json")).map((file) => {
112
+ const diff = JSON.parse(readFileSync(join(inputDir, file), "utf8"));
113
+ if (isZeroSummary(diff.summary)) {
114
+ return null;
115
+ }
116
+ return {
117
+ flowName: diff.flowName,
118
+ summary: diff.summary,
119
+ stem: safeFileName(diff.flowName)
120
+ };
121
+ }).filter((value) => value !== null);
122
+ }
123
+ function isZeroSummary(summary) {
124
+ return summary.addedNodes === 0 && summary.removedNodes === 0 && summary.modifiedNodes === 0 && summary.addedEdges === 0 && summary.removedEdges === 0;
125
+ }
126
+ function resolveEnv(values) {
127
+ const apiUrl = stringValue(values["api-url"] ?? process.env.CI_API_V4_URL, "CI_API_V4_URL");
128
+ const projectId = stringValue(values["project-id"] ?? process.env.CI_PROJECT_ID, "CI_PROJECT_ID");
129
+ const mergeRequestIid = stringValue(values["mr-iid"] ?? process.env.CI_MERGE_REQUEST_IID, "CI_MERGE_REQUEST_IID");
130
+ const projectUrl = stringValue(values["project-url"] ?? process.env.CI_PROJECT_URL, "CI_PROJECT_URL");
131
+ const jobId = stringValue(values["job-id"] ?? process.env.CI_JOB_ID, "CI_JOB_ID");
132
+ const token = stringValue(values.token ?? process.env.FlowDelta_GITLAB_TOKEN, "FlowDelta_GITLAB_TOKEN");
133
+ const commitSha = stringValueOptional(values["commit-sha"] ?? process.env.CI_COMMIT_SHA);
134
+ const marker = stringValueOptional(values.marker) ?? DEFAULT_MARKER;
135
+ return { apiUrl, projectId, mergeRequestIid, projectUrl, jobId, token, commitSha, marker };
136
+ }
137
+ function stringValue(value, name) {
138
+ if (typeof value !== "string" || value.length === 0) {
139
+ throw new Error(`Missing required value: ${name}`);
140
+ }
141
+ return value;
142
+ }
143
+ function stringValueOptional(value) {
144
+ return typeof value === "string" && value.length > 0 ? value : void 0;
145
+ }
146
+ async function listNotes(fetchImpl, env, headers) {
147
+ const notes = [];
148
+ let page = 1;
149
+ while (true) {
150
+ const response = await fetchImpl(
151
+ `${env.apiUrl}/projects/${env.projectId}/merge_requests/${env.mergeRequestIid}/notes?per_page=100&page=${page}`,
152
+ { headers }
153
+ );
154
+ const pageNotes = await parseResponse(response, "list MR notes");
155
+ notes.push(...pageNotes);
156
+ if (pageNotes.length < 100) {
157
+ return notes;
158
+ }
159
+ page += 1;
160
+ }
161
+ }
162
+ async function requestJson(fetchImpl, url, init, label = url) {
163
+ const response = await fetchImpl(url, init);
164
+ return parseResponse(response, label);
165
+ }
166
+ async function parseResponse(response, label) {
167
+ const body = await response.text();
168
+ if (!response.ok) {
169
+ throw new Error(`${label} failed: ${response.status} ${body}`);
170
+ }
171
+ return body.length > 0 ? JSON.parse(body) : void 0;
172
+ }
173
+ function normalizePath(value) {
174
+ return value.replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/^\/+/, "").replace(/\/+$/, "");
175
+ }
176
+ function escapeTableCell(value) {
177
+ return value.replaceAll("|", "\\|").replaceAll("`", "\\`").replace(/\r?\n/g, "<br>");
178
+ }
179
+ if (import.meta.url === `file://${process.argv[1]}`) {
180
+ void main();
181
+ }
182
+ export {
183
+ artifactUrl,
184
+ buildComment,
185
+ findStickyNote,
186
+ main,
187
+ upsertComment
188
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@syntax-syllogism/flow-delta",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Semantic visual diff for Salesforce Flows",
6
+ "license": "MIT",
7
+ "author": "Jacob Richter",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/syntax-syllogism/flow-delta.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/syntax-syllogism/flow-delta/issues"
14
+ },
15
+ "homepage": "https://github.com/syntax-syllogism/flow-delta#readme",
16
+ "keywords": [
17
+ "salesforce",
18
+ "flow",
19
+ "diff",
20
+ "visual-diff",
21
+ "metadata",
22
+ "ci"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "node scripts/build.mjs",
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",
31
+ "test:parser": "node --import tsx --test test/parser.test.ts",
32
+ "prepublishOnly": "npm run build && npm test",
33
+ "render:fixtures": "bash bin/render-fixtures.sh"
34
+ },
35
+ "devDependencies": {
36
+ "esbuild": "^0.28.0",
37
+ "@types/node": "^22.0.0",
38
+ "@types/xml2js": "^0.4.14",
39
+ "tsx": "^4.19.0"
40
+ },
41
+ "dependencies": {
42
+ "elkjs": "^0.10.0",
43
+ "xml2js": "^0.6.2"
44
+ },
45
+ "bin": {
46
+ "flow-delta": "dist/cli.js",
47
+ "flow-delta-gitlab": "dist/gitlab-report.js"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "NOTICE",
52
+ "LICENSE",
53
+ "LICENSE-APACHE",
54
+ "README.md"
55
+ ],
56
+ "engines": {
57
+ "node": ">=18"
58
+ }
59
+ }