@oss-autopilot/core 1.0.0 → 1.0.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.
@@ -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 { buildLabelQuery, buildEffectiveLabels, interleaveArrays, cachedSearchIssues, filterVetAndScore, searchInRepos, } from './search-phases.js';
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
- const establishedQuery = `is:issue is:open ${langQuery} no:assignee`.replace(/ +/g, ' ').trim();
194
- // Phases 1+ use label-filtered query for discovery in unfamiliar repos
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, establishedQuery, remainingNeeded, 'merged_pr', filterIssues);
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, establishedQuery, remainingNeeded, 'starred', filterIssues);
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 orgQuery = `${baseQuery} (${orgRepoFilter})`;
276
+ const orgOps = orgsToSearch.length - 1;
279
277
  try {
280
- const data = await cachedSearchIssues(this.octokit, {
281
- q: orgQuery,
282
- sort: 'created',
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), baseQuery, remainingNeeded, 'starred', filterIssues);
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 data = await cachedSearchIssues(this.octokit, {
369
- q: tierQuery,
370
- sort: 'created',
371
- order: 'desc',
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
- * This reduces API calls from N (one per repo) to ceil(N/BATCH_SIZE).
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[], baseQuery: string, maxResults: number, priority: SearchPriority, filterFn: (items: GitHubSearchItem[]) => GitHubSearchItem[]): Promise<{
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
- * This reduces API calls from N (one per repo) to ceil(N/BATCH_SIZE).
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, baseQuery, maxResults, priority, filterFn) {
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 batchQuery = `${baseQuery} (${repoFilter})`;
126
- const data = await cachedSearchIssues(octokit, {
127
- q: batchQuery,
128
- sort: 'created',
129
- order: 'desc',
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);
@@ -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.
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {