@oss-autopilot/core 1.16.1 → 1.17.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.
Files changed (58) hide show
  1. package/dist/cli-registry.js +53 -11
  2. package/dist/cli.bundle.cjs +82 -69
  3. package/dist/cli.js +22 -10
  4. package/dist/commands/comments.js +38 -20
  5. package/dist/commands/config.d.ts +9 -2
  6. package/dist/commands/config.js +12 -3
  7. package/dist/commands/daily.d.ts +3 -1
  8. package/dist/commands/daily.js +126 -37
  9. package/dist/commands/dashboard-data.d.ts +26 -2
  10. package/dist/commands/dashboard-data.js +45 -19
  11. package/dist/commands/dashboard-server.d.ts +1 -1
  12. package/dist/commands/dashboard-server.js +109 -20
  13. package/dist/commands/dismiss.js +4 -1
  14. package/dist/commands/doctor.d.ts +49 -0
  15. package/dist/commands/doctor.js +358 -0
  16. package/dist/commands/index.d.ts +2 -0
  17. package/dist/commands/index.js +2 -0
  18. package/dist/commands/move.d.ts +1 -2
  19. package/dist/commands/move.js +8 -4
  20. package/dist/commands/read.js +2 -1
  21. package/dist/commands/search.d.ts +0 -18
  22. package/dist/commands/search.js +38 -1
  23. package/dist/commands/setup.js +42 -2
  24. package/dist/commands/shelve.js +4 -1
  25. package/dist/commands/skip-add.js +1 -1
  26. package/dist/commands/startup.js +7 -3
  27. package/dist/commands/track.js +2 -1
  28. package/dist/commands/vet-list.d.ts +23 -2
  29. package/dist/commands/vet-list.js +57 -10
  30. package/dist/core/anti-llm-policy.d.ts +5 -0
  31. package/dist/core/anti-llm-policy.js +5 -0
  32. package/dist/core/ci-analysis.js +6 -1
  33. package/dist/core/config-registry.d.ts +44 -0
  34. package/dist/core/config-registry.js +286 -0
  35. package/dist/core/dashboard-data-schema.d.ts +78 -0
  36. package/dist/core/dashboard-data-schema.js +80 -0
  37. package/dist/core/errors.d.ts +14 -0
  38. package/dist/core/errors.js +22 -0
  39. package/dist/core/http-cache.d.ts +8 -1
  40. package/dist/core/http-cache.js +59 -1
  41. package/dist/core/index.d.ts +3 -1
  42. package/dist/core/index.js +3 -1
  43. package/dist/core/maintainer-analysis.js +9 -3
  44. package/dist/core/pr-monitor.d.ts +7 -0
  45. package/dist/core/pr-monitor.js +16 -3
  46. package/dist/core/repo-score-manager.d.ts +17 -3
  47. package/dist/core/repo-score-manager.js +48 -19
  48. package/dist/core/state-persistence.d.ts +14 -1
  49. package/dist/core/state-persistence.js +24 -2
  50. package/dist/core/state-schema.d.ts +2 -0
  51. package/dist/core/state-schema.js +5 -0
  52. package/dist/core/state.d.ts +26 -2
  53. package/dist/core/state.js +50 -5
  54. package/dist/core/status-determination.d.ts +16 -0
  55. package/dist/core/status-determination.js +44 -11
  56. package/dist/formatters/json.d.ts +40 -2
  57. package/dist/formatters/json.js +1 -0
  58. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  * Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
5
5
  */
6
6
  import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit } from '../core/index.js';
7
- import { errorMessage, isRateLimitOrAuthError, nonFatalCatch } from '../core/errors.js';
7
+ import { errorMessage, isRateLimitOrAuthError } from '../core/errors.js';
8
8
  import { warn } from '../core/logger.js';
9
9
  import { emptyPRCountsResult, fetchMergedPRsSince, fetchClosedPRsSince } from '../core/github-stats.js';
10
10
  import { parseGitHubUrl } from '../core/utils.js';
@@ -38,13 +38,21 @@ export function buildDashboardStats(digest, state, storedMergedCount, storedClos
38
38
  /**
39
39
  * Merge fresh API counts into existing stored counts.
40
40
  * Months present in the fresh data are updated; months only in the existing data are preserved.
41
- * This prevents historical data loss when the API returns incomplete results
42
- * (e.g. due to pagination limits or transient failures).
41
+ *
42
+ * Anti-regression guard (#1035): when the fresh count for a given month is
43
+ * smaller than the already-stored count for that month, we keep the larger
44
+ * value. This matters when the fresh fetch was capped (pagination limits,
45
+ * 1000-result Search API ceiling, or partial failures) and would otherwise
46
+ * silently overwrite authoritative historical data with a partial window.
47
+ * The trade-off: a month that genuinely shrinks (e.g., user deleted a merged
48
+ * PR reference remotely) cannot be decremented via this path — but that is
49
+ * a rare case, and the alternative is silent decay of historical analytics.
43
50
  */
44
51
  export function mergeMonthlyCounts(existing, fresh) {
45
52
  const merged = { ...existing };
46
53
  for (const [month, count] of Object.entries(fresh)) {
47
- merged[month] = count;
54
+ const current = merged[month];
55
+ merged[month] = current === undefined ? count : Math.max(current, count);
48
56
  }
49
57
  return merged;
50
58
  }
@@ -108,22 +116,30 @@ export async function fetchDashboardData(token) {
108
116
  // Get watermarks for incremental PR fetch
109
117
  const watermark = stateManager.getMergedPRWatermark();
110
118
  const closedWatermark = stateManager.getClosedPRWatermark();
111
- const [{ prs, failures }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues, newMergedPRs, newClosedPRs,] = await Promise.all([
119
+ // Track which non-critical sub-fetches degraded to fallbacks so the SPA
120
+ // can surface a "partial data" banner instead of silently showing zeros.
121
+ // Rate-limit / auth errors still rethrow via isRateLimitOrAuthError —
122
+ // those abort the whole run, not a partial surface.
123
+ const partialFailures = [];
124
+ const trackingCatch = (label, fallback) => {
125
+ return (err) => {
126
+ if (isRateLimitOrAuthError(err))
127
+ throw err;
128
+ partialFailures.push(label);
129
+ warn(MODULE, `Failed to ${label}: ${errorMessage(err)}`);
130
+ return fallback;
131
+ };
132
+ };
133
+ const [{ prs, failures, warnings: fetchWarnings }, recentlyClosedPRs, recentlyMergedPRs, mergedResult, closedResult, fetchedIssues, newMergedPRs, newClosedPRs,] = await Promise.all([
112
134
  prMonitor.fetchUserOpenPRs(),
135
+ prMonitor.fetchRecentlyClosedPRs().catch(trackingCatch('fetch recently closed PRs', [])),
136
+ prMonitor.fetchRecentlyMergedPRs().catch(trackingCatch('fetch recently merged PRs', [])),
113
137
  prMonitor
114
- .fetchRecentlyClosedPRs()
115
- .catch(nonFatalCatch({ module: MODULE, label: 'fetch recently closed PRs', fallback: [] })),
116
- prMonitor
117
- .fetchRecentlyMergedPRs()
118
- .catch(nonFatalCatch({ module: MODULE, label: 'fetch recently merged PRs', fallback: [] })),
119
- prMonitor.fetchUserMergedPRCounts(starFilter).catch(nonFatalCatch({
120
- module: MODULE,
121
- label: 'fetch merged PR counts',
122
- fallback: emptyPRCountsResult(),
123
- })),
138
+ .fetchUserMergedPRCounts(starFilter)
139
+ .catch(trackingCatch('fetch merged PR counts', emptyPRCountsResult())),
124
140
  prMonitor
125
141
  .fetchUserClosedPRCounts(starFilter)
126
- .catch(nonFatalCatch({ module: MODULE, label: 'fetch closed PR counts', fallback: emptyPRCountsResult() })),
142
+ .catch(trackingCatch('fetch closed PR counts', emptyPRCountsResult())),
127
143
  // Issue conversation fetch has custom messaging based on the error content, so it keeps its bespoke catch.
128
144
  issueMonitor.fetchCommentedIssues().catch((error) => {
129
145
  if (isRateLimitOrAuthError(error))
@@ -134,14 +150,15 @@ export async function fetchDashboardData(token) {
134
150
  }
135
151
  else {
136
152
  warn(MODULE, `Issue conversation fetch failed: ${msg}`);
153
+ partialFailures.push('fetch issue conversations');
137
154
  }
138
155
  return {
139
156
  issues: [],
140
157
  failures: [{ issueUrl: 'N/A', error: `Issue conversation fetch failed: ${msg}` }],
141
158
  };
142
159
  }),
143
- fetchMergedPRsSince(octokit, config, watermark).catch(nonFatalCatch({ module: MODULE, label: 'fetch merged PRs for storage', fallback: [] })),
144
- fetchClosedPRsSince(octokit, config, closedWatermark).catch(nonFatalCatch({ module: MODULE, label: 'fetch closed PRs for storage', fallback: [] })),
160
+ fetchMergedPRsSince(octokit, config, watermark).catch(trackingCatch('fetch merged PRs for storage', [])),
161
+ fetchClosedPRsSince(octokit, config, closedWatermark).catch(trackingCatch('fetch closed PRs for storage', [])),
145
162
  ]);
146
163
  const commentedIssues = fetchedIssues.issues;
147
164
  if (fetchedIssues.failures.length > 0) {
@@ -150,6 +167,15 @@ export async function fetchDashboardData(token) {
150
167
  if (failures.length > 0) {
151
168
  warn(MODULE, `${failures.length} PR fetch(es) failed`);
152
169
  }
170
+ // Surface search-API truncation warnings (#1057 M25) into the dashboard's
171
+ // partialFailures banner so a user with >1000 open PRs sees the "partial
172
+ // view" signal in the SPA rather than silently seeing an incomplete list.
173
+ if (fetchWarnings) {
174
+ for (const message of fetchWarnings) {
175
+ partialFailures.push(message);
176
+ warn(MODULE, message);
177
+ }
178
+ }
153
179
  // Wrap all state mutations in a batch for a single disk write.
154
180
  // try-catch: save errors should not crash the dashboard data fetch.
155
181
  try {
@@ -194,7 +220,7 @@ export async function fetchDashboardData(token) {
194
220
  if (!digest) {
195
221
  throw new Error('Dashboard data fetch failed: digest was not generated');
196
222
  }
197
- return { digest, commentedIssues, allMergedPRs, allClosedPRs };
223
+ return { digest, commentedIssues, allMergedPRs, allClosedPRs, partialFailures };
198
224
  }
199
225
  /**
200
226
  * Convert a stored-shape PR array (StoredMergedPR[] or StoredClosedPR[]) to
@@ -21,5 +21,5 @@ export interface DashboardServerOptions {
21
21
  * handler harness can't reach (it bakes a stale cachedDigest at server
22
22
  * start-up, so tests that need a specific digest should call this directly).
23
23
  */
24
- export declare function buildDashboardJson(digest: DailyDigest, state: Readonly<AgentState>, commentedIssues: CommentedIssue[], allMergedPRs?: MergedPR[], allClosedPRs?: ClosedPR[]): DashboardJsonData;
24
+ export declare function buildDashboardJson(digest: DailyDigest, state: Readonly<AgentState>, commentedIssues: CommentedIssue[], allMergedPRs?: MergedPR[], allClosedPRs?: ClosedPR[], partialFailures?: string[]): DashboardJsonData;
25
25
  export declare function startDashboardServer(options: DashboardServerOptions): Promise<void>;
@@ -8,6 +8,7 @@
8
8
  import * as http from 'http';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
+ import * as crypto from 'crypto';
11
12
  import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
12
13
  import { errorMessage, ValidationError } from '../core/errors.js';
13
14
  import { warn } from '../core/logger.js';
@@ -73,7 +74,7 @@ function getIssueListMtimeMs() {
73
74
  * handler harness can't reach (it bakes a stale cachedDigest at server
74
75
  * start-up, so tests that need a specific digest should call this directly).
75
76
  */
76
- export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs) {
77
+ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
77
78
  const prsByRepo = computePRsByRepo(digest, state);
78
79
  const topRepos = computeTopRepos(prsByRepo);
79
80
  const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
@@ -122,6 +123,7 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
122
123
  allClosedPRs: filteredClosedPRs,
123
124
  repoMetadata,
124
125
  vettedIssues,
126
+ partialFailures: partialFailures && partialFailures.length > 0 ? partialFailures : undefined,
125
127
  };
126
128
  }
127
129
  /**
@@ -160,20 +162,65 @@ function readBody(req, maxBytes = MAX_BODY_BYTES) {
160
162
  function setSecurityHeaders(res) {
161
163
  res.setHeader('X-Content-Type-Options', 'nosniff');
162
164
  res.setHeader('X-Frame-Options', 'DENY');
163
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'");
165
+ // worker-src: canvas-confetti generates its animation worker as a Blob and
166
+ // loads it via a blob: URL. Without this directive the browser falls back
167
+ // to script-src, which doesn't list blob:, and the celebrate button fails
168
+ // silently. Scoped to workers only — safer than widening script-src.
169
+ res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; worker-src 'self' blob:");
164
170
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
165
171
  }
172
+ /**
173
+ * Valid Host header values for this server — used for both Origin checks
174
+ * (strips the `http://` scheme) and Host-header DNS-rebinding checks.
175
+ */
176
+ function allowedHostsFor(port) {
177
+ return [`localhost:${port}`, `127.0.0.1:${port}`, `oss.localhost:${port}`];
178
+ }
166
179
  /**
167
180
  * Validate that POST requests originate from the local dashboard.
168
- * Returns true if the Origin is acceptable, false otherwise.
181
+ *
182
+ * Returns true only if the `Origin` header is present AND matches the
183
+ * loopback allow-list. A missing `Origin` now returns false — previously it
184
+ * returned true to allow non-browser same-origin calls, but that let any
185
+ * local process (curl, scripts) POST to /api/action and /api/refresh. See
186
+ * issue #1031.
169
187
  */
170
188
  function isValidOrigin(req, port) {
171
189
  const origin = req.headers['origin'];
172
- if (!origin)
173
- return true; // No Origin header = same-origin request (non-browser or same-page)
174
- const allowed = [`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://oss.localhost:${port}`];
190
+ if (typeof origin !== 'string')
191
+ return false;
192
+ const allowed = allowedHostsFor(port).map((h) => `http://${h}`);
175
193
  return allowed.includes(origin);
176
194
  }
195
+ /**
196
+ * Validate the `Host` header against the loopback allow-list.
197
+ *
198
+ * Blocks DNS-rebinding attacks: a victim browser resolves an attacker domain
199
+ * to 127.0.0.1, reaches this server, and the `Host` header carries the
200
+ * attacker's hostname. Rejecting non-loopback Host headers closes that path
201
+ * for both GET /api/data (data exfil) and the POST endpoints.
202
+ */
203
+ function isValidHost(req, port) {
204
+ const host = req.headers['host'];
205
+ if (typeof host !== 'string')
206
+ return false;
207
+ return allowedHostsFor(port).includes(host);
208
+ }
209
+ /**
210
+ * Validate the CSRF token header for state-mutating POST endpoints.
211
+ */
212
+ function isValidCsrfToken(req, expected) {
213
+ const token = req.headers['x-csrf-token'];
214
+ if (typeof token !== 'string' || token.length !== expected.length)
215
+ return false;
216
+ // Constant-time comparison to avoid leaking token bytes via timing.
217
+ try {
218
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
177
224
  /**
178
225
  * Send a JSON response.
179
226
  */
@@ -198,12 +245,26 @@ export async function startDashboardServer(options) {
198
245
  const { port: requestedPort, assetsDir, token, open } = options;
199
246
  const stateManager = getStateManager();
200
247
  const resolvedAssetsDir = path.resolve(assetsDir);
248
+ // ── CSRF token ──────────────────────────────────────────────────────────
249
+ // Fresh per server-start. Exposed to the SPA via X-CSRF-Token on every
250
+ // /api/data response; required back on X-CSRF-Token for state-mutating
251
+ // POST endpoints. Prevents local non-browser processes (curl, scripts)
252
+ // from invoking /api/action and /api/refresh even when they guess the
253
+ // Origin header — the token itself is only reachable by calling
254
+ // /api/data, which enforces the Host check.
255
+ const csrfToken = crypto.randomBytes(32).toString('hex');
201
256
  // ── Cached data ──────────────────────────────────────────────────────────
202
257
  // Start immediately with state.json data (written by the daily check that
203
258
  // precedes this server launch). A background GitHub fetch refreshes the
204
259
  // cache after the port is bound, so the startup poller sees us in time.
205
260
  let cachedDigest = stateManager.getState().lastDigest;
206
261
  let cachedCommentedIssues = [];
262
+ // Persist the last-known partialFailures across rebuild requests (#1035).
263
+ // Cleared only when a fresh fetchDashboardData returns zero failures;
264
+ // re-threaded into every buildDashboardJson call so the SPA banner does
265
+ // not disappear when /api/data rebuilds after a state change or after a
266
+ // POST /api/action completes.
267
+ let cachedPartialFailures = undefined;
207
268
  if (!cachedDigest) {
208
269
  throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
209
270
  }
@@ -211,7 +272,7 @@ export async function startDashboardServer(options) {
211
272
  let cachedJsonData;
212
273
  let cachedIssueListMtimeMs = getIssueListMtimeMs();
213
274
  try {
214
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
275
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
215
276
  }
216
277
  catch (error) {
217
278
  throw new Error(`Failed to build dashboard data: ${errorMessage(error)}. State data may be corrupted — try running: daily --json`, { cause: error });
@@ -225,6 +286,13 @@ export async function startDashboardServer(options) {
225
286
  const method = req.method || 'GET';
226
287
  const url = req.url || '/';
227
288
  try {
289
+ // ── Host-header check (DNS-rebinding defense) ──────────────────────
290
+ // Applied to every request including static files so a rebound
291
+ // attacker hostname cannot read SPA assets or API responses.
292
+ if (url.startsWith('/api/') && !isValidHost(req, actualPort)) {
293
+ sendError(res, 403, 'Invalid host');
294
+ return;
295
+ }
228
296
  // ── API routes ─────────────────────────────────────────────────────
229
297
  if (url === '/api/data' && method === 'GET') {
230
298
  const check = dataLimiter.check();
@@ -233,6 +301,9 @@ export async function startDashboardServer(options) {
233
301
  sendError(res, 429, 'Too many requests');
234
302
  return;
235
303
  }
304
+ // Expose the CSRF token to the SPA on every data fetch so the client
305
+ // can attach it on subsequent POSTs. Fresh fetch → fresh token view.
306
+ res.setHeader('X-CSRF-Token', csrfToken);
236
307
  // Re-read state if modified externally (file mtime for local, Gist API for Gist mode)
237
308
  let stateChanged = false;
238
309
  if (stateManager.isGistMode()) {
@@ -246,7 +317,7 @@ export async function startDashboardServer(options) {
246
317
  const issueListChanged = currentIssueListMtimeMs !== cachedIssueListMtimeMs;
247
318
  if (stateChanged || issueListChanged) {
248
319
  try {
249
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
320
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
250
321
  cachedIssueListMtimeMs = currentIssueListMtimeMs;
251
322
  }
252
323
  catch (error) {
@@ -260,30 +331,41 @@ export async function startDashboardServer(options) {
260
331
  return;
261
332
  }
262
333
  if (url === '/api/action' && method === 'POST') {
263
- if (!isValidOrigin(req, actualPort)) {
264
- sendError(res, 403, 'Invalid origin');
265
- return;
266
- }
334
+ // Rate limit BEFORE auth checks so a local attacker cannot flood
335
+ // invalid-CSRF requests without consuming their quota. The Host
336
+ // check above already ran; still need Origin + CSRF below.
267
337
  const check = actionLimiter.check();
268
338
  if (!check.allowed) {
269
339
  res.setHeader('Retry-After', String(check.retryAfterSeconds));
270
340
  sendError(res, 429, 'Too many requests');
271
341
  return;
272
342
  }
273
- await handleAction(req, res);
274
- return;
275
- }
276
- if (url === '/api/refresh' && method === 'POST') {
277
343
  if (!isValidOrigin(req, actualPort)) {
278
344
  sendError(res, 403, 'Invalid origin');
279
345
  return;
280
346
  }
347
+ if (!isValidCsrfToken(req, csrfToken)) {
348
+ sendError(res, 403, 'Missing or invalid CSRF token');
349
+ return;
350
+ }
351
+ await handleAction(req, res);
352
+ return;
353
+ }
354
+ if (url === '/api/refresh' && method === 'POST') {
281
355
  const check = refreshLimiter.check();
282
356
  if (!check.allowed) {
283
357
  res.setHeader('Retry-After', String(check.retryAfterSeconds));
284
358
  sendError(res, 429, 'Too many requests');
285
359
  return;
286
360
  }
361
+ if (!isValidOrigin(req, actualPort)) {
362
+ sendError(res, 403, 'Invalid origin');
363
+ return;
364
+ }
365
+ if (!isValidCsrfToken(req, csrfToken)) {
366
+ sendError(res, 403, 'Missing or invalid CSRF token');
367
+ return;
368
+ }
287
369
  await handleRefresh(req, res);
288
370
  return;
289
371
  }
@@ -364,8 +446,11 @@ export async function startDashboardServer(options) {
364
446
  sendError(res, 500, 'Action failed');
365
447
  return;
366
448
  }
367
- // Rebuild dashboard data from cached digest + updated state
368
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
449
+ // Rebuild dashboard data from cached digest + updated state. Persist
450
+ // the last-known partialFailures across action rebuilds (#1035) so the
451
+ // SPA banner survives user interactions until the next successful
452
+ // refresh clears it.
453
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
369
454
  cachedIssueListMtimeMs = getIssueListMtimeMs();
370
455
  sendJson(res, 200, cachedJsonData);
371
456
  }
@@ -387,7 +472,10 @@ export async function startDashboardServer(options) {
387
472
  const result = await fetchDashboardData(currentToken);
388
473
  cachedDigest = result.digest;
389
474
  cachedCommentedIssues = result.commentedIssues;
390
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs);
475
+ // Update the persistent banner signal — clear on a clean refresh,
476
+ // set when one or more sub-fetches degraded. See #1035.
477
+ cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
478
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
391
479
  cachedIssueListMtimeMs = getIssueListMtimeMs();
392
480
  sendJson(res, 200, cachedJsonData);
393
481
  }
@@ -507,7 +595,8 @@ export async function startDashboardServer(options) {
507
595
  }
508
596
  cachedDigest = result.digest;
509
597
  cachedCommentedIssues = result.commentedIssues;
510
- cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs);
598
+ cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
599
+ cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
511
600
  cachedIssueListMtimeMs = getIssueListMtimeMs();
512
601
  warn(MODULE, 'Background data refresh complete');
513
602
  })
@@ -3,8 +3,9 @@
3
3
  * Manages dismissing issue notifications without posting a comment.
4
4
  * Dismissed URLs resurface automatically when new responses arrive after the dismiss timestamp.
5
5
  */
6
- import { getStateManager } from '../core/index.js';
6
+ import { getStateManager, maybeCheckpoint } from '../core/index.js';
7
7
  import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
8
+ const MODULE = 'dismiss';
8
9
  /**
9
10
  * Dismiss an issue's reply notifications without posting a comment.
10
11
  * The dismissal auto-resurfaces when new responses arrive after the dismiss timestamp.
@@ -19,6 +20,7 @@ export async function runDismiss(options) {
19
20
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
20
21
  const stateManager = getStateManager();
21
22
  const added = stateManager.dismissIssue(options.url, new Date().toISOString());
23
+ await maybeCheckpoint(stateManager, MODULE);
22
24
  return { dismissed: added, url: options.url };
23
25
  }
24
26
  /**
@@ -34,5 +36,6 @@ export async function runUndismiss(options) {
34
36
  validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
35
37
  const stateManager = getStateManager();
36
38
  const removed = stateManager.undismissIssue(options.url);
39
+ await maybeCheckpoint(stateManager, MODULE);
37
40
  return { undismissed: removed, url: options.url };
38
41
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Doctor command — a real system-health audit.
3
+ *
4
+ * Runs independent checks against the local environment and reports an
5
+ * aggregate `DoctorOutput` so users can diagnose common failure modes
6
+ * (missing token, unauthenticated gh CLI, stale bundle, corrupted state,
7
+ * unresolvable scout dependency, low rate-limit budget) in one go.
8
+ *
9
+ * Each `check*` function is exported individually so tests can mock its
10
+ * external dependencies in isolation.
11
+ */
12
+ export type DoctorCheckStatus = 'ok' | 'warning' | 'error';
13
+ export interface DoctorCheck {
14
+ name: string;
15
+ status: DoctorCheckStatus;
16
+ message: string;
17
+ remediation?: string;
18
+ }
19
+ export interface DoctorOutput {
20
+ checks: DoctorCheck[];
21
+ summary: {
22
+ ok: number;
23
+ warnings: number;
24
+ errors: number;
25
+ };
26
+ }
27
+ export declare function checkGhCli(): Promise<DoctorCheck>;
28
+ export declare function checkGhAuth(): Promise<DoctorCheck>;
29
+ export declare function checkGitHubToken(preloadedToken?: string | null): Promise<DoctorCheck>;
30
+ /**
31
+ * Bundle-freshness check only fires when the *source* file is present — that's
32
+ * the dev-repo workflow. npm consumers install the pre-built bundle with no
33
+ * source, so we just confirm the bundle exists.
34
+ */
35
+ export declare function checkBundleUpToDate(options?: {
36
+ bundlePath?: string;
37
+ sourcePath?: string;
38
+ }): DoctorCheck;
39
+ export declare function checkStateFile(options?: {
40
+ statePathOverride?: string;
41
+ }): DoctorCheck;
42
+ /**
43
+ * Dynamic import of `@oss-scout/core`. Extracted so tests can inject a throwing
44
+ * stub without mocking the module registry.
45
+ */
46
+ export type ScoutImporter = () => Promise<unknown>;
47
+ export declare function checkScoutResolvable(importer?: ScoutImporter): Promise<DoctorCheck>;
48
+ export declare function checkGitHubRateLimit(preloadedToken?: string | null): Promise<DoctorCheck>;
49
+ export declare function runDoctor(): Promise<DoctorOutput>;