model-eol 0.2.4 → 0.3.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/bot/bot.mjs ADDED
@@ -0,0 +1,978 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { spawnSync } from 'node:child_process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { isDeepStrictEqual } from 'node:util'
9
+
10
+ import {
11
+ branchFor,
12
+ daysRemaining,
13
+ hasMetadataMarker,
14
+ itemDigest,
15
+ markdownCode,
16
+ markdownLink,
17
+ markdownText,
18
+ matchesAnyGlob,
19
+ metadataLine,
20
+ parseMetadata,
21
+ repoPath,
22
+ sha256,
23
+ } from './lib/common.mjs'
24
+ import { validatePlanItems } from '../lib/apply.mjs'
25
+ import { parseCliArgs } from '../lib/cli.mjs'
26
+ import { loadConfig } from './lib/config.mjs'
27
+ import { downloadFeeds } from './lib/feeds.mjs'
28
+ import { readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
29
+ import { GitHubClient, hasModelEolLabel } from './lib/github.mjs'
30
+ import {
31
+ cloneRepository,
32
+ commitAll,
33
+ configureIdentity,
34
+ defaultBranch,
35
+ gitAuthentication,
36
+ originFor,
37
+ prepareBranch,
38
+ pushBranch,
39
+ } from './lib/git.mjs'
40
+
41
+ const ROOT = path.resolve(import.meta.dirname, '..')
42
+ const CHECKER = path.join(ROOT, 'check.mjs')
43
+ const VENDORED_FEEDS = path.join(ROOT, 'feeds')
44
+ const PLAN_SCHEMA = 'model-eol.plan/0.1'
45
+ const BOT_SCHEMA = 'model-eol.bot/0.1'
46
+ const ISSUE_REASONS = new Set([
47
+ 'not-direct-api',
48
+ 'no-replacement',
49
+ 'replacement-choice',
50
+ 'replacement-unresolved',
51
+ 'replacement-retiring',
52
+ 'publisher-fallback',
53
+ 'unresolved-channel',
54
+ ])
55
+ const ACTIONABLE_STATUSES = new Set(['retired', 'retiring'])
56
+
57
+ const asAbsolute = value => path.resolve(process.cwd(), value)
58
+
59
+ export const parseArgs = argv => {
60
+ const { values } = parseCliArgs({
61
+ args: argv,
62
+ options: {
63
+ repo: { type: 'string' },
64
+ 'target-dir': { type: 'string' },
65
+ config: { type: 'string' },
66
+ 'dry-run': { type: 'boolean' },
67
+ eval: { type: 'boolean' },
68
+ 'feeds-url': { type: 'string', multiple: true },
69
+ help: { type: 'boolean', short: 'h' },
70
+ },
71
+ help: 'node bot/bot.mjs --help',
72
+ })
73
+ const options = {
74
+ repo: values.repo ?? null,
75
+ targetDir: values['target-dir'] ?? process.cwd(),
76
+ configPath: values.config ?? null,
77
+ dryRun: values['dry-run'] ?? false,
78
+ evalEnabled: values.eval ?? false,
79
+ feedsUrls: [],
80
+ help: values.help ?? false,
81
+ }
82
+ for (const value of values['feeds-url'] ?? []) {
83
+ options.feedsUrls.push(...value.split(',').map(item => item.trim()).filter(Boolean))
84
+ }
85
+ return options
86
+ }
87
+
88
+ export const helpText = () => `model-eol bot
89
+
90
+ Usage:
91
+ node bot/bot.mjs [--repo owner/name] [--target-dir PATH] [--config PATH] [--dry-run] [--eval] [--feeds-url URL[,URL...]]
92
+
93
+ Environment:
94
+ GITHUB_TOKEN Required unless --dry-run; authenticates the API and HTTPS Git remotes.
95
+ GITHUB_API_URL GitHub API root, default https://api.github.com.
96
+ MODEL_EOL_TOKEN_KIND Set to github-token to add the checks warning to PRs.
97
+ `
98
+
99
+ const validateRepo = repo => {
100
+ if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error('--repo must be owner/name')
101
+ return repo
102
+ }
103
+
104
+ const spawnPlan = ({ workDir, config, configPath = null, feedsDir, checkerPath = CHECKER, warn = console.error }) => {
105
+ const args = [checkerPath, 'plan', '.', '--days', String(config.days), '--scope', config.scope]
106
+ if (config.via) args.push('--via', config.via)
107
+ if (configPath) args.push('--config', configPath)
108
+ if (feedsDir) args.push('--feeds', feedsDir)
109
+ const result = spawnSync(process.execPath, args, {
110
+ cwd: workDir,
111
+ encoding: 'utf8',
112
+ stdio: ['ignore', 'pipe', 'pipe'],
113
+ })
114
+ if (result.stderr?.trim()) warn(result.stderr.trim())
115
+ if (result.error || result.status !== 0) {
116
+ throw new Error(`plan subprocess failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}`)
117
+ }
118
+ let plan
119
+ try {
120
+ plan = JSON.parse(result.stdout)
121
+ } catch (error) {
122
+ throw new Error(`plan subprocess did not emit JSON: ${error.message}`)
123
+ }
124
+ return validatePlan(plan)
125
+ }
126
+
127
+ export const runPlan = spawnPlan
128
+
129
+ const validatePlan = plan => {
130
+ if (plan?.plan_schema !== PLAN_SCHEMA) {
131
+ throw new Error(`refusing plan schema ${plan?.plan_schema ?? 'missing'}; expected ${PLAN_SCHEMA}`)
132
+ }
133
+ if (!Array.isArray(plan.items) || !Array.isArray(plan.issues)) {
134
+ throw new Error('refusing malformed plan document: items and issues arrays are required')
135
+ }
136
+ try {
137
+ validatePlanItems(plan.items)
138
+ } catch (error) {
139
+ throw new Error(`refusing malformed plan document: ${error.message}`)
140
+ }
141
+ return plan
142
+ }
143
+
144
+ const readPlanFile = file => {
145
+ let plan
146
+ try {
147
+ plan = JSON.parse(fs.readFileSync(file, 'utf8'))
148
+ } catch (error) {
149
+ throw new Error(`could not read plan artifact ${file}: ${error.message}`)
150
+ }
151
+ return validatePlan(plan)
152
+ }
153
+
154
+ const verifiedPlanArtifact = (file, generatedPlan) => {
155
+ if (!file) return generatedPlan
156
+ const artifact = readPlanFile(asAbsolute(file))
157
+ const { generated: artifactGenerated, ...artifactStable } = artifact
158
+ const { generated: regeneratedGenerated, ...regeneratedStable } = generatedPlan
159
+ if (!isDeepStrictEqual(artifactStable, regeneratedStable)) {
160
+ throw new Error('refusing plan artifact: it does not match the independently regenerated plan')
161
+ }
162
+ return artifact
163
+ }
164
+
165
+ const readEvalArtifact = (file, maxBytes, statusFile = null) => {
166
+ if (!file && !statusFile) return null
167
+ let exitCode = null
168
+ let statusError = null
169
+ if (!statusFile) {
170
+ statusError = 'eval status artifact is missing'
171
+ } else {
172
+ try {
173
+ const statusArtifact = readReportCapped(statusFile, 1024)
174
+ if (statusArtifact.missing) statusError = 'eval status artifact is missing'
175
+ else {
176
+ const statusText = statusArtifact.report.trim()
177
+ if (!/^-?\d+$/.test(statusText)) statusError = 'eval status artifact is malformed'
178
+ else {
179
+ const parsed = Number(statusText)
180
+ if (!Number.isSafeInteger(parsed)) statusError = 'eval status artifact is outside the safe integer range'
181
+ else exitCode = parsed
182
+ }
183
+ }
184
+ } catch (error) {
185
+ statusError = `eval status artifact is unreadable: ${error.message}`
186
+ }
187
+ }
188
+ let artifact
189
+ try {
190
+ artifact = file ? readReportCapped(file, maxBytes) : { missing: true, report: null }
191
+ } catch (error) {
192
+ return {
193
+ status: 'fail',
194
+ exit_code: exitCode,
195
+ report: `eval report artifact is unreadable: ${error.message}`,
196
+ }
197
+ }
198
+ if (statusError) {
199
+ return {
200
+ status: 'fail',
201
+ exit_code: exitCode,
202
+ report: artifact.report ? `${statusError}; report: ${artifact.report}` : statusError,
203
+ }
204
+ }
205
+ if (artifact.missing) {
206
+ return {
207
+ status: 'fail',
208
+ exit_code: exitCode,
209
+ report: exitCode === 0 ? 'eval report artifact is missing' : null,
210
+ }
211
+ }
212
+ if (artifact.report === '' && exitCode === 0) return null
213
+ return { status: exitCode === 0 ? 'pass' : 'fail', exit_code: exitCode, report: artifact.report || null }
214
+ }
215
+
216
+ const feedContext = feedsDir => {
217
+ const records = new Map()
218
+ let files = []
219
+ try {
220
+ files = fs.readdirSync(feedsDir).filter(file => file.endsWith('.json')).sort()
221
+ } catch {
222
+ return records
223
+ }
224
+ for (const file of files) {
225
+ let feed
226
+ try {
227
+ feed = JSON.parse(fs.readFileSync(path.join(feedsDir, file), 'utf8'))
228
+ } catch {
229
+ continue
230
+ }
231
+ if (feed?.spec !== 'model-eol/0.1' || !Array.isArray(feed.models)) continue
232
+ for (const entry of feed.models) {
233
+ for (const key of [entry.id, ...(entry.aliases ?? [])]) {
234
+ if (typeof key === 'string') records.set(`${feed.publisher}\0${key}`, { feed, entry })
235
+ }
236
+ }
237
+ }
238
+ return records
239
+ }
240
+
241
+ export const contextFor = (records, publisher, id, via) => {
242
+ const record = records.get(`${publisher}\0${id}`)
243
+ if (!record) return { announced: null, notes: [], entry: null }
244
+ const distribution = via ? (record.entry.distributions ?? []).find(item => item.via === via) : null
245
+ return {
246
+ announced: distribution ? distribution.announced ?? null : record.entry.announced ?? null,
247
+ notes: [record.feed.note, record.entry.notes].filter(Boolean),
248
+ entry: record.entry,
249
+ }
250
+ }
251
+
252
+ const ignoredModelIds = (plan, config) => {
253
+ const ignored = new Set(config.ignore.models)
254
+ const ids = new Set()
255
+ for (const item of [...plan.items, ...plan.issues]) {
256
+ if (item.id && (ignored.has(item.id) || ignored.has(item.matched))) ids.add(item.id)
257
+ }
258
+ return ids
259
+ }
260
+
261
+ const isIgnored = (entry, root, config, ignoredIds) => {
262
+ if (entry.id && ignoredIds.has(entry.id)) return true
263
+ if (entry.id && config.ignore.models.includes(entry.id)) return true
264
+ if (entry.matched && config.ignore.models.includes(entry.matched)) return true
265
+ return matchesAnyGlob(repoPath(entry.file, root), config.ignore.paths)
266
+ }
267
+
268
+ const buildModelGroups = (plan, config, root, records) => {
269
+ const ignoredIds = ignoredModelIds(plan, config)
270
+ const groups = new Map()
271
+ for (const item of plan.items) {
272
+ if (isIgnored(item, root, config, ignoredIds)) continue
273
+ const key = `${item.publisher}\0${item.id}`
274
+ if (!groups.has(key)) {
275
+ groups.set(key, {
276
+ kind: 'model',
277
+ id: item.id,
278
+ publisher: item.publisher,
279
+ items: [],
280
+ via: plan.via,
281
+ branch: branchFor(item.publisher, item.id, plan.via),
282
+ context: contextFor(records, item.publisher, item.id, plan.via),
283
+ })
284
+ }
285
+ groups.get(key).items.push(item)
286
+ }
287
+ for (const group of groups.values()) {
288
+ group.items.sort((a, b) => `${a.file}:${a.line}:${a.occurrence}`.localeCompare(`${b.file}:${b.line}:${b.occurrence}`))
289
+ group.feedDigest = itemDigest(group.items)
290
+ }
291
+ return [...groups.values()].sort((a, b) => `${a.publisher}/${a.id}`.localeCompare(`${b.publisher}/${b.id}`))
292
+ }
293
+
294
+ const buildIssueGroups = (plan, config, root, records) => {
295
+ const ignoredIds = ignoredModelIds(plan, config)
296
+ const groups = new Map()
297
+ for (const issue of plan.issues) {
298
+ if (!ISSUE_REASONS.has(issue.reason)) continue
299
+ if (issue.reason !== 'unresolved-channel' && !ACTIONABLE_STATUSES.has(issue.status)) continue
300
+ if (isIgnored(issue, root, config, ignoredIds)) continue
301
+ const channel = issue.reason === 'unresolved-channel' ? (issue.provider || issue.matched || 'unresolved-channel') : null
302
+ const subject = issue.id || channel || issue.matched || 'unresolved-model'
303
+ const key = `${issue.publisher || ''}\0${subject}\0${issue.shutdown ?? ''}`
304
+ if (!groups.has(key)) {
305
+ groups.set(key, {
306
+ kind: 'issue',
307
+ id: issue.id || null,
308
+ subject,
309
+ channel,
310
+ publisher: issue.publisher || 'unknown',
311
+ shutdown: issue.shutdown ?? null,
312
+ via: issue.via ?? plan.via,
313
+ issues: [],
314
+ root,
315
+ context: issue.id ? contextFor(records, issue.publisher, issue.id, issue.via ?? plan.via) : { announced: null, notes: [], entry: null },
316
+ })
317
+ }
318
+ groups.get(key).issues.push(issue)
319
+ }
320
+ for (const group of groups.values()) {
321
+ group.issues.sort((a, b) => `${a.file}:${a.line}:${a.reason}`.localeCompare(`${b.file}:${b.line}:${b.reason}`))
322
+ group.feedDigest = itemDigest(group.issues)
323
+ }
324
+ return [...groups.values()].sort((a, b) => `${a.publisher}/${a.subject}/${a.shutdown ?? ''}`.localeCompare(`${b.publisher}/${b.subject}/${b.shutdown ?? ''}`))
325
+ }
326
+
327
+ const sourcesFor = group => {
328
+ const values = group.kind === 'model'
329
+ ? group.items.flatMap(item => item.sources ?? [])
330
+ : group.issues.flatMap(issue => issue.sources ?? [])
331
+ return [...new Set(values.filter(Boolean))]
332
+ }
333
+
334
+ const notesFor = group => {
335
+ const planNotes = group.kind === 'model' ? group.items.map(item => item.notes) : group.issues.map(issue => issue.notes)
336
+ const values = [
337
+ ...(planNotes.some(Boolean) ? planNotes : (group.context?.notes ?? [])),
338
+ ]
339
+ return [...new Set(values.filter(Boolean))]
340
+ }
341
+
342
+ const sourceSection = group => {
343
+ const sources = sourcesFor(group)
344
+ return sources.length
345
+ ? sources.map(source => `- ${markdownLink(source, source)}`).join('\n')
346
+ : '- No source URL was included in the plan.'
347
+ }
348
+
349
+ const notesSection = group => {
350
+ const notes = notesFor(group)
351
+ return notes.length ? notes.map(note => `- ${markdownText(note)}`).join('\n') : '- No additional feed notes.'
352
+ }
353
+
354
+ const evalSection = result => {
355
+ if (!result) return ''
356
+ const exit = result.exit_code === null ? 'no exit code' : `exit code ${result.exit_code}`
357
+ const lines = [`## Eval`, `- Result: ${result.status} (${exit}).`]
358
+ if (result.report !== null) lines.push('', '```text', reportForBody(result.report), '```')
359
+ return lines.join('\n')
360
+ }
361
+
362
+ const tokenWarning = tokenKind => tokenKind === 'github-token'
363
+ ? '\n\n> Warning: checks may be skipped because PRs created with `GITHUB_TOKEN` do not trigger `pull_request` workflows. Use a fine-grained PAT or GitHub App token when checks must run.'
364
+ : ''
365
+
366
+ const replacementOptionsText = options => options.map(markdownCode).join(' | ')
367
+
368
+ const replacementSection = (item, now) => [
369
+ ...(item.replacement
370
+ ? [`replacement per feed as of ${now.toISOString().slice(0, 10)}: ${markdownCode(item.replacement)}. Treat as a snapshot in time, not a constant.`]
371
+ : []),
372
+ ...(item.replacement_options?.length
373
+ ? [`replacement options per feed as of ${now.toISOString().slice(0, 10)}: ${replacementOptionsText(item.replacement_options)}.`]
374
+ : []),
375
+ ...(item.replacement_note
376
+ ? [`replacement note: ${markdownText(item.replacement_note)}`]
377
+ : []),
378
+ ]
379
+
380
+ export const buildPullBody = ({ group, headSha, now = new Date(), tokenKind = null, evalResult = null }) => {
381
+ const item = group.items[0]
382
+ const announced = markdownText(group.context?.announced ?? 'not specified')
383
+ const days = daysRemaining(item.shutdown, now)
384
+ const metadata = {
385
+ schema: BOT_SCHEMA,
386
+ id: group.id,
387
+ publisher: group.publisher,
388
+ shutdown: item.shutdown,
389
+ via: group.via ?? null,
390
+ replacement: item.replacement,
391
+ replacement_options: item.replacement_options,
392
+ replacement_note: item.replacement_note,
393
+ head_sha: headSha,
394
+ feed_digest: group.feedDigest,
395
+ }
396
+ const sections = [
397
+ metadataLine(metadata),
398
+ '',
399
+ '## What / when',
400
+ `- Announced: ${announced}`,
401
+ `- Shutdown: ${markdownText(item.shutdown)}`,
402
+ `- Days remaining: ${days}`,
403
+ '',
404
+ '## Replacement',
405
+ ...replacementSection(item, now),
406
+ '',
407
+ '## Sources',
408
+ sourceSection(group),
409
+ '',
410
+ '## Feed notes',
411
+ notesSection(group),
412
+ ]
413
+ if (group.via) {
414
+ sections.push('', '## Distributor clock', `This migration uses the ${markdownCode(group.via)} distributor clock; the shutdown date above is the date for that channel.`)
415
+ }
416
+ const evaluation = evalSection(evalResult)
417
+ if (evaluation) sections.push('', evaluation)
418
+ sections.push(
419
+ '',
420
+ '## Checklist',
421
+ '- [ ] Review the diff',
422
+ '- [ ] Run evals',
423
+ '- [ ] Verify params/behavior',
424
+ )
425
+ return sections.join('\n') + tokenWarning(tokenKind)
426
+ }
427
+
428
+ export const buildIssueBody = ({ group, now = new Date() }) => {
429
+ const issue = group.issues[0]
430
+ const metadata = {
431
+ schema: BOT_SCHEMA,
432
+ id: group.id || group.subject,
433
+ publisher: group.publisher,
434
+ shutdown: group.shutdown,
435
+ via: group.via ?? null,
436
+ replacement: issue.replacement ?? null,
437
+ replacement_options: issue.replacement_options,
438
+ replacement_note: issue.replacement_note,
439
+ head_sha: null,
440
+ feed_digest: group.feedDigest,
441
+ ...(group.channel ? { channel: group.channel } : {}),
442
+ }
443
+ const evidence = group.issues
444
+ .map(issue => `- ${markdownText(`${repoPath(issue.file, group.root || '.')}:${issue.line} - ${issue.reason}${issue.matched ? ` (${issue.matched})` : ''}`)}`)
445
+ .join('\n')
446
+ return [
447
+ metadataLine(metadata),
448
+ '',
449
+ '## Finding',
450
+ `- Subject: ${markdownCode(group.subject)}`,
451
+ `- Reason: ${markdownText(group.issues[0].reason)}`,
452
+ `- Status: ${markdownText(group.issues[0].status ?? 'unresolved')}`,
453
+ `- Shutdown: ${markdownText(group.shutdown ?? 'not scheduled')}`,
454
+ ...(issue.replacement
455
+ ? [`- Replacement per feed as of ${now.toISOString().slice(0, 10)}: ${markdownCode(issue.replacement)}. Treat as a snapshot in time, not a constant.`]
456
+ : []),
457
+ ...(issue.replacement_options?.length
458
+ ? [`- Replacement options per feed as of ${now.toISOString().slice(0, 10)}: ${replacementOptionsText(issue.replacement_options)}.`]
459
+ : []),
460
+ ...(issue.replacement_note
461
+ ? [`- Replacement note: ${markdownText(issue.replacement_note)}`]
462
+ : []),
463
+ `- Recorded on: ${now.toISOString().slice(0, 10)}`,
464
+ '',
465
+ '## Evidence',
466
+ evidence,
467
+ '',
468
+ '## Sources',
469
+ sourceSection(group),
470
+ '',
471
+ '## Feed notes',
472
+ notesSection(group),
473
+ ].join('\n')
474
+ }
475
+
476
+ const pullTitle = group => `model-eol: migrate ${group.id} before ${group.items[0].shutdown}`
477
+ const issueTitle = group => `model-eol: investigate ${group.subject}${group.shutdown ? ` before ${group.shutdown}` : ''}`
478
+
479
+ const isOpen = item => item.state === 'open'
480
+ const isMerged = item => Boolean(item.merged_at || item.pull_request?.merged_at || item.merged === true)
481
+
482
+ const matchingPulls = (pulls, group) => pulls
483
+ .filter(item => !item.labels || hasModelEolLabel(item) || item.head?.ref === group.branch || hasMetadataMarker(item.body))
484
+ .map(item => {
485
+ const metadata = parseMetadata(item.body)
486
+ return { item, metadata, conflict: hasMetadataMarker(item.body) && !metadata }
487
+ })
488
+ .filter(record => record.conflict || (
489
+ record.metadata?.id === group.id
490
+ && record.metadata?.publisher === group.publisher
491
+ && (record.metadata.via ?? null) === (group.via ?? null)
492
+ ))
493
+
494
+ const matchingIssues = (issues, group) => issues
495
+ .filter(item => !item.labels || hasModelEolLabel(item) || hasMetadataMarker(item.body))
496
+ .map(item => {
497
+ const metadata = parseMetadata(item.body)
498
+ return { item, metadata, conflict: hasMetadataMarker(item.body) && !metadata }
499
+ })
500
+ .filter(record => {
501
+ if (record.conflict) return true
502
+ if (!record.metadata) return false
503
+ if (record.metadata.id !== (group.id || group.subject)) return false
504
+ if (record.metadata.publisher !== group.publisher) return false
505
+ if ((record.metadata.via ?? null) !== (group.via ?? null)) return false
506
+ return !group.channel || record.metadata.channel === group.channel || (record.metadata.id === group.channel && !record.metadata.channel)
507
+ })
508
+
509
+ const currentHeadFor = async (api, pull, group) => {
510
+ if (pull.head?.sha) return pull.head.sha
511
+ const branch = pull.head?.ref || group.branch
512
+ const encoded = branch.split('/').map(part => encodeURIComponent(part)).join('/')
513
+ const { data } = await api.request('GET', `/repos/${api.repo}/git/ref/heads/${encoded}`)
514
+ return data?.object?.sha ?? null
515
+ }
516
+
517
+ const standingDownComment = (group, metadata, currentHead) =>
518
+ `model-eol is standing down for this branch. The branch head (${markdownCode(currentHead || 'unknown')}) no longer satisfies the bot lease checks for metadata head (${markdownCode(metadata?.head_sha || 'unknown')}), so a human change will not be overwritten. Please update or close this PR manually. An actor with push access can forge the configured identity.`
519
+
520
+ const writeJson = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2))
521
+
522
+ const gitStatusPaths = cwd => {
523
+ const result = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
524
+ cwd,
525
+ encoding: 'utf8',
526
+ stdio: ['ignore', 'pipe', 'pipe'],
527
+ })
528
+ if (result.error || result.status !== 0) {
529
+ throw new Error(`could not inspect eval workspace: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}`)
530
+ }
531
+ return result.stdout
532
+ .split(/\r?\n/)
533
+ .filter(Boolean)
534
+ .map(line => line.slice(3).trim())
535
+ .filter(Boolean)
536
+ }
537
+
538
+ const gitHead = cwd => {
539
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], {
540
+ cwd,
541
+ encoding: 'utf8',
542
+ stdio: ['ignore', 'pipe', 'pipe'],
543
+ })
544
+ if (result.error || result.status !== 0) throw new Error(`could not inspect eval commit: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}`)
545
+ return result.stdout.trim()
546
+ }
547
+
548
+ const hashFiles = (root, files) => new Map(files.map(file => [file, sha256(fs.readFileSync(path.join(root, file)))]))
549
+
550
+ const evalWorkspaceDrift = (root, planFiles, expectedHashes, expectedHead) => {
551
+ if (gitHead(root) !== expectedHead) return 'eval changed repository history'
552
+ for (const file of planFiles) {
553
+ let actual
554
+ try {
555
+ actual = sha256(fs.readFileSync(path.join(root, file)))
556
+ } catch (error) {
557
+ return `eval changed planned file ${file}: it is unreadable after evaluation (${error.message})`
558
+ }
559
+ if (actual !== expectedHashes.get(file)) return `eval changed planned file ${file}`
560
+ }
561
+ const unexpected = gitStatusPaths(root).filter(file => !planFiles.includes(file))
562
+ if (unexpected.length) return `eval changed unexpected repository file ${unexpected[0]}`
563
+ return null
564
+ }
565
+
566
+ const makePatch = ({ source, base, branch, expectedHead, group, plan, config, checkerPath, evalEnabled, root, warn, gitAuth }) => {
567
+ const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-work-'))
568
+ const planRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-plan-'))
569
+ const clone = path.join(workRoot, 'repo')
570
+ const fullPlanPath = path.join(planRoot, 'plan.json')
571
+ const selectedPlanPath = path.join(planRoot, 'selected-plan.json')
572
+ const reportPath = path.join(planRoot, 'eval-report.md')
573
+ writeJson(fullPlanPath, plan)
574
+ writeJson(selectedPlanPath, { ...plan, items: group.items, issues: [] })
575
+ try {
576
+ cloneRepository(source, clone, gitAuth)
577
+ prepareBranch(clone, branch, base, gitAuth)
578
+ const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
579
+ cwd: clone,
580
+ encoding: 'utf8',
581
+ stdio: ['ignore', 'pipe', 'pipe'],
582
+ })
583
+ if (applied.status !== 0 || applied.error) {
584
+ throw new Error(`apply subprocess failed: ${applied.error?.message || applied.stderr?.trim() || `exit ${applied.status}`}`)
585
+ }
586
+ const planFiles = [...new Set(group.items.map(item => repoPath(item.file, clone)))]
587
+ const postApplyHashes = hashFiles(clone, planFiles)
588
+ const postApplyHead = gitHead(clone)
589
+
590
+ let evalResult = null
591
+ if (evalEnabled && config.eval.command) {
592
+ try {
593
+ evalResult = runEvalHook({
594
+ command: config.eval.command,
595
+ timeoutMs: config.eval.timeout_ms,
596
+ maxReportBytes: config.eval.max_report_bytes,
597
+ passEnv: config.eval.pass_env,
598
+ cwd: clone,
599
+ oldId: group.id,
600
+ newId: group.items[0].replacement,
601
+ planPath: fullPlanPath,
602
+ reportPath,
603
+ })
604
+ } catch (error) {
605
+ warn(`model-eol: warning: eval runner failed for ${group.id}: ${error.message}`)
606
+ evalResult = { status: 'fail', exit_code: null, report: `eval runner error: ${error.message}` }
607
+ }
608
+ }
609
+ if (evalResult) {
610
+ const drift = evalWorkspaceDrift(clone, planFiles, postApplyHashes, postApplyHead)
611
+ if (drift) {
612
+ evalResult = {
613
+ ...evalResult,
614
+ status: 'fail',
615
+ report: [evalResult.report, drift].filter(Boolean).join('\n'),
616
+ }
617
+ }
618
+ }
619
+ if (evalResult && evalResult.status !== 'pass') {
620
+ const error = new Error(`eval hook did not pass: ${evalResult.status}`)
621
+ error.code = 'MODEL_EOL_EVAL_FAILED'
622
+ error.evalResult = evalResult
623
+ throw error
624
+ }
625
+
626
+ configureIdentity(clone)
627
+ const files = planFiles
628
+ const message = `model-eol: migrate ${group.id} to ${group.items[0].replacement} (${group.feedDigest.slice(0, 8)})`
629
+ const headSha = commitAll(clone, files, message)
630
+ pushBranch(clone, branch, expectedHead, gitAuth)
631
+ return { headSha, evalResult }
632
+ } finally {
633
+ fs.rmSync(workRoot, { recursive: true, force: true })
634
+ fs.rmSync(planRoot, { recursive: true, force: true })
635
+ }
636
+ }
637
+
638
+ const decision = (group, action, extra = {}) => ({ group, action, ...extra })
639
+
640
+ const processModel = async ({ api, pulls, group, source, base, plan, config, checkerPath, evalEnabled, externalEval, root, now, tokenKind, warn, gitAuth }) => {
641
+ if (externalEval && externalEval.status !== 'pass') {
642
+ return decision(group, 'eval-failed', { evalResult: externalEval })
643
+ }
644
+ const matches = matchingPulls(pulls, group)
645
+ const conflict = matches.find(record => record.conflict)
646
+ if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
647
+ const open = matches.find(record => isOpen(record.item))
648
+ const currentDigest = group.feedDigest
649
+ if (open) {
650
+ if (open.metadata.feed_digest === currentDigest) return decision(group, 'skip-unchanged', { number: open.item.number })
651
+ const currentHead = await currentHeadFor(api, open.item, group)
652
+ if (!currentHead || currentHead !== open.metadata.head_sha) {
653
+ await api.comment(open.item.number, standingDownComment(group, open.metadata, currentHead))
654
+ return decision(group, 'stand-down', { number: open.item.number })
655
+ }
656
+ let patch
657
+ try {
658
+ patch = makePatch({
659
+ source,
660
+ base,
661
+ branch: open.item.head?.ref || group.branch,
662
+ expectedHead: open.metadata.head_sha,
663
+ group,
664
+ plan,
665
+ config,
666
+ checkerPath,
667
+ evalEnabled,
668
+ root,
669
+ warn,
670
+ gitAuth,
671
+ })
672
+ } catch (error) {
673
+ if (error.code === 'MODEL_EOL_EVAL_FAILED') {
674
+ return decision(group, 'eval-failed', { number: open.item.number, evalResult: error.evalResult })
675
+ }
676
+ if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
677
+ await api.comment(open.item.number, standingDownComment(group, open.metadata, error.currentHead))
678
+ return decision(group, 'stand-down', { number: open.item.number })
679
+ }
680
+ if (patch.evalResult && patch.evalResult.status !== 'pass') {
681
+ return decision(group, 'eval-failed', { number: open.item.number, evalResult: patch.evalResult })
682
+ }
683
+ if (!patch.evalResult && externalEval) patch.evalResult = externalEval
684
+ const body = buildPullBody({ group, headSha: patch.headSha, now, tokenKind, evalResult: patch.evalResult })
685
+ await api.updatePull(open.item.number, { title: pullTitle(group), body })
686
+ return decision(group, 'update', { number: open.item.number, body, headSha: patch.headSha })
687
+ }
688
+
689
+ const replacement = group.items[0].replacement
690
+ // Legacy metadata without replacement keeps shutdown-only suppression.
691
+ const merged = matches.find(record => !isOpen(record.item) && isMerged(record.item) && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
692
+ if (merged) return decision(group, 'skip-merged', { number: merged.item.number })
693
+ const dismissed = matches.find(record => !isOpen(record.item) && !isMerged(record.item) && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
694
+ if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
695
+
696
+ const previousBotHead = matches.find(record => record.metadata?.head_sha)?.metadata.head_sha ?? null
697
+ let patch
698
+ try {
699
+ patch = makePatch({
700
+ source,
701
+ base,
702
+ branch: group.branch,
703
+ expectedHead: previousBotHead,
704
+ group,
705
+ plan,
706
+ config,
707
+ checkerPath,
708
+ evalEnabled,
709
+ root,
710
+ warn,
711
+ gitAuth,
712
+ })
713
+ } catch (error) {
714
+ if (error.code === 'MODEL_EOL_EVAL_FAILED') {
715
+ return decision(group, 'eval-failed', { evalResult: error.evalResult })
716
+ }
717
+ if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
718
+ const prior = matches.find(record => record.metadata?.head_sha === previousBotHead)
719
+ if (prior) await api.comment(prior.item.number, standingDownComment(group, prior.metadata, error.currentHead))
720
+ return decision(group, 'stand-down', { number: prior?.item.number })
721
+ }
722
+ if (patch.evalResult && patch.evalResult.status !== 'pass') {
723
+ return decision(group, 'eval-failed', { evalResult: patch.evalResult })
724
+ }
725
+ if (!patch.evalResult && externalEval) patch.evalResult = externalEval
726
+ const body = buildPullBody({ group, headSha: patch.headSha, now, tokenKind, evalResult: patch.evalResult })
727
+ const latestMatches = matchingPulls(await api.listPullsByHead(group.branch), group)
728
+ const latestConflict = latestMatches.find(record => record.conflict)
729
+ if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
730
+ const latestOpen = latestMatches.find(record => isOpen(record.item))
731
+ if (latestOpen) {
732
+ await api.updatePull(latestOpen.item.number, { title: pullTitle(group), body })
733
+ return decision(group, 'update', { number: latestOpen.item.number, body, headSha: patch.headSha })
734
+ }
735
+ const latestSuppressed = latestMatches.find(record => !isOpen(record.item) && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
736
+ if (latestSuppressed) return decision(group, isMerged(latestSuppressed.item) ? 'skip-merged' : 'skip-dismissed', { number: latestSuppressed.item.number })
737
+ const created = await api.createPull({
738
+ title: pullTitle(group),
739
+ head: group.branch,
740
+ base,
741
+ body,
742
+ })
743
+ return decision(group, 'create', { number: created.number, body, headSha: patch.headSha })
744
+ }
745
+
746
+ const processIssue = async ({ api, issues, group, now }) => {
747
+ const matches = matchingIssues(issues, group)
748
+ const conflict = matches.find(record => record.conflict)
749
+ if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
750
+ const open = matches.find(record => isOpen(record.item))
751
+ const body = buildIssueBody({ group, now })
752
+ if (open) {
753
+ if (open.metadata.feed_digest === group.feedDigest) return decision(group, 'skip-unchanged', { number: open.item.number, body })
754
+ await api.updateIssue(open.item.number, { title: issueTitle(group), body })
755
+ return decision(group, 'update', { number: open.item.number, body })
756
+ }
757
+ const replacement = group.issues[0].replacement ?? null
758
+ const dismissed = matches.find(record => !isOpen(record.item) && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
759
+ if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
760
+ const latestMatches = matchingIssues(await api.listIssues(), group)
761
+ const latestConflict = latestMatches.find(record => record.conflict)
762
+ if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
763
+ const latestOpen = latestMatches.find(record => isOpen(record.item))
764
+ if (latestOpen) {
765
+ await api.updateIssue(latestOpen.item.number, { title: issueTitle(group), body })
766
+ return decision(group, 'update', { number: latestOpen.item.number, body })
767
+ }
768
+ const latestDismissed = latestMatches.find(record => !isOpen(record.item) && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
769
+ if (latestDismissed) return decision(group, 'skip-dismissed', { number: latestDismissed.item.number })
770
+ const created = await api.createIssue({ title: issueTitle(group), body, labels: ['model-eol'] })
771
+ return decision(group, 'create', { number: created.number, body })
772
+ }
773
+
774
+ const dryDecision = (group, now, tokenKind) => group.kind === 'model'
775
+ ? decision(group, 'create', {
776
+ body: buildPullBody({ group, headSha: 'dry-run', now, tokenKind }),
777
+ branch: group.branch,
778
+ })
779
+ : decision(group, 'create', { body: buildIssueBody({ group, now }) })
780
+
781
+ const reportOnlyDecision = (group, now, tokenKind) => {
782
+ const result = dryDecision(group, now, tokenKind)
783
+ return { ...result, action: 'report-only' }
784
+ }
785
+
786
+ const prepareScan = ({ targetDir, source, dryRun, gitAuth }) => {
787
+ if (!dryRun || !String(targetDir).startsWith('file://')) return { path: targetDir, cleanup: () => {} }
788
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-scan-'))
789
+ const clone = path.join(root, 'repo')
790
+ cloneRepository(source, clone, gitAuth)
791
+ return { path: clone, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
792
+ }
793
+
794
+ const configFileFor = ({ targetDir, configPath, scanPath }) => {
795
+ if (configPath) return asAbsolute(configPath)
796
+ if (!String(targetDir).startsWith('file://')) {
797
+ const targetConfig = path.join(path.resolve(targetDir), '.model-eol.json')
798
+ if (fs.existsSync(targetConfig)) return targetConfig
799
+ }
800
+ const candidate = path.join(scanPath, '.model-eol.json')
801
+ return candidate
802
+ }
803
+
804
+ export const formatDecisions = (decisions, { degraded = false } = {}) => {
805
+ const lines = ['model-eol decision table']
806
+ if (degraded) lines.push('model-eol status: degraded - vendored feed fallback enabled; no GitHub writes')
807
+ for (const record of decisions) {
808
+ const group = record.group
809
+ const label = group.kind === 'model' ? `${group.publisher}/${group.id}` : `${group.publisher}/${group.subject}`
810
+ lines.push(`- ${record.action} ${label}`)
811
+ if (record.branch) lines.push(` branch: ${record.branch}`)
812
+ if (record.body) lines.push(' body:', record.body.split('\n').map(line => ` ${line}`).join('\n'))
813
+ }
814
+ return lines.join('\n')
815
+ }
816
+
817
+ export const runBot = async ({
818
+ repo,
819
+ targetDir = process.cwd(),
820
+ configPath = null,
821
+ dryRun = false,
822
+ evalEnabled = false,
823
+ feedsUrls = [],
824
+ token = process.env.GITHUB_TOKEN,
825
+ apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com',
826
+ tokenKind = process.env.MODEL_EOL_TOKEN_KIND || null,
827
+ transport = globalThis.fetch,
828
+ fetchImpl = globalThis.fetch,
829
+ checkerPath = CHECKER,
830
+ vendoredFeeds = VENDORED_FEEDS,
831
+ planFile = null,
832
+ evalReportFile = null,
833
+ evalStatusFile = null,
834
+ now = new Date(),
835
+ warn = message => console.error(message),
836
+ } = {}) => {
837
+ if (!dryRun) {
838
+ validateRepo(repo)
839
+ if (!token) throw new Error('GITHUB_TOKEN is required unless --dry-run is used')
840
+ }
841
+ const targetIsUrl = String(targetDir).startsWith('file://')
842
+ const targetPath = targetIsUrl ? targetDir : path.resolve(targetDir)
843
+ const source = targetIsUrl ? targetDir : originFor(targetPath)
844
+ const gitAuth = gitAuthentication(source, token, apiUrl)
845
+ let scan = null
846
+ let baseClone = null
847
+ let ownedBaseRoot = null
848
+ let feedSet = null
849
+ try {
850
+ if (dryRun) {
851
+ scan = prepareScan({ targetDir: targetPath, source, dryRun, gitAuth })
852
+ const configFile = configFileFor({ targetDir, configPath, scanPath: scan.path })
853
+ if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
854
+ const config = loadConfig(configFile)
855
+ const planConfigPath = fs.existsSync(configFile) ? configFile : null
856
+ feedSet = await downloadFeeds({
857
+ urls: feedsUrls,
858
+ fetchImpl,
859
+ vendoredDir: vendoredFeeds,
860
+ allowVendoredFallback: config.feeds.allow_vendored_fallback,
861
+ warn,
862
+ })
863
+ const generatedPlan = runPlan({ workDir: scan.path, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
864
+ const plan = verifiedPlanArtifact(planFile, generatedPlan)
865
+ const records = feedContext(feedSet.dir)
866
+ const models = buildModelGroups(plan, config, scan.path, records)
867
+ const issues = config.issues.enabled ? buildIssueGroups(plan, config, scan.path, records) : []
868
+ const report = group => feedSet.degraded ? reportOnlyDecision(group, now, tokenKind) : dryDecision(group, now, tokenKind)
869
+ const decisions = [...models.map(report), ...issues.map(report)]
870
+ return { plan, config, decisions, feedsDir: feedSet.dir, degraded: Boolean(feedSet.degraded) }
871
+ }
872
+
873
+ ownedBaseRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-base-'))
874
+ baseClone = path.join(ownedBaseRoot, 'repo')
875
+ cloneRepository(source, baseClone, gitAuth)
876
+ const base = defaultBranch(baseClone, gitAuth)
877
+ const configFile = configFileFor({ targetDir, configPath, scanPath: baseClone })
878
+ if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
879
+ const config = loadConfig(configFile)
880
+ const planConfigPath = fs.existsSync(configFile) ? configFile : null
881
+ feedSet = await downloadFeeds({
882
+ urls: feedsUrls,
883
+ fetchImpl,
884
+ vendoredDir: vendoredFeeds,
885
+ allowVendoredFallback: config.feeds.allow_vendored_fallback,
886
+ warn,
887
+ })
888
+ const generatedPlan = runPlan({ workDir: baseClone, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
889
+ const plan = verifiedPlanArtifact(planFile, generatedPlan)
890
+ const records = feedContext(feedSet.dir)
891
+ const models = buildModelGroups(plan, config, baseClone, records)
892
+ const issues = config.issues.enabled ? buildIssueGroups(plan, config, baseClone, records) : []
893
+ if (feedSet.degraded) {
894
+ const report = group => reportOnlyDecision(group, now, tokenKind)
895
+ const decisions = [...models.map(report), ...issues.map(report)]
896
+ return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
897
+ }
898
+ const externalEval = readEvalArtifact(
899
+ evalReportFile ? asAbsolute(evalReportFile) : null,
900
+ config.eval.max_report_bytes,
901
+ evalStatusFile ? asAbsolute(evalStatusFile) : null,
902
+ )
903
+ const api = new GitHubClient({ repo, apiUrl, token, transport, warn })
904
+ if (models.length || issues.length) await api.ensureModelEolLabel()
905
+ const pulls = models.length ? await api.listPulls() : []
906
+ const issueRecords = issues.length ? await api.listIssues() : []
907
+ const decisions = []
908
+ for (const group of models) {
909
+ decisions.push(await processModel({
910
+ api,
911
+ pulls,
912
+ group,
913
+ source,
914
+ base,
915
+ plan,
916
+ config,
917
+ checkerPath,
918
+ evalEnabled,
919
+ externalEval,
920
+ root: baseClone,
921
+ now,
922
+ tokenKind,
923
+ warn,
924
+ gitAuth,
925
+ }))
926
+ }
927
+ for (const group of issues) decisions.push(await processIssue({ api, issues: issueRecords, group, now }))
928
+ return { plan, config, decisions, feedsDir: feedSet.dir, degraded: false }
929
+ } finally {
930
+ scan?.cleanup()
931
+ if (ownedBaseRoot) fs.rmSync(ownedBaseRoot, { recursive: true, force: true })
932
+ feedSet?.cleanup()
933
+ }
934
+ }
935
+
936
+ export const main = async (argv = process.argv.slice(2), env = process.env) => {
937
+ const options = parseArgs(argv)
938
+ if (options.help) {
939
+ console.log(helpText())
940
+ return 0
941
+ }
942
+ const result = await runBot({
943
+ repo: options.repo || env.GITHUB_REPOSITORY,
944
+ targetDir: options.targetDir,
945
+ configPath: options.configPath,
946
+ dryRun: options.dryRun,
947
+ evalEnabled: options.evalEnabled,
948
+ feedsUrls: options.feedsUrls,
949
+ planFile: env.MODEL_EOL_PLAN_FILE || null,
950
+ evalReportFile: env.MODEL_EOL_EVAL_REPORT_FILE || null,
951
+ evalStatusFile: env.MODEL_EOL_EVAL_STATUS_FILE || null,
952
+ token: env.GITHUB_TOKEN,
953
+ apiUrl: env.GITHUB_API_URL || 'https://api.github.com',
954
+ tokenKind: env.MODEL_EOL_TOKEN_KIND || null,
955
+ })
956
+ if (options.dryRun) console.log(formatDecisions(result.decisions, { degraded: result.degraded }))
957
+ else {
958
+ if (result.degraded) console.log('model-eol status: degraded - vendored feed fallback enabled; no GitHub writes')
959
+ for (const item of result.decisions) console.log(`${item.action} ${item.group.publisher}/${item.group.kind === 'model' ? item.group.id : item.group.subject}`)
960
+ }
961
+ return 0
962
+ }
963
+
964
+ const invokedFile = (() => {
965
+ if (!process.argv[1]) return null
966
+ try {
967
+ return fs.realpathSync(process.argv[1])
968
+ } catch {
969
+ return path.resolve(process.argv[1])
970
+ }
971
+ })()
972
+
973
+ if (invokedFile === fs.realpathSync(fileURLToPath(import.meta.url))) {
974
+ main().then(code => process.exit(code)).catch(error => {
975
+ console.error(`model-eol bot: ${error.message}`)
976
+ process.exit(2)
977
+ })
978
+ }