@goodandready/dsh-github-ops 0.1.1 → 0.2.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 +47 -0
- package/README.md +95 -5
- package/README.ru.md +82 -5
- package/README.zh.md +64 -4
- package/lib/auth-device.js +150 -0
- package/lib/auth.js +17 -5
- package/lib/client.js +604 -16
- package/lib/github.js +105 -9
- package/lib/guidance.js +26 -0
- package/lib/index.js +181 -27
- package/lib/routes.js +222 -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 +2 -1
- package/lib/approval.js +0 -193
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
|
@@ -21,16 +21,25 @@ import { presentationFor } from './present.js'
|
|
|
21
21
|
import { createRecords, startReviewJob } from './jobs.js'
|
|
22
22
|
import { registerCommands } from './commands.js'
|
|
23
23
|
import { registerJobTools } from './tools/register-jobs.js'
|
|
24
|
-
import { registerApprovalGate, ALL_ACTIONS } from './approval.js'
|
|
25
24
|
import { registerCoreTools } from './tools/register-core.js'
|
|
26
25
|
import { registerCollabTools } from './tools/register-collab.js'
|
|
27
26
|
import { registerMirrorTools } from './tools/register-mirror.js'
|
|
28
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 { AsyncLocalStorage } from 'node:async_hooks'
|
|
29
35
|
import { execFile } from 'node:child_process'
|
|
30
36
|
import { promisify } from 'node:util'
|
|
31
37
|
|
|
32
38
|
const execFileAsync = promisify(execFile)
|
|
33
39
|
|
|
40
|
+
// Carries the current tool call's cancellation signal to the shared client.
|
|
41
|
+
const callContext = new AsyncLocalStorage()
|
|
42
|
+
|
|
34
43
|
/** git runner: never throws, always answers with a code and both streams. */
|
|
35
44
|
function makeGitRunner() {
|
|
36
45
|
return async (args, { cwd, env } = {}) => {
|
|
@@ -65,10 +74,32 @@ export const OUTPUT_SCHEMA = {
|
|
|
65
74
|
},
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
|
|
77
|
+
/** Which area a tool belongs to, derived from its name so a new tool lands somewhere sane. */
|
|
78
|
+
export function groupOf(toolName) {
|
|
79
|
+
const name = String(toolName || '')
|
|
80
|
+
const rules = [
|
|
81
|
+
[/^gh_release_/, 'releases'],
|
|
82
|
+
[/^gh_tag_/, 'tags'],
|
|
83
|
+
[/^gh_run_/, 'runs'],
|
|
84
|
+
[/^gh_secret_/, 'secrets'],
|
|
85
|
+
[/^gh_variable_/, 'variables'],
|
|
86
|
+
[/^gh_ruleset_/, 'rulesets'],
|
|
87
|
+
[/^gh_branch_protection_/, 'branch-protection'],
|
|
88
|
+
[/^gh_mirror_/, 'mirror'],
|
|
89
|
+
[/^gh_review_job|^gh_review$|^review_post$|^ci_run$|^gh_checks$|^pr_/, 'pull-requests'],
|
|
90
|
+
[/^issue_|^gh_issue$|^gh_search$/, 'issues'],
|
|
91
|
+
[/^gh_repo_report|^gh_weekly_digest|^gh_notifications|^gh_repo_health|^gh_compare|^gh_trending|^gh_contributors|^gh_user_repos|^gh_commits/, 'insights'],
|
|
92
|
+
[/^gh_repo|^gh_file/, 'repositories'],
|
|
93
|
+
[/^gh_push_|^gh_upload_|^gh_delete_/, 'write-files'],
|
|
94
|
+
[/^gh_auth_/, 'auth'],
|
|
95
|
+
[/^gh_api$/, 'api'],
|
|
96
|
+
[/^gh_help$/, 'meta'],
|
|
97
|
+
]
|
|
98
|
+
for (const [pattern, group] of rules) if (pattern.test(name)) return group
|
|
99
|
+
return 'other'
|
|
100
|
+
}
|
|
69
101
|
|
|
70
|
-
|
|
71
|
-
export const KNOWN_ACTIONS = ALL_ACTIONS
|
|
102
|
+
const MAX_RENDER_CHARS = 20000
|
|
72
103
|
|
|
73
104
|
/** Render a tool result for the model: readable text, bounded so one call cannot flood the turn. */
|
|
74
105
|
export function renderToolResult(toolName, value) {
|
|
@@ -108,12 +139,22 @@ export const Config = Schema.object({
|
|
|
108
139
|
.description('Retries for a read (GET/HEAD) on a network failure, a timeout or a 5xx. Writes are never retried automatically.'),
|
|
109
140
|
reviewRulesJson: Schema.string().default('')
|
|
110
141
|
.description('Optional review-rule overrides as JSON: sensitivePaths, sensitiveSeverity, attentionPaths, migrationPaths, testsRequired, sourcePatterns, testPatterns, largeDiffLines.'),
|
|
111
|
-
|
|
112
|
-
.description('
|
|
113
|
-
|
|
114
|
-
.description('
|
|
115
|
-
|
|
116
|
-
.description('
|
|
142
|
+
oauthClientId: Schema.string().default(DEFAULT_CLIENT_ID)
|
|
143
|
+
.description('OAuth application id used by gh_auth_login. The default is the public GitHub CLI application.'),
|
|
144
|
+
oauthScope: Schema.string().default(DEFAULT_SCOPE)
|
|
145
|
+
.description('OAuth scope requested by gh_auth_login.'),
|
|
146
|
+
oauthClientSecretRef: Schema.string().role('credential-ref').default('')
|
|
147
|
+
.description('Optional credential holding the OAuth client secret, for apps that require one.'),
|
|
148
|
+
accessFile: Schema.string().default('')
|
|
149
|
+
.description('Where gh_auth_login stores its sign-in (default: $DSH_HOME/github-ops-auth.json, mode 0600).'),
|
|
150
|
+
interceptLinks: Schema.boolean().default(false)
|
|
151
|
+
.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.'),
|
|
152
|
+
guidance: Schema.boolean().default(true)
|
|
153
|
+
.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.'),
|
|
154
|
+
guidanceText: Schema.string().default('')
|
|
155
|
+
.description('Replace the default guidance paragraph with your own text.'),
|
|
156
|
+
cacheTtlMs: Schema.number().default(60000)
|
|
157
|
+
.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.'),
|
|
117
158
|
reviewJobTimeoutMs: Schema.number().default(120000)
|
|
118
159
|
.description('How long a background review job may run before it is cancelled.'),
|
|
119
160
|
})
|
|
@@ -152,6 +193,12 @@ export function apply(ctx, rawConfig) {
|
|
|
152
193
|
}
|
|
153
194
|
}
|
|
154
195
|
|
|
196
|
+
/** Where the plugin's own sign-in lives. */
|
|
197
|
+
function accessPath() {
|
|
198
|
+
const configured = liveConfig().accessFile
|
|
199
|
+
return configured && String(configured).trim() ? String(configured).trim() : accessFilePath()
|
|
200
|
+
}
|
|
201
|
+
|
|
155
202
|
/** The gh CLI session, when the machine already has `gh auth login` done. */
|
|
156
203
|
async function ghCliAccess() {
|
|
157
204
|
const { stdout } = await execFileAsync('gh', ['auth', 'token'], { timeout: 10000, maxBuffer: 1024 * 1024 })
|
|
@@ -162,6 +209,11 @@ export function apply(ctx, rawConfig) {
|
|
|
162
209
|
async function resolveToken() {
|
|
163
210
|
const cfg = liveConfig()
|
|
164
211
|
return resolveAccess({
|
|
212
|
+
// the sign-in this plugin stored itself (gh_auth_login) is one of the sources
|
|
213
|
+
runFile: async () => {
|
|
214
|
+
const stored = await readAccessFile(accessPath())
|
|
215
|
+
return stored && stored.token ? stored.token : ''
|
|
216
|
+
},
|
|
165
217
|
// the credentials service takes a branded reference, not a bare name
|
|
166
218
|
credentials: {
|
|
167
219
|
resolve: async (name) => {
|
|
@@ -179,18 +231,40 @@ export function apply(ctx, rawConfig) {
|
|
|
179
231
|
})
|
|
180
232
|
}
|
|
181
233
|
|
|
234
|
+
// One client per configuration: the read cache lives in it, and the signal for the
|
|
235
|
+
// current tool call arrives through the async context.
|
|
236
|
+
let sharedClient = null
|
|
237
|
+
let sharedKey = ''
|
|
238
|
+
|
|
182
239
|
async function client() {
|
|
183
240
|
const cfg = liveConfig()
|
|
184
241
|
const access = await resolveToken()
|
|
185
242
|
if (!access.value) {
|
|
186
243
|
throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
|
|
187
244
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
245
|
+
const key = [
|
|
246
|
+
access.source,
|
|
247
|
+
access.value.length,
|
|
248
|
+
cfg.baseUrl || '',
|
|
249
|
+
cfg.timeoutMs || '',
|
|
250
|
+
cfg.cacheTtlMs || '',
|
|
251
|
+
cfg.maxRetries ?? '',
|
|
252
|
+
].join('|')
|
|
253
|
+
if (!sharedClient || sharedKey !== key) {
|
|
254
|
+
sharedClient = createGitHubClient({
|
|
255
|
+
token: access.value,
|
|
256
|
+
baseUrl: cfg.baseUrl || 'https://api.github.com',
|
|
257
|
+
timeoutMs: Number(cfg.timeoutMs) || 30000,
|
|
258
|
+
cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
|
|
259
|
+
maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
|
|
260
|
+
signalProvider: () => {
|
|
261
|
+
const store = callContext.getStore()
|
|
262
|
+
return store && store.signal ? store.signal : undefined
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
sharedKey = key
|
|
266
|
+
}
|
|
267
|
+
return sharedClient
|
|
194
268
|
}
|
|
195
269
|
|
|
196
270
|
/**
|
|
@@ -227,6 +301,10 @@ export function apply(ctx, rawConfig) {
|
|
|
227
301
|
* plugin must never be the reason a deployment cannot start.
|
|
228
302
|
*/
|
|
229
303
|
const skippedTools = []
|
|
304
|
+
/** Every registered tool, so gh_help never drifts from reality. */
|
|
305
|
+
const catalog = []
|
|
306
|
+
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_']
|
|
307
|
+
|
|
230
308
|
function tool(toolName, description, parameters, run) {
|
|
231
309
|
const definition = defineTool({
|
|
232
310
|
name: toolName,
|
|
@@ -237,9 +315,17 @@ export function apply(ctx, rawConfig) {
|
|
|
237
315
|
render: (_args, value) => renderToolResult(toolName, value),
|
|
238
316
|
},
|
|
239
317
|
...presentationFor(toolName),
|
|
240
|
-
execute: async (args) => {
|
|
318
|
+
execute: async (args, exec) => {
|
|
241
319
|
try {
|
|
242
|
-
const value = await run(args || {})
|
|
320
|
+
const value = await callContext.run({ signal: exec && exec.signal }, () => run(args || {}))
|
|
321
|
+
// A write may invalidate what a cached read returned.
|
|
322
|
+
if (WRITE_PREFIXES.some((prefix) => toolName === prefix || toolName.startsWith(prefix))) {
|
|
323
|
+
try {
|
|
324
|
+
sharedClient?.cacheClear?.()
|
|
325
|
+
} catch {
|
|
326
|
+
// a cache reset must never fail a successful call
|
|
327
|
+
}
|
|
328
|
+
}
|
|
243
329
|
return { ok: true, data: value }
|
|
244
330
|
} catch (err) {
|
|
245
331
|
const out = { ok: false, error: describeError(err) }
|
|
@@ -254,6 +340,7 @@ export function apply(ctx, rawConfig) {
|
|
|
254
340
|
})
|
|
255
341
|
try {
|
|
256
342
|
ctx.tools.register(definition)
|
|
343
|
+
catalog.push({ name: toolName, description, group: groupOf(toolName) })
|
|
257
344
|
} catch (err) {
|
|
258
345
|
if (!/is already registered/.test(String((err && err.message) || err))) throw err
|
|
259
346
|
skippedTools.push(toolName)
|
|
@@ -261,10 +348,35 @@ export function apply(ctx, rawConfig) {
|
|
|
261
348
|
}
|
|
262
349
|
}
|
|
263
350
|
|
|
264
|
-
const
|
|
351
|
+
const pendingFlow = { current: null }
|
|
352
|
+
const authSeam = {
|
|
353
|
+
config: () => {
|
|
354
|
+
const cfg = liveConfig()
|
|
355
|
+
return { clientId: cfg.oauthClientId || DEFAULT_CLIENT_ID, scope: cfg.oauthScope || DEFAULT_SCOPE, secretRef: cfg.oauthClientSecretRef || '' }
|
|
356
|
+
},
|
|
357
|
+
path: accessPath,
|
|
358
|
+
remember: (flow) => { pendingFlow.current = flow },
|
|
359
|
+
pending: () => pendingFlow.current,
|
|
360
|
+
forget: () => { pendingFlow.current = null },
|
|
361
|
+
resolve: resolveToken,
|
|
362
|
+
secret: async () => {
|
|
363
|
+
const ref = liveConfig().oauthClientSecretRef
|
|
364
|
+
if (!ref) return ''
|
|
365
|
+
try {
|
|
366
|
+
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
367
|
+
return (resolved && resolved.value) || ''
|
|
368
|
+
} catch {
|
|
369
|
+
return ''
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam }
|
|
265
375
|
registerCoreTools(deps)
|
|
266
376
|
registerCollabTools(deps)
|
|
267
377
|
registerOpsTools(deps)
|
|
378
|
+
registerInsightTools(deps)
|
|
379
|
+
registerWriteTools(deps)
|
|
268
380
|
registerMirrorTools({
|
|
269
381
|
tool,
|
|
270
382
|
git: makeGitRunner(),
|
|
@@ -293,8 +405,32 @@ export function apply(ctx, rawConfig) {
|
|
|
293
405
|
},
|
|
294
406
|
})
|
|
295
407
|
|
|
408
|
+
registerRoutes(ctx, {
|
|
409
|
+
resolveToken,
|
|
410
|
+
client,
|
|
411
|
+
liveConfig,
|
|
412
|
+
describeError,
|
|
413
|
+
makeGitRunner,
|
|
414
|
+
cacheStats: () => (sharedClient && typeof sharedClient.cacheStats === 'function' ? sharedClient.cacheStats() : null),
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// One short paragraph in the system prompt: 50+ tools otherwise leave the model guessing.
|
|
418
|
+
ctx.inject(['systemPrompt'], (sctx) => {
|
|
419
|
+
try {
|
|
420
|
+
const cfg = liveConfig()
|
|
421
|
+
if (cfg.guidance === false) return
|
|
422
|
+
sctx.systemPrompt.section({
|
|
423
|
+
name: GUIDANCE_SECTION,
|
|
424
|
+
order: GUIDANCE_ORDER,
|
|
425
|
+
text: guidanceText(cfg.guidanceText),
|
|
426
|
+
})
|
|
427
|
+
} catch (err) {
|
|
428
|
+
ctx.logger?.warn?.('[dsh-github-ops] system prompt guidance not registered:', err && err.message)
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
|
|
296
432
|
// Slash commands are a fast path for a human: they only hand the model an instruction, so
|
|
297
|
-
// the write still
|
|
433
|
+
// the write still passes through the host approval contour.
|
|
298
434
|
ctx.inject(['commands'], (cctx) => {
|
|
299
435
|
try {
|
|
300
436
|
const git = makeGitRunner()
|
|
@@ -317,13 +453,31 @@ export function apply(ctx, rawConfig) {
|
|
|
317
453
|
}
|
|
318
454
|
})
|
|
319
455
|
|
|
320
|
-
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
456
|
+
|
|
457
|
+
// The catalog is built from the registrations above, so it cannot go stale.
|
|
458
|
+
tool(
|
|
459
|
+
'gh_help',
|
|
460
|
+
'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.',
|
|
461
|
+
{ group: { type: 'string', description: 'Optional: only this group (repositories, releases, tags, issues, pull-requests, runs, secrets, variables, rulesets, branch-protection, mirror, jobs, api, meta).' } },
|
|
462
|
+
async (args) => {
|
|
463
|
+
const groups = {}
|
|
464
|
+
for (const entry of catalog) {
|
|
465
|
+
if (entry.name === 'gh_help') continue
|
|
466
|
+
groups[entry.group] = groups[entry.group] || []
|
|
467
|
+
groups[entry.group].push({ name: entry.name, description: entry.description })
|
|
468
|
+
}
|
|
469
|
+
if (args.group) {
|
|
470
|
+
const only = groups[args.group]
|
|
471
|
+
if (!only) return { error: `unknown group "${args.group}"`, groups: Object.keys(groups).sort() }
|
|
472
|
+
return { group: args.group, count: only.length, tools: only }
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
count: Object.values(groups).reduce((sum, list) => sum + list.length, 0),
|
|
476
|
+
groups: Object.keys(groups).sort().map((name) => ({ name, count: groups[name].length })),
|
|
477
|
+
tools: groups,
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
)
|
|
327
481
|
|
|
328
482
|
if (skippedTools.length) {
|
|
329
483
|
ctx.logger?.warn?.(
|