@oss-autopilot/core 0.41.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -0
  3. package/dist/cli.bundle.cjs +17657 -0
  4. package/dist/cli.d.ts +12 -0
  5. package/dist/cli.js +325 -0
  6. package/dist/commands/check-integration.d.ts +10 -0
  7. package/dist/commands/check-integration.js +192 -0
  8. package/dist/commands/comments.d.ts +24 -0
  9. package/dist/commands/comments.js +311 -0
  10. package/dist/commands/config.d.ts +11 -0
  11. package/dist/commands/config.js +82 -0
  12. package/dist/commands/daily.d.ts +29 -0
  13. package/dist/commands/daily.js +433 -0
  14. package/dist/commands/dashboard-data.d.ts +45 -0
  15. package/dist/commands/dashboard-data.js +132 -0
  16. package/dist/commands/dashboard-templates.d.ts +23 -0
  17. package/dist/commands/dashboard-templates.js +1627 -0
  18. package/dist/commands/dashboard.d.ts +18 -0
  19. package/dist/commands/dashboard.js +134 -0
  20. package/dist/commands/dismiss.d.ts +13 -0
  21. package/dist/commands/dismiss.js +49 -0
  22. package/dist/commands/init.d.ts +10 -0
  23. package/dist/commands/init.js +27 -0
  24. package/dist/commands/local-repos.d.ts +14 -0
  25. package/dist/commands/local-repos.js +155 -0
  26. package/dist/commands/parse-list.d.ts +13 -0
  27. package/dist/commands/parse-list.js +139 -0
  28. package/dist/commands/read.d.ts +12 -0
  29. package/dist/commands/read.js +33 -0
  30. package/dist/commands/search.d.ts +10 -0
  31. package/dist/commands/search.js +74 -0
  32. package/dist/commands/setup.d.ts +15 -0
  33. package/dist/commands/setup.js +276 -0
  34. package/dist/commands/shelve.d.ts +13 -0
  35. package/dist/commands/shelve.js +49 -0
  36. package/dist/commands/snooze.d.ts +18 -0
  37. package/dist/commands/snooze.js +83 -0
  38. package/dist/commands/startup.d.ts +33 -0
  39. package/dist/commands/startup.js +197 -0
  40. package/dist/commands/status.d.ts +10 -0
  41. package/dist/commands/status.js +43 -0
  42. package/dist/commands/track.d.ts +16 -0
  43. package/dist/commands/track.js +59 -0
  44. package/dist/commands/validation.d.ts +43 -0
  45. package/dist/commands/validation.js +112 -0
  46. package/dist/commands/vet.d.ts +10 -0
  47. package/dist/commands/vet.js +36 -0
  48. package/dist/core/checklist-analysis.d.ts +17 -0
  49. package/dist/core/checklist-analysis.js +39 -0
  50. package/dist/core/ci-analysis.d.ts +78 -0
  51. package/dist/core/ci-analysis.js +163 -0
  52. package/dist/core/comment-utils.d.ts +15 -0
  53. package/dist/core/comment-utils.js +52 -0
  54. package/dist/core/concurrency.d.ts +5 -0
  55. package/dist/core/concurrency.js +15 -0
  56. package/dist/core/daily-logic.d.ts +77 -0
  57. package/dist/core/daily-logic.js +512 -0
  58. package/dist/core/display-utils.d.ts +10 -0
  59. package/dist/core/display-utils.js +100 -0
  60. package/dist/core/errors.d.ts +24 -0
  61. package/dist/core/errors.js +34 -0
  62. package/dist/core/github-stats.d.ts +73 -0
  63. package/dist/core/github-stats.js +272 -0
  64. package/dist/core/github.d.ts +19 -0
  65. package/dist/core/github.js +60 -0
  66. package/dist/core/http-cache.d.ts +97 -0
  67. package/dist/core/http-cache.js +269 -0
  68. package/dist/core/index.d.ts +15 -0
  69. package/dist/core/index.js +15 -0
  70. package/dist/core/issue-conversation.d.ts +29 -0
  71. package/dist/core/issue-conversation.js +231 -0
  72. package/dist/core/issue-discovery.d.ts +85 -0
  73. package/dist/core/issue-discovery.js +589 -0
  74. package/dist/core/issue-filtering.d.ts +51 -0
  75. package/dist/core/issue-filtering.js +103 -0
  76. package/dist/core/issue-scoring.d.ts +40 -0
  77. package/dist/core/issue-scoring.js +92 -0
  78. package/dist/core/issue-vetting.d.ts +49 -0
  79. package/dist/core/issue-vetting.js +536 -0
  80. package/dist/core/logger.d.ts +21 -0
  81. package/dist/core/logger.js +49 -0
  82. package/dist/core/maintainer-analysis.d.ts +10 -0
  83. package/dist/core/maintainer-analysis.js +59 -0
  84. package/dist/core/pagination.d.ts +11 -0
  85. package/dist/core/pagination.js +20 -0
  86. package/dist/core/pr-monitor.d.ts +109 -0
  87. package/dist/core/pr-monitor.js +594 -0
  88. package/dist/core/review-analysis.d.ts +72 -0
  89. package/dist/core/review-analysis.js +163 -0
  90. package/dist/core/state.d.ts +371 -0
  91. package/dist/core/state.js +1089 -0
  92. package/dist/core/types.d.ts +507 -0
  93. package/dist/core/types.js +34 -0
  94. package/dist/core/utils.d.ts +249 -0
  95. package/dist/core/utils.js +422 -0
  96. package/dist/formatters/json.d.ts +269 -0
  97. package/dist/formatters/json.js +88 -0
  98. package/package.json +67 -0
@@ -0,0 +1,594 @@
1
+ /**
2
+ * PR Monitor - Fetches and checks PR status from GitHub.
3
+ * v2: fetchUserOpenPRs() is stateless (no local PR tracking),
4
+ * Score methods still write to state.
5
+ *
6
+ * Decomposed into focused modules (#263):
7
+ * - ci-analysis.ts: CI check classification and analysis
8
+ * - review-analysis.ts: Review decision and comment detection
9
+ * - checklist-analysis.ts: PR body checklist analysis
10
+ * - maintainer-analysis.ts: Maintainer action hint extraction
11
+ * - display-utils.ts: Display label computation
12
+ * - github-stats.ts: Merged/closed PR counts and star fetching
13
+ */
14
+ import { getOctokit } from './github.js';
15
+ import { getStateManager } from './state.js';
16
+ import { daysBetween, parseGitHubUrl, extractOwnerRepo } from './utils.js';
17
+ import { runWorkerPool } from './concurrency.js';
18
+ import { ConfigurationError, ValidationError } from './errors.js';
19
+ import { paginateAll } from './pagination.js';
20
+ import { debug, warn, timed } from './logger.js';
21
+ import { getHttpCache, cachedRequest } from './http-cache.js';
22
+ // Extracted modules
23
+ import { classifyFailingChecks, analyzeCheckRuns, analyzeCombinedStatus, mergeStatuses } from './ci-analysis.js';
24
+ import { determineReviewDecision, getLatestChangesRequestedDate, checkUnrespondedComments, } from './review-analysis.js';
25
+ import { analyzeChecklist } from './checklist-analysis.js';
26
+ import { extractMaintainerActionHints } from './maintainer-analysis.js';
27
+ import { computeDisplayLabel } from './display-utils.js';
28
+ import { fetchUserMergedPRCounts as fetchUserMergedPRCountsImpl, fetchUserClosedPRCounts as fetchUserClosedPRCountsImpl, fetchRecentlyClosedPRs as fetchRecentlyClosedPRsImpl, fetchRecentlyMergedPRs as fetchRecentlyMergedPRsImpl, } from './github-stats.js';
29
+ // Re-export so existing consumers can still import from pr-monitor
30
+ export { computeDisplayLabel } from './display-utils.js';
31
+ export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
32
+ export { isConditionalChecklistItem } from './checklist-analysis.js';
33
+ const MODULE = 'pr-monitor';
34
+ // Concurrency limit for parallel API calls
35
+ const MAX_CONCURRENT_REQUESTS = 5;
36
+ export class PRMonitor {
37
+ octokit;
38
+ stateManager;
39
+ constructor(githubToken) {
40
+ this.octokit = getOctokit(githubToken);
41
+ this.stateManager = getStateManager();
42
+ }
43
+ /**
44
+ * Fetch all open PRs for the configured user fresh from GitHub
45
+ * This is the main entry point for the v2 architecture
46
+ */
47
+ async fetchUserOpenPRs() {
48
+ const config = this.stateManager.getState().config;
49
+ if (!config.githubUsername) {
50
+ throw new ConfigurationError('No GitHub username configured. Run setup first.');
51
+ }
52
+ debug('pr-monitor', `Fetching open PRs for @${config.githubUsername}...`);
53
+ // Search for all open PRs authored by the user with pagination
54
+ const allItems = [];
55
+ let page = 1;
56
+ const perPage = 100;
57
+ const firstPage = await this.octokit.search.issuesAndPullRequests({
58
+ q: `is:pr is:open author:${config.githubUsername}`,
59
+ sort: 'updated',
60
+ order: 'desc',
61
+ per_page: perPage,
62
+ page: 1,
63
+ });
64
+ allItems.push(...firstPage.data.items);
65
+ const totalCount = firstPage.data.total_count;
66
+ debug('pr-monitor', `Found ${totalCount} open PRs`);
67
+ // Fetch remaining pages if needed (GitHub search API returns max 1000 results)
68
+ const totalPages = Math.min(Math.ceil(totalCount / perPage), 10); // Cap at 1000 results
69
+ while (page < totalPages) {
70
+ page++;
71
+ const nextPage = await this.octokit.search.issuesAndPullRequests({
72
+ q: `is:pr is:open author:${config.githubUsername}`,
73
+ sort: 'updated',
74
+ order: 'desc',
75
+ per_page: perPage,
76
+ page,
77
+ });
78
+ allItems.push(...nextPage.data.items);
79
+ }
80
+ // Filter items to only PRs worth fetching
81
+ const prs = [];
82
+ const failures = [];
83
+ const shelvedUrls = new Set(config.shelvedPRUrls || []);
84
+ const filteredItems = allItems.filter((item) => {
85
+ if (!item.pull_request)
86
+ return false;
87
+ // Skip PRs to repos owned by the user (not OSS contributions)
88
+ const parsed = extractOwnerRepo(item.html_url);
89
+ if (!parsed) {
90
+ warn('pr-monitor', `Skipping PR with unparseable URL: ${item.html_url}`);
91
+ return false;
92
+ }
93
+ const ownerLower = parsed.owner.toLowerCase();
94
+ if (ownerLower === config.githubUsername.toLowerCase())
95
+ return false;
96
+ const repoFullName = `${parsed.owner}/${parsed.repo}`;
97
+ // Keep shelved PRs even from excluded repos/orgs — excludeRepos is meant
98
+ // to stop finding *new* issues there, not hide open PRs already being tracked (#175)
99
+ const isShelved = shelvedUrls.has(item.html_url);
100
+ if (config.excludeRepos.includes(repoFullName) && !isShelved)
101
+ return false;
102
+ if (config.excludeOrgs?.some((org) => ownerLower === org.toLowerCase()) && !isShelved)
103
+ return false;
104
+ return true;
105
+ });
106
+ debug('pr-monitor', `Filtered to ${filteredItems.length} PRs after excluding own repos, shelved, and excluded orgs/repos`);
107
+ // Fetch detailed info using a worker pool for bounded concurrency.
108
+ await timed('pr-monitor', `Fetch details for ${filteredItems.length} PRs`, async () => {
109
+ await runWorkerPool(filteredItems, async (item) => {
110
+ try {
111
+ debug('pr-monitor', `Fetching details for ${item.html_url}`);
112
+ const pr = await this.fetchPRDetails(item.html_url);
113
+ if (pr)
114
+ prs.push(pr);
115
+ }
116
+ catch (error) {
117
+ const errorMessage = error instanceof Error ? error.message : String(error);
118
+ warn('pr-monitor', `Error fetching ${item.html_url}: ${errorMessage}`);
119
+ failures.push({ prUrl: item.html_url, error: errorMessage });
120
+ }
121
+ }, MAX_CONCURRENT_REQUESTS);
122
+ });
123
+ // Sort by days since activity (most urgent first)
124
+ prs.sort((a, b) => {
125
+ // Priority: needs_response > failing_ci > merge_conflict > approaching_dormant > dormant > waiting > healthy
126
+ const statusPriority = {
127
+ needs_response: 0,
128
+ needs_changes: 1,
129
+ failing_ci: 2,
130
+ ci_blocked: 3,
131
+ ci_not_running: 4,
132
+ merge_conflict: 5,
133
+ needs_rebase: 6,
134
+ missing_required_files: 7,
135
+ incomplete_checklist: 8,
136
+ changes_addressed: 9,
137
+ approaching_dormant: 10,
138
+ dormant: 11,
139
+ waiting: 12,
140
+ waiting_on_maintainer: 13,
141
+ healthy: 14,
142
+ };
143
+ return statusPriority[a.status] - statusPriority[b.status];
144
+ });
145
+ return { prs, failures };
146
+ }
147
+ /**
148
+ * Fetch detailed information for a single PR
149
+ */
150
+ async fetchPRDetails(prUrl) {
151
+ const parsed = parseGitHubUrl(prUrl);
152
+ if (!parsed || parsed.type !== 'pull') {
153
+ throw new ValidationError(`Invalid PR URL format: ${prUrl}`);
154
+ }
155
+ const { owner, repo, number } = parsed;
156
+ const config = this.stateManager.getState().config;
157
+ // Fetch PR data, comments, reviews, and inline review comments in parallel.
158
+ // listReviewComments is non-critical (used for self-reply detection), so degrade
159
+ // gracefully on failure rather than dropping the entire PR (#199).
160
+ const [prResponse, comments, reviewsResponse, reviewComments] = await Promise.all([
161
+ this.octokit.pulls.get({ owner, repo, pull_number: number }),
162
+ paginateAll((page) => this.octokit.issues.listComments({ owner, repo, issue_number: number, per_page: 100, page })),
163
+ this.octokit.pulls.listReviews({ owner, repo, pull_number: number }),
164
+ paginateAll((page) => this.octokit.pulls.listReviewComments({ owner, repo, pull_number: number, per_page: 100, page })).catch((err) => {
165
+ const status = err?.status;
166
+ // Rate limit errors must propagate — silently swallowing them hides
167
+ // a systemic problem and produces misleading results (#229).
168
+ if (status === 429) {
169
+ throw err;
170
+ }
171
+ if (status === 403) {
172
+ const msg = (err?.message ?? '').toLowerCase();
173
+ if (msg.includes('rate limit') || msg.includes('abuse detection')) {
174
+ throw err;
175
+ }
176
+ // Non-rate-limit 403 (DMCA, private repo, SSO) — degrade gracefully
177
+ warn('pr-monitor', `403 fetching review comments for ${owner}/${repo}#${number}: ${msg}`);
178
+ return [];
179
+ }
180
+ if (status === 404) {
181
+ debug('pr-monitor', `Review comments 404 for ${owner}/${repo}#${number} (likely no inline comments)`);
182
+ }
183
+ else {
184
+ warn('pr-monitor', `Failed to fetch review comments for ${owner}/${repo}#${number} (status ${status ?? 'unknown'}): self-reply detection will be skipped`);
185
+ }
186
+ return [];
187
+ }),
188
+ ]);
189
+ const ghPR = prResponse.data;
190
+ const reviews = reviewsResponse.data;
191
+ // Determine review decision (delegated to review-analysis module)
192
+ const reviewDecision = determineReviewDecision(reviews);
193
+ // Check for merge conflict
194
+ const hasMergeConflict = this.hasMergeConflict(ghPR.mergeable, ghPR.mergeable_state);
195
+ // Check if there's an unresponded maintainer comment (delegated to review-analysis module)
196
+ const { hasUnrespondedComment, lastMaintainerComment } = checkUnrespondedComments(comments, reviews, reviewComments, config.githubUsername);
197
+ // Fetch CI status and (conditionally) latest commit date in parallel
198
+ // We need the commit date when hasUnrespondedComment is true (to distinguish
199
+ // "needs_response" from "changes_addressed") OR when reviewDecision is "changes_requested"
200
+ // (to detect needs_changes: review requested changes but no new commits pushed)
201
+ const ciPromise = this.getCIStatus(owner, repo, ghPR.head.sha);
202
+ const needCommitDate = hasUnrespondedComment || reviewDecision === 'changes_requested';
203
+ const commitDatePromise = needCommitDate
204
+ ? this.octokit.repos
205
+ .getCommit({ owner, repo, ref: ghPR.head.sha })
206
+ .then((res) => res.data.commit.author?.date)
207
+ .catch(() => undefined)
208
+ : Promise.resolve(undefined);
209
+ const [{ status: ciStatus, failingCheckNames, failingCheckConclusions }, latestCommitDate] = await Promise.all([
210
+ ciPromise,
211
+ commitDatePromise,
212
+ ]);
213
+ // Analyze PR body for incomplete checklists (delegated to checklist-analysis module)
214
+ const { hasIncompleteChecklist, checklistStats } = analyzeChecklist(ghPR.body || '');
215
+ // Extract maintainer action hints from comments (delegated to maintainer-analysis module)
216
+ const maintainerActionHints = extractMaintainerActionHints(lastMaintainerComment?.body, reviewDecision);
217
+ // Calculate days since activity
218
+ const daysSinceActivity = daysBetween(new Date(ghPR.updated_at), new Date());
219
+ // Find the date of the latest changes_requested review (delegated to review-analysis module)
220
+ const latestChangesRequestedDate = getLatestChangesRequestedDate(reviews);
221
+ // Determine status
222
+ const status = this.determineStatus({
223
+ ciStatus,
224
+ hasMergeConflict,
225
+ hasUnrespondedComment,
226
+ hasIncompleteChecklist,
227
+ reviewDecision,
228
+ daysSinceActivity,
229
+ dormantThreshold: config.dormantThresholdDays,
230
+ approachingThreshold: config.approachingDormantDays,
231
+ latestCommitDate,
232
+ lastMaintainerCommentDate: lastMaintainerComment?.createdAt,
233
+ latestChangesRequestedDate,
234
+ });
235
+ // Classify failing checks (delegated to ci-analysis module)
236
+ const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
237
+ return this.buildFetchedPR({
238
+ id: ghPR.id,
239
+ url: prUrl,
240
+ repo: `${owner}/${repo}`,
241
+ number,
242
+ title: ghPR.title,
243
+ status,
244
+ createdAt: ghPR.created_at,
245
+ updatedAt: ghPR.updated_at,
246
+ daysSinceActivity,
247
+ ciStatus,
248
+ failingCheckNames,
249
+ classifiedChecks,
250
+ hasMergeConflict,
251
+ reviewDecision,
252
+ hasUnrespondedComment,
253
+ lastMaintainerComment,
254
+ latestCommitDate,
255
+ hasIncompleteChecklist,
256
+ checklistStats,
257
+ maintainerActionHints,
258
+ });
259
+ }
260
+ /**
261
+ * Build a FetchedPR object from computed fields and attach display labels.
262
+ * Centralizes PR construction and display label computation (#79).
263
+ */
264
+ buildFetchedPR(fields) {
265
+ const pr = {
266
+ ...fields,
267
+ displayLabel: '', // computed below
268
+ displayDescription: '', // computed below
269
+ };
270
+ // Compute display labels (#79) — delegated to display-utils module
271
+ const { displayLabel, displayDescription } = computeDisplayLabel(pr);
272
+ pr.displayLabel = displayLabel;
273
+ pr.displayDescription = displayDescription;
274
+ return pr;
275
+ }
276
+ /**
277
+ * Determine the overall status of a PR
278
+ */
279
+ determineStatus(input) {
280
+ const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate, lastMaintainerCommentDate, latestChangesRequestedDate, } = input;
281
+ // Priority order: needs_response/needs_changes/changes_addressed > failing_ci > merge_conflict > incomplete_checklist > dormant > approaching_dormant > waiting_on_maintainer > waiting/healthy
282
+ if (hasUnrespondedComment) {
283
+ // If the contributor pushed a commit after the maintainer's comment,
284
+ // the changes have been addressed — waiting for maintainer re-review
285
+ if (latestCommitDate && lastMaintainerCommentDate && latestCommitDate > lastMaintainerCommentDate) {
286
+ if (ciStatus === 'failing')
287
+ return 'failing_ci';
288
+ return 'changes_addressed';
289
+ }
290
+ return 'needs_response';
291
+ }
292
+ // Review requested changes but no unresponded comment.
293
+ // If the latest commit is before the review, the contributor hasn't addressed it yet.
294
+ if (reviewDecision === 'changes_requested' && latestChangesRequestedDate) {
295
+ if (!latestCommitDate || latestCommitDate < latestChangesRequestedDate) {
296
+ return 'needs_changes';
297
+ }
298
+ // Commit is after review — changes have been addressed
299
+ if (ciStatus === 'failing')
300
+ return 'failing_ci';
301
+ return 'changes_addressed';
302
+ }
303
+ if (ciStatus === 'failing') {
304
+ return 'failing_ci';
305
+ }
306
+ if (hasMergeConflict) {
307
+ return 'merge_conflict';
308
+ }
309
+ if (hasIncompleteChecklist) {
310
+ return 'incomplete_checklist';
311
+ }
312
+ if (daysSinceActivity >= dormantThreshold) {
313
+ return 'dormant';
314
+ }
315
+ if (daysSinceActivity >= approachingThreshold) {
316
+ return 'approaching_dormant';
317
+ }
318
+ // Approved and CI passing/unknown = waiting on maintainer to merge
319
+ if (reviewDecision === 'approved' && (ciStatus === 'passing' || ciStatus === 'unknown')) {
320
+ return 'waiting_on_maintainer';
321
+ }
322
+ // CI pending means we're waiting
323
+ if (ciStatus === 'pending') {
324
+ return 'waiting';
325
+ }
326
+ return 'healthy';
327
+ }
328
+ /**
329
+ * Check if PR has merge conflict
330
+ */
331
+ hasMergeConflict(mergeable, mergeableState) {
332
+ if (mergeable === false)
333
+ return true;
334
+ if (mergeableState === 'dirty')
335
+ return true;
336
+ return false;
337
+ }
338
+ /**
339
+ * Get CI status from combined status API and check runs.
340
+ * Returns status and names of failing checks for diagnostics.
341
+ * Delegates analysis to ci-analysis module.
342
+ */
343
+ async getCIStatus(owner, repo, sha) {
344
+ if (!sha)
345
+ return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
346
+ try {
347
+ // Fetch both combined status and check runs in parallel
348
+ const [statusResponse, checksResponse] = await Promise.all([
349
+ this.octokit.repos.getCombinedStatusForRef({ owner, repo, ref: sha }),
350
+ // 404 is expected for repos without check runs configured; log other errors for debugging
351
+ this.octokit.checks.listForRef({ owner, repo, ref: sha }).catch((err) => {
352
+ const status = err?.status;
353
+ if (status === 404) {
354
+ debug('pr-monitor', `Check runs 404 for ${owner}/${repo}@${sha.slice(0, 7)} (no checks configured)`);
355
+ }
356
+ else {
357
+ warn('pr-monitor', `Non-404 error fetching check runs for ${owner}/${repo}@${sha.slice(0, 7)}: ${status ?? err}`);
358
+ }
359
+ return null;
360
+ }),
361
+ ]);
362
+ const combinedStatus = statusResponse.data;
363
+ const allCheckRuns = checksResponse?.data?.check_runs || [];
364
+ // Deduplicate check runs by name, keeping only the most recent run per unique name.
365
+ // GitHub returns all historical runs (including re-runs), so without deduplication
366
+ // a superseded failure will incorrectly flag the PR as failing even after a re-run passes.
367
+ const latestCheckRunsByName = new Map();
368
+ for (const check of allCheckRuns) {
369
+ const existing = latestCheckRunsByName.get(check.name);
370
+ if (!existing || new Date(check.started_at ?? 0) > new Date(existing.started_at ?? 0)) {
371
+ latestCheckRunsByName.set(check.name, check);
372
+ }
373
+ }
374
+ const checkRuns = [...latestCheckRunsByName.values()];
375
+ // Delegate analysis to ci-analysis module
376
+ const checkRunAnalysis = analyzeCheckRuns(checkRuns);
377
+ const combinedAnalysis = analyzeCombinedStatus(combinedStatus);
378
+ return mergeStatuses(checkRunAnalysis, combinedAnalysis, checkRuns.length);
379
+ }
380
+ catch (error) {
381
+ const statusCode = error.status;
382
+ const errorMessage = error instanceof Error ? error.message : String(error);
383
+ if (statusCode === 401) {
384
+ warn('pr-monitor', `CI check failed for ${owner}/${repo}: Invalid token`);
385
+ }
386
+ else if (statusCode === 403) {
387
+ warn('pr-monitor', `CI check failed for ${owner}/${repo}: Rate limit exceeded`);
388
+ }
389
+ else if (statusCode === 404) {
390
+ // Repo might not have CI configured, this is normal
391
+ debug('pr-monitor', `CI check 404 for ${owner}/${repo} (no CI configured)`);
392
+ return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
393
+ }
394
+ else {
395
+ warn('pr-monitor', `Failed to check CI for ${owner}/${repo}@${sha.slice(0, 7)}: ${errorMessage}`);
396
+ }
397
+ return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
398
+ }
399
+ }
400
+ /**
401
+ * Fetch merged PR counts and latest merge dates per repository for the configured user.
402
+ * Delegates to github-stats module.
403
+ */
404
+ async fetchUserMergedPRCounts() {
405
+ const config = this.stateManager.getState().config;
406
+ return fetchUserMergedPRCountsImpl(this.octokit, config.githubUsername);
407
+ }
408
+ /**
409
+ * Fetch closed-without-merge PR counts per repository for the configured user.
410
+ * Delegates to github-stats module.
411
+ */
412
+ async fetchUserClosedPRCounts() {
413
+ const config = this.stateManager.getState().config;
414
+ return fetchUserClosedPRCountsImpl(this.octokit, config.githubUsername);
415
+ }
416
+ /**
417
+ * Fetch GitHub star counts for a list of repositories.
418
+ * Delegates to github-stats module.
419
+ */
420
+ async fetchRepoStarCounts(repos) {
421
+ if (repos.length === 0)
422
+ return new Map();
423
+ debug(MODULE, `Fetching star counts for ${repos.length} repos...`);
424
+ const results = new Map();
425
+ const cache = getHttpCache();
426
+ // Deduplicate repos to avoid fetching the same repo twice
427
+ const uniqueRepos = [...new Set(repos)];
428
+ // Fetch in parallel chunks to avoid overwhelming the API
429
+ const chunkSize = 10;
430
+ for (let i = 0; i < uniqueRepos.length; i += chunkSize) {
431
+ const chunk = uniqueRepos.slice(i, i + chunkSize);
432
+ const settled = await Promise.allSettled(chunk.map(async (repo) => {
433
+ const parts = repo.split('/');
434
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
435
+ throw new ValidationError(`Malformed repo identifier: "${repo}"`);
436
+ }
437
+ const [owner, name] = parts;
438
+ const url = `/repos/${owner}/${name}`;
439
+ const data = await cachedRequest(cache, url, (headers) => this.octokit.repos.get({
440
+ owner,
441
+ repo: name,
442
+ headers,
443
+ }));
444
+ return { repo, stars: data.stargazers_count };
445
+ }));
446
+ let chunkFailures = 0;
447
+ for (let j = 0; j < settled.length; j++) {
448
+ const result = settled[j];
449
+ if (result.status === 'fulfilled') {
450
+ results.set(result.value.repo, result.value.stars);
451
+ }
452
+ else {
453
+ chunkFailures++;
454
+ warn(MODULE, `Failed to fetch stars for ${chunk[j]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`);
455
+ }
456
+ }
457
+ // If entire chunk failed, likely a systemic issue (rate limit, auth, outage) — abort remaining
458
+ if (chunkFailures === chunk.length && chunk.length > 0) {
459
+ const remaining = repos.length - i - chunkSize;
460
+ if (remaining > 0) {
461
+ warn(MODULE, `Entire chunk failed, aborting remaining ${remaining} repos`);
462
+ }
463
+ break;
464
+ }
465
+ }
466
+ debug(MODULE, `Fetched star counts for ${results.size}/${repos.length} repos`);
467
+ return results;
468
+ }
469
+ /**
470
+ * Shared helper: search for recent PRs and filter out own repos, excluded repos/orgs.
471
+ * Returns parsed search results that pass all filters.
472
+ */
473
+ async fetchRecentPRs(query, label, days, mapItem) {
474
+ const config = this.stateManager.getState().config;
475
+ if (!config.githubUsername) {
476
+ warn(MODULE, `Skipping recently ${label} PRs fetch: no githubUsername configured. Run /setup-oss to configure.`);
477
+ return [];
478
+ }
479
+ const sinceDate = new Date();
480
+ sinceDate.setDate(sinceDate.getDate() - days);
481
+ const since = sinceDate.toISOString().split('T')[0]; // YYYY-MM-DD
482
+ debug(MODULE, `Fetching recently ${label} PRs for @${config.githubUsername} (since ${since})...`);
483
+ const { data } = await this.octokit.search.issuesAndPullRequests({
484
+ q: query.replace('{username}', config.githubUsername).replace('{since}', since),
485
+ sort: 'updated',
486
+ order: 'desc',
487
+ per_page: 100,
488
+ });
489
+ const results = [];
490
+ for (const item of data.items) {
491
+ const parsed = parseGitHubUrl(item.html_url);
492
+ if (!parsed) {
493
+ warn(MODULE, `Could not parse GitHub URL from API response: ${item.html_url}`);
494
+ continue;
495
+ }
496
+ const repo = `${parsed.owner}/${parsed.repo}`;
497
+ // Skip own repos
498
+ if (parsed.owner.toLowerCase() === config.githubUsername.toLowerCase())
499
+ continue;
500
+ // Skip excluded repos and orgs
501
+ if (config.excludeRepos.includes(repo))
502
+ continue;
503
+ if (config.excludeOrgs?.some((org) => parsed.owner.toLowerCase() === org.toLowerCase()))
504
+ continue;
505
+ results.push(mapItem(item, { owner: parsed.owner, repo, number: parsed.number }));
506
+ }
507
+ debug(MODULE, `Found ${results.length} recently ${label} PRs`);
508
+ return results;
509
+ }
510
+ /**
511
+ * Fetch PRs closed without merge in the last N days.
512
+ * Delegates to github-stats module.
513
+ */
514
+ async fetchRecentlyClosedPRs(days = 7) {
515
+ const config = this.stateManager.getState().config;
516
+ return fetchRecentlyClosedPRsImpl(this.octokit, config, days);
517
+ }
518
+ /**
519
+ * Fetch PRs merged in the last N days.
520
+ * Delegates to github-stats module.
521
+ */
522
+ async fetchRecentlyMergedPRs(days = 7) {
523
+ const config = this.stateManager.getState().config;
524
+ return fetchRecentlyMergedPRsImpl(this.octokit, config, days);
525
+ }
526
+ /**
527
+ * Generate a daily digest from fetched PRs
528
+ */
529
+ generateDigest(prs, recentlyClosedPRs = [], recentlyMergedPRs = []) {
530
+ const now = new Date().toISOString();
531
+ // Categorize PRs
532
+ const prsNeedingResponse = prs.filter((pr) => pr.status === 'needs_response');
533
+ const ciFailingPRs = prs.filter((pr) => pr.status === 'failing_ci');
534
+ const mergeConflictPRs = prs.filter((pr) => pr.status === 'merge_conflict');
535
+ const approachingDormant = prs.filter((pr) => pr.status === 'approaching_dormant');
536
+ const dormantPRs = prs.filter((pr) => pr.status === 'dormant');
537
+ const healthyPRs = prs.filter((pr) => pr.status === 'healthy' || pr.status === 'waiting');
538
+ // Get stats from state manager (historical data from repo scores)
539
+ const stats = this.stateManager.getStats();
540
+ const ciBlockedPRs = prs.filter((pr) => pr.status === 'ci_blocked');
541
+ const ciNotRunningPRs = prs.filter((pr) => pr.status === 'ci_not_running');
542
+ const needsRebasePRs = prs.filter((pr) => pr.status === 'needs_rebase');
543
+ const missingRequiredFilesPRs = prs.filter((pr) => pr.status === 'missing_required_files');
544
+ const incompleteChecklistPRs = prs.filter((pr) => pr.status === 'incomplete_checklist');
545
+ const needsChangesPRs = prs.filter((pr) => pr.status === 'needs_changes');
546
+ const changesAddressedPRs = prs.filter((pr) => pr.status === 'changes_addressed');
547
+ const waitingOnMaintainerPRs = prs.filter((pr) => pr.status === 'waiting_on_maintainer');
548
+ return {
549
+ generatedAt: now,
550
+ openPRs: prs,
551
+ prsNeedingResponse,
552
+ ciFailingPRs,
553
+ ciBlockedPRs,
554
+ ciNotRunningPRs,
555
+ mergeConflictPRs,
556
+ needsRebasePRs,
557
+ missingRequiredFilesPRs,
558
+ incompleteChecklistPRs,
559
+ needsChangesPRs,
560
+ changesAddressedPRs,
561
+ waitingOnMaintainerPRs,
562
+ approachingDormant,
563
+ dormantPRs,
564
+ healthyPRs,
565
+ recentlyClosedPRs,
566
+ recentlyMergedPRs,
567
+ shelvedPRs: [],
568
+ autoUnshelvedPRs: [],
569
+ summary: {
570
+ totalActivePRs: prs.length,
571
+ totalNeedingAttention: prsNeedingResponse.length +
572
+ needsChangesPRs.length +
573
+ ciFailingPRs.length +
574
+ mergeConflictPRs.length +
575
+ needsRebasePRs.length +
576
+ missingRequiredFilesPRs.length +
577
+ incompleteChecklistPRs.length,
578
+ totalMergedAllTime: stats.mergedPRs,
579
+ mergeRate: parseFloat(stats.mergeRate),
580
+ },
581
+ };
582
+ }
583
+ /**
584
+ * Update repository scores based on observed PR (called when we detect merged/closed PRs)
585
+ */
586
+ async updateRepoScoreFromObservedPR(repo, wasMerged) {
587
+ if (wasMerged) {
588
+ this.stateManager.incrementMergedCount(repo);
589
+ }
590
+ else {
591
+ this.stateManager.incrementClosedCount(repo);
592
+ }
593
+ }
594
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Review Analysis - Review decision computation, unresponded comment detection,
3
+ * and self-reply filtering for PR reviews.
4
+ * Extracted from PRMonitor to isolate review-related logic (#263).
5
+ */
6
+ import { FetchedPR, ReviewDecision } from './types.js';
7
+ /** Inline review comment shape used for self-reply detection and body extraction (#199). */
8
+ export interface ReviewComment {
9
+ id: number;
10
+ user?: {
11
+ login?: string;
12
+ } | null;
13
+ body?: string | null;
14
+ created_at: string;
15
+ in_reply_to_id?: number;
16
+ pull_request_review_id?: number | null;
17
+ }
18
+ /**
19
+ * Determine review decision from reviews list.
20
+ * Groups reviews by user, keeping only the latest from each user,
21
+ * then checks for CHANGES_REQUESTED or APPROVED states.
22
+ */
23
+ export declare function determineReviewDecision(reviews: Array<{
24
+ state?: string | null;
25
+ user?: {
26
+ login?: string;
27
+ } | null;
28
+ }>): ReviewDecision;
29
+ /**
30
+ * Get the date of the latest CHANGES_REQUESTED review (from any reviewer).
31
+ * Used to detect needs_changes status when review feedback is in inline comments.
32
+ */
33
+ export declare function getLatestChangesRequestedDate(reviews: Array<{
34
+ state?: string | null;
35
+ submitted_at?: string | null;
36
+ }>): string | undefined;
37
+ /**
38
+ * Check if all inline comments in a COMMENTED review are self-replies.
39
+ * A self-reply is when an author replies to their own earlier inline comment.
40
+ * Used to filter out informational follow-ups that don't require contributor action (#199).
41
+ */
42
+ export declare function isAllSelfReplies(reviewId: number, reviewComments: ReviewComment[]): boolean;
43
+ /**
44
+ * Get the body text of inline review comments for a COMMENTED review.
45
+ * Returns the first non-empty comment body, or undefined.
46
+ * Enables the acknowledgment filter to evaluate real content instead of
47
+ * synthetic placeholders (#199).
48
+ */
49
+ export declare function getInlineCommentBody(reviewId: number, reviewComments: ReviewComment[]): string | undefined;
50
+ /**
51
+ * Check if there are unresponded comments from maintainers.
52
+ * Combines issue comments and review comments into a timeline,
53
+ * then finds maintainer comments after the user's last comment.
54
+ */
55
+ export declare function checkUnrespondedComments(comments: Array<{
56
+ user?: {
57
+ login?: string;
58
+ } | null;
59
+ body?: string | null;
60
+ created_at: string;
61
+ }>, reviews: Array<{
62
+ user?: {
63
+ login?: string;
64
+ } | null;
65
+ body?: string | null;
66
+ submitted_at?: string | null;
67
+ state?: string | null;
68
+ id?: number;
69
+ }>, reviewComments: ReviewComment[], username: string): {
70
+ hasUnrespondedComment: boolean;
71
+ lastMaintainerComment?: FetchedPR['lastMaintainerComment'];
72
+ };