model-eol 0.3.0 → 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/README.md +60 -19
- package/bot/bot.mjs +464 -71
- package/bot/lib/common.mjs +2 -0
- package/bot/lib/eval.mjs +6 -0
- package/bot/lib/git.mjs +11 -8
- package/bot/lib/github.mjs +10 -19
- package/bot.yml.example +84 -51
- package/check.mjs +81 -8
- package/feeds/amazon.json +1 -1
- package/feeds/anthropic.json +1 -1
- package/feeds/google.json +88 -5
- package/feeds/openai.json +11 -26
- package/lib/config.mjs +130 -5
- package/lib/feeds.mjs +38 -4
- package/lib/plan.mjs +27 -8
- package/lib/reports.mjs +24 -7
- package/lib/scanner.mjs +191 -73
- package/package.json +1 -1
- package/schema/model-eol.bot-config.schema.json +61 -0
- package/schema/model-eol.inventory.schema.json +21 -0
- package/schema/model-eol.plan.schema.json +22 -0
package/bot/bot.mjs
CHANGED
|
@@ -20,12 +20,13 @@ import {
|
|
|
20
20
|
parseMetadata,
|
|
21
21
|
repoPath,
|
|
22
22
|
sha256,
|
|
23
|
+
stableJson,
|
|
23
24
|
} from './lib/common.mjs'
|
|
24
25
|
import { validatePlanItems } from '../lib/apply.mjs'
|
|
25
26
|
import { parseCliArgs } from '../lib/cli.mjs'
|
|
26
27
|
import { loadConfig } from './lib/config.mjs'
|
|
27
28
|
import { downloadFeeds } from './lib/feeds.mjs'
|
|
28
|
-
import { readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
|
|
29
|
+
import { capReport, readReportCapped, runEvalHook, reportForBody } from './lib/eval.mjs'
|
|
29
30
|
import { GitHubClient, hasModelEolLabel } from './lib/github.mjs'
|
|
30
31
|
import {
|
|
31
32
|
cloneRepository,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
originFor,
|
|
37
38
|
prepareBranch,
|
|
38
39
|
pushBranch,
|
|
40
|
+
verifyBotBranch,
|
|
39
41
|
} from './lib/git.mjs'
|
|
40
42
|
|
|
41
43
|
const ROOT = path.resolve(import.meta.dirname, '..')
|
|
@@ -43,12 +45,15 @@ const CHECKER = path.join(ROOT, 'check.mjs')
|
|
|
43
45
|
const VENDORED_FEEDS = path.join(ROOT, 'feeds')
|
|
44
46
|
const PLAN_SCHEMA = 'model-eol.plan/0.1'
|
|
45
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
|
|
46
50
|
const ISSUE_REASONS = new Set([
|
|
47
51
|
'not-direct-api',
|
|
48
52
|
'no-replacement',
|
|
49
53
|
'replacement-choice',
|
|
50
54
|
'replacement-unresolved',
|
|
51
55
|
'replacement-retiring',
|
|
56
|
+
'shutdown-date-unavailable',
|
|
52
57
|
'publisher-fallback',
|
|
53
58
|
'unresolved-channel',
|
|
54
59
|
])
|
|
@@ -85,15 +90,44 @@ export const parseArgs = argv => {
|
|
|
85
90
|
return options
|
|
86
91
|
}
|
|
87
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
|
+
|
|
88
120
|
export const helpText = () => `model-eol bot
|
|
89
121
|
|
|
90
122
|
Usage:
|
|
91
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...]]
|
|
92
125
|
|
|
93
126
|
Environment:
|
|
94
127
|
GITHUB_TOKEN Required unless --dry-run; authenticates the API and HTTPS Git remotes.
|
|
95
128
|
GITHUB_API_URL GitHub API root, default https://api.github.com.
|
|
96
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.
|
|
97
131
|
`
|
|
98
132
|
|
|
99
133
|
const validateRepo = repo => {
|
|
@@ -102,9 +136,12 @@ const validateRepo = repo => {
|
|
|
102
136
|
}
|
|
103
137
|
|
|
104
138
|
const spawnPlan = ({ workDir, config, configPath = null, feedsDir, checkerPath = CHECKER, warn = console.error }) => {
|
|
105
|
-
const args = [checkerPath, 'plan', '.'
|
|
106
|
-
if (config.via) args.push('--via', config.via)
|
|
139
|
+
const args = [checkerPath, 'plan', '.']
|
|
107
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
|
+
}
|
|
108
145
|
if (feedsDir) args.push('--feeds', feedsDir)
|
|
109
146
|
const result = spawnSync(process.execPath, args, {
|
|
110
147
|
cwd: workDir,
|
|
@@ -213,6 +250,74 @@ const readEvalArtifact = (file, maxBytes, statusFile = null) => {
|
|
|
213
250
|
return { status: exitCode === 0 ? 'pass' : 'fail', exit_code: exitCode, report: artifact.report || null }
|
|
214
251
|
}
|
|
215
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
|
+
|
|
216
321
|
const feedContext = feedsDir => {
|
|
217
322
|
const records = new Map()
|
|
218
323
|
let files = []
|
|
@@ -270,16 +375,17 @@ const buildModelGroups = (plan, config, root, records) => {
|
|
|
270
375
|
const groups = new Map()
|
|
271
376
|
for (const item of plan.items) {
|
|
272
377
|
if (isIgnored(item, root, config, ignoredIds)) continue
|
|
273
|
-
const
|
|
378
|
+
const via = Object.hasOwn(item, 'requested_via') ? item.requested_via : plan.via
|
|
379
|
+
const key = `${item.publisher}\0${item.id}\0${via ?? ''}`
|
|
274
380
|
if (!groups.has(key)) {
|
|
275
381
|
groups.set(key, {
|
|
276
382
|
kind: 'model',
|
|
277
383
|
id: item.id,
|
|
278
384
|
publisher: item.publisher,
|
|
279
385
|
items: [],
|
|
280
|
-
via
|
|
281
|
-
branch: branchFor(item.publisher, item.id,
|
|
282
|
-
context: contextFor(records, item.publisher, item.id,
|
|
386
|
+
via,
|
|
387
|
+
branch: branchFor(item.publisher, item.id, via),
|
|
388
|
+
context: contextFor(records, item.publisher, item.id, via),
|
|
283
389
|
})
|
|
284
390
|
}
|
|
285
391
|
groups.get(key).items.push(item)
|
|
@@ -300,7 +406,8 @@ const buildIssueGroups = (plan, config, root, records) => {
|
|
|
300
406
|
if (isIgnored(issue, root, config, ignoredIds)) continue
|
|
301
407
|
const channel = issue.reason === 'unresolved-channel' ? (issue.provider || issue.matched || 'unresolved-channel') : null
|
|
302
408
|
const subject = issue.id || channel || issue.matched || 'unresolved-model'
|
|
303
|
-
const
|
|
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 ?? ''}`
|
|
304
411
|
if (!groups.has(key)) {
|
|
305
412
|
groups.set(key, {
|
|
306
413
|
kind: 'issue',
|
|
@@ -309,10 +416,10 @@ const buildIssueGroups = (plan, config, root, records) => {
|
|
|
309
416
|
channel,
|
|
310
417
|
publisher: issue.publisher || 'unknown',
|
|
311
418
|
shutdown: issue.shutdown ?? null,
|
|
312
|
-
via
|
|
419
|
+
via,
|
|
313
420
|
issues: [],
|
|
314
421
|
root,
|
|
315
|
-
context: issue.id ? contextFor(records, issue.publisher, issue.id,
|
|
422
|
+
context: issue.id ? contextFor(records, issue.publisher, issue.id, via) : { announced: null, notes: [], entry: null },
|
|
316
423
|
})
|
|
317
424
|
}
|
|
318
425
|
groups.get(key).issues.push(issue)
|
|
@@ -377,7 +484,7 @@ const replacementSection = (item, now) => [
|
|
|
377
484
|
: []),
|
|
378
485
|
]
|
|
379
486
|
|
|
380
|
-
export const buildPullBody = ({ group, headSha, now = new Date(), tokenKind = null, evalResult = null }) => {
|
|
487
|
+
export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null }) => {
|
|
381
488
|
const item = group.items[0]
|
|
382
489
|
const announced = markdownText(group.context?.announced ?? 'not specified')
|
|
383
490
|
const days = daysRemaining(item.shutdown, now)
|
|
@@ -390,6 +497,7 @@ export const buildPullBody = ({ group, headSha, now = new Date(), tokenKind = nu
|
|
|
390
497
|
replacement: item.replacement,
|
|
391
498
|
replacement_options: item.replacement_options,
|
|
392
499
|
replacement_note: item.replacement_note,
|
|
500
|
+
base_sha: baseSha,
|
|
393
501
|
head_sha: headSha,
|
|
394
502
|
feed_digest: group.feedDigest,
|
|
395
503
|
}
|
|
@@ -479,11 +587,19 @@ const issueTitle = group => `model-eol: investigate ${group.subject}${group.shut
|
|
|
479
587
|
const isOpen = item => item.state === 'open'
|
|
480
588
|
const isMerged = item => Boolean(item.merged_at || item.pull_request?.merged_at || item.merged === true)
|
|
481
589
|
|
|
482
|
-
const
|
|
483
|
-
|
|
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))
|
|
484
596
|
.map(item => {
|
|
485
597
|
const metadata = parseMetadata(item.body)
|
|
486
|
-
return {
|
|
598
|
+
return {
|
|
599
|
+
item,
|
|
600
|
+
metadata,
|
|
601
|
+
conflict: hasMetadataMarker(item.body) && (!metadata || typeof metadata.head_sha !== 'string' || !metadata.head_sha),
|
|
602
|
+
}
|
|
487
603
|
})
|
|
488
604
|
.filter(record => record.conflict || (
|
|
489
605
|
record.metadata?.id === group.id
|
|
@@ -492,13 +608,12 @@ const matchingPulls = (pulls, group) => pulls
|
|
|
492
608
|
))
|
|
493
609
|
|
|
494
610
|
const matchingIssues = (issues, group) => issues
|
|
495
|
-
.filter(item =>
|
|
611
|
+
.filter(item => hasModelEolLabel(item))
|
|
496
612
|
.map(item => {
|
|
497
613
|
const metadata = parseMetadata(item.body)
|
|
498
|
-
return { item, metadata, conflict:
|
|
614
|
+
return { item, metadata, conflict: false }
|
|
499
615
|
})
|
|
500
616
|
.filter(record => {
|
|
501
|
-
if (record.conflict) return true
|
|
502
617
|
if (!record.metadata) return false
|
|
503
618
|
if (record.metadata.id !== (group.id || group.subject)) return false
|
|
504
619
|
if (record.metadata.publisher !== group.publisher) return false
|
|
@@ -506,17 +621,78 @@ const matchingIssues = (issues, group) => issues
|
|
|
506
621
|
return !group.channel || record.metadata.channel === group.channel || (record.metadata.id === group.channel && !record.metadata.channel)
|
|
507
622
|
})
|
|
508
623
|
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
515
688
|
}
|
|
516
689
|
|
|
517
690
|
const standingDownComment = (group, metadata, currentHead) =>
|
|
518
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.`
|
|
519
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
|
+
|
|
520
696
|
const writeJson = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2))
|
|
521
697
|
|
|
522
698
|
const gitStatusPaths = cwd => {
|
|
@@ -563,7 +739,133 @@ const evalWorkspaceDrift = (root, planFiles, expectedHashes, expectedHead) => {
|
|
|
563
739
|
return null
|
|
564
740
|
}
|
|
565
741
|
|
|
566
|
-
const
|
|
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 }) => {
|
|
567
869
|
const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-work-'))
|
|
568
870
|
const planRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-plan-'))
|
|
569
871
|
const clone = path.join(workRoot, 'repo')
|
|
@@ -612,7 +914,7 @@ const makePatch = ({ source, base, branch, expectedHead, group, plan, config, ch
|
|
|
612
914
|
evalResult = {
|
|
613
915
|
...evalResult,
|
|
614
916
|
status: 'fail',
|
|
615
|
-
report: [evalResult.report, drift].filter(Boolean).join('\n'),
|
|
917
|
+
report: capReport([evalResult.report, drift].filter(Boolean).join('\n'), config.eval.max_report_bytes),
|
|
616
918
|
}
|
|
617
919
|
}
|
|
618
920
|
}
|
|
@@ -627,7 +929,7 @@ const makePatch = ({ source, base, branch, expectedHead, group, plan, config, ch
|
|
|
627
929
|
const files = planFiles
|
|
628
930
|
const message = `model-eol: migrate ${group.id} to ${group.items[0].replacement} (${group.feedDigest.slice(0, 8)})`
|
|
629
931
|
const headSha = commitAll(clone, files, message)
|
|
630
|
-
pushBranch(clone, branch, expectedHead, gitAuth)
|
|
932
|
+
pushBranch(clone, branch, expectedHead, gitAuth, { allowMissing: allowMissingBranch })
|
|
631
933
|
return { headSha, evalResult }
|
|
632
934
|
} finally {
|
|
633
935
|
fs.rmSync(workRoot, { recursive: true, force: true })
|
|
@@ -637,22 +939,28 @@ const makePatch = ({ source, base, branch, expectedHead, group, plan, config, ch
|
|
|
637
939
|
|
|
638
940
|
const decision = (group, action, extra = {}) => ({ group, action, ...extra })
|
|
639
941
|
|
|
640
|
-
const processModel = async ({ api, pulls, group, source, base, plan, config, checkerPath, evalEnabled, externalEval, root, now, tokenKind, warn, gitAuth }) => {
|
|
641
|
-
|
|
642
|
-
return decision(group, 'eval-failed', { evalResult: externalEval })
|
|
643
|
-
}
|
|
644
|
-
const matches = matchingPulls(pulls, group)
|
|
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)
|
|
645
944
|
const conflict = matches.find(record => record.conflict)
|
|
646
945
|
if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
|
|
647
946
|
const open = matches.find(record => isOpen(record.item))
|
|
648
947
|
const currentDigest = group.feedDigest
|
|
649
948
|
if (open) {
|
|
650
|
-
if (open.
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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))
|
|
654
956
|
return decision(group, 'stand-down', { number: open.item.number })
|
|
655
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
|
+
}
|
|
656
964
|
let patch
|
|
657
965
|
try {
|
|
658
966
|
patch = makePatch({
|
|
@@ -681,19 +989,29 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
|
|
|
681
989
|
return decision(group, 'eval-failed', { number: open.item.number, evalResult: patch.evalResult })
|
|
682
990
|
}
|
|
683
991
|
if (!patch.evalResult && externalEval) patch.evalResult = externalEval
|
|
684
|
-
const body = buildPullBody({ group, headSha: patch.headSha, now, tokenKind, evalResult: patch.evalResult })
|
|
992
|
+
const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: patch.evalResult })
|
|
685
993
|
await api.updatePull(open.item.number, { title: pullTitle(group), body })
|
|
686
994
|
return decision(group, 'update', { number: open.item.number, body, headSha: patch.headSha })
|
|
687
995
|
}
|
|
688
996
|
|
|
997
|
+
if (externalEval && externalEval.status !== 'pass') {
|
|
998
|
+
return decision(group, 'eval-failed', { evalResult: externalEval })
|
|
999
|
+
}
|
|
1000
|
+
|
|
689
1001
|
const replacement = group.items[0].replacement
|
|
690
|
-
|
|
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))
|
|
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))
|
|
694
1003
|
if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
|
|
695
1004
|
|
|
696
|
-
const
|
|
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
|
+
}
|
|
697
1015
|
let patch
|
|
698
1016
|
try {
|
|
699
1017
|
patch = makePatch({
|
|
@@ -701,6 +1019,7 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
|
|
|
701
1019
|
base,
|
|
702
1020
|
branch: group.branch,
|
|
703
1021
|
expectedHead: previousBotHead,
|
|
1022
|
+
allowMissingBranch: Boolean(previousBotHead && !isOpen(previous.item)),
|
|
704
1023
|
group,
|
|
705
1024
|
plan,
|
|
706
1025
|
config,
|
|
@@ -723,8 +1042,9 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
|
|
|
723
1042
|
return decision(group, 'eval-failed', { evalResult: patch.evalResult })
|
|
724
1043
|
}
|
|
725
1044
|
if (!patch.evalResult && externalEval) patch.evalResult = externalEval
|
|
726
|
-
const body = buildPullBody({ group, headSha: patch.headSha, now, tokenKind, evalResult: patch.evalResult })
|
|
727
|
-
const
|
|
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)
|
|
728
1048
|
const latestConflict = latestMatches.find(record => record.conflict)
|
|
729
1049
|
if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
|
|
730
1050
|
const latestOpen = latestMatches.find(record => isOpen(record.item))
|
|
@@ -732,8 +1052,10 @@ const processModel = async ({ api, pulls, group, source, base, plan, config, che
|
|
|
732
1052
|
await api.updatePull(latestOpen.item.number, { title: pullTitle(group), body })
|
|
733
1053
|
return decision(group, 'update', { number: latestOpen.item.number, body, headSha: patch.headSha })
|
|
734
1054
|
}
|
|
735
|
-
const
|
|
736
|
-
if (
|
|
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 })
|
|
737
1059
|
const created = await api.createPull({
|
|
738
1060
|
title: pullTitle(group),
|
|
739
1061
|
head: group.branch,
|
|
@@ -755,7 +1077,7 @@ const processIssue = async ({ api, issues, group, now }) => {
|
|
|
755
1077
|
return decision(group, 'update', { number: open.item.number, body })
|
|
756
1078
|
}
|
|
757
1079
|
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))
|
|
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))
|
|
759
1081
|
if (dismissed) return decision(group, 'skip-dismissed', { number: dismissed.item.number })
|
|
760
1082
|
const latestMatches = matchingIssues(await api.listIssues(), group)
|
|
761
1083
|
const latestConflict = latestMatches.find(record => record.conflict)
|
|
@@ -765,7 +1087,7 @@ const processIssue = async ({ api, issues, group, now }) => {
|
|
|
765
1087
|
await api.updateIssue(latestOpen.item.number, { title: issueTitle(group), body })
|
|
766
1088
|
return decision(group, 'update', { number: latestOpen.item.number, body })
|
|
767
1089
|
}
|
|
768
|
-
const latestDismissed = latestMatches.find(record => !isOpen(record.item) && record.metadata.shutdown === group.shutdown && (record.metadata.replacement === undefined || record.metadata.replacement === replacement))
|
|
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))
|
|
769
1091
|
if (latestDismissed) return decision(group, 'skip-dismissed', { number: latestDismissed.item.number })
|
|
770
1092
|
const created = await api.createIssue({ title: issueTitle(group), body, labels: ['model-eol'] })
|
|
771
1093
|
return decision(group, 'create', { number: created.number, body })
|
|
@@ -814,6 +1136,43 @@ export const formatDecisions = (decisions, { degraded = false } = {}) => {
|
|
|
814
1136
|
return lines.join('\n')
|
|
815
1137
|
}
|
|
816
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
|
+
|
|
817
1176
|
export const runBot = async ({
|
|
818
1177
|
repo,
|
|
819
1178
|
targetDir = process.cwd(),
|
|
@@ -831,6 +1190,8 @@ export const runBot = async ({
|
|
|
831
1190
|
planFile = null,
|
|
832
1191
|
evalReportFile = null,
|
|
833
1192
|
evalStatusFile = null,
|
|
1193
|
+
evalResultsFile = null,
|
|
1194
|
+
evalCommandOverride = null,
|
|
834
1195
|
now = new Date(),
|
|
835
1196
|
warn = message => console.error(message),
|
|
836
1197
|
} = {}) => {
|
|
@@ -874,6 +1235,7 @@ export const runBot = async ({
|
|
|
874
1235
|
baseClone = path.join(ownedBaseRoot, 'repo')
|
|
875
1236
|
cloneRepository(source, baseClone, gitAuth)
|
|
876
1237
|
const base = defaultBranch(baseClone, gitAuth)
|
|
1238
|
+
const baseHead = gitHead(baseClone)
|
|
877
1239
|
const configFile = configFileFor({ targetDir, configPath, scanPath: baseClone })
|
|
878
1240
|
if (configPath && !fs.existsSync(configFile)) throw new Error(`config file not found: ${configFile}`)
|
|
879
1241
|
const config = loadConfig(configFile)
|
|
@@ -895,36 +1257,46 @@ export const runBot = async ({
|
|
|
895
1257
|
const decisions = [...models.map(report), ...issues.map(report)]
|
|
896
1258
|
return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
|
|
897
1259
|
}
|
|
898
|
-
const
|
|
1260
|
+
const externalEvalResults = evalResultsFile
|
|
1261
|
+
? readEvalResults({ file: evalResultsFile, config, commandOverride: evalCommandOverride, plan, groups: models, baseHead })
|
|
1262
|
+
: null
|
|
1263
|
+
const legacyExternalEval = externalEvalResults ? null : readEvalArtifact(
|
|
899
1264
|
evalReportFile ? asAbsolute(evalReportFile) : null,
|
|
900
1265
|
config.eval.max_report_bytes,
|
|
901
1266
|
evalStatusFile ? asAbsolute(evalStatusFile) : null,
|
|
902
1267
|
)
|
|
903
1268
|
const api = new GitHubClient({ repo, apiUrl, token, transport, warn })
|
|
904
|
-
|
|
905
|
-
const pulls =
|
|
906
|
-
const issueRecords =
|
|
1269
|
+
const labelReady = models.length || issues.length ? await api.ensureModelEolLabel() : true
|
|
1270
|
+
const pulls = await api.listPulls()
|
|
1271
|
+
const issueRecords = await api.listIssues()
|
|
907
1272
|
const decisions = []
|
|
908
|
-
|
|
909
|
-
decisions.push(
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
group,
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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 }))
|
|
926
1298
|
}
|
|
927
|
-
|
|
1299
|
+
decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: issues }))
|
|
928
1300
|
return { plan, config, decisions, feedsDir: feedSet.dir, degraded: false }
|
|
929
1301
|
} finally {
|
|
930
1302
|
scan?.cleanup()
|
|
@@ -934,6 +1306,24 @@ export const runBot = async ({
|
|
|
934
1306
|
}
|
|
935
1307
|
|
|
936
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
|
+
}
|
|
937
1327
|
const options = parseArgs(argv)
|
|
938
1328
|
if (options.help) {
|
|
939
1329
|
console.log(helpText())
|
|
@@ -949,6 +1339,8 @@ export const main = async (argv = process.argv.slice(2), env = process.env) => {
|
|
|
949
1339
|
planFile: env.MODEL_EOL_PLAN_FILE || null,
|
|
950
1340
|
evalReportFile: env.MODEL_EOL_EVAL_REPORT_FILE || null,
|
|
951
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,
|
|
952
1344
|
token: env.GITHUB_TOKEN,
|
|
953
1345
|
apiUrl: env.GITHUB_API_URL || 'https://api.github.com',
|
|
954
1346
|
tokenKind: env.MODEL_EOL_TOKEN_KIND || null,
|
|
@@ -958,7 +1350,8 @@ export const main = async (argv = process.argv.slice(2), env = process.env) => {
|
|
|
958
1350
|
if (result.degraded) console.log('model-eol status: degraded - vendored feed fallback enabled; no GitHub writes')
|
|
959
1351
|
for (const item of result.decisions) console.log(`${item.action} ${item.group.publisher}/${item.group.kind === 'model' ? item.group.id : item.group.subject}`)
|
|
960
1352
|
}
|
|
961
|
-
|
|
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
|
|
962
1355
|
}
|
|
963
1356
|
|
|
964
1357
|
const invokedFile = (() => {
|