@oss-autopilot/core 0.50.0 → 0.51.1

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.
@@ -10,10 +10,12 @@
10
10
  * - maintainer-analysis.ts: Maintainer action hint extraction
11
11
  * - display-utils.ts: Display label computation
12
12
  * - github-stats.ts: Merged/closed PR counts and star fetching
13
+ * - status-determination.ts: PR status classification logic
13
14
  */
14
15
  import { getOctokit } from './github.js';
15
16
  import { getStateManager } from './state.js';
16
17
  import { daysBetween, parseGitHubUrl, extractOwnerRepo, isOwnRepo, DEFAULT_CONCURRENCY } from './utils.js';
18
+ import { determineStatus } from './status-determination.js';
17
19
  import { runWorkerPool } from './concurrency.js';
18
20
  import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
19
21
  import { paginateAll } from './pagination.js';
@@ -30,6 +32,7 @@ import { fetchUserMergedPRCounts as fetchUserMergedPRCountsImpl, fetchUserClosed
30
32
  export { computeDisplayLabel } from './display-utils.js';
31
33
  export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
32
34
  export { isConditionalChecklistItem } from './checklist-analysis.js';
35
+ export { determineStatus } from './status-determination.js';
33
36
  const MODULE = 'pr-monitor';
34
37
  const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
35
38
  export class PRMonitor {
@@ -217,7 +220,7 @@ export class PRMonitor {
217
220
  const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
218
221
  // Determine status
219
222
  const hasActionableCIFailure = ciStatus === 'failing' && classifiedChecks.some((c) => c.category === 'actionable');
220
- const { status, actionReason, waitReason, stalenessTier } = this.determineStatus({
223
+ const { status, actionReason, waitReason, stalenessTier } = determineStatus({
221
224
  ciStatus,
222
225
  hasMergeConflict,
223
226
  hasUnrespondedComment,
@@ -275,115 +278,6 @@ export class PRMonitor {
275
278
  pr.displayDescription = displayDescription;
276
279
  return pr;
277
280
  }
278
- /**
279
- * Determine the overall status of a PR
280
- */
281
- determineStatus(input) {
282
- const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate: rawCommitDate, latestCommitAuthor, contributorUsername, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
283
- // Compute staleness tier (independent of status)
284
- let stalenessTier = 'active';
285
- if (daysSinceActivity >= dormantThreshold)
286
- stalenessTier = 'dormant';
287
- else if (daysSinceActivity >= approachingThreshold)
288
- stalenessTier = 'approaching_dormant';
289
- // Only count the latest commit if it was authored by the contributor or a
290
- // CI bot (#547, #568). Non-contributor commits (maintainer merge commits,
291
- // GitHub suggestion commits) should not mask unaddressed feedback.
292
- const latestCommitDate = rawCommitDate && this.isContributorCommit(latestCommitAuthor, contributorUsername) ? rawCommitDate : undefined;
293
- // Priority order: needs_addressing (response/changes/ci/conflict/checklist) > waiting_on_maintainer (review/merge/addressed/ci_blocked)
294
- if (hasUnrespondedComment) {
295
- // If the contributor pushed a commit after the maintainer's comment,
296
- // the changes have been addressed — waiting for maintainer re-review.
297
- // Require a minimum 2-minute gap to avoid false positives from race
298
- // conditions (pushing while review is being submitted) (#547).
299
- if (latestCommitDate &&
300
- lastMaintainerCommentDate &&
301
- this.isCommitAfterComment(latestCommitDate, lastMaintainerCommentDate)) {
302
- // Safety net (#431): if a CHANGES_REQUESTED review was submitted after
303
- // the commit, the maintainer still expects changes — don't mask it
304
- if (latestChangesRequestedDate && latestCommitDate < latestChangesRequestedDate) {
305
- return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
306
- }
307
- if (ciStatus === 'failing' && hasActionableCIFailure)
308
- return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
309
- // Non-actionable CI failures (infrastructure, fork, auth) don't block changes_addressed —
310
- // the contributor can't fix them, so the relevant status is "waiting for re-review" (#502)
311
- return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
312
- }
313
- return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
314
- }
315
- // Review requested changes but no unresponded comment.
316
- // If the latest commit is before the review, the contributor hasn't addressed it yet.
317
- if (reviewDecision === 'changes_requested' && latestChangesRequestedDate) {
318
- if (!latestCommitDate || latestCommitDate < latestChangesRequestedDate) {
319
- return { status: 'needs_addressing', actionReason: 'needs_changes', stalenessTier };
320
- }
321
- // Commit is after review — changes have been addressed
322
- if (ciStatus === 'failing' && hasActionableCIFailure)
323
- return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
324
- // Non-actionable CI failures don't block changes_addressed (#502)
325
- return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
326
- }
327
- if (ciStatus === 'failing') {
328
- return hasActionableCIFailure
329
- ? { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier }
330
- : { status: 'waiting_on_maintainer', waitReason: 'ci_blocked', stalenessTier };
331
- }
332
- if (hasMergeConflict) {
333
- return { status: 'needs_addressing', actionReason: 'merge_conflict', stalenessTier };
334
- }
335
- if (hasIncompleteChecklist) {
336
- return { status: 'needs_addressing', actionReason: 'incomplete_checklist', stalenessTier };
337
- }
338
- // Approved and CI passing/unknown = waiting on maintainer to merge
339
- if (reviewDecision === 'approved' && (ciStatus === 'passing' || ciStatus === 'unknown')) {
340
- return { status: 'waiting_on_maintainer', waitReason: 'pending_merge', stalenessTier };
341
- }
342
- // Default: no actionable issues found. Covers pending CI, no reviews yet, etc.
343
- return { status: 'waiting_on_maintainer', waitReason: 'pending_review', stalenessTier };
344
- }
345
- /**
346
- * CI-fix bots that push commits as a direct result of the contributor's push (#568).
347
- * Their commits represent contributor work and should count as addressing feedback.
348
- * This is intentionally an allowlist — not all `[bot]` accounts are CI-fix bots
349
- * (e.g. dependabot[bot] and renovate[bot] open their own PRs).
350
- * Values must be lowercase — lookup uses .toLowerCase() for case-insensitive matching.
351
- */
352
- static CI_FIX_BOTS = new Set([
353
- 'autofix-ci[bot]',
354
- 'prettier-ci[bot]',
355
- 'pre-commit-ci[bot]',
356
- ]);
357
- /**
358
- * Check whether the HEAD commit was authored by the contributor (#547).
359
- * Returns true when the author matches, when the author is a known CI-fix
360
- * bot (#568), or when author info is unavailable (graceful degradation).
361
- */
362
- isContributorCommit(commitAuthor, contributorUsername) {
363
- if (!commitAuthor || !contributorUsername)
364
- return true; // degrade gracefully
365
- const author = commitAuthor.toLowerCase();
366
- if (PRMonitor.CI_FIX_BOTS.has(author))
367
- return true; // CI-fix bots act on behalf of the contributor (#568)
368
- return author === contributorUsername.toLowerCase();
369
- }
370
- /** Minimum gap (ms) between maintainer comment and contributor commit for
371
- * the commit to count as "addressing" the feedback (#547). Prevents false
372
- * positives from race conditions, clock skew, and in-flight pushes. */
373
- static MIN_RESPONSE_GAP_MS = 2 * 60 * 1000; // 2 minutes
374
- /**
375
- * Check whether the contributor's commit is meaningfully after the maintainer's
376
- * comment — i.e. the commit timestamp is at least MIN_RESPONSE_GAP_MS later (#547).
377
- */
378
- isCommitAfterComment(commitDate, commentDate) {
379
- const commitMs = new Date(commitDate).getTime();
380
- const commentMs = new Date(commentDate).getTime();
381
- if (Number.isNaN(commitMs) || Number.isNaN(commentMs)) {
382
- // Fall back to simple string comparison (pre-#547 behavior)
383
- return commitDate > commentDate;
384
- }
385
- return commitMs - commentMs >= PRMonitor.MIN_RESPONSE_GAP_MS;
386
- }
387
281
  /**
388
282
  * Check if PR has merge conflict
389
283
  */
@@ -2,7 +2,7 @@
2
2
  * State management for the OSS Contribution Agent
3
3
  * Persists state to a JSON file in ~/.oss-autopilot/
4
4
  */
5
- import { AgentState, TrackedIssue, RepoScore, RepoScoreUpdate, StateEvent, StateEventType, DailyDigest, LocalRepoCache, SnoozeInfo, StatusOverride, FetchedPRStatus, StoredMergedPR, StoredClosedPR } from './types.js';
5
+ import { AgentState, TrackedIssue, RepoScore, RepoScoreUpdate, StateEvent, StateEventType, DailyDigest, LocalRepoCache, StatusOverride, FetchedPRStatus, StoredMergedPR, StoredClosedPR } from './types.js';
6
6
  /**
7
7
  * Acquire an advisory file lock using exclusive-create (`wx` flag).
8
8
  * If the lock file already exists but is stale (older than LOCK_TIMEOUT_MS or corrupt),
@@ -31,6 +31,7 @@ export declare function atomicWriteFileSync(filePath: string, data: string, mode
31
31
  export declare class StateManager {
32
32
  private state;
33
33
  private readonly inMemoryOnly;
34
+ private lastLoadedMtimeMs;
34
35
  /**
35
36
  * Create a new StateManager instance.
36
37
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -92,6 +93,12 @@ export declare class StateManager {
92
93
  * use the StateManager methods to make changes.
93
94
  */
94
95
  getState(): Readonly<AgentState>;
96
+ /**
97
+ * Re-read state from disk if the file has been modified since the last load/save.
98
+ * Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
99
+ * Returns true if state was reloaded, false if unchanged or in-memory mode.
100
+ */
101
+ reloadIfChanged(): boolean;
95
102
  /**
96
103
  * Store the latest daily digest for dashboard rendering.
97
104
  * @param digest - The freshly generated digest from the current daily run.
@@ -240,57 +247,25 @@ export declare class StateManager {
240
247
  */
241
248
  isPRShelved(url: string): boolean;
242
249
  /**
243
- * Dismiss an issue or PR by URL. Dismissed URLs are excluded from `new_response` notifications
250
+ * Dismiss an issue by URL. Dismissed issues are excluded from `new_response` notifications
244
251
  * until new activity occurs after the dismiss timestamp.
245
- * @param url - The full GitHub issue or PR URL.
246
- * @param timestamp - ISO timestamp of when the issue/PR was dismissed.
252
+ * @param url - The full GitHub issue URL.
253
+ * @param timestamp - ISO timestamp of when the issue was dismissed.
247
254
  * @returns true if newly dismissed, false if already dismissed.
248
255
  */
249
256
  dismissIssue(url: string, timestamp: string): boolean;
250
257
  /**
251
- * Undismiss an issue or PR by URL.
252
- * @param url - The full GitHub issue or PR URL.
258
+ * Undismiss an issue by URL.
259
+ * @param url - The full GitHub issue URL.
253
260
  * @returns true if found and removed, false if not dismissed.
254
261
  */
255
262
  undismissIssue(url: string): boolean;
256
263
  /**
257
- * Get the timestamp when an issue or PR was dismissed.
258
- * @param url - The full GitHub issue or PR URL.
264
+ * Get the timestamp when an issue was dismissed.
265
+ * @param url - The full GitHub issue URL.
259
266
  * @returns The ISO dismiss timestamp, or undefined if not dismissed.
260
267
  */
261
268
  getIssueDismissedAt(url: string): string | undefined;
262
- /**
263
- * Snooze a PR's CI failure for a given number of days.
264
- * Snoozed PRs are excluded from actionable CI failure lists until the snooze expires.
265
- * @param url - The full GitHub PR URL.
266
- * @param reason - Why the CI failure is being snoozed (e.g., "upstream infrastructure issue").
267
- * @param durationDays - Number of days to snooze. Default 7.
268
- * @returns true if newly snoozed, false if already snoozed.
269
- */
270
- snoozePR(url: string, reason: string, durationDays: number): boolean;
271
- /**
272
- * Unsnooze a PR by URL.
273
- * @param url - The full GitHub PR URL.
274
- * @returns true if found and removed, false if not snoozed.
275
- */
276
- unsnoozePR(url: string): boolean;
277
- /**
278
- * Check if a PR is currently snoozed (not expired).
279
- * @param url - The full GitHub PR URL.
280
- * @returns true if the PR is snoozed and the snooze has not expired.
281
- */
282
- isSnoozed(url: string): boolean;
283
- /**
284
- * Get snooze metadata for a PR.
285
- * @param url - The full GitHub PR URL.
286
- * @returns The snooze metadata, or undefined if not snoozed.
287
- */
288
- getSnoozeInfo(url: string): SnoozeInfo | undefined;
289
- /**
290
- * Expire all snoozes that are past their `expiresAt` timestamp.
291
- * @returns Array of PR URLs whose snoozes were expired.
292
- */
293
- expireSnoozes(): string[];
294
269
  /**
295
270
  * Set a manual status override for a PR.
296
271
  * @param url - The full GitHub PR URL.
@@ -6,7 +6,7 @@ import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import { INITIAL_STATE, isBelowMinStars, } from './types.js';
8
8
  import { getStatePath, getBackupDir, getDataDir } from './utils.js';
9
- import { ValidationError, errorMessage } from './errors.js';
9
+ import { errorMessage } from './errors.js';
10
10
  import { debug, warn } from './logger.js';
11
11
  const MODULE = 'state';
12
12
  // Current state version
@@ -169,6 +169,7 @@ function migrateV1ToV2(rawState) {
169
169
  export class StateManager {
170
170
  state;
171
171
  inMemoryOnly;
172
+ lastLoadedMtimeMs = 0;
172
173
  /**
173
174
  * Create a new StateManager instance.
174
175
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -196,7 +197,6 @@ export class StateManager {
196
197
  trustedProjects: [],
197
198
  shelvedPRUrls: [],
198
199
  dismissedIssues: {},
199
- snoozedPRs: {},
200
200
  },
201
201
  events: [],
202
202
  lastRunAt: new Date().toISOString(),
@@ -342,6 +342,41 @@ export class StateManager {
342
342
  atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
343
343
  debug(MODULE, 'Migrated state saved');
344
344
  }
345
+ // Strip legacy fields from persisted state (snoozedPRs and PR dismiss
346
+ // entries were removed in the three-state PR model simplification)
347
+ try {
348
+ let needsCleanupSave = false;
349
+ const rawConfig = state.config;
350
+ if (rawConfig.snoozedPRs) {
351
+ delete rawConfig.snoozedPRs;
352
+ needsCleanupSave = true;
353
+ }
354
+ // Strip PR URLs from dismissedIssues (PR dismiss removed)
355
+ if (state.config.dismissedIssues) {
356
+ const PR_URL_RE = /\/pull\/\d+$/;
357
+ for (const url of Object.keys(state.config.dismissedIssues)) {
358
+ if (PR_URL_RE.test(url)) {
359
+ delete state.config.dismissedIssues[url];
360
+ needsCleanupSave = true;
361
+ }
362
+ }
363
+ }
364
+ if (needsCleanupSave) {
365
+ atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
366
+ warn(MODULE, 'Cleaned up removed features (snoozedPRs, dismissed PR URLs) from persisted state');
367
+ }
368
+ }
369
+ catch (cleanupError) {
370
+ warn(MODULE, `Failed to clean up removed features from state: ${errorMessage(cleanupError)}`);
371
+ // Continue with loaded state — cleanup will be retried on next load
372
+ }
373
+ // Record file mtime so reloadIfChanged() can detect external writes
374
+ try {
375
+ this.lastLoadedMtimeMs = fs.statSync(getStatePath()).mtimeMs;
376
+ }
377
+ catch (error) {
378
+ debug(MODULE, `Could not read state file mtime (reload detection will always trigger): ${errorMessage(error)}`);
379
+ }
345
380
  // Log appropriate message based on version
346
381
  const repoCount = Object.keys(state.repoScores).length;
347
382
  debug(MODULE, `Loaded state v${state.version}: ${repoCount} repo scores tracked`);
@@ -472,6 +507,8 @@ export class StateManager {
472
507
  }
473
508
  // Atomic write: write to temp file then rename to prevent corruption on crash
474
509
  atomicWriteFileSync(statePath, JSON.stringify(this.state, null, 2), 0o600);
510
+ // Update mtime so own writes don't trigger reloadIfChanged()
511
+ this.lastLoadedMtimeMs = fs.statSync(statePath).mtimeMs;
475
512
  debug(MODULE, 'State saved successfully');
476
513
  }
477
514
  finally {
@@ -508,6 +545,40 @@ export class StateManager {
508
545
  getState() {
509
546
  return this.state;
510
547
  }
548
+ /**
549
+ * Re-read state from disk if the file has been modified since the last load/save.
550
+ * Uses mtime comparison (single statSync call) to avoid unnecessary JSON parsing.
551
+ * Returns true if state was reloaded, false if unchanged or in-memory mode.
552
+ */
553
+ reloadIfChanged() {
554
+ if (this.inMemoryOnly)
555
+ return false;
556
+ try {
557
+ const statePath = getStatePath();
558
+ const currentMtimeMs = fs.statSync(statePath).mtimeMs;
559
+ if (currentMtimeMs === this.lastLoadedMtimeMs)
560
+ return false;
561
+ this.state = this.load();
562
+ // load() only records lastLoadedMtimeMs on the happy path. Ensure it is
563
+ // always current after reload (covers backup-restore and fresh-state paths)
564
+ // to prevent repeated unnecessary reloads on every request.
565
+ try {
566
+ this.lastLoadedMtimeMs = fs.statSync(statePath).mtimeMs;
567
+ }
568
+ catch {
569
+ // If file was just loaded, stat should not fail. If it does,
570
+ // next reloadIfChanged() will simply trigger another reload.
571
+ }
572
+ return true;
573
+ }
574
+ catch (error) {
575
+ // statSync failure (file deleted) is benign — keep current in-memory state.
576
+ // load() failure should not happen (load() handles its own recovery),
577
+ // but if it does, keeping current state is the safest option.
578
+ warn(MODULE, `Failed to reload state from disk: ${errorMessage(error)}`);
579
+ return false;
580
+ }
581
+ }
511
582
  /**
512
583
  * Store the latest daily digest for dashboard rendering.
513
584
  * @param digest - The freshly generated digest from the current daily run.
@@ -803,10 +874,10 @@ export class StateManager {
803
874
  }
804
875
  // === Dismiss / Undismiss Issues ===
805
876
  /**
806
- * Dismiss an issue or PR by URL. Dismissed URLs are excluded from `new_response` notifications
877
+ * Dismiss an issue by URL. Dismissed issues are excluded from `new_response` notifications
807
878
  * until new activity occurs after the dismiss timestamp.
808
- * @param url - The full GitHub issue or PR URL.
809
- * @param timestamp - ISO timestamp of when the issue/PR was dismissed.
879
+ * @param url - The full GitHub issue URL.
880
+ * @param timestamp - ISO timestamp of when the issue was dismissed.
810
881
  * @returns true if newly dismissed, false if already dismissed.
811
882
  */
812
883
  dismissIssue(url, timestamp) {
@@ -820,8 +891,8 @@ export class StateManager {
820
891
  return true;
821
892
  }
822
893
  /**
823
- * Undismiss an issue or PR by URL.
824
- * @param url - The full GitHub issue or PR URL.
894
+ * Undismiss an issue by URL.
895
+ * @param url - The full GitHub issue URL.
825
896
  * @returns true if found and removed, false if not dismissed.
826
897
  */
827
898
  undismissIssue(url) {
@@ -832,97 +903,13 @@ export class StateManager {
832
903
  return true;
833
904
  }
834
905
  /**
835
- * Get the timestamp when an issue or PR was dismissed.
836
- * @param url - The full GitHub issue or PR URL.
906
+ * Get the timestamp when an issue was dismissed.
907
+ * @param url - The full GitHub issue URL.
837
908
  * @returns The ISO dismiss timestamp, or undefined if not dismissed.
838
909
  */
839
910
  getIssueDismissedAt(url) {
840
911
  return this.state.config.dismissedIssues?.[url];
841
912
  }
842
- // === Snooze / Unsnooze CI Failures ===
843
- /**
844
- * Snooze a PR's CI failure for a given number of days.
845
- * Snoozed PRs are excluded from actionable CI failure lists until the snooze expires.
846
- * @param url - The full GitHub PR URL.
847
- * @param reason - Why the CI failure is being snoozed (e.g., "upstream infrastructure issue").
848
- * @param durationDays - Number of days to snooze. Default 7.
849
- * @returns true if newly snoozed, false if already snoozed.
850
- */
851
- snoozePR(url, reason, durationDays) {
852
- if (!Number.isFinite(durationDays) || durationDays <= 0) {
853
- throw new ValidationError(`Invalid snooze duration: ${durationDays}. Must be a positive finite number.`);
854
- }
855
- if (!this.state.config.snoozedPRs) {
856
- this.state.config.snoozedPRs = {};
857
- }
858
- if (url in this.state.config.snoozedPRs) {
859
- return false;
860
- }
861
- const now = new Date();
862
- const expiresAt = new Date(now.getTime() + durationDays * 24 * 60 * 60 * 1000);
863
- this.state.config.snoozedPRs[url] = {
864
- reason,
865
- snoozedAt: now.toISOString(),
866
- expiresAt: expiresAt.toISOString(),
867
- };
868
- return true;
869
- }
870
- /**
871
- * Unsnooze a PR by URL.
872
- * @param url - The full GitHub PR URL.
873
- * @returns true if found and removed, false if not snoozed.
874
- */
875
- unsnoozePR(url) {
876
- if (!this.state.config.snoozedPRs || !(url in this.state.config.snoozedPRs)) {
877
- return false;
878
- }
879
- delete this.state.config.snoozedPRs[url];
880
- return true;
881
- }
882
- /**
883
- * Check if a PR is currently snoozed (not expired).
884
- * @param url - The full GitHub PR URL.
885
- * @returns true if the PR is snoozed and the snooze has not expired.
886
- */
887
- isSnoozed(url) {
888
- const info = this.getSnoozeInfo(url);
889
- if (!info)
890
- return false;
891
- const expiresAtMs = new Date(info.expiresAt).getTime();
892
- if (isNaN(expiresAtMs)) {
893
- warn(MODULE, `Invalid expiresAt for snoozed PR ${url}: "${info.expiresAt}". Treating as not snoozed.`);
894
- return false;
895
- }
896
- return expiresAtMs > Date.now();
897
- }
898
- /**
899
- * Get snooze metadata for a PR.
900
- * @param url - The full GitHub PR URL.
901
- * @returns The snooze metadata, or undefined if not snoozed.
902
- */
903
- getSnoozeInfo(url) {
904
- return this.state.config.snoozedPRs?.[url];
905
- }
906
- /**
907
- * Expire all snoozes that are past their `expiresAt` timestamp.
908
- * @returns Array of PR URLs whose snoozes were expired.
909
- */
910
- expireSnoozes() {
911
- if (!this.state.config.snoozedPRs)
912
- return [];
913
- const expired = [];
914
- const now = Date.now();
915
- for (const [url, info] of Object.entries(this.state.config.snoozedPRs)) {
916
- const expiresAtMs = new Date(info.expiresAt).getTime();
917
- if (isNaN(expiresAtMs) || expiresAtMs <= now) {
918
- expired.push(url);
919
- }
920
- }
921
- for (const url of expired) {
922
- delete this.state.config.snoozedPRs[url];
923
- }
924
- return expired;
925
- }
926
913
  // === Status Overrides ===
927
914
  /**
928
915
  * Set a manual status override for a PR.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Status determination logic for PRs — extracted from PRMonitor (#263).
3
+ *
4
+ * Computes the top-level status (needs_addressing vs waiting_on_maintainer),
5
+ * granular action/wait reasons, and staleness tier for a single PR based on
6
+ * its CI, review, merge-conflict, and timeline signals.
7
+ */
8
+ import type { DetermineStatusInput, DetermineStatusResult } from './types.js';
9
+ /**
10
+ * CI-fix bots that push commits as a direct result of the contributor's push (#568).
11
+ * Their commits represent contributor work and should count as addressing feedback.
12
+ * This is intentionally an allowlist — not all `[bot]` accounts are CI-fix bots
13
+ * (e.g. dependabot[bot] and renovate[bot] open their own PRs).
14
+ * Values must be lowercase — lookup uses .toLowerCase() for case-insensitive matching.
15
+ */
16
+ export declare const CI_FIX_BOTS: ReadonlySet<string>;
17
+ /** Minimum gap (ms) between maintainer comment and contributor commit for
18
+ * the commit to count as "addressing" the feedback (#547). Prevents false
19
+ * positives from race conditions, clock skew, and in-flight pushes. */
20
+ export declare const MIN_RESPONSE_GAP_MS: number;
21
+ /**
22
+ * Check whether the HEAD commit was authored by the contributor (#547).
23
+ * Returns true when the author matches, when the author is a known CI-fix
24
+ * bot (#568), or when author info is unavailable (graceful degradation).
25
+ */
26
+ export declare function isContributorCommit(commitAuthor?: string, contributorUsername?: string): boolean;
27
+ /**
28
+ * Check whether the contributor's commit is meaningfully after the maintainer's
29
+ * comment — i.e. the commit timestamp is at least MIN_RESPONSE_GAP_MS later (#547).
30
+ */
31
+ export declare function isCommitAfterComment(commitDate: string, commentDate: string): boolean;
32
+ /**
33
+ * Determine the overall status of a PR based on its signals.
34
+ */
35
+ export declare function determineStatus(input: DetermineStatusInput): DetermineStatusResult;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Status determination logic for PRs — extracted from PRMonitor (#263).
3
+ *
4
+ * Computes the top-level status (needs_addressing vs waiting_on_maintainer),
5
+ * granular action/wait reasons, and staleness tier for a single PR based on
6
+ * its CI, review, merge-conflict, and timeline signals.
7
+ */
8
+ /**
9
+ * CI-fix bots that push commits as a direct result of the contributor's push (#568).
10
+ * Their commits represent contributor work and should count as addressing feedback.
11
+ * This is intentionally an allowlist — not all `[bot]` accounts are CI-fix bots
12
+ * (e.g. dependabot[bot] and renovate[bot] open their own PRs).
13
+ * Values must be lowercase — lookup uses .toLowerCase() for case-insensitive matching.
14
+ */
15
+ export const CI_FIX_BOTS = new Set(['autofix-ci[bot]', 'prettier-ci[bot]', 'pre-commit-ci[bot]']);
16
+ /** Minimum gap (ms) between maintainer comment and contributor commit for
17
+ * the commit to count as "addressing" the feedback (#547). Prevents false
18
+ * positives from race conditions, clock skew, and in-flight pushes. */
19
+ export const MIN_RESPONSE_GAP_MS = 2 * 60 * 1000; // 2 minutes
20
+ /**
21
+ * Check whether the HEAD commit was authored by the contributor (#547).
22
+ * Returns true when the author matches, when the author is a known CI-fix
23
+ * bot (#568), or when author info is unavailable (graceful degradation).
24
+ */
25
+ export function isContributorCommit(commitAuthor, contributorUsername) {
26
+ if (!commitAuthor || !contributorUsername)
27
+ return true; // degrade gracefully
28
+ const author = commitAuthor.toLowerCase();
29
+ if (CI_FIX_BOTS.has(author))
30
+ return true; // CI-fix bots act on behalf of the contributor (#568)
31
+ return author === contributorUsername.toLowerCase();
32
+ }
33
+ /**
34
+ * Check whether the contributor's commit is meaningfully after the maintainer's
35
+ * comment — i.e. the commit timestamp is at least MIN_RESPONSE_GAP_MS later (#547).
36
+ */
37
+ export function isCommitAfterComment(commitDate, commentDate) {
38
+ const commitMs = new Date(commitDate).getTime();
39
+ const commentMs = new Date(commentDate).getTime();
40
+ if (Number.isNaN(commitMs) || Number.isNaN(commentMs)) {
41
+ // Fall back to simple string comparison (pre-#547 behavior)
42
+ return commitDate > commentDate;
43
+ }
44
+ return commitMs - commentMs >= MIN_RESPONSE_GAP_MS;
45
+ }
46
+ /**
47
+ * Determine the overall status of a PR based on its signals.
48
+ */
49
+ export function determineStatus(input) {
50
+ const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate: rawCommitDate, latestCommitAuthor, contributorUsername, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
51
+ // Compute staleness tier (independent of status)
52
+ let stalenessTier = 'active';
53
+ if (daysSinceActivity >= dormantThreshold)
54
+ stalenessTier = 'dormant';
55
+ else if (daysSinceActivity >= approachingThreshold)
56
+ stalenessTier = 'approaching_dormant';
57
+ // Only count the latest commit if it was authored by the contributor or a
58
+ // CI bot (#547, #568). Non-contributor commits (maintainer merge commits,
59
+ // GitHub suggestion commits) should not mask unaddressed feedback.
60
+ const latestCommitDate = rawCommitDate && isContributorCommit(latestCommitAuthor, contributorUsername) ? rawCommitDate : undefined;
61
+ // Priority order: needs_addressing (response/changes/ci/conflict/checklist) > waiting_on_maintainer (review/merge/addressed/ci_blocked)
62
+ if (hasUnrespondedComment) {
63
+ // If the contributor pushed a commit after the maintainer's comment,
64
+ // the changes have been addressed — waiting for maintainer re-review.
65
+ // Require a minimum 2-minute gap to avoid false positives from race
66
+ // conditions (pushing while review is being submitted) (#547).
67
+ if (latestCommitDate &&
68
+ lastMaintainerCommentDate &&
69
+ isCommitAfterComment(latestCommitDate, lastMaintainerCommentDate)) {
70
+ // Safety net (#431): if a CHANGES_REQUESTED review was submitted after
71
+ // the commit, the maintainer still expects changes — don't mask it
72
+ if (latestChangesRequestedDate && latestCommitDate < latestChangesRequestedDate) {
73
+ return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
74
+ }
75
+ if (ciStatus === 'failing' && hasActionableCIFailure)
76
+ return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
77
+ // Non-actionable CI failures (infrastructure, fork, auth) don't block changes_addressed —
78
+ // the contributor can't fix them, so the relevant status is "waiting for re-review" (#502)
79
+ return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
80
+ }
81
+ return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
82
+ }
83
+ // Review requested changes but no unresponded comment.
84
+ // If the latest commit is before the review, the contributor hasn't addressed it yet.
85
+ if (reviewDecision === 'changes_requested' && latestChangesRequestedDate) {
86
+ if (!latestCommitDate || latestCommitDate < latestChangesRequestedDate) {
87
+ return { status: 'needs_addressing', actionReason: 'needs_changes', stalenessTier };
88
+ }
89
+ // Commit is after review — changes have been addressed
90
+ if (ciStatus === 'failing' && hasActionableCIFailure)
91
+ return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
92
+ // Non-actionable CI failures don't block changes_addressed (#502)
93
+ return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
94
+ }
95
+ if (ciStatus === 'failing') {
96
+ return hasActionableCIFailure
97
+ ? { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier }
98
+ : { status: 'waiting_on_maintainer', waitReason: 'ci_blocked', stalenessTier };
99
+ }
100
+ if (hasMergeConflict) {
101
+ return { status: 'needs_addressing', actionReason: 'merge_conflict', stalenessTier };
102
+ }
103
+ if (hasIncompleteChecklist) {
104
+ return { status: 'needs_addressing', actionReason: 'incomplete_checklist', stalenessTier };
105
+ }
106
+ // Approved and CI passing/unknown = waiting on maintainer to merge
107
+ if (reviewDecision === 'approved' && (ciStatus === 'passing' || ciStatus === 'unknown')) {
108
+ return { status: 'waiting_on_maintainer', waitReason: 'pending_merge', stalenessTier };
109
+ }
110
+ // Default: no actionable issues found. Covers pending CI, no reviews yet, etc.
111
+ return { status: 'waiting_on_maintainer', waitReason: 'pending_review', stalenessTier };
112
+ }