@oss-autopilot/core 0.54.0 → 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 (37) 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 +190 -157
  5. package/dist/commands/dashboard-data.js +37 -30
  6. package/dist/commands/dashboard-server.js +0 -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 +89 -12
  17. package/dist/core/daily-logic.js +24 -33
  18. package/dist/core/index.d.ts +2 -1
  19. package/dist/core/index.js +2 -1
  20. package/dist/core/issue-discovery.d.ts +7 -44
  21. package/dist/core/issue-discovery.js +83 -188
  22. package/dist/core/issue-eligibility.d.ts +35 -0
  23. package/dist/core/issue-eligibility.js +126 -0
  24. package/dist/core/issue-vetting.d.ts +6 -21
  25. package/dist/core/issue-vetting.js +15 -279
  26. package/dist/core/pr-monitor.d.ts +7 -12
  27. package/dist/core/pr-monitor.js +14 -80
  28. package/dist/core/repo-health.d.ts +24 -0
  29. package/dist/core/repo-health.js +193 -0
  30. package/dist/core/search-phases.d.ts +55 -0
  31. package/dist/core/search-phases.js +155 -0
  32. package/dist/core/state.d.ts +11 -0
  33. package/dist/core/state.js +63 -4
  34. package/dist/core/types.d.ts +8 -1
  35. package/dist/core/types.js +7 -0
  36. package/dist/formatters/json.d.ts +1 -1
  37. package/package.json +1 -1
@@ -21,15 +21,17 @@ export async function runMove(options) {
21
21
  // Use current time — the CLI doesn't have cached PR data. The override
22
22
  // will auto-clear on the next daily run if the PR has new activity after this.
23
23
  const lastActivityAt = new Date().toISOString();
24
- stateManager.setStatusOverride(options.prUrl, status, lastActivityAt);
25
- stateManager.unshelvePR(options.prUrl);
26
- stateManager.save();
24
+ stateManager.batch(() => {
25
+ stateManager.setStatusOverride(options.prUrl, status, lastActivityAt);
26
+ stateManager.unshelvePR(options.prUrl);
27
+ });
27
28
  return { url: options.prUrl, target, description: `Moved to ${label}` };
28
29
  }
29
30
  case 'shelved': {
30
- stateManager.shelvePR(options.prUrl);
31
- stateManager.clearStatusOverride(options.prUrl);
32
- stateManager.save();
31
+ stateManager.batch(() => {
32
+ stateManager.shelvePR(options.prUrl);
33
+ stateManager.clearStatusOverride(options.prUrl);
34
+ });
33
35
  return {
34
36
  url: options.prUrl,
35
37
  target,
@@ -37,11 +39,10 @@ export async function runMove(options) {
37
39
  };
38
40
  }
39
41
  case 'auto': {
40
- const clearedOverride = stateManager.clearStatusOverride(options.prUrl);
41
- const unshelved = stateManager.unshelvePR(options.prUrl);
42
- if (clearedOverride || unshelved) {
43
- stateManager.save();
44
- }
42
+ stateManager.batch(() => {
43
+ stateManager.clearStatusOverride(options.prUrl);
44
+ stateManager.unshelvePR(options.prUrl);
45
+ });
45
46
  return {
46
47
  url: options.prUrl,
47
48
  target,
@@ -2,7 +2,7 @@
2
2
  * Setup command
3
3
  * Interactive setup / configuration
4
4
  */
5
- import { type ProjectCategory } from '../core/types.js';
5
+ import { type ProjectCategory, type IssueScope } from '../core/types.js';
6
6
  interface SetupOptions {
7
7
  reset?: boolean;
8
8
  set?: string[];
@@ -23,6 +23,7 @@ export interface SetupCompleteOutput {
23
23
  labels: string[];
24
24
  projectCategories: ProjectCategory[];
25
25
  preferredOrgs: string[];
26
+ scope: IssueScope[];
26
27
  };
27
28
  }
28
29
  export interface SetupPrompt {
@@ -5,7 +5,7 @@
5
5
  import { getStateManager, DEFAULT_CONFIG } from '../core/index.js';
6
6
  import { ValidationError } from '../core/errors.js';
7
7
  import { validateGitHubUsername } from './validation.js';
8
- import { PROJECT_CATEGORIES } from '../core/types.js';
8
+ import { PROJECT_CATEGORIES, ISSUE_SCOPES } from '../core/types.js';
9
9
  /** Parse and validate a positive integer setting value. */
10
10
  function parsePositiveInt(value, settingName) {
11
11
  const parsed = Number(value);
@@ -21,153 +21,181 @@ export async function runSetup(options) {
21
21
  if (options.set && options.set.length > 0) {
22
22
  const results = {};
23
23
  const warnings = [];
24
- for (const setting of options.set) {
25
- const [key, ...valueParts] = setting.split('=');
26
- const value = valueParts.join('=');
27
- switch (key) {
28
- case 'username':
29
- validateGitHubUsername(value);
30
- stateManager.updateConfig({ githubUsername: value });
31
- results[key] = value;
32
- break;
33
- case 'maxActivePRs': {
34
- const maxPRs = parsePositiveInt(value, 'maxActivePRs');
35
- stateManager.updateConfig({ maxActivePRs: maxPRs });
36
- results[key] = String(maxPRs);
37
- break;
38
- }
39
- case 'dormantDays': {
40
- const dormant = parsePositiveInt(value, 'dormantDays');
41
- stateManager.updateConfig({ dormantThresholdDays: dormant });
42
- results[key] = String(dormant);
43
- break;
44
- }
45
- case 'approachingDays': {
46
- const approaching = parsePositiveInt(value, 'approachingDays');
47
- stateManager.updateConfig({ approachingDormantDays: approaching });
48
- results[key] = String(approaching);
49
- break;
50
- }
51
- case 'languages':
52
- stateManager.updateConfig({ languages: value.split(',').map((l) => l.trim()) });
53
- results[key] = value;
54
- break;
55
- case 'labels':
56
- stateManager.updateConfig({ labels: value.split(',').map((l) => l.trim()) });
57
- results[key] = value;
58
- break;
59
- case 'showHealthCheck':
60
- stateManager.updateConfig({ showHealthCheck: value !== 'false' });
61
- results[key] = value !== 'false' ? 'true' : 'false';
62
- break;
63
- case 'squashByDefault':
64
- if (value === 'ask') {
65
- stateManager.updateConfig({ squashByDefault: 'ask' });
66
- results[key] = 'ask';
24
+ stateManager.batch(() => {
25
+ for (const setting of options.set) {
26
+ const [key, ...valueParts] = setting.split('=');
27
+ const value = valueParts.join('=');
28
+ switch (key) {
29
+ case 'username':
30
+ validateGitHubUsername(value);
31
+ stateManager.updateConfig({ githubUsername: value });
32
+ results[key] = value;
33
+ break;
34
+ case 'maxActivePRs': {
35
+ const maxPRs = parsePositiveInt(value, 'maxActivePRs');
36
+ stateManager.updateConfig({ maxActivePRs: maxPRs });
37
+ results[key] = String(maxPRs);
38
+ break;
67
39
  }
68
- else {
69
- stateManager.updateConfig({ squashByDefault: value !== 'false' });
70
- results[key] = value !== 'false' ? 'true' : 'false';
40
+ case 'dormantDays': {
41
+ const dormant = parsePositiveInt(value, 'dormantDays');
42
+ stateManager.updateConfig({ dormantThresholdDays: dormant });
43
+ results[key] = String(dormant);
44
+ break;
71
45
  }
72
- break;
73
- case 'minStars': {
74
- const stars = Number(value);
75
- if (!Number.isFinite(stars) || !Number.isInteger(stars) || stars < 0) {
76
- throw new ValidationError(`Invalid value for minStars: "${value}". Must be a non-negative integer.`);
46
+ case 'approachingDays': {
47
+ const approaching = parsePositiveInt(value, 'approachingDays');
48
+ stateManager.updateConfig({ approachingDormantDays: approaching });
49
+ results[key] = String(approaching);
50
+ break;
77
51
  }
78
- stateManager.updateConfig({ minStars: stars });
79
- results[key] = String(stars);
80
- break;
81
- }
82
- case 'includeDocIssues':
83
- stateManager.updateConfig({ includeDocIssues: value === 'true' });
84
- results[key] = value === 'true' ? 'true' : 'false';
85
- break;
86
- case 'aiPolicyBlocklist': {
87
- const entries = value
88
- .split(',')
89
- .map((r) => r.trim())
90
- .filter(Boolean);
91
- const valid = [];
92
- const invalid = [];
93
- for (const entry of entries) {
94
- const normalized = entry.replace(/\s+/g, '');
95
- if (/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(normalized)) {
96
- valid.push(normalized);
52
+ case 'languages':
53
+ stateManager.updateConfig({ languages: value.split(',').map((l) => l.trim()) });
54
+ results[key] = value;
55
+ break;
56
+ case 'labels':
57
+ stateManager.updateConfig({ labels: value.split(',').map((l) => l.trim()) });
58
+ results[key] = value;
59
+ break;
60
+ case 'showHealthCheck':
61
+ stateManager.updateConfig({ showHealthCheck: value !== 'false' });
62
+ results[key] = value !== 'false' ? 'true' : 'false';
63
+ break;
64
+ case 'squashByDefault':
65
+ if (value === 'ask') {
66
+ stateManager.updateConfig({ squashByDefault: 'ask' });
67
+ results[key] = 'ask';
97
68
  }
98
69
  else {
99
- invalid.push(entry);
70
+ stateManager.updateConfig({ squashByDefault: value !== 'false' });
71
+ results[key] = value !== 'false' ? 'true' : 'false';
100
72
  }
101
- }
102
- if (invalid.length > 0) {
103
- warnings.push(`Warning: Skipping invalid entries (expected "owner/repo" format): ${invalid.join(', ')}`);
104
- results['aiPolicyBlocklist_invalidEntries'] = invalid.join(', ');
105
- }
106
- if (valid.length === 0 && entries.length > 0) {
107
- warnings.push('Warning: All entries were invalid. Blocklist not updated.');
108
- results[key] = '(all entries invalid)';
73
+ break;
74
+ case 'minStars': {
75
+ const stars = Number(value);
76
+ if (!Number.isFinite(stars) || !Number.isInteger(stars) || stars < 0) {
77
+ throw new ValidationError(`Invalid value for minStars: "${value}". Must be a non-negative integer.`);
78
+ }
79
+ stateManager.updateConfig({ minStars: stars });
80
+ results[key] = String(stars);
109
81
  break;
110
82
  }
111
- stateManager.updateConfig({ aiPolicyBlocklist: valid });
112
- results[key] = valid.length > 0 ? valid.join(', ') : '(empty)';
113
- break;
114
- }
115
- case 'projectCategories': {
116
- const categories = value
117
- .split(',')
118
- .map((c) => c.trim())
119
- .filter(Boolean);
120
- const validCategories = [];
121
- const invalidCategories = [];
122
- for (const cat of categories) {
123
- if (PROJECT_CATEGORIES.includes(cat)) {
124
- validCategories.push(cat);
83
+ case 'includeDocIssues':
84
+ stateManager.updateConfig({ includeDocIssues: value === 'true' });
85
+ results[key] = value === 'true' ? 'true' : 'false';
86
+ break;
87
+ case 'aiPolicyBlocklist': {
88
+ const entries = value
89
+ .split(',')
90
+ .map((r) => r.trim())
91
+ .filter(Boolean);
92
+ const valid = [];
93
+ const invalid = [];
94
+ for (const entry of entries) {
95
+ const normalized = entry.replace(/\s+/g, '');
96
+ if (/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(normalized)) {
97
+ valid.push(normalized);
98
+ }
99
+ else {
100
+ invalid.push(entry);
101
+ }
125
102
  }
126
- else {
127
- invalidCategories.push(cat);
103
+ if (invalid.length > 0) {
104
+ warnings.push(`Warning: Skipping invalid entries (expected "owner/repo" format): ${invalid.join(', ')}`);
105
+ results['aiPolicyBlocklist_invalidEntries'] = invalid.join(', ');
106
+ }
107
+ if (valid.length === 0 && entries.length > 0) {
108
+ warnings.push('Warning: All entries were invalid. Blocklist not updated.');
109
+ results[key] = '(all entries invalid)';
110
+ break;
128
111
  }
112
+ stateManager.updateConfig({ aiPolicyBlocklist: valid });
113
+ results[key] = valid.length > 0 ? valid.join(', ') : '(empty)';
114
+ break;
129
115
  }
130
- if (invalidCategories.length > 0) {
131
- warnings.push(`Unknown project categories: ${invalidCategories.join(', ')}. Valid: ${PROJECT_CATEGORIES.join(', ')}`);
116
+ case 'projectCategories': {
117
+ const categories = value
118
+ .split(',')
119
+ .map((c) => c.trim())
120
+ .filter(Boolean);
121
+ const validCategories = [];
122
+ const invalidCategories = [];
123
+ for (const cat of categories) {
124
+ if (PROJECT_CATEGORIES.includes(cat)) {
125
+ validCategories.push(cat);
126
+ }
127
+ else {
128
+ invalidCategories.push(cat);
129
+ }
130
+ }
131
+ if (invalidCategories.length > 0) {
132
+ warnings.push(`Unknown project categories: ${invalidCategories.join(', ')}. Valid: ${PROJECT_CATEGORIES.join(', ')}`);
133
+ }
134
+ const dedupedCategories = [...new Set(validCategories)];
135
+ stateManager.updateConfig({ projectCategories: dedupedCategories });
136
+ results[key] = dedupedCategories.length > 0 ? dedupedCategories.join(', ') : '(empty)';
137
+ break;
132
138
  }
133
- const dedupedCategories = [...new Set(validCategories)];
134
- stateManager.updateConfig({ projectCategories: dedupedCategories });
135
- results[key] = dedupedCategories.length > 0 ? dedupedCategories.join(', ') : '(empty)';
136
- break;
137
- }
138
- case 'preferredOrgs': {
139
- const orgs = value
140
- .split(',')
141
- .map((o) => o.trim())
142
- .filter(Boolean);
143
- const validOrgs = [];
144
- for (const org of orgs) {
145
- if (org.includes('/')) {
146
- warnings.push(`"${org}" looks like a repo path. Use org name only (e.g., "vercel" not "vercel/next.js").`);
139
+ case 'preferredOrgs': {
140
+ const orgs = value
141
+ .split(',')
142
+ .map((o) => o.trim())
143
+ .filter(Boolean);
144
+ const validOrgs = [];
145
+ for (const org of orgs) {
146
+ if (org.includes('/')) {
147
+ warnings.push(`"${org}" looks like a repo path. Use org name only (e.g., "vercel" not "vercel/next.js").`);
148
+ }
149
+ else if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(org)) {
150
+ warnings.push(`"${org}" is not a valid GitHub organization name. Skipping.`);
151
+ }
152
+ else {
153
+ validOrgs.push(org.toLowerCase());
154
+ }
147
155
  }
148
- else if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(org)) {
149
- warnings.push(`"${org}" is not a valid GitHub organization name. Skipping.`);
156
+ const dedupedOrgs = [...new Set(validOrgs)];
157
+ stateManager.updateConfig({ preferredOrgs: dedupedOrgs });
158
+ results[key] = dedupedOrgs.length > 0 ? dedupedOrgs.join(', ') : '(empty)';
159
+ break;
160
+ }
161
+ case 'scope': {
162
+ const scopeValues = value
163
+ .split(',')
164
+ .map((s) => s.trim())
165
+ .filter(Boolean);
166
+ const validScopes = [];
167
+ const invalidScopes = [];
168
+ for (const s of scopeValues) {
169
+ if (ISSUE_SCOPES.includes(s)) {
170
+ validScopes.push(s);
171
+ }
172
+ else {
173
+ invalidScopes.push(s);
174
+ }
150
175
  }
151
- else {
152
- validOrgs.push(org.toLowerCase());
176
+ if (invalidScopes.length > 0) {
177
+ warnings.push(`Unknown issue scopes: ${invalidScopes.join(', ')}. Valid: ${ISSUE_SCOPES.join(', ')}`);
153
178
  }
179
+ const dedupedScopes = [...new Set(validScopes)];
180
+ stateManager.updateConfig({ scope: dedupedScopes.length > 0 ? dedupedScopes : undefined });
181
+ results[key] = dedupedScopes.length > 0 ? dedupedScopes.join(', ') : '(empty — using labels only)';
182
+ break;
154
183
  }
155
- const dedupedOrgs = [...new Set(validOrgs)];
156
- stateManager.updateConfig({ preferredOrgs: dedupedOrgs });
157
- results[key] = dedupedOrgs.length > 0 ? dedupedOrgs.join(', ') : '(empty)';
158
- break;
184
+ case 'issueListPath':
185
+ stateManager.updateConfig({ issueListPath: value || undefined });
186
+ results[key] = value || '(cleared)';
187
+ break;
188
+ case 'complete':
189
+ if (value === 'true') {
190
+ stateManager.markSetupComplete();
191
+ results[key] = 'true';
192
+ }
193
+ break;
194
+ default:
195
+ warnings.push(`Unknown setting: ${key}`);
159
196
  }
160
- case 'complete':
161
- if (value === 'true') {
162
- stateManager.markSetupComplete();
163
- results[key] = 'true';
164
- }
165
- break;
166
- default:
167
- warnings.push(`Unknown setting: ${key}`);
168
197
  }
169
- }
170
- stateManager.save();
198
+ });
171
199
  return { success: true, settings: results, warnings: warnings.length > 0 ? warnings : undefined };
172
200
  }
173
201
  // Show setup status
@@ -183,6 +211,7 @@ export async function runSetup(options) {
183
211
  labels: config.labels,
184
212
  projectCategories: config.projectCategories ?? [],
185
213
  preferredOrgs: config.preferredOrgs ?? [],
214
+ scope: config.scope ?? [],
186
215
  },
187
216
  };
188
217
  }
@@ -232,6 +261,13 @@ export async function runSetup(options) {
232
261
  default: ['good first issue', 'help wanted'],
233
262
  type: 'list',
234
263
  },
264
+ {
265
+ setting: 'scope',
266
+ prompt: 'What scope of issues do you want to discover? (beginner, intermediate, advanced — leave empty for default labels only)',
267
+ current: config.scope ?? [],
268
+ default: [],
269
+ type: 'list',
270
+ },
235
271
  {
236
272
  setting: 'aiPolicyBlocklist',
237
273
  prompt: 'Repos with anti-AI contribution policies to block (owner/repo, comma-separated)?',
@@ -15,21 +15,21 @@ export async function runShelve(options) {
15
15
  validateUrl(options.prUrl);
16
16
  validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
17
17
  const stateManager = getStateManager();
18
- const added = stateManager.shelvePR(options.prUrl);
19
- const clearedOverride = stateManager.clearStatusOverride(options.prUrl);
20
- if (added || clearedOverride) {
21
- stateManager.save();
22
- }
18
+ let added = false;
19
+ stateManager.batch(() => {
20
+ added = stateManager.shelvePR(options.prUrl);
21
+ stateManager.clearStatusOverride(options.prUrl);
22
+ });
23
23
  return { shelved: added, url: options.prUrl };
24
24
  }
25
25
  export async function runUnshelve(options) {
26
26
  validateUrl(options.prUrl);
27
27
  validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
28
28
  const stateManager = getStateManager();
29
- const removed = stateManager.unshelvePR(options.prUrl);
30
- const clearedOverride = stateManager.clearStatusOverride(options.prUrl);
31
- if (removed || clearedOverride) {
32
- stateManager.save();
33
- }
29
+ let removed = false;
30
+ stateManager.batch(() => {
31
+ removed = stateManager.unshelvePR(options.prUrl);
32
+ stateManager.clearStatusOverride(options.prUrl);
33
+ });
34
34
  return { unshelved: removed, url: options.prUrl };
35
35
  }
@@ -10,6 +10,7 @@ import * as fs from 'fs';
10
10
  import { execFile } from 'child_process';
11
11
  import { getStateManager, getGitHubToken, getCLIVersion, detectGitHubUsername } from '../core/index.js';
12
12
  import { errorMessage } from '../core/errors.js';
13
+ import { warn } from '../core/logger.js';
13
14
  import { executeDailyCheck } from './daily.js';
14
15
  import { launchDashboardServer } from './dashboard-lifecycle.js';
15
16
  /**
@@ -53,22 +54,37 @@ export function countIssueListItems(content) {
53
54
  export function detectIssueList() {
54
55
  let issueListPath = '';
55
56
  let source = 'auto-detected';
56
- // 1. Check config file for configured path
57
- const configPath = '.claude/oss-autopilot/config.md';
58
- if (fs.existsSync(configPath)) {
59
- try {
60
- const configContent = fs.readFileSync(configPath, 'utf-8');
61
- const configuredPath = parseIssueListPathFromConfig(configContent);
62
- if (configuredPath && fs.existsSync(configuredPath)) {
63
- issueListPath = configuredPath;
64
- source = 'configured';
65
- }
57
+ // 1. Check state.json config (primary)
58
+ try {
59
+ const stateManager = getStateManager();
60
+ const configuredPath = stateManager.getState().config.issueListPath;
61
+ if (configuredPath && fs.existsSync(configuredPath)) {
62
+ issueListPath = configuredPath;
63
+ source = 'configured';
66
64
  }
67
- catch (error) {
68
- console.error('[STARTUP] Failed to read config:', errorMessage(error));
65
+ }
66
+ catch (error) {
67
+ // State manager may not be initialized yet — fall through to legacy config.md
68
+ warn('startup', `Could not read issueListPath from state: ${errorMessage(error)}`);
69
+ }
70
+ // 2. Fallback: config.md (legacy — will be removed in future)
71
+ if (!issueListPath) {
72
+ const configPath = '.claude/oss-autopilot/config.md';
73
+ if (fs.existsSync(configPath)) {
74
+ try {
75
+ const configContent = fs.readFileSync(configPath, 'utf-8');
76
+ const configuredPath = parseIssueListPathFromConfig(configContent);
77
+ if (configuredPath && fs.existsSync(configuredPath)) {
78
+ issueListPath = configuredPath;
79
+ source = 'configured';
80
+ }
81
+ }
82
+ catch (error) {
83
+ console.error('[STARTUP] Failed to read config:', errorMessage(error));
84
+ }
69
85
  }
70
86
  }
71
- // 2. Probe known paths
87
+ // 3. Probe known paths
72
88
  if (!issueListPath) {
73
89
  const probes = ['open-source/potential-issue-list.md', 'oss/issue-list.md', 'issues.md'];
74
90
  for (const probe of probes) {
@@ -81,7 +97,7 @@ export function detectIssueList() {
81
97
  }
82
98
  if (!issueListPath)
83
99
  return undefined;
84
- // 3. Count available/completed items
100
+ // 4. Count available/completed items
85
101
  try {
86
102
  const content = fs.readFileSync(issueListPath, 'utf-8');
87
103
  const { availableCount, completedCount } = countIssueListItems(content);
@@ -2,6 +2,7 @@
2
2
  * CI Analysis - Classification and analysis of CI check runs and combined statuses.
3
3
  * Extracted from PRMonitor to isolate CI-related logic (#263).
4
4
  */
5
+ import type { Octokit } from '@octokit/rest';
5
6
  import { CIFailureCategory, ClassifiedCheck, CIStatusResult } from './types.js';
6
7
  /**
7
8
  * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145).
@@ -61,4 +62,9 @@ export declare function analyzeCombinedStatus(combinedStatus: {
61
62
  * Priority: failing > pending > passing > unknown.
62
63
  */
63
64
  export declare function mergeStatuses(checkRunAnalysis: CheckRunAnalysis, combinedAnalysis: CombinedStatusAnalysis, checkRunCount: number): CIStatusResult;
65
+ /**
66
+ * Get CI status for a commit SHA by querying both the combined status API and check runs API.
67
+ * Returns the merged status and names of any failing checks for diagnostics.
68
+ */
69
+ export declare function getCIStatus(octokit: Octokit, owner: string, repo: string, sha: string): Promise<CIStatusResult>;
64
70
  export {};
@@ -2,6 +2,12 @@
2
2
  * CI Analysis - Classification and analysis of CI check runs and combined statuses.
3
3
  * Extracted from PRMonitor to isolate CI-related logic (#263).
4
4
  */
5
+ import { getHttpStatusCode, errorMessage } from './errors.js';
6
+ import { debug, warn } from './logger.js';
7
+ /** Return a fresh unknown CI status (avoids shared mutable state between callers). */
8
+ function unknownCIStatus() {
9
+ return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
10
+ }
5
11
  /**
6
12
  * Known CI check name patterns that indicate fork limitations rather than real failures (#81).
7
13
  * These are deployment/preview services that require repo-level secrets unavailable in forks.
@@ -119,15 +125,23 @@ export function analyzeCombinedStatus(combinedStatus) {
119
125
  const hasRealFailure = realStatuses.some((s) => s.state === 'failure' || s.state === 'error');
120
126
  const hasRealPending = realStatuses.some((s) => s.state === 'pending');
121
127
  const hasRealSuccess = realStatuses.some((s) => s.state === 'success');
122
- const effectiveCombinedState = hasRealFailure
123
- ? 'failure'
124
- : hasRealPending
125
- ? 'pending'
126
- : hasRealSuccess
127
- ? 'success'
128
- : realStatuses.length === 0
129
- ? 'success' // All statuses were auth gates; don't inherit original failure
130
- : combinedStatus.state;
128
+ let effectiveCombinedState;
129
+ if (hasRealFailure) {
130
+ effectiveCombinedState = 'failure';
131
+ }
132
+ else if (hasRealPending) {
133
+ effectiveCombinedState = 'pending';
134
+ }
135
+ else if (hasRealSuccess) {
136
+ effectiveCombinedState = 'success';
137
+ }
138
+ else if (realStatuses.length === 0) {
139
+ // All statuses were auth gates; don't inherit original failure
140
+ effectiveCombinedState = 'success';
141
+ }
142
+ else {
143
+ effectiveCombinedState = combinedStatus.state;
144
+ }
131
145
  const hasStatuses = combinedStatus.statuses.length > 0;
132
146
  // Collect failing status names from combined status API
133
147
  const failingStatusNames = [];
@@ -157,9 +171,72 @@ export function mergeStatuses(checkRunAnalysis, combinedAnalysis, checkRunCount)
157
171
  if (hasSuccessfulChecks || effectiveCombinedState === 'success') {
158
172
  return { status: 'passing', failingCheckNames: [], failingCheckConclusions: new Map() };
159
173
  }
160
- // No checks found at all - this is common for repos without CI
174
+ // No checks found at all common for repos without CI
161
175
  if (!hasStatuses && checkRunCount === 0) {
162
- return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
176
+ return unknownCIStatus();
177
+ }
178
+ return unknownCIStatus();
179
+ }
180
+ /**
181
+ * Get CI status for a commit SHA by querying both the combined status API and check runs API.
182
+ * Returns the merged status and names of any failing checks for diagnostics.
183
+ */
184
+ export async function getCIStatus(octokit, owner, repo, sha) {
185
+ if (!sha)
186
+ return unknownCIStatus();
187
+ try {
188
+ // Fetch both combined status and check runs in parallel
189
+ const [statusResponse, checksResponse] = await Promise.all([
190
+ octokit.repos.getCombinedStatusForRef({ owner, repo, ref: sha }),
191
+ // 404 is expected for repos without check runs configured; log other errors for debugging
192
+ octokit.checks.listForRef({ owner, repo, ref: sha }).catch((err) => {
193
+ const status = getHttpStatusCode(err);
194
+ // Rate limit errors must propagate — matches listReviewComments pattern (#481)
195
+ if (status === 429)
196
+ throw err;
197
+ if (status === 403) {
198
+ const msg = errorMessage(err).toLowerCase();
199
+ if (msg.includes('rate limit') || msg.includes('abuse detection'))
200
+ throw err;
201
+ }
202
+ if (status === 404) {
203
+ debug('pr-monitor', `Check runs 404 for ${owner}/${repo}@${sha.slice(0, 7)} (no checks configured)`);
204
+ }
205
+ else {
206
+ warn('pr-monitor', `Non-404 error fetching check runs for ${owner}/${repo}@${sha.slice(0, 7)}: ${status ?? err}`);
207
+ }
208
+ return null;
209
+ }),
210
+ ]);
211
+ const combinedStatus = statusResponse.data;
212
+ const allCheckRuns = checksResponse?.data?.check_runs || [];
213
+ // Deduplicate check runs by name, keeping only the most recent run per unique name.
214
+ // GitHub returns all historical runs (including re-runs), so without deduplication
215
+ // a superseded failure will incorrectly flag the PR as failing even after a re-run passes.
216
+ const latestCheckRunsByName = new Map();
217
+ for (const check of allCheckRuns) {
218
+ const existing = latestCheckRunsByName.get(check.name);
219
+ if (!existing || new Date(check.started_at ?? 0) > new Date(existing.started_at ?? 0)) {
220
+ latestCheckRunsByName.set(check.name, check);
221
+ }
222
+ }
223
+ const checkRuns = [...latestCheckRunsByName.values()];
224
+ const checkRunAnalysis = analyzeCheckRuns(checkRuns);
225
+ const combinedAnalysis = analyzeCombinedStatus(combinedStatus);
226
+ return mergeStatuses(checkRunAnalysis, combinedAnalysis, checkRuns.length);
227
+ }
228
+ catch (error) {
229
+ const statusCode = getHttpStatusCode(error);
230
+ if (statusCode === 401 || statusCode === 403 || statusCode === 429) {
231
+ throw error;
232
+ }
233
+ else if (statusCode === 404) {
234
+ // Repo might not have CI configured, this is normal
235
+ debug('pr-monitor', `CI check 404 for ${owner}/${repo} (no CI configured)`);
236
+ }
237
+ else {
238
+ warn('pr-monitor', `Failed to check CI for ${owner}/${repo}@${sha.slice(0, 7)}: ${errorMessage(error)}`);
239
+ }
240
+ return unknownCIStatus();
163
241
  }
164
- return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
165
242
  }