@oss-autopilot/core 0.44.3 → 0.44.15

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.
@@ -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 MODULE = 'display-utils';
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
- ci_blocked: {
37
- label: '[CI Blocked]',
38
- description: (pr) => {
39
- const checks = pr.classifiedChecks || [];
40
- if (checks.length > 0 && checks.every((c) => c.category !== 'actionable')) {
41
- const categories = [...new Set(checks.map((c) => c.category))];
42
- return `All failing checks are non-actionable (${categories.join(', ')})`;
43
- }
44
- return 'CI checks are failing but no action is needed from you';
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
- incomplete_checklist: {
64
- label: '[Incomplete Checklist]',
65
- description: (pr) => pr.checklistStats
66
- ? `${pr.checklistStats.checked}/${pr.checklistStats.total} items checked`
67
- : 'PR body has unchecked required checkboxes',
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
- waiting_on_maintainer: {
63
+ pending_merge: {
80
64
  label: '[Waiting on Maintainer]',
81
65
  description: () => 'Approved and CI passes — waiting for merge',
82
66
  },
83
- healthy: {
84
- label: '[Healthy]',
85
- description: () => 'Everything looks good — normal review cycle',
86
- },
87
- approaching_dormant: {
88
- label: '[Approaching Dormant]',
89
- description: (pr) => `No activity for ${pr.daysSinceActivity} days`,
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
- dormant: {
92
- label: '[Dormant]',
93
- description: (pr) => `No activity for ${pr.daysSinceActivity} days`,
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
- const entry = STATUS_DISPLAY[pr.status];
99
- if (!entry) {
100
- warn(MODULE, `Unknown status "${pr.status}" for PR #${pr.number} (${pr.url})`);
101
- return { displayLabel: `[${pr.status}]`, displayDescription: 'Unknown status' };
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 cacheKey = `pr-counts:${label}:${githubUsername}`;
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,12 @@ 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 included — they'll be
93
+ // filtered on the next run once star data is cached in repoScores.
94
+ if (starFilter && isBelowMinStars(starFilter.knownStarCounts.get(repo), starFilter.minStars)) {
95
+ continue;
96
+ }
89
97
  // Per-repo accumulation + get primary date for histograms
90
98
  const primaryDate = accumulateRepo(repos, repo, item);
91
99
  // Monthly histogram for primary date (merged/closed)
@@ -135,7 +143,7 @@ async function fetchUserPRCounts(octokit, githubUsername, query, label, accumula
135
143
  * Fetch merged PR counts and latest merge dates per repository for the configured user.
136
144
  * Also builds a monthly histogram of all merges for the contribution timeline.
137
145
  */
138
- export function fetchUserMergedPRCounts(octokit, githubUsername) {
146
+ export function fetchUserMergedPRCounts(octokit, githubUsername, starFilter) {
139
147
  return fetchUserPRCounts(octokit, githubUsername, 'is:merged', 'merged', (repos, repo, item) => {
140
148
  if (!item.pull_request?.merged_at) {
141
149
  warn(MODULE, `merged_at missing for merged PR ${item.html_url}${item.closed_at ? ', falling back to closed_at' : ', no date available'}`);
@@ -152,17 +160,17 @@ export function fetchUserMergedPRCounts(octokit, githubUsername) {
152
160
  repos.set(repo, { count: 1, lastMergedAt: mergedAt });
153
161
  }
154
162
  return mergedAt;
155
- });
163
+ }, starFilter);
156
164
  }
157
165
  /**
158
166
  * Fetch closed-without-merge PR counts per repository for the configured user.
159
167
  * Used to populate closedWithoutMergeCount in repo scores for accurate merge rate.
160
168
  */
161
- export function fetchUserClosedPRCounts(octokit, githubUsername) {
169
+ export function fetchUserClosedPRCounts(octokit, githubUsername, starFilter) {
162
170
  return fetchUserPRCounts(octokit, githubUsername, 'is:closed is:unmerged', 'closed', (repos, repo, item) => {
163
171
  repos.set(repo, (repos.get(repo) || 0) + 1);
164
172
  return item.closed_at || '';
165
- });
173
+ }, starFilter);
166
174
  }
167
175
  /**
168
176
  * 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+\.|[-*]\s/.test(body);
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.
@@ -119,27 +119,11 @@ export class PRMonitor {
119
119
  }
120
120
  }, MAX_CONCURRENT_REQUESTS);
121
121
  });
122
- // Sort by days since activity (most urgent first)
122
+ // Sort by status (needs_addressing first, then waiting_on_maintainer)
123
123
  prs.sort((a, b) => {
124
- // Priority: needs_response > failing_ci > merge_conflict > approaching_dormant > dormant > waiting > healthy
125
- const statusPriority = {
126
- needs_response: 0,
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 "changes_addressed") OR when reviewDecision is "changes_requested"
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 commitDatePromise = needCommitDate
186
+ const commitInfoPromise = needCommitDate
203
187
  ? this.octokit.repos
204
188
  .getCommit({ owner, repo, ref: ghPR.head.sha })
205
- .then((res) => res.data.commit.author?.date)
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 }, latestCommitDate] = await Promise.all([
212
+ const [{ status: ciStatus, failingCheckNames, failingCheckConclusions }, commitInfo] = await Promise.all([
225
213
  ciPromise,
226
- commitDatePromise,
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
- // Priority order: needs_response/needs_changes/changes_addressed > failing_ci > merge_conflict > incomplete_checklist > dormant > approaching_dormant > waiting_on_maintainer > waiting/healthy
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
- if (latestCommitDate && lastMaintainerCommentDate && latestCommitDate > lastMaintainerCommentDate) {
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 ? 'failing_ci' : 'ci_blocked';
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
- // CI pending means we're waiting
348
- if (ciStatus === 'pending') {
349
- return 'waiting';
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 'healthy';
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 prsNeedingResponse = prs.filter((pr) => pr.status === 'needs_response');
517
- const ciFailingPRs = prs.filter((pr) => pr.status === 'failing_ci');
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
- prsNeedingResponse,
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: prsNeedingResponse.length +
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
  },