@oss-autopilot/core 0.49.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.
Files changed (38) hide show
  1. package/dist/cli-registry.js +44 -98
  2. package/dist/cli.bundle.cjs +43 -45
  3. package/dist/cli.bundle.cjs.map +4 -4
  4. package/dist/commands/daily.d.ts +1 -1
  5. package/dist/commands/daily.js +5 -42
  6. package/dist/commands/dashboard-server.d.ts +1 -1
  7. package/dist/commands/dashboard-server.js +19 -29
  8. package/dist/commands/dismiss.d.ts +1 -1
  9. package/dist/commands/dismiss.js +4 -4
  10. package/dist/commands/index.d.ts +3 -5
  11. package/dist/commands/index.js +2 -4
  12. package/dist/commands/move.d.ts +16 -0
  13. package/dist/commands/move.js +56 -0
  14. package/dist/commands/setup.d.ts +3 -0
  15. package/dist/commands/setup.js +62 -0
  16. package/dist/commands/shelve.d.ts +4 -0
  17. package/dist/commands/shelve.js +8 -2
  18. package/dist/core/category-mapping.d.ts +19 -0
  19. package/dist/core/category-mapping.js +58 -0
  20. package/dist/core/daily-logic.d.ts +1 -1
  21. package/dist/core/daily-logic.js +8 -5
  22. package/dist/core/issue-discovery.js +55 -3
  23. package/dist/core/issue-scoring.d.ts +3 -0
  24. package/dist/core/issue-scoring.js +5 -0
  25. package/dist/core/issue-vetting.js +12 -0
  26. package/dist/core/pr-monitor.d.ts +2 -27
  27. package/dist/core/pr-monitor.js +4 -110
  28. package/dist/core/state.d.ts +8 -40
  29. package/dist/core/state.js +36 -93
  30. package/dist/core/status-determination.d.ts +35 -0
  31. package/dist/core/status-determination.js +112 -0
  32. package/dist/core/types.d.ts +18 -12
  33. package/dist/core/types.js +11 -1
  34. package/package.json +1 -1
  35. package/dist/commands/override.d.ts +0 -21
  36. package/dist/commands/override.js +0 -35
  37. package/dist/commands/snooze.d.ts +0 -24
  38. package/dist/commands/snooze.js +0 -40
@@ -18,6 +18,7 @@ import { getHttpCache, cachedTimeBased } from './http-cache.js';
18
18
  import { isDocOnlyIssue, detectLabelFarmingRepos, applyPerRepoCap } from './issue-filtering.js';
19
19
  import { IssueVetter } from './issue-vetting.js';
20
20
  import { calculateViabilityScore as calcViabilityScore } from './issue-scoring.js';
21
+ import { getTopicsForCategories } from './category-mapping.js';
21
22
  // Re-export everything from sub-modules for backward compatibility.
22
23
  // Existing consumers (tests, CLI commands) import from './issue-discovery.js'.
23
24
  export { isDocOnlyIssue, applyPerRepoCap, isLabelFarming, hasTemplatedTitle, detectLabelFarmingRepos, DOC_ONLY_LABELS, BEGINNER_LABELS, } from './issue-filtering.js';
@@ -282,6 +283,51 @@ export class IssueDiscovery {
282
283
  }
283
284
  }
284
285
  }
286
+ // Phase 0.5: Search preferred organizations (explicit user preference)
287
+ let phase0_5Error = null;
288
+ const preferredOrgs = config.preferredOrgs ?? [];
289
+ if (allCandidates.length < maxResults && preferredOrgs.length > 0) {
290
+ // Filter out orgs already covered by Phase 0 repos
291
+ const phase0Orgs = new Set(phase0Repos.map((r) => r.split('/')[0]?.toLowerCase()));
292
+ const orgsToSearch = preferredOrgs.filter((org) => !phase0Orgs.has(org.toLowerCase())).slice(0, 5);
293
+ if (orgsToSearch.length > 0) {
294
+ info(MODULE, `Phase 0.5: Searching issues in ${orgsToSearch.length} preferred org(s)...`);
295
+ const remainingNeeded = maxResults - allCandidates.length;
296
+ const orgRepoFilter = orgsToSearch.map((org) => `org:${org}`).join(' OR ');
297
+ const orgQuery = `${baseQuery} (${orgRepoFilter})`;
298
+ try {
299
+ const data = await this.cachedSearch({
300
+ q: orgQuery,
301
+ sort: 'created',
302
+ order: 'desc',
303
+ per_page: remainingNeeded * 3,
304
+ });
305
+ if (data.items.length > 0) {
306
+ const filtered = filterIssues(data.items).filter((item) => {
307
+ const repoFullName = item.repository_url.split('/').slice(-2).join('/');
308
+ return !phase0RepoSet.has(repoFullName);
309
+ });
310
+ const { candidates: orgCandidates, allFailed: allVetFailed, rateLimitHit, } = await this.vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, 'preferred_org');
311
+ allCandidates.push(...orgCandidates);
312
+ if (allVetFailed) {
313
+ phase0_5Error = 'All preferred org issue vetting failed';
314
+ }
315
+ if (rateLimitHit) {
316
+ rateLimitHitDuringSearch = true;
317
+ }
318
+ info(MODULE, `Found ${orgCandidates.length} candidates from preferred orgs`);
319
+ }
320
+ }
321
+ catch (error) {
322
+ const errMsg = errorMessage(error);
323
+ phase0_5Error = errMsg;
324
+ if (isRateLimitError(error)) {
325
+ rateLimitHitDuringSearch = true;
326
+ }
327
+ warn(MODULE, `Error searching preferred orgs: ${errMsg}`);
328
+ }
329
+ }
330
+ }
285
331
  // Phase 1: Search starred repos (filter out already-searched Phase 0 repos)
286
332
  if (allCandidates.length < maxResults && starredRepos.length > 0) {
287
333
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
@@ -345,7 +391,12 @@ export class IssueDiscovery {
345
391
  const thirtyDaysAgo = new Date();
346
392
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
347
393
  const pushedSince = thirtyDaysAgo.toISOString().split('T')[0];
348
- const phase3Query = `is:issue is:open no:assignee ${langQuery} stars:>=${minStars} pushed:>=${pushedSince} archived:false`
394
+ // When user has category preferences, add a single topic filter to focus on relevant repos.
395
+ // GitHub Search API AND-joins multiple topic: qualifiers, which is overly restrictive,
396
+ // so we pick just the first topic to nudge results without eliminating valid matches.
397
+ const categoryTopics = getTopicsForCategories(config.projectCategories ?? []);
398
+ const topicQuery = categoryTopics.length > 0 ? `topic:${categoryTopics[0]}` : '';
399
+ const phase3Query = `is:issue is:open no:assignee ${langQuery} ${topicQuery} stars:>=${minStars} pushed:>=${pushedSince} archived:false`
349
400
  .replace(/ +/g, ' ')
350
401
  .trim();
351
402
  try {
@@ -379,6 +430,7 @@ export class IssueDiscovery {
379
430
  if (allCandidates.length === 0) {
380
431
  const phaseErrors = [
381
432
  phase0Error ? `Phase 0 (merged-PR repos): ${phase0Error}` : null,
433
+ phase0_5Error ? `Phase 0.5 (preferred orgs): ${phase0_5Error}` : null,
382
434
  phase1Error ? `Phase 1 (starred repos): ${phase1Error}` : null,
383
435
  phase2Error ? `Phase 2 (general): ${phase2Error}` : null,
384
436
  phase3Error ? `Phase 3 (maintained repos): ${phase3Error}` : null,
@@ -406,8 +458,8 @@ export class IssueDiscovery {
406
458
  }
407
459
  // Sort by priority first, then by recommendation, then by viability score
408
460
  allCandidates.sort((a, b) => {
409
- // Priority order: merged_pr > starred > normal
410
- const priorityOrder = { merged_pr: 0, starred: 1, normal: 2 };
461
+ // Priority order: merged_pr > preferred_org > starred > normal
462
+ const priorityOrder = { merged_pr: 0, preferred_org: 1, starred: 2, normal: 3 };
411
463
  const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
412
464
  if (priorityDiff !== 0)
413
465
  return priorityDiff;
@@ -21,6 +21,8 @@ export interface ViabilityScoreParams {
21
21
  mergedPRCount: number;
22
22
  orgHasMergedPRs: boolean;
23
23
  repoQualityBonus?: number;
24
+ /** True when the repo matches one of the user's preferred project categories. */
25
+ matchesPreferredCategory?: boolean;
24
26
  }
25
27
  /**
26
28
  * Calculate viability score for an issue (0-100 scale)
@@ -33,6 +35,7 @@ export interface ViabilityScoreParams {
33
35
  * - +15 for freshness (recently updated)
34
36
  * - +10 for contribution guidelines
35
37
  * - +5 for org affinity (merged PRs in same org)
38
+ * - +5 for category preference (matches user's project categories)
36
39
  * - -30 if existing PR
37
40
  * - -20 if claimed
38
41
  * - -15 if closed-without-merge history with no merges
@@ -37,6 +37,7 @@ export function calculateRepoQualityBonus(stargazersCount, forksCount) {
37
37
  * - +15 for freshness (recently updated)
38
38
  * - +10 for contribution guidelines
39
39
  * - +5 for org affinity (merged PRs in same org)
40
+ * - +5 for category preference (matches user's project categories)
40
41
  * - -30 if existing PR
41
42
  * - -20 if claimed
42
43
  * - -15 if closed-without-merge history with no merges
@@ -75,6 +76,10 @@ export function calculateViabilityScore(params) {
75
76
  if (params.orgHasMergedPRs) {
76
77
  score += 5;
77
78
  }
79
+ // Category preference bonus (+5) — repo matches user's preferred project categories
80
+ if (params.matchesPreferredCategory) {
81
+ score += 5;
82
+ }
78
83
  // Penalty for existing PR (-30)
79
84
  if (params.hasExistingPR) {
80
85
  score -= 30;
@@ -10,6 +10,7 @@ import { ValidationError, errorMessage, isRateLimitError } from './errors.js';
10
10
  import { warn } from './logger.js';
11
11
  import { getHttpCache, cachedRequest, cachedTimeBased } from './http-cache.js';
12
12
  import { calculateRepoQualityBonus, calculateViabilityScore } from './issue-scoring.js';
13
+ import { repoBelongsToCategory } from './category-mapping.js';
13
14
  const MODULE = 'issue-vetting';
14
15
  const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
15
16
  // Cache for contribution guidelines (expires after 1 hour, max 100 entries)
@@ -173,6 +174,12 @@ export class IssueVetter {
173
174
  if (orgHasMergedPRs) {
174
175
  reasonsToApprove.push(`Org affinity (merged PRs in other ${orgName} repos)`);
175
176
  }
177
+ // Check for category preference match
178
+ const projectCategories = config.projectCategories ?? [];
179
+ const matchesCategory = repoBelongsToCategory(repoFullName, projectCategories);
180
+ if (matchesCategory) {
181
+ reasonsToApprove.push('Matches preferred project category');
182
+ }
176
183
  let recommendation;
177
184
  if (vettingResult.passedAllChecks) {
178
185
  recommendation = 'approve';
@@ -207,12 +214,17 @@ export class IssueVetter {
207
214
  mergedPRCount: effectiveMergedCount,
208
215
  orgHasMergedPRs,
209
216
  repoQualityBonus,
217
+ matchesPreferredCategory: matchesCategory,
210
218
  });
211
219
  const starredRepos = this.stateManager.getStarredRepos();
220
+ const preferredOrgs = config.preferredOrgs ?? [];
212
221
  let searchPriority = 'normal';
213
222
  if (effectiveMergedCount > 0) {
214
223
  searchPriority = 'merged_pr';
215
224
  }
225
+ else if (preferredOrgs.some((o) => o.toLowerCase() === orgName?.toLowerCase())) {
226
+ searchPriority = 'preferred_org';
227
+ }
216
228
  else if (starredRepos.includes(repoFullName)) {
217
229
  searchPriority = 'starred';
218
230
  }
@@ -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
  */
@@ -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),
@@ -240,57 +240,25 @@ export declare class StateManager {
240
240
  */
241
241
  isPRShelved(url: string): boolean;
242
242
  /**
243
- * Dismiss an issue or PR by URL. Dismissed URLs are excluded from `new_response` notifications
243
+ * Dismiss an issue by URL. Dismissed issues are excluded from `new_response` notifications
244
244
  * 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.
245
+ * @param url - The full GitHub issue URL.
246
+ * @param timestamp - ISO timestamp of when the issue was dismissed.
247
247
  * @returns true if newly dismissed, false if already dismissed.
248
248
  */
249
249
  dismissIssue(url: string, timestamp: string): boolean;
250
250
  /**
251
- * Undismiss an issue or PR by URL.
252
- * @param url - The full GitHub issue or PR URL.
251
+ * Undismiss an issue by URL.
252
+ * @param url - The full GitHub issue URL.
253
253
  * @returns true if found and removed, false if not dismissed.
254
254
  */
255
255
  undismissIssue(url: string): boolean;
256
256
  /**
257
- * Get the timestamp when an issue or PR was dismissed.
258
- * @param url - The full GitHub issue or PR URL.
257
+ * Get the timestamp when an issue was dismissed.
258
+ * @param url - The full GitHub issue URL.
259
259
  * @returns The ISO dismiss timestamp, or undefined if not dismissed.
260
260
  */
261
261
  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
262
  /**
295
263
  * Set a manual status override for a PR.
296
264
  * @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
@@ -196,7 +196,6 @@ export class StateManager {
196
196
  trustedProjects: [],
197
197
  shelvedPRUrls: [],
198
198
  dismissedIssues: {},
199
- snoozedPRs: {},
200
199
  },
201
200
  events: [],
202
201
  lastRunAt: new Date().toISOString(),
@@ -342,6 +341,34 @@ export class StateManager {
342
341
  atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
343
342
  debug(MODULE, 'Migrated state saved');
344
343
  }
344
+ // Strip legacy fields from persisted state (snoozedPRs and PR dismiss
345
+ // entries were removed in the three-state PR model simplification)
346
+ try {
347
+ let needsCleanupSave = false;
348
+ const rawConfig = state.config;
349
+ if (rawConfig.snoozedPRs) {
350
+ delete rawConfig.snoozedPRs;
351
+ needsCleanupSave = true;
352
+ }
353
+ // Strip PR URLs from dismissedIssues (PR dismiss removed)
354
+ if (state.config.dismissedIssues) {
355
+ const PR_URL_RE = /\/pull\/\d+$/;
356
+ for (const url of Object.keys(state.config.dismissedIssues)) {
357
+ if (PR_URL_RE.test(url)) {
358
+ delete state.config.dismissedIssues[url];
359
+ needsCleanupSave = true;
360
+ }
361
+ }
362
+ }
363
+ if (needsCleanupSave) {
364
+ atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
365
+ warn(MODULE, 'Cleaned up removed features (snoozedPRs, dismissed PR URLs) from persisted state');
366
+ }
367
+ }
368
+ catch (cleanupError) {
369
+ warn(MODULE, `Failed to clean up removed features from state: ${errorMessage(cleanupError)}`);
370
+ // Continue with loaded state — cleanup will be retried on next load
371
+ }
345
372
  // Log appropriate message based on version
346
373
  const repoCount = Object.keys(state.repoScores).length;
347
374
  debug(MODULE, `Loaded state v${state.version}: ${repoCount} repo scores tracked`);
@@ -803,10 +830,10 @@ export class StateManager {
803
830
  }
804
831
  // === Dismiss / Undismiss Issues ===
805
832
  /**
806
- * Dismiss an issue or PR by URL. Dismissed URLs are excluded from `new_response` notifications
833
+ * Dismiss an issue by URL. Dismissed issues are excluded from `new_response` notifications
807
834
  * 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.
835
+ * @param url - The full GitHub issue URL.
836
+ * @param timestamp - ISO timestamp of when the issue was dismissed.
810
837
  * @returns true if newly dismissed, false if already dismissed.
811
838
  */
812
839
  dismissIssue(url, timestamp) {
@@ -820,8 +847,8 @@ export class StateManager {
820
847
  return true;
821
848
  }
822
849
  /**
823
- * Undismiss an issue or PR by URL.
824
- * @param url - The full GitHub issue or PR URL.
850
+ * Undismiss an issue by URL.
851
+ * @param url - The full GitHub issue URL.
825
852
  * @returns true if found and removed, false if not dismissed.
826
853
  */
827
854
  undismissIssue(url) {
@@ -832,97 +859,13 @@ export class StateManager {
832
859
  return true;
833
860
  }
834
861
  /**
835
- * Get the timestamp when an issue or PR was dismissed.
836
- * @param url - The full GitHub issue or PR URL.
862
+ * Get the timestamp when an issue was dismissed.
863
+ * @param url - The full GitHub issue URL.
837
864
  * @returns The ISO dismiss timestamp, or undefined if not dismissed.
838
865
  */
839
866
  getIssueDismissedAt(url) {
840
867
  return this.state.config.dismissedIssues?.[url];
841
868
  }
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
869
  // === Status Overrides ===
927
870
  /**
928
871
  * 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;