@oss-autopilot/core 1.0.0 → 1.1.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.bundle.cjs +57 -57
- package/dist/core/issue-discovery.js +14 -28
- package/dist/core/repo-score-manager.d.ts +7 -1
- package/dist/core/repo-score-manager.js +44 -0
- package/dist/core/search-phases.d.ts +30 -2
- package/dist/core/search-phases.js +81 -13
- package/dist/core/state.d.ts +10 -0
- package/dist/core/state.js +30 -1
- package/package.json +1 -1
|
@@ -20,7 +20,7 @@ import { debug, info, warn } from './logger.js';
|
|
|
20
20
|
import { isDocOnlyIssue, applyPerRepoCap } from './issue-filtering.js';
|
|
21
21
|
import { IssueVetter } from './issue-vetting.js';
|
|
22
22
|
import { getTopicsForCategories } from './category-mapping.js';
|
|
23
|
-
import {
|
|
23
|
+
import { buildEffectiveLabels, interleaveArrays, cachedSearchIssues, filterVetAndScore, searchInRepos, searchWithChunkedLabels, } from './search-phases.js';
|
|
24
24
|
const MODULE = 'issue-discovery';
|
|
25
25
|
/**
|
|
26
26
|
* Multi-phase issue discovery engine that searches GitHub for contributable issues.
|
|
@@ -185,14 +185,12 @@ export class IssueDiscovery {
|
|
|
185
185
|
const maxAgeDays = config.maxIssueAgeDays || 90;
|
|
186
186
|
const now = new Date();
|
|
187
187
|
// Build query parts
|
|
188
|
-
const labelQuery = buildLabelQuery(labels);
|
|
189
188
|
// When languages includes 'any', omit the language filter entirely
|
|
190
189
|
const isAnyLanguage = languages.some((l) => l.toLowerCase() === 'any');
|
|
191
190
|
const langQuery = isAnyLanguage ? '' : languages.map((l) => `language:${l}`).join(' ');
|
|
192
191
|
// Phase 0 uses a broader query — established contributors don't need beginner labels
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const baseQuery = `is:issue is:open ${labelQuery} ${langQuery} no:assignee`.replace(/ +/g, ' ').trim();
|
|
192
|
+
// Phases 1+ pass labels separately to searchInRepos/searchWithChunkedLabels
|
|
193
|
+
const baseQualifiers = `is:issue is:open ${langQuery} no:assignee`.replace(/ +/g, ' ').trim();
|
|
196
194
|
// Helper to filter issues
|
|
197
195
|
const includeDocIssues = config.includeDocIssues ?? true;
|
|
198
196
|
const aiBlocklisted = new Set(config.aiPolicyBlocklist ?? DEFAULT_CONFIG.aiPolicyBlocklist ?? []);
|
|
@@ -235,7 +233,7 @@ export class IssueDiscovery {
|
|
|
235
233
|
if (mergedPhase0Repos.length > 0) {
|
|
236
234
|
const remainingNeeded = maxResults - allCandidates.length;
|
|
237
235
|
if (remainingNeeded > 0) {
|
|
238
|
-
const { candidates: mergedCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, mergedPhase0Repos,
|
|
236
|
+
const { candidates: mergedCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, mergedPhase0Repos, baseQualifiers, [], remainingNeeded, 'merged_pr', filterIssues);
|
|
239
237
|
allCandidates.push(...mergedCandidates);
|
|
240
238
|
if (allBatchesFailed) {
|
|
241
239
|
phase0Error = 'All merged-PR repo batches failed';
|
|
@@ -251,7 +249,7 @@ export class IssueDiscovery {
|
|
|
251
249
|
if (openPhase0Repos.length > 0 && allCandidates.length < maxResults) {
|
|
252
250
|
const remainingNeeded = maxResults - allCandidates.length;
|
|
253
251
|
if (remainingNeeded > 0) {
|
|
254
|
-
const { candidates: openCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, openPhase0Repos,
|
|
252
|
+
const { candidates: openCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, openPhase0Repos, baseQualifiers, [], remainingNeeded, 'starred', filterIssues);
|
|
255
253
|
allCandidates.push(...openCandidates);
|
|
256
254
|
if (allBatchesFailed) {
|
|
257
255
|
const msg = 'All open-PR repo batches failed';
|
|
@@ -275,16 +273,11 @@ export class IssueDiscovery {
|
|
|
275
273
|
info(MODULE, `Phase 0.5: Searching issues in ${orgsToSearch.length} preferred org(s)...`);
|
|
276
274
|
const remainingNeeded = maxResults - allCandidates.length;
|
|
277
275
|
const orgRepoFilter = orgsToSearch.map((org) => `org:${org}`).join(' OR ');
|
|
278
|
-
const
|
|
276
|
+
const orgOps = orgsToSearch.length - 1;
|
|
279
277
|
try {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
order: 'desc',
|
|
284
|
-
per_page: remainingNeeded * 3,
|
|
285
|
-
});
|
|
286
|
-
if (data.items.length > 0) {
|
|
287
|
-
const filtered = filterIssues(data.items).filter((item) => {
|
|
278
|
+
const allItems = await searchWithChunkedLabels(this.octokit, labels, orgOps, (labelQ) => `${baseQualifiers} ${labelQ} (${orgRepoFilter})`.replace(/ +/g, ' ').trim(), remainingNeeded * 3);
|
|
279
|
+
if (allItems.length > 0) {
|
|
280
|
+
const filtered = filterIssues(allItems).filter((item) => {
|
|
288
281
|
const repoFullName = item.repository_url.split('/').slice(-2).join('/');
|
|
289
282
|
return !phase0RepoSet.has(repoFullName);
|
|
290
283
|
});
|
|
@@ -316,7 +309,7 @@ export class IssueDiscovery {
|
|
|
316
309
|
info(MODULE, `Phase 1: Searching issues in ${reposToSearch.length} starred repos...`);
|
|
317
310
|
const remainingNeeded = maxResults - allCandidates.length;
|
|
318
311
|
if (remainingNeeded > 0) {
|
|
319
|
-
const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, reposToSearch.slice(0, 10),
|
|
312
|
+
const { candidates: starredCandidates, allBatchesFailed, rateLimitHit, } = await searchInRepos(this.octokit, this.vetter, reposToSearch.slice(0, 10), baseQualifiers, labels, remainingNeeded, 'starred', filterIssues);
|
|
320
313
|
allCandidates.push(...starredCandidates);
|
|
321
314
|
if (allBatchesFailed) {
|
|
322
315
|
phase1Error = 'All starred repo batches failed';
|
|
@@ -361,18 +354,11 @@ export class IssueDiscovery {
|
|
|
361
354
|
const budgetPerTier = Math.ceil(remainingNeeded / tierLabelGroups.length);
|
|
362
355
|
const tierResults = [];
|
|
363
356
|
for (const { tier, tierLabels } of tierLabelGroups) {
|
|
364
|
-
const tierQuery = `is:issue is:open ${buildLabelQuery(tierLabels)} ${langQuery} no:assignee`
|
|
365
|
-
.replace(/ +/g, ' ')
|
|
366
|
-
.trim();
|
|
367
357
|
try {
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
per_page: budgetPerTier * 3,
|
|
373
|
-
});
|
|
374
|
-
info(MODULE, `Phase 2 [${tier}]: ${data.total_count} total, processing top ${data.items.length}...`);
|
|
375
|
-
const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(this.vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
|
|
358
|
+
const allItems = await searchWithChunkedLabels(this.octokit, tierLabels, 0, // no repo/org ORs in Phase 2
|
|
359
|
+
(labelQ) => `${baseQualifiers} ${labelQ}`.replace(/ +/g, ' ').trim(), budgetPerTier * 3);
|
|
360
|
+
info(MODULE, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
|
|
361
|
+
const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit, } = await filterVetAndScore(this.vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
|
|
376
362
|
tierResults.push(tierCandidates);
|
|
377
363
|
// Update seenRepos so later tiers don't return duplicate repos
|
|
378
364
|
for (const c of tierCandidates)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* and computing aggregate statistics. Mutation functions modify
|
|
5
5
|
* the passed state object in place; query functions are pure.
|
|
6
6
|
*/
|
|
7
|
-
import { AgentState, RepoScore, RepoScoreUpdate } from './types.js';
|
|
7
|
+
import { AgentState, RepoScore, RepoScoreUpdate, StoredMergedPR, StoredClosedPR } from './types.js';
|
|
8
8
|
/**
|
|
9
9
|
* Calculate the score based on the repo's metrics.
|
|
10
10
|
* Base 5, logarithmic merge bonus (max +5), -1 per closed without merge (max -3),
|
|
@@ -70,6 +70,12 @@ export interface Stats {
|
|
|
70
70
|
/** Number of PRs needing a response. Always 0 in v2 (sourced from fresh fetch instead). */
|
|
71
71
|
needsResponse: number;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Reconcile repoScores merged/closed counts with the stored PR arrays.
|
|
75
|
+
* Counts PRs per repo from the arrays and bumps repoScores counters when
|
|
76
|
+
* the array count is higher (never decreases). Returns true if any updates were made.
|
|
77
|
+
*/
|
|
78
|
+
export declare function reconcilePRCounts(state: AgentState, mergedPRs: StoredMergedPR[], closedPRs: StoredClosedPR[]): boolean;
|
|
73
79
|
/**
|
|
74
80
|
* Compute aggregate statistics from the current state.
|
|
75
81
|
*/
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { isBelowMinStars } from './types.js';
|
|
8
8
|
import { debug, warn } from './logger.js';
|
|
9
|
+
import { parseGitHubUrl } from './utils.js';
|
|
9
10
|
const MODULE = 'scoring';
|
|
10
11
|
/** Repo scores older than this are considered stale and excluded from low-scoring lists. */
|
|
11
12
|
const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
@@ -178,6 +179,49 @@ export function getLowScoringRepos(state, maxScore) {
|
|
|
178
179
|
.sort((a, b) => a.score - b.score)
|
|
179
180
|
.map((rs) => rs.repo);
|
|
180
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Count PRs per repo from an array of stored PR records.
|
|
184
|
+
* Skips entries with unparseable URLs.
|
|
185
|
+
*/
|
|
186
|
+
function countByRepo(prs) {
|
|
187
|
+
const counts = new Map();
|
|
188
|
+
for (const pr of prs) {
|
|
189
|
+
const parsed = parseGitHubUrl(pr.url);
|
|
190
|
+
if (!parsed) {
|
|
191
|
+
warn(MODULE, `Skipping PR with unparseable URL during reconciliation: "${pr.url}"`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const repo = `${parsed.owner}/${parsed.repo}`;
|
|
195
|
+
counts.set(repo, (counts.get(repo) ?? 0) + 1);
|
|
196
|
+
}
|
|
197
|
+
return counts;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Reconcile repoScores merged/closed counts with the stored PR arrays.
|
|
201
|
+
* Counts PRs per repo from the arrays and bumps repoScores counters when
|
|
202
|
+
* the array count is higher (never decreases). Returns true if any updates were made.
|
|
203
|
+
*/
|
|
204
|
+
export function reconcilePRCounts(state, mergedPRs, closedPRs) {
|
|
205
|
+
const mergedUpdated = reconcileField(state, countByRepo(mergedPRs), 'mergedPRCount');
|
|
206
|
+
const closedUpdated = reconcileField(state, countByRepo(closedPRs), 'closedWithoutMergeCount');
|
|
207
|
+
return mergedUpdated || closedUpdated;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Reconcile a single counter field across all repos.
|
|
211
|
+
* Returns true if any repo's counter was bumped.
|
|
212
|
+
*/
|
|
213
|
+
function reconcileField(state, countsByRepo, field) {
|
|
214
|
+
let updated = false;
|
|
215
|
+
for (const [repo, arrayCount] of countsByRepo) {
|
|
216
|
+
const current = state.repoScores[repo]?.[field] ?? 0;
|
|
217
|
+
if (arrayCount > current) {
|
|
218
|
+
debug(MODULE, `Reconciling ${repo} ${field}: ${current} → ${arrayCount}`);
|
|
219
|
+
updateRepoScore(state, repo, { [field]: arrayCount });
|
|
220
|
+
updated = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return updated;
|
|
224
|
+
}
|
|
181
225
|
/**
|
|
182
226
|
* Compute aggregate statistics from the current state.
|
|
183
227
|
*/
|
|
@@ -8,6 +8,16 @@ import { Octokit } from '@octokit/rest';
|
|
|
8
8
|
import { type SearchPriority, type IssueCandidate, type IssueScope } from './types.js';
|
|
9
9
|
import { type GitHubSearchItem } from './issue-filtering.js';
|
|
10
10
|
import { IssueVetter } from './issue-vetting.js';
|
|
11
|
+
/** GitHub Search API enforces a max of 5 AND/OR/NOT operators per query. */
|
|
12
|
+
export declare const GITHUB_MAX_BOOLEAN_OPS = 5;
|
|
13
|
+
/**
|
|
14
|
+
* Chunk labels into groups that fit within the operator budget.
|
|
15
|
+
* N labels require N-1 OR operators, so maxPerChunk = budget + 1.
|
|
16
|
+
*
|
|
17
|
+
* @param labels Full label list
|
|
18
|
+
* @param reservedOps OR operators already consumed by repo/org filters
|
|
19
|
+
*/
|
|
20
|
+
export declare function chunkLabels(labels: string[], reservedOps?: number): string[][];
|
|
11
21
|
/** Build a GitHub Search API label filter from a list of labels. */
|
|
12
22
|
export declare function buildLabelQuery(labels: string[]): string;
|
|
13
23
|
/** Resolve scope tiers into a flat label list, merged with custom labels. */
|
|
@@ -30,6 +40,20 @@ export declare function cachedSearchIssues(octokit: Octokit, params: {
|
|
|
30
40
|
total_count: number;
|
|
31
41
|
items: GitHubSearchItem[];
|
|
32
42
|
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Search across chunked labels with deduplication.
|
|
45
|
+
*
|
|
46
|
+
* Splits labels into chunks that fit within GitHub's boolean operator budget,
|
|
47
|
+
* issues one search query per chunk, deduplicates results by URL, and returns
|
|
48
|
+
* the merged item list.
|
|
49
|
+
*
|
|
50
|
+
* @param octokit Authenticated Octokit instance
|
|
51
|
+
* @param labels Full label list to chunk
|
|
52
|
+
* @param reservedOps OR operators already consumed by repo/org filters in the query
|
|
53
|
+
* @param buildQuery Callback that receives a label query string and returns the full search query
|
|
54
|
+
* @param perPage Number of results per API call
|
|
55
|
+
*/
|
|
56
|
+
export declare function searchWithChunkedLabels(octokit: Octokit, labels: string[], reservedOps: number, buildQuery: (labelQuery: string) => string, perPage: number): Promise<GitHubSearchItem[]>;
|
|
33
57
|
/**
|
|
34
58
|
* Shared pipeline: spam-filter, repo-exclusion, vetting, and star-count filter.
|
|
35
59
|
* Used by Phases 2 and 3 to convert raw search results into vetted candidates.
|
|
@@ -46,9 +70,13 @@ export declare function filterVetAndScore(vetter: IssueVetter, items: GitHubSear
|
|
|
46
70
|
* multiple repos into a single search query using OR syntax:
|
|
47
71
|
* repo:owner1/repo1 OR repo:owner2/repo2 OR repo:owner3/repo3
|
|
48
72
|
*
|
|
49
|
-
*
|
|
73
|
+
* Labels are chunked separately to stay within GitHub's 5 boolean operator limit.
|
|
74
|
+
* Each batch of repos consumes (batch.length - 1) OR operators, and the remaining
|
|
75
|
+
* budget is used for label OR operators.
|
|
76
|
+
*
|
|
77
|
+
* This reduces API calls from N (one per repo) to ceil(N/BATCH_SIZE) * label_chunks.
|
|
50
78
|
*/
|
|
51
|
-
export declare function searchInRepos(octokit: Octokit, vetter: IssueVetter, repos: string[],
|
|
79
|
+
export declare function searchInRepos(octokit: Octokit, vetter: IssueVetter, repos: string[], baseQualifiers: string, labels: string[], maxResults: number, priority: SearchPriority, filterFn: (items: GitHubSearchItem[]) => GitHubSearchItem[]): Promise<{
|
|
52
80
|
candidates: IssueCandidate[];
|
|
53
81
|
allBatchesFailed: boolean;
|
|
54
82
|
rateLimitHit: boolean;
|
|
@@ -10,6 +10,40 @@ import { debug, warn } from './logger.js';
|
|
|
10
10
|
import { getHttpCache, cachedTimeBased } from './http-cache.js';
|
|
11
11
|
import { detectLabelFarmingRepos } from './issue-filtering.js';
|
|
12
12
|
const MODULE = 'search-phases';
|
|
13
|
+
/** GitHub Search API enforces a max of 5 AND/OR/NOT operators per query. */
|
|
14
|
+
export const GITHUB_MAX_BOOLEAN_OPS = 5;
|
|
15
|
+
/** Small delay between search API calls to avoid secondary rate limits. */
|
|
16
|
+
const INTER_QUERY_DELAY_MS = 500;
|
|
17
|
+
/** Batch size for repo queries. 3 repos = 2 OR operators, leaving room for labels. */
|
|
18
|
+
const BATCH_SIZE = 3;
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Chunk labels into groups that fit within the operator budget.
|
|
24
|
+
* N labels require N-1 OR operators, so maxPerChunk = budget + 1.
|
|
25
|
+
*
|
|
26
|
+
* @param labels Full label list
|
|
27
|
+
* @param reservedOps OR operators already consumed by repo/org filters
|
|
28
|
+
*/
|
|
29
|
+
export function chunkLabels(labels, reservedOps = 0) {
|
|
30
|
+
const maxPerChunk = GITHUB_MAX_BOOLEAN_OPS - reservedOps + 1;
|
|
31
|
+
if (maxPerChunk < 1) {
|
|
32
|
+
if (labels.length > 0) {
|
|
33
|
+
warn(MODULE, `Label filtering disabled: ${reservedOps} repo/org ORs exceed GitHub's ${GITHUB_MAX_BOOLEAN_OPS} operator limit. ` +
|
|
34
|
+
`All ${labels.length} label(s) dropped from query.`);
|
|
35
|
+
}
|
|
36
|
+
return [[]];
|
|
37
|
+
}
|
|
38
|
+
if (labels.length <= maxPerChunk)
|
|
39
|
+
return [labels];
|
|
40
|
+
const chunks = [];
|
|
41
|
+
for (let i = 0; i < labels.length; i += maxPerChunk) {
|
|
42
|
+
chunks.push(labels.slice(i, i + maxPerChunk));
|
|
43
|
+
}
|
|
44
|
+
debug(MODULE, `Split ${labels.length} labels into ${chunks.length} chunks (${reservedOps} ops reserved, max ${maxPerChunk} per chunk)`);
|
|
45
|
+
return chunks;
|
|
46
|
+
}
|
|
13
47
|
// ── Pure utilities ──
|
|
14
48
|
/** Build a GitHub Search API label filter from a list of labels. */
|
|
15
49
|
export function buildLabelQuery(labels) {
|
|
@@ -66,6 +100,42 @@ export async function cachedSearchIssues(octokit, params) {
|
|
|
66
100
|
});
|
|
67
101
|
}
|
|
68
102
|
// ── Search infrastructure ──
|
|
103
|
+
/**
|
|
104
|
+
* Search across chunked labels with deduplication.
|
|
105
|
+
*
|
|
106
|
+
* Splits labels into chunks that fit within GitHub's boolean operator budget,
|
|
107
|
+
* issues one search query per chunk, deduplicates results by URL, and returns
|
|
108
|
+
* the merged item list.
|
|
109
|
+
*
|
|
110
|
+
* @param octokit Authenticated Octokit instance
|
|
111
|
+
* @param labels Full label list to chunk
|
|
112
|
+
* @param reservedOps OR operators already consumed by repo/org filters in the query
|
|
113
|
+
* @param buildQuery Callback that receives a label query string and returns the full search query
|
|
114
|
+
* @param perPage Number of results per API call
|
|
115
|
+
*/
|
|
116
|
+
export async function searchWithChunkedLabels(octokit, labels, reservedOps, buildQuery, perPage) {
|
|
117
|
+
const labelChunks = chunkLabels(labels, reservedOps);
|
|
118
|
+
const seenUrls = new Set();
|
|
119
|
+
const allItems = [];
|
|
120
|
+
for (let i = 0; i < labelChunks.length; i++) {
|
|
121
|
+
if (i > 0)
|
|
122
|
+
await sleep(INTER_QUERY_DELAY_MS);
|
|
123
|
+
const query = buildQuery(buildLabelQuery(labelChunks[i]));
|
|
124
|
+
const data = await cachedSearchIssues(octokit, {
|
|
125
|
+
q: query,
|
|
126
|
+
sort: 'created',
|
|
127
|
+
order: 'desc',
|
|
128
|
+
per_page: perPage,
|
|
129
|
+
});
|
|
130
|
+
for (const item of data.items) {
|
|
131
|
+
if (!seenUrls.has(item.html_url)) {
|
|
132
|
+
seenUrls.add(item.html_url);
|
|
133
|
+
allItems.push(item);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return allItems;
|
|
138
|
+
}
|
|
69
139
|
/**
|
|
70
140
|
* Shared pipeline: spam-filter, repo-exclusion, vetting, and star-count filter.
|
|
71
141
|
* Used by Phases 2 and 3 to convert raw search results into vetted candidates.
|
|
@@ -108,11 +178,14 @@ export async function filterVetAndScore(vetter, items, filterIssues, excludedRep
|
|
|
108
178
|
* multiple repos into a single search query using OR syntax:
|
|
109
179
|
* repo:owner1/repo1 OR repo:owner2/repo2 OR repo:owner3/repo3
|
|
110
180
|
*
|
|
111
|
-
*
|
|
181
|
+
* Labels are chunked separately to stay within GitHub's 5 boolean operator limit.
|
|
182
|
+
* Each batch of repos consumes (batch.length - 1) OR operators, and the remaining
|
|
183
|
+
* budget is used for label OR operators.
|
|
184
|
+
*
|
|
185
|
+
* This reduces API calls from N (one per repo) to ceil(N/BATCH_SIZE) * label_chunks.
|
|
112
186
|
*/
|
|
113
|
-
export async function searchInRepos(octokit, vetter, repos,
|
|
187
|
+
export async function searchInRepos(octokit, vetter, repos, baseQualifiers, labels, maxResults, priority, filterFn) {
|
|
114
188
|
const candidates = [];
|
|
115
|
-
const BATCH_SIZE = 5;
|
|
116
189
|
const batches = batchRepos(repos, BATCH_SIZE);
|
|
117
190
|
let failedBatches = 0;
|
|
118
191
|
let rateLimitFailures = 0;
|
|
@@ -120,17 +193,12 @@ export async function searchInRepos(octokit, vetter, repos, baseQuery, maxResult
|
|
|
120
193
|
if (candidates.length >= maxResults)
|
|
121
194
|
break;
|
|
122
195
|
try {
|
|
123
|
-
// Build repo filter: (repo:a OR repo:b OR repo:c)
|
|
124
196
|
const repoFilter = batch.map((r) => `repo:${r}`).join(' OR ');
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
per_page: Math.min(30, (maxResults - candidates.length) * 3),
|
|
131
|
-
});
|
|
132
|
-
if (data.items.length > 0) {
|
|
133
|
-
const filtered = filterFn(data.items);
|
|
197
|
+
const repoOps = batch.length - 1;
|
|
198
|
+
const perPage = Math.min(30, (maxResults - candidates.length) * 3);
|
|
199
|
+
const allItems = await searchWithChunkedLabels(octokit, labels, repoOps, (labelQ) => `${baseQualifiers} ${labelQ} (${repoFilter})`.replace(/ +/g, ' ').trim(), perPage);
|
|
200
|
+
if (allItems.length > 0) {
|
|
201
|
+
const filtered = filterFn(allItems);
|
|
134
202
|
const remainingNeeded = maxResults - candidates.length;
|
|
135
203
|
const { candidates: vetted } = await vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, priority);
|
|
136
204
|
candidates.push(...vetted);
|
package/dist/core/state.d.ts
CHANGED
|
@@ -27,6 +27,11 @@ export declare class StateManager {
|
|
|
27
27
|
* Defaults to false (normal persistent mode).
|
|
28
28
|
*/
|
|
29
29
|
constructor(inMemoryOnly?: boolean);
|
|
30
|
+
/**
|
|
31
|
+
* Attempt PR count reconciliation, logging a warning on failure.
|
|
32
|
+
* Called after every state load from disk.
|
|
33
|
+
*/
|
|
34
|
+
private tryReconcilePRCounts;
|
|
30
35
|
/**
|
|
31
36
|
* Execute multiple mutations as a single batch, deferring disk I/O until the
|
|
32
37
|
* batch completes. Nested `batch()` calls are flattened — only the outermost saves.
|
|
@@ -262,6 +267,11 @@ export declare class StateManager {
|
|
|
262
267
|
getLowScoringRepos(maxScore?: number): string[];
|
|
263
268
|
/** Returns aggregate contribution statistics (merge rate, PR counts, repo breakdown). */
|
|
264
269
|
getStats(): Stats;
|
|
270
|
+
/**
|
|
271
|
+
* Reconcile repoScores merged/closed counts with the stored PR arrays.
|
|
272
|
+
* Bumps counters when the array has more PRs than the counter tracks.
|
|
273
|
+
*/
|
|
274
|
+
private reconcilePRCounts;
|
|
265
275
|
}
|
|
266
276
|
/**
|
|
267
277
|
* Get the singleton StateManager instance, creating it on first call.
|
package/dist/core/state.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { loadState, saveState, reloadStateIfChanged, createFreshState } from './state-persistence.js';
|
|
7
7
|
import * as repoScoring from './repo-score-manager.js';
|
|
8
|
-
import { debug } from './logger.js';
|
|
8
|
+
import { debug, warn } from './logger.js';
|
|
9
|
+
import { errorMessage } from './errors.js';
|
|
9
10
|
export { acquireLock, releaseLock, atomicWriteFileSync } from './state-persistence.js';
|
|
10
11
|
const MODULE = 'state';
|
|
11
12
|
// Maximum number of events to retain in the event log
|
|
@@ -38,6 +39,20 @@ export class StateManager {
|
|
|
38
39
|
const result = loadState();
|
|
39
40
|
this.state = result.state;
|
|
40
41
|
this.lastLoadedMtimeMs = result.mtimeMs;
|
|
42
|
+
this.tryReconcilePRCounts();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Attempt PR count reconciliation, logging a warning on failure.
|
|
47
|
+
* Called after every state load from disk.
|
|
48
|
+
*/
|
|
49
|
+
tryReconcilePRCounts() {
|
|
50
|
+
try {
|
|
51
|
+
this.reconcilePRCounts();
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
warn(MODULE, `PR count reconciliation failed (will retry on next load): ${errorMessage(err)}`);
|
|
55
|
+
debug(MODULE, `Reconciliation error details: ${err instanceof Error ? err.stack : String(err)}`);
|
|
41
56
|
}
|
|
42
57
|
}
|
|
43
58
|
/**
|
|
@@ -130,6 +145,7 @@ export class StateManager {
|
|
|
130
145
|
return false;
|
|
131
146
|
this.state = result.state;
|
|
132
147
|
this.lastLoadedMtimeMs = result.mtimeMs;
|
|
148
|
+
this.tryReconcilePRCounts();
|
|
133
149
|
return true;
|
|
134
150
|
}
|
|
135
151
|
// === Dashboard Data Setters ===
|
|
@@ -558,6 +574,19 @@ export class StateManager {
|
|
|
558
574
|
getStats() {
|
|
559
575
|
return repoScoring.getStats(this.state);
|
|
560
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* Reconcile repoScores merged/closed counts with the stored PR arrays.
|
|
579
|
+
* Bumps counters when the array has more PRs than the counter tracks.
|
|
580
|
+
*/
|
|
581
|
+
reconcilePRCounts() {
|
|
582
|
+
const merged = this.state.mergedPRs ?? [];
|
|
583
|
+
const closed = this.state.closedPRs ?? [];
|
|
584
|
+
if (merged.length === 0 && closed.length === 0)
|
|
585
|
+
return;
|
|
586
|
+
const updated = repoScoring.reconcilePRCounts(this.state, merged, closed);
|
|
587
|
+
if (updated)
|
|
588
|
+
this.autoSave();
|
|
589
|
+
}
|
|
561
590
|
}
|
|
562
591
|
// Singleton instance
|
|
563
592
|
let stateManager = null;
|