model-eol 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -22
- package/action.yml +124 -9
- package/bot/bot.mjs +1371 -0
- package/bot/lib/common.mjs +122 -0
- package/bot/lib/config.mjs +1 -0
- package/bot/lib/eval.mjs +103 -0
- package/bot/lib/feeds.mjs +48 -0
- package/bot/lib/git.mjs +131 -0
- package/bot/lib/github.mjs +187 -0
- package/bot.yml.example +145 -0
- package/check.mjs +151 -17
- package/feeds/amazon.json +1 -1
- package/feeds/anthropic.json +1 -1
- package/feeds/google.json +88 -5
- package/feeds/openai.json +11 -26
- package/lib/config.mjs +258 -0
- package/lib/feeds.mjs +38 -4
- package/lib/glob.mjs +35 -0
- package/lib/plan.mjs +27 -8
- package/lib/reports.mjs +24 -7
- package/lib/scanner.mjs +247 -70
- package/package.json +8 -4
- package/schema/model-eol.bot-config.schema.json +73 -8
- package/schema/model-eol.inventory.schema.json +21 -0
- package/schema/model-eol.plan.schema.json +22 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export { matchesAnyGlob, matchesGlob } from '../../lib/glob.mjs'
|
|
5
|
+
|
|
6
|
+
export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
|
|
7
|
+
|
|
8
|
+
const stableValue = value => {
|
|
9
|
+
if (Array.isArray(value)) return value.map(stableValue)
|
|
10
|
+
if (value && typeof value === 'object') {
|
|
11
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]))
|
|
12
|
+
}
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const stableJson = value => JSON.stringify(stableValue(value))
|
|
17
|
+
|
|
18
|
+
export const sortedJsonDigest = values => {
|
|
19
|
+
const sorted = values.slice().sort((a, b) => stableJson(a).localeCompare(stableJson(b)))
|
|
20
|
+
return sha256(stableJson(sorted))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const safeSegment = value => {
|
|
24
|
+
const result = String(value ?? '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9._-]/g, '-')
|
|
27
|
+
.replace(/-+/g, '-')
|
|
28
|
+
.replace(/^-+|-+$/g, '')
|
|
29
|
+
return result || 'model'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const slugFor = id => {
|
|
33
|
+
const slug = safeSegment(id).slice(0, 40).replace(/^-+|-+$/g, '')
|
|
34
|
+
return slug || 'model'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const branchFor = (publisher, id, via = null) =>
|
|
38
|
+
`model-eol/${safeSegment(publisher)}${via ? `/${safeSegment(via)}` : ''}/${slugFor(id)}-${sha256(String(id)).slice(0, 8)}`
|
|
39
|
+
|
|
40
|
+
export const daysRemaining = (shutdown, now = new Date()) => {
|
|
41
|
+
if (!shutdown) return null
|
|
42
|
+
return Math.ceil((new Date(`${shutdown}T00:00:00Z`) - now) / 86400000)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const repoPath = (file, root) => {
|
|
46
|
+
const absolute = path.isAbsolute(file) ? file : path.resolve(root, file)
|
|
47
|
+
const relative = path.relative(root, absolute)
|
|
48
|
+
return relative.split(path.sep).join('/').replace(/^\.\//, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const metadataLine = metadata => `<!-- model-eol ${JSON.stringify(metadata).replaceAll('-->', '\\u002d\\u002d\\u003e')} -->`
|
|
52
|
+
|
|
53
|
+
export const hasMetadataMarker = body => String(body ?? '').includes('<!-- model-eol')
|
|
54
|
+
|
|
55
|
+
export const parseMetadata = body => {
|
|
56
|
+
const firstLine = String(body ?? '').split(/\r?\n/, 1)[0]
|
|
57
|
+
const match = firstLine.match(/^<!-- model-eol (\{.*\}) -->$/)
|
|
58
|
+
if (!match) return null
|
|
59
|
+
try {
|
|
60
|
+
const metadata = JSON.parse(match[1])
|
|
61
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
|
|
62
|
+
if (metadata.schema !== 'model-eol.bot/0.1') return null
|
|
63
|
+
if (typeof metadata.id !== 'string' || !metadata.id) return null
|
|
64
|
+
if (typeof metadata.publisher !== 'string' || !metadata.publisher) return null
|
|
65
|
+
if (!Object.hasOwn(metadata, 'shutdown') || (metadata.shutdown !== null && typeof metadata.shutdown !== 'string')) return null
|
|
66
|
+
if (metadata.via !== undefined && metadata.via !== null && typeof metadata.via !== 'string') return null
|
|
67
|
+
if (metadata.replacement !== undefined && metadata.replacement !== null && typeof metadata.replacement !== 'string') return null
|
|
68
|
+
if (metadata.replacement_options !== undefined && (!Array.isArray(metadata.replacement_options) || metadata.replacement_options.some(option => typeof option !== 'string'))) return null
|
|
69
|
+
if (metadata.replacement_note !== undefined && metadata.replacement_note !== null && typeof metadata.replacement_note !== 'string') return null
|
|
70
|
+
if (metadata.base_sha !== undefined && metadata.base_sha !== null && (typeof metadata.base_sha !== 'string' || !/^[0-9a-f]{40,64}$/.test(metadata.base_sha))) return null
|
|
71
|
+
if (metadata.stale_closed !== undefined && metadata.stale_closed !== true) return null
|
|
72
|
+
if (typeof metadata.feed_digest !== 'string' || !metadata.feed_digest) return null
|
|
73
|
+
if (!Object.hasOwn(metadata, 'head_sha') || (metadata.head_sha !== null && typeof metadata.head_sha !== 'string')) return null
|
|
74
|
+
if (metadata.channel !== undefined && (typeof metadata.channel !== 'string' || !metadata.channel)) return null
|
|
75
|
+
return metadata
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const itemDigest = items => sortedJsonDigest(items)
|
|
82
|
+
|
|
83
|
+
const entityMap = new Map([
|
|
84
|
+
['&', '&'],
|
|
85
|
+
['<', '<'],
|
|
86
|
+
['>', '>'],
|
|
87
|
+
['`', '`'],
|
|
88
|
+
['\\', '\'],
|
|
89
|
+
['[', '['],
|
|
90
|
+
[']', ']'],
|
|
91
|
+
['(', '('],
|
|
92
|
+
[')', ')'],
|
|
93
|
+
['#', '#'],
|
|
94
|
+
['*', '*'],
|
|
95
|
+
['_', '_'],
|
|
96
|
+
['~', '~'],
|
|
97
|
+
['!', '!'],
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
export const markdownText = value => String(value ?? '')
|
|
101
|
+
.replaceAll('\r\n', '\n')
|
|
102
|
+
.replaceAll('\r', '\n')
|
|
103
|
+
.replace(/[&<>`\\[\]()#*_~!]/g, character => entityMap.get(character))
|
|
104
|
+
.split('\n')
|
|
105
|
+
.map(line => line.replace(/^(\s*)([-+>]\s|\d+\.\s)/, '$1- '))
|
|
106
|
+
.join('\n')
|
|
107
|
+
|
|
108
|
+
export const markdownCode = value => `\`${markdownText(value)}\``
|
|
109
|
+
|
|
110
|
+
export const markdownLink = (label, value) => {
|
|
111
|
+
const text = String(value ?? '')
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(text)
|
|
114
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return markdownText(text)
|
|
115
|
+
const href = url.toString().replaceAll('\\', '%5C').replaceAll('(', '%28').replaceAll(')', '%29')
|
|
116
|
+
return `[${markdownText(label)}](${href})`
|
|
117
|
+
} catch {
|
|
118
|
+
return markdownText(text)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const markdownFenceText = value => String(value ?? '').replaceAll('`', '')
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DEFAULT_CONFIG, loadConfig, normalizeConfig } from '../../lib/config.mjs'
|
package/bot/lib/eval.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { markdownFenceText } from './common.mjs'
|
|
4
|
+
|
|
5
|
+
const truncateReport = (bytes, limit, truncated) => {
|
|
6
|
+
if (!truncated) return bytes.toString('utf8')
|
|
7
|
+
const marker = Buffer.from('\n[model-eol: report truncated]', 'utf8')
|
|
8
|
+
if (limit <= marker.length) return marker.subarray(0, limit).toString('utf8')
|
|
9
|
+
return Buffer.concat([bytes.subarray(0, limit - marker.length), marker]).subarray(0, limit).toString('utf8')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const capReport = (report, limit) => {
|
|
13
|
+
if (report === null || report === undefined) return null
|
|
14
|
+
const bytes = Buffer.from(String(report), 'utf8')
|
|
15
|
+
return truncateReport(bytes, Math.max(0, limit), bytes.length > limit)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const readReportCapped = (file, limit) => {
|
|
19
|
+
const cap = Math.max(0, limit)
|
|
20
|
+
let lstat
|
|
21
|
+
try {
|
|
22
|
+
lstat = fs.lstatSync(file)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (error.code === 'ENOENT') return { missing: true, report: null }
|
|
25
|
+
throw error
|
|
26
|
+
}
|
|
27
|
+
if (!lstat.isFile() || lstat.isSymbolicLink()) throw new Error(`${file} is not a regular non-symlink file`)
|
|
28
|
+
let fd
|
|
29
|
+
try {
|
|
30
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0))
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code === 'ENOENT') return { missing: true, report: null }
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.fstatSync(fd).isFile()) throw new Error(`${file} is not a regular non-symlink file`)
|
|
37
|
+
} catch (error) {
|
|
38
|
+
fs.closeSync(fd)
|
|
39
|
+
throw error
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const buffer = Buffer.alloc(cap + 1)
|
|
43
|
+
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0)
|
|
44
|
+
return {
|
|
45
|
+
missing: false,
|
|
46
|
+
report: truncateReport(buffer.subarray(0, Math.min(bytesRead, cap)), cap, bytesRead > cap),
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
fs.closeSync(fd)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const runEvalHook = ({ command, timeoutMs, maxReportBytes, passEnv = [], cwd, oldId, newId, planPath, reportPath }) => {
|
|
54
|
+
if (fs.existsSync(reportPath)) fs.rmSync(reportPath, { force: true })
|
|
55
|
+
const env = {}
|
|
56
|
+
for (const key of ['PATH', 'HOME', 'TMPDIR', 'LANG', 'USER', 'SHELL']) {
|
|
57
|
+
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
58
|
+
}
|
|
59
|
+
for (const key of passEnv) {
|
|
60
|
+
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
61
|
+
}
|
|
62
|
+
env.MODEL_EOL_OLD_ID = oldId
|
|
63
|
+
env.MODEL_EOL_NEW_ID = newId
|
|
64
|
+
env.MODEL_EOL_PLAN = planPath
|
|
65
|
+
env.MODEL_EOL_REPORT = reportPath
|
|
66
|
+
|
|
67
|
+
const result = spawnSync(command, {
|
|
68
|
+
cwd,
|
|
69
|
+
env,
|
|
70
|
+
shell: true,
|
|
71
|
+
timeout: timeoutMs,
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
74
|
+
detached: true,
|
|
75
|
+
})
|
|
76
|
+
const timedOut = result.error?.code === 'ETIMEDOUT'
|
|
77
|
+
|| result.error?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
|
78
|
+
if (timedOut && Number.isInteger(result.pid)) {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(-result.pid, 'SIGKILL')
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== 'ESRCH') throw new Error(`could not kill eval process group: ${error.message}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const exitCode = Number.isInteger(result.status) ? result.status : null
|
|
86
|
+
let status = timedOut ? 'timeout' : exitCode === 0 ? 'pass' : 'fail'
|
|
87
|
+
let report = null
|
|
88
|
+
try {
|
|
89
|
+
const artifact = readReportCapped(reportPath, maxReportBytes)
|
|
90
|
+
if (artifact.missing && status === 'pass') {
|
|
91
|
+
status = 'fail'
|
|
92
|
+
report = 'eval report artifact is missing'
|
|
93
|
+
} else {
|
|
94
|
+
report = artifact.report
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (status === 'pass') status = 'fail'
|
|
98
|
+
report = `eval report artifact is unreadable: ${error.message}`
|
|
99
|
+
}
|
|
100
|
+
return { status, exit_code: exitCode, report }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const reportForBody = report => markdownFenceText(report)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { assertValidFeed } from '../../lib/validate-feed.mjs'
|
|
6
|
+
|
|
7
|
+
const responseText = async response => {
|
|
8
|
+
if (typeof response?.text === 'function') return response.text()
|
|
9
|
+
if (typeof response?.body === 'string') return response.body
|
|
10
|
+
if (response?.data !== undefined) return JSON.stringify(response.data)
|
|
11
|
+
return JSON.stringify(response)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const downloadFeeds = async ({
|
|
15
|
+
urls = [],
|
|
16
|
+
fetchImpl = globalThis.fetch,
|
|
17
|
+
vendoredDir,
|
|
18
|
+
allowVendoredFallback = false,
|
|
19
|
+
warn = console.error,
|
|
20
|
+
}) => {
|
|
21
|
+
if (!urls.length) return { dir: vendoredDir, cleanup: () => {} }
|
|
22
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'model-eol-feeds-'))
|
|
23
|
+
try {
|
|
24
|
+
for (const [index, url] of urls.entries()) {
|
|
25
|
+
if (typeof fetchImpl !== 'function') throw new Error('feed fetch transport is not callable')
|
|
26
|
+
const response = await fetchImpl(url)
|
|
27
|
+
const status = typeof response?.status === 'number' ? response.status : 200
|
|
28
|
+
if (status >= 400 || response?.ok === false) throw new Error(`${url} returned HTTP ${status}`)
|
|
29
|
+
let value
|
|
30
|
+
try {
|
|
31
|
+
value = JSON.parse(await responseText(response))
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error(`${url} was not valid JSON: ${error.message}`)
|
|
34
|
+
}
|
|
35
|
+
assertValidFeed(value, url)
|
|
36
|
+
fs.writeFileSync(path.join(tempDir, `feed-${index}.json`), JSON.stringify(value, null, 2))
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
dir: tempDir,
|
|
40
|
+
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
44
|
+
if (!allowVendoredFallback) throw new Error(`feed download failed: ${error.message}`)
|
|
45
|
+
warn(`model-eol: warning: feed download failed (${error.message}); using vendored feeds in degraded report-only mode`)
|
|
46
|
+
return { dir: vendoredDir, cleanup: () => {}, degraded: true }
|
|
47
|
+
}
|
|
48
|
+
}
|
package/bot/lib/git.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
export const BOT_IDENTITY_EMAIL = 'model-eol[bot]@users.noreply.github.com'
|
|
6
|
+
|
|
7
|
+
const environmentFor = auth => auth
|
|
8
|
+
? {
|
|
9
|
+
...process.env,
|
|
10
|
+
GIT_CONFIG_COUNT: '1',
|
|
11
|
+
GIT_CONFIG_KEY_0: auth.key,
|
|
12
|
+
GIT_CONFIG_VALUE_0: auth.value,
|
|
13
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
14
|
+
GIT_TRACE_REDACT: '1',
|
|
15
|
+
}
|
|
16
|
+
: process.env
|
|
17
|
+
|
|
18
|
+
const run = (cwd, args, allowFailure = false, auth = null) => {
|
|
19
|
+
const result = spawnSync('git', args, {
|
|
20
|
+
cwd,
|
|
21
|
+
env: environmentFor(auth),
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
24
|
+
})
|
|
25
|
+
if (!result.error && result.status === 0) return result.stdout.trim()
|
|
26
|
+
if (allowFailure) return null
|
|
27
|
+
const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`
|
|
28
|
+
throw new Error(`git ${args.join(' ')} failed: ${detail}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const git = run
|
|
32
|
+
|
|
33
|
+
export const gitAuthentication = (source, token, apiUrl = 'https://api.github.com') => {
|
|
34
|
+
if (!token) return null
|
|
35
|
+
let url
|
|
36
|
+
let api
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(source)
|
|
39
|
+
api = new URL(apiUrl)
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
if (url.protocol !== 'https:') return null
|
|
44
|
+
const expectedHost = api.host === 'api.github.com' ? 'github.com' : api.host
|
|
45
|
+
if (api.protocol !== 'https:' || url.host !== expectedHost) return null
|
|
46
|
+
const scope = `${url.protocol}//${url.host}/`
|
|
47
|
+
const credential = Buffer.from(`x-access-token:${token}`).toString('base64')
|
|
48
|
+
return {
|
|
49
|
+
key: `http.${scope}.extraheader`,
|
|
50
|
+
value: `AUTHORIZATION: basic ${credential}`,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const originFor = targetDir =>
|
|
55
|
+
run(targetDir, ['remote', 'get-url', 'origin'], true) || targetDir
|
|
56
|
+
|
|
57
|
+
export const cloneRepository = (source, destination, auth = null) => {
|
|
58
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
|
59
|
+
run(null, ['clone', '--quiet', '--', source, destination], false, auth)
|
|
60
|
+
return destination
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const defaultBranch = (cwd, auth = null) => {
|
|
64
|
+
const remoteHead = run(cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], true)
|
|
65
|
+
if (remoteHead?.startsWith('origin/')) return remoteHead.slice('origin/'.length)
|
|
66
|
+
const current = run(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD'], true)
|
|
67
|
+
if (current) return current
|
|
68
|
+
const remoteShow = run(cwd, ['remote', 'show', 'origin'], true, auth)
|
|
69
|
+
const match = remoteShow?.match(/HEAD branch:\s*(\S+)/)
|
|
70
|
+
if (match) return match[1]
|
|
71
|
+
return 'main'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const prepareBranch = (cwd, branch, base, auth = null) => {
|
|
75
|
+
run(cwd, ['fetch', 'origin', '--prune'], false, auth)
|
|
76
|
+
const remoteBase = run(cwd, ['rev-parse', '--verify', `refs/remotes/origin/${base}`], true)
|
|
77
|
+
run(cwd, ['checkout', '-B', branch, remoteBase ? `origin/${base}` : base])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const configureIdentity = cwd => {
|
|
81
|
+
run(cwd, ['config', 'user.name', 'model-eol[bot]'])
|
|
82
|
+
run(cwd, ['config', 'user.email', BOT_IDENTITY_EMAIL])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const commitAll = (cwd, files, message) => {
|
|
86
|
+
const unique = [...new Set(files)]
|
|
87
|
+
if (unique.length) run(cwd, ['add', '--', ...unique])
|
|
88
|
+
run(cwd, ['commit', '--allow-empty', '-m', message])
|
|
89
|
+
return run(cwd, ['rev-parse', 'HEAD'])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const verifyBotBranch = (cwd, branch, expectedHead, auth = null) => {
|
|
93
|
+
try {
|
|
94
|
+
run(cwd, ['fetch', 'origin', '--prune'], false, auth)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return { head: null, committerEmail: null, safe: false, error: error.message }
|
|
97
|
+
}
|
|
98
|
+
const head = run(cwd, ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], true)
|
|
99
|
+
const committerEmail = head ? run(cwd, ['show', '-s', '--format=%cE', head], true) : null
|
|
100
|
+
return {
|
|
101
|
+
head,
|
|
102
|
+
committerEmail,
|
|
103
|
+
safe: head === expectedHead && committerEmail === BOT_IDENTITY_EMAIL,
|
|
104
|
+
error: null,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const pushBranch = (cwd, branch, expectedHead = null, auth = null, { allowMissing = false } = {}) => {
|
|
109
|
+
const destination = `HEAD:refs/heads/${branch}`
|
|
110
|
+
if (expectedHead !== null) {
|
|
111
|
+
const state = verifyBotBranch(cwd, branch, expectedHead, auth)
|
|
112
|
+
if (!state.safe && !(allowMissing && !state.error && state.head === null)) {
|
|
113
|
+
const error = new Error(state.error || `refusing force-push: branch head ${state.head || 'missing'} or committer email ${state.committerEmail || 'missing'} failed bot lease checks`)
|
|
114
|
+
error.code = 'MODEL_EOL_BRANCH_STAND_DOWN'
|
|
115
|
+
error.currentHead = state.head
|
|
116
|
+
error.committerEmail = state.committerEmail
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
if (state.head === null) run(cwd, ['push', 'origin', destination], false, auth)
|
|
120
|
+
else {
|
|
121
|
+
run(cwd, [
|
|
122
|
+
'push',
|
|
123
|
+
`--force-with-lease=refs/heads/${branch}:${expectedHead}`,
|
|
124
|
+
'origin',
|
|
125
|
+
destination,
|
|
126
|
+
], false, auth)
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
run(cwd, ['push', 'origin', destination], false, auth)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const linkFor = (headers, relation) => {
|
|
2
|
+
const value = headers?.get?.('link') || headers?.get?.('Link') || headers?.link || headers?.Link || ''
|
|
3
|
+
const match = value.split(',').map(part => part.trim()).find(part => part.endsWith(`rel="${relation}"`))
|
|
4
|
+
return match?.match(/^<([^>]+)>/)?.[1] ?? null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const bodyFromResponse = async response => {
|
|
8
|
+
if (response === null || response === undefined) return null
|
|
9
|
+
if (Array.isArray(response)) return response
|
|
10
|
+
if (typeof response.json === 'function') {
|
|
11
|
+
try {
|
|
12
|
+
return await response.json()
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (response.status === 204) return null
|
|
15
|
+
throw error
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (response.data !== undefined) return response.data
|
|
19
|
+
if (response.body !== undefined) {
|
|
20
|
+
if (typeof response.body === 'string') {
|
|
21
|
+
try { return JSON.parse(response.body) } catch { return response.body }
|
|
22
|
+
}
|
|
23
|
+
return response.body
|
|
24
|
+
}
|
|
25
|
+
return response
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MODEL_EOL_LABEL = {
|
|
29
|
+
name: 'model-eol',
|
|
30
|
+
color: 'b60205',
|
|
31
|
+
description: 'AI model lifecycle migration tracked by model-eol',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class GitHubClient {
|
|
35
|
+
constructor({ repo, apiUrl = 'https://api.github.com', token, transport = globalThis.fetch, warn = console.error }) {
|
|
36
|
+
this.repo = repo
|
|
37
|
+
this.apiUrl = apiUrl.replace(/\/$/, '')
|
|
38
|
+
this.token = token
|
|
39
|
+
this.transport = transport
|
|
40
|
+
this.warn = warn
|
|
41
|
+
this.modelEolLabelAvailable = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
url(value) {
|
|
45
|
+
return value.startsWith('http://') || value.startsWith('https://')
|
|
46
|
+
? value
|
|
47
|
+
: `${this.apiUrl}${value}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async request(method, endpoint, body = undefined) {
|
|
51
|
+
if (typeof this.transport !== 'function') throw new Error('GitHub transport is not callable')
|
|
52
|
+
const headers = {
|
|
53
|
+
accept: 'application/vnd.github+json',
|
|
54
|
+
authorization: `Bearer ${this.token}`,
|
|
55
|
+
'x-github-api-version': '2022-11-28',
|
|
56
|
+
}
|
|
57
|
+
const options = { method, headers }
|
|
58
|
+
if (body !== undefined) {
|
|
59
|
+
headers['content-type'] = 'application/json'
|
|
60
|
+
options.body = JSON.stringify(body)
|
|
61
|
+
}
|
|
62
|
+
const response = await this.transport(this.url(endpoint), options)
|
|
63
|
+
const status = typeof response?.status === 'number' ? response.status : 200
|
|
64
|
+
const data = await bodyFromResponse(response)
|
|
65
|
+
if (status >= 400 || response?.ok === false) {
|
|
66
|
+
const detail = typeof data === 'string' ? data : JSON.stringify(data)
|
|
67
|
+
const error = new Error(`GitHub ${method} ${endpoint} failed (${status}): ${detail}`)
|
|
68
|
+
error.status = status
|
|
69
|
+
error.data = data
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
return { data, response }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async ensureModelEolLabel() {
|
|
76
|
+
const endpoint = `/repos/${this.repo}/labels/${encodeURIComponent(MODEL_EOL_LABEL.name)}`
|
|
77
|
+
try {
|
|
78
|
+
await this.request('GET', endpoint)
|
|
79
|
+
this.modelEolLabelAvailable = true
|
|
80
|
+
return true
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.status !== 404) {
|
|
83
|
+
this.modelEolLabelAvailable = false
|
|
84
|
+
this.warn(`model-eol: warning: could not check the model-eol label; refusing to publish untrusted work (${error.message})`)
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await this.request('POST', `/repos/${this.repo}/labels`, MODEL_EOL_LABEL)
|
|
90
|
+
this.modelEolLabelAvailable = true
|
|
91
|
+
return true
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.status === 422) {
|
|
94
|
+
try {
|
|
95
|
+
await this.request('GET', endpoint)
|
|
96
|
+
this.modelEolLabelAvailable = true
|
|
97
|
+
return true
|
|
98
|
+
} catch {
|
|
99
|
+
// Fall through to the warning below. Another writer may have raced us,
|
|
100
|
+
// but ownership must be confirmed before publishing work.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.modelEolLabelAvailable = false
|
|
104
|
+
this.warn(`model-eol: warning: could not create the model-eol label; refusing to publish untrusted work (${error.message})`)
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async addModelEolLabel(number) {
|
|
110
|
+
if (!this.modelEolLabelAvailable) return false
|
|
111
|
+
try {
|
|
112
|
+
await this.request('POST', `/repos/${this.repo}/issues/${number}/labels`, { labels: [MODEL_EOL_LABEL.name] })
|
|
113
|
+
return true
|
|
114
|
+
} catch (error) {
|
|
115
|
+
this.modelEolLabelAvailable = false
|
|
116
|
+
this.warn(`model-eol: warning: could not label #${number}; refusing to leave it open as trusted bot work (${error.message})`)
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async listAll(resource) {
|
|
122
|
+
const result = []
|
|
123
|
+
let endpoint = `${resource}?state=all&per_page=100&page=1`
|
|
124
|
+
for (let page = 0; page < 1000; page++) {
|
|
125
|
+
const { data, response } = await this.request('GET', endpoint)
|
|
126
|
+
if (!Array.isArray(data)) throw new Error(`GitHub ${resource} response was not an array`)
|
|
127
|
+
result.push(...data)
|
|
128
|
+
const next = linkFor(response?.headers, 'next')
|
|
129
|
+
if (next) {
|
|
130
|
+
endpoint = next
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (data.length < 100) break
|
|
134
|
+
const nextPage = page + 2
|
|
135
|
+
endpoint = `${resource}?state=all&per_page=100&page=${nextPage}`
|
|
136
|
+
}
|
|
137
|
+
return result
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async listPulls() {
|
|
141
|
+
return this.listAll(`/repos/${this.repo}/pulls`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async listPullsByHead(branch) {
|
|
145
|
+
const owner = this.repo.split('/')[0]
|
|
146
|
+
const head = encodeURIComponent(`${owner}:${branch}`)
|
|
147
|
+
const { data } = await this.request('GET', `/repos/${this.repo}/pulls?state=all&head=${head}&per_page=100&page=1`)
|
|
148
|
+
if (!Array.isArray(data)) throw new Error(`GitHub pull request head query response was not an array`)
|
|
149
|
+
return data
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async listIssues() {
|
|
153
|
+
const issues = await this.listAll(`/repos/${this.repo}/issues`)
|
|
154
|
+
return issues.filter(issue => !issue.pull_request)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async createPull(payload) {
|
|
158
|
+
const { data } = await this.request('POST', `/repos/${this.repo}/pulls`, payload)
|
|
159
|
+
if (data?.number === undefined) throw new Error('GitHub pull request response did not contain a number')
|
|
160
|
+
if (!await this.addModelEolLabel(data.number)) {
|
|
161
|
+
try { await this.updatePull(data.number, { state: 'closed' }) } catch {}
|
|
162
|
+
throw new Error(`refusing unlabeled bot pull request #${data.number}; it was closed because ownership could not be established`)
|
|
163
|
+
}
|
|
164
|
+
return data
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async updatePull(number, payload) {
|
|
168
|
+
return (await this.request('PATCH', `/repos/${this.repo}/pulls/${number}`, payload)).data
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async comment(number, body) {
|
|
172
|
+
return (await this.request('POST', `/repos/${this.repo}/issues/${number}/comments`, { body })).data
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async createIssue(payload) {
|
|
176
|
+
if (!this.modelEolLabelAvailable) throw new Error('refusing unlabeled bot issue; model-eol label is unavailable')
|
|
177
|
+
return (await this.request('POST', `/repos/${this.repo}/issues`, payload)).data
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async updateIssue(number, payload) {
|
|
181
|
+
return (await this.request('PATCH', `/repos/${this.repo}/issues/${number}`, payload)).data
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const labelNames = item => (item.labels ?? []).map(label => typeof label === 'string' ? label : label?.name).filter(Boolean)
|
|
186
|
+
|
|
187
|
+
export const hasModelEolLabel = item => labelNames(item).includes('model-eol')
|