@oss-autopilot/core 3.14.0 → 3.14.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-registry.js +9 -1
- package/dist/cli.bundle.cjs +95 -95
- package/dist/commands/dashboard-data.js +23 -1
- package/dist/commands/dashboard-server.js +46 -19
- package/dist/commands/skip-file-parser.d.ts +5 -1
- package/dist/commands/skip-file-parser.js +9 -3
- package/dist/commands/vet-list.d.ts +6 -6
- package/dist/commands/vet-list.js +190 -65
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/issue-verification.d.ts +39 -0
- package/dist/core/issue-verification.js +48 -0
- package/dist/formatters/json.d.ts +39 -5
- package/package.json +2 -2
|
@@ -182,6 +182,28 @@ export async function fetchDashboardData(token) {
|
|
|
182
182
|
// Get watermarks for incremental PR fetch
|
|
183
183
|
const watermark = stateManager.getMergedPRWatermark();
|
|
184
184
|
const closedWatermark = stateManager.getClosedPRWatermark();
|
|
185
|
+
// Self-heal a ledger stranded behind a recent watermark (#1504). The
|
|
186
|
+
// incremental fetch only asks for merges newer than the newest stored one,
|
|
187
|
+
// so once the recent-merge feed (daily's recentlyMergedPRs) seeds the ledger
|
|
188
|
+
// before any full historical fetch ran, the older history can never be
|
|
189
|
+
// recovered. When the stored detail count is materially below the known
|
|
190
|
+
// all-time merged count, drop the watermark for a one-shot full backfill
|
|
191
|
+
// (bounded by the existing pagination cap); once the ledger catches up the
|
|
192
|
+
// watermark resumes incremental fetching. Closed PRs are not seeded ahead of
|
|
193
|
+
// their backfill in practice, so only merged needs the guard.
|
|
194
|
+
const storedMergedCount = stateManager.getMergedPRs().length;
|
|
195
|
+
const knownMergedAllTime = stateManager.getState().lastDigest?.summary?.totalMergedAllTime ?? 0;
|
|
196
|
+
// A full backfill can only ever return up to fetchMergedPRsSince's pagination
|
|
197
|
+
// cap (MAX_PAGINATION_PAGES * 100 = 300). Stop forcing one once the ledger has
|
|
198
|
+
// reached that ceiling, so a contributor whose star-filtered all-time count
|
|
199
|
+
// legitimately exceeds 300 doesn't trigger a full refetch on every poll.
|
|
200
|
+
const MERGED_BACKFILL_CEILING = 300;
|
|
201
|
+
const mergedNeedsBackfill = storedMergedCount < knownMergedAllTime && storedMergedCount < MERGED_BACKFILL_CEILING;
|
|
202
|
+
const mergedFetchSince = mergedNeedsBackfill ? undefined : watermark;
|
|
203
|
+
if (mergedNeedsBackfill) {
|
|
204
|
+
warn(MODULE, `Merged-PR ledger has ${storedMergedCount} of ~${knownMergedAllTime} known merges; ` +
|
|
205
|
+
'running a full backfill (watermark dropped) to recover history (#1504).');
|
|
206
|
+
}
|
|
185
207
|
// Track which non-critical sub-fetches degraded to fallbacks so the SPA
|
|
186
208
|
// can surface a "partial data" banner instead of silently showing zeros.
|
|
187
209
|
// Rate-limit / auth errors still rethrow via isRateLimitOrAuthError —
|
|
@@ -223,7 +245,7 @@ export async function fetchDashboardData(token) {
|
|
|
223
245
|
failures: [{ issueUrl: 'N/A', error: `Issue conversation fetch failed: ${msg}` }],
|
|
224
246
|
};
|
|
225
247
|
}),
|
|
226
|
-
fetchMergedPRsSince(octokit, config,
|
|
248
|
+
fetchMergedPRsSince(octokit, config, mergedFetchSince).catch(trackingCatch('fetch merged PRs for storage', [])),
|
|
227
249
|
fetchClosedPRsSince(octokit, config, closedWatermark).catch(trackingCatch('fetch closed PRs for storage', [])),
|
|
228
250
|
]);
|
|
229
251
|
const commentedIssues = fetchedIssues.issues;
|
|
@@ -258,17 +258,6 @@ export async function startDashboardServer(options) {
|
|
|
258
258
|
// signal) lives inside reloadState too — do NOT add a second
|
|
259
259
|
// maybeRecoverGist call here or recovery would double-run per poll.
|
|
260
260
|
const stateChanged = await reloadState();
|
|
261
|
-
// Gist-mode staleness (#1446 item 2): refreshFromGist() returns
|
|
262
|
-
// false on BOTH "no change" and "fetch failed", so the boolean can't
|
|
263
|
-
// signal failure. getStateStaleness() is the API built for exactly
|
|
264
|
-
// this — StateManager sets the marker when in-memory state diverged
|
|
265
|
-
// from the canonical Gist (refresh failure, invalid payload,
|
|
266
|
-
// degraded bootstrap) and clears it on a successful pull.
|
|
267
|
-
const staleness = gistSync.stateManager.getStateStaleness();
|
|
268
|
-
if (staleness !== null) {
|
|
269
|
-
res.setHeader('X-Dashboard-Stale', '1');
|
|
270
|
-
res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`state-stale: ${staleness.reason}`));
|
|
271
|
-
}
|
|
272
261
|
// Also rebuild when the vetted issue list file was edited outside this server (#924)
|
|
273
262
|
const currentIssueListMtimeMs = getIssueListMtimeMs();
|
|
274
263
|
const issueListChanged = currentIssueListMtimeMs !== cache.issueListMtimeMs;
|
|
@@ -283,14 +272,10 @@ export async function startDashboardServer(options) {
|
|
|
283
272
|
res.setHeader('X-Dashboard-Stale', '1');
|
|
284
273
|
}
|
|
285
274
|
}
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
if (cache.lastBackgroundRefreshError !== null) {
|
|
291
|
-
res.setHeader('X-Dashboard-Stale', '1');
|
|
292
|
-
res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`background-refresh-failed: ${cache.lastBackgroundRefreshError}`));
|
|
293
|
-
}
|
|
275
|
+
// Gist-divergence + background-refresh staleness (#1446 item 2, #1205)
|
|
276
|
+
// via the shared helper so this GET and the POST mutators emit the
|
|
277
|
+
// same headers (#1487). The rebuild-failure flag above is GET-only.
|
|
278
|
+
applyStalenessHeaders(res);
|
|
294
279
|
sendJson(res, 200, cache.jsonData);
|
|
295
280
|
return;
|
|
296
281
|
}
|
|
@@ -398,6 +383,36 @@ export async function startDashboardServer(options) {
|
|
|
398
383
|
changed = true;
|
|
399
384
|
return changed;
|
|
400
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* Emit the X-Dashboard-Stale / X-Dashboard-Stale-Reason headers from the
|
|
388
|
+
* two server-lived staleness sources so EVERY response — GET /api/data and
|
|
389
|
+
* the POST mutators — reflects the same truth (#1487).
|
|
390
|
+
*
|
|
391
|
+
* Before this the setters were GET-only, but the SPA's applyResult clears
|
|
392
|
+
* its `stale` flag on ANY successfully-applied response. A dashboard whose
|
|
393
|
+
* gist pulls keep failing while POST /api/refresh and /api/action succeed
|
|
394
|
+
* would flip back to "healthy" until the next GET (mount or CSRF re-prime).
|
|
395
|
+
* Carrying the headers on the POST paths keeps the flag honest.
|
|
396
|
+
*
|
|
397
|
+
* Reads the same two probes the GET path used inline: the StateManager's
|
|
398
|
+
* gist-divergence marker (getStateStaleness, #1446 item 2) — set on a
|
|
399
|
+
* refresh failure / invalid payload / degraded bootstrap and cleared on a
|
|
400
|
+
* successful pull — and the cache's last background-refresh error (#1205).
|
|
401
|
+
* When both are set the background-refresh reason wins, matching the prior
|
|
402
|
+
* GET ordering. The GET path's third, transient source — an inline rebuild
|
|
403
|
+
* failure (#994) — is specific to that handler and stays inline there.
|
|
404
|
+
*/
|
|
405
|
+
function applyStalenessHeaders(res) {
|
|
406
|
+
const staleness = gistSync.stateManager.getStateStaleness();
|
|
407
|
+
if (staleness !== null) {
|
|
408
|
+
res.setHeader('X-Dashboard-Stale', '1');
|
|
409
|
+
res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`state-stale: ${staleness.reason}`));
|
|
410
|
+
}
|
|
411
|
+
if (cache.lastBackgroundRefreshError !== null) {
|
|
412
|
+
res.setHeader('X-Dashboard-Stale', '1');
|
|
413
|
+
res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`background-refresh-failed: ${cache.lastBackgroundRefreshError}`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
401
416
|
async function handleAction(req, res) {
|
|
402
417
|
let body;
|
|
403
418
|
try {
|
|
@@ -514,6 +529,10 @@ export async function startDashboardServer(options) {
|
|
|
514
529
|
// SPA banner survives user interactions until the next successful
|
|
515
530
|
// refresh clears it.
|
|
516
531
|
cache.rebuild(gistSync.stateManager.getState());
|
|
532
|
+
// Carry the same staleness signal a GET would (#1487): a mutation that
|
|
533
|
+
// succeeds locally while the gist stays unreachable must not let the SPA
|
|
534
|
+
// clear a previously-stale flag.
|
|
535
|
+
applyStalenessHeaders(res);
|
|
517
536
|
sendJson(res, 200, cache.jsonData);
|
|
518
537
|
}
|
|
519
538
|
// ── POST /api/refresh handler ────────────────────────────────────────────
|
|
@@ -535,6 +554,14 @@ export async function startDashboardServer(options) {
|
|
|
535
554
|
const result = await fetchDashboardData(currentToken);
|
|
536
555
|
cache.adoptFetchResult(result);
|
|
537
556
|
cache.rebuild(gistSync.stateManager.getState(), result.allMergedPRs, result.allClosedPRs);
|
|
557
|
+
// This manual refresh just fetched GitHub successfully, so a prior
|
|
558
|
+
// background-refresh failure is no longer current (#1487) — clear it as
|
|
559
|
+
// the background success path does, then emit the staleness headers. A
|
|
560
|
+
// gist-divergence marker that survived a failed pull inside reloadState
|
|
561
|
+
// still surfaces via getStateStaleness, so the SPA stays honest even
|
|
562
|
+
// when the GitHub fetch succeeds but the gist remains unreachable.
|
|
563
|
+
cache.lastBackgroundRefreshError = null;
|
|
564
|
+
applyStalenessHeaders(res);
|
|
538
565
|
sendJson(res, 200, cache.jsonData);
|
|
539
566
|
}
|
|
540
567
|
catch (error) {
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The file format is one entry per line:
|
|
5
5
|
* 2026-04-15 https://github.com/owner/repo/issues/123
|
|
6
|
-
* Lines starting with `#` and blank lines are ignored.
|
|
6
|
+
* Lines starting with `#` and blank lines are ignored. A trailing inline
|
|
7
|
+
* comment is also allowed and ignored:
|
|
8
|
+
* 2026-04-15 https://github.com/owner/repo/issues/123 # reason for skipping
|
|
9
|
+
* The comment delimiter is whitespace followed by `#`, so a `#fragment` inside
|
|
10
|
+
* the URL (no preceding whitespace) is preserved, not treated as a comment.
|
|
7
11
|
*
|
|
8
12
|
* Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
|
|
9
13
|
* so the search engine filters already-skipped URLs out of results.
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The file format is one entry per line:
|
|
5
5
|
* 2026-04-15 https://github.com/owner/repo/issues/123
|
|
6
|
-
* Lines starting with `#` and blank lines are ignored.
|
|
6
|
+
* Lines starting with `#` and blank lines are ignored. A trailing inline
|
|
7
|
+
* comment is also allowed and ignored:
|
|
8
|
+
* 2026-04-15 https://github.com/owner/repo/issues/123 # reason for skipping
|
|
9
|
+
* The comment delimiter is whitespace followed by `#`, so a `#fragment` inside
|
|
10
|
+
* the URL (no preceding whitespace) is preserved, not treated as a comment.
|
|
7
11
|
*
|
|
8
12
|
* Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
|
|
9
13
|
* so the search engine filters already-skipped URLs out of results.
|
|
@@ -26,8 +30,10 @@ export function parseSkippedIssuesContent(content) {
|
|
|
26
30
|
const line = lines[i].trim();
|
|
27
31
|
if (line === '' || line.startsWith('#'))
|
|
28
32
|
continue;
|
|
29
|
-
//
|
|
30
|
-
|
|
33
|
+
// Parse "YYYY-MM-DD <url>" with an optional trailing "# comment". The
|
|
34
|
+
// comment delimiter is whitespace followed by `#`, so a `#fragment` inside
|
|
35
|
+
// the URL (no preceding whitespace) stays part of the URL token.
|
|
36
|
+
const match = line.match(/^(\S+)\s+(\S+)(?:\s+#.*)?$/);
|
|
31
37
|
if (!match) {
|
|
32
38
|
warn('skip-file-parser', `Line ${lineNumber}: malformed (expected "<date> <url>"): ${line}`);
|
|
33
39
|
continue;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Re-vets all available issues in a curated issue list file via @oss-scout/core.
|
|
4
4
|
*/
|
|
5
5
|
import { type VetListOutput, type VetOutput, type VetListItemStatus } from '../formatters/json.js';
|
|
6
|
+
import { type IssueAvailabilityVerdict } from '../core/index.js';
|
|
6
7
|
interface VetListOptions {
|
|
7
8
|
issueListPath?: string;
|
|
8
9
|
concurrency?: number;
|
|
@@ -35,12 +36,11 @@ export declare function extractSkipReason(candidate: unknown): ScoutSkipReason |
|
|
|
35
36
|
*/
|
|
36
37
|
export declare function classifyListStatus(vetResult: VetOutput, skipReason?: ScoutSkipReason): VetListItemStatus;
|
|
37
38
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* @param options - Vet-list options
|
|
43
|
-
* @returns Consolidated vetting results with list status for each issue
|
|
39
|
+
* Map a deterministic verify-issue verdict onto the vet-list status taxonomy
|
|
40
|
+
* (#1494). This is the authoritative path: the GraphQL verdict knows the
|
|
41
|
+
* closing-vs-mention distinction (#1353) that scout's substring heuristic
|
|
42
|
+
* cannot, so `at_risk` (mention only) no longer collapses into `claimed`.
|
|
44
43
|
*/
|
|
44
|
+
export declare function listStatusFromVerdict(verdict: IssueAvailabilityVerdict): VetListItemStatus;
|
|
45
45
|
export declare function runVetList(options?: VetListOptions): Promise<VetListOutput>;
|
|
46
46
|
export {};
|
|
@@ -7,7 +7,8 @@ import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout } from
|
|
|
7
7
|
import { runParseList, pruneIssueList } from './parse-list.js';
|
|
8
8
|
import { detectIssueList } from './startup.js';
|
|
9
9
|
import { computeSuccessGrade, gradeFromCandidate } from '../core/issue-grading.js';
|
|
10
|
-
import {
|
|
10
|
+
import { ValidationError } from '../core/errors.js';
|
|
11
|
+
import { getStateManager, classifyLinkedPR, getOctokit, requireGitHubToken, parseGitHubUrl, verifyIssuesBatch, } from '../core/index.js';
|
|
11
12
|
const UNKNOWN_GRADE = computeSuccessGrade({ avgResponseDays: null, mergeRate: null, daysSinceLastCommit: null });
|
|
12
13
|
const KNOWN_SKIP_REASONS = new Set([
|
|
13
14
|
'issue_closed',
|
|
@@ -93,13 +94,64 @@ export function classifyListStatus(vetResult, skipReason) {
|
|
|
93
94
|
return 'still_available';
|
|
94
95
|
}
|
|
95
96
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
97
|
+
* Map a deterministic verify-issue verdict onto the vet-list status taxonomy
|
|
98
|
+
* (#1494). This is the authoritative path: the GraphQL verdict knows the
|
|
99
|
+
* closing-vs-mention distinction (#1353) that scout's substring heuristic
|
|
100
|
+
* cannot, so `at_risk` (mention only) no longer collapses into `claimed`.
|
|
101
|
+
*/
|
|
102
|
+
export function listStatusFromVerdict(verdict) {
|
|
103
|
+
switch (verdict) {
|
|
104
|
+
case 'closed': {
|
|
105
|
+
return 'closed';
|
|
106
|
+
}
|
|
107
|
+
case 'own-open-pr': {
|
|
108
|
+
return 'own_open_pr';
|
|
109
|
+
}
|
|
110
|
+
case 'taken': {
|
|
111
|
+
return 'claimed';
|
|
112
|
+
}
|
|
113
|
+
case 'at-risk': {
|
|
114
|
+
return 'at_risk';
|
|
115
|
+
}
|
|
116
|
+
case 'available': {
|
|
117
|
+
return 'still_available';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Verdicts that conclusively rule an issue out as a new opportunity. For these
|
|
123
|
+
* we skip scout's grade/health vet entirely — there is nothing to grade — and
|
|
124
|
+
* build the result row straight from the verification.
|
|
102
125
|
*/
|
|
126
|
+
const RULED_OUT_VERDICTS = new Set(['closed', 'taken', 'own-open-pr']);
|
|
127
|
+
/** The per-row `verification` sub-object: the authoritative facts behind the
|
|
128
|
+
* status, projected from the full {@link IssueVerification}. */
|
|
129
|
+
function toVerificationSummary(v) {
|
|
130
|
+
return {
|
|
131
|
+
verdict: v.verdict,
|
|
132
|
+
verdictReason: v.verdictReason,
|
|
133
|
+
state: v.state,
|
|
134
|
+
stateReason: v.stateReason,
|
|
135
|
+
assignees: v.assignees,
|
|
136
|
+
linkedPRs: v.linkedPRs,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Minimal VetOutput for a ruled-out issue (no scout vet was run). The
|
|
140
|
+
* verification carries the real reason; scout-derived fields are left empty. */
|
|
141
|
+
function ruledOutVetOutput(v) {
|
|
142
|
+
return {
|
|
143
|
+
issue: { repo: `${v.owner}/${v.repo}`, number: v.number, title: v.title, url: v.url, labels: [] },
|
|
144
|
+
recommendation: 'skip',
|
|
145
|
+
reasonsToApprove: [],
|
|
146
|
+
reasonsToSkip: [v.verdictReason],
|
|
147
|
+
projectHealth: {},
|
|
148
|
+
vettingResult: {},
|
|
149
|
+
grade: UNKNOWN_GRADE,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function toMessage(error) {
|
|
153
|
+
return error instanceof Error ? error.message : String(error);
|
|
154
|
+
}
|
|
103
155
|
export async function runVetList(options = {}) {
|
|
104
156
|
const concurrency = options.concurrency ?? 5;
|
|
105
157
|
// 1. Find and parse the issue list
|
|
@@ -115,75 +167,146 @@ export async function runVetList(options = {}) {
|
|
|
115
167
|
if (parsed.available.length === 0) {
|
|
116
168
|
return {
|
|
117
169
|
results: [],
|
|
118
|
-
summary: {
|
|
170
|
+
summary: {
|
|
171
|
+
total: 0,
|
|
172
|
+
stillAvailable: 0,
|
|
173
|
+
claimed: 0,
|
|
174
|
+
closed: 0,
|
|
175
|
+
hasPR: 0,
|
|
176
|
+
hasStalledPR: 0,
|
|
177
|
+
atRisk: 0,
|
|
178
|
+
ownOpenPr: 0,
|
|
179
|
+
errors: 0,
|
|
180
|
+
},
|
|
119
181
|
};
|
|
120
182
|
}
|
|
121
|
-
// 2. Vet each available issue in parallel with concurrency limit
|
|
122
|
-
const scout = await createAutopilotScout();
|
|
123
|
-
const results = [];
|
|
124
|
-
// Simple concurrency limiter
|
|
125
183
|
const items = parsed.available;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
// 2. Verify FIRST (#1494): one deterministic GraphQL check per entry, in a
|
|
185
|
+
// bounded batch. The verdict is closing-vs-mention aware, so it carries
|
|
186
|
+
// the authoritative status — and ruling issues out here means we skip
|
|
187
|
+
// scout's search-driven vet (a net REDUCTION in /search/issues calls).
|
|
188
|
+
const octokit = getOctokit(requireGitHubToken());
|
|
189
|
+
const verifyTargets = [];
|
|
190
|
+
items.forEach((item, i) => {
|
|
191
|
+
const ghUrl = parseGitHubUrl(item.url);
|
|
192
|
+
if (ghUrl && ghUrl.type === 'issues') {
|
|
193
|
+
verifyTargets.push({ index: i, params: { owner: ghUrl.owner, repo: ghUrl.repo, number: ghUrl.number } });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
const batch = await verifyIssuesBatch(octokit, verifyTargets.map((t) => t.params), { concurrency });
|
|
197
|
+
const verificationByIndex = new Map();
|
|
198
|
+
verifyTargets.forEach((t, k) => verificationByIndex.set(t.index, batch[k]));
|
|
199
|
+
// 3. Vet only the issues still in play; build each row.
|
|
200
|
+
const scout = await createAutopilotScout();
|
|
201
|
+
/** Run scout's grade/health vet for an issue (the search-driven path). */
|
|
202
|
+
async function runScoutVet(item) {
|
|
203
|
+
const candidate = await scout.vetIssue(item.url);
|
|
204
|
+
const grade = gradeFromCandidate({
|
|
205
|
+
repo: candidate.issue.repo,
|
|
206
|
+
projectHealth: candidate.projectHealth,
|
|
207
|
+
getRepoScore: (repo) => getStateManager().getRepoScore(repo),
|
|
208
|
+
});
|
|
209
|
+
const userLogin = getStateManager().getState().config.githubUsername;
|
|
210
|
+
const linkedPRClassification = classifyLinkedPR({
|
|
211
|
+
linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
|
|
212
|
+
userLogin,
|
|
213
|
+
});
|
|
214
|
+
const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
|
|
215
|
+
const vetResult = {
|
|
216
|
+
issue: {
|
|
217
|
+
repo: candidate.issue.repo,
|
|
218
|
+
number: candidate.issue.number,
|
|
219
|
+
title: candidate.issue.title,
|
|
220
|
+
url: candidate.issue.url,
|
|
221
|
+
labels: candidate.issue.labels,
|
|
222
|
+
},
|
|
223
|
+
recommendation: candidate.recommendation,
|
|
224
|
+
reasonsToApprove: candidate.reasonsToApprove,
|
|
225
|
+
reasonsToSkip: candidate.reasonsToSkip,
|
|
226
|
+
projectHealth: candidate.projectHealth,
|
|
227
|
+
vettingResult: candidate.vettingResult,
|
|
228
|
+
antiLLMPolicy: candidate.antiLLMPolicy,
|
|
229
|
+
linkedPRClassification,
|
|
230
|
+
...(linkedPR ? { linkedPR } : {}),
|
|
231
|
+
slmTriage: candidate.slmTriage ?? null,
|
|
232
|
+
grade,
|
|
233
|
+
};
|
|
234
|
+
return { vetResult, skipReason: extractSkipReason(candidate) };
|
|
235
|
+
}
|
|
236
|
+
function errorRow(item, error) {
|
|
237
|
+
const message = toMessage(error);
|
|
238
|
+
return {
|
|
239
|
+
issue: { repo: item.repo, number: item.number, title: item.title, url: item.url, labels: [] },
|
|
240
|
+
recommendation: 'skip',
|
|
241
|
+
reasonsToApprove: [],
|
|
242
|
+
reasonsToSkip: [`Error: ${message}`],
|
|
243
|
+
projectHealth: {},
|
|
244
|
+
vettingResult: {},
|
|
245
|
+
grade: UNKNOWN_GRADE,
|
|
246
|
+
listStatus: 'error',
|
|
247
|
+
errorMessage: message,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async function processItem(item, verification) {
|
|
251
|
+
// No authoritative verdict for this row: either verify errored, or the URL
|
|
252
|
+
// was never enrolled (parse-list also accepts PR URLs, which `verify-issue`
|
|
253
|
+
// can't classify). Either way `listStatus` falls back to scout's heuristic,
|
|
254
|
+
// so we ALWAYS tag the row with `verifyError` — the explicit signal that the
|
|
255
|
+
// status is not authoritative and must be re-verified before acting.
|
|
256
|
+
if (!verification || verification.verification === undefined) {
|
|
257
|
+
const verifyError = verification?.error;
|
|
258
|
+
// A ValidationError means the issue definitively doesn't exist (deleted,
|
|
259
|
+
// private, or the URL points at a PR). Don't re-grade it via scout — that
|
|
260
|
+
// could mislabel a dead issue as available. Report it as an error row.
|
|
261
|
+
if (verifyError instanceof ValidationError) {
|
|
262
|
+
return { ...errorRow(item, verifyError), verifyError: toMessage(verifyError) };
|
|
263
|
+
}
|
|
264
|
+
// Transient verify failure (rate limit, network, truncated timeline), or a
|
|
265
|
+
// non-issue URL that was never sent to verify. Fall back to scout's
|
|
266
|
+
// heuristic, tagged so the row is re-verified later.
|
|
267
|
+
const verifyErrorTag = {
|
|
268
|
+
verifyError: verifyError === undefined
|
|
269
|
+
? `not verified: ${item.url} is not a verifiable issue URL`
|
|
270
|
+
: toMessage(verifyError),
|
|
271
|
+
};
|
|
130
272
|
try {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
repo: candidate.issue.repo,
|
|
134
|
-
projectHealth: candidate.projectHealth,
|
|
135
|
-
getRepoScore: (repo) => getStateManager().getRepoScore(repo),
|
|
136
|
-
});
|
|
137
|
-
const userLogin = getStateManager().getState().config.githubUsername;
|
|
138
|
-
const linkedPRClassification = classifyLinkedPR({
|
|
139
|
-
linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
|
|
140
|
-
userLogin,
|
|
141
|
-
});
|
|
142
|
-
const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
|
|
143
|
-
const vetResult = {
|
|
144
|
-
issue: {
|
|
145
|
-
repo: candidate.issue.repo,
|
|
146
|
-
number: candidate.issue.number,
|
|
147
|
-
title: candidate.issue.title,
|
|
148
|
-
url: candidate.issue.url,
|
|
149
|
-
labels: candidate.issue.labels,
|
|
150
|
-
},
|
|
151
|
-
recommendation: candidate.recommendation,
|
|
152
|
-
reasonsToApprove: candidate.reasonsToApprove,
|
|
153
|
-
reasonsToSkip: candidate.reasonsToSkip,
|
|
154
|
-
projectHealth: candidate.projectHealth,
|
|
155
|
-
vettingResult: candidate.vettingResult,
|
|
156
|
-
antiLLMPolicy: candidate.antiLLMPolicy,
|
|
157
|
-
linkedPRClassification,
|
|
158
|
-
...(linkedPR ? { linkedPR } : {}),
|
|
159
|
-
slmTriage: candidate.slmTriage ?? null,
|
|
160
|
-
grade,
|
|
161
|
-
};
|
|
162
|
-
results.push({
|
|
163
|
-
...vetResult,
|
|
164
|
-
listStatus: classifyListStatus(vetResult, extractSkipReason(candidate)),
|
|
165
|
-
});
|
|
273
|
+
const { vetResult, skipReason } = await runScoutVet(item);
|
|
274
|
+
return { ...vetResult, listStatus: classifyListStatus(vetResult, skipReason), ...verifyErrorTag };
|
|
166
275
|
}
|
|
167
276
|
catch (error) {
|
|
168
|
-
|
|
169
|
-
results.push({
|
|
170
|
-
issue: { repo: item.repo, number: item.number, title: item.title, url: item.url, labels: [] },
|
|
171
|
-
recommendation: 'skip',
|
|
172
|
-
reasonsToApprove: [],
|
|
173
|
-
reasonsToSkip: [`Error: ${error instanceof Error ? error.message : String(error)}`],
|
|
174
|
-
projectHealth: {},
|
|
175
|
-
vettingResult: {},
|
|
176
|
-
grade: UNKNOWN_GRADE,
|
|
177
|
-
listStatus: 'error',
|
|
178
|
-
errorMessage: error instanceof Error ? error.message : String(error),
|
|
179
|
-
});
|
|
277
|
+
return { ...errorRow(item, error), ...verifyErrorTag };
|
|
180
278
|
}
|
|
181
279
|
}
|
|
280
|
+
const v = verification.verification;
|
|
281
|
+
const summary = toVerificationSummary(v);
|
|
282
|
+
const listStatus = listStatusFromVerdict(v.verdict);
|
|
283
|
+
// Conclusively ruled out — skip the scout vet entirely.
|
|
284
|
+
if (RULED_OUT_VERDICTS.has(v.verdict)) {
|
|
285
|
+
return { ...ruledOutVetOutput(v), listStatus, verification: summary };
|
|
286
|
+
}
|
|
287
|
+
// available / at-risk: still worth grading. The verdict stays authoritative
|
|
288
|
+
// for listStatus; scout only supplies grade/health/recommendation.
|
|
289
|
+
try {
|
|
290
|
+
const { vetResult } = await runScoutVet(item);
|
|
291
|
+
return { ...vetResult, listStatus, verification: summary };
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
// Verify succeeded, so keep its verdict facts even though grading failed.
|
|
295
|
+
return { ...errorRow(item, error), verification: summary };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const results = new Array(items.length);
|
|
299
|
+
let index = 0;
|
|
300
|
+
async function processNext() {
|
|
301
|
+
while (index < items.length) {
|
|
302
|
+
const i = index++;
|
|
303
|
+
results[i] = await processItem(items[i], verificationByIndex.get(i));
|
|
304
|
+
}
|
|
182
305
|
}
|
|
183
|
-
// Start `concurrency` workers
|
|
306
|
+
// Start `concurrency` workers (bounds the scout-vet fan-out).
|
|
184
307
|
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => processNext());
|
|
185
308
|
await Promise.all(workers);
|
|
186
|
-
//
|
|
309
|
+
// 4. Compute summary
|
|
187
310
|
const summary = {
|
|
188
311
|
total: results.length,
|
|
189
312
|
stillAvailable: results.filter((r) => r.listStatus === 'still_available').length,
|
|
@@ -191,6 +314,8 @@ export async function runVetList(options = {}) {
|
|
|
191
314
|
closed: results.filter((r) => r.listStatus === 'closed').length,
|
|
192
315
|
hasPR: results.filter((r) => r.listStatus === 'has_pr').length,
|
|
193
316
|
hasStalledPR: results.filter((r) => r.listStatus === 'has_stalled_pr').length,
|
|
317
|
+
atRisk: results.filter((r) => r.listStatus === 'at_risk').length,
|
|
318
|
+
ownOpenPr: results.filter((r) => r.listStatus === 'own_open_pr').length,
|
|
194
319
|
errors: results.filter((r) => r.listStatus === 'error').length,
|
|
195
320
|
};
|
|
196
321
|
// 4. Prune the file if requested — remove completed/skipped/low-score items
|
package/dist/core/index.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsBy
|
|
|
23
23
|
export { computeContributionStats, type ContributionStats, type ComputeStatsInput } from './stats.js';
|
|
24
24
|
export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
|
|
25
25
|
export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
|
|
26
|
-
export { classifyIssueAvailability, fetchIssueVerification, type IssueAvailabilityVerdict, type IssueVerification, type LinkedPRLinkType, type VerifiedLinkedPR, type VerifyIssueParams, } from './issue-verification.js';
|
|
26
|
+
export { classifyIssueAvailability, fetchIssueVerification, verifyIssuesBatch, MAX_VERIFY_CONCURRENCY, type BatchVerificationResult, type IssueAvailabilityVerdict, type IssueVerification, type LinkedPRLinkType, type VerifiedLinkedPR, type VerifyIssueParams, } from './issue-verification.js';
|
|
27
27
|
export { classifyAttentionBucket, summarizeAttentionBuckets, STUCK_CI_THRESHOLD_DAYS, DORMANT_FOLLOWUP_THRESHOLD_DAYS, type AttentionBucket, type AttentionInput, type AttentionSummary, } from './pr-attention.js';
|
|
28
28
|
export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type AntiLLMScanResult, } from './anti-llm-policy.js';
|
|
29
29
|
export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
|
package/dist/core/index.js
CHANGED
|
@@ -24,7 +24,7 @@ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsBy
|
|
|
24
24
|
export { computeContributionStats } from './stats.js';
|
|
25
25
|
export { fetchPRTemplate } from './pr-template.js';
|
|
26
26
|
export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, } from './linked-pr-classification.js';
|
|
27
|
-
export { classifyIssueAvailability, fetchIssueVerification, } from './issue-verification.js';
|
|
27
|
+
export { classifyIssueAvailability, fetchIssueVerification, verifyIssuesBatch, MAX_VERIFY_CONCURRENCY, } from './issue-verification.js';
|
|
28
28
|
export { classifyAttentionBucket, summarizeAttentionBuckets, STUCK_CI_THRESHOLD_DAYS, DORMANT_FOLLOWUP_THRESHOLD_DAYS, } from './pr-attention.js';
|
|
29
29
|
export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
|
|
30
30
|
export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
|
|
@@ -89,3 +89,42 @@ export interface VerifyIssueParams {
|
|
|
89
89
|
number: number;
|
|
90
90
|
}
|
|
91
91
|
export declare function fetchIssueVerification(octokit: Octokit, params: VerifyIssueParams): Promise<IssueVerification>;
|
|
92
|
+
/**
|
|
93
|
+
* Hard ceiling on concurrent in-flight verifications. Each issue runs its own
|
|
94
|
+
* GraphQL round-trip (point-heavy, with timeline pagination), so we never run
|
|
95
|
+
* more than this many at once regardless of what the caller requests. The
|
|
96
|
+
* single-issue path already leans on ThrottledOctokit's secondary-rate-limit
|
|
97
|
+
* backoff — this cap keeps us from spraying enough parallel queries to trip it.
|
|
98
|
+
*/
|
|
99
|
+
export declare const MAX_VERIFY_CONCURRENCY = 5;
|
|
100
|
+
/**
|
|
101
|
+
* Per-item outcome of a batched verification run. Aligned by index with the
|
|
102
|
+
* input `items`. Error isolation: one item's failure lands as `{ error }` and
|
|
103
|
+
* never aborts the batch, so a single NOT_FOUND (or transient network error)
|
|
104
|
+
* does not poison every other entry — unlike an aliased mega-query would.
|
|
105
|
+
*/
|
|
106
|
+
export interface BatchVerificationResult {
|
|
107
|
+
params: VerifyIssueParams;
|
|
108
|
+
/** Present when verification succeeded for this item. */
|
|
109
|
+
verification?: IssueVerification;
|
|
110
|
+
/** Present when `fetchIssueVerification` threw for this item. */
|
|
111
|
+
error?: unknown;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Verify many issues with bounded concurrent fan-out of the single-issue
|
|
115
|
+
* {@link fetchIssueVerification}.
|
|
116
|
+
*
|
|
117
|
+
* This is a deliberately thin worker pool, NOT one aliased GraphQL query:
|
|
118
|
+
* per-issue timeline cursors make aliasing unmanageable, and all-or-nothing
|
|
119
|
+
* failure (one bad issue poisoning the batch) is exactly what we want to
|
|
120
|
+
* avoid. No retry layer is added here — `octokit` is expected to be a
|
|
121
|
+
* ThrottledOctokit whose backoff already covers secondary rate limits.
|
|
122
|
+
*
|
|
123
|
+
* @param octokit - Throttled Octokit instance (see `getOctokit`).
|
|
124
|
+
* @param items - Issues to verify; results align with this order.
|
|
125
|
+
* @param options.concurrency - Desired parallelism; capped at
|
|
126
|
+
* {@link MAX_VERIFY_CONCURRENCY} and floored at 1.
|
|
127
|
+
*/
|
|
128
|
+
export declare function verifyIssuesBatch(octokit: Octokit, items: readonly VerifyIssueParams[], options?: {
|
|
129
|
+
concurrency?: number;
|
|
130
|
+
}): Promise<BatchVerificationResult[]>;
|
|
@@ -268,3 +268,51 @@ export async function fetchIssueVerification(octokit, params) {
|
|
|
268
268
|
userLogin,
|
|
269
269
|
};
|
|
270
270
|
}
|
|
271
|
+
// ── Batched verification ───────────────────────────────────────────────
|
|
272
|
+
/**
|
|
273
|
+
* Hard ceiling on concurrent in-flight verifications. Each issue runs its own
|
|
274
|
+
* GraphQL round-trip (point-heavy, with timeline pagination), so we never run
|
|
275
|
+
* more than this many at once regardless of what the caller requests. The
|
|
276
|
+
* single-issue path already leans on ThrottledOctokit's secondary-rate-limit
|
|
277
|
+
* backoff — this cap keeps us from spraying enough parallel queries to trip it.
|
|
278
|
+
*/
|
|
279
|
+
export const MAX_VERIFY_CONCURRENCY = 5;
|
|
280
|
+
/**
|
|
281
|
+
* Verify many issues with bounded concurrent fan-out of the single-issue
|
|
282
|
+
* {@link fetchIssueVerification}.
|
|
283
|
+
*
|
|
284
|
+
* This is a deliberately thin worker pool, NOT one aliased GraphQL query:
|
|
285
|
+
* per-issue timeline cursors make aliasing unmanageable, and all-or-nothing
|
|
286
|
+
* failure (one bad issue poisoning the batch) is exactly what we want to
|
|
287
|
+
* avoid. No retry layer is added here — `octokit` is expected to be a
|
|
288
|
+
* ThrottledOctokit whose backoff already covers secondary rate limits.
|
|
289
|
+
*
|
|
290
|
+
* @param octokit - Throttled Octokit instance (see `getOctokit`).
|
|
291
|
+
* @param items - Issues to verify; results align with this order.
|
|
292
|
+
* @param options.concurrency - Desired parallelism; capped at
|
|
293
|
+
* {@link MAX_VERIFY_CONCURRENCY} and floored at 1.
|
|
294
|
+
*/
|
|
295
|
+
export async function verifyIssuesBatch(octokit, items, options = {}) {
|
|
296
|
+
const results = new Array(items.length);
|
|
297
|
+
if (items.length === 0)
|
|
298
|
+
return results;
|
|
299
|
+
const requested = options.concurrency ?? MAX_VERIFY_CONCURRENCY;
|
|
300
|
+
// Floor at 1 so a 0/negative/NaN request can never produce zero workers
|
|
301
|
+
// (which would leave the results array full of holes).
|
|
302
|
+
const concurrency = Math.max(1, Math.min(Number.isFinite(requested) ? requested : MAX_VERIFY_CONCURRENCY, MAX_VERIFY_CONCURRENCY, items.length));
|
|
303
|
+
let index = 0;
|
|
304
|
+
async function worker() {
|
|
305
|
+
while (index < items.length) {
|
|
306
|
+
const i = index++;
|
|
307
|
+
const params = items[i];
|
|
308
|
+
try {
|
|
309
|
+
results[i] = { params, verification: await fetchIssueVerification(octokit, params) };
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
results[i] = { params, error };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
317
|
+
return results;
|
|
318
|
+
}
|