@goodandready/dsh-github-ops 0.2.9 → 0.2.10

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.10
4
+
5
+ ### Fixed
6
+ - **Settings Surface & Volatile Fields on DSH 0.2** (#76, #85): Config fields declare `.volatile()` wrappers and live settings reads are unwrapped via `plainConfig()` to comply with the DSH 0.2 live-value contract and prevent settings from being marked unavailable.
7
+ - **Dead settingsScope.replace Removed** (#100, #86): Removed unused dead `settingsScope.replace` method that swallowed errors.
8
+ - **Client Fetch AbortSignal Timeout** (#101): Attached 15s/120s `AbortSignal` timeouts to all `fetch` calls in `lib/client.js` to prevent the UI from hanging indefinitely on stalled requests.
9
+ - **Git Runner Timeout** (#90): SCM route passes `timeoutMs` (120000ms) to `execFileAsync` git runner options, properly enforcing timeouts on write operations.
10
+ - **Presenter Interpolation Format** (#94): Fixed entity number interpolation in `issue_comment` (`#${number}`) and `pr_merge` (`Merged pull request #${number}`) cards.
11
+ - **GraphQL Thread Mutation Validation** (#88): `resolveReviewThread` validates return thread existence and throws on empty/null responses instead of reporting false success.
12
+ - **Merged PRs Filter** (#89): `mergedPulls` in insights now verifies `mergedAt` presence instead of counting closed-unmerged pull requests.
13
+ - **Auth Device Secret Await** (#84): Fixed missing `await auth.secret()` in `register-write.js` that was passing a Promise instance as OAuth `client_secret`.
14
+
15
+ ## 0.2.9
16
+
17
+ ### Fixed
18
+ - **Audit Findings & Stability**: Pre-release audit bundle addressing review thread mutations, auth credentials and input validation.
19
+
20
+
3
21
  ## 0.2.8
4
22
 
5
23
  ### Fixed
package/lib/client.js CHANGED
@@ -20,6 +20,13 @@ window.__ModuleLoader__.load({
20
20
  const SCM_PATH = '/dsh-github-ops/scm'
21
21
  const PANEL_PATH = '/dsh-github-ops/panel'
22
22
 
23
+ const fetchWithTimeout = (url, options = {}, timeoutMs = 15000) => {
24
+ const signal = (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
25
+ ? AbortSignal.timeout(timeoutMs)
26
+ : undefined
27
+ return fetch(url, signal ? { signal, ...options } : options)
28
+ }
29
+
23
30
  // The repository a github.com link in the conversation pointed at, so the tab and the
24
31
  // panel can follow the human instead of asking again.
25
32
  const linkState = { repo: '', enabled: false }
@@ -283,7 +290,7 @@ window.__ModuleLoader__.load({
283
290
  setState({ phase: 'error', message: 'fetch is unavailable in this client' })
284
291
  return
285
292
  }
286
- fetch(cwdQuery ? `${SCM_PATH}?${cwdQuery}` : SCM_PATH, { headers: { accept: 'application/json' } })
293
+ fetchWithTimeout(cwdQuery ? `${SCM_PATH}?${cwdQuery}` : SCM_PATH, { headers: { accept: 'application/json' } })
287
294
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
288
295
  .then((payload) => setState({ phase: 'ready', payload }))
289
296
  .catch((err) => setState({ phase: 'error', message: String((err && err.message) || err) }))
@@ -296,11 +303,11 @@ window.__ModuleLoader__.load({
296
303
  if (typeof fetch !== 'function') return
297
304
  setBusy(action)
298
305
  setActionError('')
299
- fetch(SCM_PATH, {
306
+ fetchWithTimeout(SCM_PATH, {
300
307
  method: 'POST',
301
308
  headers: { 'content-type': 'application/json', accept: 'application/json' },
302
309
  body: JSON.stringify(repoPath ? { action, args: args || {}, cwd: repoPath } : { action, args: args || {} }),
303
- })
310
+ }, 120000)
304
311
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
305
312
  .then((payload) => {
306
313
  setBusy('')
@@ -322,7 +329,7 @@ window.__ModuleLoader__.load({
322
329
  setSelected({ path: file.path, staged })
323
330
  setDiff({ phase: 'loading', text: '' })
324
331
  const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}`
325
- fetch(SCM_PATH + query, { headers: { accept: 'application/json' } })
332
+ fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
326
333
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
327
334
  .then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
328
335
  .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
@@ -518,7 +525,7 @@ window.__ModuleLoader__.load({
518
525
 
519
526
  const load = React.useCallback(() => {
520
527
  if (typeof fetch !== 'function') return
521
- fetch(SCM_PATH, { headers: { accept: 'application/json' } })
528
+ fetchWithTimeout(SCM_PATH, { headers: { accept: 'application/json' } })
522
529
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
523
530
  .then((payload) => setState({ phase: 'ready', payload }))
524
531
  .catch(() => setState({ phase: 'idle' }))
@@ -602,7 +609,7 @@ window.__ModuleLoader__.load({
602
609
  const call = React.useCallback((params) => {
603
610
  if (typeof fetch !== 'function') return Promise.reject(new Error('fetch is unavailable in this client'))
604
611
  const query = new URLSearchParams(params).toString()
605
- return fetch(`${PANEL_PATH}?${query}`, { headers: { accept: 'application/json' } })
612
+ return fetchWithTimeout(`${PANEL_PATH}?${query}`, { headers: { accept: 'application/json' } })
606
613
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
607
614
  }, [])
608
615
 
@@ -841,7 +848,7 @@ window.__ModuleLoader__.load({
841
848
  setStatusError('fetch is unavailable in this client')
842
849
  return
843
850
  }
844
- fetch(STATUS_PATH, { headers: { accept: 'application/json' } })
851
+ fetchWithTimeout(STATUS_PATH, { headers: { accept: 'application/json' } })
845
852
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
846
853
  .then((payload) => setAccessStatus(payload))
847
854
  .catch((err) => { setAccessStatus(null); setStatusError(String((err && err.message) || err)) })
package/lib/index.js CHANGED
@@ -44,11 +44,12 @@ const callContext = new AsyncLocalStorage()
44
44
 
45
45
  /** git runner: never throws, always answers with a code and both streams. */
46
46
  function makeGitRunner() {
47
- return async (args, { cwd, env } = {}) => {
47
+ return async (args, { cwd, env, timeoutMs, timeout } = {}) => {
48
48
  try {
49
49
  const { stdout, stderr } = await execFileAsync('git', args, {
50
50
  cwd: cwd || process.cwd(),
51
51
  env: { ...process.env, ...(env || {}) },
52
+ timeout: typeof timeoutMs === 'number' ? timeoutMs : (typeof timeout === 'number' ? timeout : undefined),
52
53
  maxBuffer: 8 * 1024 * 1024,
53
54
  })
54
55
  return { code: 0, stdout, stderr }
@@ -171,25 +172,21 @@ export const Config = Schema.object({
171
172
  })
172
173
 
173
174
  export function apply(ctx, rawConfig) {
174
- const config = { ...Config(), ...(rawConfig || {}) }
175
+ const config = plainConfig({ ...Config(), ...(rawConfig || {}) })
175
176
  let settingsScope = null
176
177
 
177
178
  ctx.inject(['settings'], (sctx) => {
178
179
  try {
179
180
  settingsScope = {
180
- get: () => {
181
- try {
182
- const row = sctx.settings?.describe?.().find((r) => r && r.ns === name);
183
- return row?.value ?? config ?? {};
184
- } catch { return config ?? {}; }
185
- },
186
- replace: async (next) => {
187
- try {
188
- const row = sctx.settings?.describe?.().find((r) => r && r.ns === name);
189
- return await sctx.settings?.replace?.(name, next, row?.revision);
190
- } catch {}
191
- },
192
- }
181
+ get: () => {
182
+ try {
183
+ const row = sctx.settings?.describe?.().find((r) => r && (r.ns === name || r.ns === '@goodandready/dsh-github-ops'))
184
+ return plainConfig(row?.value ?? config ?? {})
185
+ } catch {
186
+ return plainConfig(config ?? {})
187
+ }
188
+ },
189
+ }
193
190
  } catch (err) {
194
191
  ctx.logger?.warn?.('[dsh-github-ops] settings namespace unavailable:', err && err.message)
195
192
  }
@@ -211,9 +208,9 @@ export function apply(ctx, rawConfig) {
211
208
  function liveConfig() {
212
209
  try {
213
210
  const fromScope = settingsScope && settingsScope.get && settingsScope.get()
214
- return { ...config, ...(fromScope || {}) }
211
+ return plainConfig({ ...config, ...(fromScope || {}) })
215
212
  } catch {
216
- return config
213
+ return plainConfig(config)
217
214
  }
218
215
  }
219
216
 
package/lib/present.js CHANGED
@@ -102,7 +102,7 @@ const PRESENTATIONS = {
102
102
  },
103
103
  issue_comment: {
104
104
  call: (args) => pending(`Comment on #${args.number}`, pick(args, ['repository', 'number'])),
105
- result: (_args, r) => failure('Comment not posted', r) || text(`Commented on #${r.data.id ? '' : ''}`, `comment id ${r.data.id}`),
105
+ result: (_args, r) => failure('Comment not posted', r) || text(`Commented on #${_args?.number || (r.data && r.data.id) || ''}`, `comment id ${r.data.id}`),
106
106
  },
107
107
  issue_close: {
108
108
  call: (args) => pending(`Close #${args.number}`, pick(args, ['repository', 'number', 'stateReason'])),
@@ -126,7 +126,7 @@ const PRESENTATIONS = {
126
126
  pr_merge: {
127
127
  call: (args) => pending(`Merge pull request #${args.number}`, pick(args, ['repository', 'number', 'method', 'deleteBranch'])),
128
128
  result: (_args, r) => failure('Pull request not merged', r)
129
- || text(r.data.merged ? `Merged #${'pull request'}` : 'Merge not performed', `${r.data.message || ''}${r.data.sha ? `\n${String(r.data.sha).slice(0, 7)}` : ''}${r.data.branchDeleted ? `\nhead branch ${r.data.branchDeleted} deleted` : ''}`),
129
+ || text(r.data.merged ? `Merged pull request #${_args?.number || (r.data && r.data.number) || ''}` : 'Merge not performed', `${r.data.message || ''}${r.data.sha ? `\n${String(r.data.sha).slice(0, 7)}` : ''}${r.data.branchDeleted ? `\nhead branch ${r.data.branchDeleted} deleted` : ''}`),
130
130
  },
131
131
  gh_review: {
132
132
  call: (args) => pending(`Review pull request #${args.number}`, pick(args, ['repository', 'number', 'maxDiffChars'])),
@@ -102,7 +102,7 @@ export async function weeklyDigest(client, { owner, repo, days = 7, limit = 10 }
102
102
  since,
103
103
  releases: releases.releases.filter((r) => after(r.publishedAt || r.createdAt)),
104
104
  newIssues: issues.issues.filter((i) => after(i.createdAt)),
105
- mergedPulls: pulls.issues.filter((i) => after(i.closedAt || i.updatedAt)),
105
+ mergedPulls: pulls.issues.filter((i) => Boolean(i.mergedAt) && after(i.mergedAt)),
106
106
  commits: commits.commits,
107
107
  }
108
108
  }
@@ -26,6 +26,7 @@ export function normalizeIssue(data, { mine = '' } = {}) {
26
26
  createdAt: data.created_at,
27
27
  updatedAt: data.updated_at,
28
28
  closedAt: data.closed_at,
29
+ mergedAt: (data.pull_request && data.pull_request.merged_at) || data.merged_at || null,
29
30
  url: data.html_url,
30
31
  body: typeof data.body === 'string' ? data.body : '',
31
32
  isMine: mine ? (data.user && data.user.login) === mine : undefined,
@@ -329,8 +329,12 @@ export async function resolveReviewThread(client, { owner, repo, threadId, resol
329
329
  ? data?.resolveReviewThread?.thread
330
330
  : data?.unresolveReviewThread?.thread
331
331
 
332
+ if (!thread) {
333
+ throw new Error(`failed to ${shouldResolve ? 'resolve' : 'unresolve'} review thread: mutation returned no thread`)
334
+ }
335
+
332
336
  return {
333
337
  threadId,
334
- isResolved: thread ? Boolean(thread.isResolved) : shouldResolve,
338
+ isResolved: Boolean(thread.isResolved),
335
339
  }
336
340
  }
@@ -188,7 +188,7 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
188
188
  const cfg = authConfig()
189
189
  const outcome = await pollDeviceFlow({
190
190
  clientId: cfg.clientId || DEFAULT_CLIENT_ID,
191
- clientSecret: auth.secret(),
191
+ clientSecret: await auth.secret(),
192
192
  deviceCode: pending.deviceCode,
193
193
  intervalSeconds: pending.intervalSeconds,
194
194
  maxWaitMs: args.maxWaitMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
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",