@oss-autopilot/core 3.13.0 → 3.13.2
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.bundle.cjs +114 -99
- package/dist/commands/dashboard-data.js +24 -4
- package/dist/commands/dashboard-server.js +83 -16
- package/dist/commands/features.js +5 -1
- package/dist/commands/list-move-tier.js +11 -1
- package/dist/commands/scout-bridge.js +12 -0
- package/dist/commands/search.js +13 -4
- package/dist/core/auth.js +33 -6
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +1 -1
- package/dist/core/issue-grading.d.ts +2 -5
- package/dist/core/issue-grading.js +12 -1
- package/dist/core/state.d.ts +15 -1
- package/dist/core/state.js +38 -28
- package/dist/core/strategy.js +31 -4
- package/dist/core/untrusted-content.d.ts +24 -0
- package/dist/core/untrusted-content.js +25 -0
- package/dist/formatters/json.js +5 -1
- package/package.json +3 -3
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Handles GitHub API calls, PR grouping, stats computation, and monthly chart data.
|
|
4
4
|
* Consumed by the dashboard HTTP server (dashboard-server.ts) for the SPA API.
|
|
5
5
|
*/
|
|
6
|
-
import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit, CRITICAL_STATUSES } from '../core/index.js';
|
|
6
|
+
import { getStateManager, PRMonitor, IssueConversationMonitor, getOctokit, CRITICAL_STATUSES, applyStatusOverrides, } from '../core/index.js';
|
|
7
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';
|
|
@@ -236,6 +236,13 @@ export async function fetchDashboardData(token) {
|
|
|
236
236
|
// try-catch: save errors should not crash the dashboard data fetch.
|
|
237
237
|
try {
|
|
238
238
|
stateManager.batch(() => {
|
|
239
|
+
// Apply status overrides BEFORE partitioning, mirroring the daily check
|
|
240
|
+
// (daily.ts partitions overriddenPRs). Partitioning on raw status let a
|
|
241
|
+
// dormant PR with a criticality-flipping override land in a different
|
|
242
|
+
// partition here than on the CLI surface (#1416). Inside the batch
|
|
243
|
+
// because getStatusOverride may auto-clear stale overrides with a save,
|
|
244
|
+
// which must defer to this guarded boundary rather than throw here.
|
|
245
|
+
const overriddenPRs = applyStatusOverrides(prs, stateManager.getState());
|
|
239
246
|
// Store new merged PRs incrementally (dedupes by URL)
|
|
240
247
|
try {
|
|
241
248
|
const { dropped } = stateManager.addMergedPRs(newMergedPRs);
|
|
@@ -256,20 +263,33 @@ export async function fetchDashboardData(token) {
|
|
|
256
263
|
catch (error) {
|
|
257
264
|
warn(MODULE, `Failed to store closed PRs: ${errorMessage(error)}`);
|
|
258
265
|
}
|
|
259
|
-
// Store monthly chart data (opened/merged/closed) so charts have data
|
|
266
|
+
// Store monthly chart data (opened/merged/closed) so charts have data.
|
|
267
|
+
// Analytics intentionally consume RAW prs: overrides only change status,
|
|
268
|
+
// never the createdAt/mergedAt dates the monthly counts key off.
|
|
260
269
|
const { monthlyCounts, monthlyOpenedCounts: openedFromMerged } = mergedResult;
|
|
261
270
|
const { monthlyCounts: monthlyClosedCounts, monthlyOpenedCounts: openedFromClosed } = closedResult;
|
|
262
271
|
updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
|
|
272
|
+
// The digest keeps RAW statuses in openPRs: this digest becomes the
|
|
273
|
+
// server's cached/persisted rebuild source, and baking overridden
|
|
274
|
+
// statuses into it would make CLEARING an override (move target=auto) a
|
|
275
|
+
// silent no-op until the next full refresh — buildDashboardJson can
|
|
276
|
+
// only apply overrides that still exist, never un-apply baked ones. It
|
|
277
|
+
// re-applies live overrides per request before partitioning.
|
|
263
278
|
const digest = prMonitor.generateDigest(prs, recentlyClosedPRs, recentlyMergedPRs);
|
|
264
279
|
// Apply shelve partitioning for display (auto-unshelve only runs in daily check).
|
|
265
280
|
// Dormant PRs are treated as shelved unless they need addressing, and a
|
|
266
281
|
// critical PR is never display-shelved (#1352) — mirrors the daily
|
|
267
282
|
// check's CRITICAL_STATUSES auto-unshelve so headline counts agree.
|
|
283
|
+
// Partitioned over overriddenPRs (#1416): the shelve decision must use
|
|
284
|
+
// the same post-override status the CLI partitions on.
|
|
268
285
|
const shelvedUrls = new Set(stateManager.getState().config.shelvedPRUrls || []);
|
|
269
|
-
|
|
286
|
+
// Single copy of the shelve predicate (#1421): isShelvedForDisplay is
|
|
287
|
+
// the same rule reconcileShelvePartition applies on rebuilds, so its
|
|
288
|
+
// regression tests now guard this line too.
|
|
289
|
+
const freshShelved = overriddenPRs.filter((pr) => isShelvedForDisplay(pr, shelvedUrls));
|
|
270
290
|
digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
|
|
271
291
|
digest.autoUnshelvedPRs = [];
|
|
272
|
-
digest.summary.totalActivePRs =
|
|
292
|
+
digest.summary.totalActivePRs = overriddenPRs.length - freshShelved.length;
|
|
273
293
|
stateManager.setLastDigest(digest);
|
|
274
294
|
});
|
|
275
295
|
}
|
|
@@ -9,7 +9,7 @@ import * as http from 'node:http';
|
|
|
9
9
|
import * as fs from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import * as crypto from 'node:crypto';
|
|
12
|
-
import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, } from '../core/index.js';
|
|
12
|
+
import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides, classifyAttentionBucket, maybeCheckpoint, } from '../core/index.js';
|
|
13
13
|
import { errorMessage, ValidationError, ConcurrencyError, GistConcurrencyError } from '../core/errors.js';
|
|
14
14
|
import { warn } from '../core/logger.js';
|
|
15
15
|
import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
|
|
@@ -75,12 +75,23 @@ function getIssueListMtimeMs() {
|
|
|
75
75
|
* start-up, so tests that need a specific digest should call this directly).
|
|
76
76
|
*/
|
|
77
77
|
export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClosedPRs, partialFailures) {
|
|
78
|
+
// Apply status overrides ONCE, before the shelve partition is derived, so
|
|
79
|
+
// the dashboard partitions on the same post-override status the CLI
|
|
80
|
+
// partitions on (#1416). This also covers overrides set AFTER the digest
|
|
81
|
+
// was cached (a dashboard move stores an override; the action path rebuilds
|
|
82
|
+
// from the cached digest). Work on a copy — the caller's cached digest must
|
|
83
|
+
// not accumulate per-request derivations.
|
|
84
|
+
const overriddenDigest = {
|
|
85
|
+
...digest,
|
|
86
|
+
openPRs: applyStatusOverrides(digest.openPRs || [], state),
|
|
87
|
+
summary: { ...digest.summary },
|
|
88
|
+
};
|
|
78
89
|
// Re-derive the shelve partition from the CURRENT state before reading it.
|
|
79
90
|
// The POST /api/action path rebuilds with a cached digest whose shelvedPRs
|
|
80
91
|
// predates the shelve/unshelve, so without this the SPA action appears to do
|
|
81
92
|
// nothing until the next full /api/refresh.
|
|
82
|
-
reconcileShelvePartition(
|
|
83
|
-
const prsByRepo = computePRsByRepo(
|
|
93
|
+
reconcileShelvePartition(overriddenDigest, state);
|
|
94
|
+
const prsByRepo = computePRsByRepo(overriddenDigest, state);
|
|
84
95
|
const topRepos = computeTopRepos(prsByRepo);
|
|
85
96
|
const { monthlyMerged, monthlyOpened, monthlyClosed } = getMonthlyData(state);
|
|
86
97
|
// Derive from state if not provided (e.g. initial load from cached state)
|
|
@@ -92,7 +103,7 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
|
|
|
92
103
|
const isAboveMinStars = (pr) => !isBelowMinStars(repoScores[pr.repo]?.stargazersCount, minStars);
|
|
93
104
|
const filteredMergedPRs = mergedPRs.filter(isAboveMinStars);
|
|
94
105
|
const filteredClosedPRs = closedPRs.filter(isAboveMinStars);
|
|
95
|
-
const stats = buildDashboardStats(
|
|
106
|
+
const stats = buildDashboardStats(overriddenDigest, state, filteredMergedPRs.length, filteredClosedPRs.length);
|
|
96
107
|
const dismissedIssues = state.config.dismissedIssues || {};
|
|
97
108
|
const issueResponses = commentedIssues.filter((i) => i.status === 'new_response' && !(i.url in dismissedIssues));
|
|
98
109
|
// Build repo metadata map from repoScores — omit repos without stars or language to avoid empty entries
|
|
@@ -115,7 +126,10 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
|
|
|
115
126
|
monthlyClosed,
|
|
116
127
|
// #1352: stamp the unified attention bucket so the SPA renders the same
|
|
117
128
|
// taxonomy the CLI brief counts (single classifier, no second opinion).
|
|
118
|
-
|
|
129
|
+
// Overrides were already applied when overriddenDigest was built — a
|
|
130
|
+
// second application here would be a no-op but obscures the single
|
|
131
|
+
// apply-then-partition ordering (#1416).
|
|
132
|
+
activePRs: (overriddenDigest.openPRs || []).map((pr) => ({
|
|
119
133
|
...pr,
|
|
120
134
|
attentionBucket: classifyAttentionBucket(pr),
|
|
121
135
|
})),
|
|
@@ -123,10 +137,10 @@ export function buildDashboardJson(digest, state, commentedIssues, allMergedPRs,
|
|
|
123
137
|
// and dormant-non-addressing PRs auto-shelved for display). Returning
|
|
124
138
|
// only state.config.shelvedPRUrls would under-count and desync from
|
|
125
139
|
// stats.shelvedPRs, which is already derived from digest.shelvedPRs. (#981)
|
|
126
|
-
shelvedPRUrls: (
|
|
127
|
-
recentlyMergedPRs:
|
|
128
|
-
recentlyClosedPRs:
|
|
129
|
-
autoUnshelvedPRs:
|
|
140
|
+
shelvedPRUrls: (overriddenDigest.shelvedPRs || []).map((ref) => ref.url),
|
|
141
|
+
recentlyMergedPRs: overriddenDigest.recentlyMergedPRs || [],
|
|
142
|
+
recentlyClosedPRs: overriddenDigest.recentlyClosedPRs || [],
|
|
143
|
+
autoUnshelvedPRs: overriddenDigest.autoUnshelvedPRs || [],
|
|
130
144
|
commentedIssues,
|
|
131
145
|
issueResponses,
|
|
132
146
|
allMergedPRs: filteredMergedPRs,
|
|
@@ -296,11 +310,46 @@ export async function startDashboardServer(options) {
|
|
|
296
310
|
// not disappear when /api/data rebuilds after a state change or after a
|
|
297
311
|
// POST /api/action completes.
|
|
298
312
|
let cachedPartialFailures = undefined;
|
|
313
|
+
// Gist checkpoint warnings from dashboard mutations (#1417). DELIBERATELY
|
|
314
|
+
// separate from cachedPartialFailures: fetch failures clear on a successful
|
|
315
|
+
// PULL, but a gist-sync warning means an un-pushed mutation, and a pull is
|
|
316
|
+
// exactly the event that can destroy it (refreshFromGist wholesale-replaces
|
|
317
|
+
// state). These clear only when a checkpoint PUSH succeeds.
|
|
318
|
+
let pendingGistSyncWarnings = [];
|
|
299
319
|
// Tracks the last background-refresh failure so /api/data can surface
|
|
300
320
|
// staleness to the SPA via the X-Dashboard-Stale header (#1205). Cleared
|
|
301
321
|
// when a refresh succeeds. Without this, token expiry / GitHub outage
|
|
302
322
|
// produces silent stale data hours old with no client-visible signal.
|
|
303
323
|
let lastBackgroundRefreshError = null;
|
|
324
|
+
/** Record a mutation's checkpoint outcome. `null` means the push succeeded
|
|
325
|
+
* (or local mode) — and a successful push carries the FULL current state,
|
|
326
|
+
* so any previously pending warning is resolved with it. */
|
|
327
|
+
function recordGistSyncOutcome(warning) {
|
|
328
|
+
if (warning === null) {
|
|
329
|
+
pendingGistSyncWarnings = [];
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!pendingGistSyncWarnings.includes(warning)) {
|
|
333
|
+
pendingGistSyncWarnings.push(warning);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** Merge pending gist-sync warnings into a partialFailures payload for the
|
|
337
|
+
* SPA banner without coupling their lifecycles. */
|
|
338
|
+
function withPendingGistWarnings(failures) {
|
|
339
|
+
if (pendingGistSyncWarnings.length === 0)
|
|
340
|
+
return failures;
|
|
341
|
+
const base = failures ?? [];
|
|
342
|
+
return [...base, ...pendingGistSyncWarnings.filter((w) => !base.includes(w))];
|
|
343
|
+
}
|
|
344
|
+
/** Push-before-pull (#1417): an un-pushed mutation would be silently
|
|
345
|
+
* reverted by the next Gist pull. Retry the checkpoint first so a recovered
|
|
346
|
+
* network turns the pending warning into a real push before any pull runs.
|
|
347
|
+
* No-op when nothing is pending. */
|
|
348
|
+
async function flushPendingGistSync() {
|
|
349
|
+
if (pendingGistSyncWarnings.length === 0)
|
|
350
|
+
return;
|
|
351
|
+
recordGistSyncOutcome(await maybeCheckpoint(stateManager, MODULE));
|
|
352
|
+
}
|
|
304
353
|
if (!cachedDigest) {
|
|
305
354
|
throw new Error('No dashboard data available. Run the daily check first: GITHUB_TOKEN=$(gh auth token) npm start -- daily');
|
|
306
355
|
}
|
|
@@ -308,7 +357,7 @@ export async function startDashboardServer(options) {
|
|
|
308
357
|
let cachedJsonData;
|
|
309
358
|
let cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
310
359
|
try {
|
|
311
|
-
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
|
|
360
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
|
|
312
361
|
}
|
|
313
362
|
catch (error) {
|
|
314
363
|
throw new Error(`Failed to build dashboard data: ${errorMessage(error)}. State data may be corrupted — try running: daily --json`, { cause: error });
|
|
@@ -343,6 +392,7 @@ export async function startDashboardServer(options) {
|
|
|
343
392
|
// Re-read state if modified externally (file mtime for local, Gist API for Gist mode)
|
|
344
393
|
let stateChanged = false;
|
|
345
394
|
if (stateManager.isGistMode()) {
|
|
395
|
+
await flushPendingGistSync();
|
|
346
396
|
stateChanged = await stateManager.refreshFromGist();
|
|
347
397
|
}
|
|
348
398
|
else {
|
|
@@ -353,7 +403,7 @@ export async function startDashboardServer(options) {
|
|
|
353
403
|
const issueListChanged = currentIssueListMtimeMs !== cachedIssueListMtimeMs;
|
|
354
404
|
if (stateChanged || issueListChanged) {
|
|
355
405
|
try {
|
|
356
|
-
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
|
|
406
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
|
|
357
407
|
cachedIssueListMtimeMs = currentIssueListMtimeMs;
|
|
358
408
|
}
|
|
359
409
|
catch (error) {
|
|
@@ -432,6 +482,7 @@ export async function startDashboardServer(options) {
|
|
|
432
482
|
/** Re-read state written by external processes (CLI) before mutating. */
|
|
433
483
|
async function reloadState() {
|
|
434
484
|
if (stateManager.isGistMode()) {
|
|
485
|
+
await flushPendingGistSync();
|
|
435
486
|
await stateManager.refreshFromGist();
|
|
436
487
|
}
|
|
437
488
|
else {
|
|
@@ -475,6 +526,11 @@ export async function startDashboardServer(options) {
|
|
|
475
526
|
}
|
|
476
527
|
// Resolve the mutation up-front so target validation happens before the
|
|
477
528
|
// state reload — keeps the reload-to-save freshness window minimal.
|
|
529
|
+
// Each mutation records the Gist checkpoint outcome (#1417): in gist mode
|
|
530
|
+
// `save()` only writes the local cache, and an un-pushed mutation can be
|
|
531
|
+
// wholesale-reverted by the next successful refreshFromGist — so a failed
|
|
532
|
+
// push must reach the SPA, not vanish into a discarded return value.
|
|
533
|
+
let gistSyncWarning = null;
|
|
478
534
|
let applyMutation;
|
|
479
535
|
if (body.action === 'move') {
|
|
480
536
|
const { VALID_TARGETS, runMove } = await import('./move.js');
|
|
@@ -484,13 +540,18 @@ export async function startDashboardServer(options) {
|
|
|
484
540
|
}
|
|
485
541
|
const target = body.target;
|
|
486
542
|
applyMutation = async () => {
|
|
487
|
-
await runMove({ prUrl: body.url, target });
|
|
543
|
+
const output = await runMove({ prUrl: body.url, target });
|
|
544
|
+
gistSyncWarning = output.gistSyncWarning ?? null;
|
|
488
545
|
};
|
|
489
546
|
}
|
|
490
547
|
else {
|
|
491
548
|
// dismiss_issue_response
|
|
492
|
-
applyMutation = () => {
|
|
549
|
+
applyMutation = async () => {
|
|
493
550
|
stateManager.dismissIssue(body.url, new Date().toISOString());
|
|
551
|
+
// Mirror runMove's contract: every mutating surface checkpoints to
|
|
552
|
+
// Gist and surfaces the warning. Never throws — failures come back
|
|
553
|
+
// as the warning string.
|
|
554
|
+
gistSyncWarning = await maybeCheckpoint(stateManager, MODULE);
|
|
494
555
|
};
|
|
495
556
|
}
|
|
496
557
|
// Reload state before mutating to avoid overwriting external CLI changes.
|
|
@@ -530,11 +591,16 @@ export async function startDashboardServer(options) {
|
|
|
530
591
|
return;
|
|
531
592
|
}
|
|
532
593
|
}
|
|
594
|
+
// Surface the checkpoint outcome through the partialFailures banner the
|
|
595
|
+
// SPA already renders (#1417). Tracked in pendingGistSyncWarnings — NOT
|
|
596
|
+
// cachedPartialFailures — because gist warnings clear on a successful
|
|
597
|
+
// PUSH, while fetch failures clear on a successful pull/refresh.
|
|
598
|
+
recordGistSyncOutcome(gistSyncWarning);
|
|
533
599
|
// Rebuild dashboard data from cached digest + updated state. Persist
|
|
534
600
|
// the last-known partialFailures across action rebuilds (#1035) so the
|
|
535
601
|
// SPA banner survives user interactions until the next successful
|
|
536
602
|
// refresh clears it.
|
|
537
|
-
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, cachedPartialFailures);
|
|
603
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, undefined, undefined, withPendingGistWarnings(cachedPartialFailures));
|
|
538
604
|
cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
539
605
|
sendJson(res, 200, cachedJsonData);
|
|
540
606
|
}
|
|
@@ -554,7 +620,7 @@ export async function startDashboardServer(options) {
|
|
|
554
620
|
// Update the persistent banner signal — clear on a clean refresh,
|
|
555
621
|
// set when one or more sub-fetches degraded. See #1035.
|
|
556
622
|
cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
|
|
557
|
-
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
|
|
623
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, withPendingGistWarnings(cachedPartialFailures));
|
|
558
624
|
cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
559
625
|
sendJson(res, 200, cachedJsonData);
|
|
560
626
|
}
|
|
@@ -675,6 +741,7 @@ export async function startDashboardServer(options) {
|
|
|
675
741
|
fetchDashboardData(token)
|
|
676
742
|
.then(async (result) => {
|
|
677
743
|
if (stateManager.isGistMode()) {
|
|
744
|
+
await flushPendingGistSync();
|
|
678
745
|
await stateManager.refreshFromGist();
|
|
679
746
|
}
|
|
680
747
|
else {
|
|
@@ -683,7 +750,7 @@ export async function startDashboardServer(options) {
|
|
|
683
750
|
cachedDigest = result.digest;
|
|
684
751
|
cachedCommentedIssues = result.commentedIssues;
|
|
685
752
|
cachedPartialFailures = result.partialFailures.length > 0 ? result.partialFailures : undefined;
|
|
686
|
-
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, cachedPartialFailures);
|
|
753
|
+
cachedJsonData = buildDashboardJson(cachedDigest, stateManager.getState(), cachedCommentedIssues, result.allMergedPRs, result.allClosedPRs, withPendingGistWarnings(cachedPartialFailures));
|
|
687
754
|
cachedIssueListMtimeMs = getIssueListMtimeMs();
|
|
688
755
|
// Successful refresh clears any prior failure signal (#1205).
|
|
689
756
|
lastBackgroundRefreshError = null;
|
|
@@ -52,7 +52,11 @@ function toFeaturesCandidate(scoutCandidate, getState) {
|
|
|
52
52
|
// this repo" rather than a fabricated score.
|
|
53
53
|
const grade = gradeFromCandidate({
|
|
54
54
|
repo: scoutCandidate.issue.repo,
|
|
55
|
-
projectHealth: {
|
|
55
|
+
projectHealth: {
|
|
56
|
+
repo: scoutCandidate.issue.repo,
|
|
57
|
+
checkFailed: true,
|
|
58
|
+
failureReason: 'health not fetched on the multi-issue feature surface',
|
|
59
|
+
},
|
|
56
60
|
getRepoScore: (repo) => {
|
|
57
61
|
const score = getState.getRepoScore(repo);
|
|
58
62
|
return score
|
|
@@ -184,10 +184,20 @@ export async function runListMoveTier(options) {
|
|
|
184
184
|
'Add the entry to the list first, then re-run list-move-tier.');
|
|
185
185
|
}
|
|
186
186
|
if (result.moved) {
|
|
187
|
+
// tmp+rename so a crash mid-write can't truncate the curated list —
|
|
188
|
+
// same atomic pattern as list-mark-done (#1421).
|
|
189
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
187
190
|
try {
|
|
188
|
-
fs.writeFileSync(
|
|
191
|
+
fs.writeFileSync(tmp, result.content, 'utf8');
|
|
192
|
+
fs.renameSync(tmp, filePath);
|
|
189
193
|
}
|
|
190
194
|
catch (error) {
|
|
195
|
+
try {
|
|
196
|
+
fs.unlinkSync(tmp);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// best-effort cleanup
|
|
200
|
+
}
|
|
191
201
|
throw new Error(`Failed to write file: ${errorMessage(error)}`, { cause: error });
|
|
192
202
|
}
|
|
193
203
|
}
|
|
@@ -90,8 +90,20 @@ export function buildScoutState() {
|
|
|
90
90
|
// `--split-ratio` flags for overrides.
|
|
91
91
|
featuresAnchorThreshold: 3,
|
|
92
92
|
featuresSplitRatio: 0.6,
|
|
93
|
+
// Personalization-bias prefs, likewise required on the inferred type
|
|
94
|
+
// (scout #1244 / #168). Autopilot doesn't surface these as settings yet,
|
|
95
|
+
// so we pass scout's documented "no bias" defaults.
|
|
96
|
+
preferLanguages: [],
|
|
97
|
+
preferRepos: [],
|
|
98
|
+
diversityRatio: 0,
|
|
99
|
+
avoidRepos: [],
|
|
100
|
+
boostIssueTypes: [],
|
|
93
101
|
},
|
|
94
102
|
repoScores: state.repoScores,
|
|
103
|
+
// Scout #117 added tombstones (deletion records for gist merge). Autopilot
|
|
104
|
+
// synthesizes a fresh ScoutState per operation and tracks no deletions, so
|
|
105
|
+
// an empty list is correct here.
|
|
106
|
+
tombstones: [],
|
|
95
107
|
starredRepos: config.starredRepos,
|
|
96
108
|
starredReposLastFetched: config.starredReposLastFetched,
|
|
97
109
|
mergedPRs: (state.mergedPRs ?? []).map((pr) => ({
|
package/dist/commands/search.js
CHANGED
|
@@ -97,7 +97,11 @@ export async function runSearch(options) {
|
|
|
97
97
|
// before" rather than a fabricated score.
|
|
98
98
|
const grade = gradeFromCandidate({
|
|
99
99
|
repo: c.issue.repo,
|
|
100
|
-
projectHealth: {
|
|
100
|
+
projectHealth: {
|
|
101
|
+
repo: c.issue.repo,
|
|
102
|
+
checkFailed: true,
|
|
103
|
+
failureReason: 'health not fetched on the multi-issue search surface',
|
|
104
|
+
},
|
|
101
105
|
getRepoScore: (repo) => {
|
|
102
106
|
const score = stateManager.getRepoScore(repo);
|
|
103
107
|
return score
|
|
@@ -135,9 +139,14 @@ export async function runSearch(options) {
|
|
|
135
139
|
}
|
|
136
140
|
: undefined,
|
|
137
141
|
...(linkedPR ? { linkedPR } : {}),
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
// Scout 1.0 folded the boostScore/boostReasons/diversitySlot trio into a
|
|
143
|
+
// single `personalization` field (#158). Derive the flat output fields
|
|
144
|
+
// from it so this command's JSON shape is unchanged.
|
|
145
|
+
...(c.personalization?.kind === 'boosted' ? { boostScore: c.personalization.score } : {}),
|
|
146
|
+
...(c.personalization?.kind === 'boosted' && c.personalization.reasons.length > 0
|
|
147
|
+
? { boostReasons: c.personalization.reasons }
|
|
148
|
+
: {}),
|
|
149
|
+
...(c.personalization?.kind === 'diversity' ? { diversitySlot: true } : {}),
|
|
141
150
|
};
|
|
142
151
|
}),
|
|
143
152
|
excludedRepos: result.excludedRepos,
|
package/dist/core/auth.js
CHANGED
|
@@ -13,6 +13,22 @@ const MODULE = 'auth';
|
|
|
13
13
|
// Cached GitHub token (fetched once per session)
|
|
14
14
|
let cachedGitHubToken = null;
|
|
15
15
|
let tokenFetchAttempted = false;
|
|
16
|
+
/**
|
|
17
|
+
* A `gh auth token` spawn that timed out is transient (#1415): the CLI is
|
|
18
|
+
* installed and may answer on the next call (slow disk, machine waking,
|
|
19
|
+
* momentary load). Everything else is definitive for the process lifetime —
|
|
20
|
+
* ENOENT (gh not installed) and a non-zero exit (not authenticated) will not
|
|
21
|
+
* change without user action, so those still latch `tokenFetchAttempted`.
|
|
22
|
+
* The async execFile timeout surfaces as `killed: true` (signal SIGTERM);
|
|
23
|
+
* the sync execFileSync timeout surfaces as `code: 'ETIMEDOUT'` (no
|
|
24
|
+
* `killed` property) — both arms are load-bearing, one per path.
|
|
25
|
+
*/
|
|
26
|
+
function isTransientTokenFetchError(err) {
|
|
27
|
+
if (!err || typeof err !== 'object')
|
|
28
|
+
return false;
|
|
29
|
+
const e = err;
|
|
30
|
+
return e.killed === true || e.code === 'ETIMEDOUT';
|
|
31
|
+
}
|
|
16
32
|
/**
|
|
17
33
|
* Retrieves a GitHub authentication token, checking sources in priority order.
|
|
18
34
|
*
|
|
@@ -29,7 +45,6 @@ export function getGitHubToken() {
|
|
|
29
45
|
if (tokenFetchAttempted) {
|
|
30
46
|
return null;
|
|
31
47
|
}
|
|
32
|
-
tokenFetchAttempted = true;
|
|
33
48
|
if (process.env.GITHUB_TOKEN) {
|
|
34
49
|
cachedGitHubToken = process.env.GITHUB_TOKEN;
|
|
35
50
|
return cachedGitHubToken;
|
|
@@ -40,6 +55,8 @@ export function getGitHubToken() {
|
|
|
40
55
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
41
56
|
timeout: 2000,
|
|
42
57
|
}).trim();
|
|
58
|
+
// Definitive outcome (a token, or authoritatively none) — latch.
|
|
59
|
+
tokenFetchAttempted = true;
|
|
43
60
|
if (token && token.length > 0) {
|
|
44
61
|
cachedGitHubToken = token;
|
|
45
62
|
debug(MODULE, 'Using GitHub token from gh CLI');
|
|
@@ -47,9 +64,14 @@ export function getGitHubToken() {
|
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
66
|
catch (err) {
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
67
|
+
// Latch only on definitive failures (#1415): a timeout must not
|
|
68
|
+
// permanently disable token fetch for a long-lived process (the MCP
|
|
69
|
+
// server), or gist-mode mutations silently degrade to local-only for
|
|
70
|
+
// the process lifetime. Promote to warn so a slow `gh` (2s timeout) or
|
|
71
|
+
// a misconfigured CLI is visible without DEBUG=1 (#1209 L6).
|
|
72
|
+
if (!isTransientTokenFetchError(err)) {
|
|
73
|
+
tokenFetchAttempted = true;
|
|
74
|
+
}
|
|
53
75
|
warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
|
|
54
76
|
}
|
|
55
77
|
return null;
|
|
@@ -97,7 +119,6 @@ export async function getGitHubTokenAsync() {
|
|
|
97
119
|
if (tokenFetchAttempted) {
|
|
98
120
|
return null;
|
|
99
121
|
}
|
|
100
|
-
tokenFetchAttempted = true;
|
|
101
122
|
if (process.env.GITHUB_TOKEN) {
|
|
102
123
|
cachedGitHubToken = process.env.GITHUB_TOKEN;
|
|
103
124
|
return cachedGitHubToken;
|
|
@@ -113,6 +134,8 @@ export async function getGitHubTokenAsync() {
|
|
|
113
134
|
}
|
|
114
135
|
});
|
|
115
136
|
});
|
|
137
|
+
// Definitive outcome (a token, or authoritatively none) — latch.
|
|
138
|
+
tokenFetchAttempted = true;
|
|
116
139
|
if (token && token.length > 0) {
|
|
117
140
|
cachedGitHubToken = token;
|
|
118
141
|
debug(MODULE, 'Using GitHub token from gh CLI (async)');
|
|
@@ -120,7 +143,11 @@ export async function getGitHubTokenAsync() {
|
|
|
120
143
|
}
|
|
121
144
|
}
|
|
122
145
|
catch (err) {
|
|
123
|
-
// Same warn
|
|
146
|
+
// Same transient/definitive split and warn promotion as the sync
|
|
147
|
+
// version (#1415, #1209 L6).
|
|
148
|
+
if (!isTransientTokenFetchError(err)) {
|
|
149
|
+
tokenFetchAttempted = true;
|
|
150
|
+
}
|
|
124
151
|
warn(MODULE, `gh auth token failed (CLI unavailable or not authenticated): ${err instanceof Error ? err.message : String(err)}`);
|
|
125
152
|
}
|
|
126
153
|
return null;
|
package/dist/core/index.d.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Core module exports
|
|
3
3
|
* Re-exports all core functionality for convenient imports
|
|
4
4
|
*/
|
|
5
|
-
export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
|
|
5
|
+
export { StateManager, getStateManager, getStateManagerAsync, ensureGistPersistence, type GistPersistenceStatus, maybeCheckpoint, resetStateManager, type Stats, } from './state.js';
|
|
6
6
|
export { GistStateStore } from './gist-state-store.js';
|
|
7
7
|
export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX, GUIDELINES_MAX_BYTES, GuidelinesNotAvailableError, GuidelinesTooLargeError, } from './guidelines-store.js';
|
|
8
8
|
export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabel, classifyCICheck, classifyFailingChecks, } from './pr-monitor.js';
|
|
9
9
|
export { IssueConversationMonitor } from './issue-conversation.js';
|
|
10
10
|
export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
|
|
11
|
-
export { wrapUntrustedContent, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
|
|
11
|
+
export { wrapUntrustedContent, fenceFetchedPR, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, type UntrustedContentMeta, } from './untrusted-content.js';
|
|
12
12
|
export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
|
|
13
13
|
export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
|
|
14
14
|
export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
|
package/dist/core/index.js
CHANGED
|
@@ -9,7 +9,7 @@ export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks,
|
|
|
9
9
|
// Search/vetting now delegated to @oss-scout/core via commands/scout-bridge.ts
|
|
10
10
|
export { IssueConversationMonitor } from './issue-conversation.js';
|
|
11
11
|
export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
|
|
12
|
-
export { wrapUntrustedContent, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
|
|
12
|
+
export { wrapUntrustedContent, fenceFetchedPR, extractFromFence, safeExtractFromFence, UNTRUSTED_OPEN_TAG_NAME, UNTRUSTED_CLOSE_TAG, } from './untrusted-content.js';
|
|
13
13
|
export { getOctokit, checkRateLimit } from './github.js';
|
|
14
14
|
export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
|
|
15
15
|
export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* treated as a risk, not ignored). This matches the policy previously
|
|
11
11
|
* described as prose in agents/issue-scout.md.
|
|
12
12
|
*/
|
|
13
|
+
import type { ProjectHealth } from '@oss-scout/core';
|
|
13
14
|
export type GradeLetter = 'A' | 'B' | 'C' | 'F';
|
|
14
15
|
export interface GradeSignals {
|
|
15
16
|
/** Average maintainer response time in days; null if unknown. */
|
|
@@ -61,11 +62,7 @@ export declare function deriveGradeSignals(params: {
|
|
|
61
62
|
*/
|
|
62
63
|
export declare function gradeFromCandidate(params: {
|
|
63
64
|
repo: string;
|
|
64
|
-
projectHealth:
|
|
65
|
-
avgIssueResponseDays: number | null;
|
|
66
|
-
daysSinceLastCommit: number | null;
|
|
67
|
-
checkFailed?: boolean;
|
|
68
|
-
};
|
|
65
|
+
projectHealth: ProjectHealth;
|
|
69
66
|
getRepoScore: (repo: string) => {
|
|
70
67
|
mergedPRCount: number;
|
|
71
68
|
closedWithoutMergeCount: number;
|
|
@@ -103,8 +103,19 @@ export function deriveGradeSignals(params) {
|
|
|
103
103
|
*/
|
|
104
104
|
export function gradeFromCandidate(params) {
|
|
105
105
|
const repoScore = params.getRepoScore(params.repo);
|
|
106
|
+
// Narrow the union into the minimal shape deriveGradeSignals expects. The
|
|
107
|
+
// check-failed arm has no avgIssueResponseDays/daysSinceLastCommit, and the
|
|
108
|
+
// grader already treats checkFailed as untrusted (no snapshot signals).
|
|
109
|
+
const ph = params.projectHealth;
|
|
110
|
+
const projectHealth = ph.checkFailed
|
|
111
|
+
? { avgIssueResponseDays: null, daysSinceLastCommit: null, checkFailed: true }
|
|
112
|
+
: {
|
|
113
|
+
avgIssueResponseDays: ph.avgIssueResponseDays,
|
|
114
|
+
daysSinceLastCommit: ph.daysSinceLastCommit,
|
|
115
|
+
checkFailed: false,
|
|
116
|
+
};
|
|
106
117
|
return computeSuccessGrade(deriveGradeSignals({
|
|
107
|
-
projectHealth
|
|
118
|
+
projectHealth,
|
|
108
119
|
repoScore: repoScore
|
|
109
120
|
? {
|
|
110
121
|
mergedPRCount: repoScore.mergedPRCount,
|
package/dist/core/state.d.ts
CHANGED
|
@@ -441,7 +441,21 @@ export declare function getStateManagerAsync(token?: string): Promise<StateManag
|
|
|
441
441
|
* // CLI bootstrap
|
|
442
442
|
* await ensureGistPersistence(token);
|
|
443
443
|
*/
|
|
444
|
-
export
|
|
444
|
+
export type GistPersistenceStatus =
|
|
445
|
+
/** No state file yet, or config does not request gist mode. */
|
|
446
|
+
'local-mode'
|
|
447
|
+
/** The state file exists but could not be read/parsed for this attempt
|
|
448
|
+
* (permissions, transient FS error, corrupt JSON). Callers must NOT
|
|
449
|
+
* memoize this as "local mode chosen" — a later attempt may succeed. */
|
|
450
|
+
| 'state-unreadable'
|
|
451
|
+
/** Gist mode is configured but no token was available for this attempt. */
|
|
452
|
+
| 'no-token'
|
|
453
|
+
/** Gist mode active: the singleton is gist-backed. */
|
|
454
|
+
| 'gist'
|
|
455
|
+
/** Gist mode is configured and a token was available, but init fell back
|
|
456
|
+
* to local-only (transient network failure). A later call may recover. */
|
|
457
|
+
| 'degraded';
|
|
458
|
+
export declare function ensureGistPersistence(token: string | null): Promise<GistPersistenceStatus>;
|
|
445
459
|
/**
|
|
446
460
|
* Reset the singleton StateManager instance to null. Intended for test isolation.
|
|
447
461
|
*/
|