@goodandready/dsh-github-ops 0.1.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/CHANGELOG.md +81 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/README.ru.md +161 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +3 -0
- package/lib/approval.js +168 -0
- package/lib/auth.js +59 -0
- package/lib/client.js +304 -0
- package/lib/commands.js +151 -0
- package/lib/github.js +315 -0
- package/lib/index.js +332 -0
- package/lib/jobs.js +107 -0
- package/lib/present.js +214 -0
- package/lib/repo-context.js +72 -0
- package/lib/review-rules.js +147 -0
- package/lib/tools/actions.js +107 -0
- package/lib/tools/api.js +44 -0
- package/lib/tools/issues.js +101 -0
- package/lib/tools/mirror.js +163 -0
- package/lib/tools/pulls.js +207 -0
- package/lib/tools/register-collab.js +233 -0
- package/lib/tools/register-core.js +259 -0
- package/lib/tools/register-jobs.js +57 -0
- package/lib/tools/register-mirror.js +88 -0
- package/lib/tools/register-ops.js +283 -0
- package/lib/tools/releases.js +111 -0
- package/lib/tools/repo-settings.js +207 -0
- package/lib/tools/repos.js +108 -0
- package/lib/tools/tags.js +68 -0
- package/package.json +73 -0
package/lib/commands.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Human slash commands: /pr, /review, /issue, /gh.
|
|
2
|
+
//
|
|
3
|
+
// A command never writes to GitHub itself. It gathers the read-only context it can see (the
|
|
4
|
+
// checkout, the current branch, the remote) and hands the model an instruction to call the
|
|
5
|
+
// right tool — so the write still passes through the approval gate, exactly as if the tool
|
|
6
|
+
// had been called directly. Commands are the fast path for a human, not a second API.
|
|
7
|
+
|
|
8
|
+
export const USAGE = {
|
|
9
|
+
pr: 'Usage: /pr create [title] — opens a pull request from the current branch',
|
|
10
|
+
review: 'Usage: /review [number] — reviews a pull request; without a number, the one for the current branch',
|
|
11
|
+
issue: 'Usage: /issue new <title> | /issue list | /issue show <number>',
|
|
12
|
+
gh: 'Usage: /gh <tool> [args] — describes how to call one of the plugin tools',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TOOL_HINTS = {
|
|
16
|
+
releases: 'gh_release_list, gh_release_view, gh_release_create, gh_release_edit, gh_release_delete',
|
|
17
|
+
tags: 'gh_tag_list, gh_tag_create, gh_tag_delete',
|
|
18
|
+
mirror: 'gh_mirror_check, gh_mirror_publish',
|
|
19
|
+
runs: 'gh_run_list, gh_run_view, gh_run_jobs, gh_run_rerun, gh_run_cancel, gh_run_logs',
|
|
20
|
+
secrets: 'gh_secret_list, gh_secret_set, gh_secret_delete',
|
|
21
|
+
variables: 'gh_variable_list, gh_variable_set, gh_variable_delete',
|
|
22
|
+
rulesets: 'gh_ruleset_list, gh_ruleset_view, gh_ruleset_apply, gh_ruleset_delete',
|
|
23
|
+
protection: 'gh_branch_protection_get, gh_branch_protection_set, gh_branch_protection_delete',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The instruction a /pr create leaves for the model. Pure, so it is testable. */
|
|
27
|
+
export function buildPrInstruction({ title, branch, base = 'main', repo, remoteUrl } = {}) {
|
|
28
|
+
const where = repo ? ` in ${repo}` : remoteUrl ? ` in the repository of ${remoteUrl}` : ''
|
|
29
|
+
return [
|
|
30
|
+
`The user ran /pr create${title ? ` "${title}"` : ''}.`,
|
|
31
|
+
`Current branch: ${branch || 'unknown'}.`,
|
|
32
|
+
`Call pr_create with head=${branch || '<current branch>'}, base=${base}${where}${title ? `, title="${title}"` : ''}, and a body that summarises the commits on the branch (git log ${base}..${branch}).`,
|
|
33
|
+
'If the branch is not pushed yet, say so instead of opening the pull request.',
|
|
34
|
+
].join(' ')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The instruction a /review leaves for the model. */
|
|
38
|
+
export function buildReviewInstruction({ number, branch, repo } = {}) {
|
|
39
|
+
const target = number ? `pull request #${number}` : `the pull request for ${branch || 'the current branch'}`
|
|
40
|
+
return [
|
|
41
|
+
`The user ran /review. Review ${target}${repo ? ` in ${repo}` : ''}.`,
|
|
42
|
+
`Call gh_review, then summarise the findings in your own words — critical items first.`,
|
|
43
|
+
number ? 'If the verdict matters, also call ci_run for the same number.' : 'Find the number with gh_search first if it is unknown.',
|
|
44
|
+
].join(' ')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The instruction /issue leaves for the model. */
|
|
48
|
+
export function buildIssueInstruction({ sub, title, number, repo } = {}) {
|
|
49
|
+
if (sub === 'new') {
|
|
50
|
+
return [
|
|
51
|
+
`The user ran /issue new${title ? ` "${title}"` : ''}.`,
|
|
52
|
+
`Call issue_open${repo ? ` in ${repo}` : ''} with that title and a body that spells out the problem, the impact and what done looks like.`,
|
|
53
|
+
'Ask for anything essential that is missing before opening it.',
|
|
54
|
+
].join(' ')
|
|
55
|
+
}
|
|
56
|
+
if (sub === 'show') {
|
|
57
|
+
return `The user ran /issue show ${number}. Call gh_issue with action "get" and number ${number}${repo ? ` in ${repo}` : ''}, then summarise it.`
|
|
58
|
+
}
|
|
59
|
+
return `The user ran /issue list. Call gh_issue with action "list"${repo ? ` in ${repo}` : ''} and summarise the open items.`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The instruction /gh leaves for the model. */
|
|
63
|
+
export function buildGhInstruction({ topic, tool, args } = {}) {
|
|
64
|
+
if (!topic && !tool) {
|
|
65
|
+
return `The user ran /gh. Available groups: ${Object.keys(TOOL_HINTS).join(', ')}. Ask which one, or name a tool directly (for example /gh gh_release_list).`
|
|
66
|
+
}
|
|
67
|
+
if (tool) return `The user ran /gh ${tool}${args ? ` ${args}` : ''}. Call the ${tool} tool with the arguments they meant.`
|
|
68
|
+
return `The user asked about ${topic}. Relevant tools: ${TOOL_HINTS[topic] || 'none'}.`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register the commands. Every handler returns an instruction for the model; none of them
|
|
73
|
+
* touches GitHub.
|
|
74
|
+
*/
|
|
75
|
+
export function registerCommands(commands, { readGitState, repoHint } = {}) {
|
|
76
|
+
const disposers = []
|
|
77
|
+
const context = async () => {
|
|
78
|
+
try {
|
|
79
|
+
return (typeof readGitState === 'function' ? await readGitState() : {}) || {}
|
|
80
|
+
} catch {
|
|
81
|
+
return {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
disposers.push(commands.register({
|
|
86
|
+
name: 'pr',
|
|
87
|
+
description: 'Create a GitHub pull request from the current branch',
|
|
88
|
+
input: { hint: 'create [title]' },
|
|
89
|
+
handler: async (invocation) => {
|
|
90
|
+
const [sub, ...rest] = String(invocation.rawInput || '').trim().split(/\s+/)
|
|
91
|
+
if (sub !== 'create') return { kind: 'error', text: USAGE.pr }
|
|
92
|
+
const git = await context()
|
|
93
|
+
return {
|
|
94
|
+
kind: 'success',
|
|
95
|
+
text: buildPrInstruction({ title: rest.join(' '), branch: git.branch, base: git.base || 'main', repo: git.repo || (typeof repoHint === 'function' ? repoHint() : undefined), remoteUrl: git.remoteUrl }),
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
disposers.push(commands.register({
|
|
101
|
+
name: 'review',
|
|
102
|
+
description: 'Review a GitHub pull request',
|
|
103
|
+
input: { hint: '[number]' },
|
|
104
|
+
handler: async (invocation) => {
|
|
105
|
+
const raw = String(invocation.rawInput || '').trim()
|
|
106
|
+
const number = /^\d+$/.test(raw) ? Number(raw) : undefined
|
|
107
|
+
const git = await context()
|
|
108
|
+
return {
|
|
109
|
+
kind: 'success',
|
|
110
|
+
text: buildReviewInstruction({ number, branch: git.branch, repo: git.repo || (typeof repoHint === 'function' ? repoHint() : undefined) }),
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
disposers.push(commands.register({
|
|
116
|
+
name: 'issue',
|
|
117
|
+
description: 'Open, list or read GitHub issues',
|
|
118
|
+
input: { hint: 'new <title> | list | show <number>' },
|
|
119
|
+
handler: async (invocation) => {
|
|
120
|
+
const [sub, ...rest] = String(invocation.rawInput || '').trim().split(/\s+/)
|
|
121
|
+
const git = await context()
|
|
122
|
+
const repo = git.repo || (typeof repoHint === 'function' ? repoHint() : undefined)
|
|
123
|
+
if (sub === 'new') return { kind: 'success', text: buildIssueInstruction({ sub, title: rest.join(' '), repo }) }
|
|
124
|
+
if (sub === 'show' && /^\d+$/.test(rest[0] || '')) return { kind: 'success', text: buildIssueInstruction({ sub, number: Number(rest[0]), repo }) }
|
|
125
|
+
if (!sub || sub === 'list') return { kind: 'success', text: buildIssueInstruction({ sub: 'list', repo }) }
|
|
126
|
+
return { kind: 'error', text: USAGE.issue }
|
|
127
|
+
},
|
|
128
|
+
}))
|
|
129
|
+
|
|
130
|
+
disposers.push(commands.register({
|
|
131
|
+
name: 'gh',
|
|
132
|
+
description: 'Ask about the GitHub tools of this plugin',
|
|
133
|
+
input: { hint: '[group|tool] [args]' },
|
|
134
|
+
handler: async (invocation) => {
|
|
135
|
+
const [first, ...rest] = String(invocation.rawInput || '').trim().split(/\s+/)
|
|
136
|
+
const topic = first && TOOL_HINTS[first] ? first : undefined
|
|
137
|
+
const tool = first && /^[a-z]+_/.test(first) ? first : (topic ? undefined : first)
|
|
138
|
+
return { kind: 'success', text: buildGhInstruction({ topic, tool, args: rest.join(' ') }) }
|
|
139
|
+
},
|
|
140
|
+
}))
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
for (const dispose of disposers) {
|
|
144
|
+
try {
|
|
145
|
+
if (typeof dispose === 'function') dispose()
|
|
146
|
+
} catch {
|
|
147
|
+
// a failing disposer must not stop the others
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
package/lib/github.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// GitHub API client: REST v3 and GraphQL v4 in one place.
|
|
2
|
+
//
|
|
3
|
+
// The transport is injected (`fetchImpl`) and so is the sleep (`sleepImpl`), so every
|
|
4
|
+
// caller and every test runs without a real network or real timers. Errors are normalized
|
|
5
|
+
// into a single shape — `{ ok: false, status, code, message, rateLimit }` at the tool
|
|
6
|
+
// layer — so a tool never throws the whole turn away because GitHub answered 404 or 403.
|
|
7
|
+
//
|
|
8
|
+
// Resilience: a read (GET/HEAD) is retried on a network failure, a timeout, or a 5xx, and
|
|
9
|
+
// a 429 is honoured through `Retry-After` (seconds or a date) or the rate-limit reset. A
|
|
10
|
+
// write is never retried automatically: a failed write has an unknown outcome, and
|
|
11
|
+
// repeating it silently is worse than reporting it.
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_BASE_URL = 'https://api.github.com'
|
|
14
|
+
export const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql'
|
|
15
|
+
export const DEFAULT_API_VERSION = '2022-11-28'
|
|
16
|
+
export const DEFAULT_USER_AGENT = 'dsh-github-ops'
|
|
17
|
+
|
|
18
|
+
/** Methods whose retry cannot duplicate a state change. */
|
|
19
|
+
export const SAFE_RETRY_METHODS = new Set(['GET', 'HEAD'])
|
|
20
|
+
|
|
21
|
+
/** Statuses worth another attempt for a read. */
|
|
22
|
+
export const RETRYABLE_STATUS = new Set([429, 502, 503, 504])
|
|
23
|
+
|
|
24
|
+
/** Normalized failure. */
|
|
25
|
+
export class GitHubError extends Error {
|
|
26
|
+
constructor(message, { status = 0, code = '', rateLimit = null, details = null, attempts = 1 } = {}) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'GitHubError'
|
|
29
|
+
this.status = status
|
|
30
|
+
this.code = code
|
|
31
|
+
this.rateLimit = rateLimit
|
|
32
|
+
this.details = details
|
|
33
|
+
this.attempts = attempts
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rateLimitFrom(headers) {
|
|
38
|
+
if (!headers || typeof headers.get !== 'function') return null
|
|
39
|
+
const remaining = headers.get('x-ratelimit-remaining')
|
|
40
|
+
const reset = headers.get('x-ratelimit-reset')
|
|
41
|
+
const limit = headers.get('x-ratelimit-limit')
|
|
42
|
+
if (remaining === null && reset === null && limit === null) return null
|
|
43
|
+
const out = {}
|
|
44
|
+
if (remaining !== null) out.remaining = Number(remaining)
|
|
45
|
+
if (limit !== null) out.limit = Number(limit)
|
|
46
|
+
if (reset !== null) {
|
|
47
|
+
out.resetAt = new Date(Number(reset) * 1000).toISOString()
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How long to wait before the next attempt.
|
|
54
|
+
*
|
|
55
|
+
* `Retry-After` wins (it is what the server asks for), then the rate-limit reset when the
|
|
56
|
+
* limit is spent, then exponential backoff. The result is capped so a hostile header
|
|
57
|
+
* cannot stall a turn.
|
|
58
|
+
*/
|
|
59
|
+
export function retryDelayMs({ headers, attempt, baseMs = 500, maxDelayMs = 10000 } = {}) {
|
|
60
|
+
const header = headers && typeof headers.get === 'function' ? headers.get('retry-after') : null
|
|
61
|
+
if (header) {
|
|
62
|
+
const seconds = Number(header)
|
|
63
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, maxDelayMs)
|
|
64
|
+
const at = Date.parse(header)
|
|
65
|
+
if (Number.isFinite(at)) return Math.min(Math.max(at - Date.now(), 0), maxDelayMs)
|
|
66
|
+
}
|
|
67
|
+
const limit = rateLimitFrom(headers)
|
|
68
|
+
if (limit && limit.remaining === 0 && limit.resetAt) {
|
|
69
|
+
const wait = Date.parse(limit.resetAt) - Date.now()
|
|
70
|
+
if (Number.isFinite(wait) && wait > 0) return Math.min(wait, maxDelayMs)
|
|
71
|
+
}
|
|
72
|
+
const backoff = baseMs * (2 ** Math.max(0, attempt - 1))
|
|
73
|
+
return Math.min(backoff, maxDelayMs)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildUrl(baseUrl, path, query) {
|
|
77
|
+
const url = new URL(baseUrl.replace(/\/+$/, '') + (path.startsWith('/') ? path : `/${path}`))
|
|
78
|
+
if (query && typeof query === 'object') {
|
|
79
|
+
for (const [key, value] of Object.entries(query)) {
|
|
80
|
+
if (value === undefined || value === null || value === '') continue
|
|
81
|
+
url.searchParams.set(key, String(value))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return url.toString()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a client bound to one access value. Nothing here reads globals: the caller
|
|
89
|
+
* decides the access, the base URL, the transport and the clock.
|
|
90
|
+
*/
|
|
91
|
+
export function createGitHubClient({
|
|
92
|
+
token = '',
|
|
93
|
+
baseUrl = DEFAULT_BASE_URL,
|
|
94
|
+
graphqlUrl = DEFAULT_GRAPHQL_URL,
|
|
95
|
+
apiVersion = DEFAULT_API_VERSION,
|
|
96
|
+
userAgent = DEFAULT_USER_AGENT,
|
|
97
|
+
fetchImpl = globalThis.fetch,
|
|
98
|
+
sleepImpl,
|
|
99
|
+
timeoutMs = 30000,
|
|
100
|
+
maxRetries = 2,
|
|
101
|
+
retryBaseMs = 500,
|
|
102
|
+
maxRetryDelayMs = 10000,
|
|
103
|
+
} = {}) {
|
|
104
|
+
if (typeof fetchImpl !== 'function') {
|
|
105
|
+
throw new GitHubError('no fetch implementation available', { code: 'no_fetch' })
|
|
106
|
+
}
|
|
107
|
+
const sleep = typeof sleepImpl === 'function'
|
|
108
|
+
? sleepImpl
|
|
109
|
+
: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
110
|
+
|
|
111
|
+
async function attemptOnce({ method, path, query, body, url, accept, redirect }) {
|
|
112
|
+
const target = url || buildUrl(baseUrl, path, query)
|
|
113
|
+
const headers = {
|
|
114
|
+
accept,
|
|
115
|
+
'x-github-api-version': apiVersion,
|
|
116
|
+
'user-agent': userAgent,
|
|
117
|
+
}
|
|
118
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
119
|
+
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
120
|
+
|
|
121
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
122
|
+
const timer = controller && timeoutMs > 0
|
|
123
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
124
|
+
: null
|
|
125
|
+
|
|
126
|
+
let response
|
|
127
|
+
try {
|
|
128
|
+
response = await fetchImpl(target, {
|
|
129
|
+
method,
|
|
130
|
+
headers,
|
|
131
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
132
|
+
signal: controller ? controller.signal : undefined,
|
|
133
|
+
...(redirect ? { redirect } : {}),
|
|
134
|
+
})
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (timer) clearTimeout(timer)
|
|
137
|
+
const aborted = err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')
|
|
138
|
+
throw new GitHubError(aborted ? `request timed out after ${timeoutMs}ms` : String((err && err.message) || err), {
|
|
139
|
+
code: aborted ? 'timeout' : 'network',
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
if (timer) clearTimeout(timer)
|
|
143
|
+
|
|
144
|
+
const rateLimit = rateLimitFrom(response.headers)
|
|
145
|
+
const text = typeof response.text === 'function' ? await response.text() : ''
|
|
146
|
+
let payload = null
|
|
147
|
+
if (text) {
|
|
148
|
+
try {
|
|
149
|
+
payload = JSON.parse(text)
|
|
150
|
+
} catch {
|
|
151
|
+
payload = { raw: text.slice(0, 2000) }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { response, payload, rateLimit }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect }) {
|
|
158
|
+
const upper = method.toUpperCase()
|
|
159
|
+
const retryable = SAFE_RETRY_METHODS.has(upper)
|
|
160
|
+
const attemptsAllowed = Math.max(1, Number(maxRetries) + 1)
|
|
161
|
+
let lastError = null
|
|
162
|
+
|
|
163
|
+
for (let attempt = 1; attempt <= attemptsAllowed; attempt += 1) {
|
|
164
|
+
let result
|
|
165
|
+
try {
|
|
166
|
+
result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect })
|
|
167
|
+
} catch (err) {
|
|
168
|
+
lastError = err
|
|
169
|
+
if (!retryable || attempt >= attemptsAllowed) {
|
|
170
|
+
err.attempts = attempt
|
|
171
|
+
throw err
|
|
172
|
+
}
|
|
173
|
+
await sleep(retryDelayMs({ attempt, baseMs: retryBaseMs, maxDelayMs: maxRetryDelayMs }))
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const { response, payload, rateLimit } = result
|
|
178
|
+
if (response.ok) {
|
|
179
|
+
return { data: payload, status: response.status, rateLimit, attempts: attempt }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const message = (payload && (payload.message || payload.error)) || `HTTP ${response.status}`
|
|
183
|
+
const exhausted = rateLimit && rateLimit.remaining === 0
|
|
184
|
+
const error = new GitHubError(String(message), {
|
|
185
|
+
status: response.status,
|
|
186
|
+
code: exhausted ? 'rate_limited' : 'http_error',
|
|
187
|
+
rateLimit,
|
|
188
|
+
details: payload,
|
|
189
|
+
attempts: attempt,
|
|
190
|
+
})
|
|
191
|
+
lastError = error
|
|
192
|
+
|
|
193
|
+
const worthRetrying = retryable && RETRYABLE_STATUS.has(response.status)
|
|
194
|
+
if (!worthRetrying || attempt >= attemptsAllowed) throw error
|
|
195
|
+
await sleep(retryDelayMs({ headers: response.headers, attempt, baseMs: retryBaseMs, maxDelayMs: maxRetryDelayMs }))
|
|
196
|
+
}
|
|
197
|
+
throw lastError || new GitHubError('request failed', { code: 'http_error' })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
token,
|
|
202
|
+
baseUrl,
|
|
203
|
+
|
|
204
|
+
/** One REST call. `path` is relative to the API base, e.g. `/repos/o/r`. */
|
|
205
|
+
request(method, path, { query, body, accept } = {}) {
|
|
206
|
+
return call({ method: method.toUpperCase(), path, query, body, accept })
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
get(path, opts) {
|
|
210
|
+
return call({ method: 'GET', path, ...(opts || {}) })
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
post(path, body, opts) {
|
|
214
|
+
return call({ method: 'POST', path, body, ...(opts || {}) })
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
patch(path, body, opts) {
|
|
218
|
+
return call({ method: 'PATCH', path, body, ...(opts || {}) })
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
put(path, body, opts) {
|
|
222
|
+
return call({ method: 'PUT', path, body, ...(opts || {}) })
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
del(path, opts) {
|
|
226
|
+
return call({ method: 'DELETE', path, ...(opts || {}) })
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve an endpoint that answers with a redirect instead of JSON (the workflow
|
|
231
|
+
* logs archive is a 302 to a zip). Nothing is downloaded: the caller gets the URL.
|
|
232
|
+
*/
|
|
233
|
+
async getRedirect(path, { query } = {}) {
|
|
234
|
+
const target = buildUrl(baseUrl, path, query)
|
|
235
|
+
const headers = {
|
|
236
|
+
accept: 'application/vnd.github+json',
|
|
237
|
+
'x-github-api-version': apiVersion,
|
|
238
|
+
'user-agent': userAgent,
|
|
239
|
+
}
|
|
240
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
241
|
+
let response
|
|
242
|
+
try {
|
|
243
|
+
response = await fetchImpl(target, { method: 'GET', headers, redirect: 'manual' })
|
|
244
|
+
} catch (err) {
|
|
245
|
+
throw new GitHubError(String((err && err.message) || err), { code: 'network' })
|
|
246
|
+
}
|
|
247
|
+
const location = typeof response.headers?.get === 'function' ? response.headers.get('location') : null
|
|
248
|
+
const rateLimit = rateLimitFrom(response.headers)
|
|
249
|
+
if (!response.ok && !location) {
|
|
250
|
+
throw new GitHubError(`HTTP ${response.status}`, { status: response.status, code: 'http_error', rateLimit })
|
|
251
|
+
}
|
|
252
|
+
return { status: response.status, location, rateLimit }
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** GraphQL v4. Returns `data` only; `errors` become a GitHubError. */
|
|
256
|
+
async graphql(query, variables) {
|
|
257
|
+
const { data } = await call({
|
|
258
|
+
method: 'POST',
|
|
259
|
+
url: graphqlUrl,
|
|
260
|
+
body: { query, variables: variables || {} },
|
|
261
|
+
accept: 'application/json',
|
|
262
|
+
})
|
|
263
|
+
if (data && Array.isArray(data.errors) && data.errors.length) {
|
|
264
|
+
throw new GitHubError(data.errors.map((e) => e.message).join('; '), {
|
|
265
|
+
code: 'graphql_error',
|
|
266
|
+
details: data.errors,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
return data && data.data ? data.data : data
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Walk a paginated REST list. `pageSize` maps to `per_page`; stops when a page
|
|
274
|
+
* is shorter than the page size or when `maxItems` is reached.
|
|
275
|
+
*/
|
|
276
|
+
async paginate(path, { query = {}, pageSize = 100, maxItems = 1000 } = {}) {
|
|
277
|
+
const items = []
|
|
278
|
+
for (let page = 1; items.length < maxItems; page += 1) {
|
|
279
|
+
const { data } = await call({
|
|
280
|
+
method: 'GET',
|
|
281
|
+
path,
|
|
282
|
+
query: { ...query, per_page: pageSize, page },
|
|
283
|
+
})
|
|
284
|
+
if (!Array.isArray(data) || data.length === 0) break
|
|
285
|
+
items.push(...data)
|
|
286
|
+
if (data.length < pageSize) break
|
|
287
|
+
}
|
|
288
|
+
return items.slice(0, maxItems)
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Split `owner/repo` (or `owner/repo#123`) into its parts. Returns null when malformed. */
|
|
294
|
+
export function parseRepoSpec(spec) {
|
|
295
|
+
if (typeof spec !== 'string') return null
|
|
296
|
+
const match = spec.trim().match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:#(\d+))?$/)
|
|
297
|
+
if (!match) return null
|
|
298
|
+
return { owner: match[1], repo: match[2], number: match[3] ? Number(match[3]) : null }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Human-readable one-liner for a failure, used by every tool renderer. */
|
|
302
|
+
export function describeError(err) {
|
|
303
|
+
if (!err) return 'unknown error'
|
|
304
|
+
if (err instanceof GitHubError) {
|
|
305
|
+
const parts = []
|
|
306
|
+
if (err.status) parts.push(`HTTP ${err.status}`)
|
|
307
|
+
if (err.code) parts.push(err.code)
|
|
308
|
+
if (err.attempts > 1) parts.push(`${err.attempts} attempts`)
|
|
309
|
+
if (err.rateLimit && err.rateLimit.remaining === 0) {
|
|
310
|
+
parts.push(`rate limit exhausted, resets at ${err.rateLimit.resetAt || 'unknown'}`)
|
|
311
|
+
}
|
|
312
|
+
return parts.length ? `${err.message} (${parts.join(', ')})` : err.message
|
|
313
|
+
}
|
|
314
|
+
return String(err.message || err)
|
|
315
|
+
}
|