@goodandready/dsh-github-ops 0.2.12 → 0.3.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 +12 -0
- package/lib/client.js +15 -2
- package/lib/index.js +12 -4
- package/lib/routes.js +27 -2
- package/lib/scm-write.js +11 -4
- package/lib/status.js +3 -0
- package/lib/token-audit.js +84 -0
- package/lib/tools/actions.js +2 -0
- package/lib/tools/register-ops.js +13 -0
- package/lib/tools/register-write.js +23 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Cordis Context CWD Guard & SCM Route Exception Shield** (#70, #76): Added `safeCwd(ctx)` helper across routes and tools to prevent `Error: cannot get property "cwd" without inject` when accessing undeclared context properties in Cordis 8+ / DSH 0.2 runtimes. Wrapped SCM HTTP route handler in top-level error handling.
|
|
7
|
+
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
10
|
+
### Features
|
|
11
|
+
- **Token Capability Diagnostics & Scopes Audit** (#72): Introduced `gh_token_audit` tool (`auditTokenScopes`) providing automated token permission checks (classic vs fine-grained) and early capability warnings for missing repository, workflow, packages, or org administration scopes. Added `scopeWarnings` in `/status` payload.
|
|
12
|
+
- **Selective CI Failure Log Inspector** (#67): Added `gh_run_failure_inspector` tool with regex-based failure step isolation, automatic ANSI sequence stripping, secret token masking, and highlighted failure summaries without needing to download full workflow artifact archives.
|
|
13
|
+
- **Interactive Stash Management & Diff Preview** (#70): Added stash diff preview endpoint (`GET /scm?stash=stash@{N}`) and full lifecycle actions (`stashShow`, `stashApply`, `stashPop`, `stashDrop` with confirmation safety). Updated Source Control UI with quick diff viewer and stash operation buttons.
|
|
14
|
+
|
|
3
15
|
## 0.2.12
|
|
4
16
|
|
|
5
17
|
### Security
|
package/lib/client.js
CHANGED
|
@@ -101,6 +101,7 @@ window.__ModuleLoader__.load({
|
|
|
101
101
|
scmDelete: 'Delete',
|
|
102
102
|
scmSwitch: 'Switch',
|
|
103
103
|
scmStashSave: 'Save stash',
|
|
104
|
+
scmStashView: 'Diff',
|
|
104
105
|
scmStashApply: 'Apply',
|
|
105
106
|
scmStashPop: 'Pop',
|
|
106
107
|
scmStashDrop: 'Drop',
|
|
@@ -203,6 +204,7 @@ window.__ModuleLoader__.load({
|
|
|
203
204
|
scmDelete: '删除',
|
|
204
205
|
scmSwitch: '切换',
|
|
205
206
|
scmStashSave: '保存 stash',
|
|
207
|
+
scmStashView: '差异',
|
|
206
208
|
scmStashApply: '应用',
|
|
207
209
|
scmStashPop: '弹出',
|
|
208
210
|
scmStashDrop: '删除',
|
|
@@ -328,7 +330,17 @@ window.__ModuleLoader__.load({
|
|
|
328
330
|
const openDiff = (file, staged) => {
|
|
329
331
|
setSelected({ path: file.path, staged })
|
|
330
332
|
setDiff({ phase: 'loading', text: '' })
|
|
331
|
-
const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}`
|
|
333
|
+
const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}${cwdQuery ? `&${cwdQuery}` : ''}`
|
|
334
|
+
fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
|
|
335
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
336
|
+
.then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
|
|
337
|
+
.catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const openStashDiff = (stashRef) => {
|
|
341
|
+
setSelected({ path: stashRef, isStash: true })
|
|
342
|
+
setDiff({ phase: 'loading', text: '' })
|
|
343
|
+
const query = `?stash=${encodeURIComponent(stashRef)}${cwdQuery ? `&${cwdQuery}` : ''}`
|
|
332
344
|
fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
|
|
333
345
|
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
|
|
334
346
|
.then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
|
|
@@ -460,6 +472,7 @@ window.__ModuleLoader__.load({
|
|
|
460
472
|
onChange: (event) => setStashMessage(event.target.value),
|
|
461
473
|
}),
|
|
462
474
|
button(t('scmStashSave'), () => runAction('stashPush', { message: stashMessage }), { disabled: Boolean(busy) }),
|
|
475
|
+
(payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashView')} ${entry.ref}`, () => openStashDiff(entry.ref))),
|
|
463
476
|
(payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashApply')} ${entry.ref}`, () => runAction('stashApply', { ref: entry.ref }))),
|
|
464
477
|
(payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashPop')} ${entry.ref}`, () => runAction('stashPop', { ref: entry.ref }))),
|
|
465
478
|
(payload.stashes || []).slice(0, 4).map((entry) => button(
|
|
@@ -1128,7 +1141,7 @@ window.__ModuleLoader__.load({
|
|
|
1128
1141
|
exports.SCM_ACTIONS = [
|
|
1129
1142
|
'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
|
|
1130
1143
|
'branchCreate', 'branchCheckout', 'branchDelete',
|
|
1131
|
-
'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'continue', 'abort',
|
|
1144
|
+
'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow', 'continue', 'abort',
|
|
1132
1145
|
]
|
|
1133
1146
|
exports.PullRequestBar = PullRequestBar
|
|
1134
1147
|
exports.GitHubPanel = GitHubPanel
|
package/lib/index.js
CHANGED
|
@@ -63,6 +63,14 @@ function makeGitRunner() {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export function safeCwd(ctx) {
|
|
67
|
+
try {
|
|
68
|
+
return (typeof ctx?.cwd === 'string' && ctx.cwd) || process.cwd()
|
|
69
|
+
} catch {
|
|
70
|
+
return process.cwd()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
export const name = 'dsh-github-ops'
|
|
67
75
|
export const inject = ['tools', 'credentials', 'settings']
|
|
68
76
|
|
|
@@ -279,7 +287,7 @@ export function apply(ctx, rawConfig) {
|
|
|
279
287
|
const fallback = cfg.defaultRepository || ''
|
|
280
288
|
let remoteUrl = ''
|
|
281
289
|
if (!(explicit && explicit.includes('/')) && !fallback) {
|
|
282
|
-
const cwd = (
|
|
290
|
+
const cwd = safeCwd(ctx)
|
|
283
291
|
const git = makeGitRunner()
|
|
284
292
|
const origin = await git(['remote', 'get-url', 'origin'], { cwd })
|
|
285
293
|
if (origin.code === 0) remoteUrl = origin.stdout.trim()
|
|
@@ -375,7 +383,7 @@ export function apply(ctx, rawConfig) {
|
|
|
375
383
|
},
|
|
376
384
|
}
|
|
377
385
|
|
|
378
|
-
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => (
|
|
386
|
+
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => safeCwd(ctx) }
|
|
379
387
|
registerCoreTools(deps)
|
|
380
388
|
registerCollabTools(deps)
|
|
381
389
|
registerOpsTools(deps)
|
|
@@ -384,7 +392,7 @@ export function apply(ctx, rawConfig) {
|
|
|
384
392
|
registerMirrorTools({
|
|
385
393
|
tool,
|
|
386
394
|
git: makeGitRunner(),
|
|
387
|
-
defaultCwd: () => (
|
|
395
|
+
defaultCwd: () => safeCwd(ctx),
|
|
388
396
|
})
|
|
389
397
|
|
|
390
398
|
// Background reviews: the host registry when the composition has one, our own records
|
|
@@ -454,7 +462,7 @@ export function apply(ctx, rawConfig) {
|
|
|
454
462
|
try {
|
|
455
463
|
const git = makeGitRunner()
|
|
456
464
|
const readGitState = async () => {
|
|
457
|
-
const cwd = (
|
|
465
|
+
const cwd = safeCwd(ctx)
|
|
458
466
|
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
|
|
459
467
|
const remote = await git(['remote', 'get-url', 'origin'], { cwd })
|
|
460
468
|
const resolved = remote.code === 0
|
package/lib/routes.js
CHANGED
|
@@ -9,7 +9,7 @@ import { existsSync, statSync } from 'node:fs'
|
|
|
9
9
|
import { join as joinPath } from 'node:path'
|
|
10
10
|
|
|
11
11
|
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
|
|
12
|
-
import { buildAction, validateCwd } from './scm-write.js'
|
|
12
|
+
import { buildAction, validateCwd, validateStashRef } from './scm-write.js'
|
|
13
13
|
import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom } from './scm.js'
|
|
14
14
|
import { listIssues } from './tools/issues.js'
|
|
15
15
|
import { listRuns } from './tools/actions.js'
|
|
@@ -31,6 +31,14 @@ export const PANEL_PATH = '/dsh-github-ops/panel'
|
|
|
31
31
|
* @param deps - resolveToken, client, liveConfig, describeError, makeGitRunner and cacheStats,
|
|
32
32
|
* injected so this module stays free of the host's own composition.
|
|
33
33
|
*/
|
|
34
|
+
function safeCwd(ctx) {
|
|
35
|
+
try {
|
|
36
|
+
return (typeof ctx?.cwd === 'string' && ctx.cwd) || process.cwd()
|
|
37
|
+
} catch {
|
|
38
|
+
return process.cwd()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
export function registerRoutes(ctx, deps = {}) {
|
|
35
43
|
const {
|
|
36
44
|
resolveToken = async () => ({ value: '', source: '', guidance: '' }),
|
|
@@ -93,6 +101,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
93
101
|
kind: 'exact',
|
|
94
102
|
path: SCM_PATH,
|
|
95
103
|
handler: async (req, res) => {
|
|
104
|
+
try {
|
|
96
105
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
97
106
|
if (req.method === 'POST') {
|
|
98
107
|
if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
@@ -107,7 +116,7 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
107
116
|
// The tab may look at another directory than the session workspace; the value is
|
|
108
117
|
// validated, must exist and must be a directory.
|
|
109
118
|
const requested = validateCwd(url.searchParams.get('cwd') || '')
|
|
110
|
-
let cwd = (
|
|
119
|
+
let cwd = safeCwd(ctx)
|
|
111
120
|
if (requested) {
|
|
112
121
|
if (!existsSync(requested) || !statSync(requested).isDirectory()) {
|
|
113
122
|
return sendJson(res, 400, { error: `not a directory: ${requested}` })
|
|
@@ -168,6 +177,19 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
168
177
|
return sendJson(res, 200, { file, staged, diff: String(text || '').slice(0, 200000) })
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
const stash = url.searchParams.get('stash') || ''
|
|
181
|
+
if (stash) {
|
|
182
|
+
let stashRef
|
|
183
|
+
try {
|
|
184
|
+
stashRef = validateStashRef(stash)
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return sendJson(res, 400, { error: String((err && err.message) || err) })
|
|
187
|
+
}
|
|
188
|
+
const diff = await git(['stash', 'show', '-p', stashRef], { cwd })
|
|
189
|
+
const text = diff.code === 0 ? diff.stdout : diff.stderr
|
|
190
|
+
return sendJson(res, 200, { stash: stashRef, diff: String(text || '').slice(0, 200000) })
|
|
191
|
+
}
|
|
192
|
+
|
|
171
193
|
const branchAnswer = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
|
|
172
194
|
if (branchAnswer.code !== 0) {
|
|
173
195
|
return sendJson(res, 200, scmPayload({ cwd, error: 'not a git repository (or no commits yet)' }))
|
|
@@ -211,6 +233,9 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
211
233
|
worktrees: parseWorktrees(worktreesAnswer.code === 0 ? worktreesAnswer.stdout : ''),
|
|
212
234
|
merge,
|
|
213
235
|
}))
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
238
|
+
}
|
|
214
239
|
},
|
|
215
240
|
})
|
|
216
241
|
if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: scm route')
|
package/lib/scm-write.js
CHANGED
|
@@ -36,9 +36,10 @@ export function validateMessage(message) {
|
|
|
36
36
|
return trimmed
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** Stash references are `stash@{N}
|
|
39
|
+
/** Stash references are `stash@{N}` or integer indices; nothing else is accepted. */
|
|
40
40
|
export function validateStashRef(ref) {
|
|
41
|
-
|
|
41
|
+
let value = String(ref == null ? '' : ref).trim()
|
|
42
|
+
if (/^\d{1,3}$/.test(value)) value = `stash@{${value}}`
|
|
42
43
|
if (!/^stash@\{\d{1,3}\}$/.test(value)) throw new Error(`refusing an unsafe stash reference: "${value.slice(0, 40)}"`)
|
|
43
44
|
return value
|
|
44
45
|
}
|
|
@@ -92,11 +93,17 @@ export function buildAction(action, args = {}) {
|
|
|
92
93
|
return result(name, steps, destructive, 'save the working copy to a stash')
|
|
93
94
|
}
|
|
94
95
|
case 'stashApply':
|
|
96
|
+
case 'stash_apply':
|
|
95
97
|
return result(name, [['stash', 'apply', validateStashRef(args.ref)]], destructive, `apply ${args.ref}`)
|
|
96
98
|
case 'stashPop':
|
|
99
|
+
case 'stash_pop':
|
|
97
100
|
return result(name, [['stash', 'pop', validateStashRef(args.ref)]], destructive, `apply and drop ${args.ref}`)
|
|
98
101
|
case 'stashDrop':
|
|
102
|
+
case 'stash_drop':
|
|
99
103
|
return result(name, [['stash', 'drop', validateStashRef(args.ref)]], destructive, `drop ${args.ref}`)
|
|
104
|
+
case 'stashShow':
|
|
105
|
+
case 'stash_show':
|
|
106
|
+
return result(name, [['stash', 'show', '-p', validateStashRef(args.ref)]], destructive, `show diff of ${args.ref}`)
|
|
100
107
|
case 'continue': {
|
|
101
108
|
const verb = String(args.verb || '').trim()
|
|
102
109
|
if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to continue an unknown operation: "${verb.slice(0, 20)}"`)
|
|
@@ -136,7 +143,7 @@ export function buildAction(action, args = {}) {
|
|
|
136
143
|
}
|
|
137
144
|
}
|
|
138
145
|
|
|
139
|
-
const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'abort', 'worktreeRemove'])
|
|
146
|
+
const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'stash_drop', 'abort', 'worktreeRemove'])
|
|
140
147
|
|
|
141
148
|
function result(action, steps, destructive, summary) {
|
|
142
149
|
return { action, steps, mutating: true, destructive, needsConfirm: destructive, summary }
|
|
@@ -146,7 +153,7 @@ function result(action, steps, destructive, summary) {
|
|
|
146
153
|
export const ACTIONS = Object.freeze([
|
|
147
154
|
'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
|
|
148
155
|
'branchCreate', 'branchCheckout', 'branchDelete',
|
|
149
|
-
'stashPush', 'stashApply', 'stashPop', 'stashDrop',
|
|
156
|
+
'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow',
|
|
150
157
|
'worktreeAdd', 'worktreeRemove',
|
|
151
158
|
'continue', 'abort',
|
|
152
159
|
])
|
package/lib/status.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { auditTokenScopes } from './token-audit.js'
|
|
1
2
|
// What the settings card is allowed to know.
|
|
2
3
|
//
|
|
3
4
|
// The status of the access — where it comes from, which account, what the rate limit looks
|
|
@@ -126,12 +127,14 @@ function header(req, name) {
|
|
|
126
127
|
*/
|
|
127
128
|
export function statusPayload({ access, identity, rateLimit, scopes, cache, error } = {}) {
|
|
128
129
|
const configured = Boolean(access && access.value)
|
|
130
|
+
const audit = auditTokenScopes(scopes)
|
|
129
131
|
return {
|
|
130
132
|
configured,
|
|
131
133
|
source: configured ? access.source || '' : '',
|
|
132
134
|
login: (identity && identity.login) || '',
|
|
133
135
|
name: (identity && identity.name) || '',
|
|
134
136
|
scopes: scopes || '',
|
|
137
|
+
scopeWarnings: audit.warnings,
|
|
135
138
|
rateLimit: rateLimit
|
|
136
139
|
? { limit: rateLimit.limit, remaining: rateLimit.remaining, resetAt: rateLimit.resetAt }
|
|
137
140
|
: null,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Token scopes audit and capability diagnostics.
|
|
2
|
+
// Compares GitHub x-oauth-scopes against features of dsh-github-ops.
|
|
3
|
+
|
|
4
|
+
export const CAPABILITY_REQUIREMENTS = [
|
|
5
|
+
{
|
|
6
|
+
capability: 'Repositories & Code (Private)',
|
|
7
|
+
requiredAny: ['repo'],
|
|
8
|
+
impact: 'Access to private repositories, code pushes, branches, releases, issues and PRs',
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
capability: 'Repositories & Code (Public)',
|
|
12
|
+
requiredAny: ['repo', 'public_repo'],
|
|
13
|
+
impact: 'Access to public repository code, issues, releases and PRs',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
capability: 'Workflows & CI Runs',
|
|
17
|
+
requiredAny: ['workflow'],
|
|
18
|
+
impact: 'Triggering workflow runs, cancelling runs and updating .github/workflows',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
capability: 'GitHub Packages',
|
|
22
|
+
requiredAny: ['write:packages', 'read:packages'],
|
|
23
|
+
impact: 'Publishing and downloading package bundles from ghcr.io / npm.pkg.github.com',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
capability: 'Organization & Teams',
|
|
27
|
+
requiredAny: ['read:org', 'admin:org'],
|
|
28
|
+
impact: 'Listing organization repositories, teams and membership',
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export function auditTokenScopes(rawScopes) {
|
|
33
|
+
const scopesStr = typeof rawScopes === 'string' ? rawScopes.trim() : ''
|
|
34
|
+
if (!scopesStr) {
|
|
35
|
+
return {
|
|
36
|
+
scopes: [],
|
|
37
|
+
raw: '',
|
|
38
|
+
isFineGrained: true,
|
|
39
|
+
capabilities: [],
|
|
40
|
+
missing: [],
|
|
41
|
+
warnings: ['Token does not report OAuth scopes (typical for fine-grained Personal Access Tokens or GitHub Apps). Permissions are scoped per-repository.'],
|
|
42
|
+
hasFullRepoAccess: false,
|
|
43
|
+
hasWorkflowAccess: false,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const scopeList = scopesStr.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
48
|
+
const scopeSet = new Set(scopeList)
|
|
49
|
+
|
|
50
|
+
const capabilities = []
|
|
51
|
+
const missing = []
|
|
52
|
+
const warnings = []
|
|
53
|
+
|
|
54
|
+
for (const item of CAPABILITY_REQUIREMENTS) {
|
|
55
|
+
const granted = item.requiredAny.some((s) => scopeSet.has(s))
|
|
56
|
+
capabilities.push({
|
|
57
|
+
capability: item.capability,
|
|
58
|
+
granted,
|
|
59
|
+
requiredAny: item.requiredAny,
|
|
60
|
+
})
|
|
61
|
+
if (!granted) {
|
|
62
|
+
missing.push({
|
|
63
|
+
capability: item.capability,
|
|
64
|
+
requiredAny: item.requiredAny,
|
|
65
|
+
impact: item.impact,
|
|
66
|
+
})
|
|
67
|
+
warnings.push(`Missing scope [${item.requiredAny.join(' or ')}] for ${item.capability}: ${item.impact}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hasFullRepoAccess = scopeSet.has('repo')
|
|
72
|
+
const hasWorkflowAccess = scopeSet.has('workflow')
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
scopes: scopeList,
|
|
76
|
+
raw: scopesStr,
|
|
77
|
+
isFineGrained: false,
|
|
78
|
+
capabilities,
|
|
79
|
+
missing,
|
|
80
|
+
warnings,
|
|
81
|
+
hasFullRepoAccess,
|
|
82
|
+
hasWorkflowAccess,
|
|
83
|
+
}
|
|
84
|
+
}
|
package/lib/tools/actions.js
CHANGED
|
@@ -175,11 +175,13 @@ export async function getJobLogSummary(client, { owner, repo, runId, jobId, maxL
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
const lines = cleanLogContent(logText, maxLines)
|
|
178
|
+
const errorHighlights = lines.filter((l) => /\b(error|failed|failure|fatal)\b|##\[error\]|npm err!/i.test(l)).slice(-10)
|
|
178
179
|
return {
|
|
179
180
|
jobId: targetJobId,
|
|
180
181
|
jobName: targetJobName,
|
|
181
182
|
failedStep,
|
|
182
183
|
lineCount: lines.length,
|
|
183
184
|
lines,
|
|
185
|
+
errorHighlights,
|
|
184
186
|
}
|
|
185
187
|
}
|
|
@@ -102,6 +102,19 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
|
|
|
102
102
|
async (args) => withRepo(args, async ({ owner, repo }) => getRunLogsUrl(await client(), { owner, repo, runId: args.runId })),
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
+
tool(
|
|
106
|
+
'gh_run_failure_inspector',
|
|
107
|
+
'Inspect failed jobs and steps in a GitHub Actions workflow run, downloading only the relevant job log text, stripping ANSI escapes, redacting tokens, and returning the diagnostic error lines without downloading full zip archives. Read-only.',
|
|
108
|
+
repoParams({
|
|
109
|
+
runId: { type: 'number', description: 'Workflow run id (e.g. from gh_run_list).' },
|
|
110
|
+
jobId: { type: 'number', description: 'Specific job id to inspect (optional, defaults to first failed job).' },
|
|
111
|
+
maxLines: { type: 'number', description: 'Maximum log lines to return (default 100, max 250).' },
|
|
112
|
+
}),
|
|
113
|
+
async (args) => withRepo(args, async ({ owner, repo }) => getJobLogSummary(await client(), {
|
|
114
|
+
owner, repo, runId: args.runId, jobId: args.jobId, maxLines: args.maxLines,
|
|
115
|
+
})),
|
|
116
|
+
)
|
|
117
|
+
|
|
105
118
|
tool(
|
|
106
119
|
'gh_run_dispatch',
|
|
107
120
|
'Trigger a workflow_dispatch event for a GitHub Actions workflow with inputs. Requires confirm: true.',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { auditTokenScopes } from '../token-audit.js'
|
|
1
2
|
// Content and account tools: writing files without a checkout, and signing in without a
|
|
2
3
|
// terminal.
|
|
3
4
|
//
|
|
@@ -226,6 +227,28 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
226
227
|
},
|
|
227
228
|
)
|
|
228
229
|
|
|
230
|
+
tool(
|
|
231
|
+
'gh_token_audit',
|
|
232
|
+
'Audit OAuth token scopes and report granted vs missing capabilities (repo, workflow, packages, org) with diagnostic warnings. Read-only.',
|
|
233
|
+
{},
|
|
234
|
+
async () => {
|
|
235
|
+
const access = await auth.resolve()
|
|
236
|
+
if (!access.value) return { configured: false, guidance: access.guidance }
|
|
237
|
+
const github = await client()
|
|
238
|
+
const { data, rateLimit } = await github.get('/user')
|
|
239
|
+
const rawScopes = github.lastScopes ? github.lastScopes() : ''
|
|
240
|
+
const audit = auditTokenScopes(rawScopes)
|
|
241
|
+
return {
|
|
242
|
+
configured: true,
|
|
243
|
+
source: access.source,
|
|
244
|
+
login: data && data.login,
|
|
245
|
+
name: data && data.name,
|
|
246
|
+
rateLimit: rateLimit || undefined,
|
|
247
|
+
...audit,
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
229
252
|
tool(
|
|
230
253
|
'gh_auth_logout',
|
|
231
254
|
'Forget the sign-in stored by this plugin (gh_auth_login). Other sources (a DSH credential, the environment, the gh CLI) are untouched. Requires confirm: true.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|