@oss-autopilot/core 3.12.0 → 3.13.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 +11 -11
- package/dist/cli.bundle.cjs +114 -99
- package/dist/commands/dashboard-data.js +21 -4
- package/dist/commands/dashboard-server.js +83 -16
- package/dist/commands/features.js +5 -1
- package/dist/commands/list-mark-done.d.ts +9 -1
- package/dist/commands/list-mark-done.js +14 -1
- package/dist/commands/scout-bridge.js +12 -0
- package/dist/commands/search.d.ts +8 -0
- package/dist/commands/search.js +22 -3
- package/dist/core/index.d.ts +1 -1
- 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/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.d.ts +10 -1
- package/dist/formatters/json.js +12 -2
- 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,30 @@ 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
|
-
const freshShelved =
|
|
286
|
+
const freshShelved = overriddenPRs.filter((pr) => !CRITICAL_STATUSES.has(pr.status) && (shelvedUrls.has(pr.url) || pr.stalenessTier === 'dormant'));
|
|
270
287
|
digest.shelvedPRs = freshShelved.map(toShelvedPRRef);
|
|
271
288
|
digest.autoUnshelvedPRs = [];
|
|
272
|
-
digest.summary.totalActivePRs =
|
|
289
|
+
digest.summary.totalActivePRs = overriddenPRs.length - freshShelved.length;
|
|
273
290
|
stateManager.setLastDigest(digest);
|
|
274
291
|
});
|
|
275
292
|
}
|
|
@@ -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
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* list-mark-done command (#1299).
|
|
3
3
|
*
|
|
4
|
+
* A URL that is not in the list at all is an error (#1406) — marking done
|
|
5
|
+
* requires an existing entry; only the already-marked re-run is a quiet no-op.
|
|
6
|
+
*
|
|
4
7
|
* Mark an issue line in a curated list as done by wrapping it in
|
|
5
8
|
* `~~strikethrough~~` and appending a `**Done** — PR [#N](url) ...` sub-bullet.
|
|
6
9
|
* If every issue under the same `### repo/name` heading is now struck through,
|
|
@@ -19,7 +22,8 @@ export interface MarkDoneOptions {
|
|
|
19
22
|
listPath: string;
|
|
20
23
|
}
|
|
21
24
|
export interface MarkDoneOutput {
|
|
22
|
-
/** True if the line was found and updated. False
|
|
25
|
+
/** True if the line was found and updated. False only when already marked
|
|
26
|
+
* done — a URL missing from the list entirely throws instead (#1406). */
|
|
23
27
|
marked: boolean;
|
|
24
28
|
/** Fully-resolved file path that was inspected. */
|
|
25
29
|
filePath: string;
|
|
@@ -32,6 +36,8 @@ export interface MarkDoneOutput {
|
|
|
32
36
|
/** Human-readable explanation when `marked` is false. */
|
|
33
37
|
reason?: string;
|
|
34
38
|
}
|
|
39
|
+
/** Discriminates the two `marked: false` outcomes of {@link markIssueAsDone}. */
|
|
40
|
+
export type MarkDoneNoOpReason = 'not-found' | 'already-marked';
|
|
35
41
|
/** Pure transform — exposed for unit testing. */
|
|
36
42
|
export declare function markIssueAsDone(content: string, opts: {
|
|
37
43
|
issueUrl: string;
|
|
@@ -43,6 +49,8 @@ export declare function markIssueAsDone(content: string, opts: {
|
|
|
43
49
|
repoHeadingStruck: boolean;
|
|
44
50
|
remainingUnderRepo: number;
|
|
45
51
|
reason?: string;
|
|
52
|
+
/** Set only when `marked` is false — why nothing changed. */
|
|
53
|
+
reasonCode?: MarkDoneNoOpReason;
|
|
46
54
|
};
|
|
47
55
|
/** Read → transform → write atomically (tmp + rename) so a crash mid-write can't corrupt the file. */
|
|
48
56
|
export declare function runMarkIssueListItemDone(options: MarkDoneOptions): Promise<MarkDoneOutput>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* list-mark-done command (#1299).
|
|
3
3
|
*
|
|
4
|
+
* A URL that is not in the list at all is an error (#1406) — marking done
|
|
5
|
+
* requires an existing entry; only the already-marked re-run is a quiet no-op.
|
|
6
|
+
*
|
|
4
7
|
* Mark an issue line in a curated list as done by wrapping it in
|
|
5
8
|
* `~~strikethrough~~` and appending a `**Done** — PR [#N](url) ...` sub-bullet.
|
|
6
9
|
* If every issue under the same `### repo/name` heading is now struck through,
|
|
@@ -13,7 +16,7 @@
|
|
|
13
16
|
*/
|
|
14
17
|
import * as fs from 'node:fs';
|
|
15
18
|
import * as path from 'node:path';
|
|
16
|
-
import { errorMessage } from '../core/errors.js';
|
|
19
|
+
import { errorMessage, ValidationError } from '../core/errors.js';
|
|
17
20
|
const STRIKE = '~~';
|
|
18
21
|
const DONE_PREFIX = ' - **Done**';
|
|
19
22
|
const ISSUE_LINE_RE = /^[*+-]\s/;
|
|
@@ -113,6 +116,7 @@ export function markIssueAsDone(content, opts) {
|
|
|
113
116
|
repoHeadingStruck: false,
|
|
114
117
|
remainingUnderRepo: 0,
|
|
115
118
|
reason: 'issue URL not found in the list',
|
|
119
|
+
reasonCode: 'not-found',
|
|
116
120
|
};
|
|
117
121
|
}
|
|
118
122
|
const issueLine = lines[block.start];
|
|
@@ -126,6 +130,7 @@ export function markIssueAsDone(content, opts) {
|
|
|
126
130
|
repoHeadingStruck: false,
|
|
127
131
|
remainingUnderRepo: section ? countOpenIssues(lines, section) : 0,
|
|
128
132
|
reason: 'already marked done',
|
|
133
|
+
reasonCode: 'already-marked',
|
|
129
134
|
};
|
|
130
135
|
}
|
|
131
136
|
// 1. Strike the issue line.
|
|
@@ -186,6 +191,14 @@ export async function runMarkIssueListItemDone(options) {
|
|
|
186
191
|
prUrl: options.prUrl,
|
|
187
192
|
prStatus: options.prStatus,
|
|
188
193
|
});
|
|
194
|
+
// #1406: a missing entry is a caller error, not a quiet success — the
|
|
195
|
+
// consumer branch in workflows/draft-first-workflow.md already documents
|
|
196
|
+
// this contract (STOP on success:false mentioning "Issue URL not found").
|
|
197
|
+
// Idempotent re-runs (already marked done) still resolve normally.
|
|
198
|
+
if (result.reasonCode === 'not-found') {
|
|
199
|
+
throw new ValidationError(`Issue URL not found in the list: ${options.issueUrl} (${filePath}). ` +
|
|
200
|
+
'Check the URL and --list-path; the entry must exist before it can be marked done.');
|
|
201
|
+
}
|
|
189
202
|
if (result.marked) {
|
|
190
203
|
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
191
204
|
try {
|
|
@@ -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) => ({
|
|
@@ -10,6 +10,14 @@ export { type SearchOutput } from '../formatters/json.js';
|
|
|
10
10
|
* lands in one place instead of three (#1002).
|
|
11
11
|
*/
|
|
12
12
|
export declare const MAX_SEARCH_RESULTS = 100;
|
|
13
|
+
/**
|
|
14
|
+
* Fraction of search slots reserved for candidates that matched neither
|
|
15
|
+
* strategy-preferred languages nor repos (#1244). Counterweight against
|
|
16
|
+
* echo-chamber bias: without it, strategy-boosted searches return more of
|
|
17
|
+
* what already merged, which merges more of the same, and the profile
|
|
18
|
+
* narrows over time. Scout clamps to [0, 1]; 0.2 is the issue's proposal.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SEARCH_DIVERSITY_RATIO = 0.2;
|
|
13
21
|
interface SearchOptions {
|
|
14
22
|
maxResults: number;
|
|
15
23
|
}
|
package/dist/commands/search.js
CHANGED
|
@@ -14,6 +14,14 @@ const MODULE = 'search';
|
|
|
14
14
|
* lands in one place instead of three (#1002).
|
|
15
15
|
*/
|
|
16
16
|
export const MAX_SEARCH_RESULTS = 100;
|
|
17
|
+
/**
|
|
18
|
+
* Fraction of search slots reserved for candidates that matched neither
|
|
19
|
+
* strategy-preferred languages nor repos (#1244). Counterweight against
|
|
20
|
+
* echo-chamber bias: without it, strategy-boosted searches return more of
|
|
21
|
+
* what already merged, which merges more of the same, and the profile
|
|
22
|
+
* narrows over time. Scout clamps to [0, 1]; 0.2 is the issue's proposal.
|
|
23
|
+
*/
|
|
24
|
+
export const SEARCH_DIVERSITY_RATIO = 0.2;
|
|
17
25
|
/**
|
|
18
26
|
* Search GitHub for contributable issues using multi-phase discovery.
|
|
19
27
|
*
|
|
@@ -63,6 +71,7 @@ export async function runSearch(options) {
|
|
|
63
71
|
maxResults: options.maxResults,
|
|
64
72
|
preferLanguages,
|
|
65
73
|
preferRepos,
|
|
74
|
+
diversityRatio: SEARCH_DIVERSITY_RATIO,
|
|
66
75
|
});
|
|
67
76
|
// #1354: never surface issues the user already has an open PR for. Uses
|
|
68
77
|
// scout's structured linked-PR metadata when present; candidates without it
|
|
@@ -88,7 +97,11 @@ export async function runSearch(options) {
|
|
|
88
97
|
// before" rather than a fabricated score.
|
|
89
98
|
const grade = gradeFromCandidate({
|
|
90
99
|
repo: c.issue.repo,
|
|
91
|
-
projectHealth: {
|
|
100
|
+
projectHealth: {
|
|
101
|
+
repo: c.issue.repo,
|
|
102
|
+
checkFailed: true,
|
|
103
|
+
failureReason: 'health not fetched on the multi-issue search surface',
|
|
104
|
+
},
|
|
92
105
|
getRepoScore: (repo) => {
|
|
93
106
|
const score = stateManager.getRepoScore(repo);
|
|
94
107
|
return score
|
|
@@ -126,8 +139,14 @@ export async function runSearch(options) {
|
|
|
126
139
|
}
|
|
127
140
|
: undefined,
|
|
128
141
|
...(linkedPR ? { linkedPR } : {}),
|
|
129
|
-
|
|
130
|
-
|
|
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 } : {}),
|
|
131
150
|
};
|
|
132
151
|
}),
|
|
133
152
|
excludedRepos: result.excludedRepos,
|
package/dist/core/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { guidelinesFilename, repoFromGuidelinesFilename, GUIDELINES_FILE_PREFIX,
|
|
|
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/strategy.js
CHANGED
|
@@ -105,6 +105,25 @@ function recommendForOverExtension(openPRCount, dormantPRCount, overExtended) {
|
|
|
105
105
|
return 'open_more';
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Read-only mirror of the status-override lookup (#1416): same staleness
|
|
110
|
+
* rule as `StateManager.getStatusOverride` (an override recorded against
|
|
111
|
+
* older activity than the PR's `updatedAt` is ignored), but with NO
|
|
112
|
+
* auto-clear write — this module stays pure/no-I/O, and the daily and
|
|
113
|
+
* dashboard pipelines own clearing stale overrides.
|
|
114
|
+
*/
|
|
115
|
+
function effectiveStatus(pr, state) {
|
|
116
|
+
if (typeof pr.url !== 'string')
|
|
117
|
+
return pr.status;
|
|
118
|
+
const override = state.config.statusOverrides?.[pr.url];
|
|
119
|
+
if (!override)
|
|
120
|
+
return pr.status;
|
|
121
|
+
if (typeof pr.updatedAt === 'string' && pr.updatedAt > override.lastActivityAt)
|
|
122
|
+
return pr.status;
|
|
123
|
+
if (override.status !== 'needs_addressing' && override.status !== 'waiting_on_maintainer')
|
|
124
|
+
return pr.status;
|
|
125
|
+
return override.status;
|
|
126
|
+
}
|
|
108
127
|
/**
|
|
109
128
|
* Compute the deterministic strategy signal from agent state. Returns
|
|
110
129
|
* null when state is thinner than {@link STRATEGY_MIN_PRS} merged PRs —
|
|
@@ -158,12 +177,20 @@ export function computeStrategy(state) {
|
|
|
158
177
|
// Capacity: read from the last digest. When no digest exists yet,
|
|
159
178
|
// default to zero (the agent has nothing to recommend without a
|
|
160
179
|
// digest). The DailyDigest schema does not store a `dormantCount`
|
|
161
|
-
// directly — derive it from the
|
|
162
|
-
//
|
|
163
|
-
//
|
|
180
|
+
// directly — derive it from the "awaiting maintainer review" bucket.
|
|
181
|
+
//
|
|
182
|
+
// Status-basis reconciliation (#1416): dashboard-written digests keep RAW
|
|
183
|
+
// statuses in `openPRs` (so override CLEARS stay visible on rebuild) while
|
|
184
|
+
// `summary.totalActivePRs` is override-basis. Re-derive the waiting bucket
|
|
185
|
+
// from `openPRs` through the override map so both capacity inputs share
|
|
186
|
+
// one basis; fall back to the stored array for digests without `openPRs`
|
|
187
|
+
// (which daily.ts builds post-override anyway).
|
|
164
188
|
const summary = state.lastDigest?.summary;
|
|
165
189
|
const openPRCount = summary?.totalActivePRs ?? 0;
|
|
166
|
-
const
|
|
190
|
+
const openPRs = (state.lastDigest?.openPRs ?? []);
|
|
191
|
+
const waiting = openPRs.length > 0
|
|
192
|
+
? openPRs.filter((pr) => effectiveStatus(pr, state) === 'waiting_on_maintainer')
|
|
193
|
+
: (state.lastDigest?.waitingOnMaintainerPRs ?? []);
|
|
167
194
|
const dormantPRCount = waiting.length;
|
|
168
195
|
// Overextended: dormant PRs spread across 2+ repos. Same definition
|
|
169
196
|
// the issue body uses.
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
*
|
|
19
19
|
* - `runComments` (commands/comments.ts): review / inline / discussion
|
|
20
20
|
* comment bodies in the `comments` CLI `--json` + MCP tool output.
|
|
21
|
+
* - `deduplicateDigest` (formatters/json.ts) and the MCP PR resources:
|
|
22
|
+
* `openPRs[].lastMaintainerComment.body` via {@link fenceFetchedPR}
|
|
23
|
+
* (#1420).
|
|
21
24
|
* - `fetchPRCommentBundle` (core/pr-comments-fetcher.ts): bundle bodies
|
|
22
25
|
* feeding `guidelines fetch-corpus` (agent-only consumer).
|
|
23
26
|
* - `toDailyOutput` (commands/daily.ts): `commentedIssues[].lastResponseBody`
|
|
@@ -67,3 +70,24 @@ export declare function extractFromFence(fenced: string): string;
|
|
|
67
70
|
* never throws.
|
|
68
71
|
*/
|
|
69
72
|
export declare function safeExtractFromFence(text: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Fence the attacker-controllable maintainer-comment excerpt on a FetchedPR
|
|
75
|
+
* for agent-facing serialization (#1420). Applied at the serialization
|
|
76
|
+
* boundary (daily/startup --json via deduplicateDigest, MCP PR resources)
|
|
77
|
+
* rather than at fetch time: extractMaintainerActionHints parses the RAW
|
|
78
|
+
* body inside the pipeline, and human surfaces (dashboard SPA, CLI text
|
|
79
|
+
* mode) must not render fence markup. Returns a copy; never mutates.
|
|
80
|
+
*/
|
|
81
|
+
export declare function fenceFetchedPR<T extends FenceablePR>(pr: T): T;
|
|
82
|
+
/** Structural slice of FetchedPR that {@link fenceFetchedPR} needs — kept
|
|
83
|
+
* structural to avoid an import cycle with types.ts consumers. */
|
|
84
|
+
interface FenceablePR {
|
|
85
|
+
repo: string;
|
|
86
|
+
number: number;
|
|
87
|
+
lastMaintainerComment?: {
|
|
88
|
+
author: string;
|
|
89
|
+
body: string;
|
|
90
|
+
createdAt: string;
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export {};
|