@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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/cli.bundle.cjs +17657 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +325 -0
- package/dist/commands/check-integration.d.ts +10 -0
- package/dist/commands/check-integration.js +192 -0
- package/dist/commands/comments.d.ts +24 -0
- package/dist/commands/comments.js +311 -0
- package/dist/commands/config.d.ts +11 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/daily.d.ts +29 -0
- package/dist/commands/daily.js +433 -0
- package/dist/commands/dashboard-data.d.ts +45 -0
- package/dist/commands/dashboard-data.js +132 -0
- package/dist/commands/dashboard-templates.d.ts +23 -0
- package/dist/commands/dashboard-templates.js +1627 -0
- package/dist/commands/dashboard.d.ts +18 -0
- package/dist/commands/dashboard.js +134 -0
- package/dist/commands/dismiss.d.ts +13 -0
- package/dist/commands/dismiss.js +49 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +27 -0
- package/dist/commands/local-repos.d.ts +14 -0
- package/dist/commands/local-repos.js +155 -0
- package/dist/commands/parse-list.d.ts +13 -0
- package/dist/commands/parse-list.js +139 -0
- package/dist/commands/read.d.ts +12 -0
- package/dist/commands/read.js +33 -0
- package/dist/commands/search.d.ts +10 -0
- package/dist/commands/search.js +74 -0
- package/dist/commands/setup.d.ts +15 -0
- package/dist/commands/setup.js +276 -0
- package/dist/commands/shelve.d.ts +13 -0
- package/dist/commands/shelve.js +49 -0
- package/dist/commands/snooze.d.ts +18 -0
- package/dist/commands/snooze.js +83 -0
- package/dist/commands/startup.d.ts +33 -0
- package/dist/commands/startup.js +197 -0
- package/dist/commands/status.d.ts +10 -0
- package/dist/commands/status.js +43 -0
- package/dist/commands/track.d.ts +16 -0
- package/dist/commands/track.js +59 -0
- package/dist/commands/validation.d.ts +43 -0
- package/dist/commands/validation.js +112 -0
- package/dist/commands/vet.d.ts +10 -0
- package/dist/commands/vet.js +36 -0
- package/dist/core/checklist-analysis.d.ts +17 -0
- package/dist/core/checklist-analysis.js +39 -0
- package/dist/core/ci-analysis.d.ts +78 -0
- package/dist/core/ci-analysis.js +163 -0
- package/dist/core/comment-utils.d.ts +15 -0
- package/dist/core/comment-utils.js +52 -0
- package/dist/core/concurrency.d.ts +5 -0
- package/dist/core/concurrency.js +15 -0
- package/dist/core/daily-logic.d.ts +77 -0
- package/dist/core/daily-logic.js +512 -0
- package/dist/core/display-utils.d.ts +10 -0
- package/dist/core/display-utils.js +100 -0
- package/dist/core/errors.d.ts +24 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/github-stats.d.ts +73 -0
- package/dist/core/github-stats.js +272 -0
- package/dist/core/github.d.ts +19 -0
- package/dist/core/github.js +60 -0
- package/dist/core/http-cache.d.ts +97 -0
- package/dist/core/http-cache.js +269 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +15 -0
- package/dist/core/issue-conversation.d.ts +29 -0
- package/dist/core/issue-conversation.js +231 -0
- package/dist/core/issue-discovery.d.ts +85 -0
- package/dist/core/issue-discovery.js +589 -0
- package/dist/core/issue-filtering.d.ts +51 -0
- package/dist/core/issue-filtering.js +103 -0
- package/dist/core/issue-scoring.d.ts +40 -0
- package/dist/core/issue-scoring.js +92 -0
- package/dist/core/issue-vetting.d.ts +49 -0
- package/dist/core/issue-vetting.js +536 -0
- package/dist/core/logger.d.ts +21 -0
- package/dist/core/logger.js +49 -0
- package/dist/core/maintainer-analysis.d.ts +10 -0
- package/dist/core/maintainer-analysis.js +59 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +20 -0
- package/dist/core/pr-monitor.d.ts +109 -0
- package/dist/core/pr-monitor.js +594 -0
- package/dist/core/review-analysis.d.ts +72 -0
- package/dist/core/review-analysis.js +163 -0
- package/dist/core/state.d.ts +371 -0
- package/dist/core/state.js +1089 -0
- package/dist/core/types.d.ts +507 -0
- package/dist/core/types.js +34 -0
- package/dist/core/utils.d.ts +249 -0
- package/dist/core/utils.js +422 -0
- package/dist/formatters/json.d.ts +269 -0
- package/dist/formatters/json.js +88 -0
- 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
|
+
};
|