@oss-autopilot/core 0.44.3 → 0.44.16
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/dist/cli-registry.js +61 -0
- package/dist/cli.bundle.cjs +99 -124
- package/dist/cli.bundle.cjs.map +4 -4
- package/dist/commands/daily.d.ts +6 -1
- package/dist/commands/daily.js +27 -5
- package/dist/commands/dashboard-data.d.ts +10 -2
- package/dist/commands/dashboard-data.js +35 -11
- package/dist/commands/dashboard-lifecycle.js +39 -2
- package/dist/commands/dashboard-scripts.d.ts +1 -1
- package/dist/commands/dashboard-scripts.js +4 -4
- package/dist/commands/dashboard-server.d.ts +2 -1
- package/dist/commands/dashboard-server.js +61 -53
- package/dist/commands/dashboard-templates.js +14 -68
- package/dist/commands/override.d.ts +21 -0
- package/dist/commands/override.js +35 -0
- package/dist/core/daily-logic.d.ts +13 -10
- package/dist/core/daily-logic.js +79 -166
- package/dist/core/display-utils.d.ts +4 -0
- package/dist/core/display-utils.js +53 -54
- package/dist/core/github-stats.d.ts +3 -3
- package/dist/core/github-stats.js +14 -7
- package/dist/core/issue-vetting.js +1 -1
- package/dist/core/pr-monitor.d.ts +26 -3
- package/dist/core/pr-monitor.js +103 -89
- package/dist/core/state.d.ts +22 -1
- package/dist/core/state.js +50 -1
- package/dist/core/test-utils.js +6 -16
- package/dist/core/types.d.ts +50 -38
- package/dist/core/types.js +7 -0
- package/dist/formatters/json.d.ts +1 -13
- package/dist/formatters/json.js +1 -13
- package/package.json +2 -2
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Display Utils - Human-readable display label computation for PR statuses.
|
|
3
3
|
* Extracted from PRMonitor to isolate presentation logic (#263).
|
|
4
|
+
*
|
|
5
|
+
* Uses two reason-keyed maps (ACTION_DISPLAY / WAIT_DISPLAY) instead of a
|
|
6
|
+
* single status-keyed map, reflecting the 2-status taxonomy where the
|
|
7
|
+
* granular reason lives in `actionReason` / `waitReason`.
|
|
4
8
|
*/
|
|
5
9
|
import { warn } from './logger.js';
|
|
6
|
-
const
|
|
7
|
-
/**
|
|
8
|
-
* Deterministic mapping from FetchedPRStatus -> human-readable display label (#79).
|
|
9
|
-
* Ensures consistent label text across sessions — agents no longer derive these.
|
|
10
|
-
*/
|
|
11
|
-
const STATUS_DISPLAY = {
|
|
10
|
+
const ACTION_DISPLAY = {
|
|
12
11
|
needs_response: {
|
|
13
12
|
label: '[Needs Response]',
|
|
14
13
|
description: (pr) => pr.lastMaintainerComment ? `@${pr.lastMaintainerComment.author} commented` : 'Maintainer awaiting response',
|
|
@@ -33,25 +32,20 @@ const STATUS_DISPLAY = {
|
|
|
33
32
|
return 'One or more CI checks are failing';
|
|
34
33
|
},
|
|
35
34
|
},
|
|
36
|
-
|
|
37
|
-
label: '[
|
|
38
|
-
description: (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
},
|
|
35
|
+
merge_conflict: {
|
|
36
|
+
label: '[Merge Conflict]',
|
|
37
|
+
description: () => 'PR has merge conflicts with the base branch',
|
|
38
|
+
},
|
|
39
|
+
incomplete_checklist: {
|
|
40
|
+
label: '[Incomplete Checklist]',
|
|
41
|
+
description: (pr) => pr.checklistStats
|
|
42
|
+
? `${pr.checklistStats.checked}/${pr.checklistStats.total} items checked`
|
|
43
|
+
: 'PR body has unchecked required checkboxes',
|
|
46
44
|
},
|
|
47
45
|
ci_not_running: {
|
|
48
46
|
label: '[CI Not Running]',
|
|
49
47
|
description: () => 'No CI checks have been triggered',
|
|
50
48
|
},
|
|
51
|
-
merge_conflict: {
|
|
52
|
-
label: '[Merge Conflict]',
|
|
53
|
-
description: () => 'PR has merge conflicts with the base branch',
|
|
54
|
-
},
|
|
55
49
|
needs_rebase: {
|
|
56
50
|
label: '[Needs Rebase]',
|
|
57
51
|
description: () => 'PR branch is significantly behind upstream',
|
|
@@ -60,48 +54,53 @@ const STATUS_DISPLAY = {
|
|
|
60
54
|
label: '[Missing Files]',
|
|
61
55
|
description: (pr) => pr.missingRequiredFiles ? `Missing: ${pr.missingRequiredFiles.join(', ')}` : 'Required files are missing',
|
|
62
56
|
},
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
},
|
|
69
|
-
changes_addressed: {
|
|
70
|
-
label: '[Changes Addressed]',
|
|
71
|
-
description: (pr) => pr.lastMaintainerComment
|
|
72
|
-
? `Waiting for @${pr.lastMaintainerComment.author} to re-review`
|
|
73
|
-
: 'Waiting for maintainer re-review',
|
|
74
|
-
},
|
|
75
|
-
waiting: {
|
|
76
|
-
label: '[Waiting]',
|
|
77
|
-
description: () => 'CI pending or awaiting review',
|
|
57
|
+
};
|
|
58
|
+
const WAIT_DISPLAY = {
|
|
59
|
+
pending_review: {
|
|
60
|
+
label: '[Waiting on Maintainer]',
|
|
61
|
+
description: () => 'Awaiting review',
|
|
78
62
|
},
|
|
79
|
-
|
|
63
|
+
pending_merge: {
|
|
80
64
|
label: '[Waiting on Maintainer]',
|
|
81
65
|
description: () => 'Approved and CI passes — waiting for merge',
|
|
82
66
|
},
|
|
83
|
-
|
|
84
|
-
label: '[
|
|
85
|
-
description: () =>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
67
|
+
changes_addressed: {
|
|
68
|
+
label: '[Waiting on Maintainer]',
|
|
69
|
+
description: (pr) => {
|
|
70
|
+
if (pr.hasUnrespondedComment && pr.lastMaintainerComment) {
|
|
71
|
+
return `Changes addressed — waiting for @${pr.lastMaintainerComment.author} to re-review`;
|
|
72
|
+
}
|
|
73
|
+
return 'Changes addressed — awaiting re-review';
|
|
74
|
+
},
|
|
90
75
|
},
|
|
91
|
-
|
|
92
|
-
label: '[
|
|
93
|
-
description: (pr) =>
|
|
76
|
+
ci_blocked: {
|
|
77
|
+
label: '[CI Blocked]',
|
|
78
|
+
description: (pr) => {
|
|
79
|
+
const checks = pr.classifiedChecks || [];
|
|
80
|
+
if (checks.length > 0 && checks.every((c) => c.category !== 'actionable')) {
|
|
81
|
+
const categories = [...new Set(checks.map((c) => c.category))];
|
|
82
|
+
return `All failing checks are non-actionable (${categories.join(', ')})`;
|
|
83
|
+
}
|
|
84
|
+
return 'CI checks are failing but no action is needed from you';
|
|
85
|
+
},
|
|
94
86
|
},
|
|
95
87
|
};
|
|
96
88
|
/** Compute display label and description for a FetchedPR (#79). */
|
|
97
89
|
export function computeDisplayLabel(pr) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
90
|
+
if (pr.status === 'needs_addressing' && pr.actionReason) {
|
|
91
|
+
const entry = ACTION_DISPLAY[pr.actionReason];
|
|
92
|
+
if (entry)
|
|
93
|
+
return { displayLabel: entry.label, displayDescription: entry.description(pr) };
|
|
94
|
+
}
|
|
95
|
+
if (pr.status === 'waiting_on_maintainer' && pr.waitReason) {
|
|
96
|
+
const entry = WAIT_DISPLAY[pr.waitReason];
|
|
97
|
+
if (entry)
|
|
98
|
+
return { displayLabel: entry.label, displayDescription: entry.description(pr) };
|
|
99
|
+
}
|
|
100
|
+
// Fallback for missing reason — log so we can identify data issues
|
|
101
|
+
warn('display-utils', `PR ${pr.url} has status "${pr.status}" but no matching reason (actionReason=${pr.actionReason}, waitReason=${pr.waitReason})`);
|
|
102
|
+
if (pr.status === 'needs_addressing') {
|
|
103
|
+
return { displayLabel: '[Needs Addressing]', displayDescription: 'Action required' };
|
|
102
104
|
}
|
|
103
|
-
return {
|
|
104
|
-
displayLabel: entry.label,
|
|
105
|
-
displayDescription: entry.description(pr),
|
|
106
|
-
};
|
|
105
|
+
return { displayLabel: '[Waiting on Maintainer]', displayDescription: 'Awaiting maintainer action' };
|
|
107
106
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
|
|
4
4
|
*/
|
|
5
5
|
import { Octokit } from '@octokit/rest';
|
|
6
|
-
import { ClosedPR, MergedPR } from './types.js';
|
|
6
|
+
import { ClosedPR, MergedPR, type StarFilter } from './types.js';
|
|
7
7
|
/** TTL for cached PR count results (24 hours — these stats change slowly). */
|
|
8
8
|
export declare const PR_COUNTS_CACHE_TTL_MS: number;
|
|
9
9
|
/** Return type shared by both merged and closed PR count functions. */
|
|
@@ -19,7 +19,7 @@ export declare function emptyPRCountsResult<R>(): PRCountsResult<R>;
|
|
|
19
19
|
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
20
20
|
* Also builds a monthly histogram of all merges for the contribution timeline.
|
|
21
21
|
*/
|
|
22
|
-
export declare function fetchUserMergedPRCounts(octokit: Octokit, githubUsername: string): Promise<PRCountsResult<{
|
|
22
|
+
export declare function fetchUserMergedPRCounts(octokit: Octokit, githubUsername: string, starFilter?: StarFilter): Promise<PRCountsResult<{
|
|
23
23
|
count: number;
|
|
24
24
|
lastMergedAt: string;
|
|
25
25
|
}>>;
|
|
@@ -27,7 +27,7 @@ export declare function fetchUserMergedPRCounts(octokit: Octokit, githubUsername
|
|
|
27
27
|
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
28
28
|
* Used to populate closedWithoutMergeCount in repo scores for accurate merge rate.
|
|
29
29
|
*/
|
|
30
|
-
export declare function fetchUserClosedPRCounts(octokit: Octokit, githubUsername: string): Promise<PRCountsResult<number>>;
|
|
30
|
+
export declare function fetchUserClosedPRCounts(octokit: Octokit, githubUsername: string, starFilter?: StarFilter): Promise<PRCountsResult<number>>;
|
|
31
31
|
/**
|
|
32
32
|
* Fetch PRs closed without merge in the last N days.
|
|
33
33
|
* Returns lightweight ClosedPR objects for surfacing in the daily digest.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
|
|
4
4
|
*/
|
|
5
5
|
import { extractOwnerRepo, parseGitHubUrl, isOwnRepo } from './utils.js';
|
|
6
|
+
import { isBelowMinStars } from './types.js';
|
|
6
7
|
import { debug, warn } from './logger.js';
|
|
7
8
|
import { getHttpCache } from './http-cache.js';
|
|
8
9
|
const MODULE = 'github-stats';
|
|
@@ -39,13 +40,14 @@ function isCachedPRCounts(v) {
|
|
|
39
40
|
* string (e.g. mergedAt or closedAt) used for monthly counts and daily activity.
|
|
40
41
|
* Return an empty string to skip histogram tracking for that item.
|
|
41
42
|
*/
|
|
42
|
-
async function fetchUserPRCounts(octokit, githubUsername, query, label, accumulateRepo) {
|
|
43
|
+
async function fetchUserPRCounts(octokit, githubUsername, query, label, accumulateRepo, starFilter) {
|
|
43
44
|
if (!githubUsername) {
|
|
44
45
|
return emptyPRCountsResult();
|
|
45
46
|
}
|
|
46
47
|
// Check for a fresh cached result (avoids 10-20 paginated API calls)
|
|
47
48
|
const cache = getHttpCache();
|
|
48
|
-
const
|
|
49
|
+
const minStarsSuffix = starFilter ? `:stars${starFilter.minStars}` : '';
|
|
50
|
+
const cacheKey = `pr-counts:v3:${label}:${githubUsername}${minStarsSuffix}`;
|
|
49
51
|
const cached = cache.getIfFresh(cacheKey, PR_COUNTS_CACHE_TTL_MS);
|
|
50
52
|
if (cached && isCachedPRCounts(cached)) {
|
|
51
53
|
debug(MODULE, `Using cached ${label} PR counts for @${githubUsername}`);
|
|
@@ -66,7 +68,7 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
66
68
|
let totalCount;
|
|
67
69
|
while (true) {
|
|
68
70
|
const { data } = await octokit.search.issuesAndPullRequests({
|
|
69
|
-
q: `is:pr ${query} author:${githubUsername}`,
|
|
71
|
+
q: `is:pr ${query} author:${githubUsername} -user:${githubUsername}`,
|
|
70
72
|
sort: 'updated',
|
|
71
73
|
order: 'desc',
|
|
72
74
|
per_page: 100,
|
|
@@ -86,6 +88,11 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
86
88
|
continue;
|
|
87
89
|
// Note: excludeRepos/excludeOrgs are intentionally NOT filtered here.
|
|
88
90
|
// Those filters control issue discovery/search, not historical statistics.
|
|
91
|
+
// Skip repos below the minimum star threshold (#576).
|
|
92
|
+
// Repos with unknown star counts (not yet fetched) are also excluded (fail-closed).
|
|
93
|
+
if (starFilter && isBelowMinStars(starFilter.knownStarCounts.get(repo), starFilter.minStars)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
89
96
|
// Per-repo accumulation + get primary date for histograms
|
|
90
97
|
const primaryDate = accumulateRepo(repos, repo, item);
|
|
91
98
|
// Monthly histogram for primary date (merged/closed)
|
|
@@ -135,7 +142,7 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
|
|
|
135
142
|
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
136
143
|
* Also builds a monthly histogram of all merges for the contribution timeline.
|
|
137
144
|
*/
|
|
138
|
-
export function fetchUserMergedPRCounts(octokit, githubUsername) {
|
|
145
|
+
export function fetchUserMergedPRCounts(octokit, githubUsername, starFilter) {
|
|
139
146
|
return fetchUserPRCounts(octokit, githubUsername, 'is:merged', 'merged', (repos, repo, item) => {
|
|
140
147
|
if (!item.pull_request?.merged_at) {
|
|
141
148
|
warn(MODULE, `merged_at missing for merged PR ${item.html_url}${item.closed_at ? ', falling back to closed_at' : ', no date available'}`);
|
|
@@ -152,17 +159,17 @@ export function fetchUserMergedPRCounts(octokit, githubUsername) {
|
|
|
152
159
|
repos.set(repo, { count: 1, lastMergedAt: mergedAt });
|
|
153
160
|
}
|
|
154
161
|
return mergedAt;
|
|
155
|
-
});
|
|
162
|
+
}, starFilter);
|
|
156
163
|
}
|
|
157
164
|
/**
|
|
158
165
|
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
159
166
|
* Used to populate closedWithoutMergeCount in repo scores for accurate merge rate.
|
|
160
167
|
*/
|
|
161
|
-
export function fetchUserClosedPRCounts(octokit, githubUsername) {
|
|
168
|
+
export function fetchUserClosedPRCounts(octokit, githubUsername, starFilter) {
|
|
162
169
|
return fetchUserPRCounts(octokit, githubUsername, 'is:closed is:unmerged', 'closed', (repos, repo, item) => {
|
|
163
170
|
repos.set(repo, (repos.get(repo) || 0) + 1);
|
|
164
171
|
return item.closed_at || '';
|
|
165
|
-
});
|
|
172
|
+
}, starFilter);
|
|
166
173
|
}
|
|
167
174
|
/**
|
|
168
175
|
* Shared helper: search for recent PRs and filter out own repos, excluded repos/orgs.
|
|
@@ -509,7 +509,7 @@ export class IssueVetter {
|
|
|
509
509
|
if (!body || body.length < 50)
|
|
510
510
|
return false;
|
|
511
511
|
// Check for clear structure
|
|
512
|
-
const hasSteps = /\d
|
|
512
|
+
const hasSteps = /\d\.|[-*]\s/.test(body);
|
|
513
513
|
const hasCodeBlock = /```/.test(body);
|
|
514
514
|
const hasExpectedBehavior = /expect|should|must|want/i.test(body);
|
|
515
515
|
// Must have at least two indicators of clarity
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - display-utils.ts: Display label computation
|
|
12
12
|
* - github-stats.ts: Merged/closed PR counts and star fetching
|
|
13
13
|
*/
|
|
14
|
-
import { FetchedPR, DailyDigest, ClosedPR, MergedPR } from './types.js';
|
|
14
|
+
import { FetchedPR, DailyDigest, ClosedPR, MergedPR, StarFilter } from './types.js';
|
|
15
15
|
import { type PRCountsResult } from './github-stats.js';
|
|
16
16
|
export { computeDisplayLabel } from './display-utils.js';
|
|
17
17
|
export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
|
|
@@ -46,6 +46,29 @@ export declare class PRMonitor {
|
|
|
46
46
|
* Determine the overall status of a PR
|
|
47
47
|
*/
|
|
48
48
|
private determineStatus;
|
|
49
|
+
/**
|
|
50
|
+
* CI-fix bots that push commits as a direct result of the contributor's push (#568).
|
|
51
|
+
* Their commits represent contributor work and should count as addressing feedback.
|
|
52
|
+
* This is intentionally an allowlist — not all `[bot]` accounts are CI-fix bots
|
|
53
|
+
* (e.g. dependabot[bot] and renovate[bot] open their own PRs).
|
|
54
|
+
* Values must be lowercase — lookup uses .toLowerCase() for case-insensitive matching.
|
|
55
|
+
*/
|
|
56
|
+
private static readonly CI_FIX_BOTS;
|
|
57
|
+
/**
|
|
58
|
+
* Check whether the HEAD commit was authored by the contributor (#547).
|
|
59
|
+
* Returns true when the author matches, when the author is a known CI-fix
|
|
60
|
+
* bot (#568), or when author info is unavailable (graceful degradation).
|
|
61
|
+
*/
|
|
62
|
+
private isContributorCommit;
|
|
63
|
+
/** Minimum gap (ms) between maintainer comment and contributor commit for
|
|
64
|
+
* the commit to count as "addressing" the feedback (#547). Prevents false
|
|
65
|
+
* positives from race conditions, clock skew, and in-flight pushes. */
|
|
66
|
+
private static readonly MIN_RESPONSE_GAP_MS;
|
|
67
|
+
/**
|
|
68
|
+
* Check whether the contributor's commit is meaningfully after the maintainer's
|
|
69
|
+
* comment — i.e. the commit timestamp is at least MIN_RESPONSE_GAP_MS later (#547).
|
|
70
|
+
*/
|
|
71
|
+
private isCommitAfterComment;
|
|
49
72
|
/**
|
|
50
73
|
* Check if PR has merge conflict
|
|
51
74
|
*/
|
|
@@ -60,7 +83,7 @@ export declare class PRMonitor {
|
|
|
60
83
|
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
61
84
|
* Delegates to github-stats module.
|
|
62
85
|
*/
|
|
63
|
-
fetchUserMergedPRCounts(): Promise<PRCountsResult<{
|
|
86
|
+
fetchUserMergedPRCounts(starFilter?: StarFilter): Promise<PRCountsResult<{
|
|
64
87
|
count: number;
|
|
65
88
|
lastMergedAt: string;
|
|
66
89
|
}>>;
|
|
@@ -68,7 +91,7 @@ export declare class PRMonitor {
|
|
|
68
91
|
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
69
92
|
* Delegates to github-stats module.
|
|
70
93
|
*/
|
|
71
|
-
fetchUserClosedPRCounts(): Promise<PRCountsResult<number>>;
|
|
94
|
+
fetchUserClosedPRCounts(starFilter?: StarFilter): Promise<PRCountsResult<number>>;
|
|
72
95
|
/**
|
|
73
96
|
* Fetch GitHub star counts for a list of repositories.
|
|
74
97
|
* Delegates to github-stats module.
|
package/dist/core/pr-monitor.js
CHANGED
|
@@ -119,27 +119,11 @@ export class PRMonitor {
|
|
|
119
119
|
}
|
|
120
120
|
}, MAX_CONCURRENT_REQUESTS);
|
|
121
121
|
});
|
|
122
|
-
// Sort by
|
|
122
|
+
// Sort by status (needs_addressing first, then waiting_on_maintainer)
|
|
123
123
|
prs.sort((a, b) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
needs_changes: 1,
|
|
128
|
-
failing_ci: 2,
|
|
129
|
-
ci_blocked: 3,
|
|
130
|
-
ci_not_running: 4,
|
|
131
|
-
merge_conflict: 5,
|
|
132
|
-
needs_rebase: 6,
|
|
133
|
-
missing_required_files: 7,
|
|
134
|
-
incomplete_checklist: 8,
|
|
135
|
-
changes_addressed: 9,
|
|
136
|
-
approaching_dormant: 10,
|
|
137
|
-
dormant: 11,
|
|
138
|
-
waiting: 12,
|
|
139
|
-
waiting_on_maintainer: 13,
|
|
140
|
-
healthy: 14,
|
|
141
|
-
};
|
|
142
|
-
return statusPriority[a.status] - statusPriority[b.status];
|
|
124
|
+
if (a.status === b.status)
|
|
125
|
+
return 0;
|
|
126
|
+
return a.status === 'needs_addressing' ? -1 : 1;
|
|
143
127
|
});
|
|
144
128
|
return { prs, failures };
|
|
145
129
|
}
|
|
@@ -195,14 +179,18 @@ export class PRMonitor {
|
|
|
195
179
|
const { hasUnrespondedComment, lastMaintainerComment } = checkUnrespondedComments(comments, reviews, reviewComments, config.githubUsername);
|
|
196
180
|
// Fetch CI status and (conditionally) latest commit date in parallel
|
|
197
181
|
// We need the commit date when hasUnrespondedComment is true (to distinguish
|
|
198
|
-
// "needs_response" from "
|
|
182
|
+
// "needs_response" from "waiting_on_maintainer") OR when reviewDecision is "changes_requested"
|
|
199
183
|
// (to detect needs_changes: review requested changes but no new commits pushed)
|
|
200
184
|
const ciPromise = this.getCIStatus(owner, repo, ghPR.head.sha);
|
|
201
185
|
const needCommitDate = hasUnrespondedComment || reviewDecision === 'changes_requested';
|
|
202
|
-
const
|
|
186
|
+
const commitInfoPromise = needCommitDate
|
|
203
187
|
? this.octokit.repos
|
|
204
188
|
.getCommit({ owner, repo, ref: ghPR.head.sha })
|
|
205
|
-
.then((res) =>
|
|
189
|
+
.then((res) => ({
|
|
190
|
+
date: res.data.commit.author?.date,
|
|
191
|
+
// GitHub user login of the commit author (may differ from git author)
|
|
192
|
+
author: res.data.author?.login,
|
|
193
|
+
}))
|
|
206
194
|
.catch((err) => {
|
|
207
195
|
// Rate limit errors must propagate — silently swallowing them produces
|
|
208
196
|
// misleading status (e.g. needs_changes when changes were addressed) (#469).
|
|
@@ -221,10 +209,12 @@ export class PRMonitor {
|
|
|
221
209
|
return undefined;
|
|
222
210
|
})
|
|
223
211
|
: Promise.resolve(undefined);
|
|
224
|
-
const [{ status: ciStatus, failingCheckNames, failingCheckConclusions },
|
|
212
|
+
const [{ status: ciStatus, failingCheckNames, failingCheckConclusions }, commitInfo] = await Promise.all([
|
|
225
213
|
ciPromise,
|
|
226
|
-
|
|
214
|
+
commitInfoPromise,
|
|
227
215
|
]);
|
|
216
|
+
const latestCommitDate = commitInfo?.date;
|
|
217
|
+
const latestCommitAuthor = commitInfo?.author;
|
|
228
218
|
// Analyze PR body for incomplete checklists (delegated to checklist-analysis module)
|
|
229
219
|
const { hasIncompleteChecklist, checklistStats } = analyzeChecklist(ghPR.body || '');
|
|
230
220
|
// Extract maintainer action hints from comments (delegated to maintainer-analysis module)
|
|
@@ -237,7 +227,7 @@ export class PRMonitor {
|
|
|
237
227
|
const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
|
|
238
228
|
// Determine status
|
|
239
229
|
const hasActionableCIFailure = ciStatus === 'failing' && classifiedChecks.some((c) => c.category === 'actionable');
|
|
240
|
-
const status = this.determineStatus({
|
|
230
|
+
const { status, actionReason, waitReason, stalenessTier } = this.determineStatus({
|
|
241
231
|
ciStatus,
|
|
242
232
|
hasMergeConflict,
|
|
243
233
|
hasUnrespondedComment,
|
|
@@ -247,6 +237,8 @@ export class PRMonitor {
|
|
|
247
237
|
dormantThreshold: config.dormantThresholdDays,
|
|
248
238
|
approachingThreshold: config.approachingDormantDays,
|
|
249
239
|
latestCommitDate,
|
|
240
|
+
latestCommitAuthor,
|
|
241
|
+
contributorUsername: config.githubUsername,
|
|
250
242
|
lastMaintainerCommentDate: lastMaintainerComment?.createdAt,
|
|
251
243
|
latestChangesRequestedDate,
|
|
252
244
|
hasActionableCIFailure,
|
|
@@ -258,6 +250,9 @@ export class PRMonitor {
|
|
|
258
250
|
number,
|
|
259
251
|
title: ghPR.title,
|
|
260
252
|
status,
|
|
253
|
+
actionReason,
|
|
254
|
+
waitReason,
|
|
255
|
+
stalenessTier,
|
|
261
256
|
createdAt: ghPR.created_at,
|
|
262
257
|
updatedAt: ghPR.updated_at,
|
|
263
258
|
daysSinceActivity,
|
|
@@ -294,61 +289,110 @@ export class PRMonitor {
|
|
|
294
289
|
* Determine the overall status of a PR
|
|
295
290
|
*/
|
|
296
291
|
determineStatus(input) {
|
|
297
|
-
const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
|
|
298
|
-
//
|
|
292
|
+
const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate: rawCommitDate, latestCommitAuthor, contributorUsername, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
|
|
293
|
+
// Compute staleness tier (independent of status)
|
|
294
|
+
let stalenessTier = 'active';
|
|
295
|
+
if (daysSinceActivity >= dormantThreshold)
|
|
296
|
+
stalenessTier = 'dormant';
|
|
297
|
+
else if (daysSinceActivity >= approachingThreshold)
|
|
298
|
+
stalenessTier = 'approaching_dormant';
|
|
299
|
+
// Only count the latest commit if it was authored by the contributor or a
|
|
300
|
+
// CI bot (#547, #568). Non-contributor commits (maintainer merge commits,
|
|
301
|
+
// GitHub suggestion commits) should not mask unaddressed feedback.
|
|
302
|
+
const latestCommitDate = rawCommitDate && this.isContributorCommit(latestCommitAuthor, contributorUsername) ? rawCommitDate : undefined;
|
|
303
|
+
// Priority order: needs_addressing (response/changes/ci/conflict/checklist) > waiting_on_maintainer (review/merge/addressed/ci_blocked)
|
|
299
304
|
if (hasUnrespondedComment) {
|
|
300
305
|
// If the contributor pushed a commit after the maintainer's comment,
|
|
301
|
-
// the changes have been addressed — waiting for maintainer re-review
|
|
302
|
-
|
|
306
|
+
// the changes have been addressed — waiting for maintainer re-review.
|
|
307
|
+
// Require a minimum 2-minute gap to avoid false positives from race
|
|
308
|
+
// conditions (pushing while review is being submitted) (#547).
|
|
309
|
+
if (latestCommitDate &&
|
|
310
|
+
lastMaintainerCommentDate &&
|
|
311
|
+
this.isCommitAfterComment(latestCommitDate, lastMaintainerCommentDate)) {
|
|
303
312
|
// Safety net (#431): if a CHANGES_REQUESTED review was submitted after
|
|
304
313
|
// the commit, the maintainer still expects changes — don't mask it
|
|
305
314
|
if (latestChangesRequestedDate && latestCommitDate < latestChangesRequestedDate) {
|
|
306
|
-
return 'needs_response';
|
|
315
|
+
return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
|
|
307
316
|
}
|
|
308
317
|
if (ciStatus === 'failing' && hasActionableCIFailure)
|
|
309
|
-
return 'failing_ci';
|
|
318
|
+
return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
|
|
310
319
|
// Non-actionable CI failures (infrastructure, fork, auth) don't block changes_addressed —
|
|
311
320
|
// the contributor can't fix them, so the relevant status is "waiting for re-review" (#502)
|
|
312
|
-
return 'changes_addressed';
|
|
321
|
+
return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
|
|
313
322
|
}
|
|
314
|
-
return 'needs_response';
|
|
323
|
+
return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
|
|
315
324
|
}
|
|
316
325
|
// Review requested changes but no unresponded comment.
|
|
317
326
|
// If the latest commit is before the review, the contributor hasn't addressed it yet.
|
|
318
327
|
if (reviewDecision === 'changes_requested' && latestChangesRequestedDate) {
|
|
319
328
|
if (!latestCommitDate || latestCommitDate < latestChangesRequestedDate) {
|
|
320
|
-
return 'needs_changes';
|
|
329
|
+
return { status: 'needs_addressing', actionReason: 'needs_changes', stalenessTier };
|
|
321
330
|
}
|
|
322
331
|
// Commit is after review — changes have been addressed
|
|
323
332
|
if (ciStatus === 'failing' && hasActionableCIFailure)
|
|
324
|
-
return 'failing_ci';
|
|
333
|
+
return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
|
|
325
334
|
// Non-actionable CI failures don't block changes_addressed (#502)
|
|
326
|
-
return 'changes_addressed';
|
|
335
|
+
return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
|
|
327
336
|
}
|
|
328
337
|
if (ciStatus === 'failing') {
|
|
329
|
-
return hasActionableCIFailure
|
|
338
|
+
return hasActionableCIFailure
|
|
339
|
+
? { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier }
|
|
340
|
+
: { status: 'waiting_on_maintainer', waitReason: 'ci_blocked', stalenessTier };
|
|
330
341
|
}
|
|
331
342
|
if (hasMergeConflict) {
|
|
332
|
-
return 'merge_conflict';
|
|
343
|
+
return { status: 'needs_addressing', actionReason: 'merge_conflict', stalenessTier };
|
|
333
344
|
}
|
|
334
345
|
if (hasIncompleteChecklist) {
|
|
335
|
-
return 'incomplete_checklist';
|
|
336
|
-
}
|
|
337
|
-
if (daysSinceActivity >= dormantThreshold) {
|
|
338
|
-
return 'dormant';
|
|
339
|
-
}
|
|
340
|
-
if (daysSinceActivity >= approachingThreshold) {
|
|
341
|
-
return 'approaching_dormant';
|
|
346
|
+
return { status: 'needs_addressing', actionReason: 'incomplete_checklist', stalenessTier };
|
|
342
347
|
}
|
|
343
348
|
// Approved and CI passing/unknown = waiting on maintainer to merge
|
|
344
349
|
if (reviewDecision === 'approved' && (ciStatus === 'passing' || ciStatus === 'unknown')) {
|
|
345
|
-
return 'waiting_on_maintainer';
|
|
350
|
+
return { status: 'waiting_on_maintainer', waitReason: 'pending_merge', stalenessTier };
|
|
346
351
|
}
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
352
|
+
// Default: no actionable issues found. Covers pending CI, no reviews yet, etc.
|
|
353
|
+
return { status: 'waiting_on_maintainer', waitReason: 'pending_review', stalenessTier };
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* CI-fix bots that push commits as a direct result of the contributor's push (#568).
|
|
357
|
+
* Their commits represent contributor work and should count as addressing feedback.
|
|
358
|
+
* This is intentionally an allowlist — not all `[bot]` accounts are CI-fix bots
|
|
359
|
+
* (e.g. dependabot[bot] and renovate[bot] open their own PRs).
|
|
360
|
+
* Values must be lowercase — lookup uses .toLowerCase() for case-insensitive matching.
|
|
361
|
+
*/
|
|
362
|
+
static CI_FIX_BOTS = new Set([
|
|
363
|
+
'autofix-ci[bot]',
|
|
364
|
+
'prettier-ci[bot]',
|
|
365
|
+
'pre-commit-ci[bot]',
|
|
366
|
+
]);
|
|
367
|
+
/**
|
|
368
|
+
* Check whether the HEAD commit was authored by the contributor (#547).
|
|
369
|
+
* Returns true when the author matches, when the author is a known CI-fix
|
|
370
|
+
* bot (#568), or when author info is unavailable (graceful degradation).
|
|
371
|
+
*/
|
|
372
|
+
isContributorCommit(commitAuthor, contributorUsername) {
|
|
373
|
+
if (!commitAuthor || !contributorUsername)
|
|
374
|
+
return true; // degrade gracefully
|
|
375
|
+
const author = commitAuthor.toLowerCase();
|
|
376
|
+
if (PRMonitor.CI_FIX_BOTS.has(author))
|
|
377
|
+
return true; // CI-fix bots act on behalf of the contributor (#568)
|
|
378
|
+
return author === contributorUsername.toLowerCase();
|
|
379
|
+
}
|
|
380
|
+
/** Minimum gap (ms) between maintainer comment and contributor commit for
|
|
381
|
+
* the commit to count as "addressing" the feedback (#547). Prevents false
|
|
382
|
+
* positives from race conditions, clock skew, and in-flight pushes. */
|
|
383
|
+
static MIN_RESPONSE_GAP_MS = 2 * 60 * 1000; // 2 minutes
|
|
384
|
+
/**
|
|
385
|
+
* Check whether the contributor's commit is meaningfully after the maintainer's
|
|
386
|
+
* comment — i.e. the commit timestamp is at least MIN_RESPONSE_GAP_MS later (#547).
|
|
387
|
+
*/
|
|
388
|
+
isCommitAfterComment(commitDate, commentDate) {
|
|
389
|
+
const commitMs = new Date(commitDate).getTime();
|
|
390
|
+
const commentMs = new Date(commentDate).getTime();
|
|
391
|
+
if (Number.isNaN(commitMs) || Number.isNaN(commentMs)) {
|
|
392
|
+
// Fall back to simple string comparison (pre-#547 behavior)
|
|
393
|
+
return commitDate > commentDate;
|
|
350
394
|
}
|
|
351
|
-
return
|
|
395
|
+
return commitMs - commentMs >= PRMonitor.MIN_RESPONSE_GAP_MS;
|
|
352
396
|
}
|
|
353
397
|
/**
|
|
354
398
|
* Check if PR has merge conflict
|
|
@@ -426,17 +470,17 @@ export class PRMonitor {
|
|
|
426
470
|
* Fetch merged PR counts and latest merge dates per repository for the configured user.
|
|
427
471
|
* Delegates to github-stats module.
|
|
428
472
|
*/
|
|
429
|
-
async fetchUserMergedPRCounts() {
|
|
473
|
+
async fetchUserMergedPRCounts(starFilter) {
|
|
430
474
|
const config = this.stateManager.getState().config;
|
|
431
|
-
return fetchUserMergedPRCountsImpl(this.octokit, config.githubUsername);
|
|
475
|
+
return fetchUserMergedPRCountsImpl(this.octokit, config.githubUsername, starFilter);
|
|
432
476
|
}
|
|
433
477
|
/**
|
|
434
478
|
* Fetch closed-without-merge PR counts per repository for the configured user.
|
|
435
479
|
* Delegates to github-stats module.
|
|
436
480
|
*/
|
|
437
|
-
async fetchUserClosedPRCounts() {
|
|
481
|
+
async fetchUserClosedPRCounts(starFilter) {
|
|
438
482
|
const config = this.stateManager.getState().config;
|
|
439
|
-
return fetchUserClosedPRCountsImpl(this.octokit, config.githubUsername);
|
|
483
|
+
return fetchUserClosedPRCountsImpl(this.octokit, config.githubUsername, starFilter);
|
|
440
484
|
}
|
|
441
485
|
/**
|
|
442
486
|
* Fetch GitHub star counts for a list of repositories.
|
|
@@ -513,52 +557,22 @@ export class PRMonitor {
|
|
|
513
557
|
generateDigest(prs, recentlyClosedPRs = [], recentlyMergedPRs = []) {
|
|
514
558
|
const now = new Date().toISOString();
|
|
515
559
|
// Categorize PRs
|
|
516
|
-
const
|
|
517
|
-
const
|
|
518
|
-
const mergeConflictPRs = prs.filter((pr) => pr.status === 'merge_conflict');
|
|
519
|
-
const approachingDormant = prs.filter((pr) => pr.status === 'approaching_dormant');
|
|
520
|
-
const dormantPRs = prs.filter((pr) => pr.status === 'dormant');
|
|
521
|
-
const healthyPRs = prs.filter((pr) => pr.status === 'healthy' || pr.status === 'waiting');
|
|
560
|
+
const needsAddressingPRs = prs.filter((pr) => pr.status === 'needs_addressing');
|
|
561
|
+
const waitingOnMaintainerPRs = prs.filter((pr) => pr.status === 'waiting_on_maintainer');
|
|
522
562
|
// Get stats from state manager (historical data from repo scores)
|
|
523
563
|
const stats = this.stateManager.getStats();
|
|
524
|
-
const ciBlockedPRs = prs.filter((pr) => pr.status === 'ci_blocked');
|
|
525
|
-
const ciNotRunningPRs = prs.filter((pr) => pr.status === 'ci_not_running');
|
|
526
|
-
const needsRebasePRs = prs.filter((pr) => pr.status === 'needs_rebase');
|
|
527
|
-
const missingRequiredFilesPRs = prs.filter((pr) => pr.status === 'missing_required_files');
|
|
528
|
-
const incompleteChecklistPRs = prs.filter((pr) => pr.status === 'incomplete_checklist');
|
|
529
|
-
const needsChangesPRs = prs.filter((pr) => pr.status === 'needs_changes');
|
|
530
|
-
const changesAddressedPRs = prs.filter((pr) => pr.status === 'changes_addressed');
|
|
531
|
-
const waitingOnMaintainerPRs = prs.filter((pr) => pr.status === 'waiting_on_maintainer');
|
|
532
564
|
return {
|
|
533
565
|
generatedAt: now,
|
|
534
566
|
openPRs: prs,
|
|
535
|
-
|
|
536
|
-
ciFailingPRs,
|
|
537
|
-
ciBlockedPRs,
|
|
538
|
-
ciNotRunningPRs,
|
|
539
|
-
mergeConflictPRs,
|
|
540
|
-
needsRebasePRs,
|
|
541
|
-
missingRequiredFilesPRs,
|
|
542
|
-
incompleteChecklistPRs,
|
|
543
|
-
needsChangesPRs,
|
|
544
|
-
changesAddressedPRs,
|
|
567
|
+
needsAddressingPRs,
|
|
545
568
|
waitingOnMaintainerPRs,
|
|
546
|
-
approachingDormant,
|
|
547
|
-
dormantPRs,
|
|
548
|
-
healthyPRs,
|
|
549
569
|
recentlyClosedPRs,
|
|
550
570
|
recentlyMergedPRs,
|
|
551
571
|
shelvedPRs: [],
|
|
552
572
|
autoUnshelvedPRs: [],
|
|
553
573
|
summary: {
|
|
554
574
|
totalActivePRs: prs.length,
|
|
555
|
-
totalNeedingAttention:
|
|
556
|
-
needsChangesPRs.length +
|
|
557
|
-
ciFailingPRs.length +
|
|
558
|
-
mergeConflictPRs.length +
|
|
559
|
-
needsRebasePRs.length +
|
|
560
|
-
missingRequiredFilesPRs.length +
|
|
561
|
-
incompleteChecklistPRs.length,
|
|
575
|
+
totalNeedingAttention: needsAddressingPRs.length,
|
|
562
576
|
totalMergedAllTime: stats.mergedPRs,
|
|
563
577
|
mergeRate: parseFloat(stats.mergeRate),
|
|
564
578
|
},
|