@letta-ai/letta-code 0.30.28 → 0.30.29
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/dist/mcp-client.js +2 -2
- package/dist/mcp-client.js.map +1 -1
- package/dist/types/agent/client-skills.d.ts +3 -0
- package/dist/types/agent/client-skills.d.ts.map +1 -1
- package/dist/types/agent/memory-git.d.ts +2 -0
- package/dist/types/agent/memory-git.d.ts.map +1 -1
- package/dist/types/agent/shared-memory-skills.d.ts +18 -0
- package/dist/types/agent/shared-memory-skills.d.ts.map +1 -0
- package/dist/types/backend/dev/pi-model-factory.d.ts +6 -0
- package/dist/types/backend/dev/pi-model-factory.d.ts.map +1 -1
- package/dist/types/backend/dev/pi-provider-registry.d.ts.map +1 -1
- package/dist/types/backend/local/local-provider-auth-store.d.ts.map +1 -1
- package/dist/types/tools/impl/skill.d.ts +10 -5
- package/dist/types/tools/impl/skill.d.ts.map +1 -1
- package/image-resize-worker.js +42 -20
- package/letta.js +87528 -141365
- package/package.json +4 -3
- package/scripts/codex-watch/agent-watch.ts +63 -8
- package/scripts/codex-watch/release-analysis.test.ts +57 -0
- package/scripts/codex-watch/release-analysis.ts +31 -1
- package/scripts/codex-watch/tracker.test.ts +243 -6
- package/scripts/codex-watch/tracker.ts +221 -20
- package/scripts/pi-ai-watch/agent-watch.ts +253 -0
- package/scripts/pi-ai-watch/github.ts +145 -0
- package/scripts/pi-ai-watch/release-analysis.test.ts +137 -0
- package/scripts/pi-ai-watch/release-analysis.ts +371 -0
- package/scripts/pi-ai-watch/tracker.test.ts +138 -0
- package/scripts/pi-ai-watch/tracker.ts +303 -0
- package/scripts/pi-ai-watch/update-tracker.ts +215 -0
|
@@ -26,6 +26,8 @@ export interface TrackerEntry {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface TrackerState {
|
|
29
|
+
audit_cursor_tag: string | null;
|
|
30
|
+
audit_cursor_validated: boolean;
|
|
29
31
|
last_checked_tag: string | null;
|
|
30
32
|
last_checked_at: string | null;
|
|
31
33
|
processed: TrackerEntry[];
|
|
@@ -41,6 +43,8 @@ export interface RecordAnalysisOptions {
|
|
|
41
43
|
|
|
42
44
|
export function emptyTrackerState(): TrackerState {
|
|
43
45
|
return {
|
|
46
|
+
audit_cursor_tag: null,
|
|
47
|
+
audit_cursor_validated: true,
|
|
44
48
|
last_checked_tag: null,
|
|
45
49
|
last_checked_at: null,
|
|
46
50
|
processed: [],
|
|
@@ -49,21 +53,81 @@ export function emptyTrackerState(): TrackerState {
|
|
|
49
53
|
|
|
50
54
|
export function parseTrackerState(body: string): TrackerState {
|
|
51
55
|
const start = body.indexOf(STATE_START);
|
|
52
|
-
if (start === -1)
|
|
56
|
+
if (start === -1) throw new Error("Codex tracker hidden state is missing");
|
|
53
57
|
|
|
54
58
|
const jsonStart = start + STATE_START.length;
|
|
55
59
|
const end = body.indexOf(STATE_END, jsonStart);
|
|
56
|
-
if (end === -1)
|
|
60
|
+
if (end === -1) throw new Error("Codex tracker hidden state is incomplete");
|
|
57
61
|
|
|
58
62
|
try {
|
|
59
63
|
return normalizeState(JSON.parse(body.slice(jsonStart, end).trim()));
|
|
60
|
-
} catch {
|
|
61
|
-
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error("Codex tracker hidden state is invalid", { cause: error });
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
export function
|
|
66
|
-
return
|
|
69
|
+
export function isTerminalOutcome(outcome: TrackerOutcome): boolean {
|
|
70
|
+
return outcome !== "error";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hasProcessedRange(
|
|
74
|
+
state: TrackerState,
|
|
75
|
+
previousTag: string,
|
|
76
|
+
currentTag: string,
|
|
77
|
+
): boolean {
|
|
78
|
+
return state.processed.some(
|
|
79
|
+
(entry) =>
|
|
80
|
+
entry.previous_tag === previousTag &&
|
|
81
|
+
entry.tag === currentTag &&
|
|
82
|
+
isTerminalOutcome(entry.outcome),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getCodexAuditCursorTag(state: TrackerState): string | null {
|
|
87
|
+
return state.audit_cursor_tag;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function validateLegacyCodexAuditCursor(
|
|
91
|
+
state: TrackerState,
|
|
92
|
+
stableTags: string[],
|
|
93
|
+
): TrackerState {
|
|
94
|
+
if (state.audit_cursor_validated) return state;
|
|
95
|
+
|
|
96
|
+
let tag = state.audit_cursor_tag;
|
|
97
|
+
while (tag !== null) {
|
|
98
|
+
const entry = state.processed.find(
|
|
99
|
+
(candidate) =>
|
|
100
|
+
candidate.tag === tag && isTerminalOutcome(candidate.outcome),
|
|
101
|
+
);
|
|
102
|
+
if (!entry) break;
|
|
103
|
+
const currentIndex = stableTags.indexOf(entry.tag);
|
|
104
|
+
if (
|
|
105
|
+
currentIndex < 1 ||
|
|
106
|
+
stableTags[currentIndex - 1] !== entry.previous_tag
|
|
107
|
+
) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Legacy Codex tracker range is not adjacent: ${entry.previous_tag}...${entry.tag}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
tag = entry.previous_tag;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ...state, audit_cursor_validated: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function advanceCodexAuditCursor(
|
|
119
|
+
state: TrackerState,
|
|
120
|
+
previousTag: string,
|
|
121
|
+
currentTag: string,
|
|
122
|
+
isAdjacentRelease: boolean,
|
|
123
|
+
): TrackerState {
|
|
124
|
+
if (!isAdjacentRelease || state.audit_cursor_tag !== previousTag) return state;
|
|
125
|
+
if (!hasProcessedRange(state, previousTag, currentTag)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Cannot advance Codex audit cursor without terminal ${previousTag}...${currentTag}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { ...state, audit_cursor_tag: currentTag };
|
|
67
131
|
}
|
|
68
132
|
|
|
69
133
|
export function recordAnalysis(
|
|
@@ -81,19 +145,51 @@ export function recordAnalysis(
|
|
|
81
145
|
processed_at: processedAt,
|
|
82
146
|
compare_url: options.analysis.compare_url,
|
|
83
147
|
workflow_run_url: options.analysis.workflow_run_url,
|
|
84
|
-
});
|
|
148
|
+
}, options.analysis.is_adjacent_release);
|
|
85
149
|
}
|
|
86
150
|
|
|
87
151
|
export function upsertTrackerEntry(
|
|
88
152
|
state: TrackerState,
|
|
89
153
|
entry: TrackerEntry,
|
|
154
|
+
advanceAuditCursor = true,
|
|
90
155
|
): TrackerState {
|
|
91
|
-
|
|
156
|
+
let auditCursorTag = state.audit_cursor_tag;
|
|
157
|
+
if (auditCursorTag === null && !advanceAuditCursor) {
|
|
158
|
+
auditCursorTag = entry.previous_tag;
|
|
159
|
+
} else if (
|
|
160
|
+
advanceAuditCursor &&
|
|
161
|
+
entry.outcome === "error" &&
|
|
162
|
+
auditCursorTag === null
|
|
163
|
+
) {
|
|
164
|
+
auditCursorTag = entry.previous_tag;
|
|
165
|
+
} else if (
|
|
166
|
+
advanceAuditCursor &&
|
|
167
|
+
isTerminalOutcome(entry.outcome) &&
|
|
168
|
+
(auditCursorTag === null || entry.previous_tag === auditCursorTag)
|
|
169
|
+
) {
|
|
170
|
+
auditCursorTag = entry.tag;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const candidates = [
|
|
92
174
|
entry,
|
|
93
|
-
...state.processed.filter(
|
|
94
|
-
|
|
175
|
+
...state.processed.filter(
|
|
176
|
+
(existing) =>
|
|
177
|
+
existing.tag !== entry.tag ||
|
|
178
|
+
existing.previous_tag !== entry.previous_tag,
|
|
179
|
+
),
|
|
180
|
+
];
|
|
181
|
+
let processed = candidates.slice(0, HIDDEN_STATE_LIMIT);
|
|
182
|
+
if (auditCursorTag !== null && !isSupportedAuditCursor(processed, auditCursorTag)) {
|
|
183
|
+
const support = candidates.find((candidate) =>
|
|
184
|
+
isAuditCursorSupportEntry(candidate, auditCursorTag),
|
|
185
|
+
);
|
|
186
|
+
if (!support) throw new Error("Codex audit cursor has no supporting entry");
|
|
187
|
+
processed = [...processed.slice(0, HIDDEN_STATE_LIMIT - 1), support];
|
|
188
|
+
}
|
|
95
189
|
|
|
96
190
|
return {
|
|
191
|
+
audit_cursor_tag: auditCursorTag,
|
|
192
|
+
audit_cursor_validated: state.audit_cursor_validated,
|
|
97
193
|
last_checked_tag: entry.tag,
|
|
98
194
|
last_checked_at: entry.processed_at,
|
|
99
195
|
processed,
|
|
@@ -179,28 +275,129 @@ function escapeTable(value: string): string {
|
|
|
179
275
|
}
|
|
180
276
|
|
|
181
277
|
function normalizeState(value: unknown): TrackerState {
|
|
182
|
-
if (!isRecord(value))
|
|
278
|
+
if (!isRecord(value)) throw new TypeError("tracker state must be an object");
|
|
279
|
+
if (!Array.isArray(value.processed) || !value.processed.every(isTrackerEntry)) {
|
|
280
|
+
throw new TypeError("tracker processed entries are invalid");
|
|
281
|
+
}
|
|
282
|
+
if (
|
|
283
|
+
value.last_checked_tag !== null &&
|
|
284
|
+
typeof value.last_checked_tag !== "string"
|
|
285
|
+
) {
|
|
286
|
+
throw new TypeError("tracker last_checked_tag is invalid");
|
|
287
|
+
}
|
|
288
|
+
if (
|
|
289
|
+
value.last_checked_at !== null &&
|
|
290
|
+
typeof value.last_checked_at !== "string"
|
|
291
|
+
) {
|
|
292
|
+
throw new TypeError("tracker last_checked_at is invalid");
|
|
293
|
+
}
|
|
183
294
|
|
|
184
|
-
const processed =
|
|
185
|
-
|
|
186
|
-
|
|
295
|
+
const processed = value.processed.slice(
|
|
296
|
+
0,
|
|
297
|
+
HIDDEN_STATE_LIMIT,
|
|
298
|
+
) as TrackerEntry[];
|
|
299
|
+
const lastCheckedTag = value.last_checked_tag as string | null;
|
|
300
|
+
const lastChecked =
|
|
301
|
+
lastCheckedTag === null
|
|
302
|
+
? null
|
|
303
|
+
: processed.find((entry) => entry.tag === lastCheckedTag);
|
|
304
|
+
if (lastCheckedTag === null ? processed.length > 0 : !lastChecked) {
|
|
305
|
+
throw new TypeError("tracker last_checked_tag is inconsistent");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let auditCursorTag: string | null;
|
|
309
|
+
let auditCursorValidated: boolean;
|
|
310
|
+
if (value.audit_cursor_tag === undefined) {
|
|
311
|
+
auditCursorTag = deriveLegacyAuditCursor(processed);
|
|
312
|
+
auditCursorValidated = false;
|
|
313
|
+
} else if (
|
|
314
|
+
(value.audit_cursor_tag === null ||
|
|
315
|
+
typeof value.audit_cursor_tag === "string") &&
|
|
316
|
+
value.audit_cursor_validated === true
|
|
317
|
+
) {
|
|
318
|
+
auditCursorTag = value.audit_cursor_tag;
|
|
319
|
+
auditCursorValidated = true;
|
|
320
|
+
} else {
|
|
321
|
+
throw new TypeError("tracker audit cursor is invalid");
|
|
322
|
+
}
|
|
323
|
+
if (auditCursorTag !== null && !isStableTag(auditCursorTag)) {
|
|
324
|
+
throw new TypeError("tracker audit_cursor_tag is invalid");
|
|
325
|
+
}
|
|
326
|
+
if (auditCursorTag === null && processed.length > 0) {
|
|
327
|
+
throw new TypeError("tracker audit_cursor_tag is missing");
|
|
328
|
+
}
|
|
329
|
+
if (
|
|
330
|
+
auditCursorTag !== null &&
|
|
331
|
+
!isSupportedAuditCursor(processed, auditCursorTag)
|
|
332
|
+
) {
|
|
333
|
+
throw new TypeError("tracker audit_cursor_tag is inconsistent");
|
|
334
|
+
}
|
|
187
335
|
|
|
188
336
|
return {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
last_checked_at:
|
|
194
|
-
typeof value.last_checked_at === "string" ? value.last_checked_at : null,
|
|
337
|
+
audit_cursor_tag: auditCursorTag,
|
|
338
|
+
audit_cursor_validated: auditCursorValidated,
|
|
339
|
+
last_checked_tag: lastCheckedTag,
|
|
340
|
+
last_checked_at: value.last_checked_at as string | null,
|
|
195
341
|
processed,
|
|
196
342
|
};
|
|
197
343
|
}
|
|
198
344
|
|
|
345
|
+
function isSupportedAuditCursor(
|
|
346
|
+
processed: TrackerEntry[],
|
|
347
|
+
cursorTag: string,
|
|
348
|
+
): boolean {
|
|
349
|
+
return processed.some((entry) =>
|
|
350
|
+
isAuditCursorSupportEntry(entry, cursorTag),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isAuditCursorSupportEntry(
|
|
355
|
+
entry: TrackerEntry,
|
|
356
|
+
cursorTag: string,
|
|
357
|
+
): boolean {
|
|
358
|
+
return (
|
|
359
|
+
(entry.tag === cursorTag && isTerminalOutcome(entry.outcome)) ||
|
|
360
|
+
entry.previous_tag === cursorTag
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function deriveLegacyAuditCursor(processed: TrackerEntry[]): string | null {
|
|
365
|
+
const terminal = processed.filter((entry) =>
|
|
366
|
+
isTerminalOutcome(entry.outcome),
|
|
367
|
+
);
|
|
368
|
+
if (terminal.length === 0) {
|
|
369
|
+
const errorBaselines = [
|
|
370
|
+
...new Set(
|
|
371
|
+
processed
|
|
372
|
+
.filter((entry) => entry.outcome === "error")
|
|
373
|
+
.map((entry) => entry.previous_tag),
|
|
374
|
+
),
|
|
375
|
+
];
|
|
376
|
+
if (errorBaselines.length > 1) {
|
|
377
|
+
throw new TypeError("legacy tracker error baselines are ambiguous");
|
|
378
|
+
}
|
|
379
|
+
return errorBaselines[0] ?? null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const tags = terminal.map((entry) => entry.tag);
|
|
383
|
+
if (new Set(tags).size !== tags.length) {
|
|
384
|
+
throw new TypeError("legacy tracker terminal tags are duplicated");
|
|
385
|
+
}
|
|
386
|
+
const previousTags = new Set(terminal.map((entry) => entry.previous_tag));
|
|
387
|
+
const endpoints = tags.filter((tag) => !previousTags.has(tag));
|
|
388
|
+
if (endpoints.length !== 1) {
|
|
389
|
+
throw new TypeError("legacy tracker audit chain is ambiguous");
|
|
390
|
+
}
|
|
391
|
+
return endpoints[0] ?? null;
|
|
392
|
+
}
|
|
393
|
+
|
|
199
394
|
function isTrackerEntry(value: unknown): value is TrackerEntry {
|
|
200
395
|
if (!isRecord(value)) return false;
|
|
201
396
|
return (
|
|
202
397
|
typeof value.tag === "string" &&
|
|
398
|
+
isStableTag(value.tag) &&
|
|
203
399
|
typeof value.previous_tag === "string" &&
|
|
400
|
+
isStableTag(value.previous_tag) &&
|
|
204
401
|
isVerdict(value.verdict) &&
|
|
205
402
|
isOutcome(value.outcome) &&
|
|
206
403
|
(typeof value.pr_url === "string" || value.pr_url === null) &&
|
|
@@ -211,6 +408,10 @@ function isTrackerEntry(value: unknown): value is TrackerEntry {
|
|
|
211
408
|
);
|
|
212
409
|
}
|
|
213
410
|
|
|
411
|
+
function isStableTag(value: string): boolean {
|
|
412
|
+
return /^(?:rust-v|v)?\d+\.\d+\.\d+$/.test(value);
|
|
413
|
+
}
|
|
414
|
+
|
|
214
415
|
function isVerdict(value: unknown): value is Verdict {
|
|
215
416
|
return (
|
|
216
417
|
value === "no-op" ||
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
createIssueWithBody,
|
|
6
|
+
editIssueBody,
|
|
7
|
+
ensureLabels,
|
|
8
|
+
findIssueByExactTitle,
|
|
9
|
+
getIssueBody,
|
|
10
|
+
getPullRequestStatus,
|
|
11
|
+
} from "./github.ts";
|
|
12
|
+
import {
|
|
13
|
+
analyzePiAiRelease,
|
|
14
|
+
compareStableVersions,
|
|
15
|
+
DEFAULT_TARGET_REPO,
|
|
16
|
+
findNextStableRelease,
|
|
17
|
+
listStableReleases,
|
|
18
|
+
readInstalledVersion,
|
|
19
|
+
} from "./release-analysis.ts";
|
|
20
|
+
import {
|
|
21
|
+
advanceMergedPr,
|
|
22
|
+
getPendingPrForCursor,
|
|
23
|
+
hasCompletedRange,
|
|
24
|
+
initialTrackerState,
|
|
25
|
+
parseTrackerState,
|
|
26
|
+
renderTrackerBody,
|
|
27
|
+
} from "./tracker.ts";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TRACKER_TITLE = "pi-ai dependency upgrade tracker";
|
|
30
|
+
const DEFAULT_ANALYSIS_FILE = "pi-ai-watch-analysis.json";
|
|
31
|
+
|
|
32
|
+
interface Args {
|
|
33
|
+
dryRun: boolean;
|
|
34
|
+
previousVersion: string | null;
|
|
35
|
+
currentVersion: string | null;
|
|
36
|
+
repo: string;
|
|
37
|
+
trackerTitle: string;
|
|
38
|
+
analysisFile: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TrackerIssue {
|
|
42
|
+
number: number;
|
|
43
|
+
url: string;
|
|
44
|
+
body: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv: string[]): Args {
|
|
48
|
+
const args: Args = {
|
|
49
|
+
dryRun: false,
|
|
50
|
+
previousVersion: null,
|
|
51
|
+
currentVersion: null,
|
|
52
|
+
repo: DEFAULT_TARGET_REPO,
|
|
53
|
+
trackerTitle: DEFAULT_TRACKER_TITLE,
|
|
54
|
+
analysisFile: DEFAULT_ANALYSIS_FILE,
|
|
55
|
+
};
|
|
56
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
57
|
+
const argument = argv[index];
|
|
58
|
+
if (argument === "--dry-run") args.dryRun = true;
|
|
59
|
+
else if (argument === "--previous-version") {
|
|
60
|
+
args.previousVersion = argv[++index] ?? null;
|
|
61
|
+
} else if (argument === "--current-version") {
|
|
62
|
+
args.currentVersion = argv[++index] ?? null;
|
|
63
|
+
} else if (argument === "--repo") {
|
|
64
|
+
args.repo = argv[++index] ?? args.repo;
|
|
65
|
+
} else if (argument === "--tracker-title") {
|
|
66
|
+
args.trackerTitle = argv[++index] ?? args.trackerTitle;
|
|
67
|
+
} else if (argument === "--analysis-file") {
|
|
68
|
+
args.analysisFile = argv[++index] ?? args.analysisFile;
|
|
69
|
+
} else if (argument === "--help" || argument === "-h") {
|
|
70
|
+
console.log(
|
|
71
|
+
"Usage: bun scripts/pi-ai-watch/agent-watch.ts [--dry-run] [--previous-version VERSION] [--current-version VERSION] [--repo OWNER/REPO] [--tracker-title TITLE] [--analysis-file FILE]",
|
|
72
|
+
);
|
|
73
|
+
process.exit(0);
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`Unknown argument: ${argument}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function main(): Promise<void> {
|
|
82
|
+
const args = parseArgs(process.argv.slice(2));
|
|
83
|
+
const installedVersion = readInstalledVersion();
|
|
84
|
+
const tracker = ensureTrackerIssue(args, installedVersion);
|
|
85
|
+
let state = parseTrackerState(tracker.body);
|
|
86
|
+
const releases = await listStableReleases();
|
|
87
|
+
let previousVersion = args.previousVersion;
|
|
88
|
+
let currentVersion = args.currentVersion;
|
|
89
|
+
const isScheduledSelection = !previousVersion && !currentVersion;
|
|
90
|
+
|
|
91
|
+
if (isScheduledSelection) {
|
|
92
|
+
const pending = getPendingPrForCursor(state);
|
|
93
|
+
if (pending?.pr_url) {
|
|
94
|
+
const status = getPullRequestStatus(pending.pr_url);
|
|
95
|
+
if (status.state === "OPEN") {
|
|
96
|
+
console.log(`Waiting for pending pi-ai upgrade PR: ${status.url}`);
|
|
97
|
+
writeCommonOutputs(tracker, {
|
|
98
|
+
installedVersion,
|
|
99
|
+
previousVersion: pending.previous_version,
|
|
100
|
+
currentVersion: pending.version,
|
|
101
|
+
});
|
|
102
|
+
writeOutput("pending_pr_url", status.url);
|
|
103
|
+
writeOutput("should_run_agent", "false");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (status.state === "MERGED") {
|
|
107
|
+
if (compareStableVersions(installedVersion, pending.version) < 0) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Merged pi-ai PR ${status.url} targets ${pending.version}, but main still declares ${installedVersion}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
state = advanceMergedPr(state, pending.version);
|
|
113
|
+
if (!args.dryRun) {
|
|
114
|
+
editIssueBody(args.repo, tracker.number, renderTrackerBody(state));
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
console.log(
|
|
118
|
+
`Retrying ${pending.version}; prior PR was closed unmerged.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const next = findNextStableRelease(releases, state.audit_cursor_version);
|
|
124
|
+
if (!next) {
|
|
125
|
+
console.log(
|
|
126
|
+
`No stable pi-ai release after ${state.audit_cursor_version}.`,
|
|
127
|
+
);
|
|
128
|
+
writeCommonOutputs(tracker, {
|
|
129
|
+
installedVersion,
|
|
130
|
+
previousVersion: state.audit_cursor_version,
|
|
131
|
+
currentVersion: state.audit_cursor_version,
|
|
132
|
+
});
|
|
133
|
+
writeOutput("should_run_agent", "false");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
previousVersion = state.audit_cursor_version;
|
|
137
|
+
currentVersion = next.version;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const analysis = await analyzePiAiRelease({
|
|
141
|
+
previousVersion,
|
|
142
|
+
currentVersion,
|
|
143
|
+
installedVersion,
|
|
144
|
+
stableReleases: releases,
|
|
145
|
+
});
|
|
146
|
+
writeFileSync(args.analysisFile, `${JSON.stringify(analysis, null, 2)}\n`);
|
|
147
|
+
writeCommonOutputs(tracker, {
|
|
148
|
+
installedVersion,
|
|
149
|
+
previousVersion: analysis.previous_version,
|
|
150
|
+
currentVersion: analysis.current_version,
|
|
151
|
+
});
|
|
152
|
+
writeOutput("analysis_file", args.analysisFile);
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
!args.dryRun &&
|
|
156
|
+
hasCompletedRange(
|
|
157
|
+
state,
|
|
158
|
+
analysis.previous_version,
|
|
159
|
+
analysis.current_version,
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
console.log(
|
|
163
|
+
`Already completed pi-ai review ${analysis.previous_version}...${analysis.current_version}.`,
|
|
164
|
+
);
|
|
165
|
+
writeOutput("should_run_agent", "false");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (args.dryRun) {
|
|
170
|
+
console.log(JSON.stringify(analysis, null, 2));
|
|
171
|
+
writeOutput("should_run_agent", "false");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log(
|
|
176
|
+
`pi-ai ${analysis.previous_version}...${analysis.current_version} needs Amelia review.`,
|
|
177
|
+
);
|
|
178
|
+
writeOutput("should_run_agent", "true");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ensureTrackerIssue(
|
|
182
|
+
args: Args,
|
|
183
|
+
installedVersion: string,
|
|
184
|
+
): TrackerIssue {
|
|
185
|
+
const existing = args.dryRun
|
|
186
|
+
? null
|
|
187
|
+
: findIssueByExactTitle(args.repo, args.trackerTitle);
|
|
188
|
+
if (existing) {
|
|
189
|
+
return {
|
|
190
|
+
number: existing.number,
|
|
191
|
+
url: `https://github.com/${args.repo}/issues/${existing.number}`,
|
|
192
|
+
body: getIssueBody(args.repo, existing.number),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const body = renderTrackerBody(initialTrackerState(installedVersion));
|
|
197
|
+
if (args.dryRun) {
|
|
198
|
+
return {
|
|
199
|
+
number: 0,
|
|
200
|
+
url: `https://github.com/${args.repo}/issues/0`,
|
|
201
|
+
body,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const labels = ["pi-ai-watch", "automation"];
|
|
206
|
+
ensureLabels(args.repo, labels);
|
|
207
|
+
const issueUrl = createIssueWithBody(
|
|
208
|
+
args.repo,
|
|
209
|
+
args.trackerTitle,
|
|
210
|
+
body,
|
|
211
|
+
labels,
|
|
212
|
+
);
|
|
213
|
+
return {
|
|
214
|
+
number: issueNumberFromUrl(issueUrl),
|
|
215
|
+
url: issueUrl,
|
|
216
|
+
body,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function issueNumberFromUrl(issueUrl: string): number {
|
|
221
|
+
const issueNumber = Number(issueUrl.trim().split("/").at(-1));
|
|
222
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
223
|
+
throw new Error(`Could not parse issue number from ${issueUrl}`);
|
|
224
|
+
}
|
|
225
|
+
return issueNumber;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function writeCommonOutputs(
|
|
229
|
+
tracker: TrackerIssue,
|
|
230
|
+
versions: {
|
|
231
|
+
installedVersion: string;
|
|
232
|
+
previousVersion: string;
|
|
233
|
+
currentVersion: string;
|
|
234
|
+
},
|
|
235
|
+
): void {
|
|
236
|
+
writeOutput("tracker_issue", String(tracker.number));
|
|
237
|
+
writeOutput("tracker_issue_url", tracker.url);
|
|
238
|
+
writeOutput("installed_version", versions.installedVersion);
|
|
239
|
+
writeOutput("previous_version", versions.previousVersion);
|
|
240
|
+
writeOutput("current_version", versions.currentVersion);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function writeOutput(name: string, value: string): void {
|
|
244
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
245
|
+
if (!outputPath) return;
|
|
246
|
+
appendFileSync(outputPath, `${name}=${value}\n`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
main().catch((error) => {
|
|
250
|
+
console.error(error);
|
|
251
|
+
writeOutput("should_run_agent", "false");
|
|
252
|
+
process.exit(1);
|
|
253
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface PullRequestStatus {
|
|
7
|
+
state: "OPEN" | "CLOSED" | "MERGED";
|
|
8
|
+
url: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function runGh(args: string[], input?: string): string {
|
|
12
|
+
const result = spawnSync("gh", args, {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
input,
|
|
15
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
16
|
+
stdio: input ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
17
|
+
});
|
|
18
|
+
if (result.status !== 0) {
|
|
19
|
+
throw new Error(`gh ${args.join(" ")} failed:\n${result.stderr}`);
|
|
20
|
+
}
|
|
21
|
+
return result.stdout;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ghJson<T>(args: string[], input?: string): T {
|
|
25
|
+
return JSON.parse(runGh(args, input)) as T;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ensureLabels(repo: string, labels: string[]): void {
|
|
29
|
+
for (const label of labels) {
|
|
30
|
+
const result = spawnSync("gh", ["label", "create", label, "--repo", repo], {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
33
|
+
});
|
|
34
|
+
if (result.status !== 0 && !result.stderr.includes("already exists")) {
|
|
35
|
+
throw new Error(`gh label create ${label} failed:\n${result.stderr}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createIssueWithBody(
|
|
41
|
+
repo: string,
|
|
42
|
+
title: string,
|
|
43
|
+
body: string,
|
|
44
|
+
labels: string[],
|
|
45
|
+
): string {
|
|
46
|
+
const bodyFile = writeTempMarkdown(body, "pi-ai-watch-issue");
|
|
47
|
+
try {
|
|
48
|
+
const args = [
|
|
49
|
+
"issue",
|
|
50
|
+
"create",
|
|
51
|
+
"--repo",
|
|
52
|
+
repo,
|
|
53
|
+
"--title",
|
|
54
|
+
title,
|
|
55
|
+
"--body-file",
|
|
56
|
+
bodyFile,
|
|
57
|
+
];
|
|
58
|
+
for (const label of labels) args.push("--label", label);
|
|
59
|
+
return runGh(args).trim();
|
|
60
|
+
} finally {
|
|
61
|
+
rmSync(bodyFile, { force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function editIssueBody(
|
|
66
|
+
repo: string,
|
|
67
|
+
issueNumber: number,
|
|
68
|
+
body: string,
|
|
69
|
+
): void {
|
|
70
|
+
const bodyFile = writeTempMarkdown(body, "pi-ai-watch-tracker");
|
|
71
|
+
try {
|
|
72
|
+
runGh([
|
|
73
|
+
"issue",
|
|
74
|
+
"edit",
|
|
75
|
+
String(issueNumber),
|
|
76
|
+
"--repo",
|
|
77
|
+
repo,
|
|
78
|
+
"--body-file",
|
|
79
|
+
bodyFile,
|
|
80
|
+
]);
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(bodyFile, { force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getIssueBody(repo: string, issueNumber: number): string {
|
|
87
|
+
const issue = ghJson<{ body: string | null }>([
|
|
88
|
+
"issue",
|
|
89
|
+
"view",
|
|
90
|
+
String(issueNumber),
|
|
91
|
+
"--repo",
|
|
92
|
+
repo,
|
|
93
|
+
"--json",
|
|
94
|
+
"body",
|
|
95
|
+
]);
|
|
96
|
+
return issue.body ?? "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function findIssueByExactTitle(
|
|
100
|
+
repo: string,
|
|
101
|
+
title: string,
|
|
102
|
+
): { number: number; title: string } | null {
|
|
103
|
+
const issues = ghJson<Array<{ number: number; title: string }>>([
|
|
104
|
+
"issue",
|
|
105
|
+
"list",
|
|
106
|
+
"--repo",
|
|
107
|
+
repo,
|
|
108
|
+
"--state",
|
|
109
|
+
"all",
|
|
110
|
+
"--search",
|
|
111
|
+
`${title} in:title`,
|
|
112
|
+
"--limit",
|
|
113
|
+
"20",
|
|
114
|
+
"--json",
|
|
115
|
+
"number,title",
|
|
116
|
+
]);
|
|
117
|
+
return issues.find((issue) => issue.title === title) ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getPullRequestStatus(prUrl: string): PullRequestStatus {
|
|
121
|
+
const match = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)$/.exec(
|
|
122
|
+
prUrl,
|
|
123
|
+
);
|
|
124
|
+
if (!match) throw new Error(`Invalid GitHub pull request URL ${prUrl}`);
|
|
125
|
+
const [, owner, repo, number] = match;
|
|
126
|
+
const pull = ghJson<{ state: "OPEN" | "CLOSED"; mergedAt: string | null }>([
|
|
127
|
+
"pr",
|
|
128
|
+
"view",
|
|
129
|
+
number as string,
|
|
130
|
+
"--repo",
|
|
131
|
+
`${owner}/${repo}`,
|
|
132
|
+
"--json",
|
|
133
|
+
"state,mergedAt",
|
|
134
|
+
]);
|
|
135
|
+
return {
|
|
136
|
+
state: pull.mergedAt ? "MERGED" : pull.state,
|
|
137
|
+
url: prUrl,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function writeTempMarkdown(body: string, prefix: string): string {
|
|
142
|
+
const path = join(tmpdir(), `${prefix}-${Date.now()}.md`);
|
|
143
|
+
writeFileSync(path, body);
|
|
144
|
+
return path;
|
|
145
|
+
}
|