@oss-autopilot/core 1.6.3 → 1.7.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 +5 -1
- package/dist/cli.bundle.cjs +69 -65
- package/dist/commands/dashboard-data.d.ts +1 -0
- package/dist/commands/dashboard-server.js +23 -1
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +1 -1
- package/dist/commands/parse-list.d.ts +14 -0
- package/dist/commands/parse-list.js +145 -1
- package/dist/commands/vet-list.d.ts +1 -0
- package/dist/commands/vet-list.js +19 -2
- package/dist/core/issue-discovery.js +13 -2
- package/dist/core/issue-eligibility.d.ts +4 -1
- package/dist/core/issue-eligibility.js +40 -15
- package/dist/core/issue-vetting.js +17 -2
- package/dist/core/repo-health.js +1 -0
- package/dist/core/search-budget.d.ts +62 -0
- package/dist/core/search-budget.js +129 -0
- package/dist/core/search-phases.js +15 -4
- package/dist/core/types.d.ts +2 -0
- package/dist/formatters/json.d.ts +4 -0
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ export interface DashboardStats {
|
|
|
10
10
|
mergedPRs: number;
|
|
11
11
|
closedPRs: number;
|
|
12
12
|
mergeRate: string;
|
|
13
|
+
availableIssues?: number;
|
|
13
14
|
}
|
|
14
15
|
export declare function buildDashboardStats(digest: DailyDigest, state: Readonly<AgentState>, storedMergedCount?: number, storedClosedCount?: number): DashboardStats;
|
|
15
16
|
/**
|
|
@@ -13,7 +13,8 @@ import { errorMessage, ValidationError } from '../core/errors.js';
|
|
|
13
13
|
import { warn } from '../core/logger.js';
|
|
14
14
|
import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
|
|
15
15
|
import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
|
|
16
|
-
import { openInBrowser } from './startup.js';
|
|
16
|
+
import { openInBrowser, detectIssueList } from './startup.js';
|
|
17
|
+
import { parseIssueList } from './parse-list.js';
|
|
17
18
|
import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
|
|
18
19
|
import { RateLimiter } from './rate-limiter.js';
|
|
19
20
|
import { isBelowMinStars, } from '../core/types.js';
|
|
@@ -34,6 +35,22 @@ const MIME_TYPES = {
|
|
|
34
35
|
'.ico': 'image/x-icon',
|
|
35
36
|
};
|
|
36
37
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Read and parse the vetted issue list file (non-fatal on failure).
|
|
40
|
+
*/
|
|
41
|
+
function readVettedIssues() {
|
|
42
|
+
try {
|
|
43
|
+
const info = detectIssueList();
|
|
44
|
+
if (!info)
|
|
45
|
+
return null;
|
|
46
|
+
const content = fs.readFileSync(info.path, 'utf-8');
|
|
47
|
+
return parseIssueList(content);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
warn(MODULE, `Failed to read vetted issue list: ${errorMessage(error)}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
37
54
|
/**
|
|
38
55
|
* Build the JSON payload that the SPA expects from GET /api/data.
|
|
39
56
|
*/
|
|
@@ -60,6 +77,10 @@ function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClo
|
|
|
60
77
|
repoMetadata[repo] = { stars: score.stargazersCount, language: score.language };
|
|
61
78
|
}
|
|
62
79
|
}
|
|
80
|
+
const vettedIssues = readVettedIssues();
|
|
81
|
+
if (vettedIssues) {
|
|
82
|
+
stats.availableIssues = vettedIssues.availableCount;
|
|
83
|
+
}
|
|
63
84
|
return {
|
|
64
85
|
stats,
|
|
65
86
|
prsByRepo,
|
|
@@ -77,6 +98,7 @@ function buildDashboardJson(digest, state, commentedIssues, allMergedPRs, allClo
|
|
|
77
98
|
allMergedPRs: filteredMergedPRs,
|
|
78
99
|
allClosedPRs: filteredClosedPRs,
|
|
79
100
|
repoMetadata,
|
|
101
|
+
vettedIssues,
|
|
80
102
|
};
|
|
81
103
|
}
|
|
82
104
|
/**
|
package/dist/commands/index.d.ts
CHANGED
|
@@ -56,7 +56,7 @@ export { runSetup } from './setup.js';
|
|
|
56
56
|
/** Check whether setup has been completed. */
|
|
57
57
|
export { runCheckSetup } from './setup.js';
|
|
58
58
|
/** Parse a curated markdown issue list file into structured issue items. */
|
|
59
|
-
export { runParseList } from './parse-list.js';
|
|
59
|
+
export { runParseList, pruneIssueList } from './parse-list.js';
|
|
60
60
|
/** Check if new files are properly referenced/integrated. */
|
|
61
61
|
export { runCheckIntegration } from './check-integration.js';
|
|
62
62
|
/** Detect formatters/linters configured in a local repository (#703). */
|
|
@@ -65,7 +65,7 @@ export { runDetectFormatters } from './detect-formatters.js';
|
|
|
65
65
|
export { runLocalRepos } from './local-repos.js';
|
|
66
66
|
export type { DailyOutput, SearchOutput, StartupOutput, StatusOutput, TrackOutput } from '../formatters/json.js';
|
|
67
67
|
export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput } from '../formatters/json.js';
|
|
68
|
-
export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
|
|
68
|
+
export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
|
|
69
69
|
export type { ReadOutput } from './read.js';
|
|
70
70
|
export type { ShelveOutput, UnshelveOutput } from './shelve.js';
|
|
71
71
|
export type { MoveOutput, MoveTarget } from './move.js';
|
package/dist/commands/index.js
CHANGED
|
@@ -61,7 +61,7 @@ export { runSetup } from './setup.js';
|
|
|
61
61
|
export { runCheckSetup } from './setup.js';
|
|
62
62
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
63
63
|
/** Parse a curated markdown issue list file into structured issue items. */
|
|
64
|
-
export { runParseList } from './parse-list.js';
|
|
64
|
+
export { runParseList, pruneIssueList } from './parse-list.js';
|
|
65
65
|
/** Check if new files are properly referenced/integrated. */
|
|
66
66
|
export { runCheckIntegration } from './check-integration.js';
|
|
67
67
|
/** Detect formatters/linters configured in a local repository (#703). */
|
|
@@ -9,4 +9,18 @@ interface ParseListOptions {
|
|
|
9
9
|
export type { ParseIssueListOutput, ParsedIssueItem };
|
|
10
10
|
/** Parse a markdown string into structured issue items */
|
|
11
11
|
export declare function parseIssueList(content: string): ParseIssueListOutput;
|
|
12
|
+
/**
|
|
13
|
+
* Prune a markdown issue list: remove completed, skipped, and low-score items.
|
|
14
|
+
* Rewrites the file in place, removing:
|
|
15
|
+
* - Items with strikethrough, [x], Done, Skip, or Dropped status
|
|
16
|
+
* - Items with score < minScore
|
|
17
|
+
* - Empty section headings left after removal
|
|
18
|
+
* - Sub-bullets belonging to removed items
|
|
19
|
+
*
|
|
20
|
+
* @returns Count of items removed
|
|
21
|
+
*/
|
|
22
|
+
export declare function pruneIssueList(content: string, minScore?: number): {
|
|
23
|
+
pruned: string;
|
|
24
|
+
removedCount: number;
|
|
25
|
+
};
|
|
12
26
|
export declare function runParseList(options: ParseListOptions): Promise<ParseIssueListOutput>;
|
|
@@ -48,17 +48,34 @@ function isCompleted(line) {
|
|
|
48
48
|
return true;
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
|
+
/** Extract a score from a sub-bullet line (e.g., "Score 8/10" or "Score 7.5/10") */
|
|
52
|
+
function extractScore(line) {
|
|
53
|
+
const match = line.match(/Score\s+(\d+(?:\.\d+)?)\/10/i);
|
|
54
|
+
return match ? parseFloat(match[1]) : undefined;
|
|
55
|
+
}
|
|
56
|
+
/** Check if a sub-bullet indicates the item is terminal (completed/abandoned — safe to prune) */
|
|
57
|
+
function isSubBulletTerminal(line) {
|
|
58
|
+
return /\*\*(Skip|Done|Dropped|Merged|Closed)\*\*/i.test(line);
|
|
59
|
+
}
|
|
60
|
+
/** Check if a sub-bullet indicates the item is in-progress (not available, but NOT safe to prune) */
|
|
61
|
+
function isSubBulletInProgress(line) {
|
|
62
|
+
return /\*\*(In Progress|Wait|Waiting)\*\*/i.test(line);
|
|
63
|
+
}
|
|
51
64
|
/** Parse a markdown string into structured issue items */
|
|
52
65
|
export function parseIssueList(content) {
|
|
53
66
|
const lines = content.split('\n');
|
|
54
67
|
const available = [];
|
|
55
68
|
const completed = [];
|
|
56
69
|
let currentTier = 'Uncategorized';
|
|
70
|
+
let lastItem = null;
|
|
71
|
+
// Track which array the last item was placed in so sub-bullets can move it
|
|
72
|
+
let lastItemInAvailable = false;
|
|
57
73
|
for (const line of lines) {
|
|
58
74
|
// Check for section headings (# or ##)
|
|
59
75
|
const headingMatch = line.match(/^#{1,3}\s+(.+)/);
|
|
60
76
|
if (headingMatch) {
|
|
61
77
|
currentTier = headingMatch[1].trim();
|
|
78
|
+
lastItem = null;
|
|
62
79
|
continue;
|
|
63
80
|
}
|
|
64
81
|
// Skip empty lines and non-list items
|
|
@@ -67,8 +84,26 @@ export function parseIssueList(content) {
|
|
|
67
84
|
}
|
|
68
85
|
// Extract GitHub URL -- skip lines without one
|
|
69
86
|
const ghUrl = extractGitHubUrl(line);
|
|
70
|
-
if (!ghUrl)
|
|
87
|
+
if (!ghUrl) {
|
|
88
|
+
// No URL — check if this is a sub-bullet for the previous item
|
|
89
|
+
if (lastItem && /^\s{2,}/.test(line)) {
|
|
90
|
+
const score = extractScore(line);
|
|
91
|
+
if (score !== undefined) {
|
|
92
|
+
lastItem.score = score;
|
|
93
|
+
}
|
|
94
|
+
// Check if sub-bullet marks item as terminal or in-progress
|
|
95
|
+
if (lastItemInAvailable && (isSubBulletTerminal(line) || isSubBulletInProgress(line))) {
|
|
96
|
+
// Move from available to completed
|
|
97
|
+
const idx = available.indexOf(lastItem);
|
|
98
|
+
if (idx !== -1) {
|
|
99
|
+
available.splice(idx, 1);
|
|
100
|
+
completed.push(lastItem);
|
|
101
|
+
lastItemInAvailable = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
71
105
|
continue;
|
|
106
|
+
}
|
|
72
107
|
const title = extractTitle(line);
|
|
73
108
|
const item = {
|
|
74
109
|
repo: ghUrl.repo,
|
|
@@ -79,10 +114,13 @@ export function parseIssueList(content) {
|
|
|
79
114
|
};
|
|
80
115
|
if (isCompleted(line)) {
|
|
81
116
|
completed.push(item);
|
|
117
|
+
lastItemInAvailable = false;
|
|
82
118
|
}
|
|
83
119
|
else {
|
|
84
120
|
available.push(item);
|
|
121
|
+
lastItemInAvailable = true;
|
|
85
122
|
}
|
|
123
|
+
lastItem = item;
|
|
86
124
|
}
|
|
87
125
|
return {
|
|
88
126
|
available,
|
|
@@ -91,6 +129,112 @@ export function parseIssueList(content) {
|
|
|
91
129
|
completedCount: completed.length,
|
|
92
130
|
};
|
|
93
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Prune a markdown issue list: remove completed, skipped, and low-score items.
|
|
134
|
+
* Rewrites the file in place, removing:
|
|
135
|
+
* - Items with strikethrough, [x], Done, Skip, or Dropped status
|
|
136
|
+
* - Items with score < minScore
|
|
137
|
+
* - Empty section headings left after removal
|
|
138
|
+
* - Sub-bullets belonging to removed items
|
|
139
|
+
*
|
|
140
|
+
* @returns Count of items removed
|
|
141
|
+
*/
|
|
142
|
+
export function pruneIssueList(content, minScore = 6) {
|
|
143
|
+
const lines = content.split('\n');
|
|
144
|
+
const output = [];
|
|
145
|
+
let removedCount = 0;
|
|
146
|
+
let skipSubBullets = false;
|
|
147
|
+
for (let i = 0; i < lines.length; i++) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
// Always keep headings (we'll clean empty ones in a second pass)
|
|
150
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
151
|
+
skipSubBullets = false;
|
|
152
|
+
output.push(line);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Sub-bullet of a removed item — skip it
|
|
156
|
+
if (skipSubBullets && /^\s{2,}/.test(line)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
skipSubBullets = false;
|
|
160
|
+
// Check if this is a list item
|
|
161
|
+
const isList = /^\s*[-*+]\s/.test(line);
|
|
162
|
+
// Remove any completed list item (even without a GitHub URL)
|
|
163
|
+
if (isList && isCompleted(line)) {
|
|
164
|
+
removedCount++;
|
|
165
|
+
skipSubBullets = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const ghUrl = extractGitHubUrl(line);
|
|
169
|
+
if (ghUrl) {
|
|
170
|
+
// Look ahead at sub-bullets for terminal status or low score
|
|
171
|
+
let shouldRemove = false;
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
while (j < lines.length && /^\s{2,}/.test(lines[j])) {
|
|
174
|
+
if (isSubBulletTerminal(lines[j])) {
|
|
175
|
+
shouldRemove = true;
|
|
176
|
+
}
|
|
177
|
+
const score = extractScore(lines[j]);
|
|
178
|
+
if (score !== undefined && score < minScore) {
|
|
179
|
+
shouldRemove = true;
|
|
180
|
+
}
|
|
181
|
+
j++;
|
|
182
|
+
}
|
|
183
|
+
if (shouldRemove) {
|
|
184
|
+
removedCount++;
|
|
185
|
+
skipSubBullets = true;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Skip cruft lines: "Skip (N issues)", standalone "---", old "Removed" labels
|
|
190
|
+
if (/^\s*skip\s*\(\d+\s*issues?\)/i.test(line.replace(/[#*]/g, '').trim())) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (/^---\s*$/.test(line)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (/^\s*(###?\s*)?(Removed|Previously dropped)/i.test(line)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Skip blockquote metadata lines ("> Sources: ...", "> Prioritized ...")
|
|
200
|
+
if (/^>\s/.test(line)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Non-list lines, metadata, and surviving items — keep
|
|
204
|
+
output.push(line);
|
|
205
|
+
}
|
|
206
|
+
// Second pass: remove empty section headings (heading followed by another heading or end of file)
|
|
207
|
+
const cleaned = [];
|
|
208
|
+
for (let i = 0; i < output.length; i++) {
|
|
209
|
+
const isHeading = /^#{1,3}\s+/.test(output[i]);
|
|
210
|
+
if (isHeading) {
|
|
211
|
+
// Check if next non-empty line is another heading or end of content
|
|
212
|
+
let nextContentIdx = i + 1;
|
|
213
|
+
while (nextContentIdx < output.length && output[nextContentIdx].trim() === '') {
|
|
214
|
+
nextContentIdx++;
|
|
215
|
+
}
|
|
216
|
+
const nextIsHeadingOrEnd = nextContentIdx >= output.length || /^#{1,3}\s+/.test(output[nextContentIdx]);
|
|
217
|
+
if (nextIsHeadingOrEnd) {
|
|
218
|
+
// Skip this empty heading and any blank lines after it
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
cleaned.push(output[i]);
|
|
223
|
+
}
|
|
224
|
+
// Collapse consecutive blank lines into at most one
|
|
225
|
+
const collapsed = [];
|
|
226
|
+
for (const line of cleaned) {
|
|
227
|
+
if (line.trim() === '' && collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === '') {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
collapsed.push(line);
|
|
231
|
+
}
|
|
232
|
+
// Remove trailing blank lines
|
|
233
|
+
while (collapsed.length > 0 && collapsed[collapsed.length - 1].trim() === '') {
|
|
234
|
+
collapsed.pop();
|
|
235
|
+
}
|
|
236
|
+
return { pruned: collapsed.join('\n') + '\n', removedCount };
|
|
237
|
+
}
|
|
94
238
|
export async function runParseList(options) {
|
|
95
239
|
const filePath = path.resolve(options.filePath);
|
|
96
240
|
if (!fs.existsSync(filePath)) {
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Vet-list command (#764)
|
|
3
3
|
* Re-vets all available issues in a curated issue list file.
|
|
4
4
|
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
5
6
|
import { IssueDiscovery, requireGitHubToken } from '../core/index.js';
|
|
7
|
+
import { runParseList, pruneIssueList } from './parse-list.js';
|
|
6
8
|
import { detectIssueList } from './startup.js';
|
|
7
9
|
/**
|
|
8
10
|
* Determine the list status from vetting results.
|
|
@@ -43,7 +45,6 @@ export async function runVetList(options = {}) {
|
|
|
43
45
|
}
|
|
44
46
|
issueListPath = detected.path;
|
|
45
47
|
}
|
|
46
|
-
const { runParseList } = await import('./parse-list.js');
|
|
47
48
|
const parsed = await runParseList({ filePath: issueListPath });
|
|
48
49
|
if (parsed.available.length === 0) {
|
|
49
50
|
return {
|
|
@@ -108,5 +109,21 @@ export async function runVetList(options = {}) {
|
|
|
108
109
|
hasPR: results.filter((r) => r.listStatus === 'has_pr').length,
|
|
109
110
|
errors: results.filter((r) => r.listStatus === 'error').length,
|
|
110
111
|
};
|
|
111
|
-
|
|
112
|
+
// 4. Prune the file if requested — remove completed/skipped/low-score items
|
|
113
|
+
let pruneResult;
|
|
114
|
+
if (options.prune && issueListPath) {
|
|
115
|
+
try {
|
|
116
|
+
const content = fs.readFileSync(issueListPath, 'utf-8');
|
|
117
|
+
const { pruned, removedCount } = pruneIssueList(content);
|
|
118
|
+
if (pruned !== content) {
|
|
119
|
+
fs.writeFileSync(issueListPath, pruned, 'utf-8');
|
|
120
|
+
}
|
|
121
|
+
pruneResult = { removedCount };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
125
|
+
console.error(`Warning: Failed to prune ${issueListPath}: ${msg}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { results, summary, pruneResult };
|
|
112
129
|
}
|
|
@@ -13,6 +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 { getSearchBudgetTracker } from './search-budget.js';
|
|
16
17
|
import { daysBetween, getDataDir, sleep } from './utils.js';
|
|
17
18
|
import { DEFAULT_CONFIG, SCOPE_LABELS } from './types.js';
|
|
18
19
|
import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError } from './errors.js';
|
|
@@ -159,10 +160,12 @@ export class IssueDiscovery {
|
|
|
159
160
|
let rateLimitHitDuringSearch = false;
|
|
160
161
|
// Pre-flight rate limit check (#100) — also determines adaptive phase budget
|
|
161
162
|
this.rateLimitWarning = null;
|
|
163
|
+
const tracker = getSearchBudgetTracker();
|
|
162
164
|
let searchBudget = LOW_BUDGET_THRESHOLD - 1; // conservative: below threshold to skip heavy phases
|
|
163
165
|
try {
|
|
164
166
|
const rateLimit = await checkRateLimit(this.githubToken);
|
|
165
167
|
searchBudget = rateLimit.remaining;
|
|
168
|
+
tracker.init(rateLimit.remaining, rateLimit.resetAt);
|
|
166
169
|
if (rateLimit.remaining < 5) {
|
|
167
170
|
const resetTime = new Date(rateLimit.resetAt).toLocaleTimeString('en-US', { hour12: false });
|
|
168
171
|
this.rateLimitWarning = `GitHub search API quota low (${rateLimit.remaining}/${rateLimit.limit} remaining, resets at ${resetTime}). Search may be slow.`;
|
|
@@ -180,7 +183,9 @@ export class IssueDiscovery {
|
|
|
180
183
|
if (getHttpStatusCode(error) === 401) {
|
|
181
184
|
throw error;
|
|
182
185
|
}
|
|
183
|
-
// Non-fatal: proceed with conservative budget for transient/network errors
|
|
186
|
+
// Non-fatal: proceed with conservative budget for transient/network errors.
|
|
187
|
+
// Initialize tracker with conservative defaults so it doesn't fly blind.
|
|
188
|
+
tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 60000).toISOString());
|
|
184
189
|
warn(MODULE, 'Could not check rate limit — using conservative budget, skipping heavy phases:', errorMessage(error));
|
|
185
190
|
}
|
|
186
191
|
// Get merged-PR repos (highest merge probability)
|
|
@@ -329,7 +334,12 @@ export class IssueDiscovery {
|
|
|
329
334
|
info(MODULE, `Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
|
|
330
335
|
const remainingNeeded = maxResults - allCandidates.length;
|
|
331
336
|
if (remainingNeeded > 0) {
|
|
332
|
-
|
|
337
|
+
// Cap labels to reduce Search API calls: starred repos already signal user
|
|
338
|
+
// interest, so fewer labels suffice. With 3 labels and batch size 3 (2 repo ORs),
|
|
339
|
+
// each batch fits in a single label chunk instead of 3+, cutting Phase 1 calls
|
|
340
|
+
// from ~12 to ~4.
|
|
341
|
+
const phase1Labels = labels.slice(0, 3);
|
|
342
|
+
const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, reposToSearch.slice(0, 10), baseQualifiers, phase1Labels, remainingNeeded, 'starred', filterIssues);
|
|
333
343
|
allCandidates.push(...starredCandidates);
|
|
334
344
|
if (allBatchesFailed) {
|
|
335
345
|
phase1Error = 'All starred repo batches failed';
|
|
@@ -502,6 +512,7 @@ export class IssueDiscovery {
|
|
|
502
512
|
});
|
|
503
513
|
// Apply per-repo cap: max 2 issues from any single repo (#105)
|
|
504
514
|
const capped = applyPerRepoCap(allCandidates, 2);
|
|
515
|
+
info(MODULE, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${capped.length} candidates returned`);
|
|
505
516
|
return capped.slice(0, maxResults);
|
|
506
517
|
}
|
|
507
518
|
/**
|
|
@@ -14,12 +14,15 @@ export interface CheckResult {
|
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* Check whether an open PR already exists for the given issue.
|
|
17
|
-
*
|
|
17
|
+
* Uses the timeline API (REST) to detect cross-referenced PRs, avoiding
|
|
18
|
+
* the Search API's strict 30 req/min rate limit.
|
|
18
19
|
*/
|
|
19
20
|
export declare function checkNoExistingPR(octokit: Octokit, owner: string, repo: string, issueNumber: number): Promise<CheckResult>;
|
|
20
21
|
/**
|
|
21
22
|
* Check how many merged PRs the authenticated user has in a repo.
|
|
22
23
|
* Uses GitHub Search API. Returns 0 on error (non-fatal).
|
|
24
|
+
* Results are cached per-repo for 15 minutes to avoid redundant Search API
|
|
25
|
+
* calls when multiple issues from the same repo are vetted.
|
|
23
26
|
*/
|
|
24
27
|
export declare function checkUserMergedPRsInRepo(octokit: Octokit, owner: string, repo: string): Promise<number>;
|
|
25
28
|
/**
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { paginateAll } from './pagination.js';
|
|
9
9
|
import { errorMessage } from './errors.js';
|
|
10
10
|
import { warn } from './logger.js';
|
|
11
|
+
import { getHttpCache } from './http-cache.js';
|
|
12
|
+
import { getSearchBudgetTracker } from './search-budget.js';
|
|
11
13
|
const MODULE = 'issue-eligibility';
|
|
12
14
|
/** Phrases that indicate someone has already claimed an issue. */
|
|
13
15
|
const CLAIM_PHRASES = [
|
|
@@ -29,16 +31,16 @@ const CLAIM_PHRASES = [
|
|
|
29
31
|
];
|
|
30
32
|
/**
|
|
31
33
|
* Check whether an open PR already exists for the given issue.
|
|
32
|
-
*
|
|
34
|
+
* Uses the timeline API (REST) to detect cross-referenced PRs, avoiding
|
|
35
|
+
* the Search API's strict 30 req/min rate limit.
|
|
33
36
|
*/
|
|
34
37
|
export async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
|
|
35
38
|
try {
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Also check timeline for linked PRs
|
|
39
|
+
// Use the timeline API (REST, not Search) to detect linked PRs.
|
|
40
|
+
// This avoids consuming GitHub Search API quota (30 req/min limit).
|
|
41
|
+
// Timeline captures formally linked PRs via cross-referenced events
|
|
42
|
+
// but may miss PRs that only mention the issue number without a formal
|
|
43
|
+
// link — an acceptable trade-off since most PRs use "Fixes #N" syntax.
|
|
42
44
|
const timeline = await paginateAll((page) => octokit.issues.listEventsForTimeline({
|
|
43
45
|
owner,
|
|
44
46
|
repo,
|
|
@@ -50,7 +52,7 @@ export async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
|
|
|
50
52
|
const e = event;
|
|
51
53
|
return e.event === 'cross-referenced' && e.source?.issue?.pull_request;
|
|
52
54
|
});
|
|
53
|
-
return { passed:
|
|
55
|
+
return { passed: linkedPRs.length === 0 };
|
|
54
56
|
}
|
|
55
57
|
catch (error) {
|
|
56
58
|
const errMsg = errorMessage(error);
|
|
@@ -58,23 +60,46 @@ export async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
|
|
|
58
60
|
return { passed: true, inconclusive: true, reason: errMsg };
|
|
59
61
|
}
|
|
60
62
|
}
|
|
63
|
+
/** TTL for cached merged-PR counts per repo (15 minutes). */
|
|
64
|
+
const MERGED_PR_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
61
65
|
/**
|
|
62
66
|
* Check how many merged PRs the authenticated user has in a repo.
|
|
63
67
|
* Uses GitHub Search API. Returns 0 on error (non-fatal).
|
|
68
|
+
* Results are cached per-repo for 15 minutes to avoid redundant Search API
|
|
69
|
+
* calls when multiple issues from the same repo are vetted.
|
|
64
70
|
*/
|
|
65
71
|
export async function checkUserMergedPRsInRepo(octokit, owner, repo) {
|
|
72
|
+
const cache = getHttpCache();
|
|
73
|
+
const cacheKey = `merged-prs:${owner}/${repo}`;
|
|
74
|
+
// Manual cache check — do not use cachedTimeBased because we must NOT cache
|
|
75
|
+
// error-path fallback values (a transient failure returning 0 would poison the
|
|
76
|
+
// cache for 15 minutes, hiding that the user has merged PRs in the repo).
|
|
77
|
+
const cached = cache.getIfFresh(cacheKey, MERGED_PR_CACHE_TTL_MS);
|
|
78
|
+
if (cached != null && typeof cached === 'number') {
|
|
79
|
+
return cached;
|
|
80
|
+
}
|
|
66
81
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
const tracker = getSearchBudgetTracker();
|
|
83
|
+
await tracker.waitForBudget();
|
|
84
|
+
try {
|
|
85
|
+
// Use @me to search as the authenticated user
|
|
86
|
+
const { data } = await octokit.search.issuesAndPullRequests({
|
|
87
|
+
q: `repo:${owner}/${repo} is:pr is:merged author:@me`,
|
|
88
|
+
per_page: 1, // We only need total_count
|
|
89
|
+
});
|
|
90
|
+
// Only cache successful results
|
|
91
|
+
cache.set(cacheKey, '', data.total_count);
|
|
92
|
+
return data.total_count;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
// Always record the call — failed requests still consume GitHub rate limit points
|
|
96
|
+
tracker.recordCall();
|
|
97
|
+
}
|
|
73
98
|
}
|
|
74
99
|
catch (error) {
|
|
75
100
|
const errMsg = errorMessage(error);
|
|
76
101
|
warn(MODULE, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Defaulting to 0.`);
|
|
77
|
-
return 0;
|
|
102
|
+
return 0; // Not cached — next call will retry
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
/**
|
|
@@ -52,13 +52,17 @@ export class IssueVetter {
|
|
|
52
52
|
repo,
|
|
53
53
|
issue_number: number,
|
|
54
54
|
});
|
|
55
|
+
// Check local state first to skip the merged-PR Search API call when
|
|
56
|
+
// the repo already has authoritative data (saves 1 Search call per issue).
|
|
57
|
+
const repoScoreRecord = this.stateManager.getRepoScore(repoFullName);
|
|
58
|
+
const skipMergedPRCheck = repoScoreRecord != null && repoScoreRecord.mergedPRCount > 0;
|
|
55
59
|
// Run all vetting checks in parallel — delegates to standalone functions
|
|
56
60
|
const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount] = await Promise.all([
|
|
57
61
|
checkNoExistingPR(this.octokit, owner, repo, number),
|
|
58
62
|
checkNotClaimed(this.octokit, owner, repo, number, ghIssue.comments),
|
|
59
63
|
checkProjectHealth(this.octokit, owner, repo),
|
|
60
64
|
fetchContributionGuidelines(this.octokit, owner, repo),
|
|
61
|
-
checkUserMergedPRsInRepo(this.octokit, owner, repo),
|
|
65
|
+
skipMergedPRCheck ? Promise.resolve(0) : checkUserMergedPRsInRepo(this.octokit, owner, repo),
|
|
62
66
|
]);
|
|
63
67
|
const noExistingPR = existingPRCheck.passed;
|
|
64
68
|
const notClaimed = claimCheck.passed;
|
|
@@ -138,7 +142,6 @@ export class IssueVetter {
|
|
|
138
142
|
// Determine effective merged PR count: prefer local state (authoritative if present),
|
|
139
143
|
// fall back to live GitHub API count to detect contributions made before using oss-autopilot (#373)
|
|
140
144
|
const config = this.stateManager.getState().config;
|
|
141
|
-
const repoScoreRecord = this.stateManager.getRepoScore(repoFullName);
|
|
142
145
|
const effectiveMergedCount = repoScoreRecord && repoScoreRecord.mergedPRCount > 0 ? repoScoreRecord.mergedPRCount : userMergedPRCount;
|
|
143
146
|
if (effectiveMergedCount > 0) {
|
|
144
147
|
reasonsToApprove.push(`Trusted project (${effectiveMergedCount} PR${effectiveMergedCount > 1 ? 's' : ''} merged)`);
|
|
@@ -230,6 +233,18 @@ export class IssueVetter {
|
|
|
230
233
|
viabilityScore,
|
|
231
234
|
searchPriority,
|
|
232
235
|
};
|
|
236
|
+
// Persist repo metadata (stars, language) so the dashboard can display it (#839)
|
|
237
|
+
if (!projectHealth.checkFailed) {
|
|
238
|
+
try {
|
|
239
|
+
this.stateManager.updateRepoScore(repoFullName, {
|
|
240
|
+
stargazersCount: projectHealth.stargazersCount,
|
|
241
|
+
language: projectHealth.language,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
warn(MODULE, `Failed to persist repo metadata for ${repoFullName}: ${errorMessage(error)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
233
248
|
// Cache the vetting result to avoid redundant API calls on repeated searches
|
|
234
249
|
cache.set(cacheKey, '', result);
|
|
235
250
|
return result;
|
package/dist/core/repo-health.js
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Budget Tracker — centralized rate limit management for GitHub Search API.
|
|
3
|
+
*
|
|
4
|
+
* The GitHub Search API enforces a strict 30 requests/minute limit for
|
|
5
|
+
* authenticated users. This module tracks actual consumption via a sliding
|
|
6
|
+
* window and provides adaptive delays to stay within budget.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* - Initialize once per search run with pre-flight rate limit data
|
|
10
|
+
* - Call recordCall() after every Search API call
|
|
11
|
+
* - Call waitForBudget() before making a Search API call to pace requests
|
|
12
|
+
* - Call canAfford(n) to check if n more calls fit in the remaining budget
|
|
13
|
+
*/
|
|
14
|
+
export declare class SearchBudgetTracker {
|
|
15
|
+
/** Timestamps of recent Search API calls within the sliding window. */
|
|
16
|
+
private callTimestamps;
|
|
17
|
+
/** Last known remaining quota from GitHub's rate limit endpoint. */
|
|
18
|
+
private knownRemaining;
|
|
19
|
+
/** Epoch ms when the rate limit window resets (from GitHub API). */
|
|
20
|
+
private resetAt;
|
|
21
|
+
/** Total calls recorded since init (for diagnostics). */
|
|
22
|
+
private totalCalls;
|
|
23
|
+
/**
|
|
24
|
+
* Initialize with pre-flight rate limit data from GitHub.
|
|
25
|
+
*/
|
|
26
|
+
init(remaining: number, resetAt: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Record that a Search API call was just made.
|
|
29
|
+
*/
|
|
30
|
+
recordCall(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Remove timestamps older than the sliding window.
|
|
33
|
+
*/
|
|
34
|
+
private pruneOldTimestamps;
|
|
35
|
+
/**
|
|
36
|
+
* Get the number of calls made in the current sliding window.
|
|
37
|
+
*/
|
|
38
|
+
getCallsInWindow(): number;
|
|
39
|
+
/**
|
|
40
|
+
* Get the effective budget, accounting for both the sliding window limit
|
|
41
|
+
* and the pre-flight remaining quota from GitHub.
|
|
42
|
+
*/
|
|
43
|
+
private getEffectiveBudget;
|
|
44
|
+
/**
|
|
45
|
+
* Check if we can afford N more Search API calls without exceeding the budget.
|
|
46
|
+
*/
|
|
47
|
+
canAfford(n: number): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Wait if necessary to stay within the Search API rate limit.
|
|
50
|
+
* If the sliding window is at capacity, sleeps until the oldest
|
|
51
|
+
* call ages out of the window.
|
|
52
|
+
*/
|
|
53
|
+
waitForBudget(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Get total calls recorded since init (for diagnostics).
|
|
56
|
+
*/
|
|
57
|
+
getTotalCalls(): number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get (or create) the shared SearchBudgetTracker singleton.
|
|
61
|
+
*/
|
|
62
|
+
export declare function getSearchBudgetTracker(): SearchBudgetTracker;
|