@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.
@@ -0,0 +1,129 @@
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
+ import { debug } from './logger.js';
15
+ import { sleep } from './utils.js';
16
+ const MODULE = 'search-budget';
17
+ /** GitHub Search API rate limit: 30 requests per 60-second rolling window. */
18
+ const SEARCH_RATE_LIMIT = 30;
19
+ const SEARCH_WINDOW_MS = 60 * 1000;
20
+ /** Safety margin: reserve a few calls for retries and cross-process usage. */
21
+ const SAFETY_MARGIN = 4;
22
+ /** Effective budget per window after safety margin. */
23
+ const EFFECTIVE_BUDGET = SEARCH_RATE_LIMIT - SAFETY_MARGIN;
24
+ export class SearchBudgetTracker {
25
+ /** Timestamps of recent Search API calls within the sliding window. */
26
+ callTimestamps = [];
27
+ /** Last known remaining quota from GitHub's rate limit endpoint. */
28
+ knownRemaining = SEARCH_RATE_LIMIT;
29
+ /** Epoch ms when the rate limit window resets (from GitHub API). */
30
+ resetAt = 0;
31
+ /** Total calls recorded since init (for diagnostics). */
32
+ totalCalls = 0;
33
+ /**
34
+ * Initialize with pre-flight rate limit data from GitHub.
35
+ */
36
+ init(remaining, resetAt) {
37
+ this.knownRemaining = remaining;
38
+ this.resetAt = new Date(resetAt).getTime();
39
+ this.callTimestamps = [];
40
+ this.totalCalls = 0;
41
+ debug(MODULE, `Initialized: ${remaining} remaining, resets at ${new Date(this.resetAt).toLocaleTimeString()}`);
42
+ }
43
+ /**
44
+ * Record that a Search API call was just made.
45
+ */
46
+ recordCall() {
47
+ this.callTimestamps.push(Date.now());
48
+ this.totalCalls++;
49
+ this.pruneOldTimestamps();
50
+ }
51
+ /**
52
+ * Remove timestamps older than the sliding window.
53
+ */
54
+ pruneOldTimestamps() {
55
+ const cutoff = Date.now() - SEARCH_WINDOW_MS;
56
+ while (this.callTimestamps.length > 0 && this.callTimestamps[0] < cutoff) {
57
+ this.callTimestamps.shift();
58
+ }
59
+ }
60
+ /**
61
+ * Get the number of calls made in the current sliding window.
62
+ */
63
+ getCallsInWindow() {
64
+ this.pruneOldTimestamps();
65
+ return this.callTimestamps.length;
66
+ }
67
+ /**
68
+ * Get the effective budget, accounting for both the sliding window limit
69
+ * and the pre-flight remaining quota from GitHub.
70
+ */
71
+ getEffectiveBudget() {
72
+ // Use the stricter of: local window limit vs. pre-flight remaining minus calls made
73
+ const localBudget = EFFECTIVE_BUDGET - this.callTimestamps.length;
74
+ const externalBudget = this.knownRemaining - this.totalCalls;
75
+ return Math.max(0, Math.min(localBudget, externalBudget));
76
+ }
77
+ /**
78
+ * Check if we can afford N more Search API calls without exceeding the budget.
79
+ */
80
+ canAfford(n) {
81
+ this.pruneOldTimestamps();
82
+ return this.getEffectiveBudget() >= n;
83
+ }
84
+ /**
85
+ * Wait if necessary to stay within the Search API rate limit.
86
+ * If the sliding window is at capacity, sleeps until the oldest
87
+ * call ages out of the window.
88
+ */
89
+ async waitForBudget() {
90
+ // Loop to handle edge cases where a single sleep isn't enough
91
+ // (e.g., concurrent callers, clock skew, or external budget depletion)
92
+ while (true) {
93
+ this.pruneOldTimestamps();
94
+ if (this.getEffectiveBudget() > 0) {
95
+ return; // Budget available, no wait needed
96
+ }
97
+ // Wait until the oldest call in the window ages out
98
+ const oldestInWindow = this.callTimestamps[0];
99
+ if (!oldestInWindow) {
100
+ return; // No calls in window — budget exhausted by external consumption, can't wait it out
101
+ }
102
+ const waitUntil = oldestInWindow + SEARCH_WINDOW_MS;
103
+ const waitMs = waitUntil - Date.now();
104
+ if (waitMs > 0) {
105
+ debug(MODULE, `Budget full (${this.callTimestamps.length}/${EFFECTIVE_BUDGET} in window), waiting ${waitMs}ms`);
106
+ await sleep(waitMs + 100); // +100ms safety buffer
107
+ }
108
+ }
109
+ }
110
+ /**
111
+ * Get total calls recorded since init (for diagnostics).
112
+ */
113
+ getTotalCalls() {
114
+ return this.totalCalls;
115
+ }
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Singleton
119
+ // ---------------------------------------------------------------------------
120
+ let _tracker = null;
121
+ /**
122
+ * Get (or create) the shared SearchBudgetTracker singleton.
123
+ */
124
+ export function getSearchBudgetTracker() {
125
+ if (!_tracker) {
126
+ _tracker = new SearchBudgetTracker();
127
+ }
128
+ return _tracker;
129
+ }
@@ -10,11 +10,14 @@ 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
  import { sleep } from './utils.js';
13
+ import { getSearchBudgetTracker } from './search-budget.js';
13
14
  const MODULE = 'search-phases';
14
15
  /** GitHub Search API enforces a max of 5 AND/OR/NOT operators per query. */
15
16
  export const GITHUB_MAX_BOOLEAN_OPS = 5;
16
- /** Delay between search API calls to avoid GitHub's secondary rate limit (~30 req/min). */
17
- const INTER_QUERY_DELAY_MS = 1500;
17
+ /** Delay between search API calls to avoid GitHub's secondary rate limit (~30 req/min).
18
+ * Set to 2000ms as a safety floor (max 30/min at the limit). The SearchBudgetTracker
19
+ * adds additional adaptive delays when needed. */
20
+ const INTER_QUERY_DELAY_MS = 2000;
18
21
  /** Batch size for repo queries. 3 repos = 2 OR operators, leaving room for labels. */
19
22
  const BATCH_SIZE = 3;
20
23
  /**
@@ -93,8 +96,16 @@ const SEARCH_CACHE_TTL_MS = 15 * 60 * 1000;
93
96
  export async function cachedSearchIssues(octokit, params) {
94
97
  const cacheKey = `search:${params.q}:${params.sort}:${params.order}:${params.per_page}`;
95
98
  return cachedTimeBased(getHttpCache(), cacheKey, SEARCH_CACHE_TTL_MS, async () => {
96
- const { data } = await octokit.search.issuesAndPullRequests(params);
97
- return data;
99
+ const tracker = getSearchBudgetTracker();
100
+ await tracker.waitForBudget();
101
+ try {
102
+ const { data } = await octokit.search.issuesAndPullRequests(params);
103
+ return data;
104
+ }
105
+ finally {
106
+ // Always record the call — failed requests still consume GitHub rate limit points
107
+ tracker.recordCall();
108
+ }
98
109
  });
99
110
  }
100
111
  // ── Search infrastructure ──
@@ -159,6 +159,8 @@ export interface ProjectHealth {
159
159
  stargazersCount?: number;
160
160
  /** GitHub fork count, used for repo quality scoring (#98). */
161
161
  forksCount?: number;
162
+ /** Primary programming language as reported by GitHub. */
163
+ language?: string | null;
162
164
  /** True if the health check itself failed (e.g., API error). */
163
165
  checkFailed?: boolean;
164
166
  failureReason?: string;
@@ -255,6 +255,7 @@ export interface ParsedIssueItem {
255
255
  title: string;
256
256
  tier: string;
257
257
  url: string;
258
+ score?: number;
258
259
  }
259
260
  /** Output of the parse-issue-list command (#82) */
260
261
  export interface ParseIssueListOutput {
@@ -291,6 +292,9 @@ export interface VetListOutput {
291
292
  hasPR: number;
292
293
  errors: number;
293
294
  };
295
+ pruneResult?: {
296
+ removedCount: number;
297
+ };
294
298
  }
295
299
  /** Output of the vet command */
296
300
  export interface VetOutput {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "1.6.3",
3
+ "version": "1.7.1",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {