@oss-autopilot/core 1.16.2 → 1.17.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/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 +104 -19
- 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 +14 -4
- 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 +45 -4
- 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
|
/**
|
|
@@ -167,17 +169,58 @@ function setSecurityHeaders(res) {
|
|
|
167
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:");
|
|
168
170
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
169
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
|
+
}
|
|
170
179
|
/**
|
|
171
180
|
* Validate that POST requests originate from the local dashboard.
|
|
172
|
-
*
|
|
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.
|
|
173
187
|
*/
|
|
174
188
|
function isValidOrigin(req, port) {
|
|
175
189
|
const origin = req.headers['origin'];
|
|
176
|
-
if (
|
|
177
|
-
return
|
|
178
|
-
const allowed =
|
|
190
|
+
if (typeof origin !== 'string')
|
|
191
|
+
return false;
|
|
192
|
+
const allowed = allowedHostsFor(port).map((h) => `http://${h}`);
|
|
179
193
|
return allowed.includes(origin);
|
|
180
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
|
+
}
|
|
181
224
|
/**
|
|
182
225
|
* Send a JSON response.
|
|
183
226
|
*/
|
|
@@ -202,12 +245,26 @@ export async function startDashboardServer(options) {
|
|
|
202
245
|
const { port: requestedPort, assetsDir, token, open } = options;
|
|
203
246
|
const stateManager = getStateManager();
|
|
204
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');
|
|
205
256
|
// ── Cached data ──────────────────────────────────────────────────────────
|
|
206
257
|
// Start immediately with state.json data (written by the daily check that
|
|
207
258
|
// precedes this server launch). A background GitHub fetch refreshes the
|
|
208
259
|
// cache after the port is bound, so the startup poller sees us in time.
|
|
209
260
|
let cachedDigest = stateManager.getState().lastDigest;
|
|
210
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;
|
|
211
268
|
if (!cachedDigest) {
|
|
212
269
|
throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
|
|
213
270
|
}
|
|
@@ -215,7 +272,7 @@ export async function startDashboardServer(options) {
|
|
|
215
272
|
let cachedJsonData;
|
|
216
273
|
let cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
217
274
|
try {
|
|
218
|
-
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
|
|
275
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
|
|
219
276
|
}
|
|
220
277
|
catch (error) {
|
|
221
278
|
throw new Error(`Failed to build dashboard data: ${errorMessage(error)}. State data may be corrupted — try running: daily --json`, { cause: error });
|
|
@@ -229,6 +286,13 @@ export async function startDashboardServer(options) {
|
|
|
229
286
|
const method = req.method || 'GET';
|
|
230
287
|
const url = req.url || '/';
|
|
231
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
|
+
}
|
|
232
296
|
// ── API routes ─────────────────────────────────────────────────────
|
|
233
297
|
if (url === '/api/data' && method === 'GET') {
|
|
234
298
|
const check = dataLimiter.check();
|
|
@@ -237,6 +301,9 @@ export async function startDashboardServer(options) {
|
|
|
237
301
|
sendError(res, 429, 'Too many requests');
|
|
238
302
|
return;
|
|
239
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);
|
|
240
307
|
// Re-read state if modified externally (file mtime for local, Gist API for Gist mode)
|
|
241
308
|
let stateChanged = false;
|
|
242
309
|
if (stateManager.isGistMode()) {
|
|
@@ -250,7 +317,7 @@ export async function startDashboardServer(options) {
|
|
|
250
317
|
const issueListChanged = currentIssueListMtimeMs !== cachedIssueListMtimeMs;
|
|
251
318
|
if (stateChanged || issueListChanged) {
|
|
252
319
|
try {
|
|
253
|
-
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues);
|
|
320
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
|
|
254
321
|
cachedIssueListMtimeMs = currentIssueListMtimeMs;
|
|
255
322
|
}
|
|
256
323
|
catch (error) {
|
|
@@ -264,30 +331,41 @@ export async function startDashboardServer(options) {
|
|
|
264
331
|
return;
|
|
265
332
|
}
|
|
266
333
|
if (url === '/api/action' && method === 'POST') {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
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.
|
|
271
337
|
const check = actionLimiter.check();
|
|
272
338
|
if (!check.allowed) {
|
|
273
339
|
res.setHeader('Retry-After', String(check.retryAfterSeconds));
|
|
274
340
|
sendError(res, 429, 'Too many requests');
|
|
275
341
|
return;
|
|
276
342
|
}
|
|
277
|
-
await handleAction(req, res);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
if (url === '/api/refresh' && method === 'POST') {
|
|
281
343
|
if (!isValidOrigin(req, actualPort)) {
|
|
282
344
|
sendError(res, 403, 'Invalid origin');
|
|
283
345
|
return;
|
|
284
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') {
|
|
285
355
|
const check = refreshLimiter.check();
|
|
286
356
|
if (!check.allowed) {
|
|
287
357
|
res.setHeader('Retry-After', String(check.retryAfterSeconds));
|
|
288
358
|
sendError(res, 429, 'Too many requests');
|
|
289
359
|
return;
|
|
290
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
|
+
}
|
|
291
369
|
await handleRefresh(req, res);
|
|
292
370
|
return;
|
|
293
371
|
}
|
|
@@ -368,8 +446,11 @@ export async function startDashboardServer(options) {
|
|
|
368
446
|
sendError(res, 500, 'Action failed');
|
|
369
447
|
return;
|
|
370
448
|
}
|
|
371
|
-
// Rebuild dashboard data from cached digest + updated state
|
|
372
|
-
|
|
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);
|
|
373
454
|
cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
374
455
|
sendJson(res, 200, cachedJsonData);
|
|
375
456
|
}
|
|
@@ -391,7 +472,10 @@ export async function startDashboardServer(options) {
|
|
|
391
472
|
const result = await fetchDashboardData(currentToken);
|
|
392
473
|
cachedDigest = result.digest;
|
|
393
474
|
cachedCommentedIssues = result.commentedIssues;
|
|
394
|
-
|
|
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);
|
|
395
479
|
cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
396
480
|
sendJson(res, 200, cachedJsonData);
|
|
397
481
|
}
|
|
@@ -511,7 +595,8 @@ export async function startDashboardServer(options) {
|
|
|
511
595
|
}
|
|
512
596
|
cachedDigest = result.digest;
|
|
513
597
|
cachedCommentedIssues = result.commentedIssues;
|
|
514
|
-
|
|
598
|
+
cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
|
|
599
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
|
|
515
600
|
cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
516
601
|
warn(MODULE, 'Background data refresh complete');
|
|
517
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>;
|