@oss-autopilot/core 1.3.0 → 1.5.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.
@@ -35,6 +35,7 @@ export async function runSearch(options) {
35
35
  return {
36
36
  issue: {
37
37
  repo: c.issue.repo,
38
+ repoUrl: `https://github.com/${c.issue.repo}`,
38
39
  number: c.issue.number,
39
40
  title: c.issue.title,
40
41
  url: c.issue.url,
@@ -34,6 +34,8 @@ export declare function fetchUserClosedPRCounts(octokit: Octokit, githubUsername
34
34
  */
35
35
  export declare function fetchRecentlyClosedPRs(octokit: Octokit, config: {
36
36
  githubUsername: string;
37
+ excludeRepos?: string[];
38
+ excludeOrgs?: string[];
37
39
  }, days?: number): Promise<ClosedPR[]>;
38
40
  /**
39
41
  * Fetch PRs merged in the last N days.
@@ -41,6 +43,8 @@ export declare function fetchRecentlyClosedPRs(octokit: Octokit, config: {
41
43
  */
42
44
  export declare function fetchRecentlyMergedPRs(octokit: Octokit, config: {
43
45
  githubUsername: string;
46
+ excludeRepos?: string[];
47
+ excludeOrgs?: string[];
44
48
  }, days?: number): Promise<MergedPR[]>;
45
49
  /**
46
50
  * Fetch merged PRs since a watermark date for incremental storage.
@@ -198,6 +198,15 @@ async function fetchRecentPRs(octokit, config, query, label, days, mapItem) {
198
198
  // Skip own repos
199
199
  if (isOwnRepo(parsed.owner, config.githubUsername))
200
200
  continue;
201
+ // Exclude configured repos and orgs (#792)
202
+ if (config.excludeRepos?.includes(repo)) {
203
+ debug(MODULE, `Skipping excluded repo: ${repo}`);
204
+ continue;
205
+ }
206
+ if (config.excludeOrgs?.some((org) => org.toLowerCase() === parsed.owner.toLowerCase())) {
207
+ debug(MODULE, `Skipping excluded org: ${parsed.owner}`);
208
+ continue;
209
+ }
201
210
  results.push(mapItem(item, { owner: parsed.owner, repo, number: parsed.number }));
202
211
  }
203
212
  debug(MODULE, `Found ${results.length} recently ${label} PRs`);
@@ -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 (1 retry).
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
@@ -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 < 1) {
35
- warn(MODULE, `Secondary rate limit hit (retry ${retryCount + 1}/1, waiting ${retryAfter}s, resets at ${formatResetTime(resetAt)}) — ${opts.method} ${opts.url}`);
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 (1 retry).
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
- * 1. Repos where user has merged PRs (highest merge probability)
18
- * 2. Preferred organizations
19
- * 3. Starred repos
20
- * 4. General label-filtered search
21
- * 5. Actively maintained repos
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 starred repos,
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
- * 1. Repos where user has merged PRs (highest merge probability)
30
- * 2. Preferred organizations
31
- * 3. Starred repos
32
- * 4. General label-filtered search
33
- * 5. Actively maintained repos
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 starred repos,
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 search for transient/network errors
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
- if (allCandidates.length < maxResults && starredRepos.length > 0) {
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 failed. ` +
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, DEFAULT_CONCURRENCY } from './utils.js';
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
- const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
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
- return {
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 >= MAX_CONCURRENT_REQUESTS) {
270
+ if (pending.size >= MAX_CONCURRENT_VETTING) {
255
271
  await Promise.race(pending.values());
256
272
  }
257
273
  }
@@ -124,6 +124,15 @@ export class PRMonitor {
124
124
  }
125
125
  if (isOwnRepo(parsed.owner, config.githubUsername))
126
126
  return false;
127
+ // Exclude configured repos and orgs (#792)
128
+ if (config.excludeRepos.includes(`${parsed.owner}/${parsed.repo}`)) {
129
+ debug('pr-monitor', `Skipping excluded repo: ${parsed.owner}/${parsed.repo}`);
130
+ return false;
131
+ }
132
+ if (config.excludeOrgs?.some((org) => org.toLowerCase() === parsed.owner.toLowerCase())) {
133
+ debug('pr-monitor', `Skipping excluded org: ${parsed.owner}`);
134
+ return false;
135
+ }
127
136
  return true;
128
137
  });
129
138
  debug('pr-monitor', `Filtered to ${filteredItems.length} PRs after excluding own repos`);
@@ -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
- /** Small delay between search API calls to avoid secondary rate limits. */
16
- const INTER_QUERY_DELAY_MS = 500;
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 (const batch of batches) {
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) {
@@ -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
  *
@@ -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;
@@ -171,6 +171,7 @@ export interface SearchOutput {
171
171
  candidates: Array<{
172
172
  issue: {
173
173
  repo: string;
174
+ repoUrl: string;
174
175
  number: number;
175
176
  title: string;
176
177
  url: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,13 +54,13 @@
54
54
  "zod": "^4.3.6"
55
55
  },
56
56
  "devDependencies": {
57
- "@types/node": "^25.4.0",
58
- "@vitest/coverage-v8": "^4.0.18",
59
- "esbuild": "^0.27.3",
57
+ "@types/node": "^25.5.0",
58
+ "@vitest/coverage-v8": "^4.1.0",
59
+ "esbuild": "^0.27.4",
60
60
  "tsx": "^4.21.0",
61
61
  "typedoc": "^0.28.17",
62
62
  "typescript": "^5.9.3",
63
- "vitest": "^4.0.16"
63
+ "vitest": "^4.1.0"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "tsc",