@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.
- package/dist/cli-registry.js +53 -11
- package/dist/cli.bundle.cjs +82 -69
- package/dist/cli.js +22 -10
- package/dist/commands/comments.js +38 -20
- package/dist/commands/config.d.ts +9 -2
- package/dist/commands/config.js +12 -3
- package/dist/commands/daily.d.ts +3 -1
- package/dist/commands/daily.js +126 -37
- package/dist/commands/dashboard-data.d.ts +26 -2
- package/dist/commands/dashboard-data.js +45 -19
- package/dist/commands/dashboard-server.d.ts +1 -1
- package/dist/commands/dashboard-server.js +109 -20
- package/dist/commands/dismiss.js +4 -1
- package/dist/commands/doctor.d.ts +49 -0
- package/dist/commands/doctor.js +358 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/move.d.ts +1 -2
- package/dist/commands/move.js +8 -4
- package/dist/commands/read.js +2 -1
- package/dist/commands/search.d.ts +0 -18
- package/dist/commands/search.js +38 -1
- package/dist/commands/setup.js +42 -2
- package/dist/commands/shelve.js +4 -1
- package/dist/commands/skip-add.js +1 -1
- package/dist/commands/startup.js +7 -3
- package/dist/commands/track.js +2 -1
- package/dist/commands/vet-list.d.ts +23 -2
- package/dist/commands/vet-list.js +57 -10
- package/dist/core/anti-llm-policy.d.ts +5 -0
- package/dist/core/anti-llm-policy.js +5 -0
- package/dist/core/ci-analysis.js +6 -1
- package/dist/core/config-registry.d.ts +44 -0
- package/dist/core/config-registry.js +286 -0
- package/dist/core/dashboard-data-schema.d.ts +78 -0
- package/dist/core/dashboard-data-schema.js +80 -0
- package/dist/core/errors.d.ts +14 -0
- package/dist/core/errors.js +22 -0
- package/dist/core/http-cache.d.ts +8 -1
- package/dist/core/http-cache.js +59 -1
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +3 -1
- package/dist/core/maintainer-analysis.js +9 -3
- package/dist/core/pr-monitor.d.ts +7 -0
- package/dist/core/pr-monitor.js +16 -3
- package/dist/core/repo-score-manager.d.ts +17 -3
- package/dist/core/repo-score-manager.js +48 -19
- package/dist/core/state-persistence.d.ts +14 -1
- package/dist/core/state-persistence.js +24 -2
- package/dist/core/state-schema.d.ts +2 -0
- package/dist/core/state-schema.js +5 -0
- package/dist/core/state.d.ts +26 -2
- package/dist/core/state.js +50 -5
- package/dist/core/status-determination.d.ts +16 -0
- package/dist/core/status-determination.js +44 -11
- package/dist/formatters/json.d.ts +40 -2
- package/dist/formatters/json.js +1 -0
- 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
|
|
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
|
-
*
|
|
42
|
-
* (
|
|
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]
|
|
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
|
-
|
|
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
|
-
.
|
|
115
|
-
.catch(
|
|
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(
|
|
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(
|
|
144
|
-
fetchClosedPRsSince(octokit, config, closedWatermark).catch(
|
|
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
|
-
|
|
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
|
-
*
|
|
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 (
|
|
173
|
-
return
|
|
174
|
-
const allowed =
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|
package/dist/commands/dismiss.js
CHANGED
|
@@ -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>;
|