@oss-scout/core 0.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 +114 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +341 -0
- package/dist/commands/config.d.ts +22 -0
- package/dist/commands/config.js +169 -0
- package/dist/commands/results.d.ts +8 -0
- package/dist/commands/results.js +13 -0
- package/dist/commands/search.d.ts +39 -0
- package/dist/commands/search.js +50 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +104 -0
- package/dist/commands/validation.d.ts +6 -0
- package/dist/commands/validation.js +17 -0
- package/dist/commands/vet-list.d.ts +9 -0
- package/dist/commands/vet-list.js +16 -0
- package/dist/commands/vet.d.ts +25 -0
- package/dist/commands/vet.js +29 -0
- package/dist/core/bootstrap.d.ts +14 -0
- package/dist/core/bootstrap.js +122 -0
- package/dist/core/category-mapping.d.ts +19 -0
- package/dist/core/category-mapping.js +58 -0
- package/dist/core/concurrency.d.ts +6 -0
- package/dist/core/concurrency.js +25 -0
- package/dist/core/errors.d.ts +22 -0
- package/dist/core/errors.js +69 -0
- package/dist/core/gist-state-store.d.ts +96 -0
- package/dist/core/gist-state-store.js +302 -0
- package/dist/core/github.d.ts +16 -0
- package/dist/core/github.js +58 -0
- package/dist/core/http-cache.d.ts +108 -0
- package/dist/core/http-cache.js +314 -0
- package/dist/core/issue-discovery.d.ts +93 -0
- package/dist/core/issue-discovery.js +475 -0
- package/dist/core/issue-eligibility.d.ts +33 -0
- package/dist/core/issue-eligibility.js +151 -0
- package/dist/core/issue-filtering.d.ts +51 -0
- package/dist/core/issue-filtering.js +103 -0
- package/dist/core/issue-scoring.d.ts +43 -0
- package/dist/core/issue-scoring.js +97 -0
- package/dist/core/issue-vetting.d.ts +44 -0
- package/dist/core/issue-vetting.js +270 -0
- package/dist/core/local-state.d.ts +16 -0
- package/dist/core/local-state.js +56 -0
- package/dist/core/logger.d.ts +11 -0
- package/dist/core/logger.js +25 -0
- package/dist/core/pagination.d.ts +7 -0
- package/dist/core/pagination.js +16 -0
- package/dist/core/repo-health.d.ts +19 -0
- package/dist/core/repo-health.js +179 -0
- package/dist/core/schemas.d.ts +315 -0
- package/dist/core/schemas.js +137 -0
- package/dist/core/search-budget.d.ts +62 -0
- package/dist/core/search-budget.js +129 -0
- package/dist/core/search-phases.d.ts +69 -0
- package/dist/core/search-phases.js +238 -0
- package/dist/core/types.d.ts +124 -0
- package/dist/core/types.js +9 -0
- package/dist/core/utils.d.ts +18 -0
- package/dist/core/utils.js +106 -0
- package/dist/formatters/json.d.ts +6 -0
- package/dist/formatters/json.js +20 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +25 -0
- package/dist/scout.d.ts +125 -0
- package/dist/scout.js +391 -0
- package/package.json +70 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gist-backed state persistence for oss-scout.
|
|
3
|
+
*
|
|
4
|
+
* Stores ScoutState as a private GitHub Gist, with a local file cache
|
|
5
|
+
* as fallback when the API is unavailable.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { ScoutStateSchema } from './schemas.js';
|
|
10
|
+
import { getDataDir } from './utils.js';
|
|
11
|
+
import { debug, warn } from './logger.js';
|
|
12
|
+
import { errorMessage } from './errors.js';
|
|
13
|
+
const MODULE = 'gist-state';
|
|
14
|
+
const GIST_DESCRIPTION = 'oss-scout-state';
|
|
15
|
+
const GIST_FILENAME = 'state.json';
|
|
16
|
+
const GIST_ID_FILE = 'gist-id';
|
|
17
|
+
const CACHE_FILE = 'state-cache.json';
|
|
18
|
+
const SEARCH_MAX_PAGES = 5;
|
|
19
|
+
function getGistIdPath() {
|
|
20
|
+
return path.join(getDataDir(), GIST_ID_FILE);
|
|
21
|
+
}
|
|
22
|
+
function getCachePath() {
|
|
23
|
+
return path.join(getDataDir(), CACHE_FILE);
|
|
24
|
+
}
|
|
25
|
+
export class GistStateStore {
|
|
26
|
+
octokit;
|
|
27
|
+
gistId = null;
|
|
28
|
+
constructor(octokit) {
|
|
29
|
+
this.octokit = octokit;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Bootstrap: find an existing gist or create a new one.
|
|
33
|
+
* Falls back to local cache if the API is unavailable.
|
|
34
|
+
*/
|
|
35
|
+
async bootstrap() {
|
|
36
|
+
try {
|
|
37
|
+
return await this.bootstrapFromApi();
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
warn(MODULE, `API bootstrap failed: ${errorMessage(err)}`);
|
|
41
|
+
return this.bootstrapFromCache();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Push state to the gist. Also writes to local cache as fallback.
|
|
46
|
+
*/
|
|
47
|
+
async push(state) {
|
|
48
|
+
this.writeCache(state);
|
|
49
|
+
if (!this.gistId) {
|
|
50
|
+
warn(MODULE, 'No gist ID — cannot push');
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await this.octokit.gists.update({
|
|
55
|
+
gist_id: this.gistId,
|
|
56
|
+
files: {
|
|
57
|
+
[GIST_FILENAME]: { content: JSON.stringify(state, null, 2) },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
debug(MODULE, 'State pushed to gist');
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
warn(MODULE, `Failed to push: ${errorMessage(err)}`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Pull state from the gist and merge with local state.
|
|
70
|
+
*/
|
|
71
|
+
async pull() {
|
|
72
|
+
if (!this.gistId)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const state = await this.fetchGistState(this.gistId);
|
|
76
|
+
if (state) {
|
|
77
|
+
this.writeCache(state);
|
|
78
|
+
}
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
warn(MODULE, `Failed to pull: ${errorMessage(err)}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Get the current gist ID (if known). */
|
|
87
|
+
getGistId() {
|
|
88
|
+
return this.gistId;
|
|
89
|
+
}
|
|
90
|
+
// ── API bootstrap flow ───────────────────────────────────────────────
|
|
91
|
+
async bootstrapFromApi() {
|
|
92
|
+
// 1. Check local gist-id cache
|
|
93
|
+
const cachedId = this.readCachedGistId();
|
|
94
|
+
if (cachedId) {
|
|
95
|
+
debug(MODULE, `Trying cached gist ID: ${cachedId}`);
|
|
96
|
+
try {
|
|
97
|
+
const state = await this.fetchGistState(cachedId);
|
|
98
|
+
if (state) {
|
|
99
|
+
this.gistId = cachedId;
|
|
100
|
+
this.writeCache(state);
|
|
101
|
+
return { gistId: cachedId, state, created: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
debug(MODULE, `Cached gist ID invalid: ${errorMessage(err)}`);
|
|
106
|
+
}
|
|
107
|
+
debug(MODULE, 'Cached gist ID invalid, searching...');
|
|
108
|
+
}
|
|
109
|
+
// 2. Search user's gists
|
|
110
|
+
const foundId = await this.searchForGist();
|
|
111
|
+
if (foundId) {
|
|
112
|
+
debug(MODULE, `Found gist via search: ${foundId}`);
|
|
113
|
+
this.saveGistId(foundId);
|
|
114
|
+
this.gistId = foundId;
|
|
115
|
+
const state = await this.fetchGistState(foundId);
|
|
116
|
+
if (state) {
|
|
117
|
+
this.writeCache(state);
|
|
118
|
+
return { gistId: foundId, state, created: false };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// 3. Create new gist
|
|
122
|
+
debug(MODULE, 'No existing gist found, creating new one');
|
|
123
|
+
const freshState = ScoutStateSchema.parse({ version: 1 });
|
|
124
|
+
const newId = await this.createGist(freshState);
|
|
125
|
+
this.saveGistId(newId);
|
|
126
|
+
this.gistId = newId;
|
|
127
|
+
this.writeCache(freshState);
|
|
128
|
+
return { gistId: newId, state: freshState, created: true };
|
|
129
|
+
}
|
|
130
|
+
bootstrapFromCache() {
|
|
131
|
+
const cached = this.readCache();
|
|
132
|
+
if (cached) {
|
|
133
|
+
debug(MODULE, 'Bootstrapped from local cache (degraded mode)');
|
|
134
|
+
const cachedId = this.readCachedGistId();
|
|
135
|
+
if (cachedId)
|
|
136
|
+
this.gistId = cachedId;
|
|
137
|
+
return {
|
|
138
|
+
gistId: cachedId ?? '',
|
|
139
|
+
state: cached,
|
|
140
|
+
created: false,
|
|
141
|
+
degraded: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
debug(MODULE, 'No cache available, using fresh state (degraded mode)');
|
|
145
|
+
const fresh = ScoutStateSchema.parse({ version: 1 });
|
|
146
|
+
return { gistId: '', state: fresh, created: false, degraded: true };
|
|
147
|
+
}
|
|
148
|
+
// ── Gist API operations ──────────────────────────────────────────────
|
|
149
|
+
async fetchGistState(gistId) {
|
|
150
|
+
const { data } = await this.octokit.gists.get({ gist_id: gistId });
|
|
151
|
+
const file = data.files?.[GIST_FILENAME];
|
|
152
|
+
if (!file?.content)
|
|
153
|
+
return null;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(file.content);
|
|
156
|
+
return ScoutStateSchema.parse(parsed);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
warn(MODULE, `Gist content failed validation: ${errorMessage(err)}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async searchForGist() {
|
|
164
|
+
for (let page = 1; page <= SEARCH_MAX_PAGES; page++) {
|
|
165
|
+
const { data: gists } = await this.octokit.gists.list({
|
|
166
|
+
per_page: 100,
|
|
167
|
+
page,
|
|
168
|
+
});
|
|
169
|
+
if (gists.length === 0)
|
|
170
|
+
break;
|
|
171
|
+
const match = gists.find((g) => g.description === GIST_DESCRIPTION);
|
|
172
|
+
if (match)
|
|
173
|
+
return match.id;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
async createGist(state) {
|
|
178
|
+
const { data } = await this.octokit.gists.create({
|
|
179
|
+
description: GIST_DESCRIPTION,
|
|
180
|
+
public: false,
|
|
181
|
+
files: {
|
|
182
|
+
[GIST_FILENAME]: { content: JSON.stringify(state, null, 2) },
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
return data.id;
|
|
186
|
+
}
|
|
187
|
+
// ── Local file helpers ───────────────────────────────────────────────
|
|
188
|
+
readCachedGistId() {
|
|
189
|
+
try {
|
|
190
|
+
const id = fs.readFileSync(getGistIdPath(), 'utf-8').trim();
|
|
191
|
+
return id || null;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
const code = err?.code;
|
|
195
|
+
if (code !== 'ENOENT') {
|
|
196
|
+
warn(MODULE, `Failed to read cached gist ID: ${errorMessage(err)}`);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
saveGistId(id) {
|
|
202
|
+
fs.writeFileSync(getGistIdPath(), id + '\n', { mode: 0o600 });
|
|
203
|
+
}
|
|
204
|
+
readCache() {
|
|
205
|
+
try {
|
|
206
|
+
const raw = fs.readFileSync(getCachePath(), 'utf-8');
|
|
207
|
+
return ScoutStateSchema.parse(JSON.parse(raw));
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const code = err?.code;
|
|
211
|
+
if (code !== 'ENOENT') {
|
|
212
|
+
warn(MODULE, `Failed to read state cache: ${errorMessage(err)}`);
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
writeCache(state) {
|
|
218
|
+
try {
|
|
219
|
+
fs.writeFileSync(getCachePath(), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
warn(MODULE, `Failed to write cache: ${errorMessage(err)}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// ── State merging ────────────────────────────────────────────────────
|
|
227
|
+
/**
|
|
228
|
+
* Merge two ScoutState objects with conflict resolution:
|
|
229
|
+
* - repoScores: per-repo, keep the one with more total PR activity
|
|
230
|
+
* - mergedPRs/closedPRs: union by URL
|
|
231
|
+
* - preferences: remote wins
|
|
232
|
+
* - starredRepos: keep the list with the fresher timestamp
|
|
233
|
+
* - savedResults: union by issueUrl, keep newer lastSeenAt
|
|
234
|
+
*/
|
|
235
|
+
export function mergeStates(local, remote) {
|
|
236
|
+
return {
|
|
237
|
+
version: 1,
|
|
238
|
+
preferences: remote.preferences,
|
|
239
|
+
repoScores: mergeRepoScores(local.repoScores, remote.repoScores),
|
|
240
|
+
starredRepos: mergeStarredRepos(local, remote),
|
|
241
|
+
starredReposLastFetched: pickFresherTimestamp(local.starredReposLastFetched, remote.starredReposLastFetched),
|
|
242
|
+
mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
|
|
243
|
+
closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
|
|
244
|
+
savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
|
|
245
|
+
lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
|
|
246
|
+
lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ?? new Date().toISOString(),
|
|
247
|
+
gistId: remote.gistId ?? local.gistId,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function mergeRepoScores(local, remote) {
|
|
251
|
+
const merged = { ...local };
|
|
252
|
+
for (const [repo, remoteScore] of Object.entries(remote)) {
|
|
253
|
+
const localScore = merged[repo];
|
|
254
|
+
if (!localScore) {
|
|
255
|
+
merged[repo] = remoteScore;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const localActivity = localScore.mergedPRCount + localScore.closedWithoutMergeCount;
|
|
259
|
+
const remoteActivity = remoteScore.mergedPRCount + remoteScore.closedWithoutMergeCount;
|
|
260
|
+
merged[repo] = remoteActivity >= localActivity ? remoteScore : localScore;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return merged;
|
|
264
|
+
}
|
|
265
|
+
function mergeStarredRepos(local, remote) {
|
|
266
|
+
const localTs = local.starredReposLastFetched;
|
|
267
|
+
const remoteTs = remote.starredReposLastFetched;
|
|
268
|
+
if (!localTs && !remoteTs)
|
|
269
|
+
return remote.starredRepos.length >= local.starredRepos.length ? remote.starredRepos : local.starredRepos;
|
|
270
|
+
if (!localTs)
|
|
271
|
+
return remote.starredRepos;
|
|
272
|
+
if (!remoteTs)
|
|
273
|
+
return local.starredRepos;
|
|
274
|
+
return remoteTs >= localTs ? remote.starredRepos : local.starredRepos;
|
|
275
|
+
}
|
|
276
|
+
function unionByUrl(local, remote) {
|
|
277
|
+
const seen = new Map();
|
|
278
|
+
for (const item of local)
|
|
279
|
+
seen.set(item.url, item);
|
|
280
|
+
for (const item of remote)
|
|
281
|
+
seen.set(item.url, item);
|
|
282
|
+
return [...seen.values()];
|
|
283
|
+
}
|
|
284
|
+
function mergeSavedResults(local, remote) {
|
|
285
|
+
const merged = new Map();
|
|
286
|
+
for (const item of local)
|
|
287
|
+
merged.set(item.issueUrl, item);
|
|
288
|
+
for (const item of remote) {
|
|
289
|
+
const existing = merged.get(item.issueUrl);
|
|
290
|
+
if (!existing || item.lastSeenAt > existing.lastSeenAt) {
|
|
291
|
+
merged.set(item.issueUrl, item);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return [...merged.values()];
|
|
295
|
+
}
|
|
296
|
+
function pickFresherTimestamp(a, b) {
|
|
297
|
+
if (!a)
|
|
298
|
+
return b;
|
|
299
|
+
if (!b)
|
|
300
|
+
return a;
|
|
301
|
+
return a >= b ? a : b;
|
|
302
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GitHub API client with rate limiting and throttling.
|
|
3
|
+
*/
|
|
4
|
+
import { Octokit } from '@octokit/rest';
|
|
5
|
+
interface RateLimitInfo {
|
|
6
|
+
remaining: number;
|
|
7
|
+
limit: number;
|
|
8
|
+
resetAt: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getRateLimitCallbacks(): {
|
|
11
|
+
onRateLimit: (retryAfter: number, options: unknown, _octokit: unknown, retryCount: number) => boolean;
|
|
12
|
+
onSecondaryRateLimit: (retryAfter: number, options: unknown, _octokit: unknown, retryCount: number) => boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function getOctokit(token: string): Octokit;
|
|
15
|
+
export declare function checkRateLimit(token: string): Promise<RateLimitInfo>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GitHub API client with rate limiting and throttling.
|
|
3
|
+
*/
|
|
4
|
+
import { Octokit } from '@octokit/rest';
|
|
5
|
+
import { throttling } from '@octokit/plugin-throttling';
|
|
6
|
+
import { warn } from './logger.js';
|
|
7
|
+
const MODULE = 'github';
|
|
8
|
+
const ThrottledOctokit = Octokit.plugin(throttling);
|
|
9
|
+
let _octokit = null;
|
|
10
|
+
let _currentToken = null;
|
|
11
|
+
function formatResetTime(date) {
|
|
12
|
+
return date.toLocaleTimeString('en-US', { hour12: false });
|
|
13
|
+
}
|
|
14
|
+
export function getRateLimitCallbacks() {
|
|
15
|
+
return {
|
|
16
|
+
onRateLimit: (retryAfter, options, _octokit, retryCount) => {
|
|
17
|
+
const opts = options;
|
|
18
|
+
const resetAt = new Date(Date.now() + retryAfter * 1000);
|
|
19
|
+
if (retryCount < 2) {
|
|
20
|
+
warn(MODULE, `Rate limit hit (retry ${retryCount + 1}/2, waiting ${retryAfter}s, resets at ${formatResetTime(resetAt)}) — ${opts.method} ${opts.url}`);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
warn(MODULE, `Rate limit exceeded, not retrying — ${opts.method} ${opts.url} (resets at ${formatResetTime(resetAt)})`);
|
|
24
|
+
return false;
|
|
25
|
+
},
|
|
26
|
+
onSecondaryRateLimit: (retryAfter, options, _octokit, retryCount) => {
|
|
27
|
+
const opts = options;
|
|
28
|
+
const resetAt = new Date(Date.now() + retryAfter * 1000);
|
|
29
|
+
if (retryCount < 3) {
|
|
30
|
+
warn(MODULE, `Secondary rate limit hit (retry ${retryCount + 1}/3, waiting ${retryAfter}s, resets at ${formatResetTime(resetAt)}) — ${opts.method} ${opts.url}`);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
warn(MODULE, `Secondary rate limit exceeded, not retrying — ${opts.method} ${opts.url} (resets at ${formatResetTime(resetAt)})`);
|
|
34
|
+
return false;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function getOctokit(token) {
|
|
39
|
+
if (_octokit && _currentToken === token)
|
|
40
|
+
return _octokit;
|
|
41
|
+
const callbacks = getRateLimitCallbacks();
|
|
42
|
+
_octokit = new ThrottledOctokit({
|
|
43
|
+
auth: token,
|
|
44
|
+
throttle: callbacks,
|
|
45
|
+
});
|
|
46
|
+
_currentToken = token;
|
|
47
|
+
return _octokit;
|
|
48
|
+
}
|
|
49
|
+
export async function checkRateLimit(token) {
|
|
50
|
+
const octokit = getOctokit(token);
|
|
51
|
+
const { data } = await octokit.rateLimit.get();
|
|
52
|
+
const search = data.resources.search;
|
|
53
|
+
return {
|
|
54
|
+
remaining: search.remaining,
|
|
55
|
+
limit: search.limit,
|
|
56
|
+
resetAt: new Date(search.reset * 1000).toISOString(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP caching with ETags for GitHub API responses.
|
|
3
|
+
*
|
|
4
|
+
* Stores ETags and response bodies for cacheable GET endpoints in
|
|
5
|
+
* `~/.oss-scout/cache/`. On subsequent requests, sends `If-None-Match`
|
|
6
|
+
* headers — 304 responses don't count against GitHub rate limits.
|
|
7
|
+
*
|
|
8
|
+
* Also provides in-flight request deduplication so that concurrent calls
|
|
9
|
+
* for the same endpoint (e.g., star counts for two PRs in the same repo)
|
|
10
|
+
* share a single HTTP round-trip.
|
|
11
|
+
*/
|
|
12
|
+
/** Shape of a single cache entry on disk. */
|
|
13
|
+
interface CacheEntry {
|
|
14
|
+
etag: string;
|
|
15
|
+
url: string;
|
|
16
|
+
body: unknown;
|
|
17
|
+
cachedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* File-based HTTP cache backed by `~/.oss-scout/cache/`.
|
|
21
|
+
*
|
|
22
|
+
* Each cache entry is stored as a separate JSON file keyed by the SHA-256
|
|
23
|
+
* hash of the request URL. This avoids filesystem issues with URL-based
|
|
24
|
+
* filenames and keeps lookup O(1).
|
|
25
|
+
*/
|
|
26
|
+
export declare class HttpCache {
|
|
27
|
+
private readonly cacheDir;
|
|
28
|
+
/** In-flight request deduplication map: URL -> Promise<response>. */
|
|
29
|
+
private readonly inflightRequests;
|
|
30
|
+
constructor(cacheDir?: string);
|
|
31
|
+
/** Derive a filesystem-safe cache key from a URL. */
|
|
32
|
+
private keyFor;
|
|
33
|
+
/** Full path to the cache file for a given URL. */
|
|
34
|
+
private pathFor;
|
|
35
|
+
/**
|
|
36
|
+
* Return the cached body if the entry exists and is younger than `maxAgeMs`.
|
|
37
|
+
* Useful for time-based caching where ETag validation isn't applicable
|
|
38
|
+
* (e.g., caching aggregated results from paginated API calls).
|
|
39
|
+
*/
|
|
40
|
+
getIfFresh(key: string, maxAgeMs: number): unknown | null;
|
|
41
|
+
/**
|
|
42
|
+
* Look up a cached response. Returns `null` if no cache entry exists.
|
|
43
|
+
*/
|
|
44
|
+
get(url: string): CacheEntry | null;
|
|
45
|
+
/**
|
|
46
|
+
* Store a response with its ETag.
|
|
47
|
+
*/
|
|
48
|
+
set(url: string, etag: string, body: unknown): void;
|
|
49
|
+
/**
|
|
50
|
+
* Get the in-flight promise for a URL (for deduplication).
|
|
51
|
+
*/
|
|
52
|
+
getInflight(url: string): Promise<unknown> | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Register an in-flight request for deduplication.
|
|
55
|
+
* Returns a cleanup function to call when the request completes.
|
|
56
|
+
*/
|
|
57
|
+
setInflight(url: string, promise: Promise<unknown>): () => void;
|
|
58
|
+
/**
|
|
59
|
+
* Remove stale entries older than `maxAgeMs` from the cache directory.
|
|
60
|
+
* Intended to be called periodically (e.g., once per search invocation).
|
|
61
|
+
*/
|
|
62
|
+
evictStale(maxAgeMs?: number): number;
|
|
63
|
+
/**
|
|
64
|
+
* Remove all entries from the cache.
|
|
65
|
+
*/
|
|
66
|
+
clear(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Return the number of entries currently in the cache.
|
|
69
|
+
*/
|
|
70
|
+
size(): number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get (or create) the shared HttpCache singleton.
|
|
74
|
+
* The singleton is lazily initialized on first access.
|
|
75
|
+
*/
|
|
76
|
+
export declare function getHttpCache(): HttpCache;
|
|
77
|
+
/**
|
|
78
|
+
* Wraps an Octokit `repos.get`-style call with ETag caching and request
|
|
79
|
+
* deduplication.
|
|
80
|
+
*
|
|
81
|
+
* Usage:
|
|
82
|
+
* ```ts
|
|
83
|
+
* const data = await cachedRequest(cache, octokit, '/repos/owner/repo', () =>
|
|
84
|
+
* octokit.repos.get({ owner, repo: name }),
|
|
85
|
+
* );
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* 1. If an identical request is already in-flight, returns the existing promise
|
|
89
|
+
* (request deduplication).
|
|
90
|
+
* 2. If a cached ETag exists, sends `If-None-Match`. On 304, returns the
|
|
91
|
+
* cached body without consuming a rate-limit point.
|
|
92
|
+
* 3. On a fresh 200, caches the ETag + body for next time.
|
|
93
|
+
*/
|
|
94
|
+
export declare function cachedRequest<T>(cache: HttpCache, url: string, fetcher: (headers: Record<string, string>) => Promise<{
|
|
95
|
+
data: T;
|
|
96
|
+
headers?: Record<string, string>;
|
|
97
|
+
}>): Promise<T>;
|
|
98
|
+
/**
|
|
99
|
+
* Time-based cache wrapper (no ETag / conditional requests).
|
|
100
|
+
*
|
|
101
|
+
* If a cached result exists and is younger than `maxAgeMs`, returns it.
|
|
102
|
+
* Otherwise calls `fetcher`, caches the result, and returns it.
|
|
103
|
+
*
|
|
104
|
+
* Use this for expensive operations whose results change slowly
|
|
105
|
+
* (e.g. search queries, project health checks).
|
|
106
|
+
*/
|
|
107
|
+
export declare function cachedTimeBased<T>(cache: HttpCache, key: string, maxAgeMs: number, fetcher: () => Promise<T>): Promise<T>;
|
|
108
|
+
export {};
|