@oss-autopilot/core 0.53.1 → 0.55.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.
Files changed (43) hide show
  1. package/dist/cli.bundle.cjs +63 -63
  2. package/dist/commands/comments.js +0 -1
  3. package/dist/commands/config.js +45 -5
  4. package/dist/commands/daily.js +197 -162
  5. package/dist/commands/dashboard-data.js +37 -30
  6. package/dist/commands/dashboard-server.js +8 -1
  7. package/dist/commands/dismiss.js +0 -6
  8. package/dist/commands/init.js +0 -1
  9. package/dist/commands/local-repos.js +1 -2
  10. package/dist/commands/move.js +12 -11
  11. package/dist/commands/setup.d.ts +2 -1
  12. package/dist/commands/setup.js +166 -130
  13. package/dist/commands/shelve.js +10 -10
  14. package/dist/commands/startup.js +30 -14
  15. package/dist/core/ci-analysis.d.ts +6 -0
  16. package/dist/core/ci-analysis.js +91 -12
  17. package/dist/core/daily-logic.js +24 -33
  18. package/dist/core/display-utils.js +22 -2
  19. package/dist/core/github-stats.d.ts +1 -1
  20. package/dist/core/github-stats.js +1 -1
  21. package/dist/core/index.d.ts +2 -1
  22. package/dist/core/index.js +2 -1
  23. package/dist/core/issue-discovery.d.ts +7 -44
  24. package/dist/core/issue-discovery.js +83 -188
  25. package/dist/core/issue-eligibility.d.ts +35 -0
  26. package/dist/core/issue-eligibility.js +126 -0
  27. package/dist/core/issue-vetting.d.ts +6 -21
  28. package/dist/core/issue-vetting.js +15 -279
  29. package/dist/core/pr-monitor.d.ts +14 -16
  30. package/dist/core/pr-monitor.js +26 -90
  31. package/dist/core/repo-health.d.ts +24 -0
  32. package/dist/core/repo-health.js +193 -0
  33. package/dist/core/repo-score-manager.js +2 -0
  34. package/dist/core/search-phases.d.ts +55 -0
  35. package/dist/core/search-phases.js +155 -0
  36. package/dist/core/state.d.ts +11 -0
  37. package/dist/core/state.js +63 -4
  38. package/dist/core/status-determination.d.ts +2 -0
  39. package/dist/core/status-determination.js +82 -22
  40. package/dist/core/types.d.ts +23 -2
  41. package/dist/core/types.js +7 -0
  42. package/dist/formatters/json.d.ts +1 -1
  43. package/package.json +1 -1
@@ -1,41 +1,20 @@
1
1
  /**
2
- * Issue Vetting — checks individual issues for claimability, existing PRs,
3
- * project health, contribution guidelines, and requirement clarity.
2
+ * Issue Vetting — orchestrates individual issue checks and computes
3
+ * recommendation + viability score.
4
4
  *
5
- * Extracted from issue-discovery.ts (#356) to isolate vetting logic.
5
+ * Delegates to focused modules (#621):
6
+ * - issue-eligibility.ts — PR existence, claim detection, requirements analysis
7
+ * - repo-health.ts — project health, contribution guidelines
6
8
  */
7
- import { paginateAll } from './pagination.js';
8
- import { parseGitHubUrl, daysBetween, DEFAULT_CONCURRENCY } from './utils.js';
9
+ import { parseGitHubUrl, DEFAULT_CONCURRENCY } from './utils.js';
9
10
  import { ValidationError, errorMessage, isRateLimitError } from './errors.js';
10
11
  import { warn } from './logger.js';
11
- import { getHttpCache, cachedRequest, cachedTimeBased } from './http-cache.js';
12
12
  import { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
13
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';
14
16
  const MODULE = 'issue-vetting';
15
17
  const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
16
- // Cache for contribution guidelines (expires after 1 hour, max 100 entries)
17
- const guidelinesCache = new Map();
18
- const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
19
- /** TTL for cached project health results (4 hours). Health data (stars, commits, CI) changes slowly. */
20
- const HEALTH_CACHE_TTL_MS = 4 * 60 * 60 * 1000;
21
- const CACHE_MAX_SIZE = 100;
22
- function pruneCache() {
23
- const now = Date.now();
24
- // First, remove expired entries (older than CACHE_TTL_MS)
25
- for (const [key, value] of guidelinesCache.entries()) {
26
- if (now - value.fetchedAt > CACHE_TTL_MS) {
27
- guidelinesCache.delete(key);
28
- }
29
- }
30
- // Then, if still over size limit, remove oldest entries
31
- if (guidelinesCache.size > CACHE_MAX_SIZE) {
32
- const entries = Array.from(guidelinesCache.entries()).sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
33
- const toRemove = entries.slice(0, guidelinesCache.size - CACHE_MAX_SIZE);
34
- for (const [key] of toRemove) {
35
- guidelinesCache.delete(key);
36
- }
37
- }
38
- }
39
18
  export class IssueVetter {
40
19
  octokit;
41
20
  stateManager;
@@ -60,18 +39,18 @@ export class IssueVetter {
60
39
  repo,
61
40
  issue_number: number,
62
41
  });
63
- // Run all vetting checks in parallel
42
+ // Run all vetting checks in parallel — delegates to standalone functions
64
43
  const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount] = await Promise.all([
65
- this.checkNoExistingPR(owner, repo, number),
66
- this.checkNotClaimed(owner, repo, number, ghIssue.comments),
67
- this.checkProjectHealth(owner, repo),
68
- this.fetchContributionGuidelines(owner, repo),
69
- this.checkUserMergedPRsInRepo(owner, repo),
44
+ checkNoExistingPR(this.octokit, owner, repo, number),
45
+ checkNotClaimed(this.octokit, owner, repo, number, ghIssue.comments),
46
+ checkProjectHealth(this.octokit, owner, repo),
47
+ fetchContributionGuidelines(this.octokit, owner, repo),
48
+ checkUserMergedPRsInRepo(this.octokit, owner, repo),
70
49
  ]);
71
50
  const noExistingPR = existingPRCheck.passed;
72
51
  const notClaimed = claimCheck.passed;
73
52
  // Analyze issue quality
74
- const clearRequirements = this.analyzeRequirements(ghIssue.body || '');
53
+ const clearRequirements = analyzeRequirements(ghIssue.body || '');
75
54
  // When the health check itself failed (API error), use a neutral default:
76
55
  // don't penalize the repo as inactive, but don't credit it as active either.
77
56
  const projectActive = projectHealth.checkFailed ? true : projectHealth.isActive;
@@ -285,249 +264,6 @@ export class IssueVetter {
285
264
  }
286
265
  return { candidates: candidates.slice(0, maxResults), allFailed, rateLimitHit: rateLimitFailures > 0 };
287
266
  }
288
- async checkNoExistingPR(owner, repo, issueNumber) {
289
- try {
290
- // Search for PRs that mention this issue
291
- const { data } = await this.octokit.search.issuesAndPullRequests({
292
- q: `repo:${owner}/${repo} is:pr ${issueNumber}`,
293
- per_page: 5,
294
- });
295
- // Also check timeline for linked PRs
296
- const timeline = await paginateAll((page) => this.octokit.issues.listEventsForTimeline({
297
- owner,
298
- repo,
299
- issue_number: issueNumber,
300
- per_page: 100,
301
- page,
302
- }));
303
- const linkedPRs = timeline.filter((event) => {
304
- const e = event;
305
- return e.event === 'cross-referenced' && e.source?.issue?.pull_request;
306
- });
307
- return { passed: data.total_count === 0 && linkedPRs.length === 0 };
308
- }
309
- catch (error) {
310
- const errMsg = errorMessage(error);
311
- warn(MODULE, `Failed to check for existing PRs on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming no existing PR.`);
312
- return { passed: true, inconclusive: true, reason: errMsg };
313
- }
314
- }
315
- /**
316
- * Check how many merged PRs the authenticated user has in a repo.
317
- * Uses GitHub Search API. Returns 0 on error (non-fatal).
318
- */
319
- async checkUserMergedPRsInRepo(owner, repo) {
320
- try {
321
- // Use @me to search as the authenticated user
322
- const { data } = await this.octokit.search.issuesAndPullRequests({
323
- q: `repo:${owner}/${repo} is:pr is:merged author:@me`,
324
- per_page: 1, // We only need total_count
325
- });
326
- return data.total_count;
327
- }
328
- catch (error) {
329
- const errMsg = errorMessage(error);
330
- warn(MODULE, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Defaulting to 0.`);
331
- return 0;
332
- }
333
- }
334
- async checkNotClaimed(owner, repo, issueNumber, commentCount) {
335
- if (commentCount === 0)
336
- return { passed: true };
337
- try {
338
- // Paginate through all comments (up to 100)
339
- const comments = await this.octokit.paginate(this.octokit.issues.listComments, {
340
- owner,
341
- repo,
342
- issue_number: issueNumber,
343
- per_page: 100,
344
- }, (response) => response.data);
345
- // Limit to last 100 comments to avoid excessive processing
346
- const recentComments = comments.slice(-100);
347
- // Look for claiming phrases
348
- const claimPhrases = [
349
- "i'm working on this",
350
- 'i am working on this',
351
- "i'll take this",
352
- 'i will take this',
353
- 'working on it',
354
- "i'd like to work on",
355
- 'i would like to work on',
356
- 'can i work on',
357
- 'may i work on',
358
- 'assigned to me',
359
- "i'm on it",
360
- "i'll submit a pr",
361
- 'i will submit a pr',
362
- 'working on a fix',
363
- 'working on a pr',
364
- ];
365
- for (const comment of recentComments) {
366
- const body = (comment.body || '').toLowerCase();
367
- if (claimPhrases.some((phrase) => body.includes(phrase))) {
368
- return { passed: false };
369
- }
370
- }
371
- return { passed: true };
372
- }
373
- catch (error) {
374
- const errMsg = errorMessage(error);
375
- warn(MODULE, `Failed to check claim status on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming not claimed.`);
376
- return { passed: true, inconclusive: true, reason: errMsg };
377
- }
378
- }
379
- async checkProjectHealth(owner, repo) {
380
- const cache = getHttpCache();
381
- const healthCacheKey = `health:${owner}/${repo}`;
382
- try {
383
- return await cachedTimeBased(cache, healthCacheKey, HEALTH_CACHE_TTL_MS, async () => {
384
- // Get repo info (with ETag caching — repo metadata changes infrequently)
385
- const url = `/repos/${owner}/${repo}`;
386
- const repoData = await cachedRequest(cache, url, (headers) => this.octokit.repos.get({ owner, repo, headers }));
387
- // Get recent commits
388
- const { data: commits } = await this.octokit.repos.listCommits({
389
- owner,
390
- repo,
391
- per_page: 1,
392
- });
393
- const lastCommit = commits[0];
394
- const lastCommitAt = lastCommit?.commit?.author?.date || repoData.pushed_at;
395
- const daysSinceLastCommit = daysBetween(new Date(lastCommitAt));
396
- // Check CI status (simplified - just check if workflows exist)
397
- let ciStatus = 'unknown';
398
- try {
399
- const { data: workflows } = await this.octokit.actions.listRepoWorkflows({
400
- owner,
401
- repo,
402
- per_page: 1,
403
- });
404
- if (workflows.total_count > 0) {
405
- ciStatus = 'passing'; // Assume passing if workflows exist
406
- }
407
- }
408
- catch (error) {
409
- const errMsg = errorMessage(error);
410
- warn(MODULE, `Failed to check CI status for ${owner}/${repo}: ${errMsg}. Defaulting to unknown.`);
411
- }
412
- return {
413
- repo: `${owner}/${repo}`,
414
- lastCommitAt,
415
- daysSinceLastCommit,
416
- openIssuesCount: repoData.open_issues_count,
417
- avgIssueResponseDays: 0, // Would need more API calls to calculate
418
- ciStatus,
419
- isActive: daysSinceLastCommit < 30,
420
- stargazersCount: repoData.stargazers_count,
421
- forksCount: repoData.forks_count,
422
- };
423
- });
424
- }
425
- catch (error) {
426
- const errMsg = errorMessage(error);
427
- warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
428
- return {
429
- repo: `${owner}/${repo}`,
430
- lastCommitAt: '',
431
- daysSinceLastCommit: 999,
432
- openIssuesCount: 0,
433
- avgIssueResponseDays: 0,
434
- ciStatus: 'unknown',
435
- isActive: false,
436
- checkFailed: true,
437
- failureReason: errMsg,
438
- };
439
- }
440
- }
441
- async fetchContributionGuidelines(owner, repo) {
442
- const cacheKey = `${owner}/${repo}`;
443
- // Check cache first
444
- const cached = guidelinesCache.get(cacheKey);
445
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
446
- return cached.guidelines;
447
- }
448
- const filesToCheck = ['CONTRIBUTING.md', '.github/CONTRIBUTING.md', 'docs/CONTRIBUTING.md', 'contributing.md'];
449
- // Probe all paths in parallel — take the first success in priority order
450
- const results = await Promise.allSettled(filesToCheck.map((file) => this.octokit.repos.getContent({ owner, repo, path: file }).then(({ data }) => {
451
- if ('content' in data) {
452
- return Buffer.from(data.content, 'base64').toString('utf-8');
453
- }
454
- return null;
455
- })));
456
- for (let i = 0; i < results.length; i++) {
457
- const result = results[i];
458
- if (result.status === 'fulfilled' && result.value) {
459
- const guidelines = this.parseContributionGuidelines(result.value);
460
- guidelinesCache.set(cacheKey, { guidelines, fetchedAt: Date.now() });
461
- pruneCache();
462
- return guidelines;
463
- }
464
- if (result.status === 'rejected') {
465
- const msg = result.reason instanceof Error ? result.reason.message : String(result.reason);
466
- if (!msg.includes('404') && !msg.includes('Not Found')) {
467
- warn(MODULE, `Unexpected error fetching ${filesToCheck[i]} from ${owner}/${repo}: ${msg}`);
468
- }
469
- }
470
- }
471
- // Cache the negative result too and prune if needed
472
- guidelinesCache.set(cacheKey, { guidelines: undefined, fetchedAt: Date.now() });
473
- pruneCache();
474
- return undefined;
475
- }
476
- parseContributionGuidelines(content) {
477
- const guidelines = {
478
- rawContent: content,
479
- };
480
- const lowerContent = content.toLowerCase();
481
- // Detect branch naming conventions
482
- if (lowerContent.includes('branch')) {
483
- const branchMatch = content.match(/branch[^\n]*(?:named?|format|convention)[^\n]*[`"]([^`"]+)[`"]/i);
484
- if (branchMatch) {
485
- guidelines.branchNamingConvention = branchMatch[1];
486
- }
487
- }
488
- // Detect commit message format
489
- if (lowerContent.includes('conventional commit')) {
490
- guidelines.commitMessageFormat = 'conventional commits';
491
- }
492
- else if (lowerContent.includes('commit message')) {
493
- const commitMatch = content.match(/commit message[^\n]*[`"]([^`"]+)[`"]/i);
494
- if (commitMatch) {
495
- guidelines.commitMessageFormat = commitMatch[1];
496
- }
497
- }
498
- // Detect test framework
499
- if (lowerContent.includes('jest'))
500
- guidelines.testFramework = 'Jest';
501
- else if (lowerContent.includes('rspec'))
502
- guidelines.testFramework = 'RSpec';
503
- else if (lowerContent.includes('pytest'))
504
- guidelines.testFramework = 'pytest';
505
- else if (lowerContent.includes('mocha'))
506
- guidelines.testFramework = 'Mocha';
507
- // Detect linter
508
- if (lowerContent.includes('eslint'))
509
- guidelines.linter = 'ESLint';
510
- else if (lowerContent.includes('rubocop'))
511
- guidelines.linter = 'RuboCop';
512
- else if (lowerContent.includes('prettier'))
513
- guidelines.formatter = 'Prettier';
514
- // Detect CLA requirement
515
- if (lowerContent.includes('cla') || lowerContent.includes('contributor license agreement')) {
516
- guidelines.claRequired = true;
517
- }
518
- return guidelines;
519
- }
520
- analyzeRequirements(body) {
521
- if (!body || body.length < 50)
522
- return false;
523
- // Check for clear structure
524
- const hasSteps = /\d\.|[-*]\s/.test(body);
525
- const hasCodeBlock = /```/.test(body);
526
- const hasExpectedBehavior = /expect|should|must|want/i.test(body);
527
- // Must have at least two indicators of clarity
528
- const indicators = [hasSteps, hasCodeBlock, hasExpectedBehavior, body.length > 200];
529
- return indicators.filter(Boolean).length >= 2;
530
- }
531
267
  /**
532
268
  * Get the repo score from state, or return null if not evaluated
533
269
  */
@@ -4,20 +4,25 @@
4
4
  * Score methods still write to state.
5
5
  *
6
6
  * Decomposed into focused modules (#263):
7
- * - ci-analysis.ts: CI check classification and analysis
7
+ * - ci-analysis.ts: CI status fetching, check classification and analysis
8
8
  * - review-analysis.ts: Review decision and comment detection
9
9
  * - checklist-analysis.ts: PR body checklist analysis
10
10
  * - maintainer-analysis.ts: Maintainer action hint extraction
11
11
  * - display-utils.ts: Display label computation
12
- * - github-stats.ts: Merged/closed PR counts and star fetching
12
+ * - github-stats.ts: Merged/closed PR counts and star-based filtering
13
13
  * - status-determination.ts: PR status classification logic
14
14
  */
15
15
  import { FetchedPR, DailyDigest, ClosedPR, MergedPR, StarFilter } from './types.js';
16
16
  import { type PRCountsResult } from './github-stats.js';
17
17
  export { computeDisplayLabel } from './display-utils.js';
18
- export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
18
+ export { classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
19
19
  export { isConditionalChecklistItem } from './checklist-analysis.js';
20
20
  export { determineStatus } from './status-determination.js';
21
+ /**
22
+ * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
23
+ * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
24
+ */
25
+ export declare function hasMergeConflict(mergeable: boolean | null, mergeableState: string | null): boolean;
21
26
  export interface PRCheckFailure {
22
27
  prUrl: string;
23
28
  error: string;
@@ -44,16 +49,6 @@ export declare class PRMonitor {
44
49
  * Centralizes PR construction and display label computation (#79).
45
50
  */
46
51
  private buildFetchedPR;
47
- /**
48
- * Check if PR has merge conflict
49
- */
50
- private hasMergeConflict;
51
- /**
52
- * Get CI status from combined status API and check runs.
53
- * Returns status and names of failing checks for diagnostics.
54
- * Delegates analysis to ci-analysis module.
55
- */
56
- private getCIStatus;
57
52
  /**
58
53
  * Fetch merged PR counts and latest merge dates per repository for the configured user.
59
54
  * Delegates to github-stats module.
@@ -68,10 +63,13 @@ export declare class PRMonitor {
68
63
  */
69
64
  fetchUserClosedPRCounts(starFilter?: StarFilter): Promise<PRCountsResult<number>>;
70
65
  /**
71
- * Fetch GitHub star counts for a list of repositories.
72
- * Delegates to github-stats module.
66
+ * Fetch metadata (star count and primary language) for a list of repositories.
67
+ * Both fields come from the same `repos.get()` call — zero additional API cost.
73
68
  */
74
- fetchRepoStarCounts(repos: string[]): Promise<Map<string, number>>;
69
+ fetchRepoMetadata(repos: string[]): Promise<Map<string, {
70
+ stars: number;
71
+ language: string | null;
72
+ }>>;
75
73
  /**
76
74
  * Fetch PRs closed without merge in the last N days.
77
75
  * Delegates to github-stats module.
@@ -4,12 +4,12 @@
4
4
  * Score methods still write to state.
5
5
  *
6
6
  * Decomposed into focused modules (#263):
7
- * - ci-analysis.ts: CI check classification and analysis
7
+ * - ci-analysis.ts: CI status fetching, check classification and analysis
8
8
  * - review-analysis.ts: Review decision and comment detection
9
9
  * - checklist-analysis.ts: PR body checklist analysis
10
10
  * - maintainer-analysis.ts: Maintainer action hint extraction
11
11
  * - display-utils.ts: Display label computation
12
- * - github-stats.ts: Merged/closed PR counts and star fetching
12
+ * - github-stats.ts: Merged/closed PR counts and star-based filtering
13
13
  * - status-determination.ts: PR status classification logic
14
14
  */
15
15
  import { getOctokit } from './github.js';
@@ -21,8 +21,7 @@ import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode }
21
21
  import { paginateAll } from './pagination.js';
22
22
  import { debug, warn, timed } from './logger.js';
23
23
  import { getHttpCache, cachedRequest } from './http-cache.js';
24
- // Extracted modules
25
- import { classifyFailingChecks, analyzeCheckRuns, analyzeCombinedStatus, mergeStatuses } from './ci-analysis.js';
24
+ import { classifyFailingChecks, getCIStatus } from './ci-analysis.js';
26
25
  import { determineReviewDecision, getLatestChangesRequestedDate, checkUnrespondedComments, } from './review-analysis.js';
27
26
  import { analyzeChecklist } from './checklist-analysis.js';
28
27
  import { extractMaintainerActionHints } from './maintainer-analysis.js';
@@ -30,9 +29,16 @@ import { computeDisplayLabel } from './display-utils.js';
30
29
  import { fetchUserMergedPRCounts as fetchUserMergedPRCountsImpl, fetchUserClosedPRCounts as fetchUserClosedPRCountsImpl, fetchRecentlyClosedPRs as fetchRecentlyClosedPRsImpl, fetchRecentlyMergedPRs as fetchRecentlyMergedPRsImpl, } from './github-stats.js';
31
30
  // Re-export so existing consumers can still import from pr-monitor
32
31
  export { computeDisplayLabel } from './display-utils.js';
33
- export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
32
+ export { classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
34
33
  export { isConditionalChecklistItem } from './checklist-analysis.js';
35
34
  export { determineStatus } from './status-determination.js';
35
+ /**
36
+ * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
37
+ * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
38
+ */
39
+ export function hasMergeConflict(mergeable, mergeableState) {
40
+ return mergeable === false || mergeableState === 'dirty';
41
+ }
36
42
  const MODULE = 'pr-monitor';
37
43
  const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
38
44
  export class PRMonitor {
@@ -167,14 +173,14 @@ export class PRMonitor {
167
173
  // Determine review decision (delegated to review-analysis module)
168
174
  const reviewDecision = determineReviewDecision(reviews);
169
175
  // Check for merge conflict
170
- const hasMergeConflict = this.hasMergeConflict(ghPR.mergeable, ghPR.mergeable_state);
176
+ const mergeConflict = hasMergeConflict(ghPR.mergeable, ghPR.mergeable_state);
171
177
  // Check if there's an unresponded maintainer comment (delegated to review-analysis module)
172
178
  const { hasUnrespondedComment, lastMaintainerComment } = checkUnrespondedComments(comments, reviews, reviewComments, config.githubUsername);
173
179
  // Fetch CI status and (conditionally) latest commit date in parallel
174
180
  // We need the commit date when hasUnrespondedComment is true (to distinguish
175
181
  // "needs_response" from "waiting_on_maintainer") OR when reviewDecision is "changes_requested"
176
182
  // (to detect needs_changes: review requested changes but no new commits pushed)
177
- const ciPromise = this.getCIStatus(owner, repo, ghPR.head.sha);
183
+ const ciPromise = getCIStatus(this.octokit, owner, repo, ghPR.head.sha);
178
184
  const needCommitDate = hasUnrespondedComment || reviewDecision === 'changes_requested';
179
185
  const commitInfoPromise = needCommitDate
180
186
  ? this.octokit.repos
@@ -220,9 +226,9 @@ export class PRMonitor {
220
226
  const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
221
227
  // Determine status
222
228
  const hasActionableCIFailure = ciStatus === 'failing' && classifiedChecks.some((c) => c.category === 'actionable');
223
- const { status, actionReason, waitReason, stalenessTier } = determineStatus({
229
+ const { status, actionReason, waitReason, stalenessTier, actionReasons } = determineStatus({
224
230
  ciStatus,
225
- hasMergeConflict,
231
+ hasMergeConflict: mergeConflict,
226
232
  hasUnrespondedComment,
227
233
  hasIncompleteChecklist,
228
234
  reviewDecision,
@@ -246,13 +252,14 @@ export class PRMonitor {
246
252
  actionReason,
247
253
  waitReason,
248
254
  stalenessTier,
255
+ actionReasons,
249
256
  createdAt: ghPR.created_at,
250
257
  updatedAt: ghPR.updated_at,
251
258
  daysSinceActivity,
252
259
  ciStatus,
253
260
  failingCheckNames,
254
261
  classifiedChecks,
255
- hasMergeConflict,
262
+ hasMergeConflict: mergeConflict,
256
263
  reviewDecision,
257
264
  hasUnrespondedComment,
258
265
  lastMaintainerComment,
@@ -278,78 +285,6 @@ export class PRMonitor {
278
285
  pr.displayDescription = displayDescription;
279
286
  return pr;
280
287
  }
281
- /**
282
- * Check if PR has merge conflict
283
- */
284
- hasMergeConflict(mergeable, mergeableState) {
285
- return mergeable === false || mergeableState === 'dirty';
286
- }
287
- /**
288
- * Get CI status from combined status API and check runs.
289
- * Returns status and names of failing checks for diagnostics.
290
- * Delegates analysis to ci-analysis module.
291
- */
292
- async getCIStatus(owner, repo, sha) {
293
- if (!sha)
294
- return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
295
- try {
296
- // Fetch both combined status and check runs in parallel
297
- const [statusResponse, checksResponse] = await Promise.all([
298
- this.octokit.repos.getCombinedStatusForRef({ owner, repo, ref: sha }),
299
- // 404 is expected for repos without check runs configured; log other errors for debugging
300
- this.octokit.checks.listForRef({ owner, repo, ref: sha }).catch((err) => {
301
- const status = getHttpStatusCode(err);
302
- // Rate limit errors must propagate — matches listReviewComments pattern (#481)
303
- if (status === 429)
304
- throw err;
305
- if (status === 403) {
306
- const msg = errorMessage(err).toLowerCase();
307
- if (msg.includes('rate limit') || msg.includes('abuse detection'))
308
- throw err;
309
- }
310
- if (status === 404) {
311
- debug('pr-monitor', `Check runs 404 for ${owner}/${repo}@${sha.slice(0, 7)} (no checks configured)`);
312
- }
313
- else {
314
- warn('pr-monitor', `Non-404 error fetching check runs for ${owner}/${repo}@${sha.slice(0, 7)}: ${status ?? err}`);
315
- }
316
- return null;
317
- }),
318
- ]);
319
- const combinedStatus = statusResponse.data;
320
- const allCheckRuns = checksResponse?.data?.check_runs || [];
321
- // Deduplicate check runs by name, keeping only the most recent run per unique name.
322
- // GitHub returns all historical runs (including re-runs), so without deduplication
323
- // a superseded failure will incorrectly flag the PR as failing even after a re-run passes.
324
- const latestCheckRunsByName = new Map();
325
- for (const check of allCheckRuns) {
326
- const existing = latestCheckRunsByName.get(check.name);
327
- if (!existing || new Date(check.started_at ?? 0) > new Date(existing.started_at ?? 0)) {
328
- latestCheckRunsByName.set(check.name, check);
329
- }
330
- }
331
- const checkRuns = [...latestCheckRunsByName.values()];
332
- // Delegate analysis to ci-analysis module
333
- const checkRunAnalysis = analyzeCheckRuns(checkRuns);
334
- const combinedAnalysis = analyzeCombinedStatus(combinedStatus);
335
- return mergeStatuses(checkRunAnalysis, combinedAnalysis, checkRuns.length);
336
- }
337
- catch (error) {
338
- const statusCode = getHttpStatusCode(error);
339
- if (statusCode === 401 || statusCode === 403 || statusCode === 429) {
340
- throw error;
341
- }
342
- else if (statusCode === 404) {
343
- // Repo might not have CI configured, this is normal
344
- debug('pr-monitor', `CI check 404 for ${owner}/${repo} (no CI configured)`);
345
- return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
346
- }
347
- else {
348
- warn('pr-monitor', `Failed to check CI for ${owner}/${repo}@${sha.slice(0, 7)}: ${errorMessage(error)}`);
349
- }
350
- return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
351
- }
352
- }
353
288
  /**
354
289
  * Fetch merged PR counts and latest merge dates per repository for the configured user.
355
290
  * Delegates to github-stats module.
@@ -367,13 +302,13 @@ export class PRMonitor {
367
302
  return fetchUserClosedPRCountsImpl(this.octokit, config.githubUsername, starFilter);
368
303
  }
369
304
  /**
370
- * Fetch GitHub star counts for a list of repositories.
371
- * Delegates to github-stats module.
305
+ * Fetch metadata (star count and primary language) for a list of repositories.
306
+ * Both fields come from the same `repos.get()` call — zero additional API cost.
372
307
  */
373
- async fetchRepoStarCounts(repos) {
308
+ async fetchRepoMetadata(repos) {
374
309
  if (repos.length === 0)
375
310
  return new Map();
376
- debug(MODULE, `Fetching star counts for ${repos.length} repos...`);
311
+ debug(MODULE, `Fetching repo metadata for ${repos.length} repos...`);
377
312
  const results = new Map();
378
313
  const cache = getHttpCache();
379
314
  // Deduplicate repos to avoid fetching the same repo twice
@@ -394,17 +329,18 @@ export class PRMonitor {
394
329
  repo: name,
395
330
  headers,
396
331
  }));
397
- return { repo, stars: data.stargazers_count };
332
+ const metadata = { stars: data.stargazers_count, language: data.language ?? null };
333
+ return { repo, metadata };
398
334
  }));
399
335
  let chunkFailures = 0;
400
336
  for (let j = 0; j < settled.length; j++) {
401
337
  const result = settled[j];
402
338
  if (result.status === 'fulfilled') {
403
- results.set(result.value.repo, result.value.stars);
339
+ results.set(result.value.repo, result.value.metadata);
404
340
  }
405
341
  else {
406
342
  chunkFailures++;
407
- warn(MODULE, `Failed to fetch stars for ${chunk[j]}: ${errorMessage(result.reason)}`);
343
+ warn(MODULE, `Failed to fetch metadata for ${chunk[j]}: ${errorMessage(result.reason)}`);
408
344
  }
409
345
  }
410
346
  // If entire chunk failed, likely a systemic issue (rate limit, auth, outage) — abort remaining
@@ -416,7 +352,7 @@ export class PRMonitor {
416
352
  break;
417
353
  }
418
354
  }
419
- debug(MODULE, `Fetched star counts for ${results.size}/${repos.length} repos`);
355
+ debug(MODULE, `Fetched repo metadata for ${results.size}/${repos.length} repos`);
420
356
  return results;
421
357
  }
422
358
  /**
@@ -0,0 +1,24 @@
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;