@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.
@@ -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
+ }