@nimbus-dev/connectors 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/connectors/bitrise/nimbus.extension.json +2 -2
- package/connectors/bitrise/src/server.ts +12 -4
- package/connectors/bitrise/src/tools.ts +138 -7
- package/connectors/codemagic/nimbus.extension.json +2 -2
- package/connectors/codemagic/src/server.ts +12 -4
- package/connectors/codemagic/src/tools.ts +130 -6
- package/connectors/firebase/nimbus.extension.json +2 -2
- package/connectors/firebase/src/server.ts +12 -4
- package/connectors/firebase/src/tools.ts +155 -9
- package/connectors/kubernetes/src/manifest-diff.ts +250 -0
- package/connectors/kubernetes/src/tools.ts +259 -179
- package/connectors/netlify/nimbus.extension.json +2 -2
- package/connectors/netlify/src/server.ts +12 -4
- package/connectors/netlify/src/tools.ts +158 -8
- package/connectors/testflight/nimbus.extension.json +2 -2
- package/connectors/testflight/src/server.ts +12 -4
- package/connectors/testflight/src/tools.ts +185 -6
- package/connectors/vercel/nimbus.extension.json +2 -2
- package/connectors/vercel/src/server.ts +12 -4
- package/connectors/vercel/src/tools.ts +161 -7
- package/package.json +1 -1
- package/shared/cap-text.ts +15 -0
- package/shared/env-json-api.ts +24 -6
- package/shared/line-diff.ts +158 -0
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { type ConsentServer, createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
3
|
+
import {
|
|
4
|
+
createJsonGetter,
|
|
5
|
+
createJsonSender,
|
|
6
|
+
envAuthHeaders,
|
|
7
|
+
type JsonApiConfig,
|
|
8
|
+
} from "../../../shared/env-json-api.ts";
|
|
3
9
|
import { matchesResult, searchToolInputSchema } from "../../../shared/mcp-search-tool.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
10
|
+
import {
|
|
11
|
+
createRegisterSimpleTool,
|
|
12
|
+
createZodToolRegistrar,
|
|
13
|
+
mcpJsonResult as jsonResult,
|
|
14
|
+
} from "../../../shared/mcp-tool-kit.ts";
|
|
6
15
|
import { filterVercelDeployments } from "./search-filter.ts";
|
|
7
16
|
|
|
8
17
|
const BASE = "https://api.vercel.com";
|
|
@@ -21,11 +30,15 @@ function withTeam(path: string): string {
|
|
|
21
30
|
return `${path}${sep}teamId=${encodeURIComponent(team)}`;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
const
|
|
33
|
+
const api: JsonApiConfig = {
|
|
25
34
|
base: BASE,
|
|
26
35
|
label: "Vercel",
|
|
27
36
|
headers: envAuthHeaders({ env: "VERCEL_TOKEN" }),
|
|
28
|
-
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getJson = createJsonGetter(api);
|
|
40
|
+
// A mutation's 3xx is refused (never followed), so the token is not replayed anywhere.
|
|
41
|
+
const sendJson = createJsonSender(api);
|
|
29
42
|
|
|
30
43
|
/** Every request is scoped to `VERCEL_TEAM_ID` when one is configured. */
|
|
31
44
|
async function vercelGet(path: string): Promise<unknown> {
|
|
@@ -33,9 +46,71 @@ async function vercelGet(path: string): Promise<unknown> {
|
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
36
|
-
export const VERCEL_TOOL_NAMES = [
|
|
49
|
+
export const VERCEL_TOOL_NAMES = [
|
|
50
|
+
"vercel_list",
|
|
51
|
+
"vercel_get",
|
|
52
|
+
"vercel_search",
|
|
53
|
+
"vercel_project_production_get",
|
|
54
|
+
"vercel_deployment_redeploy",
|
|
55
|
+
"vercel_deployment_promote",
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
/** Deployment and project ids: never `.` or `..` (a URL parser collapses them out of the path). */
|
|
59
|
+
const idArg = z
|
|
60
|
+
.string()
|
|
61
|
+
.regex(/^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/, "letters, digits, '.', '_' and '-' only");
|
|
62
|
+
|
|
63
|
+
type Obj = Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
function asObj(v: unknown): Obj {
|
|
66
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Obj) : {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function str(v: unknown): string | undefined {
|
|
70
|
+
return typeof v === "string" && v !== "" ? v : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The facts of one deployment a redeploy needs and an approver wants to see. */
|
|
74
|
+
async function readDeployment(deploymentId: string): Promise<Obj & { name: string | undefined }> {
|
|
75
|
+
const d = asObj(await vercelGet(`/v13/deployments/${encodeURIComponent(deploymentId)}`));
|
|
76
|
+
return {
|
|
77
|
+
id: str(d["id"]) ?? str(d["uid"]) ?? deploymentId,
|
|
78
|
+
name: str(d["name"]),
|
|
79
|
+
target: d["target"] ?? null,
|
|
80
|
+
readyState: str(d["readyState"]) ?? str(d["state"]),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `{projectId, name, production}`: the deployment a promote makes stop being current. */
|
|
85
|
+
async function readProductionState(projectId: string): Promise<Obj> {
|
|
86
|
+
const project = asObj(await vercelGet(`/v9/projects/${encodeURIComponent(projectId)}`));
|
|
87
|
+
const prod = asObj(asObj(project["targets"])["production"]);
|
|
88
|
+
const id = str(prod["id"]) ?? str(prod["uid"]);
|
|
89
|
+
return {
|
|
90
|
+
projectId: str(project["id"]) ?? projectId,
|
|
91
|
+
name: str(project["name"]),
|
|
92
|
+
production:
|
|
93
|
+
id === undefined
|
|
94
|
+
? null
|
|
95
|
+
: {
|
|
96
|
+
id,
|
|
97
|
+
url: str(prod["url"]),
|
|
98
|
+
readyState: str(prod["readyState"]) ?? str(prod["state"]),
|
|
99
|
+
createdAt: prod["createdAt"],
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function registerVercelTools(
|
|
105
|
+
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
106
|
+
): void {
|
|
107
|
+
const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
|
|
108
|
+
const registerWriteTool = createWriteToolRegistrar(server, {
|
|
109
|
+
connector: "vercel",
|
|
110
|
+
scopeEnv: "NIMBUS_MCP_VERCEL_WRITE_SCOPE",
|
|
111
|
+
scopeKinds: ["project", "deployment"],
|
|
112
|
+
});
|
|
37
113
|
|
|
38
|
-
export function registerVercelTools(reg: ZodToolRegistrar): void {
|
|
39
114
|
reg(
|
|
40
115
|
"vercel_list",
|
|
41
116
|
"List recent Vercel deployments (`GET /v6/deployments`), most-recent-first, capped at `limit` (default 100). Returns the `{ deployments, pagination }` envelope.",
|
|
@@ -70,4 +145,83 @@ export function registerVercelTools(reg: ZodToolRegistrar): void {
|
|
|
70
145
|
return matchesResult(deployments, filterVercelDeployments, p);
|
|
71
146
|
},
|
|
72
147
|
);
|
|
148
|
+
|
|
149
|
+
reg(
|
|
150
|
+
"vercel_project_production_get",
|
|
151
|
+
"Read which deployment is the CURRENT production deployment of a Vercel project (`GET /v9/projects/{projectId}`, `targets.production`), projected to `{ projectId, name, production: { id, url, readyState, createdAt } | null }`. `production` is null when the project has none. This is what `vercel_deployment_promote` replaces.",
|
|
152
|
+
z.object({ projectId: idArg }),
|
|
153
|
+
async (p) => jsonResult(await readProductionState(p.projectId)),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
registerWriteTool(
|
|
157
|
+
"vercel_deployment_redeploy",
|
|
158
|
+
{
|
|
159
|
+
mutates: "vercel.deployment.redeploy",
|
|
160
|
+
// A production redeploy can supersede the deployment that is live, and nothing here puts it
|
|
161
|
+
// back. The source deployment is recorded in the audit pre-state.
|
|
162
|
+
recoverable: false,
|
|
163
|
+
capturePreState: async (p: { deploymentId: string }) => {
|
|
164
|
+
const { id, target, readyState } = await readDeployment(p.deploymentId);
|
|
165
|
+
return { id, target, readyState };
|
|
166
|
+
},
|
|
167
|
+
scopeTargetOf: (p: { deploymentId: string }) => ({
|
|
168
|
+
kind: "deployment",
|
|
169
|
+
value: p.deploymentId,
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
"Redeploy an existing Vercel deployment as a NEW build (`POST /v13/deployments?forceNew=1`, body `{ name, deploymentId, target, withLatestCommit? }`). `target` is required and always sent: `production` replaces the live site once the new build is ready, `preview` does not (omitting it would make a preview). The project `name` is read from the source deployment first, and an unknown deployment sends nothing. The reply is `accepted` with the NEW deployment's id: Vercel builds it afterwards, so poll `vercel_get` until `readyState` is READY. Not undone by this tool.",
|
|
173
|
+
z.object({
|
|
174
|
+
deploymentId: idArg,
|
|
175
|
+
target: z.enum(["production", "preview"]),
|
|
176
|
+
withLatestCommit: z.boolean().optional(),
|
|
177
|
+
}),
|
|
178
|
+
async (p) => {
|
|
179
|
+
const source = await readDeployment(p.deploymentId);
|
|
180
|
+
if (source.name === undefined) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Vercel deployment ${p.deploymentId} has no project name; nothing was sent`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const body: Obj = { name: source.name, deploymentId: p.deploymentId, target: p.target };
|
|
186
|
+
if (p.withLatestCommit !== undefined) {
|
|
187
|
+
body["withLatestCommit"] = p.withLatestCommit;
|
|
188
|
+
}
|
|
189
|
+
const reply = asObj(await sendJson("POST", withTeam("/v13/deployments?forceNew=1"), body));
|
|
190
|
+
return jsonResult({
|
|
191
|
+
ok: true,
|
|
192
|
+
status: "accepted",
|
|
193
|
+
deploymentId: str(reply["id"]) ?? str(reply["uid"]),
|
|
194
|
+
url: str(reply["url"]),
|
|
195
|
+
readyState: str(reply["readyState"]) ?? str(reply["state"]),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
registerWriteTool(
|
|
201
|
+
"vercel_deployment_promote",
|
|
202
|
+
{
|
|
203
|
+
mutates: "vercel.deployment.promote",
|
|
204
|
+
// The previous production deployment is recorded; promoting it back is a second promote.
|
|
205
|
+
recoverable: false,
|
|
206
|
+
capturePreState: (p: { projectId: string }) => readProductionState(p.projectId),
|
|
207
|
+
scopeTargetOf: (p: { projectId: string }) => ({ kind: "project", value: p.projectId }),
|
|
208
|
+
},
|
|
209
|
+
"Promote an existing Vercel deployment to production WITHOUT rebuilding it (`POST /v10/projects/{projectId}/promote/{deploymentId}`, no body). The deployment that is production now stops being current: read it first with `vercel_project_production_get`. The reply is `accepted`; Vercel applies the promotion afterwards.",
|
|
210
|
+
z.object({ projectId: idArg, deploymentId: idArg }),
|
|
211
|
+
async (p) => {
|
|
212
|
+
await sendJson(
|
|
213
|
+
"POST",
|
|
214
|
+
withTeam(
|
|
215
|
+
`/v10/projects/${encodeURIComponent(p.projectId)}/promote/${encodeURIComponent(p.deploymentId)}`,
|
|
216
|
+
),
|
|
217
|
+
undefined,
|
|
218
|
+
);
|
|
219
|
+
return jsonResult({
|
|
220
|
+
ok: true,
|
|
221
|
+
status: "accepted",
|
|
222
|
+
projectId: p.projectId,
|
|
223
|
+
deploymentId: p.deploymentId,
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
);
|
|
73
227
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Longest vendor string a read projection returns, in UTF-16 code units. */
|
|
2
|
+
export const MAX_VENDOR_TEXT = 500;
|
|
3
|
+
|
|
4
|
+
/** Appended to a string that was cut, so a reader can tell it is not the whole value. */
|
|
5
|
+
export const TRUNCATION_MARKER = "...[truncated]";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A vendor string capped at `max` characters, with an explicit marker when it was cut; `undefined`
|
|
9
|
+
* for anything that is not a non-empty string. Read projections use it so one unbounded field
|
|
10
|
+
* (an error message, a commit message) cannot flood the model's context.
|
|
11
|
+
*/
|
|
12
|
+
export function capText(value: unknown, max: number = MAX_VENDOR_TEXT): string | undefined {
|
|
13
|
+
if (typeof value !== "string" || value === "") return undefined;
|
|
14
|
+
return value.length <= max ? value : `${value.slice(0, max)}${TRUNCATION_MARKER}`;
|
|
15
|
+
}
|
package/shared/env-json-api.ts
CHANGED
|
@@ -222,17 +222,35 @@ export function createJsonSender(
|
|
|
222
222
|
return async (method, path, body, extraHeaders) => {
|
|
223
223
|
const text = await send(
|
|
224
224
|
path,
|
|
225
|
-
(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
body
|
|
229
|
-
|
|
225
|
+
// An `undefined` body is a bodiless request (Netlify's build trigger, Vercel's promote): no
|
|
226
|
+
// entity, so no Content-Type either, which a server may read as a body that must parse.
|
|
227
|
+
(headers) =>
|
|
228
|
+
body === undefined
|
|
229
|
+
? { method, headers: { ...headers, ...extraHeaders } }
|
|
230
|
+
: {
|
|
231
|
+
method,
|
|
232
|
+
headers: { ...headers, "Content-Type": "application/json", ...extraHeaders },
|
|
233
|
+
body: JSON.stringify(body),
|
|
234
|
+
},
|
|
230
235
|
errorPrefix(method, path),
|
|
231
236
|
);
|
|
232
|
-
return
|
|
237
|
+
return parsedOrEmpty(text);
|
|
233
238
|
};
|
|
234
239
|
}
|
|
235
240
|
|
|
241
|
+
/**
|
|
242
|
+
* A 2xx body as JSON, or `{}` when it is empty or not JSON. The write was applied either way, and a
|
|
243
|
+
* throw here would invite the caller to retry it (a duplicate build, a second invitation email).
|
|
244
|
+
*/
|
|
245
|
+
function parsedOrEmpty(text: string): unknown {
|
|
246
|
+
if (text === "") return {};
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(text) as unknown;
|
|
249
|
+
} catch {
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
236
254
|
/**
|
|
237
255
|
* The mutating twin of {@link createJsonGetter}: a `(path, body) => Promise<unknown>`
|
|
238
256
|
* POST client that sends `body` as JSON (adding `Content-Type: application/json` to
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* line-diff: a pure unified diff of two lists of lines (Myers' O(ND) shortest edit script).
|
|
3
|
+
*
|
|
4
|
+
* It exists so a tool can show an approver exactly what will change without starting a `diff`
|
|
5
|
+
* program: Windows ships none, and a program the environment names is not one to trust with the text
|
|
6
|
+
* an owner approves. Equal inputs give "". The output has the two header lines, then one hunk
|
|
7
|
+
* `@@ -a,b +c,d @@` per run of changes with `context` lines either side (hunks whose context touches
|
|
8
|
+
* are merged), every line ending with "\n". Counts are always written, `,1` included.
|
|
9
|
+
*
|
|
10
|
+
* Bounded: an edit script longer than {@link MAX_EDIT_DISTANCE} throws {@link LineDiffTooLargeError}
|
|
11
|
+
* (the caller refuses, it never cuts), so a pathological input cannot hold memory or the event loop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MAX_EDIT_DISTANCE = 2000;
|
|
15
|
+
|
|
16
|
+
/** `V[j]` from a trace window that holds `V[-d..d]` (index `j + d`); 0 outside it. */
|
|
17
|
+
function at(window: Int32Array, d: number, j: number): number {
|
|
18
|
+
return j < -d || j > d ? 0 : (window[j + d] ?? 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class LineDiffTooLargeError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("the two sides differ by too many lines to diff");
|
|
24
|
+
this.name = "LineDiffTooLargeError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Op = { readonly kind: " " | "-" | "+"; readonly text: string };
|
|
29
|
+
|
|
30
|
+
/** The edit script from `a` to `b`: the equal prefix and suffix are peeled off, Myers does the rest. */
|
|
31
|
+
function editScript(a: readonly string[], b: readonly string[]): Op[] {
|
|
32
|
+
let start = 0;
|
|
33
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
|
|
34
|
+
let endA = a.length;
|
|
35
|
+
let endB = b.length;
|
|
36
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
37
|
+
endA -= 1;
|
|
38
|
+
endB -= 1;
|
|
39
|
+
}
|
|
40
|
+
const head: Op[] = a.slice(0, start).map((text) => ({ kind: " ", text }));
|
|
41
|
+
const tail: Op[] = a.slice(endA).map((text) => ({ kind: " ", text }));
|
|
42
|
+
return [...head, ...myers(a.slice(start, endA), b.slice(start, endB)), ...tail];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function myers(a: readonly string[], b: readonly string[]): Op[] {
|
|
46
|
+
const n = a.length;
|
|
47
|
+
const m = b.length;
|
|
48
|
+
if (n === 0) return b.map((text) => ({ kind: "+", text }));
|
|
49
|
+
if (m === 0) return a.map((text) => ({ kind: "-", text }));
|
|
50
|
+
const max = Math.min(n + m, MAX_EDIT_DISTANCE);
|
|
51
|
+
const offset = max + 1;
|
|
52
|
+
const v = new Int32Array(2 * max + 3);
|
|
53
|
+
// trace[d] is V[-d..d] as it stood BEFORE step d (index k + d), all the walk back reads. Storing
|
|
54
|
+
// the whole V at every step would hold (max + 1) * (2 * max + 3) values; the window holds about
|
|
55
|
+
// max * max, 16 MB at the bound.
|
|
56
|
+
const trace: Int32Array[] = [];
|
|
57
|
+
let found = -1;
|
|
58
|
+
for (let d = 0; d <= max && found < 0; d += 1) {
|
|
59
|
+
trace.push(v.slice(offset - d, offset + d + 1));
|
|
60
|
+
for (let k = -d; k <= d; k += 2) {
|
|
61
|
+
const down = k === -d || (k !== d && (v[offset + k - 1] ?? 0) < (v[offset + k + 1] ?? 0));
|
|
62
|
+
let x = down ? (v[offset + k + 1] ?? 0) : (v[offset + k - 1] ?? 0) + 1;
|
|
63
|
+
let y = x - k;
|
|
64
|
+
while (x < n && y < m && a[x] === b[y]) {
|
|
65
|
+
x += 1;
|
|
66
|
+
y += 1;
|
|
67
|
+
}
|
|
68
|
+
v[offset + k] = x;
|
|
69
|
+
if (x >= n && y >= m) {
|
|
70
|
+
found = d;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (found < 0) throw new LineDiffTooLargeError();
|
|
76
|
+
const ops: Op[] = [];
|
|
77
|
+
let x = n;
|
|
78
|
+
let y = m;
|
|
79
|
+
for (let d = found; d > 0; d -= 1) {
|
|
80
|
+
const prev = trace[d];
|
|
81
|
+
if (prev === undefined) throw new LineDiffTooLargeError();
|
|
82
|
+
const k = x - y;
|
|
83
|
+
const down = k === -d || (k !== d && at(prev, d, k - 1) < at(prev, d, k + 1));
|
|
84
|
+
const prevK = down ? k + 1 : k - 1;
|
|
85
|
+
const prevX = at(prev, d, prevK);
|
|
86
|
+
const prevY = prevX - prevK;
|
|
87
|
+
while (x > prevX && y > prevY) {
|
|
88
|
+
x -= 1;
|
|
89
|
+
y -= 1;
|
|
90
|
+
ops.push({ kind: " ", text: a[x] ?? "" });
|
|
91
|
+
}
|
|
92
|
+
if (down) {
|
|
93
|
+
y -= 1;
|
|
94
|
+
ops.push({ kind: "+", text: b[y] ?? "" });
|
|
95
|
+
} else {
|
|
96
|
+
x -= 1;
|
|
97
|
+
ops.push({ kind: "-", text: a[x] ?? "" });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
while (x > 0 && y > 0) {
|
|
101
|
+
x -= 1;
|
|
102
|
+
y -= 1;
|
|
103
|
+
ops.push({ kind: " ", text: a[x] ?? "" });
|
|
104
|
+
}
|
|
105
|
+
return ops.reverse();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function unifiedLineDiff(
|
|
109
|
+
from: readonly string[],
|
|
110
|
+
to: readonly string[],
|
|
111
|
+
opts: {
|
|
112
|
+
readonly fromLabel: string;
|
|
113
|
+
readonly toLabel: string;
|
|
114
|
+
readonly context?: number | undefined;
|
|
115
|
+
},
|
|
116
|
+
): string {
|
|
117
|
+
const context = opts.context ?? 3;
|
|
118
|
+
const ops = editScript(from, to);
|
|
119
|
+
const changed: number[] = [];
|
|
120
|
+
ops.forEach((op, i) => {
|
|
121
|
+
if (op.kind !== " ") changed.push(i);
|
|
122
|
+
});
|
|
123
|
+
if (changed.length === 0) return "";
|
|
124
|
+
|
|
125
|
+
// Group the changes into hunks: [first, last] indexes into ops, widened by the context.
|
|
126
|
+
const ranges: [number, number][] = [];
|
|
127
|
+
for (const i of changed) {
|
|
128
|
+
const lo = Math.max(0, i - context);
|
|
129
|
+
const hi = Math.min(ops.length - 1, i + context);
|
|
130
|
+
const last = ranges[ranges.length - 1];
|
|
131
|
+
if (last !== undefined && lo <= last[1] + 1) last[1] = hi;
|
|
132
|
+
else ranges.push([lo, hi]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Lines of each side consumed before each op, so a hunk's start is a lookup.
|
|
136
|
+
const aBefore: number[] = [];
|
|
137
|
+
const bBefore: number[] = [];
|
|
138
|
+
let ai = 0;
|
|
139
|
+
let bi = 0;
|
|
140
|
+
for (const op of ops) {
|
|
141
|
+
aBefore.push(ai);
|
|
142
|
+
bBefore.push(bi);
|
|
143
|
+
if (op.kind !== "+") ai += 1;
|
|
144
|
+
if (op.kind !== "-") bi += 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const out = [`--- ${opts.fromLabel}`, `+++ ${opts.toLabel}`];
|
|
148
|
+
for (const [lo, hi] of ranges) {
|
|
149
|
+
const slice = ops.slice(lo, hi + 1);
|
|
150
|
+
const aCount = slice.filter((o) => o.kind !== "+").length;
|
|
151
|
+
const bCount = slice.filter((o) => o.kind !== "-").length;
|
|
152
|
+
const aStart = (aBefore[lo] ?? 0) + (aCount === 0 ? 0 : 1);
|
|
153
|
+
const bStart = (bBefore[lo] ?? 0) + (bCount === 0 ? 0 : 1);
|
|
154
|
+
out.push(`@@ -${String(aStart)},${String(aCount)} +${String(bStart)},${String(bCount)} @@`);
|
|
155
|
+
for (const op of slice) out.push(`${op.kind}${op.text}`);
|
|
156
|
+
}
|
|
157
|
+
return `${out.join("\n")}\n`;
|
|
158
|
+
}
|