@goodandready/dsh-github-ops 0.1.2 → 0.2.1
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 +68 -0
- package/README.md +91 -0
- package/README.ru.md +81 -0
- package/README.zh.md +63 -0
- package/lib/auth-device.js +150 -0
- package/lib/auth.js +17 -5
- package/lib/client.js +802 -0
- package/lib/github.js +105 -9
- package/lib/guidance.js +26 -0
- package/lib/index.js +185 -11
- package/lib/lossless.js +90 -0
- package/lib/routes.js +309 -0
- package/lib/scm-write.js +144 -0
- package/lib/scm.js +115 -0
- package/lib/status.js +66 -0
- package/lib/tools/actions.js +1 -1
- package/lib/tools/insights.js +246 -0
- package/lib/tools/issues.js +2 -1
- package/lib/tools/panel-data.js +45 -0
- package/lib/tools/register-insights.js +126 -0
- package/lib/tools/register-write.js +245 -0
- package/lib/tools/releases.js +3 -1
- package/lib/tools/repos.js +1 -1
- package/lib/tools/write-files.js +207 -0
- package/media/visual-verification.png +0 -0
- package/package.json +3 -2
package/lib/github.js
CHANGED
|
@@ -96,7 +96,10 @@ export function createGitHubClient({
|
|
|
96
96
|
userAgent = DEFAULT_USER_AGENT,
|
|
97
97
|
fetchImpl = globalThis.fetch,
|
|
98
98
|
sleepImpl,
|
|
99
|
+
signalProvider,
|
|
99
100
|
timeoutMs = 30000,
|
|
101
|
+
cacheTtlMs = 60000,
|
|
102
|
+
cacheLimit = 200,
|
|
100
103
|
maxRetries = 2,
|
|
101
104
|
retryBaseMs = 500,
|
|
102
105
|
maxRetryDelayMs = 10000,
|
|
@@ -108,7 +111,30 @@ export function createGitHubClient({
|
|
|
108
111
|
? sleepImpl
|
|
109
112
|
: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
110
113
|
|
|
111
|
-
|
|
114
|
+
// A short TTL cache for reads only: a composed report or a repeated list must not spend
|
|
115
|
+
// the rate limit twice. Writes always go to the network.
|
|
116
|
+
let lastScopesValue = ''
|
|
117
|
+
const cache = new Map()
|
|
118
|
+
const cacheKey = (method, target) => `${method} ${target}`
|
|
119
|
+
function cacheGet(key) {
|
|
120
|
+
const hit = cache.get(key)
|
|
121
|
+
if (!hit) return null
|
|
122
|
+
if (hit.expiresAt <= Date.now()) {
|
|
123
|
+
cache.delete(key)
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
return hit.value
|
|
127
|
+
}
|
|
128
|
+
function cacheSet(key, value) {
|
|
129
|
+
if (!(cacheTtlMs > 0)) return
|
|
130
|
+
if (cache.size >= cacheLimit) {
|
|
131
|
+
const oldest = cache.keys().next().value
|
|
132
|
+
cache.delete(oldest)
|
|
133
|
+
}
|
|
134
|
+
cache.set(key, { value, expiresAt: Date.now() + cacheTtlMs })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function attemptOnce({ method, path, query, body, url, accept, redirect, signal }) {
|
|
112
138
|
const target = url || buildUrl(baseUrl, path, query)
|
|
113
139
|
const headers = {
|
|
114
140
|
accept,
|
|
@@ -122,6 +148,16 @@ export function createGitHubClient({
|
|
|
122
148
|
const timer = controller && timeoutMs > 0
|
|
123
149
|
? setTimeout(() => controller.abort(), timeoutMs)
|
|
124
150
|
: null
|
|
151
|
+
// The caller's signal (a cancelled turn) must abort the request too, not just the clock.
|
|
152
|
+
const combined = (() => {
|
|
153
|
+
if (!controller) return signal
|
|
154
|
+
if (!signal) return controller.signal
|
|
155
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
|
|
156
|
+
return AbortSignal.any([controller.signal, signal])
|
|
157
|
+
}
|
|
158
|
+
signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
159
|
+
return controller.signal
|
|
160
|
+
})()
|
|
125
161
|
|
|
126
162
|
let response
|
|
127
163
|
try {
|
|
@@ -129,19 +165,29 @@ export function createGitHubClient({
|
|
|
129
165
|
method,
|
|
130
166
|
headers,
|
|
131
167
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
132
|
-
signal:
|
|
168
|
+
signal: combined || undefined,
|
|
133
169
|
...(redirect ? { redirect } : {}),
|
|
134
170
|
})
|
|
135
171
|
} catch (err) {
|
|
136
172
|
if (timer) clearTimeout(timer)
|
|
137
173
|
const aborted = err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
174
|
+
// A caller cancellation (the turn was stopped) is not a timeout: saying "timed out"
|
|
175
|
+
// there sends the model looking for a network problem that does not exist.
|
|
176
|
+
const cancelled = aborted && signal && signal.aborted
|
|
177
|
+
const message = cancelled
|
|
178
|
+
? 'request cancelled by the caller'
|
|
179
|
+
: aborted
|
|
180
|
+
? `request timed out after ${timeoutMs}ms`
|
|
181
|
+
: String((err && err.message) || err)
|
|
182
|
+
throw new GitHubError(message, { code: cancelled ? 'cancelled' : aborted ? 'timeout' : 'network' })
|
|
141
183
|
}
|
|
142
184
|
if (timer) clearTimeout(timer)
|
|
143
185
|
|
|
144
186
|
const rateLimit = rateLimitFrom(response.headers)
|
|
187
|
+
if (typeof response.headers.get === 'function') {
|
|
188
|
+
const scopes = response.headers.get('x-oauth-scopes')
|
|
189
|
+
if (scopes) lastScopesValue = String(scopes)
|
|
190
|
+
}
|
|
145
191
|
const text = typeof response.text === 'function' ? await response.text() : ''
|
|
146
192
|
let payload = null
|
|
147
193
|
if (text) {
|
|
@@ -154,16 +200,25 @@ export function createGitHubClient({
|
|
|
154
200
|
return { response, payload, rateLimit }
|
|
155
201
|
}
|
|
156
202
|
|
|
157
|
-
async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect }) {
|
|
203
|
+
async function call({ method = 'GET', path, query, body, url, accept = 'application/vnd.github+json', redirect, signal }) {
|
|
204
|
+
// The signal may come from the caller's turn: the shared client asks the provider, so a
|
|
205
|
+
// cancelled turn aborts the request in flight instead of waiting for the timeout.
|
|
206
|
+
const turnSignal = signal || (typeof signalProvider === 'function' ? signalProvider() : undefined)
|
|
158
207
|
const upper = method.toUpperCase()
|
|
159
208
|
const retryable = SAFE_RETRY_METHODS.has(upper)
|
|
209
|
+
const target = url || buildUrl(baseUrl, path, query)
|
|
210
|
+
const key = cacheKey(upper, target)
|
|
211
|
+
if (retryable) {
|
|
212
|
+
const cached = cacheGet(key)
|
|
213
|
+
if (cached) return { ...cached, cached: true }
|
|
214
|
+
}
|
|
160
215
|
const attemptsAllowed = Math.max(1, Number(maxRetries) + 1)
|
|
161
216
|
let lastError = null
|
|
162
217
|
|
|
163
218
|
for (let attempt = 1; attempt <= attemptsAllowed; attempt += 1) {
|
|
164
219
|
let result
|
|
165
220
|
try {
|
|
166
|
-
result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect })
|
|
221
|
+
result = await attemptOnce({ method: upper, path, query, body, url, accept, redirect, signal: turnSignal })
|
|
167
222
|
} catch (err) {
|
|
168
223
|
lastError = err
|
|
169
224
|
if (!retryable || attempt >= attemptsAllowed) {
|
|
@@ -176,7 +231,9 @@ export function createGitHubClient({
|
|
|
176
231
|
|
|
177
232
|
const { response, payload, rateLimit } = result
|
|
178
233
|
if (response.ok) {
|
|
179
|
-
|
|
234
|
+
const value = { data: payload, status: response.status, rateLimit, attempts: attempt }
|
|
235
|
+
if (retryable) cacheSet(key, value)
|
|
236
|
+
return value
|
|
180
237
|
}
|
|
181
238
|
|
|
182
239
|
const message = (payload && (payload.message || payload.error)) || `HTTP ${response.status}`
|
|
@@ -210,6 +267,21 @@ export function createGitHubClient({
|
|
|
210
267
|
return call({ method: 'GET', path, ...(opts || {}) })
|
|
211
268
|
},
|
|
212
269
|
|
|
270
|
+
/** The OAuth scopes GitHub reported on the last answer, when it reported any. */
|
|
271
|
+
lastScopes() {
|
|
272
|
+
return lastScopesValue
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
/** Cache counters, for the settings card and for tests. */
|
|
276
|
+
cacheStats() {
|
|
277
|
+
return { size: cache.size, ttlMs: cacheTtlMs }
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
/** Drop the cache (a write may have invalidated what a read returned). */
|
|
281
|
+
cacheClear() {
|
|
282
|
+
cache.clear()
|
|
283
|
+
},
|
|
284
|
+
|
|
213
285
|
post(path, body, opts) {
|
|
214
286
|
return call({ method: 'POST', path, body, ...(opts || {}) })
|
|
215
287
|
},
|
|
@@ -298,6 +370,28 @@ export function parseRepoSpec(spec) {
|
|
|
298
370
|
return { owner: match[1], repo: match[2], number: match[3] ? Number(match[3]) : null }
|
|
299
371
|
}
|
|
300
372
|
|
|
373
|
+
/**
|
|
374
|
+
* What a human should try next for a given failure. An error that only says "HTTP 401" makes
|
|
375
|
+
* the model guess; a named next step makes it act.
|
|
376
|
+
*/
|
|
377
|
+
export function hintFor(err) {
|
|
378
|
+
if (!err) return ''
|
|
379
|
+
const status = err.status || 0
|
|
380
|
+
if (status === 401) return 'the access value is missing or expired: check the credential, the environment variable or `gh auth status`'
|
|
381
|
+
if (status === 403 && err.rateLimit && err.rateLimit.remaining === 0) {
|
|
382
|
+
return `the rate limit is spent and resets at ${err.rateLimit.resetAt || 'an unknown time'}: wait, or use a token with a higher limit`
|
|
383
|
+
}
|
|
384
|
+
if (status === 403) return 'the access value lacks the scope this call needs'
|
|
385
|
+
if (status === 404) return 'the repository, tag, issue or release does not exist, or the access value cannot see it (a draft release is only visible by id)'
|
|
386
|
+
if (status === 422) return 'GitHub rejected the payload: check the required fields'
|
|
387
|
+
if (status === 409) return 'the state changed under us (a conflict): read the object again and retry'
|
|
388
|
+
if (err.code === 'cancelled') return 'the turn was cancelled before GitHub answered'
|
|
389
|
+
if (err.code === 'timeout') return 'the request timed out: raise timeoutMs or retry'
|
|
390
|
+
if (err.code === 'network') return 'the network failed: retry, and check whether github.com resolves — a wrong /etc/hosts entry for github.com or api.github.com breaks every call'
|
|
391
|
+
if (err.code === 'rate_limited') return 'the rate limit is spent: wait for the reset before retrying'
|
|
392
|
+
return ''
|
|
393
|
+
}
|
|
394
|
+
|
|
301
395
|
/** Human-readable one-liner for a failure, used by every tool renderer. */
|
|
302
396
|
export function describeError(err) {
|
|
303
397
|
if (!err) return 'unknown error'
|
|
@@ -309,7 +403,9 @@ export function describeError(err) {
|
|
|
309
403
|
if (err.rateLimit && err.rateLimit.remaining === 0) {
|
|
310
404
|
parts.push(`rate limit exhausted, resets at ${err.rateLimit.resetAt || 'unknown'}`)
|
|
311
405
|
}
|
|
312
|
-
|
|
406
|
+
const base = parts.length ? `${err.message} (${parts.join(', ')})` : err.message
|
|
407
|
+
const hint = hintFor(err)
|
|
408
|
+
return hint ? `${base} — ${hint}` : base
|
|
313
409
|
}
|
|
314
410
|
return String(err.message || err)
|
|
315
411
|
}
|
package/lib/guidance.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// What the model is told about this plugin.
|
|
2
|
+
//
|
|
3
|
+
// A plugin with 50+ tools needs one short paragraph about how to use them, otherwise the
|
|
4
|
+
// model guesses: which tool for which question, when a repository argument may be omitted,
|
|
5
|
+
// and that approvals are the host's business rather than something these tools invent.
|
|
6
|
+
|
|
7
|
+
/** Section name in the system prompt; unique, so a reload replaces it instead of clashing. */
|
|
8
|
+
export const GUIDANCE_SECTION = 'dsh-github-ops'
|
|
9
|
+
|
|
10
|
+
/** Placement among the other sections. */
|
|
11
|
+
export const GUIDANCE_ORDER = 60
|
|
12
|
+
|
|
13
|
+
/** The default text. Keep it short: it is paid for on every turn. */
|
|
14
|
+
export const GUIDANCE_TEXT = [
|
|
15
|
+
'GitHub (dsh-github-ops):',
|
|
16
|
+
'- Unsure which tool fits? gh_help lists every one of them, grouped by area.',
|
|
17
|
+
'- Inside a checkout the repository argument can be omitted: the origin remote decides. Both "owner/repo#12" and a bare "12" are accepted.',
|
|
18
|
+
'- Prefer one composed call over many plain ones: gh_repo_report (what a repository is), gh_weekly_digest (what happened lately), gh_repo_health (what to fix), gh_review (a pull request as one pack), ci_run (a deterministic verdict).',
|
|
19
|
+
'- Reads are free. Deleting a file, a branch, a tag, a release, a secret or a ruleset, forcing a ref, and publishing a mirror need confirm: true; approvals themselves belong to the host, not to these tools.',
|
|
20
|
+
'- A failure states the next step; gh_auth_status says where the access comes from.',
|
|
21
|
+
].join('\n')
|
|
22
|
+
|
|
23
|
+
/** The text to register: the configured override, or the default. */
|
|
24
|
+
export function guidanceText(configured) {
|
|
25
|
+
return configured && String(configured).trim() ? String(configured).trim() : GUIDANCE_TEXT
|
|
26
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -25,11 +25,22 @@ import { registerCoreTools } from './tools/register-core.js'
|
|
|
25
25
|
import { registerCollabTools } from './tools/register-collab.js'
|
|
26
26
|
import { registerMirrorTools } from './tools/register-mirror.js'
|
|
27
27
|
import { registerOpsTools } from './tools/register-ops.js'
|
|
28
|
+
import { registerInsightTools } from './tools/register-insights.js'
|
|
29
|
+
import { registerWriteTools } from './tools/register-write.js'
|
|
30
|
+
import { accessFilePath, readAccessFile } from './auth-device.js'
|
|
31
|
+
import { DEFAULT_CLIENT_ID, DEFAULT_SCOPE } from './auth-device.js'
|
|
32
|
+
import { GUIDANCE_SECTION, GUIDANCE_ORDER, guidanceText } from './guidance.js'
|
|
33
|
+
import { registerRoutes, STATUS_PATH, SCM_PATH, PANEL_PATH } from './routes.js'
|
|
34
|
+
import { lossless } from './lossless.js'
|
|
35
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
28
36
|
import { execFile } from 'node:child_process'
|
|
29
37
|
import { promisify } from 'node:util'
|
|
30
38
|
|
|
31
39
|
const execFileAsync = promisify(execFile)
|
|
32
40
|
|
|
41
|
+
// Carries the current tool call's cancellation signal to the shared client.
|
|
42
|
+
const callContext = new AsyncLocalStorage()
|
|
43
|
+
|
|
33
44
|
/** git runner: never throws, always answers with a code and both streams. */
|
|
34
45
|
function makeGitRunner() {
|
|
35
46
|
return async (args, { cwd, env } = {}) => {
|
|
@@ -64,6 +75,31 @@ export const OUTPUT_SCHEMA = {
|
|
|
64
75
|
},
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
/** Which area a tool belongs to, derived from its name so a new tool lands somewhere sane. */
|
|
79
|
+
export function groupOf(toolName) {
|
|
80
|
+
const name = String(toolName || '')
|
|
81
|
+
const rules = [
|
|
82
|
+
[/^gh_release_/, 'releases'],
|
|
83
|
+
[/^gh_tag_/, 'tags'],
|
|
84
|
+
[/^gh_run_/, 'runs'],
|
|
85
|
+
[/^gh_secret_/, 'secrets'],
|
|
86
|
+
[/^gh_variable_/, 'variables'],
|
|
87
|
+
[/^gh_ruleset_/, 'rulesets'],
|
|
88
|
+
[/^gh_branch_protection_/, 'branch-protection'],
|
|
89
|
+
[/^gh_mirror_/, 'mirror'],
|
|
90
|
+
[/^gh_review_job|^gh_review$|^review_post$|^ci_run$|^gh_checks$|^pr_/, 'pull-requests'],
|
|
91
|
+
[/^issue_|^gh_issue$|^gh_search$/, 'issues'],
|
|
92
|
+
[/^gh_repo_report|^gh_weekly_digest|^gh_notifications|^gh_repo_health|^gh_compare|^gh_trending|^gh_contributors|^gh_user_repos|^gh_commits/, 'insights'],
|
|
93
|
+
[/^gh_repo|^gh_file/, 'repositories'],
|
|
94
|
+
[/^gh_push_|^gh_upload_|^gh_delete_/, 'write-files'],
|
|
95
|
+
[/^gh_auth_/, 'auth'],
|
|
96
|
+
[/^gh_api$/, 'api'],
|
|
97
|
+
[/^gh_help$/, 'meta'],
|
|
98
|
+
]
|
|
99
|
+
for (const [pattern, group] of rules) if (pattern.test(name)) return group
|
|
100
|
+
return 'other'
|
|
101
|
+
}
|
|
102
|
+
|
|
67
103
|
const MAX_RENDER_CHARS = 20000
|
|
68
104
|
|
|
69
105
|
/** Render a tool result for the model: readable text, bounded so one call cannot flood the turn. */
|
|
@@ -104,6 +140,22 @@ export const Config = Schema.object({
|
|
|
104
140
|
.description('Retries for a read (GET/HEAD) on a network failure, a timeout or a 5xx. Writes are never retried automatically.'),
|
|
105
141
|
reviewRulesJson: Schema.string().default('')
|
|
106
142
|
.description('Optional review-rule overrides as JSON: sensitivePaths, sensitiveSeverity, attentionPaths, migrationPaths, testsRequired, sourcePatterns, testPatterns, largeDiffLines.'),
|
|
143
|
+
oauthClientId: Schema.string().default(DEFAULT_CLIENT_ID)
|
|
144
|
+
.description('OAuth application id used by gh_auth_login. The default is the public GitHub CLI application.'),
|
|
145
|
+
oauthScope: Schema.string().default(DEFAULT_SCOPE)
|
|
146
|
+
.description('OAuth scope requested by gh_auth_login.'),
|
|
147
|
+
oauthClientSecretRef: Schema.string().role('credential-ref').default('')
|
|
148
|
+
.description('Optional credential holding the OAuth client secret, for apps that require one.'),
|
|
149
|
+
accessFile: Schema.string().default('')
|
|
150
|
+
.description('Where gh_auth_login stores its sign-in (default: $DSH_HOME/github-ops-auth.json, mode 0600).'),
|
|
151
|
+
interceptLinks: Schema.boolean().default(false)
|
|
152
|
+
.description('Let a github.com link clicked in the conversation also select that repository for the Source control tab and the sidebar panel. Off by default: the link always opens in the browser either way.'),
|
|
153
|
+
guidance: Schema.boolean().default(true)
|
|
154
|
+
.description('Add a short paragraph about this plugin to the system prompt: which tool for which question, when the repository argument can be omitted, and that approvals belong to the host.'),
|
|
155
|
+
guidanceText: Schema.string().default('')
|
|
156
|
+
.description('Replace the default guidance paragraph with your own text.'),
|
|
157
|
+
cacheTtlMs: Schema.number().default(60000)
|
|
158
|
+
.description('How long a read stays cached, in milliseconds. A composed report then costs one request set instead of many; writes always go to the network.'),
|
|
107
159
|
reviewJobTimeoutMs: Schema.number().default(120000)
|
|
108
160
|
.description('How long a background review job may run before it is cancelled.'),
|
|
109
161
|
})
|
|
@@ -142,6 +194,12 @@ export function apply(ctx, rawConfig) {
|
|
|
142
194
|
}
|
|
143
195
|
}
|
|
144
196
|
|
|
197
|
+
/** Where the plugin's own sign-in lives. */
|
|
198
|
+
function accessPath() {
|
|
199
|
+
const configured = liveConfig().accessFile
|
|
200
|
+
return configured && String(configured).trim() ? String(configured).trim() : accessFilePath()
|
|
201
|
+
}
|
|
202
|
+
|
|
145
203
|
/** The gh CLI session, when the machine already has `gh auth login` done. */
|
|
146
204
|
async function ghCliAccess() {
|
|
147
205
|
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 10000, maxBuffer: 1024 * 1024 })
|
|
@@ -152,6 +210,11 @@ export function apply(ctx, rawConfig) {
|
|
|
152
210
|
async function resolveToken() {
|
|
153
211
|
const cfg = liveConfig()
|
|
154
212
|
return resolveAccess({
|
|
213
|
+
// the sign-in this plugin stored itself (gh_auth_login) is one of the sources
|
|
214
|
+
runFile: async () => {
|
|
215
|
+
const stored = await readAccessFile(accessPath())
|
|
216
|
+
return stored && stored.token ? stored.token : ''
|
|
217
|
+
},
|
|
155
218
|
// the credentials service takes a branded reference, not a bare name
|
|
156
219
|
credentials: {
|
|
157
220
|
resolve: async (name) => {
|
|
@@ -169,18 +232,40 @@ export function apply(ctx, rawConfig) {
|
|
|
169
232
|
})
|
|
170
233
|
}
|
|
171
234
|
|
|
235
|
+
// One client per configuration: the read cache lives in it, and the signal for the
|
|
236
|
+
// current tool call arrives through the async context.
|
|
237
|
+
let sharedClient = null
|
|
238
|
+
let sharedKey = ''
|
|
239
|
+
|
|
172
240
|
async function client() {
|
|
173
241
|
const cfg = liveConfig()
|
|
174
242
|
const access = await resolveToken()
|
|
175
243
|
if (!access.value) {
|
|
176
244
|
throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
|
|
177
245
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
246
|
+
const key = [
|
|
247
|
+
access.source,
|
|
248
|
+
access.value.length,
|
|
249
|
+
cfg.baseUrl || '',
|
|
250
|
+
cfg.timeoutMs || '',
|
|
251
|
+
cfg.cacheTtlMs || '',
|
|
252
|
+
cfg.maxRetries ?? '',
|
|
253
|
+
].join('|')
|
|
254
|
+
if (!sharedClient || sharedKey !== key) {
|
|
255
|
+
sharedClient = createGitHubClient({
|
|
256
|
+
token: access.value,
|
|
257
|
+
baseUrl: cfg.baseUrl || 'https://api.github.com',
|
|
258
|
+
timeoutMs: Number(cfg.timeoutMs) || 30000,
|
|
259
|
+
cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
|
|
260
|
+
maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
|
|
261
|
+
signalProvider: () => {
|
|
262
|
+
const store = callContext.getStore()
|
|
263
|
+
return store && store.signal ? store.signal : undefined
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
sharedKey = key
|
|
267
|
+
}
|
|
268
|
+
return sharedClient
|
|
184
269
|
}
|
|
185
270
|
|
|
186
271
|
/**
|
|
@@ -217,6 +302,10 @@ export function apply(ctx, rawConfig) {
|
|
|
217
302
|
* plugin must never be the reason a deployment cannot start.
|
|
218
303
|
*/
|
|
219
304
|
const skippedTools = []
|
|
305
|
+
/** Every registered tool, so gh_help never drifts from reality. */
|
|
306
|
+
const catalog = []
|
|
307
|
+
const WRITE_PREFIXES = ['pr_', 'issue_', 'review_post', 'gh_api', 'gh_run_rerun', 'gh_run_cancel', 'gh_mirror_publish', 'gh_release_', 'gh_tag_', 'gh_repo_create', 'gh_repo_edit', 'gh_secret_', 'gh_variable_', 'gh_ruleset_', 'gh_branch_protection_set', 'gh_branch_protection_delete', 'gh_push_']
|
|
308
|
+
|
|
220
309
|
function tool(toolName, description, parameters, run) {
|
|
221
310
|
const definition = defineTool({
|
|
222
311
|
name: toolName,
|
|
@@ -227,10 +316,20 @@ export function apply(ctx, rawConfig) {
|
|
|
227
316
|
render: (_args, value) => renderToolResult(toolName, value),
|
|
228
317
|
},
|
|
229
318
|
...presentationFor(toolName),
|
|
230
|
-
execute: async (args) => {
|
|
319
|
+
execute: async (args, exec) => {
|
|
231
320
|
try {
|
|
232
|
-
const value = await run(args || {})
|
|
233
|
-
|
|
321
|
+
const value = await callContext.run({ signal: exec && exec.signal }, () => run(args || {}))
|
|
322
|
+
// A write may invalidate what a cached read returned.
|
|
323
|
+
if (WRITE_PREFIXES.some((prefix) => toolName === prefix || toolName.startsWith(prefix))) {
|
|
324
|
+
try {
|
|
325
|
+
sharedClient?.cacheClear?.()
|
|
326
|
+
} catch {
|
|
327
|
+
// a cache reset must never fail a successful call
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// One place decides what a result may contain: the harness refuses a value that
|
|
331
|
+
// cannot be serialised losslessly, and it refuses the whole call for one undefined.
|
|
332
|
+
return { ok: true, data: lossless(value) }
|
|
234
333
|
} catch (err) {
|
|
235
334
|
const out = { ok: false, error: describeError(err) }
|
|
236
335
|
if (err instanceof GitHubError) {
|
|
@@ -238,12 +337,13 @@ export function apply(ctx, rawConfig) {
|
|
|
238
337
|
if (err.code) out.code = err.code
|
|
239
338
|
if (err.rateLimit) out.rateLimit = err.rateLimit
|
|
240
339
|
}
|
|
241
|
-
return out
|
|
340
|
+
return lossless(out)
|
|
242
341
|
}
|
|
243
342
|
},
|
|
244
343
|
})
|
|
245
344
|
try {
|
|
246
345
|
ctx.tools.register(definition)
|
|
346
|
+
catalog.push({ name: toolName, description, group: groupOf(toolName) })
|
|
247
347
|
} catch (err) {
|
|
248
348
|
if (!/is already registered/.test(String((err && err.message) || err))) throw err
|
|
249
349
|
skippedTools.push(toolName)
|
|
@@ -251,10 +351,35 @@ export function apply(ctx, rawConfig) {
|
|
|
251
351
|
}
|
|
252
352
|
}
|
|
253
353
|
|
|
254
|
-
const
|
|
354
|
+
const pendingFlow = { current: null }
|
|
355
|
+
const authSeam = {
|
|
356
|
+
config: () => {
|
|
357
|
+
const cfg = liveConfig()
|
|
358
|
+
return { clientId: cfg.oauthClientId || DEFAULT_CLIENT_ID, scope: cfg.oauthScope || DEFAULT_SCOPE, secretRef: cfg.oauthClientSecretRef || '' }
|
|
359
|
+
},
|
|
360
|
+
path: accessPath,
|
|
361
|
+
remember: (flow) => { pendingFlow.current = flow },
|
|
362
|
+
pending: () => pendingFlow.current,
|
|
363
|
+
forget: () => { pendingFlow.current = null },
|
|
364
|
+
resolve: resolveToken,
|
|
365
|
+
secret: async () => {
|
|
366
|
+
const ref = liveConfig().oauthClientSecretRef
|
|
367
|
+
if (!ref) return ''
|
|
368
|
+
try {
|
|
369
|
+
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
370
|
+
return (resolved && resolved.value) || ''
|
|
371
|
+
} catch {
|
|
372
|
+
return ''
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam }
|
|
255
378
|
registerCoreTools(deps)
|
|
256
379
|
registerCollabTools(deps)
|
|
257
380
|
registerOpsTools(deps)
|
|
381
|
+
registerInsightTools(deps)
|
|
382
|
+
registerWriteTools(deps)
|
|
258
383
|
registerMirrorTools({
|
|
259
384
|
tool,
|
|
260
385
|
git: makeGitRunner(),
|
|
@@ -283,6 +408,30 @@ export function apply(ctx, rawConfig) {
|
|
|
283
408
|
},
|
|
284
409
|
})
|
|
285
410
|
|
|
411
|
+
registerRoutes(ctx, {
|
|
412
|
+
resolveToken,
|
|
413
|
+
client,
|
|
414
|
+
liveConfig,
|
|
415
|
+
describeError,
|
|
416
|
+
makeGitRunner,
|
|
417
|
+
cacheStats: () => (sharedClient && typeof sharedClient.cacheStats === 'function' ? sharedClient.cacheStats() : null),
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// One short paragraph in the system prompt: 50+ tools otherwise leave the model guessing.
|
|
421
|
+
ctx.inject(['systemPrompt'], (sctx) => {
|
|
422
|
+
try {
|
|
423
|
+
const cfg = liveConfig()
|
|
424
|
+
if (cfg.guidance === false) return
|
|
425
|
+
sctx.systemPrompt.section({
|
|
426
|
+
name: GUIDANCE_SECTION,
|
|
427
|
+
order: GUIDANCE_ORDER,
|
|
428
|
+
text: guidanceText(cfg.guidanceText),
|
|
429
|
+
})
|
|
430
|
+
} catch (err) {
|
|
431
|
+
ctx.logger?.warn?.('[dsh-github-ops] system prompt guidance not registered:', err && err.message)
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
286
435
|
// Slash commands are a fast path for a human: they only hand the model an instruction, so
|
|
287
436
|
// the write still passes through the host approval contour.
|
|
288
437
|
ctx.inject(['commands'], (cctx) => {
|
|
@@ -308,6 +457,31 @@ export function apply(ctx, rawConfig) {
|
|
|
308
457
|
})
|
|
309
458
|
|
|
310
459
|
|
|
460
|
+
// The catalog is built from the registrations above, so it cannot go stale.
|
|
461
|
+
tool(
|
|
462
|
+
'gh_help',
|
|
463
|
+
'Catalog of every tool this plugin provides, grouped by area, with one line each. Call it when unsure which tool fits — it lists what exists instead of guessing.',
|
|
464
|
+
{ group: { type: 'string', description: 'Optional: only this group (repositories, releases, tags, issues, pull-requests, runs, secrets, variables, rulesets, branch-protection, mirror, jobs, api, meta).' } },
|
|
465
|
+
async (args) => {
|
|
466
|
+
const groups = {}
|
|
467
|
+
for (const entry of catalog) {
|
|
468
|
+
if (entry.name === 'gh_help') continue
|
|
469
|
+
groups[entry.group] = groups[entry.group] || []
|
|
470
|
+
groups[entry.group].push({ name: entry.name, description: entry.description })
|
|
471
|
+
}
|
|
472
|
+
if (args.group) {
|
|
473
|
+
const only = groups[args.group]
|
|
474
|
+
if (!only) return { error: `unknown group "${args.group}"`, groups: Object.keys(groups).sort() }
|
|
475
|
+
return { group: args.group, count: only.length, tools: only }
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
count: Object.values(groups).reduce((sum, list) => sum + list.length, 0),
|
|
479
|
+
groups: Object.keys(groups).sort().map((name) => ({ name, count: groups[name].length })),
|
|
480
|
+
tools: groups,
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
|
|
311
485
|
if (skippedTools.length) {
|
|
312
486
|
ctx.logger?.warn?.(
|
|
313
487
|
`[dsh-github-ops] ${skippedTools.length} tool(s) left to the plugin that already provides them: `
|
package/lib/lossless.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Making every tool result survive JSON without losing anything.
|
|
2
|
+
//
|
|
3
|
+
// The harness refuses a tool result that cannot be serialised losslessly, and it refuses the
|
|
4
|
+
// WHOLE call: one `undefined` deep inside a payload drops the answer the caller waited for. That
|
|
5
|
+
// is how a directory entry without a `size` field took down `gh_repo_tree`, and how the same
|
|
6
|
+
// class of defect took down two tools of another plugin.
|
|
7
|
+
//
|
|
8
|
+
// This module is the single place that decides what a result may contain. It is applied to every
|
|
9
|
+
// tool result in one wrapper, so no individual tool has to remember the rule.
|
|
10
|
+
|
|
11
|
+
const MAX_DEPTH = 40
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return a value that serialises identically in every JSON implementation:
|
|
15
|
+
* - `undefined` is dropped from objects and becomes `null` inside arrays (JSON has no holes);
|
|
16
|
+
* - `NaN` and the infinities become `null` (they have no JSON form);
|
|
17
|
+
* - a `Date` becomes its ISO string; a `Map` an object; a `Set` and a typed array an array; a
|
|
18
|
+
* `Buffer` a base64 string;
|
|
19
|
+
* - functions and symbols are dropped — they cannot be sent anywhere;
|
|
20
|
+
* - a `BigInt` becomes a string (JSON has no integer that large);
|
|
21
|
+
* - a cycle becomes the string "[circular]" instead of endless recursion.
|
|
22
|
+
*/
|
|
23
|
+
export function lossless(value, depth = 0, seen = new WeakSet()) {
|
|
24
|
+
if (value === null) return null
|
|
25
|
+
const type = typeof value
|
|
26
|
+
if (type === 'string' || type === 'boolean') return value
|
|
27
|
+
if (type === 'number') return Number.isFinite(value) ? value : null
|
|
28
|
+
if (type === 'bigint') return value.toString()
|
|
29
|
+
if (type === 'undefined' || type === 'function' || type === 'symbol') return undefined
|
|
30
|
+
|
|
31
|
+
if (depth > MAX_DEPTH) return '[too deep]'
|
|
32
|
+
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null
|
|
33
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) return value.toString('base64')
|
|
34
|
+
|
|
35
|
+
if (typeof value === 'object') {
|
|
36
|
+
if (seen.has(value)) return '[circular]'
|
|
37
|
+
seen.add(value)
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
|
|
40
|
+
const out = Array.from(value, (item) => {
|
|
41
|
+
const converted = lossless(item, depth + 1, seen)
|
|
42
|
+
return converted === undefined ? null : converted
|
|
43
|
+
})
|
|
44
|
+
seen.delete(value)
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
if (value instanceof Map) {
|
|
48
|
+
const out = {}
|
|
49
|
+
for (const [key, item] of value.entries()) {
|
|
50
|
+
const converted = lossless(item, depth + 1, seen)
|
|
51
|
+
if (converted !== undefined) out[String(key)] = converted
|
|
52
|
+
}
|
|
53
|
+
seen.delete(value)
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
if (value instanceof Set) {
|
|
57
|
+
const out = []
|
|
58
|
+
for (const item of value.values()) {
|
|
59
|
+
const converted = lossless(item, depth + 1, seen)
|
|
60
|
+
out.push(converted === undefined ? null : converted)
|
|
61
|
+
}
|
|
62
|
+
seen.delete(value)
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const out = {}
|
|
67
|
+
for (const key of Object.keys(value)) {
|
|
68
|
+
const converted = lossless(value[key], depth + 1, seen)
|
|
69
|
+
if (converted !== undefined) out[key] = converted
|
|
70
|
+
}
|
|
71
|
+
seen.delete(value)
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a value would pass the harness's check. Kept as a separate, tiny predicate so tests can
|
|
80
|
+
* assert the rule instead of comparing strings.
|
|
81
|
+
*/
|
|
82
|
+
export function isLossless(value) {
|
|
83
|
+
try {
|
|
84
|
+
const once = JSON.stringify(value)
|
|
85
|
+
if (once === undefined) return false
|
|
86
|
+
return JSON.stringify(lossless(JSON.parse(once))) === once
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|