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.
@@ -0,0 +1,120 @@
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 (typeof metadata.feed_digest !== 'string' || !metadata.feed_digest) return null
71
+ if (!Object.hasOwn(metadata, 'head_sha') || (metadata.head_sha !== null && typeof metadata.head_sha !== 'string')) return null
72
+ if (metadata.channel !== undefined && (typeof metadata.channel !== 'string' || !metadata.channel)) return null
73
+ return metadata
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
79
+ export const itemDigest = items => sortedJsonDigest(items)
80
+
81
+ const entityMap = new Map([
82
+ ['&', '&amp;'],
83
+ ['<', '&lt;'],
84
+ ['>', '&gt;'],
85
+ ['`', '&#96;'],
86
+ ['\\', '&#92;'],
87
+ ['[', '&#91;'],
88
+ [']', '&#93;'],
89
+ ['(', '&#40;'],
90
+ [')', '&#41;'],
91
+ ['#', '&#35;'],
92
+ ['*', '&#42;'],
93
+ ['_', '&#95;'],
94
+ ['~', '&#126;'],
95
+ ['!', '&#33;'],
96
+ ])
97
+
98
+ export const markdownText = value => String(value ?? '')
99
+ .replaceAll('\r\n', '\n')
100
+ .replaceAll('\r', '\n')
101
+ .replace(/[&<>`\\[\]()#*_~!]/g, character => entityMap.get(character))
102
+ .split('\n')
103
+ .map(line => line.replace(/^(\s*)([-+>]\s|\d+\.\s)/, '$1&#45; '))
104
+ .join('\n')
105
+
106
+ export const markdownCode = value => `\`${markdownText(value)}\``
107
+
108
+ export const markdownLink = (label, value) => {
109
+ const text = String(value ?? '')
110
+ try {
111
+ const url = new URL(text)
112
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return markdownText(text)
113
+ const href = url.toString().replaceAll('\\', '%5C').replaceAll('(', '%28').replaceAll(')', '%29')
114
+ return `[${markdownText(label)}](${href})`
115
+ } catch {
116
+ return markdownText(text)
117
+ }
118
+ }
119
+
120
+ export const markdownFenceText = value => String(value ?? '').replaceAll('`', '')
@@ -0,0 +1 @@
1
+ export { DEFAULT_CONFIG, loadConfig, normalizeConfig } from '../../lib/config.mjs'
@@ -0,0 +1,97 @@
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 readReportCapped = (file, limit) => {
13
+ const cap = Math.max(0, limit)
14
+ let lstat
15
+ try {
16
+ lstat = fs.lstatSync(file)
17
+ } catch (error) {
18
+ if (error.code === 'ENOENT') return { missing: true, report: null }
19
+ throw error
20
+ }
21
+ if (!lstat.isFile() || lstat.isSymbolicLink()) throw new Error(`${file} is not a regular non-symlink file`)
22
+ let fd
23
+ try {
24
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0))
25
+ } catch (error) {
26
+ if (error.code === 'ENOENT') return { missing: true, report: null }
27
+ throw error
28
+ }
29
+ try {
30
+ if (!fs.fstatSync(fd).isFile()) throw new Error(`${file} is not a regular non-symlink file`)
31
+ } catch (error) {
32
+ fs.closeSync(fd)
33
+ throw error
34
+ }
35
+ try {
36
+ const buffer = Buffer.alloc(cap + 1)
37
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0)
38
+ return {
39
+ missing: false,
40
+ report: truncateReport(buffer.subarray(0, Math.min(bytesRead, cap)), cap, bytesRead > cap),
41
+ }
42
+ } finally {
43
+ fs.closeSync(fd)
44
+ }
45
+ }
46
+
47
+ export const runEvalHook = ({ command, timeoutMs, maxReportBytes, passEnv = [], cwd, oldId, newId, planPath, reportPath }) => {
48
+ if (fs.existsSync(reportPath)) fs.rmSync(reportPath, { force: true })
49
+ const env = {}
50
+ for (const key of ['PATH', 'HOME', 'TMPDIR', 'LANG', 'USER', 'SHELL']) {
51
+ if (process.env[key] !== undefined) env[key] = process.env[key]
52
+ }
53
+ for (const key of passEnv) {
54
+ if (process.env[key] !== undefined) env[key] = process.env[key]
55
+ }
56
+ env.MODEL_EOL_OLD_ID = oldId
57
+ env.MODEL_EOL_NEW_ID = newId
58
+ env.MODEL_EOL_PLAN = planPath
59
+ env.MODEL_EOL_REPORT = reportPath
60
+
61
+ const result = spawnSync(command, {
62
+ cwd,
63
+ env,
64
+ shell: true,
65
+ timeout: timeoutMs,
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'ignore', 'ignore'],
68
+ detached: true,
69
+ })
70
+ const timedOut = result.error?.code === 'ETIMEDOUT'
71
+ || result.error?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
72
+ if (timedOut && Number.isInteger(result.pid)) {
73
+ try {
74
+ process.kill(-result.pid, 'SIGKILL')
75
+ } catch (error) {
76
+ if (error.code !== 'ESRCH') throw new Error(`could not kill eval process group: ${error.message}`)
77
+ }
78
+ }
79
+ const exitCode = Number.isInteger(result.status) ? result.status : null
80
+ let status = timedOut ? 'timeout' : exitCode === 0 ? 'pass' : 'fail'
81
+ let report = null
82
+ try {
83
+ const artifact = readReportCapped(reportPath, maxReportBytes)
84
+ if (artifact.missing && status === 'pass') {
85
+ status = 'fail'
86
+ report = 'eval report artifact is missing'
87
+ } else {
88
+ report = artifact.report
89
+ }
90
+ } catch (error) {
91
+ if (status === 'pass') status = 'fail'
92
+ report = `eval report artifact is unreadable: ${error.message}`
93
+ }
94
+ return { status, exit_code: exitCode, report }
95
+ }
96
+
97
+ 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
+ }
@@ -0,0 +1,128 @@
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) => {
109
+ const destination = `HEAD:refs/heads/${branch}`
110
+ if (expectedHead !== null) {
111
+ const state = verifyBotBranch(cwd, branch, expectedHead, auth)
112
+ if (!state.safe) {
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
+ run(cwd, [
120
+ 'push',
121
+ `--force-with-lease=refs/heads/${branch}:${expectedHead}`,
122
+ 'origin',
123
+ destination,
124
+ ], false, auth)
125
+ } else {
126
+ run(cwd, ['push', 'origin', destination], false, auth)
127
+ }
128
+ }
@@ -0,0 +1,196 @@
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; continuing without labels (${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 a failed confirmation should never block the migration itself.
101
+ }
102
+ }
103
+ this.modelEolLabelAvailable = false
104
+ this.warn(`model-eol: warning: could not create the model-eol label; continuing without labels (${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}; the migration was still published (${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
+ await this.addModelEolLabel(data.number)
161
+ return data
162
+ }
163
+
164
+ async updatePull(number, payload) {
165
+ return (await this.request('PATCH', `/repos/${this.repo}/pulls/${number}`, payload)).data
166
+ }
167
+
168
+ async comment(number, body) {
169
+ return (await this.request('POST', `/repos/${this.repo}/issues/${number}/comments`, { body })).data
170
+ }
171
+
172
+ async createIssue(payload) {
173
+ const body = this.modelEolLabelAvailable
174
+ ? payload
175
+ : { ...payload, labels: (payload.labels ?? []).filter(label => label !== MODEL_EOL_LABEL.name) }
176
+ try {
177
+ return (await this.request('POST', `/repos/${this.repo}/issues`, body)).data
178
+ } catch (error) {
179
+ if (error.status !== 422 || !(body.labels ?? []).includes(MODEL_EOL_LABEL.name)) throw error
180
+ this.modelEolLabelAvailable = false
181
+ this.warn(`model-eol: warning: issue labels were rejected; retrying without the model-eol label (${error.message})`)
182
+ return (await this.request('POST', `/repos/${this.repo}/issues`, {
183
+ ...body,
184
+ labels: body.labels.filter(label => label !== MODEL_EOL_LABEL.name),
185
+ })).data
186
+ }
187
+ }
188
+
189
+ async updateIssue(number, payload) {
190
+ return (await this.request('PATCH', `/repos/${this.repo}/issues/${number}`, payload)).data
191
+ }
192
+ }
193
+
194
+ export const labelNames = item => (item.labels ?? []).map(label => typeof label === 'string' ? label : label?.name).filter(Boolean)
195
+
196
+ export const hasModelEolLabel = item => labelNames(item).includes('model-eol')