model-eol 0.2.4 → 0.4.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,1371 @@
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
+ stableJson,
24
+ } from './lib/common.mjs'
25
+ import { validatePlanItems } from '../lib/apply.mjs'
26
+ import { parseCliArgs } from '../lib/cli.mjs'
27
+ import { loadConfig } from './lib/config.mjs'
28
+ import { downloadFeeds } from './lib/feeds.mjs'
29
+ import { capReport, readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
30
+ import { GitHubClient, hasModelEolLabel } from './lib/github.mjs'
31
+ import {
32
+ cloneRepository,
33
+ commitAll,
34
+ configureIdentity,
35
+ defaultBranch,
36
+ gitAuthentication,
37
+ originFor,
38
+ prepareBranch,
39
+ pushBranch,
40
+ verifyBotBranch,
41
+ } from './lib/git.mjs'
42
+
43
+ const ROOT = path.resolve(import.meta.dirname, '..')
44
+ const CHECKER = path.join(ROOT, 'check.mjs')
45
+ const VENDORED_FEEDS = path.join(ROOT, 'feeds')
46
+ const PLAN_SCHEMA = 'model-eol.plan/0.1'
47
+ const BOT_SCHEMA = 'model-eol.bot/0.1'
48
+ const EVAL_SCHEMA = 'model-eol.eval/0.1'
49
+ const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
50
+ const ISSUE_REASONS = new Set([
51
+ 'not-direct-api',
52
+ 'no-replacement',
53
+ 'replacement-choice',
54
+ 'replacement-unresolved',
55
+ 'replacement-retiring',
56
+ 'shutdown-date-unavailable',
57
+ 'publisher-fallback',
58
+ 'unresolved-channel',
59
+ ])
60
+ const ACTIONABLE_STATUSES = new Set(['retired', 'retiring'])
61
+
62
+ const asAbsolute = value => path.resolve(process.cwd(), value)
63
+
64
+ export const parseArgs = argv => {
65
+ const { values } = parseCliArgs({
66
+ args: argv,
67
+ options: {
68
+ repo: { type: 'string' },
69
+ 'target-dir': { type: 'string' },
70
+ config: { type: 'string' },
71
+ 'dry-run': { type: 'boolean' },
72
+ eval: { type: 'boolean' },
73
+ 'feeds-url': { type: 'string', multiple: true },
74
+ help: { type: 'boolean', short: 'h' },
75
+ },
76
+ help: 'node bot/bot.mjs --help',
77
+ })
78
+ const options = {
79
+ repo: values.repo ?? null,
80
+ targetDir: values['target-dir'] ?? process.cwd(),
81
+ configPath: values.config ?? null,
82
+ dryRun: values['dry-run'] ?? false,
83
+ evalEnabled: values.eval ?? false,
84
+ feedsUrls: [],
85
+ help: values.help ?? false,
86
+ }
87
+ for (const value of values['feeds-url'] ?? []) {
88
+ options.feedsUrls.push(...value.split(',').map(item => item.trim()).filter(Boolean))
89
+ }
90
+ return options
91
+ }
92
+
93
+ export const parseEvaluateArgs = argv => {
94
+ const { values } = parseCliArgs({
95
+ args: argv,
96
+ options: {
97
+ 'target-dir': { type: 'string' },
98
+ config: { type: 'string' },
99
+ 'plan-file': { type: 'string' },
100
+ 'output-file': { type: 'string' },
101
+ 'feeds-url': { type: 'string', multiple: true },
102
+ help: { type: 'boolean', short: 'h' },
103
+ },
104
+ help: 'model-eol-bot evaluate --help',
105
+ })
106
+ const feedsUrls = []
107
+ for (const value of values['feeds-url'] ?? []) {
108
+ feedsUrls.push(...value.split(',').map(item => item.trim()).filter(Boolean))
109
+ }
110
+ return {
111
+ targetDir: values['target-dir'] ?? process.cwd(),
112
+ configPath: values.config ?? null,
113
+ planFile: values['plan-file'] ?? null,
114
+ outputFile: values['output-file'] ?? null,
115
+ feedsUrls,
116
+ help: values.help ?? false,
117
+ }
118
+ }
119
+
120
+ export const helpText = () => `model-eol bot
121
+
122
+ Usage:
123
+ node bot/bot.mjs [--repo owner/name] [--target-dir PATH] [--config PATH] [--dry-run] [--eval] [--feeds-url URL[,URL...]]
124
+ node bot/bot.mjs evaluate --target-dir PATH --plan-file PATH --output-file PATH [--config PATH] [--feeds-url URL[,URL...]]
125
+
126
+ Environment:
127
+ GITHUB_TOKEN Required unless --dry-run; authenticates the API and HTTPS Git remotes.
128
+ GITHUB_API_URL GitHub API root, default https://api.github.com.
129
+ MODEL_EOL_TOKEN_KIND Set to github-token to add the checks warning to PRs.
130
+ MODEL_EOL_EVAL_COMMAND Optional command override used by both evaluate and publish.
131
+ `
132
+
133
+ const validateRepo = repo => {
134
+ if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error('--repo must be owner/name')
135
+ return repo
136
+ }
137
+
138
+ const spawnPlan = ({ workDir, config, configPath = null, feedsDir, checkerPath = CHECKER, warn = console.error }) => {
139
+ const args = [checkerPath, 'plan', '.']
140
+ if (configPath) args.push('--config', configPath)
141
+ else {
142
+ args.push('--days', String(config.days), '--scope', config.scope)
143
+ if (config.via) args.push('--via', config.via)
144
+ }
145
+ if (feedsDir) args.push('--feeds', feedsDir)
146
+ const result = spawnSync(process.execPath, args, {
147
+ cwd: workDir,
148
+ encoding: 'utf8',
149
+ stdio: ['ignore', 'pipe', 'pipe'],
150
+ })
151
+ if (result.stderr?.trim()) warn(result.stderr.trim())
152
+ if (result.error || result.status !== 0) {
153
+ throw new Error(`plan subprocess failed: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}`)
154
+ }
155
+ let plan
156
+ try {
157
+ plan = JSON.parse(result.stdout)
158
+ } catch (error) {
159
+ throw new Error(`plan subprocess did not emit JSON: ${error.message}`)
160
+ }
161
+ return validatePlan(plan)
162
+ }
163
+
164
+ export const runPlan = spawnPlan
165
+
166
+ const validatePlan = plan => {
167
+ if (plan?.plan_schema !== PLAN_SCHEMA) {
168
+ throw new Error(`refusing plan schema ${plan?.plan_schema ?? 'missing'}; expected ${PLAN_SCHEMA}`)
169
+ }
170
+ if (!Array.isArray(plan.items) || !Array.isArray(plan.issues)) {
171
+ throw new Error('refusing malformed plan document: items and issues arrays are required')
172
+ }
173
+ try {
174
+ validatePlanItems(plan.items)
175
+ } catch (error) {
176
+ throw new Error(`refusing malformed plan document: ${error.message}`)
177
+ }
178
+ return plan
179
+ }
180
+
181
+ const readPlanFile = file => {
182
+ let plan
183
+ try {
184
+ plan = JSON.parse(fs.readFileSync(file, 'utf8'))
185
+ } catch (error) {
186
+ throw new Error(`could not read plan artifact ${file}: ${error.message}`)
187
+ }
188
+ return validatePlan(plan)
189
+ }
190
+
191
+ const verifiedPlanArtifact = (file, generatedPlan) => {
192
+ if (!file) return generatedPlan
193
+ const artifact = readPlanFile(asAbsolute(file))
194
+ const { generated: artifactGenerated, ...artifactStable } = artifact
195
+ const { generated: regeneratedGenerated, ...regeneratedStable } = generatedPlan
196
+ if (!isDeepStrictEqual(artifactStable, regeneratedStable)) {
197
+ throw new Error('refusing plan artifact: it does not match the independently regenerated plan')
198
+ }
199
+ return artifact
200
+ }
201
+
202
+ const readEvalArtifact = (file, maxBytes, statusFile = null) => {
203
+ if (!file && !statusFile) return null
204
+ let exitCode = null
205
+ let statusError = null
206
+ if (!statusFile) {
207
+ statusError = 'eval status artifact is missing'
208
+ } else {
209
+ try {
210
+ const statusArtifact = readReportCapped(statusFile, 1024)
211
+ if (statusArtifact.missing) statusError = 'eval status artifact is missing'
212
+ else {
213
+ const statusText = statusArtifact.report.trim()
214
+ if (!/^-?\d+$/.test(statusText)) statusError = 'eval status artifact is malformed'
215
+ else {
216
+ const parsed = Number(statusText)
217
+ if (!Number.isSafeInteger(parsed)) statusError = 'eval status artifact is outside the safe integer range'
218
+ else exitCode = parsed
219
+ }
220
+ }
221
+ } catch (error) {
222
+ statusError = `eval status artifact is unreadable: ${error.message}`
223
+ }
224
+ }
225
+ let artifact
226
+ try {
227
+ artifact = file ? readReportCapped(file, maxBytes) : { missing: true, report: null }
228
+ } catch (error) {
229
+ return {
230
+ status: 'fail',
231
+ exit_code: exitCode,
232
+ report: `eval report artifact is unreadable: ${error.message}`,
233
+ }
234
+ }
235
+ if (statusError) {
236
+ return {
237
+ status: 'fail',
238
+ exit_code: exitCode,
239
+ report: artifact.report ? `${statusError}; report: ${artifact.report}` : statusError,
240
+ }
241
+ }
242
+ if (artifact.missing) {
243
+ return {
244
+ status: 'fail',
245
+ exit_code: exitCode,
246
+ report: exitCode === 0 ? 'eval report artifact is missing' : null,
247
+ }
248
+ }
249
+ if (artifact.report === '' && exitCode === 0) return null
250
+ return { status: exitCode === 0 ? 'pass' : 'fail', exit_code: exitCode, report: artifact.report || null }
251
+ }
252
+
253
+ const withoutGenerated = plan => {
254
+ const { generated, ...stable } = plan
255
+ return stable
256
+ }
257
+
258
+ const planDigest = plan => sha256(stableJson(withoutGenerated(plan)))
259
+
260
+ const effectiveEval = (config, commandOverride = null) => ({
261
+ ...config.eval,
262
+ command: typeof commandOverride === 'string' && commandOverride.trim() ? commandOverride : config.eval.command,
263
+ })
264
+
265
+ const evalConfigDigest = settings => sha256(stableJson({
266
+ command: settings.command ?? null,
267
+ timeout_ms: settings.timeout_ms,
268
+ max_report_bytes: settings.max_report_bytes,
269
+ pass_env: settings.pass_env,
270
+ }))
271
+
272
+ const evalIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
273
+
274
+ const readJsonArtifact = (file, maxBytes, label) => {
275
+ const absolute = asAbsolute(file)
276
+ const artifact = readReportCapped(absolute, maxBytes + 1)
277
+ if (artifact.missing) throw new Error(`${label} is missing`)
278
+ if (Buffer.byteLength(artifact.report) > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes`)
279
+ try {
280
+ return JSON.parse(artifact.report)
281
+ } catch (error) {
282
+ throw new Error(`${label} is malformed JSON: ${error.message}`)
283
+ }
284
+ }
285
+
286
+ const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead }) => {
287
+ const settings = effectiveEval(config, commandOverride)
288
+ const maxBytes = Math.min(EVAL_ARTIFACT_MAX_BYTES, Math.max(1024 * 1024, groups.length * (settings.max_report_bytes + 2048)))
289
+ const artifact = readJsonArtifact(file, maxBytes, 'eval results artifact')
290
+ if (artifact?.schema !== EVAL_SCHEMA) throw new Error(`refusing eval results schema ${artifact?.schema ?? 'missing'}; expected ${EVAL_SCHEMA}`)
291
+ if (artifact.plan_digest !== planDigest(plan)) throw new Error('refusing eval results artifact: plan digest does not match the independently verified plan')
292
+ if (artifact.eval_config_digest !== evalConfigDigest(settings)) throw new Error('refusing eval results artifact: eval configuration digest does not match')
293
+ if (typeof artifact.base_sha !== 'string' || !/^[0-9a-f]{40,64}$/.test(artifact.base_sha)) throw new Error('refusing eval results artifact: evaluated base commit is malformed or missing')
294
+ if (artifact.base_sha !== baseHead) throw new Error(`refusing eval results artifact: evaluated base commit ${artifact.base_sha} does not match current default-branch head ${baseHead}`)
295
+ const configured = Boolean(settings.command)
296
+ if (artifact.configured !== configured) throw new Error('refusing eval results artifact: configured state does not match eval.command')
297
+ if (!Array.isArray(artifact.results)) throw new Error('refusing eval results artifact: results must be an array')
298
+ if (artifact.results.length !== (configured ? groups.length : 0)) {
299
+ throw new Error('refusing eval results artifact: expected exactly one result for every planned migration')
300
+ }
301
+ const expected = new Map(groups.map(group => [evalIdentity(group.publisher, group.id, group.via), group]))
302
+ const results = new Map()
303
+ for (const result of artifact.results) {
304
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('refusing eval results artifact: result must be an object')
305
+ if (typeof result.publisher !== 'string' || !result.publisher || typeof result.id !== 'string' || !result.id) throw new Error('refusing eval results artifact: result identity is malformed')
306
+ if (result.via !== null && typeof result.via !== 'string') throw new Error('refusing eval results artifact: result clock is malformed')
307
+ const key = evalIdentity(result.publisher, result.id, result.via)
308
+ const group = expected.get(key)
309
+ if (!group || results.has(key)) throw new Error('refusing eval results artifact: result identity is unexpected or duplicated')
310
+ if (result.feed_digest !== group.feedDigest) throw new Error(`refusing eval results artifact: feed digest mismatch for ${group.publisher}/${group.id}`)
311
+ if (!['pass', 'fail', 'timeout'].includes(result.status)) throw new Error(`refusing eval results artifact: invalid status for ${group.publisher}/${group.id}`)
312
+ if (result.exit_code !== null && !Number.isSafeInteger(result.exit_code)) throw new Error(`refusing eval results artifact: invalid exit code for ${group.publisher}/${group.id}`)
313
+ if (result.status === 'pass' && result.exit_code !== 0) throw new Error(`refusing eval results artifact: passing result has a non-zero exit code for ${group.publisher}/${group.id}`)
314
+ if (result.report !== null && typeof result.report !== 'string') throw new Error(`refusing eval results artifact: invalid report for ${group.publisher}/${group.id}`)
315
+ if (result.report !== null && Buffer.byteLength(result.report) > settings.max_report_bytes) throw new Error(`refusing eval results artifact: report exceeds configured cap for ${group.publisher}/${group.id}`)
316
+ results.set(key, { status: result.status, exit_code: result.exit_code, report: result.report })
317
+ }
318
+ return results
319
+ }
320
+
321
+ const feedContext = feedsDir => {
322
+ const records = new Map()
323
+ let files = []
324
+ try {
325
+ files = fs.readdirSync(feedsDir).filter(file => file.endsWith('.json')).sort()
326
+ } catch {
327
+ return records
328
+ }
329
+ for (const file of files) {
330
+ let feed
331
+ try {
332
+ feed = JSON.parse(fs.readFileSync(path.join(feedsDir, file), 'utf8'))
333
+ } catch {
334
+ continue
335
+ }
336
+ if (feed?.spec !== 'model-eol/0.1' || !Array.isArray(feed.models)) continue
337
+ for (const entry of feed.models) {
338
+ for (const key of [entry.id, ...(entry.aliases ?? [])]) {
339
+ if (typeof key === 'string') records.set(`${feed.publisher}\0${key}`, { feed, entry })
340
+ }
341
+ }
342
+ }
343
+ return records
344
+ }
345
+
346
+ export const contextFor = (records, publisher, id, via) => {
347
+ const record = records.get(`${publisher}\0${id}`)
348
+ if (!record) return { announced: null, notes: [], entry: null }
349
+ const distribution = via ? (record.entry.distributions ?? []).find(item => item.via === via) : null
350
+ return {
351
+ announced: distribution ? distribution.announced ?? null : record.entry.announced ?? null,
352
+ notes: [record.feed.note, record.entry.notes].filter(Boolean),
353
+ entry: record.entry,
354
+ }
355
+ }
356
+
357
+ const ignoredModelIds = (plan, config) => {
358
+ const ignored = new Set(config.ignore.models)
359
+ const ids = new Set()
360
+ for (const item of [...plan.items, ...plan.issues]) {
361
+ if (item.id && (ignored.has(item.id) || ignored.has(item.matched))) ids.add(item.id)
362
+ }
363
+ return ids
364
+ }
365
+
366
+ const isIgnored = (entry, root, config, ignoredIds) => {
367
+ if (entry.id && ignoredIds.has(entry.id)) return true
368
+ if (entry.id && config.ignore.models.includes(entry.id)) return true
369
+ if (entry.matched && config.ignore.models.includes(entry.matched)) return true
370
+ return matchesAnyGlob(repoPath(entry.file, root), config.ignore.paths)
371
+ }
372
+
373
+ const buildModelGroups = (plan, config, root, records) => {
374
+ const ignoredIds = ignoredModelIds(plan, config)
375
+ const groups = new Map()
376
+ for (const item of plan.items) {
377
+ if (isIgnored(item, root, config, ignoredIds)) continue
378
+ const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : plan.via
379
+ const key = `${item.publisher}\0${item.id}\0${via ?? ''}`
380
+ if (!groups.has(key)) {
381
+ groups.set(key, {
382
+ kind: 'model',
383
+ id: item.id,
384
+ publisher: item.publisher,
385
+ items: [],
386
+ via,
387
+ branch: branchFor(item.publisher, item.id, via),
388
+ context: contextFor(records, item.publisher, item.id, via),
389
+ })
390
+ }
391
+ groups.get(key).items.push(item)
392
+ }
393
+ for (const group of groups.values()) {
394
+ group.items.sort((a, b) => `${a.file}:${a.line}:${a.occurrence}`.localeCompare(`${b.file}:${b.line}:${b.occurrence}`))
395
+ group.feedDigest = itemDigest(group.items)
396
+ }
397
+ return [...groups.values()].sort((a, b) => `${a.publisher}/${a.id}`.localeCompare(`${b.publisher}/${b.id}`))
398
+ }
399
+
400
+ const buildIssueGroups = (plan, config, root, records) => {
401
+ const ignoredIds = ignoredModelIds(plan, config)
402
+ const groups = new Map()
403
+ for (const issue of plan.issues) {
404
+ if (!ISSUE_REASONS.has(issue.reason)) continue
405
+ if (issue.reason !== 'unresolved-channel' && !ACTIONABLE_STATUSES.has(issue.status)) continue
406
+ if (isIgnored(issue, root, config, ignoredIds)) continue
407
+ const channel = issue.reason === 'unresolved-channel' ? (issue.provider || issue.matched || 'unresolved-channel') : null
408
+ const subject = issue.id || channel || issue.matched || 'unresolved-model'
409
+ const via = Object.hasOwn(issue, 'requested_via') ? issue.requested_via : (issue.via ?? plan.via)
410
+ const key = `${issue.publisher || ''}\0${subject}\0${issue.shutdown ?? ''}\0${via ?? ''}\0${channel ?? ''}`
411
+ if (!groups.has(key)) {
412
+ groups.set(key, {
413
+ kind: 'issue',
414
+ id: issue.id || null,
415
+ subject,
416
+ channel,
417
+ publisher: issue.publisher || 'unknown',
418
+ shutdown: issue.shutdown ?? null,
419
+ via,
420
+ issues: [],
421
+ root,
422
+ context: issue.id ? contextFor(records, issue.publisher, issue.id, via) : { announced: null, notes: [], entry: null },
423
+ })
424
+ }
425
+ groups.get(key).issues.push(issue)
426
+ }
427
+ for (const group of groups.values()) {
428
+ group.issues.sort((a, b) => `${a.file}:${a.line}:${a.reason}`.localeCompare(`${b.file}:${b.line}:${b.reason}`))
429
+ group.feedDigest = itemDigest(group.issues)
430
+ }
431
+ return [...groups.values()].sort((a, b) => `${a.publisher}/${a.subject}/${a.shutdown ?? ''}`.localeCompare(`${b.publisher}/${b.subject}/${b.shutdown ?? ''}`))
432
+ }
433
+
434
+ const sourcesFor = group => {
435
+ const values = group.kind === 'model'
436
+ ? group.items.flatMap(item => item.sources ?? [])
437
+ : group.issues.flatMap(issue => issue.sources ?? [])
438
+ return [...new Set(values.filter(Boolean))]
439
+ }
440
+
441
+ const notesFor = group => {
442
+ const planNotes = group.kind === 'model' ? group.items.map(item => item.notes) : group.issues.map(issue => issue.notes)
443
+ const values = [
444
+ ...(planNotes.some(Boolean) ? planNotes : (group.context?.notes ?? [])),
445
+ ]
446
+ return [...new Set(values.filter(Boolean))]
447
+ }
448
+
449
+ const sourceSection = group => {
450
+ const sources = sourcesFor(group)
451
+ return sources.length
452
+ ? sources.map(source => `- ${markdownLink(source, source)}`).join('\n')
453
+ : '- No source URL was included in the plan.'
454
+ }
455
+
456
+ const notesSection = group => {
457
+ const notes = notesFor(group)
458
+ return notes.length ? notes.map(note => `- ${markdownText(note)}`).join('\n') : '- No additional feed notes.'
459
+ }
460
+
461
+ const evalSection = result => {
462
+ if (!result) return ''
463
+ const exit = result.exit_code === null ? 'no exit code' : `exit code ${result.exit_code}`
464
+ const lines = [`## Eval`, `- Result: ${result.status} (${exit}).`]
465
+ if (result.report !== null) lines.push('', '```text', reportForBody(result.report), '```')
466
+ return lines.join('\n')
467
+ }
468
+
469
+ const tokenWarning = tokenKind => tokenKind === 'github-token'
470
+ ? '\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.'
471
+ : ''
472
+
473
+ const replacementOptionsText = options => options.map(markdownCode).join(' | ')
474
+
475
+ const replacementSection = (item, now) => [
476
+ ...(item.replacement
477
+ ? [`replacement per feed as of ${now.toISOString().slice(0, 10)}: ${markdownCode(item.replacement)}. Treat as a snapshot in time, not a constant.`]
478
+ : []),
479
+ ...(item.replacement_options?.length
480
+ ? [`replacement options per feed as of ${now.toISOString().slice(0, 10)}: ${replacementOptionsText(item.replacement_options)}.`]
481
+ : []),
482
+ ...(item.replacement_note
483
+ ? [`replacement note: ${markdownText(item.replacement_note)}`]
484
+ : []),
485
+ ]
486
+
487
+ export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null }) => {
488
+ const item = group.items[0]
489
+ const announced = markdownText(group.context?.announced ?? 'not specified')
490
+ const days = daysRemaining(item.shutdown, now)
491
+ const metadata = {
492
+ schema: BOT_SCHEMA,
493
+ id: group.id,
494
+ publisher: group.publisher,
495
+ shutdown: item.shutdown,
496
+ via: group.via ?? null,
497
+ replacement: item.replacement,
498
+ replacement_options: item.replacement_options,
499
+ replacement_note: item.replacement_note,
500
+ base_sha: baseSha,
501
+ head_sha: headSha,
502
+ feed_digest: group.feedDigest,
503
+ }
504
+ const sections = [
505
+ metadataLine(metadata),
506
+ '',
507
+ '## What / when',
508
+ `- Announced: ${announced}`,
509
+ `- Shutdown: ${markdownText(item.shutdown)}`,
510
+ `- Days remaining: ${days}`,
511
+ '',
512
+ '## Replacement',
513
+ ...replacementSection(item, now),
514
+ '',
515
+ '## Sources',
516
+ sourceSection(group),
517
+ '',
518
+ '## Feed notes',
519
+ notesSection(group),
520
+ ]
521
+ if (group.via) {
522
+ sections.push('', '## Distributor clock', `This migration uses the ${markdownCode(group.via)} distributor clock; the shutdown date above is the date for that channel.`)
523
+ }
524
+ const evaluation = evalSection(evalResult)
525
+ if (evaluation) sections.push('', evaluation)
526
+ sections.push(
527
+ '',
528
+ '## Checklist',
529
+ '- [ ] Review the diff',
530
+ '- [ ] Run evals',
531
+ '- [ ] Verify params/behavior',
532
+ )
533
+ return sections.join('\n') + tokenWarning(tokenKind)
534
+ }
535
+
536
+ export const buildIssueBody = ({ group, now = new Date() }) => {
537
+ const issue = group.issues[0]
538
+ const metadata = {
539
+ schema: BOT_SCHEMA,
540
+ id: group.id || group.subject,
541
+ publisher: group.publisher,
542
+ shutdown: group.shutdown,
543
+ via: group.via ?? null,
544
+ replacement: issue.replacement ?? null,
545
+ replacement_options: issue.replacement_options,
546
+ replacement_note: issue.replacement_note,
547
+ head_sha: null,
548
+ feed_digest: group.feedDigest,
549
+ ...(group.channel ? { channel: group.channel } : {}),
550
+ }
551
+ const evidence = group.issues
552
+ .map(issue => `- ${markdownText(`${repoPath(issue.file, group.root || '.')}:${issue.line} - ${issue.reason}${issue.matched ? ` (${issue.matched})` : ''}`)}`)
553
+ .join('\n')
554
+ return [
555
+ metadataLine(metadata),
556
+ '',
557
+ '## Finding',
558
+ `- Subject: ${markdownCode(group.subject)}`,
559
+ `- Reason: ${markdownText(group.issues[0].reason)}`,
560
+ `- Status: ${markdownText(group.issues[0].status ?? 'unresolved')}`,
561
+ `- Shutdown: ${markdownText(group.shutdown ?? 'not scheduled')}`,
562
+ ...(issue.replacement
563
+ ? [`- Replacement per feed as of ${now.toISOString().slice(0, 10)}: ${markdownCode(issue.replacement)}. Treat as a snapshot in time, not a constant.`]
564
+ : []),
565
+ ...(issue.replacement_options?.length
566
+ ? [`- Replacement options per feed as of ${now.toISOString().slice(0, 10)}: ${replacementOptionsText(issue.replacement_options)}.`]
567
+ : []),
568
+ ...(issue.replacement_note
569
+ ? [`- Replacement note: ${markdownText(issue.replacement_note)}`]
570
+ : []),
571
+ `- Recorded on: ${now.toISOString().slice(0, 10)}`,
572
+ '',
573
+ '## Evidence',
574
+ evidence,
575
+ '',
576
+ '## Sources',
577
+ sourceSection(group),
578
+ '',
579
+ '## Feed notes',
580
+ notesSection(group),
581
+ ].join('\n')
582
+ }
583
+
584
+ const pullTitle = group => `model-eol: migrate ${group.id} before ${group.items[0].shutdown}`
585
+ const issueTitle = group => `model-eol: investigate ${group.subject}${group.shutdown ? ` before ${group.shutdown}` : ''}`
586
+
587
+ const isOpen = item => item.state === 'open'
588
+ const isMerged = item => Boolean(item.merged_at || item.pull_request?.merged_at || item.merged === true)
589
+
590
+ const pullFromTargetRepo = (item, repo) => !item.head?.repo?.full_name || item.head.repo.full_name === repo
591
+
592
+ const pullOnExpectedBranch = (item, group, repo) => item.head?.ref === group.branch && pullFromTargetRepo(item, repo)
593
+
594
+ const matchingPulls = (pulls, group, repo) => pulls
595
+ .filter(item => hasModelEolLabel(item) && pullOnExpectedBranch(item, group, repo))
596
+ .map(item => {
597
+ const metadata = parseMetadata(item.body)
598
+ return {
599
+ item,
600
+ metadata,
601
+ conflict: hasMetadataMarker(item.body) && (!metadata || typeof metadata.head_sha !== 'string' || !metadata.head_sha),
602
+ }
603
+ })
604
+ .filter(record => record.conflict || (
605
+ record.metadata?.id === group.id
606
+ && record.metadata?.publisher === group.publisher
607
+ && (record.metadata.via ?? null) === (group.via ?? null)
608
+ ))
609
+
610
+ const matchingIssues = (issues, group) => issues
611
+ .filter(item => hasModelEolLabel(item))
612
+ .map(item => {
613
+ const metadata = parseMetadata(item.body)
614
+ return { item, metadata, conflict: false }
615
+ })
616
+ .filter(record => {
617
+ if (!record.metadata) return false
618
+ if (record.metadata.id !== (group.id || group.subject)) return false
619
+ if (record.metadata.publisher !== group.publisher) return false
620
+ if ((record.metadata.via ?? null) !== (group.via ?? null)) return false
621
+ return !group.channel || record.metadata.channel === group.channel || (record.metadata.id === group.channel && !record.metadata.channel)
622
+ })
623
+
624
+ const modelIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
625
+ const issueIdentity = (publisher, id, via, channel = null) => `${publisher}\0${id}\0${via ?? ''}\0${channel ?? ''}`
626
+
627
+ const ownedPullRecords = (pulls, repo) => pulls
628
+ .filter(item => hasModelEolLabel(item) && pullFromTargetRepo(item, repo))
629
+ .map(item => ({ item, metadata: parseMetadata(item.body) }))
630
+ .filter(({ item, metadata }) => metadata
631
+ && typeof metadata.head_sha === 'string'
632
+ && metadata.head_sha
633
+ && item.head?.ref === branchFor(metadata.publisher, metadata.id, metadata.via ?? null))
634
+
635
+ const ownedIssueRecords = issues => issues
636
+ .filter(item => hasModelEolLabel(item))
637
+ .map(item => ({ item, metadata: parseMetadata(item.body) }))
638
+ .filter(record => record.metadata)
639
+
640
+ const staleWorkComment = kind => `model-eol is closing this bot-owned ${kind} because its finding is no longer actionable on the repository's current default branch. The reference may have been removed, ignored, retracted by the feed, moved to another clock, or disabled by repository configuration.`
641
+
642
+ const staleClosedBody = (body, metadata) => {
643
+ const lines = String(body ?? '').split(/\r?\n/)
644
+ lines[0] = metadataLine({ ...metadata, stale_closed: true })
645
+ return lines.join('\n')
646
+ }
647
+
648
+ const groupFromMetadata = (kind, metadata) => ({
649
+ kind,
650
+ id: metadata.id,
651
+ subject: metadata.id,
652
+ publisher: metadata.publisher,
653
+ via: metadata.via ?? null,
654
+ branch: kind === 'model' ? branchFor(metadata.publisher, metadata.id, metadata.via ?? null) : undefined,
655
+ })
656
+
657
+ const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) => {
658
+ const activeModels = new Set(models.map(group => modelIdentity(group.publisher, group.id, group.via)))
659
+ const activeIssues = new Set(issueGroups.flatMap(group => [
660
+ issueIdentity(group.publisher, group.id || group.subject, group.via, group.channel),
661
+ issueIdentity(group.publisher, group.id || group.subject, group.via, null),
662
+ ]))
663
+ const decisions = []
664
+ for (const record of ownedPullRecords(pulls, api.repo)) {
665
+ if (!isOpen(record.item)) continue
666
+ const key = modelIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via)
667
+ if (activeModels.has(key)) continue
668
+ await api.comment(record.item.number, staleWorkComment('pull request'))
669
+ await api.updatePull(record.item.number, {
670
+ state: 'closed',
671
+ body: staleClosedBody(record.item.body, record.metadata),
672
+ })
673
+ decisions.push(decision(groupFromMetadata('model', record.metadata), 'close-stale', { number: record.item.number }))
674
+ }
675
+ for (const record of ownedIssueRecords(issues)) {
676
+ if (!isOpen(record.item)) continue
677
+ const key = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, record.metadata.channel)
678
+ const legacyKey = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, null)
679
+ if (activeIssues.has(key) || activeIssues.has(legacyKey)) continue
680
+ await api.comment(record.item.number, staleWorkComment('issue'))
681
+ await api.updateIssue(record.item.number, {
682
+ state: 'closed',
683
+ body: staleClosedBody(record.item.body, record.metadata),
684
+ })
685
+ decisions.push(decision(groupFromMetadata('issue', record.metadata), 'close-stale', { number: record.item.number }))
686
+ }
687
+ return decisions
688
+ }
689
+
690
+ const standingDownComment = (group, metadata, currentHead) =>
691
+ `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.`
692
+
693
+ const staleBaseComment = (metadata, base) =>
694
+ `model-eol is standing down because this bot-owned PR no longer targets the expected default branch. Expected ${markdownCode(base)}, but the PR targets ${markdownCode(metadata || 'unknown')}. Please retarget or close this PR manually.`
695
+
696
+ const writeJson = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2))
697
+
698
+ const gitStatusPaths = cwd => {
699
+ const result = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
700
+ cwd,
701
+ encoding: 'utf8',
702
+ stdio: ['ignore', 'pipe', 'pipe'],
703
+ })
704
+ if (result.error || result.status !== 0) {
705
+ throw new Error(`could not inspect eval workspace: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}`)
706
+ }
707
+ return result.stdout
708
+ .split(/\r?\n/)
709
+ .filter(Boolean)
710
+ .map(line => line.slice(3).trim())
711
+ .filter(Boolean)
712
+ }
713
+
714
+ const gitHead = cwd => {
715
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], {
716
+ cwd,
717
+ encoding: 'utf8',
718
+ stdio: ['ignore', 'pipe', 'pipe'],
719
+ })
720
+ if (result.error || result.status !== 0) throw new Error(`could not inspect eval commit: ${result.error?.message || result.stderr?.trim() || `exit ${result.status}`}`)
721
+ return result.stdout.trim()
722
+ }
723
+
724
+ const hashFiles = (root, files) => new Map(files.map(file => [file, sha256(fs.readFileSync(path.join(root, file)))]))
725
+
726
+ const evalWorkspaceDrift = (root, planFiles, expectedHashes, expectedHead) => {
727
+ if (gitHead(root) !== expectedHead) return 'eval changed repository history'
728
+ for (const file of planFiles) {
729
+ let actual
730
+ try {
731
+ actual = sha256(fs.readFileSync(path.join(root, file)))
732
+ } catch (error) {
733
+ return `eval changed planned file ${file}: it is unreadable after evaluation (${error.message})`
734
+ }
735
+ if (actual !== expectedHashes.get(file)) return `eval changed planned file ${file}`
736
+ }
737
+ const unexpected = gitStatusPaths(root).filter(file => !planFiles.includes(file))
738
+ if (unexpected.length) return `eval changed unexpected repository file ${unexpected[0]}`
739
+ return null
740
+ }
741
+
742
+ const writeJsonAtomic = (file, value) => {
743
+ const absolute = asAbsolute(file)
744
+ fs.mkdirSync(path.dirname(absolute), { recursive: true })
745
+ const temporary = `${absolute}.${process.pid}.${sha256(`${Date.now()}-${absolute}`).slice(0, 8)}.tmp`
746
+ try {
747
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
748
+ fs.renameSync(temporary, absolute)
749
+ } finally {
750
+ fs.rmSync(temporary, { force: true })
751
+ }
752
+ }
753
+
754
+ export const evaluatePlan = async ({
755
+ targetDir = process.cwd(),
756
+ configPath = null,
757
+ planFile,
758
+ outputFile,
759
+ feedsUrls = [],
760
+ commandOverride = process.env.MODEL_EOL_EVAL_COMMAND || null,
761
+ fetchImpl = globalThis.fetch,
762
+ checkerPath = CHECKER,
763
+ vendoredFeeds = VENDORED_FEEDS,
764
+ warn = message => console.error(message),
765
+ now = new Date(),
766
+ } = {}) => {
767
+ if (!planFile) throw new Error('--plan-file is required for evaluate')
768
+ if (!outputFile) throw new Error('--output-file is required for evaluate')
769
+ const targetPath = path.resolve(targetDir)
770
+ const configFile = configFileFor({ targetDir: targetPath, configPath, scanPath: targetPath })
771
+ if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
772
+ const config = loadConfig(configFile)
773
+ const settings = effectiveEval(config, commandOverride)
774
+ const planConfigPath = fs.existsSync(configFile) ? configFile : null
775
+ const feedSet = await downloadFeeds({
776
+ urls: feedsUrls,
777
+ fetchImpl,
778
+ vendoredDir: vendoredFeeds,
779
+ allowVendoredFallback: config.feeds.allow_vendored_fallback,
780
+ warn,
781
+ })
782
+ try {
783
+ const generatedPlan = runPlan({ workDir: targetPath, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
784
+ const plan = verifiedPlanArtifact(planFile, generatedPlan)
785
+ const groups = buildModelGroups(plan, config, targetPath, feedContext(feedSet.dir))
786
+ const results = []
787
+ if (settings.command) {
788
+ for (const group of groups) {
789
+ const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-evaluate-'))
790
+ const clone = path.join(workRoot, 'repo')
791
+ const selectedPlanPath = path.join(workRoot, 'selected-plan.json')
792
+ const reportPath = path.join(workRoot, 'eval-report.md')
793
+ writeJson(selectedPlanPath, { ...plan, items: group.items, issues: [] })
794
+ const selectedPlanHash = sha256(fs.readFileSync(selectedPlanPath))
795
+ let result
796
+ try {
797
+ cloneRepository(targetPath, clone)
798
+ const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
799
+ cwd: clone,
800
+ encoding: 'utf8',
801
+ stdio: ['ignore', 'pipe', 'pipe'],
802
+ })
803
+ if (applied.status !== 0 || applied.error) {
804
+ throw new Error(`apply subprocess failed for ${group.publisher}/${group.id}: ${applied.error?.message || applied.stderr?.trim() || `exit ${applied.status}`}`)
805
+ }
806
+ const planFiles = [...new Set(group.items.map(item => repoPath(item.file, clone)))]
807
+ const postApplyHashes = hashFiles(clone, planFiles)
808
+ const postApplyHead = gitHead(clone)
809
+ try {
810
+ result = runEvalHook({
811
+ command: settings.command,
812
+ timeoutMs: settings.timeout_ms,
813
+ maxReportBytes: settings.max_report_bytes,
814
+ passEnv: settings.pass_env,
815
+ cwd: clone,
816
+ oldId: group.id,
817
+ newId: group.items[0].replacement,
818
+ planPath: selectedPlanPath,
819
+ reportPath,
820
+ })
821
+ } catch (error) {
822
+ result = { status: 'fail', exit_code: null, report: `eval runner error: ${error.message}` }
823
+ }
824
+ const drift = evalWorkspaceDrift(clone, planFiles, postApplyHashes, postApplyHead)
825
+ let planDrift = null
826
+ try {
827
+ if (sha256(fs.readFileSync(selectedPlanPath)) !== selectedPlanHash) planDrift = 'eval changed its selected plan artifact'
828
+ } catch (error) {
829
+ planDrift = `eval made its selected plan artifact unreadable (${error.message})`
830
+ }
831
+ if (drift || planDrift) {
832
+ result = {
833
+ ...result,
834
+ status: 'fail',
835
+ report: capReport([result.report, drift, planDrift].filter(Boolean).join('\n'), settings.max_report_bytes),
836
+ }
837
+ }
838
+ } finally {
839
+ fs.rmSync(workRoot, { recursive: true, force: true })
840
+ }
841
+ results.push({
842
+ publisher: group.publisher,
843
+ id: group.id,
844
+ via: group.via ?? null,
845
+ feed_digest: group.feedDigest,
846
+ status: result.status,
847
+ exit_code: result.exit_code,
848
+ report: capReport(result.report, settings.max_report_bytes),
849
+ })
850
+ }
851
+ }
852
+ const artifact = {
853
+ schema: EVAL_SCHEMA,
854
+ generated: now.toISOString(),
855
+ base_sha: gitHead(targetPath),
856
+ plan_digest: planDigest(plan),
857
+ eval_config_digest: evalConfigDigest(settings),
858
+ configured: Boolean(settings.command),
859
+ results,
860
+ }
861
+ writeJsonAtomic(outputFile, artifact)
862
+ return { artifact, plan, config: { ...config, eval: settings }, degraded: Boolean(feedSet.degraded) }
863
+ } finally {
864
+ feedSet.cleanup()
865
+ }
866
+ }
867
+
868
+ const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = false, group, plan, config, checkerPath, evalEnabled, root, warn, gitAuth }) => {
869
+ const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-work-'))
870
+ const planRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-plan-'))
871
+ const clone = path.join(workRoot, 'repo')
872
+ const fullPlanPath = path.join(planRoot, 'plan.json')
873
+ const selectedPlanPath = path.join(planRoot, 'selected-plan.json')
874
+ const reportPath = path.join(planRoot, 'eval-report.md')
875
+ writeJson(fullPlanPath, plan)
876
+ writeJson(selectedPlanPath, { ...plan, items: group.items, issues: [] })
877
+ try {
878
+ cloneRepository(source, clone, gitAuth)
879
+ prepareBranch(clone, branch, base, gitAuth)
880
+ const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
881
+ cwd: clone,
882
+ encoding: 'utf8',
883
+ stdio: ['ignore', 'pipe', 'pipe'],
884
+ })
885
+ if (applied.status !== 0 || applied.error) {
886
+ throw new Error(`apply subprocess failed: ${applied.error?.message || applied.stderr?.trim() || `exit ${applied.status}`}`)
887
+ }
888
+ const planFiles = [...new Set(group.items.map(item => repoPath(item.file, clone)))]
889
+ const postApplyHashes = hashFiles(clone, planFiles)
890
+ const postApplyHead = gitHead(clone)
891
+
892
+ let evalResult = null
893
+ if (evalEnabled && config.eval.command) {
894
+ try {
895
+ evalResult = runEvalHook({
896
+ command: config.eval.command,
897
+ timeoutMs: config.eval.timeout_ms,
898
+ maxReportBytes: config.eval.max_report_bytes,
899
+ passEnv: config.eval.pass_env,
900
+ cwd: clone,
901
+ oldId: group.id,
902
+ newId: group.items[0].replacement,
903
+ planPath: fullPlanPath,
904
+ reportPath,
905
+ })
906
+ } catch (error) {
907
+ warn(`model-eol: warning: eval runner failed for ${group.id}: ${error.message}`)
908
+ evalResult = { status: 'fail', exit_code: null, report: `eval runner error: ${error.message}` }
909
+ }
910
+ }
911
+ if (evalResult) {
912
+ const drift = evalWorkspaceDrift(clone, planFiles, postApplyHashes, postApplyHead)
913
+ if (drift) {
914
+ evalResult = {
915
+ ...evalResult,
916
+ status: 'fail',
917
+ report: capReport([evalResult.report, drift].filter(Boolean).join('\n'), config.eval.max_report_bytes),
918
+ }
919
+ }
920
+ }
921
+ if (evalResult && evalResult.status !== 'pass') {
922
+ const error = new Error(`eval hook did not pass: ${evalResult.status}`)
923
+ error.code = 'MODEL_EOL_EVAL_FAILED'
924
+ error.evalResult = evalResult
925
+ throw error
926
+ }
927
+
928
+ configureIdentity(clone)
929
+ const files = planFiles
930
+ const message = `model-eol: migrate ${group.id} to ${group.items[0].replacement} (${group.feedDigest.slice(0, 8)})`
931
+ const headSha = commitAll(clone, files, message)
932
+ pushBranch(clone, branch, expectedHead, gitAuth, { allowMissing: allowMissingBranch })
933
+ return { headSha, evalResult }
934
+ } finally {
935
+ fs.rmSync(workRoot, { recursive: true, force: true })
936
+ fs.rmSync(planRoot, { recursive: true, force: true })
937
+ }
938
+ }
939
+
940
+ const decision = (group, action, extra = {}) => ({ group, action, ...extra })
941
+
942
+ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, config, checkerPath, evalEnabled, externalEval, root, now, tokenKind, warn, gitAuth }) => {
943
+ const matches = matchingPulls(pulls, group, api.repo)
944
+ const conflict = matches.find(record => record.conflict)
945
+ if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
946
+ const open = matches.find(record => isOpen(record.item))
947
+ const currentDigest = group.feedDigest
948
+ if (open) {
949
+ if (open.item.base?.ref && open.item.base.ref !== base) {
950
+ await api.comment(open.item.number, staleBaseComment(open.item.base.ref, base))
951
+ return decision(group, 'stand-down', { number: open.item.number })
952
+ }
953
+ const lease = verifyBotBranch(root, open.item.head?.ref || group.branch, open.metadata.head_sha, gitAuth)
954
+ if (!lease.safe) {
955
+ await api.comment(open.item.number, standingDownComment(group, open.metadata, lease.head))
956
+ return decision(group, 'stand-down', { number: open.item.number })
957
+ }
958
+ if (externalEval && externalEval.status !== 'pass') {
959
+ return decision(group, 'eval-failed', { number: open.item.number, evalResult: externalEval })
960
+ }
961
+ if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead) {
962
+ return decision(group, 'skip-unchanged', { number: open.item.number })
963
+ }
964
+ let patch
965
+ try {
966
+ patch = makePatch({
967
+ source,
968
+ base,
969
+ branch: open.item.head?.ref || group.branch,
970
+ expectedHead: open.metadata.head_sha,
971
+ group,
972
+ plan,
973
+ config,
974
+ checkerPath,
975
+ evalEnabled,
976
+ root,
977
+ warn,
978
+ gitAuth,
979
+ })
980
+ } catch (error) {
981
+ if (error.code === 'MODEL_EOL_EVAL_FAILED') {
982
+ return decision(group, 'eval-failed', { number: open.item.number, evalResult: error.evalResult })
983
+ }
984
+ if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
985
+ await api.comment(open.item.number, standingDownComment(group, open.metadata, error.currentHead))
986
+ return decision(group, 'stand-down', { number: open.item.number })
987
+ }
988
+ if (patch.evalResult && patch.evalResult.status !== 'pass') {
989
+ return decision(group, 'eval-failed', { number: open.item.number, evalResult: patch.evalResult })
990
+ }
991
+ if (!patch.evalResult && externalEval) patch.evalResult = externalEval
992
+ const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: patch.evalResult })
993
+ await api.updatePull(open.item.number, { title: pullTitle(group), body })
994
+ return decision(group, 'update', { number: open.item.number, body, headSha: patch.headSha })
995
+ }
996
+
997
+ if (externalEval && externalEval.status !== 'pass') {
998
+ return decision(group, 'eval-failed', { evalResult: externalEval })
999
+ }
1000
+
1001
+ const replacement = group.items[0].replacement
1002
+ const dismissed = matches.find(record => !isOpen(record.item) && !isMerged(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1003
+ if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
1004
+
1005
+ const previous = matches.find(record => record.metadata?.head_sha) ?? null
1006
+ const previousBotHead = previous?.metadata.head_sha ?? null
1007
+ if (!previousBotHead) {
1008
+ const occupied = verifyBotBranch(root, group.branch, null, gitAuth)
1009
+ if (occupied.error) throw new Error(occupied.error)
1010
+ if (occupied.head) {
1011
+ const occupant = pulls.find(item => pullOnExpectedBranch(item, group, api.repo))
1012
+ return decision(group, 'conflict', { number: occupant?.number })
1013
+ }
1014
+ }
1015
+ let patch
1016
+ try {
1017
+ patch = makePatch({
1018
+ source,
1019
+ base,
1020
+ branch: group.branch,
1021
+ expectedHead: previousBotHead,
1022
+ allowMissingBranch: Boolean(previousBotHead && !isOpen(previous.item)),
1023
+ group,
1024
+ plan,
1025
+ config,
1026
+ checkerPath,
1027
+ evalEnabled,
1028
+ root,
1029
+ warn,
1030
+ gitAuth,
1031
+ })
1032
+ } catch (error) {
1033
+ if (error.code === 'MODEL_EOL_EVAL_FAILED') {
1034
+ return decision(group, 'eval-failed', { evalResult: error.evalResult })
1035
+ }
1036
+ if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
1037
+ const prior = matches.find(record => record.metadata?.head_sha === previousBotHead)
1038
+ if (prior) await api.comment(prior.item.number, standingDownComment(group, prior.metadata, error.currentHead))
1039
+ return decision(group, 'stand-down', { number: prior?.item.number })
1040
+ }
1041
+ if (patch.evalResult && patch.evalResult.status !== 'pass') {
1042
+ return decision(group, 'eval-failed', { evalResult: patch.evalResult })
1043
+ }
1044
+ if (!patch.evalResult && externalEval) patch.evalResult = externalEval
1045
+ const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: patch.evalResult })
1046
+ const latestPulls = await api.listPullsByHead(group.branch)
1047
+ const latestMatches = matchingPulls(latestPulls, group, api.repo)
1048
+ const latestConflict = latestMatches.find(record => record.conflict)
1049
+ if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
1050
+ const latestOpen = latestMatches.find(record => isOpen(record.item))
1051
+ if (latestOpen) {
1052
+ await api.updatePull(latestOpen.item.number, { title: pullTitle(group), body })
1053
+ return decision(group, 'update', { number: latestOpen.item.number, body, headSha: patch.headSha })
1054
+ }
1055
+ const latestUntrusted = latestPulls.find(item => pullOnExpectedBranch(item, group, api.repo) && !latestMatches.some(record => record.item.number === item.number))
1056
+ if (latestUntrusted) return decision(group, 'conflict', { number: latestUntrusted.number })
1057
+ const latestSuppressed = latestMatches.find(record => !isOpen(record.item) && !isMerged(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.items[0].shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1058
+ if (latestSuppressed) return decision(group, 'skip-dismissed', { number: latestSuppressed.item.number })
1059
+ const created = await api.createPull({
1060
+ title: pullTitle(group),
1061
+ head: group.branch,
1062
+ base,
1063
+ body,
1064
+ })
1065
+ return decision(group, 'create', { number: created.number, body, headSha: patch.headSha })
1066
+ }
1067
+
1068
+ const processIssue = async ({ api, issues, group, now }) => {
1069
+ const matches = matchingIssues(issues, group)
1070
+ const conflict = matches.find(record => record.conflict)
1071
+ if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
1072
+ const open = matches.find(record => isOpen(record.item))
1073
+ const body = buildIssueBody({ group, now })
1074
+ if (open) {
1075
+ if (open.metadata.feed_digest === group.feedDigest) return decision(group, 'skip-unchanged', { number: open.item.number, body })
1076
+ await api.updateIssue(open.item.number, { title: issueTitle(group), body })
1077
+ return decision(group, 'update', { number: open.item.number, body })
1078
+ }
1079
+ const replacement = group.issues[0].replacement ?? null
1080
+ const dismissed = matches.find(record => !isOpen(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1081
+ if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
1082
+ const latestMatches = matchingIssues(await api.listIssues(), group)
1083
+ const latestConflict = latestMatches.find(record => record.conflict)
1084
+ if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
1085
+ const latestOpen = latestMatches.find(record => isOpen(record.item))
1086
+ if (latestOpen) {
1087
+ await api.updateIssue(latestOpen.item.number, { title: issueTitle(group), body })
1088
+ return decision(group, 'update', { number: latestOpen.item.number, body })
1089
+ }
1090
+ const latestDismissed = latestMatches.find(record => !isOpen(record.item) && record.metadata.stale_closed !== true && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
1091
+ if (latestDismissed) return decision(group, 'skip-dismissed', { number: latestDismissed.item.number })
1092
+ const created = await api.createIssue({ title: issueTitle(group), body, labels: ['model-eol'] })
1093
+ return decision(group, 'create', { number: created.number, body })
1094
+ }
1095
+
1096
+ const dryDecision = (group, now, tokenKind) => group.kind === 'model'
1097
+ ? decision(group, 'create', {
1098
+ body: buildPullBody({ group, headSha: 'dry-run', now, tokenKind }),
1099
+ branch: group.branch,
1100
+ })
1101
+ : decision(group, 'create', { body: buildIssueBody({ group, now }) })
1102
+
1103
+ const reportOnlyDecision = (group, now, tokenKind) => {
1104
+ const result = dryDecision(group, now, tokenKind)
1105
+ return { ...result, action: 'report-only' }
1106
+ }
1107
+
1108
+ const prepareScan = ({ targetDir, source, dryRun, gitAuth }) => {
1109
+ if (!dryRun || !String(targetDir).startsWith('file://')) return { path: targetDir, cleanup: () => {} }
1110
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-scan-'))
1111
+ const clone = path.join(root, 'repo')
1112
+ cloneRepository(source, clone, gitAuth)
1113
+ return { path: clone, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
1114
+ }
1115
+
1116
+ const configFileFor = ({ targetDir, configPath, scanPath }) => {
1117
+ if (configPath) return asAbsolute(configPath)
1118
+ if (!String(targetDir).startsWith('file://')) {
1119
+ const targetConfig = path.join(path.resolve(targetDir), '.model-eol.json')
1120
+ if (fs.existsSync(targetConfig)) return targetConfig
1121
+ }
1122
+ const candidate = path.join(scanPath, '.model-eol.json')
1123
+ return candidate
1124
+ }
1125
+
1126
+ export const formatDecisions = (decisions, { degraded = false } = {}) => {
1127
+ const lines = ['model-eol decision table']
1128
+ if (degraded) lines.push('model-eol status: degraded - vendored feed fallback enabled; no GitHub writes')
1129
+ for (const record of decisions) {
1130
+ const group = record.group
1131
+ const label = group.kind === 'model' ? `${group.publisher}/${group.id}` : `${group.publisher}/${group.subject}`
1132
+ lines.push(`- ${record.action} ${label}`)
1133
+ if (record.branch) lines.push(` branch: ${record.branch}`)
1134
+ if (record.body) lines.push(' body:', record.body.split('\n').map(line => ` ${line}`).join('\n'))
1135
+ }
1136
+ return lines.join('\n')
1137
+ }
1138
+
1139
+ const BLOCKED_ACTIONS = new Set(['conflict', 'eval-failed', 'label-unavailable', 'report-only', 'stand-down'])
1140
+
1141
+ const appendDecisionSummary = (file, decisions, { degraded = false } = {}) => {
1142
+ if (!file) return
1143
+ const blocked = degraded || decisions.some(record => BLOCKED_ACTIONS.has(record.action))
1144
+ const lines = [
1145
+ '## model-eol bot',
1146
+ '',
1147
+ `Outcome: **${blocked ? 'blocked' : 'success'}**${degraded ? ' (vendored feed fallback; no writes)' : ''}`,
1148
+ '',
1149
+ '| Action | Finding |',
1150
+ '| --- | --- |',
1151
+ ]
1152
+ for (const record of decisions) {
1153
+ const group = record.group
1154
+ const label = group.kind === 'model' ? `${group.publisher}/${group.id}` : `${group.publisher}/${group.subject}`
1155
+ lines.push(`| ${markdownText(record.action)} | ${markdownText(label)} |`)
1156
+ }
1157
+ if (!decisions.length) lines.push('| none | No actionable findings |')
1158
+ fs.appendFileSync(file, `${lines.join('\n')}\n`)
1159
+ }
1160
+
1161
+ const appendEvalSummary = (file, artifact) => {
1162
+ if (!file) return
1163
+ const lines = [
1164
+ '## model-eol migration evals',
1165
+ '',
1166
+ artifact.configured ? 'Each migration was applied and evaluated in an isolated checkout.' : 'No eval command is configured.',
1167
+ '',
1168
+ '| Result | Migration |',
1169
+ '| --- | --- |',
1170
+ ]
1171
+ for (const result of artifact.results) lines.push(`| ${markdownText(result.status)} | ${markdownText(`${result.publisher}/${result.id}`)} |`)
1172
+ if (!artifact.results.length) lines.push('| not run | No configured migration evals |')
1173
+ fs.appendFileSync(file, `${lines.join('\n')}\n`)
1174
+ }
1175
+
1176
+ export const runBot = async ({
1177
+ repo,
1178
+ targetDir = process.cwd(),
1179
+ configPath = null,
1180
+ dryRun = false,
1181
+ evalEnabled = false,
1182
+ feedsUrls = [],
1183
+ token = process.env.GITHUB_TOKEN,
1184
+ apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com',
1185
+ tokenKind = process.env.MODEL_EOL_TOKEN_KIND || null,
1186
+ transport = globalThis.fetch,
1187
+ fetchImpl = globalThis.fetch,
1188
+ checkerPath = CHECKER,
1189
+ vendoredFeeds = VENDORED_FEEDS,
1190
+ planFile = null,
1191
+ evalReportFile = null,
1192
+ evalStatusFile = null,
1193
+ evalResultsFile = null,
1194
+ evalCommandOverride = null,
1195
+ now = new Date(),
1196
+ warn = message => console.error(message),
1197
+ } = {}) => {
1198
+ if (!dryRun) {
1199
+ validateRepo(repo)
1200
+ if (!token) throw new Error('GITHUB_TOKEN is required unless --dry-run is used')
1201
+ }
1202
+ const targetIsUrl = String(targetDir).startsWith('file://')
1203
+ const targetPath = targetIsUrl ? targetDir : path.resolve(targetDir)
1204
+ const source = targetIsUrl ? targetDir : originFor(targetPath)
1205
+ const gitAuth = gitAuthentication(source, token, apiUrl)
1206
+ let scan = null
1207
+ let baseClone = null
1208
+ let ownedBaseRoot = null
1209
+ let feedSet = null
1210
+ try {
1211
+ if (dryRun) {
1212
+ scan = prepareScan({ targetDir: targetPath, source, dryRun, gitAuth })
1213
+ const configFile = configFileFor({ targetDir, configPath, scanPath: scan.path })
1214
+ if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
1215
+ const config = loadConfig(configFile)
1216
+ const planConfigPath = fs.existsSync(configFile) ? configFile : null
1217
+ feedSet = await downloadFeeds({
1218
+ urls: feedsUrls,
1219
+ fetchImpl,
1220
+ vendoredDir: vendoredFeeds,
1221
+ allowVendoredFallback: config.feeds.allow_vendored_fallback,
1222
+ warn,
1223
+ })
1224
+ const generatedPlan = runPlan({ workDir: scan.path, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
1225
+ const plan = verifiedPlanArtifact(planFile, generatedPlan)
1226
+ const records = feedContext(feedSet.dir)
1227
+ const models = buildModelGroups(plan, config, scan.path, records)
1228
+ const issues = config.issues.enabled ? buildIssueGroups(plan, config, scan.path, records) : []
1229
+ const report = group => feedSet.degraded ? reportOnlyDecision(group, now, tokenKind) : dryDecision(group, now, tokenKind)
1230
+ const decisions = [...models.map(report), ...issues.map(report)]
1231
+ return { plan, config, decisions, feedsDir: feedSet.dir, degraded: Boolean(feedSet.degraded) }
1232
+ }
1233
+
1234
+ ownedBaseRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-base-'))
1235
+ baseClone = path.join(ownedBaseRoot, 'repo')
1236
+ cloneRepository(source, baseClone, gitAuth)
1237
+ const base = defaultBranch(baseClone, gitAuth)
1238
+ const baseHead = gitHead(baseClone)
1239
+ const configFile = configFileFor({ targetDir, configPath, scanPath: baseClone })
1240
+ if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
1241
+ const config = loadConfig(configFile)
1242
+ const planConfigPath = fs.existsSync(configFile) ? configFile : null
1243
+ feedSet = await downloadFeeds({
1244
+ urls: feedsUrls,
1245
+ fetchImpl,
1246
+ vendoredDir: vendoredFeeds,
1247
+ allowVendoredFallback: config.feeds.allow_vendored_fallback,
1248
+ warn,
1249
+ })
1250
+ const generatedPlan = runPlan({ workDir: baseClone, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
1251
+ const plan = verifiedPlanArtifact(planFile, generatedPlan)
1252
+ const records = feedContext(feedSet.dir)
1253
+ const models = buildModelGroups(plan, config, baseClone, records)
1254
+ const issues = config.issues.enabled ? buildIssueGroups(plan, config, baseClone, records) : []
1255
+ if (feedSet.degraded) {
1256
+ const report = group => reportOnlyDecision(group, now, tokenKind)
1257
+ const decisions = [...models.map(report), ...issues.map(report)]
1258
+ return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
1259
+ }
1260
+ const externalEvalResults = evalResultsFile
1261
+ ? readEvalResults({ file: evalResultsFile, config, commandOverride: evalCommandOverride, plan, groups: models, baseHead })
1262
+ : null
1263
+ const legacyExternalEval = externalEvalResults ? null : readEvalArtifact(
1264
+ evalReportFile ? asAbsolute(evalReportFile) : null,
1265
+ config.eval.max_report_bytes,
1266
+ evalStatusFile ? asAbsolute(evalStatusFile) : null,
1267
+ )
1268
+ const api = new GitHubClient({ repo, apiUrl, token, transport, warn })
1269
+ const labelReady = models.length || issues.length ? await api.ensureModelEolLabel() : true
1270
+ const pulls = await api.listPulls()
1271
+ const issueRecords = await api.listIssues()
1272
+ const decisions = []
1273
+ if (!labelReady) {
1274
+ for (const group of [...models, ...issues]) decisions.push(decision(group, 'label-unavailable'))
1275
+ } else {
1276
+ for (const group of models) {
1277
+ const externalEval = externalEvalResults?.get(evalIdentity(group.publisher, group.id, group.via)) ?? legacyExternalEval
1278
+ decisions.push(await processModel({
1279
+ api,
1280
+ pulls,
1281
+ group,
1282
+ source,
1283
+ base,
1284
+ baseHead,
1285
+ plan,
1286
+ config,
1287
+ checkerPath,
1288
+ evalEnabled,
1289
+ externalEval,
1290
+ root: baseClone,
1291
+ now,
1292
+ tokenKind,
1293
+ warn,
1294
+ gitAuth,
1295
+ }))
1296
+ }
1297
+ for (const group of issues) decisions.push(await processIssue({ api, issues: issueRecords, group, now }))
1298
+ }
1299
+ decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: issues }))
1300
+ return { plan, config, decisions, feedsDir: feedSet.dir, degraded: false }
1301
+ } finally {
1302
+ scan?.cleanup()
1303
+ if (ownedBaseRoot) fs.rmSync(ownedBaseRoot, { recursive: true, force: true })
1304
+ feedSet?.cleanup()
1305
+ }
1306
+ }
1307
+
1308
+ export const main = async (argv = process.argv.slice(2), env = process.env) => {
1309
+ if (argv[0] === 'evaluate') {
1310
+ const options = parseEvaluateArgs(argv.slice(1))
1311
+ if (options.help) {
1312
+ console.log(helpText())
1313
+ return 0
1314
+ }
1315
+ const result = await evaluatePlan({
1316
+ targetDir: options.targetDir,
1317
+ configPath: options.configPath,
1318
+ planFile: options.planFile,
1319
+ outputFile: options.outputFile,
1320
+ feedsUrls: options.feedsUrls,
1321
+ commandOverride: env.MODEL_EOL_EVAL_COMMAND || null,
1322
+ })
1323
+ appendEvalSummary(env.GITHUB_STEP_SUMMARY, result.artifact)
1324
+ for (const item of result.artifact.results) console.log(`${item.status} ${item.publisher}/${item.id}`)
1325
+ return 0
1326
+ }
1327
+ const options = parseArgs(argv)
1328
+ if (options.help) {
1329
+ console.log(helpText())
1330
+ return 0
1331
+ }
1332
+ const result = await runBot({
1333
+ repo: options.repo || env.GITHUB_REPOSITORY,
1334
+ targetDir: options.targetDir,
1335
+ configPath: options.configPath,
1336
+ dryRun: options.dryRun,
1337
+ evalEnabled: options.evalEnabled,
1338
+ feedsUrls: options.feedsUrls,
1339
+ planFile: env.MODEL_EOL_PLAN_FILE || null,
1340
+ evalReportFile: env.MODEL_EOL_EVAL_REPORT_FILE || null,
1341
+ evalStatusFile: env.MODEL_EOL_EVAL_STATUS_FILE || null,
1342
+ evalResultsFile: env.MODEL_EOL_EVAL_RESULTS_FILE || null,
1343
+ evalCommandOverride: env.MODEL_EOL_EVAL_COMMAND || null,
1344
+ token: env.GITHUB_TOKEN,
1345
+ apiUrl: env.GITHUB_API_URL || 'https://api.github.com',
1346
+ tokenKind: env.MODEL_EOL_TOKEN_KIND || null,
1347
+ })
1348
+ if (options.dryRun) console.log(formatDecisions(result.decisions, { degraded: result.degraded }))
1349
+ else {
1350
+ if (result.degraded) console.log('model-eol status: degraded - vendored feed fallback enabled; no GitHub writes')
1351
+ for (const item of result.decisions) console.log(`${item.action} ${item.group.publisher}/${item.group.kind === 'model' ? item.group.id : item.group.subject}`)
1352
+ }
1353
+ appendDecisionSummary(env.GITHUB_STEP_SUMMARY, result.decisions, { degraded: result.degraded })
1354
+ return result.degraded || result.decisions.some(item => BLOCKED_ACTIONS.has(item.action)) ? 1 : 0
1355
+ }
1356
+
1357
+ const invokedFile = (() => {
1358
+ if (!process.argv[1]) return null
1359
+ try {
1360
+ return fs.realpathSync(process.argv[1])
1361
+ } catch {
1362
+ return path.resolve(process.argv[1])
1363
+ }
1364
+ })()
1365
+
1366
+ if (invokedFile === fs.realpathSync(fileURLToPath(import.meta.url))) {
1367
+ main().then(code => process.exit(code)).catch(error => {
1368
+ console.error(`model-eol bot: ${error.message}`)
1369
+ process.exit(2)
1370
+ })
1371
+ }