@oss-autopilot/core 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,7 +41,7 @@ export interface DailyCheckResult {
41
41
  * 1. fetchPRData — fetch open PRs, merged/closed counts, issues
42
42
  * 2. updateRepoScores — update signals, star counts, trust in state
43
43
  * 3. updateAnalytics — store monthly chart data
44
- * 4. partitionPRs — expire snoozes, shelve/unshelve, generate digest
44
+ * 4. partitionPRs — shelve/unshelve, generate digest
45
45
  * 5. generateDigestOutput — capacity, dismiss filter, action menu assembly
46
46
  */
47
47
  export declare function executeDailyCheck(token: string): Promise<DailyOutput>;
@@ -230,25 +230,12 @@ async function updateRepoScores(prMonitor, prs, mergedCounts, closedCounts) {
230
230
  }
231
231
  }
232
232
  /**
233
- * Phase 4: Expire snoozes and partition PRs into active vs shelved buckets.
233
+ * Phase 4: Partition PRs into active vs shelved buckets.
234
234
  * Auto-unshelves PRs where maintainers have engaged, generates the digest,
235
235
  * and persists state.
236
236
  */
237
237
  function partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs) {
238
238
  const stateManager = getStateManager();
239
- // Expire any snoozes that have passed their expiresAt timestamp.
240
- // Non-critical: corrupted snooze entries should not abort the daily check.
241
- try {
242
- const expiredSnoozes = stateManager.expireSnoozes();
243
- if (expiredSnoozes.length > 0) {
244
- const urls = expiredSnoozes.map((url) => ` - ${url}`).join('\n');
245
- warn(MODULE, `${expiredSnoozes.length} snoozed PR(s) expired and will resurface:\n${urls}`);
246
- stateManager.save();
247
- }
248
- }
249
- catch (error) {
250
- warn(MODULE, `Failed to expire/persist snoozes: ${errorMessage(error)}`);
251
- }
252
239
  // Apply dashboard/CLI status overrides before partitioning.
253
240
  // This ensures PRs reclassified in the dashboard (e.g., "Need Attention" → "Waiting")
254
241
  // are respected by the CLI pipeline.
@@ -328,31 +315,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
328
315
  });
329
316
  const issueResponses = filteredCommentedIssues.filter((i) => i.status === 'new_response');
330
317
  const summary = formatSummary(digest, capacity, issueResponses);
331
- const snoozedUrls = new Set(Object.keys(stateManager.getState().config.snoozedPRs ?? {}).filter((url) => stateManager.isSnoozed(url)));
332
- // Filter dismissed PRs: suppress if dismissed after last activity, auto-undismiss if new activity (#416, #468)
333
- const nonDismissedPRs = activePRs.filter((pr) => {
334
- const dismissedAt = stateManager.getIssueDismissedAt(pr.url);
335
- if (!dismissedAt)
336
- return true; // Not dismissed — include
337
- const activityTime = new Date(pr.updatedAt).getTime();
338
- const dismissTime = new Date(dismissedAt).getTime();
339
- if (isNaN(activityTime) || isNaN(dismissTime)) {
340
- // Invalid timestamp — fail open (include PR to be safe) without
341
- // permanently removing dismiss record (may be a transient data issue)
342
- warn(MODULE, `Invalid timestamp in PR dismiss check for ${pr.url}, including PR`);
343
- return true;
344
- }
345
- if (activityTime > dismissTime) {
346
- // New activity after dismiss — auto-undismiss and resurface
347
- warn(MODULE, `Auto-undismissing PR ${pr.url}: new activity at ${pr.updatedAt} after dismiss at ${dismissedAt}`);
348
- stateManager.undismissIssue(pr.url);
349
- hasAutoUndismissed = true;
350
- return true;
351
- }
352
- // Still dismissed (last activity is at or before dismiss timestamp)
353
- return false;
354
- });
355
- // Persist auto-undismiss state changes (issue + PR combined into one save)
318
+ // Persist auto-undismiss state changes for issues
356
319
  if (hasAutoUndismissed) {
357
320
  try {
358
321
  stateManager.save();
@@ -361,7 +324,7 @@ function generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, fa
361
324
  warn(MODULE, `Failed to persist auto-undismissed state: ${errorMessage(error)}`);
362
325
  }
363
326
  }
364
- const actionableIssues = collectActionableIssues(nonDismissedPRs, snoozedUrls, previousLastDigestAt);
327
+ const actionableIssues = collectActionableIssues(activePRs, previousLastDigestAt);
365
328
  digest.summary.totalNeedingAttention = actionableIssues.length;
366
329
  const briefSummary = formatBriefSummary(digest, actionableIssues.length, issueResponses.length);
367
330
  const actionMenu = computeActionMenu(actionableIssues, capacity, filteredCommentedIssues);
@@ -410,7 +373,7 @@ function toDailyOutput(result) {
410
373
  * 1. fetchPRData — fetch open PRs, merged/closed counts, issues
411
374
  * 2. updateRepoScores — update signals, star counts, trust in state
412
375
  * 3. updateAnalytics — store monthly chart data
413
- * 4. partitionPRs — expire snoozes, shelve/unshelve, generate digest
376
+ * 4. partitionPRs — shelve/unshelve, generate digest
414
377
  * 5. generateDigestOutput — capacity, dismiss filter, action menu assembly
415
378
  */
416
379
  export async function executeDailyCheck(token) {
@@ -432,7 +395,7 @@ async function executeDailyCheckInternal(token) {
432
395
  // Capture lastDigestAt BEFORE Phase 4 overwrites it with the current run's timestamp.
433
396
  // Used by collectActionableIssues to determine which PRs are "new" (created since last digest).
434
397
  const previousLastDigestAt = getStateManager().getState().lastDigestAt;
435
- // Phase 4: Expire snoozes, partition PRs, generate and save digest
398
+ // Phase 4: Partition PRs, generate and save digest
436
399
  const { activePRs, shelvedPRs, digest } = partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs);
437
400
  // Phase 5: Build structured output (capacity, dismiss filter, action menu)
438
401
  return generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Dashboard HTTP server.
3
3
  * Serves the Preact SPA from packages/dashboard/dist/ and provides API endpoints
4
- * for live data fetching and state mutations (shelve, unshelve, override, etc.).
4
+ * for live data fetching and state mutations (PR state transitions, issue dismiss).
5
5
  *
6
6
  * Uses Node's built-in http module — no Express/Fastify.
7
7
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Dashboard HTTP server.
3
3
  * Serves the Preact SPA from packages/dashboard/dist/ and provides API endpoints
4
- * for live data fetching and state mutations (shelve, unshelve, override, etc.).
4
+ * for live data fetching and state mutations (PR state transitions, issue dismiss).
5
5
  *
6
6
  * Uses Node's built-in http module — no Express/Fastify.
7
7
  */
@@ -11,7 +11,7 @@ import * as path from 'path';
11
11
  import { getStateManager, getGitHubToken, getCLIVersion, applyStatusOverrides } from '../core/index.js';
12
12
  import { errorMessage, ValidationError } from '../core/errors.js';
13
13
  import { warn } from '../core/logger.js';
14
- import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_OR_PR_URL_PATTERN } from './validation.js';
14
+ import { validateUrl, validateGitHubUrl, PR_URL_PATTERN, ISSUE_URL_PATTERN } from './validation.js';
15
15
  import { fetchDashboardData, computePRsByRepo, computeTopRepos, getMonthlyData, buildDashboardStats, storedToMergedPRs, storedToClosedPRs, } from './dashboard-data.js';
16
16
  import { openInBrowser } from './startup.js';
17
17
  import { writeDashboardServerInfo, removeDashboardServerInfo } from './dashboard-process.js';
@@ -20,12 +20,7 @@ import { isBelowMinStars, } from '../core/types.js';
20
20
  // Re-export process management functions for backward compatibility
21
21
  export { getDashboardPidPath, writeDashboardServerInfo, readDashboardServerInfo, removeDashboardServerInfo, isDashboardServerRunning, findRunningDashboardServer, } from './dashboard-process.js';
22
22
  // ── Constants ────────────────────────────────────────────────────────────────
23
- const VALID_ACTIONS = new Set([
24
- 'shelve',
25
- 'unshelve',
26
- 'override_status',
27
- 'dismiss_issue_response',
28
- ]);
23
+ const VALID_ACTIONS = new Set(['move', 'dismiss_issue_response']);
29
24
  const MODULE = 'dashboard-server';
30
25
  const MAX_BODY_BYTES = 10_240;
31
26
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -250,11 +245,11 @@ export async function startDashboardServer(options) {
250
245
  sendError(res, 400, 'Missing or invalid "url" field');
251
246
  return;
252
247
  }
253
- // Validate URL format — dismiss_issue_response accepts issue or PR URLs, others are PR-only.
248
+ // Validate URL format — move is PR-only, dismiss_issue_response is issue-only.
254
249
  const isDismiss = body.action === 'dismiss_issue_response';
255
250
  try {
256
251
  validateUrl(body.url);
257
- validateGitHubUrl(body.url, isDismiss ? ISSUE_OR_PR_URL_PATTERN : PR_URL_PATTERN, isDismiss ? 'issue or PR' : 'PR');
252
+ validateGitHubUrl(body.url, isDismiss ? ISSUE_URL_PATTERN : PR_URL_PATTERN, isDismiss ? 'issue' : 'PR');
258
253
  }
259
254
  catch (err) {
260
255
  if (err instanceof ValidationError) {
@@ -266,35 +261,20 @@ export async function startDashboardServer(options) {
266
261
  }
267
262
  return;
268
263
  }
269
- // Validate override_status-specific fields
270
- if (body.action === 'override_status') {
271
- if (!body.status || (body.status !== 'needs_addressing' && body.status !== 'waiting_on_maintainer')) {
272
- sendError(res, 400, 'override_status requires a valid "status" field (needs_addressing or waiting_on_maintainer)');
273
- return;
274
- }
275
- }
276
264
  try {
277
- switch (body.action) {
278
- case 'shelve':
279
- stateManager.shelvePR(body.url);
280
- break;
281
- case 'unshelve':
282
- stateManager.unshelvePR(body.url);
283
- break;
284
- case 'override_status': {
285
- // body.status is validated above — the early return ensures it's defined here
286
- const overrideStatus = body.status;
287
- // Find the PR to get its current updatedAt for auto-clear tracking
288
- const targetPR = (cachedDigest?.openPRs || []).find((pr) => pr.url === body.url);
289
- const lastActivityAt = targetPR?.updatedAt || new Date().toISOString();
290
- stateManager.setStatusOverride(body.url, overrideStatus, lastActivityAt);
291
- break;
265
+ if (body.action === 'move') {
266
+ const { VALID_TARGETS, runMove } = await import('./move.js');
267
+ if (!body.target || !VALID_TARGETS.includes(body.target)) {
268
+ sendError(res, 400, `move requires a valid "target" field (${VALID_TARGETS.join(', ')})`);
269
+ return;
292
270
  }
293
- case 'dismiss_issue_response':
294
- stateManager.dismissIssue(body.url, new Date().toISOString());
295
- break;
271
+ await runMove({ prUrl: body.url, target: body.target });
272
+ }
273
+ else {
274
+ // dismiss_issue_response
275
+ stateManager.dismissIssue(body.url, new Date().toISOString());
276
+ stateManager.save();
296
277
  }
297
- stateManager.save();
298
278
  }
299
279
  catch (error) {
300
280
  warn(MODULE, `Action failed: ${body.action} ${body.url} ${errorMessage(error)}`);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Dismiss/Undismiss commands
3
- * Manages dismissing issue and PR notifications without posting a comment.
3
+ * Manages dismissing issue notifications without posting a comment.
4
4
  * Dismissed URLs resurface automatically when new responses arrive after the dismiss timestamp.
5
5
  */
6
6
  export interface DismissOutput {
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Dismiss/Undismiss commands
3
- * Manages dismissing issue and PR notifications without posting a comment.
3
+ * Manages dismissing issue notifications without posting a comment.
4
4
  * Dismissed URLs resurface automatically when new responses arrive after the dismiss timestamp.
5
5
  */
6
6
  import { getStateManager } from '../core/index.js';
7
- import { ISSUE_OR_PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
7
+ import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
8
8
  export async function runDismiss(options) {
9
9
  validateUrl(options.url);
10
- validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, 'issue or PR');
10
+ validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
11
11
  const stateManager = getStateManager();
12
12
  const added = stateManager.dismissIssue(options.url, new Date().toISOString());
13
13
  if (added) {
@@ -17,7 +17,7 @@ export async function runDismiss(options) {
17
17
  }
18
18
  export async function runUndismiss(options) {
19
19
  validateUrl(options.url);
20
- validateGitHubUrl(options.url, ISSUE_OR_PR_URL_PATTERN, 'issue or PR');
20
+ validateGitHubUrl(options.url, ISSUE_URL_PATTERN, 'issue');
21
21
  const stateManager = getStateManager();
22
22
  const removed = stateManager.undismissIssue(options.url);
23
23
  if (removed) {
@@ -35,14 +35,12 @@ export { runRead } from './read.js';
35
35
  export { runShelve } from './shelve.js';
36
36
  /** Restore a shelved PR to the daily digest. */
37
37
  export { runUnshelve } from './shelve.js';
38
+ /** Move a PR between states: attention, waiting, shelved, auto. */
39
+ export { runMove } from './move.js';
38
40
  /** Dismiss issue reply notifications (auto-resurfaces on new activity). */
39
41
  export { runDismiss } from './dismiss.js';
40
42
  /** Restore a dismissed issue to notifications. */
41
43
  export { runUndismiss } from './dismiss.js';
42
- /** Temporarily suppress CI failure notifications for a PR. */
43
- export { runSnooze } from './snooze.js';
44
- /** Restore CI failure notifications for a snoozed PR. */
45
- export { runUnsnooze } from './snooze.js';
46
44
  /** Fetch comments for tracked issues/PRs. */
47
45
  export { runComments } from './comments.js';
48
46
  /** Post a comment to a GitHub issue or PR. */
@@ -68,8 +66,8 @@ export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput } from '../form
68
66
  export type { ConfigOutput, ParseIssueListOutput, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
69
67
  export type { ReadOutput } from './read.js';
70
68
  export type { ShelveOutput, UnshelveOutput } from './shelve.js';
69
+ export type { MoveOutput, MoveTarget } from './move.js';
71
70
  export type { DismissOutput, UndismissOutput } from './dismiss.js';
72
- export type { SnoozeOutput, UnsnoozeOutput } from './snooze.js';
73
71
  export type { UntrackOutput } from './track.js';
74
72
  export type { InitOutput } from './init.js';
75
73
  export type { ConfigSetOutput, ConfigCommandOutput } from './config.js';
@@ -37,14 +37,12 @@ export { runRead } from './read.js';
37
37
  export { runShelve } from './shelve.js';
38
38
  /** Restore a shelved PR to the daily digest. */
39
39
  export { runUnshelve } from './shelve.js';
40
+ /** Move a PR between states: attention, waiting, shelved, auto. */
41
+ export { runMove } from './move.js';
40
42
  /** Dismiss issue reply notifications (auto-resurfaces on new activity). */
41
43
  export { runDismiss } from './dismiss.js';
42
44
  /** Restore a dismissed issue to notifications. */
43
45
  export { runUndismiss } from './dismiss.js';
44
- /** Temporarily suppress CI failure notifications for a PR. */
45
- export { runSnooze } from './snooze.js';
46
- /** Restore CI failure notifications for a snoozed PR. */
47
- export { runUnsnooze } from './snooze.js';
48
46
  // ── Issue & Comment Management ──────────────────────────────────────────────
49
47
  /** Fetch comments for tracked issues/PRs. */
50
48
  export { runComments } from './comments.js';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Move command — transition a PR between states:
3
+ * attention, waiting, shelved, or auto (reset to computed status).
4
+ */
5
+ export declare const VALID_TARGETS: readonly ["attention", "waiting", "shelved", "auto"];
6
+ export type MoveTarget = (typeof VALID_TARGETS)[number];
7
+ export interface MoveOutput {
8
+ url: string;
9
+ target: MoveTarget;
10
+ /** Human-readable description of what happened. */
11
+ description: string;
12
+ }
13
+ export declare function runMove(options: {
14
+ prUrl: string;
15
+ target: string;
16
+ }): Promise<MoveOutput>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Move command — transition a PR between states:
3
+ * attention, waiting, shelved, or auto (reset to computed status).
4
+ */
5
+ import { getStateManager } from '../core/index.js';
6
+ import { PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
7
+ export const VALID_TARGETS = ['attention', 'waiting', 'shelved', 'auto'];
8
+ export async function runMove(options) {
9
+ validateUrl(options.prUrl);
10
+ validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
11
+ const target = options.target;
12
+ if (!VALID_TARGETS.includes(target)) {
13
+ throw new Error(`Invalid target "${options.target}". Must be one of: ${VALID_TARGETS.join(', ')}`);
14
+ }
15
+ const stateManager = getStateManager();
16
+ switch (target) {
17
+ case 'attention':
18
+ case 'waiting': {
19
+ const status = target === 'attention' ? 'needs_addressing' : 'waiting_on_maintainer';
20
+ const label = target === 'attention' ? 'Need Attention' : 'Waiting on Maintainer';
21
+ // Use current time — the CLI doesn't have cached PR data. The override
22
+ // will auto-clear on the next daily run if the PR has new activity after this.
23
+ const lastActivityAt = new Date().toISOString();
24
+ stateManager.setStatusOverride(options.prUrl, status, lastActivityAt);
25
+ stateManager.unshelvePR(options.prUrl);
26
+ stateManager.save();
27
+ return { url: options.prUrl, target, description: `Moved to ${label}` };
28
+ }
29
+ case 'shelved': {
30
+ stateManager.shelvePR(options.prUrl);
31
+ stateManager.clearStatusOverride(options.prUrl);
32
+ stateManager.save();
33
+ return {
34
+ url: options.prUrl,
35
+ target,
36
+ description: 'Shelved — excluded from capacity and actionable items',
37
+ };
38
+ }
39
+ case 'auto': {
40
+ const clearedOverride = stateManager.clearStatusOverride(options.prUrl);
41
+ const unshelved = stateManager.unshelvePR(options.prUrl);
42
+ if (clearedOverride || unshelved) {
43
+ stateManager.save();
44
+ }
45
+ return {
46
+ url: options.prUrl,
47
+ target,
48
+ description: 'Reset to computed status',
49
+ };
50
+ }
51
+ default: {
52
+ const _exhaustive = target;
53
+ throw new Error(`Unhandled move target: ${_exhaustive}`);
54
+ }
55
+ }
56
+ }
@@ -2,6 +2,10 @@
2
2
  * Shelve/Unshelve commands
3
3
  * Manages shelving PRs to exclude them from capacity and actionable issues.
4
4
  * Shelved PRs are auto-unshelved when a maintainer engages.
5
+ *
6
+ * Note: The CLI and MCP shelve/unshelve commands delegate to runMove(),
7
+ * which also clears status overrides. These functions match that behavior
8
+ * to keep the library API consistent.
5
9
  */
6
10
  import { PR_URL_PATTERN } from './validation.js';
7
11
  export interface ShelveOutput {
@@ -2,6 +2,10 @@
2
2
  * Shelve/Unshelve commands
3
3
  * Manages shelving PRs to exclude them from capacity and actionable issues.
4
4
  * Shelved PRs are auto-unshelved when a maintainer engages.
5
+ *
6
+ * Note: The CLI and MCP shelve/unshelve commands delegate to runMove(),
7
+ * which also clears status overrides. These functions match that behavior
8
+ * to keep the library API consistent.
5
9
  */
6
10
  import { getStateManager } from '../core/index.js';
7
11
  import { PR_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
@@ -12,7 +16,8 @@ export async function runShelve(options) {
12
16
  validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
13
17
  const stateManager = getStateManager();
14
18
  const added = stateManager.shelvePR(options.prUrl);
15
- if (added) {
19
+ const clearedOverride = stateManager.clearStatusOverride(options.prUrl);
20
+ if (added || clearedOverride) {
16
21
  stateManager.save();
17
22
  }
18
23
  return { shelved: added, url: options.prUrl };
@@ -22,7 +27,8 @@ export async function runUnshelve(options) {
22
27
  validateGitHubUrl(options.prUrl, PR_URL_PATTERN, 'PR');
23
28
  const stateManager = getStateManager();
24
29
  const removed = stateManager.unshelvePR(options.prUrl);
25
- if (removed) {
30
+ const clearedOverride = stateManager.clearStatusOverride(options.prUrl);
31
+ if (removed || clearedOverride) {
26
32
  stateManager.save();
27
33
  }
28
34
  return { unshelved: removed, url: options.prUrl };
@@ -54,7 +54,7 @@ export declare function assessCapacity(activePRs: FetchedPR[], maxActivePRs: num
54
54
  * Note: Recently closed PRs are informational only and excluded from this list.
55
55
  * They are available separately in digest.recentlyClosedPRs (#156).
56
56
  */
57
- export declare function collectActionableIssues(prs: FetchedPR[], snoozedUrls?: Set<string>, lastDigestAt?: string): ActionableIssue[];
57
+ export declare function collectActionableIssues(prs: FetchedPR[], lastDigestAt?: string): ActionableIssue[];
58
58
  /**
59
59
  * Format a maintainer action hint as a human-readable label
60
60
  */
@@ -198,7 +198,7 @@ export function assessCapacity(activePRs, maxActivePRs, shelvedPRCount) {
198
198
  * Note: Recently closed PRs are informational only and excluded from this list.
199
199
  * They are available separately in digest.recentlyClosedPRs (#156).
200
200
  */
201
- export function collectActionableIssues(prs, snoozedUrls = new Set(), lastDigestAt) {
201
+ export function collectActionableIssues(prs, lastDigestAt) {
202
202
  const issues = [];
203
203
  const actionPRs = prs.filter((pr) => pr.status === 'needs_addressing');
204
204
  const lastDigestTime = lastDigestAt ? new Date(lastDigestAt).getTime() : NaN;
@@ -213,8 +213,6 @@ export function collectActionableIssues(prs, snoozedUrls = new Set(), lastDigest
213
213
  for (const pr of actionPRs) {
214
214
  if (pr.actionReason !== reason)
215
215
  continue;
216
- if (reason === 'failing_ci' && snoozedUrls.has(pr.url))
217
- continue;
218
216
  let label;
219
217
  let type;
220
218
  switch (reason) {
@@ -10,12 +10,14 @@
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 { FetchedPR, DailyDigest, ClosedPR, MergedPR, StarFilter } from './types.js';
15
16
  import { type PRCountsResult } from './github-stats.js';
16
17
  export { computeDisplayLabel } from './display-utils.js';
17
18
  export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
18
19
  export { isConditionalChecklistItem } from './checklist-analysis.js';
20
+ export { determineStatus } from './status-determination.js';
19
21
  export interface PRCheckFailure {
20
22
  prUrl: string;
21
23
  error: string;
@@ -42,33 +44,6 @@ export declare class PRMonitor {
42
44
  * Centralizes PR construction and display label computation (#79).
43
45
  */
44
46
  private buildFetchedPR;
45
- /**
46
- * Determine the overall status of a PR
47
- */
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;
72
47
  /**
73
48
  * Check if PR has merge conflict
74
49
  */
@@ -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
  */