pi-onedev-toolkit 0.1.0 → 0.2.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/CHANGELOG.md +29 -1
- package/README.md +23 -7
- package/package.json +9 -5
- package/skills/onedev-issue-to-pr/SKILL.md +9 -4
- package/skills/onedev-pr-review/SKILL.md +12 -6
- package/src/config-storage.ts +105 -0
- package/src/context.ts +204 -38
- package/src/extension.ts +98 -8
- package/src/mutation-approvals.ts +130 -0
- package/src/source-watch.ts +521 -0
- package/src/tod.ts +33 -3
- package/src/tools/build.ts +143 -21
- package/src/tools/common.ts +27 -12
- package/src/tools/index.ts +91 -63
- package/src/tools/issue.ts +50 -26
- package/src/tools/pull.ts +116 -67
- package/src/tools/watch.ts +70 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { OneDevSessionContext } from "./context.js";
|
|
3
|
+
import { runTod, TodError, type CommandExecutor } from "./tod.js";
|
|
4
|
+
|
|
5
|
+
export type OneDevWatchKind = "build" | "pull_attention";
|
|
6
|
+
|
|
7
|
+
export interface OneDevWatchEmission {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: OneDevWatchKind;
|
|
10
|
+
ref: string;
|
|
11
|
+
message: string;
|
|
12
|
+
details: Readonly<Record<string, string | number | boolean | null>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OneDevWatchInfo {
|
|
16
|
+
id: string;
|
|
17
|
+
kind: OneDevWatchKind;
|
|
18
|
+
ref: string;
|
|
19
|
+
intervalSeconds: number;
|
|
20
|
+
expiresAt: string;
|
|
21
|
+
failures: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AddOneDevWatchOptions {
|
|
25
|
+
kind: OneDevWatchKind;
|
|
26
|
+
ref: string;
|
|
27
|
+
intervalSeconds?: number;
|
|
28
|
+
ttlMinutes?: number;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AddOneDevWatchResult {
|
|
33
|
+
watch?: OneDevWatchInfo;
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BuildSnapshot {
|
|
38
|
+
kind: "build";
|
|
39
|
+
fingerprint: string;
|
|
40
|
+
status?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PullSnapshot {
|
|
44
|
+
kind: "pull_attention";
|
|
45
|
+
fingerprint: string;
|
|
46
|
+
state?: string;
|
|
47
|
+
head?: string;
|
|
48
|
+
mergeable?: boolean;
|
|
49
|
+
conflicted?: boolean;
|
|
50
|
+
unresolvedComments: number;
|
|
51
|
+
reviewSignals: number;
|
|
52
|
+
buildSummary: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type WatchSnapshot = BuildSnapshot | PullSnapshot;
|
|
56
|
+
|
|
57
|
+
interface WatchRecord {
|
|
58
|
+
id: string;
|
|
59
|
+
kind: OneDevWatchKind;
|
|
60
|
+
ref: string;
|
|
61
|
+
intervalMs: number;
|
|
62
|
+
expiresAt: number;
|
|
63
|
+
nextPollAt: number;
|
|
64
|
+
failures: number;
|
|
65
|
+
baseline: WatchSnapshot;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface WatchDeps {
|
|
69
|
+
exec: CommandExecutor;
|
|
70
|
+
context: () => OneDevSessionContext;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DEFAULT_INTERVAL_SECONDS = 30;
|
|
74
|
+
const DEFAULT_TTL_MINUTES = 60;
|
|
75
|
+
const MIN_INTERVAL_SECONDS = 15;
|
|
76
|
+
const MAX_INTERVAL_SECONDS = 300;
|
|
77
|
+
const MAX_TTL_MINUTES = 24 * 60;
|
|
78
|
+
const MAX_WATCHES = 10;
|
|
79
|
+
const MAX_BACKOFF_MS = 5 * 60_000;
|
|
80
|
+
const TERMINAL_BUILD_STATUSES = new Set([
|
|
81
|
+
"SUCCESSFUL",
|
|
82
|
+
"FAILED",
|
|
83
|
+
"CANCELLED",
|
|
84
|
+
"TIMED_OUT",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
type JsonPrimitive = string | number | boolean | null;
|
|
88
|
+
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
|
89
|
+
|
|
90
|
+
function isJsonValue(value: unknown): value is JsonValue {
|
|
91
|
+
if (
|
|
92
|
+
value === null ||
|
|
93
|
+
typeof value === "string" ||
|
|
94
|
+
typeof value === "number" ||
|
|
95
|
+
typeof value === "boolean"
|
|
96
|
+
) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
100
|
+
if (typeof value !== "object") return false;
|
|
101
|
+
return Object.values(value).every(isJsonValue);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function json(text: string, source: string): JsonValue {
|
|
105
|
+
try {
|
|
106
|
+
const parsed: unknown = JSON.parse(text);
|
|
107
|
+
if (!isJsonValue(parsed)) throw new Error("response is not a JSON value");
|
|
108
|
+
return parsed;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new TodError(
|
|
111
|
+
`tod returned invalid JSON for ${source}: ${error instanceof Error ? error.message : String(error)}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stable(value: JsonValue): string {
|
|
117
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
118
|
+
if (typeof value === "object" && value !== null) {
|
|
119
|
+
return `{${Object.entries(value)
|
|
120
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
121
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${stable(child)}`)
|
|
122
|
+
.join(",")}}`;
|
|
123
|
+
}
|
|
124
|
+
return JSON.stringify(value) ?? "null";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function visit(
|
|
128
|
+
value: JsonValue,
|
|
129
|
+
callback: (key: string, value: JsonValue) => void,
|
|
130
|
+
): void {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
for (const item of value) visit(item, callback);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (typeof value !== "object" || value === null) return;
|
|
136
|
+
for (const [key, child] of Object.entries(value)) {
|
|
137
|
+
callback(key, child);
|
|
138
|
+
visit(child, callback);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function firstString(value: JsonValue, keys: readonly string[]): string | undefined {
|
|
143
|
+
let found: string | undefined;
|
|
144
|
+
visit(value, (key, child) => {
|
|
145
|
+
if (
|
|
146
|
+
found === undefined &&
|
|
147
|
+
keys.includes(key) &&
|
|
148
|
+
(typeof child === "string" || typeof child === "number")
|
|
149
|
+
) {
|
|
150
|
+
found = String(child);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return found;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function firstBoolean(value: JsonValue, keys: readonly string[]): boolean | undefined {
|
|
157
|
+
let found: boolean | undefined;
|
|
158
|
+
visit(value, (key, child) => {
|
|
159
|
+
if (found === undefined && keys.includes(key) && typeof child === "boolean") {
|
|
160
|
+
found = child;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function unresolvedCodeComments(
|
|
167
|
+
value: JsonValue,
|
|
168
|
+
): Array<{ id: string | number; replies: number }> {
|
|
169
|
+
const comments = Array.isArray(value) ? value : [];
|
|
170
|
+
const unresolved: Array<{ id: string | number; replies: number }> = [];
|
|
171
|
+
for (let index = 0; index < comments.length; index += 1) {
|
|
172
|
+
const comment = comments[index];
|
|
173
|
+
if (Array.isArray(comment) || typeof comment !== "object" || comment === null)
|
|
174
|
+
continue;
|
|
175
|
+
const record = comment;
|
|
176
|
+
if (record.resolved === false) {
|
|
177
|
+
const id = record.id;
|
|
178
|
+
unresolved.push({
|
|
179
|
+
id: typeof id === "string" || typeof id === "number" ? id : index,
|
|
180
|
+
replies: Array.isArray(record.replies) ? record.replies.length : 0,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return unresolved;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildSignals(
|
|
188
|
+
value: JsonValue,
|
|
189
|
+
): Array<{ id: string; status: string }> {
|
|
190
|
+
const builds = Array.isArray(value) ? value : [];
|
|
191
|
+
return builds
|
|
192
|
+
.map((build, index) => {
|
|
193
|
+
if (Array.isArray(build) || typeof build !== "object" || build === null)
|
|
194
|
+
return undefined;
|
|
195
|
+
const record = build;
|
|
196
|
+
const rawStatus = record.status;
|
|
197
|
+
if (typeof rawStatus !== "string") return undefined;
|
|
198
|
+
const rawId = record.number ?? record.id ?? index;
|
|
199
|
+
return { id: String(rawId), status: rawStatus.toUpperCase() };
|
|
200
|
+
})
|
|
201
|
+
.filter((build): build is { id: string; status: string } => build !== undefined)
|
|
202
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function valuesAtKeys(value: JsonValue, keys: readonly string[]): JsonValue[] {
|
|
206
|
+
const values: JsonValue[] = [];
|
|
207
|
+
visit(value, (key, child) => {
|
|
208
|
+
if (keys.includes(key)) values.push(child);
|
|
209
|
+
});
|
|
210
|
+
return values;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function terminalBuild(status: string | undefined): boolean {
|
|
214
|
+
return status !== undefined && TERMINAL_BUILD_STATUSES.has(status.toUpperCase());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function assertRange(
|
|
218
|
+
value: number,
|
|
219
|
+
minimum: number,
|
|
220
|
+
maximum: number,
|
|
221
|
+
name: string,
|
|
222
|
+
): number {
|
|
223
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
|
224
|
+
throw new TodError(`${name} must be between ${minimum} and ${maximum}`);
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export class OneDevSourceWatchManager {
|
|
230
|
+
readonly #records = new Map<string, WatchRecord>();
|
|
231
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
232
|
+
#polling = false;
|
|
233
|
+
#generation = 0;
|
|
234
|
+
|
|
235
|
+
constructor(
|
|
236
|
+
readonly deps: WatchDeps,
|
|
237
|
+
readonly emit: (emission: OneDevWatchEmission) => void,
|
|
238
|
+
readonly now: () => number = Date.now,
|
|
239
|
+
) {}
|
|
240
|
+
|
|
241
|
+
list(): OneDevWatchInfo[] {
|
|
242
|
+
return [...this.#records.values()]
|
|
243
|
+
.sort((left, right) => left.expiresAt - right.expiresAt)
|
|
244
|
+
.map((record) => this.#info(record));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
stop(id: string): boolean {
|
|
248
|
+
const stopped = this.#records.delete(id);
|
|
249
|
+
if (stopped) this.#schedule();
|
|
250
|
+
return stopped;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
clear(): void {
|
|
254
|
+
this.#generation += 1;
|
|
255
|
+
this.#records.clear();
|
|
256
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
257
|
+
this.#timer = undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async add(options: AddOneDevWatchOptions): Promise<AddOneDevWatchResult> {
|
|
261
|
+
const ref = options.ref.trim();
|
|
262
|
+
if (!ref) throw new TodError("watch ref must not be empty");
|
|
263
|
+
const existing = this.#duplicate(options.kind, ref);
|
|
264
|
+
if (existing) return this.#duplicateResult(existing);
|
|
265
|
+
if (this.#records.size >= MAX_WATCHES) {
|
|
266
|
+
throw new TodError(`at most ${MAX_WATCHES} OneDev watches may be active`);
|
|
267
|
+
}
|
|
268
|
+
const intervalSeconds = assertRange(
|
|
269
|
+
options.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
|
|
270
|
+
MIN_INTERVAL_SECONDS,
|
|
271
|
+
MAX_INTERVAL_SECONDS,
|
|
272
|
+
"interval_seconds",
|
|
273
|
+
);
|
|
274
|
+
const ttlMinutes = assertRange(
|
|
275
|
+
options.ttlMinutes ?? DEFAULT_TTL_MINUTES,
|
|
276
|
+
1,
|
|
277
|
+
MAX_TTL_MINUTES,
|
|
278
|
+
"ttl_minutes",
|
|
279
|
+
);
|
|
280
|
+
const generation = this.#generation;
|
|
281
|
+
const baseline = await this.#snapshot(options.kind, ref, options.signal);
|
|
282
|
+
if (generation !== this.#generation) {
|
|
283
|
+
throw new TodError("watch cancelled because the OneDev session changed");
|
|
284
|
+
}
|
|
285
|
+
if (baseline.kind === "build" && terminalBuild(baseline.status)) {
|
|
286
|
+
return {
|
|
287
|
+
message: `Build ${ref} is already ${baseline.status}; no watch was added.`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const duplicate = this.#duplicate(options.kind, ref);
|
|
292
|
+
if (duplicate) {
|
|
293
|
+
return this.#duplicateResult(duplicate);
|
|
294
|
+
}
|
|
295
|
+
if (this.#records.size >= MAX_WATCHES) {
|
|
296
|
+
throw new TodError(`at most ${MAX_WATCHES} OneDev watches may be active`);
|
|
297
|
+
}
|
|
298
|
+
const now = this.now();
|
|
299
|
+
const record: WatchRecord = {
|
|
300
|
+
id: randomUUID().slice(0, 8),
|
|
301
|
+
kind: options.kind,
|
|
302
|
+
ref,
|
|
303
|
+
intervalMs: intervalSeconds * 1_000,
|
|
304
|
+
expiresAt: now + ttlMinutes * 60_000,
|
|
305
|
+
nextPollAt: now + intervalSeconds * 1_000,
|
|
306
|
+
failures: 0,
|
|
307
|
+
baseline,
|
|
308
|
+
};
|
|
309
|
+
this.#records.set(record.id, record);
|
|
310
|
+
this.#schedule();
|
|
311
|
+
return {
|
|
312
|
+
watch: this.#info(record),
|
|
313
|
+
message: `Watch ${record.id} will notify once when ${options.kind} ${ref} needs attention.`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#duplicate(kind: OneDevWatchKind, ref: string): WatchRecord | undefined {
|
|
318
|
+
return [...this.#records.values()].find(
|
|
319
|
+
(record) => record.kind === kind && record.ref === ref,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#duplicateResult(record: WatchRecord): AddOneDevWatchResult {
|
|
324
|
+
return {
|
|
325
|
+
watch: this.#info(record),
|
|
326
|
+
message: `Watch ${record.id} already monitors ${record.kind} ${record.ref}.`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#info(record: WatchRecord): OneDevWatchInfo {
|
|
331
|
+
return {
|
|
332
|
+
id: record.id,
|
|
333
|
+
kind: record.kind,
|
|
334
|
+
ref: record.ref,
|
|
335
|
+
intervalSeconds: record.intervalMs / 1_000,
|
|
336
|
+
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
337
|
+
failures: record.failures,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async #snapshot(
|
|
342
|
+
kind: OneDevWatchKind,
|
|
343
|
+
ref: string,
|
|
344
|
+
signal?: AbortSignal,
|
|
345
|
+
): Promise<WatchSnapshot> {
|
|
346
|
+
const context = this.deps.context();
|
|
347
|
+
if (context.status !== "ready" || !context.project) {
|
|
348
|
+
throw new TodError(
|
|
349
|
+
context.problem ?? "OneDev watch requires a ready project context",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (kind === "build") {
|
|
353
|
+
const output = await runTod(this.deps.exec, ["build", "get", ref], {
|
|
354
|
+
cwd: context.cwd,
|
|
355
|
+
signal,
|
|
356
|
+
maxOutputBytes: 512_000,
|
|
357
|
+
});
|
|
358
|
+
const value = json(output.text, `build ${ref}`);
|
|
359
|
+
const status = firstString(value, ["status"])?.toUpperCase();
|
|
360
|
+
return {
|
|
361
|
+
kind,
|
|
362
|
+
status,
|
|
363
|
+
fingerprint: status ?? stable(value),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const [pull, comments, builds] = await Promise.all([
|
|
368
|
+
runTod(this.deps.exec, ["pr", "get", ref], {
|
|
369
|
+
cwd: context.cwd,
|
|
370
|
+
signal,
|
|
371
|
+
maxOutputBytes: 512_000,
|
|
372
|
+
}),
|
|
373
|
+
runTod(this.deps.exec, ["pr", "get-code-comments", ref], {
|
|
374
|
+
cwd: context.cwd,
|
|
375
|
+
signal,
|
|
376
|
+
maxOutputBytes: 512_000,
|
|
377
|
+
}),
|
|
378
|
+
runTod(this.deps.exec, ["pr", "get-builds", ref], {
|
|
379
|
+
cwd: context.cwd,
|
|
380
|
+
signal,
|
|
381
|
+
maxOutputBytes: 512_000,
|
|
382
|
+
}),
|
|
383
|
+
]);
|
|
384
|
+
const pullValue = json(pull.text, `pull request ${ref}`);
|
|
385
|
+
const unresolved = unresolvedCodeComments(
|
|
386
|
+
json(comments.text, `pull request ${ref} code comments`),
|
|
387
|
+
);
|
|
388
|
+
const buildsValue = json(builds.text, `pull request ${ref} builds`);
|
|
389
|
+
const buildSignal = buildSignals(
|
|
390
|
+
buildsValue,
|
|
391
|
+
);
|
|
392
|
+
const reviews = valuesAtKeys(pullValue, [
|
|
393
|
+
"reviewStatus",
|
|
394
|
+
"reviewDecision",
|
|
395
|
+
"pendingReviewers",
|
|
396
|
+
"reviewers",
|
|
397
|
+
"requestedChanges",
|
|
398
|
+
]);
|
|
399
|
+
const selected = {
|
|
400
|
+
state: firstString(pullValue, ["status", "state"]) ?? null,
|
|
401
|
+
head:
|
|
402
|
+
firstString(pullValue, [
|
|
403
|
+
"headCommitHash",
|
|
404
|
+
"sourceCommitHash",
|
|
405
|
+
"headCommit",
|
|
406
|
+
"sourceCommit",
|
|
407
|
+
]) ?? null,
|
|
408
|
+
mergeable: firstBoolean(pullValue, ["mergeable"]) ?? null,
|
|
409
|
+
conflicted:
|
|
410
|
+
firstBoolean(pullValue, ["hasConflicts", "conflicted"]) ?? null,
|
|
411
|
+
unresolved,
|
|
412
|
+
reviews,
|
|
413
|
+
buildSignal,
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
kind,
|
|
417
|
+
fingerprint: stable(selected),
|
|
418
|
+
state: selected.state ?? undefined,
|
|
419
|
+
head: selected.head ?? undefined,
|
|
420
|
+
mergeable: selected.mergeable ?? undefined,
|
|
421
|
+
conflicted: selected.conflicted ?? undefined,
|
|
422
|
+
unresolvedComments: unresolved.length,
|
|
423
|
+
reviewSignals: reviews.length,
|
|
424
|
+
buildSummary:
|
|
425
|
+
buildSignal.map((build) => `${build.id}:${build.status}`).join(", ") ||
|
|
426
|
+
"none",
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#changed(record: WatchRecord, snapshot: WatchSnapshot): boolean {
|
|
431
|
+
if (record.kind === "build" && snapshot.kind === "build") {
|
|
432
|
+
return (
|
|
433
|
+
terminalBuild(snapshot.status) &&
|
|
434
|
+
snapshot.fingerprint !== record.baseline.fingerprint
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return snapshot.fingerprint !== record.baseline.fingerprint;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#emission(record: WatchRecord, snapshot: WatchSnapshot): OneDevWatchEmission {
|
|
441
|
+
if (snapshot.kind === "build") {
|
|
442
|
+
return {
|
|
443
|
+
id: record.id,
|
|
444
|
+
kind: record.kind,
|
|
445
|
+
ref: record.ref,
|
|
446
|
+
message: `OneDev build ${record.ref} is ${snapshot.status ?? "updated"}. Inspect it with onedev_build get/get_log.`,
|
|
447
|
+
details: { status: snapshot.status ?? null },
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
id: record.id,
|
|
452
|
+
kind: record.kind,
|
|
453
|
+
ref: record.ref,
|
|
454
|
+
message: `OneDev pull request ${record.ref} needs attention. Inspect it with onedev_pull get/get_code_comments/get_builds.`,
|
|
455
|
+
details: {
|
|
456
|
+
state: snapshot.state ?? null,
|
|
457
|
+
head: snapshot.head?.slice(0, 12) ?? null,
|
|
458
|
+
mergeable: snapshot.mergeable ?? null,
|
|
459
|
+
conflicted: snapshot.conflicted ?? null,
|
|
460
|
+
unresolvedComments: snapshot.unresolvedComments,
|
|
461
|
+
reviewSignals: snapshot.reviewSignals,
|
|
462
|
+
builds: snapshot.buildSummary.slice(0, 200),
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#schedule(): void {
|
|
468
|
+
if (this.#timer) clearTimeout(this.#timer);
|
|
469
|
+
this.#timer = undefined;
|
|
470
|
+
if (this.#records.size === 0 || this.#polling) return;
|
|
471
|
+
const next = Math.min(
|
|
472
|
+
...[...this.#records.values()].map((record) =>
|
|
473
|
+
Math.min(record.nextPollAt, record.expiresAt),
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
this.#timer = setTimeout(() => {
|
|
477
|
+
this.#timer = undefined;
|
|
478
|
+
void this.#tick();
|
|
479
|
+
}, Math.max(0, next - this.now()));
|
|
480
|
+
this.#timer.unref?.();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async #tick(): Promise<void> {
|
|
484
|
+
if (this.#polling) return;
|
|
485
|
+
this.#polling = true;
|
|
486
|
+
const generation = this.#generation;
|
|
487
|
+
try {
|
|
488
|
+
const now = this.now();
|
|
489
|
+
const due = [...this.#records.values()].filter(
|
|
490
|
+
(record) => record.nextPollAt <= now || record.expiresAt <= now,
|
|
491
|
+
);
|
|
492
|
+
for (const record of due) {
|
|
493
|
+
if (generation !== this.#generation || !this.#records.has(record.id)) break;
|
|
494
|
+
try {
|
|
495
|
+
const snapshot = await this.#snapshot(record.kind, record.ref);
|
|
496
|
+
if (generation !== this.#generation || !this.#records.has(record.id)) break;
|
|
497
|
+
if (this.#changed(record, snapshot)) {
|
|
498
|
+
this.#records.delete(record.id);
|
|
499
|
+
this.emit(this.#emission(record, snapshot));
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
record.failures = 0;
|
|
503
|
+
record.baseline = snapshot;
|
|
504
|
+
record.nextPollAt = this.now() + record.intervalMs;
|
|
505
|
+
} catch {
|
|
506
|
+
record.failures += 1;
|
|
507
|
+
record.nextPollAt =
|
|
508
|
+
this.now() +
|
|
509
|
+
Math.min(
|
|
510
|
+
record.intervalMs * 2 ** Math.min(record.failures, 6),
|
|
511
|
+
MAX_BACKOFF_MS,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (record.expiresAt <= now) this.#records.delete(record.id);
|
|
515
|
+
}
|
|
516
|
+
} finally {
|
|
517
|
+
this.#polling = false;
|
|
518
|
+
if (generation === this.#generation) this.#schedule();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
package/src/tod.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface TodRunOptions {
|
|
|
31
31
|
timeoutMs?: number;
|
|
32
32
|
signal?: AbortSignal;
|
|
33
33
|
maxOutputBytes?: number;
|
|
34
|
+
/** Environment overrides passed through `env`; used for an isolated git index. */
|
|
35
|
+
environment?: Readonly<Record<string, string>>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export interface TodOutput {
|
|
@@ -61,11 +63,16 @@ export function truncateMiddle(text: string, maxBytes: number): TodOutput {
|
|
|
61
63
|
const head = Math.floor(maxBytes * 0.6);
|
|
62
64
|
const tailBytes = maxBytes - head;
|
|
63
65
|
const omitted = originalBytes - maxBytes;
|
|
66
|
+
let headEnd = head;
|
|
67
|
+
while (headEnd > 0 && (buffer[headEnd] ?? 0) >> 6 === 2) headEnd -= 1;
|
|
68
|
+
let tailStart = originalBytes - tailBytes;
|
|
69
|
+
while (tailStart < originalBytes && (buffer[tailStart] ?? 0) >> 6 === 2)
|
|
70
|
+
tailStart += 1;
|
|
64
71
|
return {
|
|
65
72
|
text:
|
|
66
|
-
buffer.subarray(0,
|
|
73
|
+
buffer.subarray(0, headEnd).toString("utf8") +
|
|
67
74
|
`\n[… truncated ${omitted} bytes …]\n` +
|
|
68
|
-
buffer.subarray(
|
|
75
|
+
buffer.subarray(tailStart).toString("utf8"),
|
|
69
76
|
truncated: true,
|
|
70
77
|
originalBytes,
|
|
71
78
|
};
|
|
@@ -78,7 +85,22 @@ export async function runTod(
|
|
|
78
85
|
): Promise<TodOutput> {
|
|
79
86
|
let result: ExecResult;
|
|
80
87
|
try {
|
|
81
|
-
|
|
88
|
+
const environment = Object.entries(options.environment ?? {});
|
|
89
|
+
for (const [name] of environment) {
|
|
90
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
91
|
+
throw new Error(`invalid environment variable name '${name}'`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const command = environment.length > 0 ? "env" : "tod";
|
|
95
|
+
const commandArgs =
|
|
96
|
+
environment.length > 0
|
|
97
|
+
? [
|
|
98
|
+
...environment.map(([name, value]) => `${name}=${value}`),
|
|
99
|
+
"tod",
|
|
100
|
+
...args,
|
|
101
|
+
]
|
|
102
|
+
: [...args];
|
|
103
|
+
result = await exec(command, commandArgs, {
|
|
82
104
|
cwd: options.cwd,
|
|
83
105
|
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
84
106
|
signal: options.signal,
|
|
@@ -89,6 +111,14 @@ export async function runTod(
|
|
|
89
111
|
{ cause },
|
|
90
112
|
);
|
|
91
113
|
}
|
|
114
|
+
if (result.killed) {
|
|
115
|
+
const scope = args.slice(0, 2).join(" ");
|
|
116
|
+
throw new TodError(
|
|
117
|
+
options.signal?.aborted
|
|
118
|
+
? `tod ${scope} aborted by the caller`
|
|
119
|
+
: `tod ${scope} timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
92
122
|
if (result.code !== 0) {
|
|
93
123
|
const scope = args.slice(0, 2).join(" ");
|
|
94
124
|
const detail = tail(result.stderr || result.stdout);
|