@oss-autopilot/core 1.11.0 → 1.12.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.
- package/README.md +8 -5
- package/dist/cli.bundle.cjs +67 -108
- package/dist/commands/daily.js +17 -0
- package/dist/commands/index.d.ts +3 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/scout-bridge.d.ts +15 -0
- package/dist/commands/scout-bridge.js +63 -0
- package/dist/commands/search.d.ts +1 -1
- package/dist/commands/search.js +10 -13
- package/dist/commands/vet-list.d.ts +1 -1
- package/dist/commands/vet-list.js +4 -5
- package/dist/commands/vet.d.ts +1 -1
- package/dist/commands/vet.js +4 -5
- package/dist/core/index.d.ts +0 -2
- package/dist/core/index.js +1 -2
- package/package.json +2 -1
- package/dist/core/category-mapping.d.ts +0 -19
- package/dist/core/category-mapping.js +0 -58
- package/dist/core/issue-discovery.d.ts +0 -94
- package/dist/core/issue-discovery.js +0 -591
- package/dist/core/issue-eligibility.d.ts +0 -38
- package/dist/core/issue-eligibility.js +0 -151
- package/dist/core/issue-filtering.d.ts +0 -51
- package/dist/core/issue-filtering.js +0 -103
- package/dist/core/issue-scoring.d.ts +0 -43
- package/dist/core/issue-scoring.js +0 -97
- package/dist/core/issue-vetting.d.ts +0 -33
- package/dist/core/issue-vetting.js +0 -306
- package/dist/core/repo-health.d.ts +0 -24
- package/dist/core/repo-health.js +0 -194
- package/dist/core/search-budget.d.ts +0 -62
- package/dist/core/search-budget.js +0 -129
- package/dist/core/search-phases.d.ts +0 -83
- package/dist/core/search-phases.js +0 -238
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Issue Vetting — orchestrates individual issue checks and computes
|
|
3
|
-
* recommendation + viability score.
|
|
4
|
-
*
|
|
5
|
-
* Delegates to focused modules (#621):
|
|
6
|
-
* - issue-eligibility.ts — PR existence, claim detection, requirements analysis
|
|
7
|
-
* - repo-health.ts — project health, contribution guidelines
|
|
8
|
-
*/
|
|
9
|
-
import { parseGitHubUrl } from './utils.js';
|
|
10
|
-
import { ValidationError, errorMessage, isRateLimitError } from './errors.js';
|
|
11
|
-
import { debug, warn } from './logger.js';
|
|
12
|
-
import { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
|
|
13
|
-
import { repoBelongsToCategory } from './category-mapping.js';
|
|
14
|
-
import { checkNoExistingPR, checkNotClaimed, checkUserMergedPRsInRepo, analyzeRequirements, } from './issue-eligibility.js';
|
|
15
|
-
import { checkProjectHealth, fetchContributionGuidelines } from './repo-health.js';
|
|
16
|
-
import { getHttpCache } from './http-cache.js';
|
|
17
|
-
const MODULE = 'issue-vetting';
|
|
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;
|
|
22
|
-
export class IssueVetter {
|
|
23
|
-
octokit;
|
|
24
|
-
stateManager;
|
|
25
|
-
constructor(octokit, stateManager) {
|
|
26
|
-
this.octokit = octokit;
|
|
27
|
-
this.stateManager = stateManager;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
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.
|
|
32
|
-
*/
|
|
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
|
-
}
|
|
42
|
-
// Parse URL
|
|
43
|
-
const parsed = parseGitHubUrl(issueUrl);
|
|
44
|
-
if (!parsed || parsed.type !== 'issues') {
|
|
45
|
-
throw new ValidationError(`Invalid issue URL: ${issueUrl}`);
|
|
46
|
-
}
|
|
47
|
-
const { owner, repo, number } = parsed;
|
|
48
|
-
const repoFullName = `${owner}/${repo}`;
|
|
49
|
-
// Fetch issue data
|
|
50
|
-
const { data: ghIssue } = await this.octokit.issues.get({
|
|
51
|
-
owner,
|
|
52
|
-
repo,
|
|
53
|
-
issue_number: number,
|
|
54
|
-
});
|
|
55
|
-
// Check local state first to skip the merged-PR Search API call when
|
|
56
|
-
// the repo already has authoritative data (saves 1 Search call per issue).
|
|
57
|
-
const repoScoreRecord = this.stateManager.getRepoScore(repoFullName);
|
|
58
|
-
const skipMergedPRCheck = repoScoreRecord != null && repoScoreRecord.mergedPRCount > 0;
|
|
59
|
-
// Run all vetting checks in parallel — delegates to standalone functions
|
|
60
|
-
const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount] = await Promise.all([
|
|
61
|
-
checkNoExistingPR(this.octokit, owner, repo, number),
|
|
62
|
-
checkNotClaimed(this.octokit, owner, repo, number, ghIssue.comments),
|
|
63
|
-
checkProjectHealth(this.octokit, owner, repo),
|
|
64
|
-
fetchContributionGuidelines(this.octokit, owner, repo),
|
|
65
|
-
skipMergedPRCheck ? Promise.resolve(0) : checkUserMergedPRsInRepo(this.octokit, owner, repo),
|
|
66
|
-
]);
|
|
67
|
-
const noExistingPR = existingPRCheck.passed;
|
|
68
|
-
const notClaimed = claimCheck.passed;
|
|
69
|
-
// Analyze issue quality
|
|
70
|
-
const clearRequirements = analyzeRequirements(ghIssue.body || '');
|
|
71
|
-
// When the health check itself failed (API error), use a neutral default:
|
|
72
|
-
// don't penalize the repo as inactive, but don't credit it as active either.
|
|
73
|
-
const projectActive = projectHealth.checkFailed ? true : projectHealth.isActive;
|
|
74
|
-
const vettingResult = {
|
|
75
|
-
passedAllChecks: noExistingPR && notClaimed && projectActive && clearRequirements,
|
|
76
|
-
checks: {
|
|
77
|
-
noExistingPR,
|
|
78
|
-
notClaimed,
|
|
79
|
-
projectActive,
|
|
80
|
-
clearRequirements,
|
|
81
|
-
contributionGuidelinesFound: !!contributionGuidelines,
|
|
82
|
-
},
|
|
83
|
-
contributionGuidelines,
|
|
84
|
-
notes: [],
|
|
85
|
-
};
|
|
86
|
-
// Build notes
|
|
87
|
-
if (!noExistingPR)
|
|
88
|
-
vettingResult.notes.push('Existing PR found for this issue');
|
|
89
|
-
if (!notClaimed)
|
|
90
|
-
vettingResult.notes.push('Issue appears to be claimed by someone');
|
|
91
|
-
if (existingPRCheck.inconclusive) {
|
|
92
|
-
vettingResult.notes.push(`Could not verify absence of existing PRs: ${existingPRCheck.reason || 'API error'}`);
|
|
93
|
-
}
|
|
94
|
-
if (claimCheck.inconclusive) {
|
|
95
|
-
vettingResult.notes.push(`Could not verify claim status: ${claimCheck.reason || 'API error'}`);
|
|
96
|
-
}
|
|
97
|
-
if (projectHealth.checkFailed) {
|
|
98
|
-
vettingResult.notes.push(`Could not verify project activity: ${projectHealth.failureReason || 'API error'}`);
|
|
99
|
-
}
|
|
100
|
-
else if (!projectHealth.isActive) {
|
|
101
|
-
vettingResult.notes.push('Project may be inactive');
|
|
102
|
-
}
|
|
103
|
-
if (!clearRequirements)
|
|
104
|
-
vettingResult.notes.push('Issue requirements are unclear');
|
|
105
|
-
if (!contributionGuidelines)
|
|
106
|
-
vettingResult.notes.push('No CONTRIBUTING.md found');
|
|
107
|
-
// Create tracked issue
|
|
108
|
-
const trackedIssue = {
|
|
109
|
-
id: ghIssue.id,
|
|
110
|
-
url: issueUrl,
|
|
111
|
-
repo: repoFullName,
|
|
112
|
-
number,
|
|
113
|
-
title: ghIssue.title,
|
|
114
|
-
status: 'candidate',
|
|
115
|
-
labels: ghIssue.labels.map((l) => (typeof l === 'string' ? l : l.name || '')),
|
|
116
|
-
createdAt: ghIssue.created_at,
|
|
117
|
-
updatedAt: ghIssue.updated_at,
|
|
118
|
-
vetted: true,
|
|
119
|
-
vettingResult,
|
|
120
|
-
};
|
|
121
|
-
// Determine recommendation
|
|
122
|
-
const reasonsToSkip = [];
|
|
123
|
-
const reasonsToApprove = [];
|
|
124
|
-
if (!noExistingPR)
|
|
125
|
-
reasonsToSkip.push('Has existing PR');
|
|
126
|
-
if (!notClaimed)
|
|
127
|
-
reasonsToSkip.push('Already claimed');
|
|
128
|
-
if (!projectHealth.isActive && !projectHealth.checkFailed)
|
|
129
|
-
reasonsToSkip.push('Inactive project');
|
|
130
|
-
if (!clearRequirements)
|
|
131
|
-
reasonsToSkip.push('Unclear requirements');
|
|
132
|
-
if (noExistingPR)
|
|
133
|
-
reasonsToApprove.push('No existing PR');
|
|
134
|
-
if (notClaimed)
|
|
135
|
-
reasonsToApprove.push('Not claimed');
|
|
136
|
-
if (projectHealth.isActive && !projectHealth.checkFailed)
|
|
137
|
-
reasonsToApprove.push('Active project');
|
|
138
|
-
if (clearRequirements)
|
|
139
|
-
reasonsToApprove.push('Clear requirements');
|
|
140
|
-
if (contributionGuidelines)
|
|
141
|
-
reasonsToApprove.push('Has contribution guidelines');
|
|
142
|
-
// Determine effective merged PR count: prefer local state (authoritative if present),
|
|
143
|
-
// fall back to live GitHub API count to detect contributions made before using oss-autopilot (#373)
|
|
144
|
-
const config = this.stateManager.getState().config;
|
|
145
|
-
const effectiveMergedCount = repoScoreRecord && repoScoreRecord.mergedPRCount > 0 ? repoScoreRecord.mergedPRCount : userMergedPRCount;
|
|
146
|
-
if (effectiveMergedCount > 0) {
|
|
147
|
-
reasonsToApprove.push(`Trusted project (${effectiveMergedCount} PR${effectiveMergedCount > 1 ? 's' : ''} merged)`);
|
|
148
|
-
}
|
|
149
|
-
else if (config.trustedProjects.includes(repoFullName)) {
|
|
150
|
-
reasonsToApprove.push('Trusted project (previous PR merged)');
|
|
151
|
-
}
|
|
152
|
-
// Check for closed/rejected PR history in this repo
|
|
153
|
-
// Use effectiveMergedCount to avoid contradictory signals when API data
|
|
154
|
-
// shows merges that local state doesn't know about (#373)
|
|
155
|
-
if (repoScoreRecord) {
|
|
156
|
-
if (repoScoreRecord.closedWithoutMergeCount > 0 && effectiveMergedCount === 0) {
|
|
157
|
-
reasonsToSkip.push('User has rejected PR(s) in this repo with no successful merges');
|
|
158
|
-
}
|
|
159
|
-
else if (repoScoreRecord.closedWithoutMergeCount > 0 && effectiveMergedCount > 0) {
|
|
160
|
-
vettingResult.notes.push(`Mixed history: ${effectiveMergedCount} merged, ${repoScoreRecord.closedWithoutMergeCount} closed without merge`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
// Check for org-level affinity (user has merged PRs in another repo under same org)
|
|
164
|
-
const orgName = repoFullName.split('/')[0];
|
|
165
|
-
let orgHasMergedPRs = false;
|
|
166
|
-
if (orgName && repoFullName.includes('/')) {
|
|
167
|
-
orgHasMergedPRs = Object.values(this.stateManager.getState().repoScores).some((rs) => rs.repo && rs.mergedPRCount > 0 && rs.repo.startsWith(orgName + '/') && rs.repo !== repoFullName);
|
|
168
|
-
}
|
|
169
|
-
if (orgHasMergedPRs) {
|
|
170
|
-
reasonsToApprove.push(`Org affinity (merged PRs in other ${orgName} repos)`);
|
|
171
|
-
}
|
|
172
|
-
// Check for category preference match
|
|
173
|
-
const projectCategories = config.projectCategories ?? [];
|
|
174
|
-
const matchesCategory = repoBelongsToCategory(repoFullName, projectCategories);
|
|
175
|
-
if (matchesCategory) {
|
|
176
|
-
reasonsToApprove.push('Matches preferred project category');
|
|
177
|
-
}
|
|
178
|
-
let recommendation;
|
|
179
|
-
if (vettingResult.passedAllChecks) {
|
|
180
|
-
recommendation = 'approve';
|
|
181
|
-
}
|
|
182
|
-
else if (reasonsToSkip.length > 2) {
|
|
183
|
-
recommendation = 'skip';
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
recommendation = 'needs_review';
|
|
187
|
-
}
|
|
188
|
-
// Downgrade to needs_review if any check was inconclusive —
|
|
189
|
-
// "approve" should only be given when all checks actually passed, not when they were skipped.
|
|
190
|
-
const hasInconclusiveChecks = projectHealth.checkFailed || existingPRCheck.inconclusive || claimCheck.inconclusive;
|
|
191
|
-
if (recommendation === 'approve' && hasInconclusiveChecks) {
|
|
192
|
-
recommendation = 'needs_review';
|
|
193
|
-
vettingResult.notes.push('Recommendation downgraded: one or more checks were inconclusive');
|
|
194
|
-
}
|
|
195
|
-
// Calculate repo quality bonus from star/fork counts (#98)
|
|
196
|
-
const repoQualityBonus = calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
|
|
197
|
-
if (projectHealth.checkFailed && repoQualityBonus === 0) {
|
|
198
|
-
vettingResult.notes.push('Repo quality bonus unavailable: could not fetch star/fork counts due to API error');
|
|
199
|
-
}
|
|
200
|
-
const repoScore = this.getRepoScore(repoFullName);
|
|
201
|
-
const viabilityScore = calculateViabilityScore({
|
|
202
|
-
repoScore,
|
|
203
|
-
hasExistingPR: !noExistingPR,
|
|
204
|
-
isClaimed: !notClaimed,
|
|
205
|
-
clearRequirements,
|
|
206
|
-
hasContributionGuidelines: !!contributionGuidelines,
|
|
207
|
-
issueUpdatedAt: ghIssue.updated_at,
|
|
208
|
-
closedWithoutMergeCount: repoScoreRecord?.closedWithoutMergeCount ?? 0,
|
|
209
|
-
mergedPRCount: effectiveMergedCount,
|
|
210
|
-
orgHasMergedPRs,
|
|
211
|
-
repoQualityBonus,
|
|
212
|
-
matchesPreferredCategory: matchesCategory,
|
|
213
|
-
});
|
|
214
|
-
const starredRepos = this.stateManager.getStarredRepos();
|
|
215
|
-
const preferredOrgs = config.preferredOrgs ?? [];
|
|
216
|
-
let searchPriority = 'normal';
|
|
217
|
-
if (effectiveMergedCount > 0) {
|
|
218
|
-
searchPriority = 'merged_pr';
|
|
219
|
-
}
|
|
220
|
-
else if (preferredOrgs.some((o) => o.toLowerCase() === orgName?.toLowerCase())) {
|
|
221
|
-
searchPriority = 'preferred_org';
|
|
222
|
-
}
|
|
223
|
-
else if (starredRepos.includes(repoFullName)) {
|
|
224
|
-
searchPriority = 'starred';
|
|
225
|
-
}
|
|
226
|
-
const result = {
|
|
227
|
-
issue: trackedIssue,
|
|
228
|
-
vettingResult,
|
|
229
|
-
projectHealth,
|
|
230
|
-
recommendation,
|
|
231
|
-
reasonsToSkip,
|
|
232
|
-
reasonsToApprove,
|
|
233
|
-
viabilityScore,
|
|
234
|
-
searchPriority,
|
|
235
|
-
};
|
|
236
|
-
// Persist repo metadata (stars, language) so the dashboard can display it (#839)
|
|
237
|
-
if (!projectHealth.checkFailed) {
|
|
238
|
-
try {
|
|
239
|
-
this.stateManager.updateRepoScore(repoFullName, {
|
|
240
|
-
stargazersCount: projectHealth.stargazersCount,
|
|
241
|
-
language: projectHealth.language,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
catch (error) {
|
|
245
|
-
warn(MODULE, `Failed to persist repo metadata for ${repoFullName}: ${errorMessage(error)}`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
// Cache the vetting result to avoid redundant API calls on repeated searches
|
|
249
|
-
cache.set(cacheKey, '', result);
|
|
250
|
-
return result;
|
|
251
|
-
}
|
|
252
|
-
/**
|
|
253
|
-
* Vet multiple issues in parallel with concurrency limit
|
|
254
|
-
*/
|
|
255
|
-
async vetIssuesParallel(urls, maxResults, priority) {
|
|
256
|
-
const candidates = [];
|
|
257
|
-
const pending = new Map();
|
|
258
|
-
let failedVettingCount = 0;
|
|
259
|
-
let rateLimitFailures = 0;
|
|
260
|
-
let attemptedCount = 0;
|
|
261
|
-
for (const url of urls) {
|
|
262
|
-
if (candidates.length >= maxResults)
|
|
263
|
-
break;
|
|
264
|
-
attemptedCount++;
|
|
265
|
-
const task = this.vetIssue(url)
|
|
266
|
-
.then((candidate) => {
|
|
267
|
-
if (candidates.length < maxResults) {
|
|
268
|
-
// Override the priority if provided
|
|
269
|
-
if (priority) {
|
|
270
|
-
candidate.searchPriority = priority;
|
|
271
|
-
}
|
|
272
|
-
candidates.push(candidate);
|
|
273
|
-
}
|
|
274
|
-
})
|
|
275
|
-
.catch((error) => {
|
|
276
|
-
failedVettingCount++;
|
|
277
|
-
if (isRateLimitError(error)) {
|
|
278
|
-
rateLimitFailures++;
|
|
279
|
-
}
|
|
280
|
-
warn(MODULE, `Error vetting issue ${url}:`, errorMessage(error));
|
|
281
|
-
})
|
|
282
|
-
.finally(() => pending.delete(url));
|
|
283
|
-
pending.set(url, task);
|
|
284
|
-
// Limit concurrency — wait for at least one to complete before launching more
|
|
285
|
-
if (pending.size >= MAX_CONCURRENT_VETTING) {
|
|
286
|
-
await Promise.race(pending.values());
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
// Wait for remaining
|
|
290
|
-
await Promise.allSettled(pending.values());
|
|
291
|
-
const allFailed = failedVettingCount === attemptedCount && attemptedCount > 0;
|
|
292
|
-
if (allFailed) {
|
|
293
|
-
warn(MODULE, `All ${attemptedCount} issue(s) failed vetting. ` +
|
|
294
|
-
`This may indicate a systemic issue (rate limit, auth, network).`);
|
|
295
|
-
}
|
|
296
|
-
return { candidates: candidates.slice(0, maxResults), allFailed, rateLimitHit: rateLimitFailures > 0 };
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Get the repo score from state, or return null if not evaluated
|
|
300
|
-
*/
|
|
301
|
-
getRepoScore(repoFullName) {
|
|
302
|
-
const state = this.stateManager.getState();
|
|
303
|
-
const repoScore = state.repoScores?.[repoFullName];
|
|
304
|
-
return repoScore?.score ?? null;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Repo Health — project health checks and contribution guidelines fetching.
|
|
3
|
-
*
|
|
4
|
-
* Extracted from issue-vetting.ts (#621) to isolate repo-level checks
|
|
5
|
-
* from issue-level eligibility logic.
|
|
6
|
-
*/
|
|
7
|
-
import { Octokit } from '@octokit/rest';
|
|
8
|
-
import { type ContributionGuidelines, type ProjectHealth } from './types.js';
|
|
9
|
-
/**
|
|
10
|
-
* Check the health of a GitHub project: recent commits, CI status, star/fork counts.
|
|
11
|
-
* Results are cached for HEALTH_CACHE_TTL_MS (4 hours).
|
|
12
|
-
*/
|
|
13
|
-
export declare function checkProjectHealth(octokit: Octokit, owner: string, repo: string): Promise<ProjectHealth>;
|
|
14
|
-
/**
|
|
15
|
-
* Fetch and parse CONTRIBUTING.md (or variants) from a GitHub repo.
|
|
16
|
-
* Probes multiple paths in parallel: CONTRIBUTING.md, .github/CONTRIBUTING.md,
|
|
17
|
-
* docs/CONTRIBUTING.md, contributing.md. Results are cached for CACHE_TTL_MS.
|
|
18
|
-
*/
|
|
19
|
-
export declare function fetchContributionGuidelines(octokit: Octokit, owner: string, repo: string): Promise<ContributionGuidelines | undefined>;
|
|
20
|
-
/**
|
|
21
|
-
* Parse the raw content of a CONTRIBUTING.md file to extract structured guidelines:
|
|
22
|
-
* branch naming, commit format, test framework, linter, formatter, CLA requirement.
|
|
23
|
-
*/
|
|
24
|
-
export declare function parseContributionGuidelines(content: string): ContributionGuidelines;
|
package/dist/core/repo-health.js
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Repo Health — project health checks and contribution guidelines fetching.
|
|
3
|
-
*
|
|
4
|
-
* Extracted from issue-vetting.ts (#621) to isolate repo-level checks
|
|
5
|
-
* from issue-level eligibility logic.
|
|
6
|
-
*/
|
|
7
|
-
import { daysBetween } from './utils.js';
|
|
8
|
-
import { errorMessage } from './errors.js';
|
|
9
|
-
import { warn } from './logger.js';
|
|
10
|
-
import { getHttpCache, cachedRequest, cachedTimeBased } from './http-cache.js';
|
|
11
|
-
const MODULE = 'repo-health';
|
|
12
|
-
// ── Cache for contribution guidelines ──
|
|
13
|
-
const guidelinesCache = new Map();
|
|
14
|
-
/** TTL for cached contribution guidelines (1 hour). */
|
|
15
|
-
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
16
|
-
/** TTL for cached project health results (4 hours). Health data (stars, commits, CI) changes slowly. */
|
|
17
|
-
const HEALTH_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
|
|
18
|
-
/** Max entries in the guidelines cache before pruning. */
|
|
19
|
-
const CACHE_MAX_SIZE = 100;
|
|
20
|
-
/** Remove expired and excess entries from the guidelines cache. */
|
|
21
|
-
function pruneCache() {
|
|
22
|
-
const now = Date.now();
|
|
23
|
-
// First, remove expired entries (older than CACHE_TTL_MS)
|
|
24
|
-
for (const [key, value] of guidelinesCache.entries()) {
|
|
25
|
-
if (now - value.fetchedAt > CACHE_TTL_MS) {
|
|
26
|
-
guidelinesCache.delete(key);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
// Then, if still over size limit, remove oldest entries
|
|
30
|
-
if (guidelinesCache.size > CACHE_MAX_SIZE) {
|
|
31
|
-
const entries = Array.from(guidelinesCache.entries()).sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
|
32
|
-
const toRemove = entries.slice(0, guidelinesCache.size - CACHE_MAX_SIZE);
|
|
33
|
-
for (const [key] of toRemove) {
|
|
34
|
-
guidelinesCache.delete(key);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
// ── Project health ──
|
|
39
|
-
/**
|
|
40
|
-
* Check the health of a GitHub project: recent commits, CI status, star/fork counts.
|
|
41
|
-
* Results are cached for HEALTH_CACHE_TTL_MS (4 hours).
|
|
42
|
-
*/
|
|
43
|
-
export async function checkProjectHealth(octokit, owner, repo) {
|
|
44
|
-
const cache = getHttpCache();
|
|
45
|
-
const healthCacheKey = `health:${owner}/${repo}`;
|
|
46
|
-
try {
|
|
47
|
-
return await cachedTimeBased(cache, healthCacheKey, HEALTH_CACHE_TTL_MS, async () => {
|
|
48
|
-
// Get repo info (with ETag caching — repo metadata changes infrequently)
|
|
49
|
-
const url = `/repos/${owner}/${repo}`;
|
|
50
|
-
const repoData = await cachedRequest(cache, url, (headers) => octokit.repos.get({ owner, repo, headers }));
|
|
51
|
-
// Get recent commits
|
|
52
|
-
const { data: commits } = await octokit.repos.listCommits({
|
|
53
|
-
owner,
|
|
54
|
-
repo,
|
|
55
|
-
per_page: 1,
|
|
56
|
-
});
|
|
57
|
-
const lastCommit = commits[0];
|
|
58
|
-
const lastCommitAt = lastCommit?.commit?.author?.date || repoData.pushed_at;
|
|
59
|
-
const daysSinceLastCommit = daysBetween(new Date(lastCommitAt));
|
|
60
|
-
// Check CI status (simplified - just check if workflows exist)
|
|
61
|
-
let ciStatus = 'unknown';
|
|
62
|
-
try {
|
|
63
|
-
const { data: workflows } = await octokit.actions.listRepoWorkflows({
|
|
64
|
-
owner,
|
|
65
|
-
repo,
|
|
66
|
-
per_page: 1,
|
|
67
|
-
});
|
|
68
|
-
if (workflows.total_count > 0) {
|
|
69
|
-
ciStatus = 'passing'; // Assume passing if workflows exist
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
const errMsg = errorMessage(error);
|
|
74
|
-
warn(MODULE, `Failed to check CI status for ${owner}/${repo}: ${errMsg}. Defaulting to unknown.`);
|
|
75
|
-
}
|
|
76
|
-
return {
|
|
77
|
-
repo: `${owner}/${repo}`,
|
|
78
|
-
lastCommitAt,
|
|
79
|
-
daysSinceLastCommit,
|
|
80
|
-
openIssuesCount: repoData.open_issues_count,
|
|
81
|
-
avgIssueResponseDays: 0, // Would need more API calls to calculate
|
|
82
|
-
ciStatus,
|
|
83
|
-
isActive: daysSinceLastCommit < 30,
|
|
84
|
-
stargazersCount: repoData.stargazers_count,
|
|
85
|
-
forksCount: repoData.forks_count,
|
|
86
|
-
language: repoData.language,
|
|
87
|
-
};
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
catch (error) {
|
|
91
|
-
const errMsg = errorMessage(error);
|
|
92
|
-
warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
|
|
93
|
-
return {
|
|
94
|
-
repo: `${owner}/${repo}`,
|
|
95
|
-
lastCommitAt: '',
|
|
96
|
-
daysSinceLastCommit: 999,
|
|
97
|
-
openIssuesCount: 0,
|
|
98
|
-
avgIssueResponseDays: 0,
|
|
99
|
-
ciStatus: 'unknown',
|
|
100
|
-
isActive: false,
|
|
101
|
-
checkFailed: true,
|
|
102
|
-
failureReason: errMsg,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// ── Contribution guidelines ──
|
|
107
|
-
/**
|
|
108
|
-
* Fetch and parse CONTRIBUTING.md (or variants) from a GitHub repo.
|
|
109
|
-
* Probes multiple paths in parallel: CONTRIBUTING.md, .github/CONTRIBUTING.md,
|
|
110
|
-
* docs/CONTRIBUTING.md, contributing.md. Results are cached for CACHE_TTL_MS.
|
|
111
|
-
*/
|
|
112
|
-
export async function fetchContributionGuidelines(octokit, owner, repo) {
|
|
113
|
-
const cacheKey = `${owner}/${repo}`;
|
|
114
|
-
// Check cache first
|
|
115
|
-
const cached = guidelinesCache.get(cacheKey);
|
|
116
|
-
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
117
|
-
return cached.guidelines;
|
|
118
|
-
}
|
|
119
|
-
const filesToCheck = ['CONTRIBUTING.md', '.github/CONTRIBUTING.md', 'docs/CONTRIBUTING.md', 'contributing.md'];
|
|
120
|
-
// Probe all paths in parallel — take the first success in priority order
|
|
121
|
-
const results = await Promise.allSettled(filesToCheck.map((file) => octokit.repos.getContent({ owner, repo, path: file }).then(({ data }) => {
|
|
122
|
-
if ('content' in data) {
|
|
123
|
-
return Buffer.from(data.content, 'base64').toString('utf-8');
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
})));
|
|
127
|
-
for (let i = 0; i < results.length; i++) {
|
|
128
|
-
const result = results[i];
|
|
129
|
-
if (result.status === 'fulfilled' && result.value) {
|
|
130
|
-
const guidelines = parseContributionGuidelines(result.value);
|
|
131
|
-
guidelinesCache.set(cacheKey, { guidelines, fetchedAt: Date.now() });
|
|
132
|
-
pruneCache();
|
|
133
|
-
return guidelines;
|
|
134
|
-
}
|
|
135
|
-
if (result.status === 'rejected') {
|
|
136
|
-
const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
137
|
-
if (!msg.includes('404') && !msg.includes('Not Found')) {
|
|
138
|
-
warn(MODULE, `Unexpected error fetching ${filesToCheck[i]} from ${owner}/${repo}: ${msg}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// Cache the negative result too and prune if needed
|
|
143
|
-
guidelinesCache.set(cacheKey, { guidelines: undefined, fetchedAt: Date.now() });
|
|
144
|
-
pruneCache();
|
|
145
|
-
return undefined;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Parse the raw content of a CONTRIBUTING.md file to extract structured guidelines:
|
|
149
|
-
* branch naming, commit format, test framework, linter, formatter, CLA requirement.
|
|
150
|
-
*/
|
|
151
|
-
export function parseContributionGuidelines(content) {
|
|
152
|
-
const guidelines = {
|
|
153
|
-
rawContent: content,
|
|
154
|
-
};
|
|
155
|
-
const lowerContent = content.toLowerCase();
|
|
156
|
-
// Detect branch naming conventions
|
|
157
|
-
if (lowerContent.includes('branch')) {
|
|
158
|
-
const branchMatch = content.match(/branch[^\n]*(?:named?|format|convention)[^\n]*[`"]([^`"]+)[`"]/i);
|
|
159
|
-
if (branchMatch) {
|
|
160
|
-
guidelines.branchNamingConvention = branchMatch[1];
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
// Detect commit message format
|
|
164
|
-
if (lowerContent.includes('conventional commit')) {
|
|
165
|
-
guidelines.commitMessageFormat = 'conventional commits';
|
|
166
|
-
}
|
|
167
|
-
else if (lowerContent.includes('commit message')) {
|
|
168
|
-
const commitMatch = content.match(/commit message[^\n]*[`"]([^`"]+)[`"]/i);
|
|
169
|
-
if (commitMatch) {
|
|
170
|
-
guidelines.commitMessageFormat = commitMatch[1];
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
// Detect test framework
|
|
174
|
-
if (lowerContent.includes('jest'))
|
|
175
|
-
guidelines.testFramework = 'Jest';
|
|
176
|
-
else if (lowerContent.includes('rspec'))
|
|
177
|
-
guidelines.testFramework = 'RSpec';
|
|
178
|
-
else if (lowerContent.includes('pytest'))
|
|
179
|
-
guidelines.testFramework = 'pytest';
|
|
180
|
-
else if (lowerContent.includes('mocha'))
|
|
181
|
-
guidelines.testFramework = 'Mocha';
|
|
182
|
-
// Detect linter
|
|
183
|
-
if (lowerContent.includes('eslint'))
|
|
184
|
-
guidelines.linter = 'ESLint';
|
|
185
|
-
else if (lowerContent.includes('rubocop'))
|
|
186
|
-
guidelines.linter = 'RuboCop';
|
|
187
|
-
else if (lowerContent.includes('prettier'))
|
|
188
|
-
guidelines.formatter = 'Prettier';
|
|
189
|
-
// Detect CLA requirement
|
|
190
|
-
if (lowerContent.includes('cla') || lowerContent.includes('contributor license agreement')) {
|
|
191
|
-
guidelines.claRequired = true;
|
|
192
|
-
}
|
|
193
|
-
return guidelines;
|
|
194
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
export declare class SearchBudgetTracker {
|
|
15
|
-
/** Timestamps of recent Search API calls within the sliding window. */
|
|
16
|
-
private callTimestamps;
|
|
17
|
-
/** Last known remaining quota from GitHub's rate limit endpoint. */
|
|
18
|
-
private knownRemaining;
|
|
19
|
-
/** Epoch ms when the rate limit window resets (from GitHub API). */
|
|
20
|
-
private resetAt;
|
|
21
|
-
/** Total calls recorded since init (for diagnostics). */
|
|
22
|
-
private totalCalls;
|
|
23
|
-
/**
|
|
24
|
-
* Initialize with pre-flight rate limit data from GitHub.
|
|
25
|
-
*/
|
|
26
|
-
init(remaining: number, resetAt: string): void;
|
|
27
|
-
/**
|
|
28
|
-
* Record that a Search API call was just made.
|
|
29
|
-
*/
|
|
30
|
-
recordCall(): void;
|
|
31
|
-
/**
|
|
32
|
-
* Remove timestamps older than the sliding window.
|
|
33
|
-
*/
|
|
34
|
-
private pruneOldTimestamps;
|
|
35
|
-
/**
|
|
36
|
-
* Get the number of calls made in the current sliding window.
|
|
37
|
-
*/
|
|
38
|
-
getCallsInWindow(): number;
|
|
39
|
-
/**
|
|
40
|
-
* Get the effective budget, accounting for both the sliding window limit
|
|
41
|
-
* and the pre-flight remaining quota from GitHub.
|
|
42
|
-
*/
|
|
43
|
-
private getEffectiveBudget;
|
|
44
|
-
/**
|
|
45
|
-
* Check if we can afford N more Search API calls without exceeding the budget.
|
|
46
|
-
*/
|
|
47
|
-
canAfford(n: number): boolean;
|
|
48
|
-
/**
|
|
49
|
-
* Wait if necessary to stay within the Search API rate limit.
|
|
50
|
-
* If the sliding window is at capacity, sleeps until the oldest
|
|
51
|
-
* call ages out of the window.
|
|
52
|
-
*/
|
|
53
|
-
waitForBudget(): Promise<void>;
|
|
54
|
-
/**
|
|
55
|
-
* Get total calls recorded since init (for diagnostics).
|
|
56
|
-
*/
|
|
57
|
-
getTotalCalls(): number;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Get (or create) the shared SearchBudgetTracker singleton.
|
|
61
|
-
*/
|
|
62
|
-
export declare function getSearchBudgetTracker(): SearchBudgetTracker;
|