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