@oss-autopilot/core 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-registry.js +64 -2
- package/dist/cli.bundle.cjs +60 -58
- package/dist/commands/vet-list.d.ts +24 -0
- package/dist/commands/vet-list.js +112 -0
- package/dist/core/daily-logic.js +16 -2
- package/dist/core/github.d.ts +1 -1
- package/dist/core/github.js +3 -3
- package/dist/core/issue-discovery.d.ts +7 -7
- package/dist/core/issue-discovery.js +53 -20
- package/dist/core/issue-vetting.d.ts +1 -0
- package/dist/core/issue-vetting.js +21 -5
- package/dist/core/search-phases.js +11 -7
- package/dist/core/utils.d.ts +2 -0
- package/dist/core/utils.js +6 -2
- package/dist/formatters/json.d.ts +57 -0
- package/dist/formatters/json.js +29 -0
- package/package.json +5 -5
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vet-list command (#764)
|
|
3
|
+
* Re-vets all available issues in a curated issue list file.
|
|
4
|
+
*/
|
|
5
|
+
import { type VetListOutput, type VetOutput, type VetListItemStatus } from '../formatters/json.js';
|
|
6
|
+
interface VetListOptions {
|
|
7
|
+
issueListPath?: string;
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Determine the list status from vetting results.
|
|
12
|
+
* Maps vetting recommendation + reasons to a list-level status.
|
|
13
|
+
*/
|
|
14
|
+
export declare function classifyListStatus(vetResult: VetOutput): VetListItemStatus;
|
|
15
|
+
/**
|
|
16
|
+
* Re-vet all available issues in a curated issue list.
|
|
17
|
+
* Reads the list file, extracts available (non-done) issues,
|
|
18
|
+
* and vets each in parallel with concurrency control.
|
|
19
|
+
*
|
|
20
|
+
* @param options - Vet-list options
|
|
21
|
+
* @returns Consolidated vetting results with list status for each issue
|
|
22
|
+
*/
|
|
23
|
+
export declare function runVetList(options?: VetListOptions): Promise<VetListOutput>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vet-list command (#764)
|
|
3
|
+
* Re-vets all available issues in a curated issue list file.
|
|
4
|
+
*/
|
|
5
|
+
import { IssueDiscovery, requireGitHubToken } from '../core/index.js';
|
|
6
|
+
import { detectIssueList } from './startup.js';
|
|
7
|
+
/**
|
|
8
|
+
* Determine the list status from vetting results.
|
|
9
|
+
* Maps vetting recommendation + reasons to a list-level status.
|
|
10
|
+
*/
|
|
11
|
+
export function classifyListStatus(vetResult) {
|
|
12
|
+
const skipReasons = vetResult.reasonsToSkip.map((r) => r.toLowerCase());
|
|
13
|
+
if (skipReasons.some((r) => r.includes('closed')))
|
|
14
|
+
return 'closed';
|
|
15
|
+
if (skipReasons.some((r) => r.includes('claimed') || r.includes('assigned')))
|
|
16
|
+
return 'claimed';
|
|
17
|
+
if (skipReasons.some((r) => r.includes('existing pr') || r.includes('linked pr') || r.includes('pull request')))
|
|
18
|
+
return 'has_pr';
|
|
19
|
+
if (vetResult.recommendation === 'approve' || vetResult.recommendation === 'needs_review') {
|
|
20
|
+
return 'still_available';
|
|
21
|
+
}
|
|
22
|
+
// Default: if skipped for other reasons, still mark as available
|
|
23
|
+
// (the vetting result will show why it's not recommended)
|
|
24
|
+
return 'still_available';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Re-vet all available issues in a curated issue list.
|
|
28
|
+
* Reads the list file, extracts available (non-done) issues,
|
|
29
|
+
* and vets each in parallel with concurrency control.
|
|
30
|
+
*
|
|
31
|
+
* @param options - Vet-list options
|
|
32
|
+
* @returns Consolidated vetting results with list status for each issue
|
|
33
|
+
*/
|
|
34
|
+
export async function runVetList(options = {}) {
|
|
35
|
+
const token = requireGitHubToken();
|
|
36
|
+
const concurrency = options.concurrency ?? 5;
|
|
37
|
+
// 1. Find and parse the issue list
|
|
38
|
+
let issueListPath = options.issueListPath;
|
|
39
|
+
if (!issueListPath) {
|
|
40
|
+
const detected = detectIssueList();
|
|
41
|
+
if (!detected) {
|
|
42
|
+
throw new Error('No issue list found. Provide a path with --path or configure issueListPath in settings.');
|
|
43
|
+
}
|
|
44
|
+
issueListPath = detected.path;
|
|
45
|
+
}
|
|
46
|
+
const { runParseList } = await import('./parse-list.js');
|
|
47
|
+
const parsed = await runParseList({ filePath: issueListPath });
|
|
48
|
+
if (parsed.available.length === 0) {
|
|
49
|
+
return {
|
|
50
|
+
results: [],
|
|
51
|
+
summary: { total: 0, stillAvailable: 0, claimed: 0, closed: 0, hasPR: 0, errors: 0 },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// 2. Vet each available issue in parallel with concurrency limit
|
|
55
|
+
const discovery = new IssueDiscovery(token);
|
|
56
|
+
const results = [];
|
|
57
|
+
// Simple concurrency limiter
|
|
58
|
+
const items = parsed.available;
|
|
59
|
+
let index = 0;
|
|
60
|
+
async function processNext() {
|
|
61
|
+
while (index < items.length) {
|
|
62
|
+
const item = items[index++];
|
|
63
|
+
try {
|
|
64
|
+
const candidate = await discovery.vetIssue(item.url);
|
|
65
|
+
const vetResult = {
|
|
66
|
+
issue: {
|
|
67
|
+
repo: candidate.issue.repo,
|
|
68
|
+
number: candidate.issue.number,
|
|
69
|
+
title: candidate.issue.title,
|
|
70
|
+
url: candidate.issue.url,
|
|
71
|
+
labels: candidate.issue.labels,
|
|
72
|
+
},
|
|
73
|
+
recommendation: candidate.recommendation,
|
|
74
|
+
reasonsToApprove: candidate.reasonsToApprove,
|
|
75
|
+
reasonsToSkip: candidate.reasonsToSkip,
|
|
76
|
+
projectHealth: candidate.projectHealth,
|
|
77
|
+
vettingResult: candidate.vettingResult,
|
|
78
|
+
};
|
|
79
|
+
results.push({
|
|
80
|
+
...vetResult,
|
|
81
|
+
listStatus: classifyListStatus(vetResult),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// Per-issue errors don't fail the batch
|
|
86
|
+
results.push({
|
|
87
|
+
issue: { repo: item.repo, number: item.number, title: item.title, url: item.url, labels: [] },
|
|
88
|
+
recommendation: 'skip',
|
|
89
|
+
reasonsToApprove: [],
|
|
90
|
+
reasonsToSkip: [`Error: ${error instanceof Error ? error.message : String(error)}`],
|
|
91
|
+
projectHealth: {},
|
|
92
|
+
vettingResult: {},
|
|
93
|
+
listStatus: 'error',
|
|
94
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Start `concurrency` workers
|
|
100
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => processNext());
|
|
101
|
+
await Promise.all(workers);
|
|
102
|
+
// 3. Compute summary
|
|
103
|
+
const summary = {
|
|
104
|
+
total: results.length,
|
|
105
|
+
stillAvailable: results.filter((r) => r.listStatus === 'still_available').length,
|
|
106
|
+
claimed: results.filter((r) => r.listStatus === 'claimed').length,
|
|
107
|
+
closed: results.filter((r) => r.listStatus === 'closed').length,
|
|
108
|
+
hasPR: results.filter((r) => r.listStatus === 'has_pr').length,
|
|
109
|
+
errors: results.filter((r) => r.listStatus === 'error').length,
|
|
110
|
+
};
|
|
111
|
+
return { results, summary };
|
|
112
|
+
}
|
package/dist/core/daily-logic.js
CHANGED
|
@@ -333,11 +333,25 @@ export function computeActionMenu(actionableIssues, capacity, commentedIssues =
|
|
|
333
333
|
}
|
|
334
334
|
// The orchestration layer (commands/oss.md Action Menu section) may insert issue-list
|
|
335
335
|
// options before the search item when a curated list is available.
|
|
336
|
-
|
|
336
|
+
const searchItem = {
|
|
337
337
|
key: 'search',
|
|
338
338
|
label: 'Search for new issues',
|
|
339
339
|
description: 'Look for new contribution opportunities',
|
|
340
|
-
}
|
|
340
|
+
};
|
|
341
|
+
if (!capacity.hasCapacity) {
|
|
342
|
+
const atLimit = capacity.activePRCount >= capacity.maxActivePRs;
|
|
343
|
+
const hasCritical = capacity.criticalIssueCount > 0;
|
|
344
|
+
if (atLimit && hasCritical) {
|
|
345
|
+
searchItem.capacityWarning = `You're at ${capacity.activePRCount}/${capacity.maxActivePRs} active PRs and have ${capacity.criticalIssueCount} critical issue(s). Resolve existing work before claiming new issues.`;
|
|
346
|
+
}
|
|
347
|
+
else if (atLimit) {
|
|
348
|
+
searchItem.capacityWarning = `You're at ${capacity.activePRCount}/${capacity.maxActivePRs} active PRs. Claiming a new issue will exceed your limit.`;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
searchItem.capacityWarning = `You have ${capacity.criticalIssueCount} critical issue(s) needing attention. Resolve them before claiming new issues.`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
items.push(searchItem);
|
|
341
355
|
items.push({
|
|
342
356
|
key: 'done',
|
|
343
357
|
label: 'Done for now',
|
package/dist/core/github.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export declare function getRateLimitCallbacks(): {
|
|
|
24
24
|
* Returns a cached instance if the token matches, otherwise creates a new one.
|
|
25
25
|
*
|
|
26
26
|
* The client retries on primary rate limits (up to 2 retries) and
|
|
27
|
-
* secondary rate limits (
|
|
27
|
+
* secondary rate limits (up to 3 retries).
|
|
28
28
|
*
|
|
29
29
|
* @param token - GitHub personal access token
|
|
30
30
|
* @returns Authenticated Octokit instance with rate limit throttling
|
package/dist/core/github.js
CHANGED
|
@@ -31,8 +31,8 @@ export function getRateLimitCallbacks() {
|
|
|
31
31
|
onSecondaryRateLimit: (retryAfter, options, _octokit, retryCount) => {
|
|
32
32
|
const opts = options;
|
|
33
33
|
const resetAt = new Date(Date.now() + retryAfter * 1000);
|
|
34
|
-
if (retryCount <
|
|
35
|
-
warn(MODULE, `Secondary rate limit hit (retry ${retryCount + 1}/
|
|
34
|
+
if (retryCount < 3) {
|
|
35
|
+
warn(MODULE, `Secondary rate limit hit (retry ${retryCount + 1}/3, waiting ${retryAfter}s, resets at ${formatResetTime(resetAt)}) — ${opts.method} ${opts.url}`);
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
38
|
warn(MODULE, `Secondary rate limit exceeded, not retrying — ${opts.method} ${opts.url} (resets at ${formatResetTime(resetAt)})`);
|
|
@@ -45,7 +45,7 @@ export function getRateLimitCallbacks() {
|
|
|
45
45
|
* Returns a cached instance if the token matches, otherwise creates a new one.
|
|
46
46
|
*
|
|
47
47
|
* The client retries on primary rate limits (up to 2 retries) and
|
|
48
|
-
* secondary rate limits (
|
|
48
|
+
* secondary rate limits (up to 3 retries).
|
|
49
49
|
*
|
|
50
50
|
* @param token - GitHub personal access token
|
|
51
51
|
* @returns Authenticated Octokit instance with rate limit throttling
|
|
@@ -14,11 +14,11 @@ import { type IssueCandidate } from './types.js';
|
|
|
14
14
|
* Multi-phase issue discovery engine that searches GitHub for contributable issues.
|
|
15
15
|
*
|
|
16
16
|
* Search phases (in priority order):
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* 0. Repos where user has merged PRs (highest merge probability)
|
|
18
|
+
* 0.5. Preferred organizations
|
|
19
|
+
* 1. Starred repos
|
|
20
|
+
* 2. General label-filtered search
|
|
21
|
+
* 3. Actively maintained repos
|
|
22
22
|
*
|
|
23
23
|
* Each candidate is vetted for claimability and scored 0-100 for viability.
|
|
24
24
|
*/
|
|
@@ -44,8 +44,8 @@ export declare class IssueDiscovery {
|
|
|
44
44
|
getStarredReposWithRefresh(): Promise<string[]>;
|
|
45
45
|
/**
|
|
46
46
|
* Search for issues matching our criteria.
|
|
47
|
-
* Searches in priority order: merged-PR repos first (no label filter), then
|
|
48
|
-
* then general search, then actively maintained repos.
|
|
47
|
+
* Searches in priority order: merged-PR repos first (no label filter), then preferred
|
|
48
|
+
* organizations, then starred repos, then general search, then actively maintained repos.
|
|
49
49
|
* Filters out issues from low-scoring and excluded repos.
|
|
50
50
|
*
|
|
51
51
|
* @param options - Search configuration
|
|
@@ -13,7 +13,7 @@ import * as fs from 'fs';
|
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import { getOctokit, checkRateLimit } from './github.js';
|
|
15
15
|
import { getStateManager } from './state.js';
|
|
16
|
-
import { daysBetween, getDataDir } from './utils.js';
|
|
16
|
+
import { daysBetween, getDataDir, sleep } from './utils.js';
|
|
17
17
|
import { DEFAULT_CONFIG, SCOPE_LABELS } from './types.js';
|
|
18
18
|
import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError } from './errors.js';
|
|
19
19
|
import { debug, info, warn } from './logger.js';
|
|
@@ -22,15 +22,21 @@ import { IssueVetter } from './issue-vetting.js';
|
|
|
22
22
|
import { getTopicsForCategories } from './category-mapping.js';
|
|
23
23
|
import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, filterVetAndScore, searchInRepos, searchWithChunkedLabels, } from './search-phases.js';
|
|
24
24
|
const MODULE = 'issue-discovery';
|
|
25
|
+
/** Delay between major search phases to let GitHub's rate limit window cool down. */
|
|
26
|
+
const INTER_PHASE_DELAY_MS = 2000;
|
|
27
|
+
/** If remaining search quota is below this, skip heavy phases (2, 3). */
|
|
28
|
+
const LOW_BUDGET_THRESHOLD = 20;
|
|
29
|
+
/** If remaining search quota is below this, only run Phase 0. */
|
|
30
|
+
const CRITICAL_BUDGET_THRESHOLD = 10;
|
|
25
31
|
/**
|
|
26
32
|
* Multi-phase issue discovery engine that searches GitHub for contributable issues.
|
|
27
33
|
*
|
|
28
34
|
* Search phases (in priority order):
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
35
|
+
* 0. Repos where user has merged PRs (highest merge probability)
|
|
36
|
+
* 0.5. Preferred organizations
|
|
37
|
+
* 1. Starred repos
|
|
38
|
+
* 2. General label-filtered search
|
|
39
|
+
* 3. Actively maintained repos
|
|
34
40
|
*
|
|
35
41
|
* Each candidate is vetted for claimability and scored 0-100 for viability.
|
|
36
42
|
*/
|
|
@@ -118,8 +124,8 @@ export class IssueDiscovery {
|
|
|
118
124
|
}
|
|
119
125
|
/**
|
|
120
126
|
* Search for issues matching our criteria.
|
|
121
|
-
* Searches in priority order: merged-PR repos first (no label filter), then
|
|
122
|
-
* then general search, then actively maintained repos.
|
|
127
|
+
* Searches in priority order: merged-PR repos first (no label filter), then preferred
|
|
128
|
+
* organizations, then starred repos, then general search, then actively maintained repos.
|
|
123
129
|
* Filters out issues from low-scoring and excluded repos.
|
|
124
130
|
*
|
|
125
131
|
* @param options - Search configuration
|
|
@@ -151,23 +157,31 @@ export class IssueDiscovery {
|
|
|
151
157
|
let phase0Error = null;
|
|
152
158
|
let phase1Error = null;
|
|
153
159
|
let rateLimitHitDuringSearch = false;
|
|
154
|
-
// Pre-flight rate limit check (#100)
|
|
160
|
+
// Pre-flight rate limit check (#100) — also determines adaptive phase budget
|
|
155
161
|
this.rateLimitWarning = null;
|
|
162
|
+
let searchBudget = LOW_BUDGET_THRESHOLD - 1; // conservative: below threshold to skip heavy phases
|
|
156
163
|
try {
|
|
157
164
|
const rateLimit = await checkRateLimit(this.githubToken);
|
|
165
|
+
searchBudget = rateLimit.remaining;
|
|
158
166
|
if (rateLimit.remaining < 5) {
|
|
159
167
|
const resetTime = new Date(rateLimit.resetAt).toLocaleTimeString('en-US', { hour12: false });
|
|
160
168
|
this.rateLimitWarning = `GitHub search API quota low (${rateLimit.remaining}/${rateLimit.limit} remaining, resets at ${resetTime}). Search may be slow.`;
|
|
161
169
|
warn(MODULE, this.rateLimitWarning);
|
|
162
170
|
}
|
|
171
|
+
if (searchBudget < CRITICAL_BUDGET_THRESHOLD) {
|
|
172
|
+
info(MODULE, `Search budget critical (${searchBudget} remaining) — running only Phase 0`);
|
|
173
|
+
}
|
|
174
|
+
else if (searchBudget < LOW_BUDGET_THRESHOLD) {
|
|
175
|
+
info(MODULE, `Search budget low (${searchBudget} remaining) — skipping heavy phases (2, 3)`);
|
|
176
|
+
}
|
|
163
177
|
}
|
|
164
178
|
catch (error) {
|
|
165
179
|
// Fail fast on auth errors — no point searching with a bad token
|
|
166
180
|
if (getHttpStatusCode(error) === 401) {
|
|
167
181
|
throw error;
|
|
168
182
|
}
|
|
169
|
-
// Non-fatal: proceed with
|
|
170
|
-
warn(MODULE, 'Could not check rate limit:', errorMessage(error));
|
|
183
|
+
// Non-fatal: proceed with conservative budget for transient/network errors
|
|
184
|
+
warn(MODULE, 'Could not check rate limit — using conservative budget, skipping heavy phases:', errorMessage(error));
|
|
171
185
|
}
|
|
172
186
|
// Get merged-PR repos (highest merge probability)
|
|
173
187
|
const mergedPRRepos = this.stateManager.getReposWithMergedPRs();
|
|
@@ -263,9 +277,13 @@ export class IssueDiscovery {
|
|
|
263
277
|
}
|
|
264
278
|
}
|
|
265
279
|
// Phase 0.5: Search preferred organizations (explicit user preference)
|
|
280
|
+
// Skip if budget is critical — Phase 0 results are sufficient
|
|
266
281
|
let phase0_5Error = null;
|
|
267
282
|
const preferredOrgs = config.preferredOrgs ?? [];
|
|
268
|
-
if (allCandidates.length < maxResults && preferredOrgs.length > 0) {
|
|
283
|
+
if (allCandidates.length < maxResults && preferredOrgs.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD) {
|
|
284
|
+
// Inter-phase delay to let GitHub's rate limit window cool down
|
|
285
|
+
if (phase0Repos.length > 0)
|
|
286
|
+
await sleep(INTER_PHASE_DELAY_MS);
|
|
269
287
|
// Filter out orgs already covered by Phase 0 repos
|
|
270
288
|
const phase0Orgs = new Set(phase0Repos.map((r) => r.split('/')[0]?.toLowerCase()));
|
|
271
289
|
const orgsToSearch = preferredOrgs.filter((org) => !phase0Orgs.has(org.toLowerCase())).slice(0, 5);
|
|
@@ -303,7 +321,9 @@ export class IssueDiscovery {
|
|
|
303
321
|
}
|
|
304
322
|
}
|
|
305
323
|
// Phase 1: Search starred repos (filter out already-searched Phase 0 repos)
|
|
306
|
-
|
|
324
|
+
// Skip if budget is critical
|
|
325
|
+
if (allCandidates.length < maxResults && starredRepos.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD) {
|
|
326
|
+
await sleep(INTER_PHASE_DELAY_MS);
|
|
307
327
|
const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
|
|
308
328
|
if (reposToSearch.length > 0) {
|
|
309
329
|
info(MODULE, `Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
|
|
@@ -322,11 +342,13 @@ export class IssueDiscovery {
|
|
|
322
342
|
}
|
|
323
343
|
}
|
|
324
344
|
// Phase 2: General search (if still need more)
|
|
345
|
+
// Skip if budget is low — Phases 0, 0.5, 1 are cheaper and higher-value
|
|
325
346
|
// When multiple scope tiers are active, fire one query per tier and interleave
|
|
326
347
|
// results to prevent high-volume tiers (e.g., "enhancement") from drowning out
|
|
327
348
|
// beginner results.
|
|
328
349
|
let phase2Error = null;
|
|
329
|
-
if (allCandidates.length < maxResults) {
|
|
350
|
+
if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD) {
|
|
351
|
+
await sleep(INTER_PHASE_DELAY_MS);
|
|
330
352
|
info(MODULE, 'Phase 2: General issue search...');
|
|
331
353
|
const remainingNeeded = maxResults - allCandidates.length;
|
|
332
354
|
const seenRepos = new Set(allCandidates.map((c) => c.issue.repo));
|
|
@@ -390,8 +412,10 @@ export class IssueDiscovery {
|
|
|
390
412
|
allCandidates.push(...interleaved.slice(0, remainingNeeded));
|
|
391
413
|
}
|
|
392
414
|
// Phase 3: Actively maintained repos (#349)
|
|
415
|
+
// Skip if budget is low — this phase is API-heavy with broad queries
|
|
393
416
|
let phase3Error = null;
|
|
394
|
-
if (allCandidates.length < maxResults) {
|
|
417
|
+
if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD) {
|
|
418
|
+
await sleep(INTER_PHASE_DELAY_MS);
|
|
395
419
|
info(MODULE, 'Phase 3: Searching actively maintained repos...');
|
|
396
420
|
const remainingNeeded = maxResults - allCandidates.length;
|
|
397
421
|
const thirtyDaysAgo = new Date();
|
|
@@ -430,6 +454,15 @@ export class IssueDiscovery {
|
|
|
430
454
|
warn(MODULE, `Error in maintained-repo search: ${errMsg}`);
|
|
431
455
|
}
|
|
432
456
|
}
|
|
457
|
+
// Determine if phases were skipped due to budget constraints
|
|
458
|
+
const phasesSkippedForBudget = searchBudget < LOW_BUDGET_THRESHOLD;
|
|
459
|
+
let budgetNote = '';
|
|
460
|
+
if (searchBudget < CRITICAL_BUDGET_THRESHOLD) {
|
|
461
|
+
budgetNote = ` Most search phases were skipped due to critically low API quota (${searchBudget} remaining).`;
|
|
462
|
+
}
|
|
463
|
+
else if (phasesSkippedForBudget) {
|
|
464
|
+
budgetNote = ` Some search phases were skipped due to low API quota (${searchBudget} remaining).`;
|
|
465
|
+
}
|
|
433
466
|
if (allCandidates.length === 0) {
|
|
434
467
|
const phaseErrors = [
|
|
435
468
|
phase0Error ? `Phase 0 (merged-PR repos): ${phase0Error}` : null,
|
|
@@ -439,9 +472,9 @@ export class IssueDiscovery {
|
|
|
439
472
|
phase3Error ? `Phase 3 (maintained repos): ${phase3Error}` : null,
|
|
440
473
|
].filter(Boolean);
|
|
441
474
|
const details = phaseErrors.length > 0 ? ` ${phaseErrors.join('. ')}.` : '';
|
|
442
|
-
if (rateLimitHitDuringSearch) {
|
|
475
|
+
if (rateLimitHitDuringSearch || phasesSkippedForBudget) {
|
|
443
476
|
this.rateLimitWarning =
|
|
444
|
-
`Search returned no results due to GitHub API rate limits.${details} ` +
|
|
477
|
+
`Search returned no results due to GitHub API rate limits.${details}${budgetNote} ` +
|
|
445
478
|
`Try again after the rate limit resets.`;
|
|
446
479
|
return [];
|
|
447
480
|
}
|
|
@@ -449,10 +482,10 @@ export class IssueDiscovery {
|
|
|
449
482
|
'Try adjusting your search criteria (languages, labels) or check your network connection.');
|
|
450
483
|
}
|
|
451
484
|
// Surface rate limit warning even with partial results (#100)
|
|
452
|
-
if (rateLimitHitDuringSearch) {
|
|
485
|
+
if (rateLimitHitDuringSearch || phasesSkippedForBudget) {
|
|
453
486
|
this.rateLimitWarning =
|
|
454
|
-
`Search results may be incomplete: GitHub API rate limits were hit during search
|
|
455
|
-
`Found ${allCandidates.length} candidate${allCandidates.length === 1 ? '' : 's'} but some search phases
|
|
487
|
+
`Search results may be incomplete: GitHub API rate limits were hit during search.${budgetNote} ` +
|
|
488
|
+
`Found ${allCandidates.length} candidate${allCandidates.length === 1 ? '' : 's'} but some search phases were limited. ` +
|
|
456
489
|
`Try again after the rate limit resets for complete results.`;
|
|
457
490
|
}
|
|
458
491
|
// Sort by priority first, then by recommendation, then by viability score
|
|
@@ -15,6 +15,7 @@ export declare class IssueVetter {
|
|
|
15
15
|
constructor(octokit: Octokit, stateManager: ReturnType<typeof getStateManager>);
|
|
16
16
|
/**
|
|
17
17
|
* Vet a specific issue — runs all checks and computes recommendation + viability score.
|
|
18
|
+
* Results are cached for 15 minutes to avoid redundant API calls on repeated searches.
|
|
18
19
|
*/
|
|
19
20
|
vetIssue(issueUrl: string): Promise<IssueCandidate>;
|
|
20
21
|
/**
|
|
@@ -6,15 +6,19 @@
|
|
|
6
6
|
* - issue-eligibility.ts — PR existence, claim detection, requirements analysis
|
|
7
7
|
* - repo-health.ts — project health, contribution guidelines
|
|
8
8
|
*/
|
|
9
|
-
import { parseGitHubUrl
|
|
9
|
+
import { parseGitHubUrl } from './utils.js';
|
|
10
10
|
import { ValidationError, errorMessage, isRateLimitError } from './errors.js';
|
|
11
|
-
import { warn } from './logger.js';
|
|
11
|
+
import { debug, warn } from './logger.js';
|
|
12
12
|
import { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
|
|
13
13
|
import { repoBelongsToCategory } from './category-mapping.js';
|
|
14
14
|
import { checkNoExistingPR, checkNotClaimed, checkUserMergedPRsInRepo, analyzeRequirements, } from './issue-eligibility.js';
|
|
15
15
|
import { checkProjectHealth, fetchContributionGuidelines } from './repo-health.js';
|
|
16
|
+
import { getHttpCache } from './http-cache.js';
|
|
16
17
|
const MODULE = 'issue-vetting';
|
|
17
|
-
|
|
18
|
+
/** Vetting concurrency: kept low to reduce burst pressure on GitHub's secondary rate limit. */
|
|
19
|
+
const MAX_CONCURRENT_VETTING = 3;
|
|
20
|
+
/** TTL for cached vetting results (15 minutes). Kept short so config changes take effect quickly. */
|
|
21
|
+
const VETTING_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
18
22
|
export class IssueVetter {
|
|
19
23
|
octokit;
|
|
20
24
|
stateManager;
|
|
@@ -24,8 +28,17 @@ export class IssueVetter {
|
|
|
24
28
|
}
|
|
25
29
|
/**
|
|
26
30
|
* Vet a specific issue — runs all checks and computes recommendation + viability score.
|
|
31
|
+
* Results are cached for 15 minutes to avoid redundant API calls on repeated searches.
|
|
27
32
|
*/
|
|
28
33
|
async vetIssue(issueUrl) {
|
|
34
|
+
// Check vetting cache first — avoids ~6+ API calls per issue
|
|
35
|
+
const cache = getHttpCache();
|
|
36
|
+
const cacheKey = `vet:${issueUrl}`;
|
|
37
|
+
const cached = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
|
|
38
|
+
if (cached && typeof cached === 'object' && 'issue' in cached && 'viabilityScore' in cached) {
|
|
39
|
+
debug(MODULE, `Vetting cache hit for ${issueUrl}`);
|
|
40
|
+
return cached;
|
|
41
|
+
}
|
|
29
42
|
// Parse URL
|
|
30
43
|
const parsed = parseGitHubUrl(issueUrl);
|
|
31
44
|
if (!parsed || parsed.type !== 'issues') {
|
|
@@ -207,7 +220,7 @@ export class IssueVetter {
|
|
|
207
220
|
else if (starredRepos.includes(repoFullName)) {
|
|
208
221
|
searchPriority = 'starred';
|
|
209
222
|
}
|
|
210
|
-
|
|
223
|
+
const result = {
|
|
211
224
|
issue: trackedIssue,
|
|
212
225
|
vettingResult,
|
|
213
226
|
projectHealth,
|
|
@@ -217,6 +230,9 @@ export class IssueVetter {
|
|
|
217
230
|
viabilityScore,
|
|
218
231
|
searchPriority,
|
|
219
232
|
};
|
|
233
|
+
// Cache the vetting result to avoid redundant API calls on repeated searches
|
|
234
|
+
cache.set(cacheKey, '', result);
|
|
235
|
+
return result;
|
|
220
236
|
}
|
|
221
237
|
/**
|
|
222
238
|
* Vet multiple issues in parallel with concurrency limit
|
|
@@ -251,7 +267,7 @@ export class IssueVetter {
|
|
|
251
267
|
.finally(() => pending.delete(url));
|
|
252
268
|
pending.set(url, task);
|
|
253
269
|
// Limit concurrency — wait for at least one to complete before launching more
|
|
254
|
-
if (pending.size >=
|
|
270
|
+
if (pending.size >= MAX_CONCURRENT_VETTING) {
|
|
255
271
|
await Promise.race(pending.values());
|
|
256
272
|
}
|
|
257
273
|
}
|
|
@@ -9,16 +9,14 @@ import { errorMessage, isRateLimitError } from './errors.js';
|
|
|
9
9
|
import { debug, warn } from './logger.js';
|
|
10
10
|
import { getHttpCache, cachedTimeBased } from './http-cache.js';
|
|
11
11
|
import { detectLabelFarmingRepos } from './issue-filtering.js';
|
|
12
|
+
import { sleep } from './utils.js';
|
|
12
13
|
const MODULE = 'search-phases';
|
|
13
14
|
/** GitHub Search API enforces a max of 5 AND/OR/NOT operators per query. */
|
|
14
15
|
export const GITHUB_MAX_BOOLEAN_OPS = 5;
|
|
15
|
-
/**
|
|
16
|
-
const INTER_QUERY_DELAY_MS =
|
|
16
|
+
/** Delay between search API calls to avoid GitHub's secondary rate limit (~30 req/min). */
|
|
17
|
+
const INTER_QUERY_DELAY_MS = 1500;
|
|
17
18
|
/** Batch size for repo queries. 3 repos = 2 OR operators, leaving room for labels. */
|
|
18
19
|
const BATCH_SIZE = 3;
|
|
19
|
-
function sleep(ms) {
|
|
20
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
-
}
|
|
22
20
|
/**
|
|
23
21
|
* Chunk labels into groups that fit within the operator budget.
|
|
24
22
|
* N labels require N-1 OR operators, so maxPerChunk = budget + 1.
|
|
@@ -189,9 +187,13 @@ export async function searchInRepos(octokit, vetter, repos, baseQualifiers, labe
|
|
|
189
187
|
const batches = batchRepos(repos, BATCH_SIZE);
|
|
190
188
|
let failedBatches = 0;
|
|
191
189
|
let rateLimitFailures = 0;
|
|
192
|
-
for (
|
|
190
|
+
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
|
191
|
+
const batch = batches[batchIdx];
|
|
193
192
|
if (candidates.length >= maxResults)
|
|
194
193
|
break;
|
|
194
|
+
// Delay between batches to avoid secondary rate limits
|
|
195
|
+
if (batchIdx > 0)
|
|
196
|
+
await sleep(INTER_QUERY_DELAY_MS);
|
|
195
197
|
try {
|
|
196
198
|
const repoFilter = batch.map((r) => `repo:${r}`).join(' OR ');
|
|
197
199
|
const repoOps = batch.length - 1;
|
|
@@ -200,8 +202,10 @@ export async function searchInRepos(octokit, vetter, repos, baseQualifiers, labe
|
|
|
200
202
|
if (allItems.length > 0) {
|
|
201
203
|
const filtered = filterFn(allItems);
|
|
202
204
|
const remainingNeeded = maxResults - candidates.length;
|
|
203
|
-
const { candidates: vetted } = await vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, priority);
|
|
205
|
+
const { candidates: vetted, rateLimitHit: vetRateLimitHit } = await vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, priority);
|
|
204
206
|
candidates.push(...vetted);
|
|
207
|
+
if (vetRateLimitHit)
|
|
208
|
+
rateLimitFailures++;
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
catch (error) {
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/** Default concurrency limit for parallel GitHub API requests. */
|
|
5
5
|
export declare const DEFAULT_CONCURRENCY = 5;
|
|
6
|
+
/** Async sleep — exported for mockability in tests. */
|
|
7
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
6
8
|
/**
|
|
7
9
|
* Returns the oss-autopilot data directory path, creating it if it does not exist.
|
|
8
10
|
*
|
package/dist/core/utils.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utility functions
|
|
3
3
|
*/
|
|
4
|
-
/** Default concurrency limit for parallel GitHub API requests. */
|
|
5
|
-
export const DEFAULT_CONCURRENCY = 5;
|
|
6
4
|
import * as fs from 'fs';
|
|
7
5
|
import * as path from 'path';
|
|
8
6
|
import * as os from 'os';
|
|
9
7
|
import { execFileSync, execFile } from 'child_process';
|
|
10
8
|
import { ConfigurationError } from './errors.js';
|
|
11
9
|
import { debug } from './logger.js';
|
|
10
|
+
/** Default concurrency limit for parallel GitHub API requests. */
|
|
11
|
+
export const DEFAULT_CONCURRENCY = 5;
|
|
12
|
+
/** Async sleep — exported for mockability in tests. */
|
|
13
|
+
export function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
12
16
|
const MODULE = 'utils';
|
|
13
17
|
// Cached GitHub token (fetched once per session)
|
|
14
18
|
let cachedGitHubToken = null;
|
|
@@ -53,6 +53,8 @@ export interface ActionMenuItem {
|
|
|
53
53
|
label: string;
|
|
54
54
|
/** Explanation shown below the label. */
|
|
55
55
|
description: string;
|
|
56
|
+
/** Present when the action would exceed the user's PR capacity limit (#765). */
|
|
57
|
+
capacityWarning?: string;
|
|
56
58
|
}
|
|
57
59
|
/**
|
|
58
60
|
* Pre-computed action menu for the orchestration layer.
|
|
@@ -111,6 +113,31 @@ export interface DailyOutput {
|
|
|
111
113
|
repoGroups: CompactRepoGroup[];
|
|
112
114
|
failures: PRCheckFailure[];
|
|
113
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Compact version of DailyOutput for reduced JSON payload size (#763).
|
|
118
|
+
* Omits `summary` (pre-rendered markdown ~8KB that duplicates structured fields),
|
|
119
|
+
* `repoGroups` (derivable from digest.openPRs), and full `failures` array.
|
|
120
|
+
* Retains `commentedIssues` because downstream workflows (review-issue-replies.md,
|
|
121
|
+
* action-menu.md) consume it directly.
|
|
122
|
+
* Includes `failureCount` so consumers can detect partial fetch failures without
|
|
123
|
+
* carrying the full error payload.
|
|
124
|
+
*/
|
|
125
|
+
export interface CompactDailyOutput {
|
|
126
|
+
digest: DailyDigestCompact;
|
|
127
|
+
capacity: CapacityAssessment;
|
|
128
|
+
briefSummary: string;
|
|
129
|
+
actionableIssues: CompactActionableIssue[];
|
|
130
|
+
actionMenu: ActionMenu;
|
|
131
|
+
commentedIssues: CommentedIssue[];
|
|
132
|
+
/** Number of PRs that failed to fetch. Non-zero indicates partial results. */
|
|
133
|
+
failureCount: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Strip a full DailyOutput down to the compact subset (#763).
|
|
137
|
+
* Omits summary, repoGroups, and full failures array. Retains a failureCount
|
|
138
|
+
* so consumers can detect partial fetch failures.
|
|
139
|
+
*/
|
|
140
|
+
export declare function toCompactDailyOutput(output: DailyOutput): CompactDailyOutput;
|
|
114
141
|
/**
|
|
115
142
|
* Convert a full DailyDigest to the compact format for JSON output (#287).
|
|
116
143
|
* Category arrays become PR URL arrays; full objects stay only in openPRs.
|
|
@@ -207,6 +234,19 @@ export interface StartupOutput {
|
|
|
207
234
|
dashboardUrl?: string;
|
|
208
235
|
issueList?: IssueListInfo;
|
|
209
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Compact version of StartupOutput for reduced JSON payload (#763).
|
|
239
|
+
* Derived from StartupOutput with CompactDailyOutput substituted for DailyOutput.
|
|
240
|
+
* Using Omit ensures new fields added to StartupOutput are automatically included.
|
|
241
|
+
*/
|
|
242
|
+
export type CompactStartupOutput = Omit<StartupOutput, 'daily'> & {
|
|
243
|
+
daily?: CompactDailyOutput;
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Convert a full StartupOutput to the compact format (#763).
|
|
247
|
+
* Uses destructuring to auto-forward any new fields added to StartupOutput.
|
|
248
|
+
*/
|
|
249
|
+
export declare function toCompactStartupOutput(output: StartupOutput): CompactStartupOutput;
|
|
210
250
|
/** A single parsed issue from a markdown list (#82) */
|
|
211
251
|
export interface ParsedIssueItem {
|
|
212
252
|
repo: string;
|
|
@@ -234,6 +274,23 @@ export interface CheckIntegrationOutput {
|
|
|
234
274
|
newFiles: NewFileInfo[];
|
|
235
275
|
unreferencedCount: number;
|
|
236
276
|
}
|
|
277
|
+
/** Status of a re-vetted issue from the curated list (#764). */
|
|
278
|
+
export type VetListItemStatus = 'still_available' | 'claimed' | 'closed' | 'has_pr' | 'error';
|
|
279
|
+
/** Output of the vet-list command (#764). */
|
|
280
|
+
export interface VetListOutput {
|
|
281
|
+
results: Array<VetOutput & {
|
|
282
|
+
listStatus: VetListItemStatus;
|
|
283
|
+
errorMessage?: string;
|
|
284
|
+
}>;
|
|
285
|
+
summary: {
|
|
286
|
+
total: number;
|
|
287
|
+
stillAvailable: number;
|
|
288
|
+
claimed: number;
|
|
289
|
+
closed: number;
|
|
290
|
+
hasPR: number;
|
|
291
|
+
errors: number;
|
|
292
|
+
};
|
|
293
|
+
}
|
|
237
294
|
/** Output of the vet command */
|
|
238
295
|
export interface VetOutput {
|
|
239
296
|
issue: {
|