model-eol 0.4.0 → 0.5.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 +174 -18
- package/SPEC.md +21 -6
- package/action.yml +30 -9
- package/bot/bot.mjs +361 -165
- package/bot/lib/git.mjs +49 -0
- package/bot/lib/github.mjs +4 -0
- package/bot.yml.example +37 -25
- package/check.mjs +55 -4
- package/examples/model-eol-eval.mjs +84 -0
- package/examples/workflows/model-eol.yml +52 -0
- package/feeds/amazon.json +5 -1
- package/feeds/anthropic.json +4 -1
- package/lib/apply.mjs +393 -231
- package/lib/config.mjs +16 -1
- package/lib/feeds.mjs +22 -3
- package/lib/json-schema.mjs +291 -0
- package/lib/reports.mjs +95 -22
- package/lib/scanner.mjs +159 -25
- package/lib/validate-document.mjs +320 -0
- package/lib/validate-feed.mjs +7 -1
- package/package.json +4 -3
- package/schema/model-eol.alert.schema.json +46 -10
- package/schema/model-eol.bot-config.schema.json +8 -4
- package/schema/model-eol.check.schema.json +56 -0
- package/schema/model-eol.inventory.schema.json +82 -81
- package/schema/model-eol.plan.schema.json +5 -5
- package/schema/model-eol.schedule.schema.json +10 -10
- package/schema/model-eol.schema.json +24 -8
package/bot/bot.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
sha256,
|
|
23
23
|
stableJson,
|
|
24
24
|
} from './lib/common.mjs'
|
|
25
|
-
import {
|
|
25
|
+
import { assertValidPlanDocument } from '../lib/validate-document.mjs'
|
|
26
26
|
import { parseCliArgs } from '../lib/cli.mjs'
|
|
27
27
|
import { loadConfig } from './lib/config.mjs'
|
|
28
28
|
import { downloadFeeds } from './lib/feeds.mjs'
|
|
@@ -33,11 +33,15 @@ import {
|
|
|
33
33
|
commitAll,
|
|
34
34
|
configureIdentity,
|
|
35
35
|
defaultBranch,
|
|
36
|
+
deleteRemoteBranch,
|
|
36
37
|
gitAuthentication,
|
|
38
|
+
isCommitAvailable,
|
|
37
39
|
originFor,
|
|
38
40
|
prepareBranch,
|
|
39
41
|
pushBranch,
|
|
42
|
+
restoreRemoteBranch,
|
|
40
43
|
verifyBotBranch,
|
|
44
|
+
verifyRemoteBranchHead,
|
|
41
45
|
} from './lib/git.mjs'
|
|
42
46
|
|
|
43
47
|
const ROOT = path.resolve(import.meta.dirname, '..')
|
|
@@ -46,6 +50,7 @@ const VENDORED_FEEDS = path.join(ROOT, 'feeds')
|
|
|
46
50
|
const PLAN_SCHEMA = 'model-eol.plan/0.1'
|
|
47
51
|
const BOT_SCHEMA = 'model-eol.bot/0.1'
|
|
48
52
|
const EVAL_SCHEMA = 'model-eol.eval/0.1'
|
|
53
|
+
const EVAL_FAILURE_CHANNEL = 'configured-eval'
|
|
49
54
|
const EVAL_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024
|
|
50
55
|
const ISSUE_REASONS = new Set([
|
|
51
56
|
'not-direct-api',
|
|
@@ -69,6 +74,8 @@ export const parseArgs = argv => {
|
|
|
69
74
|
'target-dir': { type: 'string' },
|
|
70
75
|
config: { type: 'string' },
|
|
71
76
|
'dry-run': { type: 'boolean' },
|
|
77
|
+
// Retained only to emit an actionable migration error. Publication must
|
|
78
|
+
// never execute repository-owned eval code in its write-capable process.
|
|
72
79
|
eval: { type: 'boolean' },
|
|
73
80
|
'feeds-url': { type: 'string', multiple: true },
|
|
74
81
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -80,7 +87,7 @@ export const parseArgs = argv => {
|
|
|
80
87
|
targetDir: values['target-dir'] ?? process.cwd(),
|
|
81
88
|
configPath: values.config ?? null,
|
|
82
89
|
dryRun: values['dry-run'] ?? false,
|
|
83
|
-
|
|
90
|
+
deprecatedInlineEval: values.eval ?? false,
|
|
84
91
|
feedsUrls: [],
|
|
85
92
|
help: values.help ?? false,
|
|
86
93
|
}
|
|
@@ -120,14 +127,15 @@ export const parseEvaluateArgs = argv => {
|
|
|
120
127
|
export const helpText = () => `model-eol bot
|
|
121
128
|
|
|
122
129
|
Usage:
|
|
123
|
-
node bot/bot.mjs [--repo owner/name] [--target-dir PATH] [--config PATH] [--dry-run] [--
|
|
130
|
+
node bot/bot.mjs [--repo owner/name] [--target-dir PATH] [--config PATH] [--dry-run] [--feeds-url URL[,URL...]]
|
|
124
131
|
node bot/bot.mjs evaluate --target-dir PATH --plan-file PATH --output-file PATH [--config PATH] [--feeds-url URL[,URL...]]
|
|
125
132
|
|
|
126
133
|
Environment:
|
|
127
134
|
GITHUB_TOKEN Required unless --dry-run; authenticates the API and HTTPS Git remotes.
|
|
128
135
|
GITHUB_API_URL GitHub API root, default https://api.github.com.
|
|
129
136
|
MODEL_EOL_TOKEN_KIND Set to github-token to add the checks warning to PRs.
|
|
130
|
-
MODEL_EOL_EVAL_COMMAND Optional
|
|
137
|
+
MODEL_EOL_EVAL_COMMAND Optional override executed by evaluate and digest-checked by publish.
|
|
138
|
+
MODEL_EOL_EVAL_RESULTS_FILE Commit-bound result manifest produced by evaluate.
|
|
131
139
|
`
|
|
132
140
|
|
|
133
141
|
const validateRepo = repo => {
|
|
@@ -167,11 +175,8 @@ const validatePlan = plan => {
|
|
|
167
175
|
if (plan?.plan_schema !== PLAN_SCHEMA) {
|
|
168
176
|
throw new Error(`refusing plan schema ${plan?.plan_schema ?? 'missing'}; expected ${PLAN_SCHEMA}`)
|
|
169
177
|
}
|
|
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
178
|
try {
|
|
174
|
-
|
|
179
|
+
assertValidPlanDocument(plan)
|
|
175
180
|
} catch (error) {
|
|
176
181
|
throw new Error(`refusing malformed plan document: ${error.message}`)
|
|
177
182
|
}
|
|
@@ -199,57 +204,6 @@ const verifiedPlanArtifact = (file, generatedPlan) => {
|
|
|
199
204
|
return artifact
|
|
200
205
|
}
|
|
201
206
|
|
|
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
207
|
const withoutGenerated = plan => {
|
|
254
208
|
const { generated, ...stable } = plan
|
|
255
209
|
return stable
|
|
@@ -318,6 +272,20 @@ const readEvalResults = ({ file, config, commandOverride, plan, groups, baseHead
|
|
|
318
272
|
return results
|
|
319
273
|
}
|
|
320
274
|
|
|
275
|
+
const boundEvalResults = ({ file, config, commandOverride, plan, groups, baseHead }) => {
|
|
276
|
+
const settings = effectiveEval(config, commandOverride)
|
|
277
|
+
if (groups.length && settings.command && !file) {
|
|
278
|
+
throw new Error('configured eval requires MODEL_EOL_EVAL_RESULTS_FILE from `model-eol-bot evaluate`; publication never executes eval.command')
|
|
279
|
+
}
|
|
280
|
+
const results = file
|
|
281
|
+
? readEvalResults({ file, config, commandOverride, plan, groups, baseHead })
|
|
282
|
+
: null
|
|
283
|
+
return {
|
|
284
|
+
results,
|
|
285
|
+
configDigest: results && settings.command ? evalConfigDigest(settings) : null,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
321
289
|
const feedContext = feedsDir => {
|
|
322
290
|
const records = new Map()
|
|
323
291
|
let files = []
|
|
@@ -431,6 +399,34 @@ const buildIssueGroups = (plan, config, root, records) => {
|
|
|
431
399
|
return [...groups.values()].sort((a, b) => `${a.publisher}/${a.subject}/${a.shutdown ?? ''}`.localeCompare(`${b.publisher}/${b.subject}/${b.shutdown ?? ''}`))
|
|
432
400
|
}
|
|
433
401
|
|
|
402
|
+
const evalIssueGroupFor = (group, evalResult, root) => ({
|
|
403
|
+
kind: 'issue',
|
|
404
|
+
id: group.id,
|
|
405
|
+
subject: group.id,
|
|
406
|
+
channel: EVAL_FAILURE_CHANNEL,
|
|
407
|
+
publisher: group.publisher,
|
|
408
|
+
shutdown: group.items[0]?.shutdown ?? null,
|
|
409
|
+
via: group.via ?? null,
|
|
410
|
+
issues: group.items.map(item => ({
|
|
411
|
+
file: item.file,
|
|
412
|
+
line: item.line,
|
|
413
|
+
matched: item.matched,
|
|
414
|
+
id: item.id,
|
|
415
|
+
publisher: item.publisher,
|
|
416
|
+
status: item.status,
|
|
417
|
+
shutdown: item.shutdown,
|
|
418
|
+
requested_via: group.via ?? null,
|
|
419
|
+
replacement: item.replacement,
|
|
420
|
+
reason: 'eval-failed',
|
|
421
|
+
sources: item.sources ?? [],
|
|
422
|
+
notes: item.notes ?? null,
|
|
423
|
+
})),
|
|
424
|
+
root,
|
|
425
|
+
context: group.context,
|
|
426
|
+
evalResult,
|
|
427
|
+
feedDigest: sha256(stableJson({ feed_digest: group.feedDigest, eval_result: evalResult })),
|
|
428
|
+
})
|
|
429
|
+
|
|
434
430
|
const sourcesFor = group => {
|
|
435
431
|
const values = group.kind === 'model'
|
|
436
432
|
? group.items.flatMap(item => item.sources ?? [])
|
|
@@ -484,7 +480,7 @@ const replacementSection = (item, now) => [
|
|
|
484
480
|
: []),
|
|
485
481
|
]
|
|
486
482
|
|
|
487
|
-
export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null }) => {
|
|
483
|
+
export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date(), tokenKind = null, evalResult = null, evalConfigHash = null }) => {
|
|
488
484
|
const item = group.items[0]
|
|
489
485
|
const announced = markdownText(group.context?.announced ?? 'not specified')
|
|
490
486
|
const days = daysRemaining(item.shutdown, now)
|
|
@@ -500,6 +496,7 @@ export const buildPullBody = ({ group, headSha, baseSha = null, now = new Date()
|
|
|
500
496
|
base_sha: baseSha,
|
|
501
497
|
head_sha: headSha,
|
|
502
498
|
feed_digest: group.feedDigest,
|
|
499
|
+
...(evalConfigHash ? { eval_config_digest: evalConfigHash } : {}),
|
|
503
500
|
}
|
|
504
501
|
const sections = [
|
|
505
502
|
metadataLine(metadata),
|
|
@@ -551,7 +548,7 @@ export const buildIssueBody = ({ group, now = new Date() }) => {
|
|
|
551
548
|
const evidence = group.issues
|
|
552
549
|
.map(issue => `- ${markdownText(`${repoPath(issue.file, group.root || '.')}:${issue.line} - ${issue.reason}${issue.matched ? ` (${issue.matched})` : ''}`)}`)
|
|
553
550
|
.join('\n')
|
|
554
|
-
|
|
551
|
+
const sections = [
|
|
555
552
|
metadataLine(metadata),
|
|
556
553
|
'',
|
|
557
554
|
'## Finding',
|
|
@@ -578,7 +575,10 @@ export const buildIssueBody = ({ group, now = new Date() }) => {
|
|
|
578
575
|
'',
|
|
579
576
|
'## Feed notes',
|
|
580
577
|
notesSection(group),
|
|
581
|
-
]
|
|
578
|
+
]
|
|
579
|
+
const evaluation = evalSection(group.evalResult)
|
|
580
|
+
if (evaluation) sections.push('', evaluation)
|
|
581
|
+
return sections.join('\n')
|
|
582
582
|
}
|
|
583
583
|
|
|
584
584
|
const pullTitle = group => `model-eol: migrate ${group.id} before ${group.items[0].shutdown}`
|
|
@@ -618,7 +618,10 @@ const matchingIssues = (issues, group) => issues
|
|
|
618
618
|
if (record.metadata.id !== (group.id || group.subject)) return false
|
|
619
619
|
if (record.metadata.publisher !== group.publisher) return false
|
|
620
620
|
if ((record.metadata.via ?? null) !== (group.via ?? null)) return false
|
|
621
|
-
|
|
621
|
+
const expectedChannel = group.channel ?? null
|
|
622
|
+
const actualChannel = record.metadata.channel ?? null
|
|
623
|
+
if (actualChannel === expectedChannel) return true
|
|
624
|
+
return expectedChannel !== null && actualChannel === null && record.metadata.id === expectedChannel
|
|
622
625
|
})
|
|
623
626
|
|
|
624
627
|
const modelIdentity = (publisher, id, via) => `${publisher}\0${id}\0${via ?? ''}`
|
|
@@ -656,10 +659,12 @@ const groupFromMetadata = (kind, metadata) => ({
|
|
|
656
659
|
|
|
657
660
|
const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) => {
|
|
658
661
|
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
|
-
|
|
662
|
-
|
|
662
|
+
const activeIssues = new Set(issueGroups.flatMap(group => {
|
|
663
|
+
const exact = issueIdentity(group.publisher, group.id || group.subject, group.via, group.channel)
|
|
664
|
+
return group.channel === EVAL_FAILURE_CHANNEL
|
|
665
|
+
? [exact]
|
|
666
|
+
: [exact, issueIdentity(group.publisher, group.id || group.subject, group.via, null)]
|
|
667
|
+
}))
|
|
663
668
|
const decisions = []
|
|
664
669
|
for (const record of ownedPullRecords(pulls, api.repo)) {
|
|
665
670
|
if (!isOpen(record.item)) continue
|
|
@@ -676,7 +681,8 @@ const reconcileStaleWork = async ({ api, pulls, issues, models, issueGroups }) =
|
|
|
676
681
|
if (!isOpen(record.item)) continue
|
|
677
682
|
const key = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, record.metadata.channel)
|
|
678
683
|
const legacyKey = issueIdentity(record.metadata.publisher, record.metadata.id, record.metadata.via, null)
|
|
679
|
-
|
|
684
|
+
const evalFailure = record.metadata.channel === EVAL_FAILURE_CHANNEL
|
|
685
|
+
if (activeIssues.has(key) || (!evalFailure && activeIssues.has(legacyKey))) continue
|
|
680
686
|
await api.comment(record.item.number, staleWorkComment('issue'))
|
|
681
687
|
await api.updateIssue(record.item.number, {
|
|
682
688
|
state: 'closed',
|
|
@@ -780,7 +786,11 @@ export const evaluatePlan = async ({
|
|
|
780
786
|
warn,
|
|
781
787
|
})
|
|
782
788
|
try {
|
|
789
|
+
const baseHead = gitHead(targetPath)
|
|
783
790
|
const generatedPlan = runPlan({ workDir: targetPath, config, configPath: planConfigPath, feedsDir: feedSet.dir, checkerPath, warn })
|
|
791
|
+
if (gitHead(targetPath) !== baseHead) {
|
|
792
|
+
throw new Error('refusing eval: target commit changed while generating the migration plan')
|
|
793
|
+
}
|
|
784
794
|
const plan = verifiedPlanArtifact(planFile, generatedPlan)
|
|
785
795
|
const groups = buildModelGroups(plan, config, targetPath, feedContext(feedSet.dir))
|
|
786
796
|
const results = []
|
|
@@ -795,6 +805,9 @@ export const evaluatePlan = async ({
|
|
|
795
805
|
let result
|
|
796
806
|
try {
|
|
797
807
|
cloneRepository(targetPath, clone)
|
|
808
|
+
if (gitHead(clone) !== baseHead) {
|
|
809
|
+
throw new Error(`refusing eval: isolated checkout for ${group.publisher}/${group.id} does not match the captured base commit`)
|
|
810
|
+
}
|
|
798
811
|
const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
|
|
799
812
|
cwd: clone,
|
|
800
813
|
encoding: 'utf8',
|
|
@@ -849,10 +862,13 @@ export const evaluatePlan = async ({
|
|
|
849
862
|
})
|
|
850
863
|
}
|
|
851
864
|
}
|
|
865
|
+
if (gitHead(targetPath) !== baseHead) {
|
|
866
|
+
throw new Error('refusing eval: target commit changed during migration evaluation')
|
|
867
|
+
}
|
|
852
868
|
const artifact = {
|
|
853
869
|
schema: EVAL_SCHEMA,
|
|
854
870
|
generated: now.toISOString(),
|
|
855
|
-
base_sha:
|
|
871
|
+
base_sha: baseHead,
|
|
856
872
|
plan_digest: planDigest(plan),
|
|
857
873
|
eval_config_digest: evalConfigDigest(settings),
|
|
858
874
|
configured: Boolean(settings.command),
|
|
@@ -865,18 +881,19 @@ export const evaluatePlan = async ({
|
|
|
865
881
|
}
|
|
866
882
|
}
|
|
867
883
|
|
|
868
|
-
const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = false, group, plan,
|
|
884
|
+
const makePatch = ({ source, base, expectedBaseHead, branch, expectedHead, allowMissingBranch = false, group, plan, checkerPath, gitAuth }) => {
|
|
869
885
|
const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-bot-work-'))
|
|
870
886
|
const planRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-plan-'))
|
|
871
887
|
const clone = path.join(workRoot, 'repo')
|
|
872
|
-
const fullPlanPath = path.join(planRoot, 'plan.json')
|
|
873
888
|
const selectedPlanPath = path.join(planRoot, 'selected-plan.json')
|
|
874
|
-
const reportPath = path.join(planRoot, 'eval-report.md')
|
|
875
|
-
writeJson(fullPlanPath, plan)
|
|
876
889
|
writeJson(selectedPlanPath, { ...plan, items: group.items, issues: [] })
|
|
877
890
|
try {
|
|
878
891
|
cloneRepository(source, clone, gitAuth)
|
|
879
892
|
prepareBranch(clone, branch, base, gitAuth)
|
|
893
|
+
const preparedBaseHead = gitHead(clone)
|
|
894
|
+
if (preparedBaseHead !== expectedBaseHead) {
|
|
895
|
+
throw new Error(`refusing patch: prepared base commit ${preparedBaseHead} does not match evaluated base commit ${expectedBaseHead}`)
|
|
896
|
+
}
|
|
880
897
|
const applied = spawnSync(process.execPath, [checkerPath, 'apply', '--plan', selectedPlanPath], {
|
|
881
898
|
cwd: clone,
|
|
882
899
|
encoding: 'utf8',
|
|
@@ -886,51 +903,12 @@ const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = fa
|
|
|
886
903
|
throw new Error(`apply subprocess failed: ${applied.error?.message || applied.stderr?.trim() || `exit ${applied.status}`}`)
|
|
887
904
|
}
|
|
888
905
|
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
906
|
configureIdentity(clone)
|
|
929
907
|
const files = planFiles
|
|
930
908
|
const message = `model-eol: migrate ${group.id} to ${group.items[0].replacement} (${group.feedDigest.slice(0, 8)})`
|
|
931
909
|
const headSha = commitAll(clone, files, message)
|
|
932
910
|
pushBranch(clone, branch, expectedHead, gitAuth, { allowMissing: allowMissingBranch })
|
|
933
|
-
return { headSha
|
|
911
|
+
return { headSha }
|
|
934
912
|
} finally {
|
|
935
913
|
fs.rmSync(workRoot, { recursive: true, force: true })
|
|
936
914
|
fs.rmSync(planRoot, { recursive: true, force: true })
|
|
@@ -939,7 +917,148 @@ const makePatch = ({ source, base, branch, expectedHead, allowMissingBranch = fa
|
|
|
939
917
|
|
|
940
918
|
const decision = (group, action, extra = {}) => ({ group, action, ...extra })
|
|
941
919
|
|
|
942
|
-
const
|
|
920
|
+
const publicationBaseDecision = ({ group, root, base, baseHead, gitAuth, number = undefined }) => {
|
|
921
|
+
const state = verifyRemoteBranchHead(root, base, baseHead, gitAuth)
|
|
922
|
+
if (state.safe) return null
|
|
923
|
+
return decision(group, 'stand-down', {
|
|
924
|
+
...(number === undefined ? {} : { number }),
|
|
925
|
+
reason: state.error ? 'default-branch-unverifiable' : 'default-branch-moved',
|
|
926
|
+
expectedBaseHead: baseHead,
|
|
927
|
+
currentBaseHead: state.head,
|
|
928
|
+
})
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const staleBodyFor = body => {
|
|
932
|
+
const metadata = parseMetadata(body)
|
|
933
|
+
if (!metadata) throw new Error('refusing to close bot pull request with malformed generated metadata')
|
|
934
|
+
return staleClosedBody(body, metadata)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const cleanupPushedBranchAndRethrow = ({ error, root, branch, headSha, gitAuth, context, relatedErrors = [] }) => {
|
|
938
|
+
try {
|
|
939
|
+
deleteRemoteBranch(root, branch, headSha, gitAuth)
|
|
940
|
+
} catch (cleanupError) {
|
|
941
|
+
throw new AggregateError(
|
|
942
|
+
[error, ...relatedErrors, cleanupError],
|
|
943
|
+
`${context} and exact-head cleanup of ${branch} also failed`,
|
|
944
|
+
)
|
|
945
|
+
}
|
|
946
|
+
if (relatedErrors.length) {
|
|
947
|
+
throw new AggregateError(
|
|
948
|
+
[error, ...relatedErrors],
|
|
949
|
+
`${context}; exact-head cleanup succeeded after pull-request state verification also failed`,
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
throw error
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const rollbackPushedBranchAndRethrow = ({ error, root, branch, headSha, restoreHead, gitAuth, context }) => {
|
|
956
|
+
try {
|
|
957
|
+
restoreRemoteBranch(root, branch, headSha, restoreHead, gitAuth)
|
|
958
|
+
} catch (rollbackError) {
|
|
959
|
+
throw new AggregateError(
|
|
960
|
+
[error, rollbackError],
|
|
961
|
+
`${context} and exact-head rollback of ${branch} also failed`,
|
|
962
|
+
)
|
|
963
|
+
}
|
|
964
|
+
throw error
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const recoverFailedPullUpdate = async ({ api, open, priorBody, error, root, group, headSha, restoreHead, rollbackTrusted, gitAuth, context }) => {
|
|
968
|
+
let current = null
|
|
969
|
+
let readError = null
|
|
970
|
+
try {
|
|
971
|
+
current = await api.getPull(open.item.number)
|
|
972
|
+
} catch (failure) {
|
|
973
|
+
readError = failure
|
|
974
|
+
}
|
|
975
|
+
const metadata = parseMetadata(current?.body)
|
|
976
|
+
const priorBodyProven = rollbackTrusted
|
|
977
|
+
&& current?.number === open.item.number
|
|
978
|
+
&& isOpen(current)
|
|
979
|
+
&& hasModelEolLabel(current)
|
|
980
|
+
&& current.head?.ref === group.branch
|
|
981
|
+
&& current.head?.repo?.full_name === api.repo
|
|
982
|
+
&& current.body === priorBody
|
|
983
|
+
&& metadata?.head_sha === restoreHead
|
|
984
|
+
if (priorBodyProven) {
|
|
985
|
+
rollbackPushedBranchAndRethrow({
|
|
986
|
+
error,
|
|
987
|
+
root,
|
|
988
|
+
branch: group.branch,
|
|
989
|
+
headSha,
|
|
990
|
+
restoreHead,
|
|
991
|
+
gitAuth,
|
|
992
|
+
context,
|
|
993
|
+
})
|
|
994
|
+
}
|
|
995
|
+
cleanupPushedBranchAndRethrow({
|
|
996
|
+
error,
|
|
997
|
+
root,
|
|
998
|
+
branch: group.branch,
|
|
999
|
+
headSha,
|
|
1000
|
+
gitAuth,
|
|
1001
|
+
context: `${context} with ambiguous or changed pull-request state`,
|
|
1002
|
+
relatedErrors: readError ? [readError] : [],
|
|
1003
|
+
})
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const closePullAtNewHead = async ({ api, open, body, root, branch, headSha, gitAuth }) => {
|
|
1007
|
+
const closedBody = staleBodyFor(body)
|
|
1008
|
+
try {
|
|
1009
|
+
await api.updatePull(open.item.number, { state: 'closed', body: closedBody })
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
cleanupPushedBranchAndRethrow({
|
|
1012
|
+
error,
|
|
1013
|
+
root,
|
|
1014
|
+
branch,
|
|
1015
|
+
headSha,
|
|
1016
|
+
gitAuth,
|
|
1017
|
+
context: `pull request #${open.item.number} closure failed`,
|
|
1018
|
+
})
|
|
1019
|
+
}
|
|
1020
|
+
return closedBody
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const publicationBaseStandDownComment = (baseDecision, headSha) => baseDecision.reason === 'default-branch-unverifiable'
|
|
1024
|
+
? `model-eol is leaving this pull request closed because it could not re-verify the default branch after pushing evaluated migration head ${markdownCode(headSha)}. The new head is recorded as automated stale work and must be regenerated from a freshly evaluated base before reopening.`
|
|
1025
|
+
: `model-eol is leaving this pull request closed because the default branch moved from evaluated commit ${markdownCode(baseDecision.expectedBaseHead)} to ${markdownCode(baseDecision.currentBaseHead || 'missing')} after pushing migration head ${markdownCode(headSha)}. The new head is recorded as automated stale work and must be reevaluated before reopening.`
|
|
1026
|
+
|
|
1027
|
+
const closedBaseStandDownDecision = async ({ api, open, baseDecision, closedBody, headSha }) => {
|
|
1028
|
+
await api.comment(open.item.number, publicationBaseStandDownComment(baseDecision, headSha))
|
|
1029
|
+
return {
|
|
1030
|
+
...baseDecision,
|
|
1031
|
+
number: open.item.number,
|
|
1032
|
+
closedPullNumber: open.item.number,
|
|
1033
|
+
body: closedBody,
|
|
1034
|
+
headSha,
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const closePullForEvalFailure = async ({ api, open, evalResult }) => {
|
|
1039
|
+
await api.comment(open.item.number, `model-eol is closing this bot-owned pull request because its configured migration eval no longer passes (${markdownCode(evalResult.status)}). The migration remains blocked and can be regenerated after the eval clears.`)
|
|
1040
|
+
await api.updatePull(open.item.number, {
|
|
1041
|
+
state: 'closed',
|
|
1042
|
+
body: staleClosedBody(open.item.body, open.metadata),
|
|
1043
|
+
})
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const processEvalFailure = async ({ api, issueRecords, group, evalResult, root, now, issuesEnabled, open = null }) => {
|
|
1047
|
+
const evalIssueGroup = evalIssueGroupFor(group, evalResult, root)
|
|
1048
|
+
const issueDecision = issuesEnabled
|
|
1049
|
+
? await processIssue({ api, issues: issueRecords, group: evalIssueGroup, now })
|
|
1050
|
+
: null
|
|
1051
|
+
if (open) await closePullForEvalFailure({ api, open, evalResult })
|
|
1052
|
+
return decision(group, 'eval-failed', {
|
|
1053
|
+
evalResult,
|
|
1054
|
+
evalIssueGroup: issuesEnabled ? evalIssueGroup : null,
|
|
1055
|
+
issueAction: issueDecision?.action ?? null,
|
|
1056
|
+
issueNumber: issueDecision?.number ?? null,
|
|
1057
|
+
closedPullNumber: open?.item.number ?? null,
|
|
1058
|
+
})
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const processModel = async ({ api, pulls, issueRecords, group, source, base, baseHead, plan, config, checkerPath, externalEval, evalConfigHash, root, now, tokenKind, gitAuth }) => {
|
|
943
1062
|
const matches = matchingPulls(pulls, group, api.repo)
|
|
944
1063
|
const conflict = matches.find(record => record.conflict)
|
|
945
1064
|
if (conflict) return decision(group, 'conflict', { number: conflict.item.number })
|
|
@@ -956,9 +1075,9 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
|
|
|
956
1075
|
return decision(group, 'stand-down', { number: open.item.number })
|
|
957
1076
|
}
|
|
958
1077
|
if (externalEval && externalEval.status !== 'pass') {
|
|
959
|
-
return
|
|
1078
|
+
return processEvalFailure({ api, issueRecords, group, evalResult: externalEval, root, now, issuesEnabled: config.issues.enabled, open })
|
|
960
1079
|
}
|
|
961
|
-
if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead) {
|
|
1080
|
+
if (open.metadata.feed_digest === currentDigest && open.metadata.base_sha === baseHead && (!evalConfigHash || open.metadata.eval_config_digest === evalConfigHash)) {
|
|
962
1081
|
return decision(group, 'skip-unchanged', { number: open.item.number })
|
|
963
1082
|
}
|
|
964
1083
|
let patch
|
|
@@ -966,36 +1085,48 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
|
|
|
966
1085
|
patch = makePatch({
|
|
967
1086
|
source,
|
|
968
1087
|
base,
|
|
1088
|
+
expectedBaseHead: baseHead,
|
|
969
1089
|
branch: open.item.head?.ref || group.branch,
|
|
970
1090
|
expectedHead: open.metadata.head_sha,
|
|
971
1091
|
group,
|
|
972
1092
|
plan,
|
|
973
|
-
config,
|
|
974
1093
|
checkerPath,
|
|
975
|
-
evalEnabled,
|
|
976
|
-
root,
|
|
977
|
-
warn,
|
|
978
1094
|
gitAuth,
|
|
979
1095
|
})
|
|
980
1096
|
} 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
1097
|
if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
|
|
985
1098
|
await api.comment(open.item.number, standingDownComment(group, open.metadata, error.currentHead))
|
|
986
1099
|
return decision(group, 'stand-down', { number: open.item.number })
|
|
987
1100
|
}
|
|
988
|
-
|
|
989
|
-
|
|
1101
|
+
const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: externalEval, evalConfigHash })
|
|
1102
|
+
const baseDecision = publicationBaseDecision({ group, root, base, baseHead, gitAuth, number: open.item.number })
|
|
1103
|
+
if (baseDecision) {
|
|
1104
|
+
const closedBody = await closePullAtNewHead({ api, open, body, root, branch: group.branch, headSha: patch.headSha, gitAuth })
|
|
1105
|
+
return closedBaseStandDownDecision({ api, open, baseDecision, closedBody, headSha: patch.headSha })
|
|
1106
|
+
}
|
|
1107
|
+
const priorBody = open.item.body
|
|
1108
|
+
try {
|
|
1109
|
+
await api.updatePull(open.item.number, { title: pullTitle(group), body })
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
await recoverFailedPullUpdate({
|
|
1112
|
+
api,
|
|
1113
|
+
open,
|
|
1114
|
+
priorBody,
|
|
1115
|
+
error,
|
|
1116
|
+
root,
|
|
1117
|
+
group,
|
|
1118
|
+
headSha: patch.headSha,
|
|
1119
|
+
restoreHead: open.metadata.head_sha,
|
|
1120
|
+
rollbackTrusted: true,
|
|
1121
|
+
gitAuth,
|
|
1122
|
+
context: `pull request #${open.item.number} update failed`,
|
|
1123
|
+
})
|
|
990
1124
|
}
|
|
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
1125
|
return decision(group, 'update', { number: open.item.number, body, headSha: patch.headSha })
|
|
995
1126
|
}
|
|
996
1127
|
|
|
997
1128
|
if (externalEval && externalEval.status !== 'pass') {
|
|
998
|
-
return
|
|
1129
|
+
return processEvalFailure({ api, issueRecords, group, evalResult: externalEval, root, now, issuesEnabled: config.issues.enabled })
|
|
999
1130
|
}
|
|
1000
1131
|
|
|
1001
1132
|
const replacement = group.items[0].replacement
|
|
@@ -1004,6 +1135,11 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
|
|
|
1004
1135
|
|
|
1005
1136
|
const previous = matches.find(record => record.metadata?.head_sha) ?? null
|
|
1006
1137
|
const previousBotHead = previous?.metadata.head_sha ?? null
|
|
1138
|
+
const previousRollbackHead = previousBotHead
|
|
1139
|
+
&& verifyBotBranch(root, group.branch, previousBotHead, gitAuth).safe
|
|
1140
|
+
&& isCommitAvailable(root, previousBotHead)
|
|
1141
|
+
? previousBotHead
|
|
1142
|
+
: null
|
|
1007
1143
|
if (!previousBotHead) {
|
|
1008
1144
|
const occupied = verifyBotBranch(root, group.branch, null, gitAuth)
|
|
1009
1145
|
if (occupied.error) throw new Error(occupied.error)
|
|
@@ -1017,51 +1153,80 @@ const processModel = async ({ api, pulls, group, source, base, baseHead, plan, c
|
|
|
1017
1153
|
patch = makePatch({
|
|
1018
1154
|
source,
|
|
1019
1155
|
base,
|
|
1156
|
+
expectedBaseHead: baseHead,
|
|
1020
1157
|
branch: group.branch,
|
|
1021
1158
|
expectedHead: previousBotHead,
|
|
1022
1159
|
allowMissingBranch: Boolean(previousBotHead && !isOpen(previous.item)),
|
|
1023
1160
|
group,
|
|
1024
1161
|
plan,
|
|
1025
|
-
config,
|
|
1026
1162
|
checkerPath,
|
|
1027
|
-
evalEnabled,
|
|
1028
|
-
root,
|
|
1029
|
-
warn,
|
|
1030
1163
|
gitAuth,
|
|
1031
1164
|
})
|
|
1032
1165
|
} catch (error) {
|
|
1033
|
-
if (error.code === 'MODEL_EOL_EVAL_FAILED') {
|
|
1034
|
-
return decision(group, 'eval-failed', { evalResult: error.evalResult })
|
|
1035
|
-
}
|
|
1036
1166
|
if (error.code !== 'MODEL_EOL_BRANCH_STAND_DOWN') throw error
|
|
1037
1167
|
const prior = matches.find(record => record.metadata?.head_sha === previousBotHead)
|
|
1038
1168
|
if (prior) await api.comment(prior.item.number, standingDownComment(group, prior.metadata, error.currentHead))
|
|
1039
1169
|
return decision(group, 'stand-down', { number: prior?.item.number })
|
|
1040
1170
|
}
|
|
1041
|
-
|
|
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 })
|
|
1171
|
+
const body = buildPullBody({ group, headSha: patch.headSha, baseSha: baseHead, now, tokenKind, evalResult: externalEval, evalConfigHash })
|
|
1046
1172
|
const latestPulls = await api.listPullsByHead(group.branch)
|
|
1047
1173
|
const latestMatches = matchingPulls(latestPulls, group, api.repo)
|
|
1048
1174
|
const latestConflict = latestMatches.find(record => record.conflict)
|
|
1049
1175
|
if (latestConflict) return decision(group, 'conflict', { number: latestConflict.item.number })
|
|
1050
1176
|
const latestOpen = latestMatches.find(record => isOpen(record.item))
|
|
1051
1177
|
if (latestOpen) {
|
|
1052
|
-
|
|
1178
|
+
const baseDecision = publicationBaseDecision({ group, root, base, baseHead, gitAuth, number: latestOpen.item.number })
|
|
1179
|
+
if (baseDecision) {
|
|
1180
|
+
const closedBody = await closePullAtNewHead({ api, open: latestOpen, body, root, branch: group.branch, headSha: patch.headSha, gitAuth })
|
|
1181
|
+
return closedBaseStandDownDecision({ api, open: latestOpen, baseDecision, closedBody, headSha: patch.headSha })
|
|
1182
|
+
}
|
|
1183
|
+
const priorBody = latestOpen.item.body
|
|
1184
|
+
try {
|
|
1185
|
+
await api.updatePull(latestOpen.item.number, { title: pullTitle(group), body })
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
await recoverFailedPullUpdate({
|
|
1188
|
+
api,
|
|
1189
|
+
open: latestOpen,
|
|
1190
|
+
priorBody,
|
|
1191
|
+
error,
|
|
1192
|
+
root,
|
|
1193
|
+
group,
|
|
1194
|
+
headSha: patch.headSha,
|
|
1195
|
+
restoreHead: previousRollbackHead,
|
|
1196
|
+
rollbackTrusted: Boolean(previousRollbackHead && latestOpen.metadata.head_sha === previousRollbackHead),
|
|
1197
|
+
gitAuth,
|
|
1198
|
+
context: `concurrent pull request #${latestOpen.item.number} update failed`,
|
|
1199
|
+
})
|
|
1200
|
+
}
|
|
1053
1201
|
return decision(group, 'update', { number: latestOpen.item.number, body, headSha: patch.headSha })
|
|
1054
1202
|
}
|
|
1055
1203
|
const latestUntrusted = latestPulls.find(item => pullOnExpectedBranch(item, group, api.repo) && !latestMatches.some(record => record.item.number === item.number))
|
|
1056
1204
|
if (latestUntrusted) return decision(group, 'conflict', { number: latestUntrusted.number })
|
|
1057
1205
|
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
1206
|
if (latestSuppressed) return decision(group, 'skip-dismissed', { number: latestSuppressed.item.number })
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1207
|
+
const baseDecision = publicationBaseDecision({ group, root, base, baseHead, gitAuth })
|
|
1208
|
+
if (baseDecision) {
|
|
1209
|
+
deleteRemoteBranch(root, group.branch, patch.headSha, gitAuth)
|
|
1210
|
+
return { ...baseDecision, headSha: patch.headSha, deletedBranch: group.branch }
|
|
1211
|
+
}
|
|
1212
|
+
let created
|
|
1213
|
+
try {
|
|
1214
|
+
created = await api.createPull({
|
|
1215
|
+
title: pullTitle(group),
|
|
1216
|
+
head: group.branch,
|
|
1217
|
+
base,
|
|
1218
|
+
body,
|
|
1219
|
+
})
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
cleanupPushedBranchAndRethrow({
|
|
1222
|
+
error,
|
|
1223
|
+
root,
|
|
1224
|
+
branch: group.branch,
|
|
1225
|
+
headSha: patch.headSha,
|
|
1226
|
+
gitAuth,
|
|
1227
|
+
context: 'pull request creation failed',
|
|
1228
|
+
})
|
|
1229
|
+
}
|
|
1065
1230
|
return decision(group, 'create', { number: created.number, body, headSha: patch.headSha })
|
|
1066
1231
|
}
|
|
1067
1232
|
|
|
@@ -1093,9 +1258,9 @@ const processIssue = async ({ api, issues, group, now }) => {
|
|
|
1093
1258
|
return decision(group, 'create', { number: created.number, body })
|
|
1094
1259
|
}
|
|
1095
1260
|
|
|
1096
|
-
const dryDecision = (group, now, tokenKind) => group.kind === 'model'
|
|
1261
|
+
const dryDecision = (group, now, tokenKind, evalResult = null, evalConfigHash = null) => group.kind === 'model'
|
|
1097
1262
|
? decision(group, 'create', {
|
|
1098
|
-
body: buildPullBody({ group, headSha: 'dry-run', now, tokenKind }),
|
|
1263
|
+
body: buildPullBody({ group, headSha: 'dry-run', now, tokenKind, evalResult, evalConfigHash }),
|
|
1099
1264
|
branch: group.branch,
|
|
1100
1265
|
})
|
|
1101
1266
|
: decision(group, 'create', { body: buildIssueBody({ group, now }) })
|
|
@@ -1178,6 +1343,9 @@ export const runBot = async ({
|
|
|
1178
1343
|
targetDir = process.cwd(),
|
|
1179
1344
|
configPath = null,
|
|
1180
1345
|
dryRun = false,
|
|
1346
|
+
deprecatedInlineEval = false,
|
|
1347
|
+
// Programmatic compatibility aliases are accepted only to fail with the
|
|
1348
|
+
// same migration guidance as the removed CLI/environment contracts.
|
|
1181
1349
|
evalEnabled = false,
|
|
1182
1350
|
feedsUrls = [],
|
|
1183
1351
|
token = process.env.GITHUB_TOKEN,
|
|
@@ -1195,6 +1363,12 @@ export const runBot = async ({
|
|
|
1195
1363
|
now = new Date(),
|
|
1196
1364
|
warn = message => console.error(message),
|
|
1197
1365
|
} = {}) => {
|
|
1366
|
+
if (deprecatedInlineEval || evalEnabled) {
|
|
1367
|
+
throw new Error('inline --eval was removed because publication is write-capable; run `model-eol-bot evaluate` in a read-only job and pass its manifest with MODEL_EOL_EVAL_RESULTS_FILE')
|
|
1368
|
+
}
|
|
1369
|
+
if (evalReportFile || evalStatusFile) {
|
|
1370
|
+
throw new Error('MODEL_EOL_EVAL_REPORT_FILE and MODEL_EOL_EVAL_STATUS_FILE were removed because they are not commit-bound; use MODEL_EOL_EVAL_RESULTS_FILE from `model-eol-bot evaluate`')
|
|
1371
|
+
}
|
|
1198
1372
|
if (!dryRun) {
|
|
1199
1373
|
validateRepo(repo)
|
|
1200
1374
|
if (!token) throw new Error('GITHUB_TOKEN is required unless --dry-run is used')
|
|
@@ -1226,8 +1400,25 @@ export const runBot = async ({
|
|
|
1226
1400
|
const records = feedContext(feedSet.dir)
|
|
1227
1401
|
const models = buildModelGroups(plan, config, scan.path, records)
|
|
1228
1402
|
const issues = config.issues.enabled ? buildIssueGroups(plan, config, scan.path, records) : []
|
|
1229
|
-
|
|
1230
|
-
|
|
1403
|
+
if (feedSet.degraded) {
|
|
1404
|
+
const report = group => reportOnlyDecision(group, now, tokenKind)
|
|
1405
|
+
const decisions = [...models.map(report), ...issues.map(report)]
|
|
1406
|
+
return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
|
|
1407
|
+
}
|
|
1408
|
+
const boundEval = boundEvalResults({
|
|
1409
|
+
file: evalResultsFile,
|
|
1410
|
+
config,
|
|
1411
|
+
commandOverride: evalCommandOverride,
|
|
1412
|
+
plan,
|
|
1413
|
+
groups: models,
|
|
1414
|
+
baseHead: evalResultsFile ? gitHead(scan.path) : null,
|
|
1415
|
+
})
|
|
1416
|
+
const decisions = models.map(group => {
|
|
1417
|
+
const evalResult = boundEval.results?.get(evalIdentity(group.publisher, group.id, group.via)) ?? null
|
|
1418
|
+
if (evalResult && evalResult.status !== 'pass') return decision(group, 'eval-failed', { evalResult })
|
|
1419
|
+
return dryDecision(group, now, tokenKind, evalResult, boundEval.configDigest)
|
|
1420
|
+
})
|
|
1421
|
+
decisions.push(...issues.map(group => dryDecision(group, now, tokenKind)))
|
|
1231
1422
|
return { plan, config, decisions, feedsDir: feedSet.dir, degraded: Boolean(feedSet.degraded) }
|
|
1232
1423
|
}
|
|
1233
1424
|
|
|
@@ -1257,27 +1448,29 @@ export const runBot = async ({
|
|
|
1257
1448
|
const decisions = [...models.map(report), ...issues.map(report)]
|
|
1258
1449
|
return { plan, config, decisions, feedsDir: feedSet.dir, degraded: true }
|
|
1259
1450
|
}
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
)
|
|
1451
|
+
const boundEval = boundEvalResults({
|
|
1452
|
+
file: evalResultsFile,
|
|
1453
|
+
config,
|
|
1454
|
+
commandOverride: evalCommandOverride,
|
|
1455
|
+
plan,
|
|
1456
|
+
groups: models,
|
|
1457
|
+
baseHead,
|
|
1458
|
+
})
|
|
1268
1459
|
const api = new GitHubClient({ repo, apiUrl, token, transport, warn })
|
|
1269
1460
|
const labelReady = models.length || issues.length ? await api.ensureModelEolLabel() : true
|
|
1270
1461
|
const pulls = await api.listPulls()
|
|
1271
1462
|
const issueRecords = await api.listIssues()
|
|
1272
1463
|
const decisions = []
|
|
1464
|
+
const evalIssueGroups = []
|
|
1273
1465
|
if (!labelReady) {
|
|
1274
1466
|
for (const group of [...models, ...issues]) decisions.push(decision(group, 'label-unavailable'))
|
|
1275
1467
|
} else {
|
|
1276
1468
|
for (const group of models) {
|
|
1277
|
-
const externalEval =
|
|
1469
|
+
const externalEval = boundEval.results?.get(evalIdentity(group.publisher, group.id, group.via)) ?? null
|
|
1278
1470
|
decisions.push(await processModel({
|
|
1279
1471
|
api,
|
|
1280
1472
|
pulls,
|
|
1473
|
+
issueRecords,
|
|
1281
1474
|
group,
|
|
1282
1475
|
source,
|
|
1283
1476
|
base,
|
|
@@ -1285,18 +1478,21 @@ export const runBot = async ({
|
|
|
1285
1478
|
plan,
|
|
1286
1479
|
config,
|
|
1287
1480
|
checkerPath,
|
|
1288
|
-
evalEnabled,
|
|
1289
1481
|
externalEval,
|
|
1482
|
+
evalConfigHash: boundEval.configDigest,
|
|
1290
1483
|
root: baseClone,
|
|
1291
1484
|
now,
|
|
1292
1485
|
tokenKind,
|
|
1293
|
-
warn,
|
|
1294
1486
|
gitAuth,
|
|
1295
1487
|
}))
|
|
1488
|
+
const latestDecision = decisions.at(-1)
|
|
1489
|
+
if (latestDecision?.evalIssueGroup) evalIssueGroups.push(latestDecision.evalIssueGroup)
|
|
1296
1490
|
}
|
|
1297
1491
|
for (const group of issues) decisions.push(await processIssue({ api, issues: issueRecords, group, now }))
|
|
1298
1492
|
}
|
|
1299
|
-
|
|
1493
|
+
if (labelReady) {
|
|
1494
|
+
decisions.push(...await reconcileStaleWork({ api, pulls, issues: issueRecords, models, issueGroups: [...issues, ...evalIssueGroups] }))
|
|
1495
|
+
}
|
|
1300
1496
|
return { plan, config, decisions, feedsDir: feedSet.dir, degraded: false }
|
|
1301
1497
|
} finally {
|
|
1302
1498
|
scan?.cleanup()
|
|
@@ -1334,7 +1530,7 @@ export const main = async (argv = process.argv.slice(2), env = process.env) => {
|
|
|
1334
1530
|
targetDir: options.targetDir,
|
|
1335
1531
|
configPath: options.configPath,
|
|
1336
1532
|
dryRun: options.dryRun,
|
|
1337
|
-
|
|
1533
|
+
deprecatedInlineEval: options.deprecatedInlineEval,
|
|
1338
1534
|
feedsUrls: options.feedsUrls,
|
|
1339
1535
|
planFile: env.MODEL_EOL_PLAN_FILE || null,
|
|
1340
1536
|
evalReportFile: env.MODEL_EOL_EVAL_REPORT_FILE || null,
|