@goodandready/dsh-github-ops 0.2.12 → 0.3.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Features
6
+ - **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.
7
+ - **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.
8
+ - **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.
9
+
3
10
  ## 0.2.12
4
11
 
5
12
  ### 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/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'
@@ -168,6 +168,19 @@ export function registerRoutes(ctx, deps = {}) {
168
168
  return sendJson(res, 200, { file, staged, diff: String(text || '').slice(0, 200000) })
169
169
  }
170
170
 
171
+ const stash = url.searchParams.get('stash') || ''
172
+ if (stash) {
173
+ let stashRef
174
+ try {
175
+ stashRef = validateStashRef(stash)
176
+ } catch (err) {
177
+ return sendJson(res, 400, { error: String((err && err.message) || err) })
178
+ }
179
+ const diff = await git(['stash', 'show', '-p', stashRef], { cwd })
180
+ const text = diff.code === 0 ? diff.stdout : diff.stderr
181
+ return sendJson(res, 200, { stash: stashRef, diff: String(text || '').slice(0, 200000) })
182
+ }
183
+
171
184
  const branchAnswer = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
172
185
  if (branchAnswer.code !== 0) {
173
186
  return sendJson(res, 200, scmPayload({ cwd, error: 'not a git repository (or no commits yet)' }))
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}`; nothing else is accepted. */
39
+ /** Stash references are `stash@{N}` or integer indices; nothing else is accepted. */
40
40
  export function validateStashRef(ref) {
41
- const value = String(ref == null ? '' : ref).trim()
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
+ }
@@ -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.2.12",
3
+ "version": "0.3.0",
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",