@oss-autopilot/core 0.44.2 → 0.44.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/cli-registry.js +61 -0
  2. package/dist/cli.bundle.cjs +101 -127
  3. package/dist/cli.bundle.cjs.map +4 -4
  4. package/dist/commands/daily.d.ts +6 -1
  5. package/dist/commands/daily.js +29 -64
  6. package/dist/commands/dashboard-data.d.ts +22 -1
  7. package/dist/commands/dashboard-data.js +85 -62
  8. package/dist/commands/dashboard-lifecycle.js +39 -2
  9. package/dist/commands/dashboard-scripts.d.ts +1 -1
  10. package/dist/commands/dashboard-scripts.js +2 -1
  11. package/dist/commands/dashboard-server.d.ts +2 -1
  12. package/dist/commands/dashboard-server.js +120 -81
  13. package/dist/commands/dashboard-templates.js +15 -69
  14. package/dist/commands/override.d.ts +21 -0
  15. package/dist/commands/override.js +35 -0
  16. package/dist/core/checklist-analysis.js +3 -1
  17. package/dist/core/daily-logic.d.ts +13 -10
  18. package/dist/core/daily-logic.js +79 -166
  19. package/dist/core/display-utils.d.ts +4 -0
  20. package/dist/core/display-utils.js +53 -54
  21. package/dist/core/errors.d.ts +8 -0
  22. package/dist/core/errors.js +26 -0
  23. package/dist/core/github-stats.d.ts +3 -3
  24. package/dist/core/github-stats.js +15 -7
  25. package/dist/core/index.d.ts +2 -2
  26. package/dist/core/index.js +2 -2
  27. package/dist/core/issue-conversation.js +2 -2
  28. package/dist/core/issue-discovery.d.ts +0 -5
  29. package/dist/core/issue-discovery.js +4 -11
  30. package/dist/core/issue-vetting.d.ts +0 -2
  31. package/dist/core/issue-vetting.js +31 -45
  32. package/dist/core/pr-monitor.d.ts +26 -3
  33. package/dist/core/pr-monitor.js +106 -93
  34. package/dist/core/state.d.ts +22 -1
  35. package/dist/core/state.js +50 -1
  36. package/dist/core/test-utils.js +6 -16
  37. package/dist/core/types.d.ts +51 -38
  38. package/dist/core/types.js +8 -0
  39. package/dist/core/utils.d.ts +2 -0
  40. package/dist/core/utils.js +5 -1
  41. package/dist/formatters/json.d.ts +1 -13
  42. package/dist/formatters/json.js +1 -13
  43. package/package.json +2 -2
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { getOctokit } from './github.js';
15
15
  import { getStateManager } from './state.js';
16
- import { daysBetween, parseGitHubUrl, extractOwnerRepo } from './utils.js';
16
+ import { daysBetween, parseGitHubUrl, extractOwnerRepo, DEFAULT_CONCURRENCY } from './utils.js';
17
17
  import { runWorkerPool } from './concurrency.js';
18
18
  import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
19
19
  import { paginateAll } from './pagination.js';
@@ -31,8 +31,7 @@ export { computeDisplayLabel } from './display-utils.js';
31
31
  export { classifyCICheck, classifyFailingChecks } from './ci-analysis.js';
32
32
  export { isConditionalChecklistItem } from './checklist-analysis.js';
33
33
  const MODULE = 'pr-monitor';
34
- // Concurrency limit for parallel API calls
35
- const MAX_CONCURRENT_REQUESTS = 5;
34
+ const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
36
35
  export class PRMonitor {
37
36
  octokit;
38
37
  stateManager;
@@ -120,27 +119,11 @@ export class PRMonitor {
120
119
  }
121
120
  }, MAX_CONCURRENT_REQUESTS);
122
121
  });
123
- // Sort by days since activity (most urgent first)
122
+ // Sort by status (needs_addressing first, then waiting_on_maintainer)
124
123
  prs.sort((a, b) => {
125
- // Priority: needs_response > failing_ci > merge_conflict > approaching_dormant > dormant > waiting > healthy
126
- const statusPriority = {
127
- needs_response: 0,
128
- needs_changes: 1,
129
- failing_ci: 2,
130
- ci_blocked: 3,
131
- ci_not_running: 4,
132
- merge_conflict: 5,
133
- needs_rebase: 6,
134
- missing_required_files: 7,
135
- incomplete_checklist: 8,
136
- changes_addressed: 9,
137
- approaching_dormant: 10,
138
- dormant: 11,
139
- waiting: 12,
140
- waiting_on_maintainer: 13,
141
- healthy: 14,
142
- };
143
- return statusPriority[a.status] - statusPriority[b.status];
124
+ if (a.status === b.status)
125
+ return 0;
126
+ return a.status === 'needs_addressing' ? -1 : 1;
144
127
  });
145
128
  return { prs, failures };
146
129
  }
@@ -196,14 +179,18 @@ export class PRMonitor {
196
179
  const { hasUnrespondedComment, lastMaintainerComment } = checkUnrespondedComments(comments, reviews, reviewComments, config.githubUsername);
197
180
  // Fetch CI status and (conditionally) latest commit date in parallel
198
181
  // We need the commit date when hasUnrespondedComment is true (to distinguish
199
- // "needs_response" from "changes_addressed") OR when reviewDecision is "changes_requested"
182
+ // "needs_response" from "waiting_on_maintainer") OR when reviewDecision is "changes_requested"
200
183
  // (to detect needs_changes: review requested changes but no new commits pushed)
201
184
  const ciPromise = this.getCIStatus(owner, repo, ghPR.head.sha);
202
185
  const needCommitDate = hasUnrespondedComment || reviewDecision === 'changes_requested';
203
- const commitDatePromise = needCommitDate
186
+ const commitInfoPromise = needCommitDate
204
187
  ? this.octokit.repos
205
188
  .getCommit({ owner, repo, ref: ghPR.head.sha })
206
- .then((res) => res.data.commit.author?.date)
189
+ .then((res) => ({
190
+ date: res.data.commit.author?.date,
191
+ // GitHub user login of the commit author (may differ from git author)
192
+ author: res.data.author?.login,
193
+ }))
207
194
  .catch((err) => {
208
195
  // Rate limit errors must propagate — silently swallowing them produces
209
196
  // misleading status (e.g. needs_changes when changes were addressed) (#469).
@@ -222,10 +209,12 @@ export class PRMonitor {
222
209
  return undefined;
223
210
  })
224
211
  : Promise.resolve(undefined);
225
- const [{ status: ciStatus, failingCheckNames, failingCheckConclusions }, latestCommitDate] = await Promise.all([
212
+ const [{ status: ciStatus, failingCheckNames, failingCheckConclusions }, commitInfo] = await Promise.all([
226
213
  ciPromise,
227
- commitDatePromise,
214
+ commitInfoPromise,
228
215
  ]);
216
+ const latestCommitDate = commitInfo?.date;
217
+ const latestCommitAuthor = commitInfo?.author;
229
218
  // Analyze PR body for incomplete checklists (delegated to checklist-analysis module)
230
219
  const { hasIncompleteChecklist, checklistStats } = analyzeChecklist(ghPR.body || '');
231
220
  // Extract maintainer action hints from comments (delegated to maintainer-analysis module)
@@ -238,7 +227,7 @@ export class PRMonitor {
238
227
  const classifiedChecks = classifyFailingChecks(failingCheckNames, failingCheckConclusions);
239
228
  // Determine status
240
229
  const hasActionableCIFailure = ciStatus === 'failing' && classifiedChecks.some((c) => c.category === 'actionable');
241
- const status = this.determineStatus({
230
+ const { status, actionReason, waitReason, stalenessTier } = this.determineStatus({
242
231
  ciStatus,
243
232
  hasMergeConflict,
244
233
  hasUnrespondedComment,
@@ -248,6 +237,8 @@ export class PRMonitor {
248
237
  dormantThreshold: config.dormantThresholdDays,
249
238
  approachingThreshold: config.approachingDormantDays,
250
239
  latestCommitDate,
240
+ latestCommitAuthor,
241
+ contributorUsername: config.githubUsername,
251
242
  lastMaintainerCommentDate: lastMaintainerComment?.createdAt,
252
243
  latestChangesRequestedDate,
253
244
  hasActionableCIFailure,
@@ -259,6 +250,9 @@ export class PRMonitor {
259
250
  number,
260
251
  title: ghPR.title,
261
252
  status,
253
+ actionReason,
254
+ waitReason,
255
+ stalenessTier,
262
256
  createdAt: ghPR.created_at,
263
257
  updatedAt: ghPR.updated_at,
264
258
  daysSinceActivity,
@@ -295,61 +289,110 @@ export class PRMonitor {
295
289
  * Determine the overall status of a PR
296
290
  */
297
291
  determineStatus(input) {
298
- const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
299
- // Priority order: needs_response/needs_changes/changes_addressed > failing_ci > merge_conflict > incomplete_checklist > dormant > approaching_dormant > waiting_on_maintainer > waiting/healthy
292
+ const { ciStatus, hasMergeConflict, hasUnrespondedComment, hasIncompleteChecklist, reviewDecision, daysSinceActivity, dormantThreshold, approachingThreshold, latestCommitDate: rawCommitDate, latestCommitAuthor, contributorUsername, lastMaintainerCommentDate, latestChangesRequestedDate, hasActionableCIFailure = true, } = input;
293
+ // Compute staleness tier (independent of status)
294
+ let stalenessTier = 'active';
295
+ if (daysSinceActivity >= dormantThreshold)
296
+ stalenessTier = 'dormant';
297
+ else if (daysSinceActivity >= approachingThreshold)
298
+ stalenessTier = 'approaching_dormant';
299
+ // Only count the latest commit if it was authored by the contributor or a
300
+ // CI bot (#547, #568). Non-contributor commits (maintainer merge commits,
301
+ // GitHub suggestion commits) should not mask unaddressed feedback.
302
+ const latestCommitDate = rawCommitDate && this.isContributorCommit(latestCommitAuthor, contributorUsername) ? rawCommitDate : undefined;
303
+ // Priority order: needs_addressing (response/changes/ci/conflict/checklist) > waiting_on_maintainer (review/merge/addressed/ci_blocked)
300
304
  if (hasUnrespondedComment) {
301
305
  // If the contributor pushed a commit after the maintainer's comment,
302
- // the changes have been addressed — waiting for maintainer re-review
303
- if (latestCommitDate && lastMaintainerCommentDate && latestCommitDate > lastMaintainerCommentDate) {
306
+ // the changes have been addressed — waiting for maintainer re-review.
307
+ // Require a minimum 2-minute gap to avoid false positives from race
308
+ // conditions (pushing while review is being submitted) (#547).
309
+ if (latestCommitDate &&
310
+ lastMaintainerCommentDate &&
311
+ this.isCommitAfterComment(latestCommitDate, lastMaintainerCommentDate)) {
304
312
  // Safety net (#431): if a CHANGES_REQUESTED review was submitted after
305
313
  // the commit, the maintainer still expects changes — don't mask it
306
314
  if (latestChangesRequestedDate && latestCommitDate < latestChangesRequestedDate) {
307
- return 'needs_response';
315
+ return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
308
316
  }
309
317
  if (ciStatus === 'failing' && hasActionableCIFailure)
310
- return 'failing_ci';
318
+ return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
311
319
  // Non-actionable CI failures (infrastructure, fork, auth) don't block changes_addressed —
312
320
  // the contributor can't fix them, so the relevant status is "waiting for re-review" (#502)
313
- return 'changes_addressed';
321
+ return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
314
322
  }
315
- return 'needs_response';
323
+ return { status: 'needs_addressing', actionReason: 'needs_response', stalenessTier };
316
324
  }
317
325
  // Review requested changes but no unresponded comment.
318
326
  // If the latest commit is before the review, the contributor hasn't addressed it yet.
319
327
  if (reviewDecision === 'changes_requested' && latestChangesRequestedDate) {
320
328
  if (!latestCommitDate || latestCommitDate < latestChangesRequestedDate) {
321
- return 'needs_changes';
329
+ return { status: 'needs_addressing', actionReason: 'needs_changes', stalenessTier };
322
330
  }
323
331
  // Commit is after review — changes have been addressed
324
332
  if (ciStatus === 'failing' && hasActionableCIFailure)
325
- return 'failing_ci';
333
+ return { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier };
326
334
  // Non-actionable CI failures don't block changes_addressed (#502)
327
- return 'changes_addressed';
335
+ return { status: 'waiting_on_maintainer', waitReason: 'changes_addressed', stalenessTier };
328
336
  }
329
337
  if (ciStatus === 'failing') {
330
- return hasActionableCIFailure ? 'failing_ci' : 'ci_blocked';
338
+ return hasActionableCIFailure
339
+ ? { status: 'needs_addressing', actionReason: 'failing_ci', stalenessTier }
340
+ : { status: 'waiting_on_maintainer', waitReason: 'ci_blocked', stalenessTier };
331
341
  }
332
342
  if (hasMergeConflict) {
333
- return 'merge_conflict';
343
+ return { status: 'needs_addressing', actionReason: 'merge_conflict', stalenessTier };
334
344
  }
335
345
  if (hasIncompleteChecklist) {
336
- return 'incomplete_checklist';
337
- }
338
- if (daysSinceActivity >= dormantThreshold) {
339
- return 'dormant';
340
- }
341
- if (daysSinceActivity >= approachingThreshold) {
342
- return 'approaching_dormant';
346
+ return { status: 'needs_addressing', actionReason: 'incomplete_checklist', stalenessTier };
343
347
  }
344
348
  // Approved and CI passing/unknown = waiting on maintainer to merge
345
349
  if (reviewDecision === 'approved' && (ciStatus === 'passing' || ciStatus === 'unknown')) {
346
- return 'waiting_on_maintainer';
350
+ return { status: 'waiting_on_maintainer', waitReason: 'pending_merge', stalenessTier };
347
351
  }
348
- // CI pending means we're waiting
349
- if (ciStatus === 'pending') {
350
- return 'waiting';
352
+ // Default: no actionable issues found. Covers pending CI, no reviews yet, etc.
353
+ return { status: 'waiting_on_maintainer', waitReason: 'pending_review', stalenessTier };
354
+ }
355
+ /**
356
+ * CI-fix bots that push commits as a direct result of the contributor's push (#568).
357
+ * Their commits represent contributor work and should count as addressing feedback.
358
+ * This is intentionally an allowlist — not all `[bot]` accounts are CI-fix bots
359
+ * (e.g. dependabot[bot] and renovate[bot] open their own PRs).
360
+ * Values must be lowercase — lookup uses .toLowerCase() for case-insensitive matching.
361
+ */
362
+ static CI_FIX_BOTS = new Set([
363
+ 'autofix-ci[bot]',
364
+ 'prettier-ci[bot]',
365
+ 'pre-commit-ci[bot]',
366
+ ]);
367
+ /**
368
+ * Check whether the HEAD commit was authored by the contributor (#547).
369
+ * Returns true when the author matches, when the author is a known CI-fix
370
+ * bot (#568), or when author info is unavailable (graceful degradation).
371
+ */
372
+ isContributorCommit(commitAuthor, contributorUsername) {
373
+ if (!commitAuthor || !contributorUsername)
374
+ return true; // degrade gracefully
375
+ const author = commitAuthor.toLowerCase();
376
+ if (PRMonitor.CI_FIX_BOTS.has(author))
377
+ return true; // CI-fix bots act on behalf of the contributor (#568)
378
+ return author === contributorUsername.toLowerCase();
379
+ }
380
+ /** Minimum gap (ms) between maintainer comment and contributor commit for
381
+ * the commit to count as "addressing" the feedback (#547). Prevents false
382
+ * positives from race conditions, clock skew, and in-flight pushes. */
383
+ static MIN_RESPONSE_GAP_MS = 2 * 60 * 1000; // 2 minutes
384
+ /**
385
+ * Check whether the contributor's commit is meaningfully after the maintainer's
386
+ * comment — i.e. the commit timestamp is at least MIN_RESPONSE_GAP_MS later (#547).
387
+ */
388
+ isCommitAfterComment(commitDate, commentDate) {
389
+ const commitMs = new Date(commitDate).getTime();
390
+ const commentMs = new Date(commentDate).getTime();
391
+ if (Number.isNaN(commitMs) || Number.isNaN(commentMs)) {
392
+ // Fall back to simple string comparison (pre-#547 behavior)
393
+ return commitDate > commentDate;
351
394
  }
352
- return 'healthy';
395
+ return commitMs - commentMs >= PRMonitor.MIN_RESPONSE_GAP_MS;
353
396
  }
354
397
  /**
355
398
  * Check if PR has merge conflict
@@ -427,17 +470,17 @@ export class PRMonitor {
427
470
  * Fetch merged PR counts and latest merge dates per repository for the configured user.
428
471
  * Delegates to github-stats module.
429
472
  */
430
- async fetchUserMergedPRCounts() {
473
+ async fetchUserMergedPRCounts(starFilter) {
431
474
  const config = this.stateManager.getState().config;
432
- return fetchUserMergedPRCountsImpl(this.octokit, config.githubUsername);
475
+ return fetchUserMergedPRCountsImpl(this.octokit, config.githubUsername, starFilter);
433
476
  }
434
477
  /**
435
478
  * Fetch closed-without-merge PR counts per repository for the configured user.
436
479
  * Delegates to github-stats module.
437
480
  */
438
- async fetchUserClosedPRCounts() {
481
+ async fetchUserClosedPRCounts(starFilter) {
439
482
  const config = this.stateManager.getState().config;
440
- return fetchUserClosedPRCountsImpl(this.octokit, config.githubUsername);
483
+ return fetchUserClosedPRCountsImpl(this.octokit, config.githubUsername, starFilter);
441
484
  }
442
485
  /**
443
486
  * Fetch GitHub star counts for a list of repositories.
@@ -482,7 +525,7 @@ export class PRMonitor {
482
525
  }
483
526
  // If entire chunk failed, likely a systemic issue (rate limit, auth, outage) — abort remaining
484
527
  if (chunkFailures === chunk.length && chunk.length > 0) {
485
- const remaining = repos.length - i - chunkSize;
528
+ const remaining = uniqueRepos.length - i - chunkSize;
486
529
  if (remaining > 0) {
487
530
  warn(MODULE, `Entire chunk failed, aborting remaining ${remaining} repos`);
488
531
  }
@@ -514,52 +557,22 @@ export class PRMonitor {
514
557
  generateDigest(prs, recentlyClosedPRs = [], recentlyMergedPRs = []) {
515
558
  const now = new Date().toISOString();
516
559
  // Categorize PRs
517
- const prsNeedingResponse = prs.filter((pr) => pr.status === 'needs_response');
518
- const ciFailingPRs = prs.filter((pr) => pr.status === 'failing_ci');
519
- const mergeConflictPRs = prs.filter((pr) => pr.status === 'merge_conflict');
520
- const approachingDormant = prs.filter((pr) => pr.status === 'approaching_dormant');
521
- const dormantPRs = prs.filter((pr) => pr.status === 'dormant');
522
- const healthyPRs = prs.filter((pr) => pr.status === 'healthy' || pr.status === 'waiting');
560
+ const needsAddressingPRs = prs.filter((pr) => pr.status === 'needs_addressing');
561
+ const waitingOnMaintainerPRs = prs.filter((pr) => pr.status === 'waiting_on_maintainer');
523
562
  // Get stats from state manager (historical data from repo scores)
524
563
  const stats = this.stateManager.getStats();
525
- const ciBlockedPRs = prs.filter((pr) => pr.status === 'ci_blocked');
526
- const ciNotRunningPRs = prs.filter((pr) => pr.status === 'ci_not_running');
527
- const needsRebasePRs = prs.filter((pr) => pr.status === 'needs_rebase');
528
- const missingRequiredFilesPRs = prs.filter((pr) => pr.status === 'missing_required_files');
529
- const incompleteChecklistPRs = prs.filter((pr) => pr.status === 'incomplete_checklist');
530
- const needsChangesPRs = prs.filter((pr) => pr.status === 'needs_changes');
531
- const changesAddressedPRs = prs.filter((pr) => pr.status === 'changes_addressed');
532
- const waitingOnMaintainerPRs = prs.filter((pr) => pr.status === 'waiting_on_maintainer');
533
564
  return {
534
565
  generatedAt: now,
535
566
  openPRs: prs,
536
- prsNeedingResponse,
537
- ciFailingPRs,
538
- ciBlockedPRs,
539
- ciNotRunningPRs,
540
- mergeConflictPRs,
541
- needsRebasePRs,
542
- missingRequiredFilesPRs,
543
- incompleteChecklistPRs,
544
- needsChangesPRs,
545
- changesAddressedPRs,
567
+ needsAddressingPRs,
546
568
  waitingOnMaintainerPRs,
547
- approachingDormant,
548
- dormantPRs,
549
- healthyPRs,
550
569
  recentlyClosedPRs,
551
570
  recentlyMergedPRs,
552
571
  shelvedPRs: [],
553
572
  autoUnshelvedPRs: [],
554
573
  summary: {
555
574
  totalActivePRs: prs.length,
556
- totalNeedingAttention: prsNeedingResponse.length +
557
- needsChangesPRs.length +
558
- ciFailingPRs.length +
559
- mergeConflictPRs.length +
560
- needsRebasePRs.length +
561
- missingRequiredFilesPRs.length +
562
- incompleteChecklistPRs.length,
575
+ totalNeedingAttention: needsAddressingPRs.length,
563
576
  totalMergedAllTime: stats.mergedPRs,
564
577
  mergeRate: parseFloat(stats.mergeRate),
565
578
  },
@@ -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 } from './types.js';
5
+ import { AgentState, TrackedIssue, RepoScore, RepoScoreUpdate, StateEvent, StateEventType, DailyDigest, LocalRepoCache, SnoozeInfo, StatusOverride, FetchedPRStatus } 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),
@@ -257,6 +257,27 @@ export declare class StateManager {
257
257
  * @returns Array of PR URLs whose snoozes were expired.
258
258
  */
259
259
  expireSnoozes(): string[];
260
+ /**
261
+ * Set a manual status override for a PR.
262
+ * @param url - The full GitHub PR URL.
263
+ * @param status - The target status to override to.
264
+ * @param lastActivityAt - The PR's current updatedAt timestamp (for auto-clear detection).
265
+ */
266
+ setStatusOverride(url: string, status: FetchedPRStatus, lastActivityAt: string): void;
267
+ /**
268
+ * Clear a status override for a PR.
269
+ * @param url - The full GitHub PR URL.
270
+ * @returns true if found and removed, false if no override existed.
271
+ */
272
+ clearStatusOverride(url: string): boolean;
273
+ /**
274
+ * Get the status override for a PR, if one exists and hasn't been auto-cleared.
275
+ * @param url - The full GitHub PR URL.
276
+ * @param currentUpdatedAt - The PR's current updatedAt from GitHub. If newer than
277
+ * the stored lastActivityAt, the override is stale and auto-cleared.
278
+ * @returns The override metadata, or undefined if none exists or it was auto-cleared.
279
+ */
280
+ getStatusOverride(url: string, currentUpdatedAt?: string): StatusOverride | undefined;
260
281
  /**
261
282
  * Get the score record for a repository.
262
283
  * @param repo - Repository in "owner/repo" format.
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
- import { INITIAL_STATE, } from './types.js';
7
+ import { INITIAL_STATE, isBelowMinStars, } from './types.js';
8
8
  import { getStatePath, getBackupDir, getDataDir } from './utils.js';
9
9
  import { ValidationError, errorMessage } from './errors.js';
10
10
  import { debug, warn } from './logger.js';
@@ -843,6 +843,53 @@ export class StateManager {
843
843
  }
844
844
  return expired;
845
845
  }
846
+ // === Status Overrides ===
847
+ /**
848
+ * Set a manual status override for a PR.
849
+ * @param url - The full GitHub PR URL.
850
+ * @param status - The target status to override to.
851
+ * @param lastActivityAt - The PR's current updatedAt timestamp (for auto-clear detection).
852
+ */
853
+ setStatusOverride(url, status, lastActivityAt) {
854
+ if (!this.state.config.statusOverrides) {
855
+ this.state.config.statusOverrides = {};
856
+ }
857
+ this.state.config.statusOverrides[url] = {
858
+ status,
859
+ setAt: new Date().toISOString(),
860
+ lastActivityAt,
861
+ };
862
+ }
863
+ /**
864
+ * Clear a status override for a PR.
865
+ * @param url - The full GitHub PR URL.
866
+ * @returns true if found and removed, false if no override existed.
867
+ */
868
+ clearStatusOverride(url) {
869
+ if (!this.state.config.statusOverrides || !(url in this.state.config.statusOverrides)) {
870
+ return false;
871
+ }
872
+ delete this.state.config.statusOverrides[url];
873
+ return true;
874
+ }
875
+ /**
876
+ * Get the status override for a PR, if one exists and hasn't been auto-cleared.
877
+ * @param url - The full GitHub PR URL.
878
+ * @param currentUpdatedAt - The PR's current updatedAt from GitHub. If newer than
879
+ * the stored lastActivityAt, the override is stale and auto-cleared.
880
+ * @returns The override metadata, or undefined if none exists or it was auto-cleared.
881
+ */
882
+ getStatusOverride(url, currentUpdatedAt) {
883
+ const override = this.state.config.statusOverrides?.[url];
884
+ if (!override)
885
+ return undefined;
886
+ // Auto-clear if the PR has new activity since the override was set
887
+ if (currentUpdatedAt && currentUpdatedAt > override.lastActivityAt) {
888
+ this.clearStatusOverride(url);
889
+ return undefined;
890
+ }
891
+ return override;
892
+ }
846
893
  // === Repository Scoring ===
847
894
  /**
848
895
  * Get the score record for a repository.
@@ -1060,6 +1107,8 @@ export class StateManager {
1060
1107
  for (const [repoKey, score] of Object.entries(this.state.repoScores)) {
1061
1108
  if (this.isExcluded(repoKey))
1062
1109
  continue;
1110
+ if (isBelowMinStars(score.stargazersCount, this.state.config.minStars ?? 50))
1111
+ continue;
1063
1112
  totalTracked++;
1064
1113
  totalMerged += score.mergedPRCount;
1065
1114
  totalClosed += score.closedWithoutMergeCount;
@@ -17,9 +17,11 @@ export function makeFetchedPR(overrides = {}) {
17
17
  repo,
18
18
  number,
19
19
  title: 'Test PR',
20
- status: 'healthy',
21
- displayLabel: '[Healthy]',
22
- displayDescription: 'Everything looks good',
20
+ status: 'waiting_on_maintainer',
21
+ waitReason: 'pending_review',
22
+ stalenessTier: 'active',
23
+ displayLabel: '[Waiting on Maintainer]',
24
+ displayDescription: 'Awaiting review',
23
25
  createdAt: '2025-06-01T00:00:00Z',
24
26
  updatedAt: '2025-06-15T00:00:00Z',
25
27
  daysSinceActivity: 2,
@@ -41,20 +43,8 @@ export function makeDailyDigest(overrides = {}) {
41
43
  return {
42
44
  generatedAt: '2025-06-20T00:00:00Z',
43
45
  openPRs: [],
44
- prsNeedingResponse: [],
45
- ciFailingPRs: [],
46
- ciBlockedPRs: [],
47
- ciNotRunningPRs: [],
48
- mergeConflictPRs: [],
49
- needsRebasePRs: [],
50
- missingRequiredFilesPRs: [],
51
- incompleteChecklistPRs: [],
52
- needsChangesPRs: [],
53
- changesAddressedPRs: [],
46
+ needsAddressingPRs: [],
54
47
  waitingOnMaintainerPRs: [],
55
- approachingDormant: [],
56
- dormantPRs: [],
57
- healthyPRs: [],
58
48
  recentlyClosedPRs: [],
59
49
  recentlyMergedPRs: [],
60
50
  shelvedPRs: [],
@@ -52,37 +52,35 @@ export interface DetermineStatusInput {
52
52
  dormantThreshold: number;
53
53
  approachingThreshold: number;
54
54
  latestCommitDate?: string;
55
+ /** GitHub login of the HEAD commit's author (from `repos.getCommit`). */
56
+ latestCommitAuthor?: string;
57
+ /** GitHub login of the PR contributor (configured username). */
58
+ contributorUsername?: string;
55
59
  lastMaintainerCommentDate?: string;
56
60
  latestChangesRequestedDate?: string;
57
61
  /** True if at least one failing CI check is classified as 'actionable'. */
58
62
  hasActionableCIFailure?: boolean;
59
63
  }
60
64
  /**
61
- * Computed status for a {@link FetchedPR}, determined by `PRMonitor.determineStatus()`.
62
- * Statuses are checked in priority order — the first match wins.
63
- *
64
- * **Action required (contributor must act):**
65
- * - `needs_response` — Maintainer commented after the contributor's last activity
66
- * - `needs_changes` — Reviewer requested changes (via review, not just a comment)
67
- * - `failing_ci` One or more CI checks are failing (at least one is actionable)
68
- * - `ci_not_running` No CI checks have been triggered *(reserved)*
69
- * - `merge_conflict` PR has merge conflicts with the base branch
70
- * - `needs_rebase` PR branch is significantly behind upstream *(reserved)*
71
- * - `missing_required_files` Required files like changesets or CLA are missing *(reserved)*
72
- * - `incomplete_checklist` — PR body has unchecked required checkboxes
73
- *
74
- * **Waiting (no action needed right now):**
75
- * - `ci_blocked` — All failing CI checks are non-actionable (infrastructure, fork limitation, auth gate)
76
- * - `changes_addressed` — Contributor pushed commits after reviewer feedback; awaiting re-review
77
- * - `waiting` — CI is pending or no specific action needed
78
- * - `waiting_on_maintainer` — PR is approved and CI passes; waiting for maintainer to merge
79
- * - `healthy` — Everything looks good; normal review cycle
65
+ * Granular reason why a PR needs addressing (contributor's turn).
66
+ * Active values (produced by determineStatus): needs_response, needs_changes,
67
+ * failing_ci, merge_conflict, incomplete_checklist.
68
+ * Reserved (display mappings exist but detection not yet wired): ci_not_running,
69
+ * needs_rebase, missing_required_files.
70
+ */
71
+ export type ActionReason = 'needs_response' | 'needs_changes' | 'failing_ci' | 'merge_conflict' | 'incomplete_checklist' | 'ci_not_running' | 'needs_rebase' | 'missing_required_files';
72
+ /** Granular reason why a PR is waiting on the maintainer. */
73
+ export type WaitReason = 'pending_review' | 'pending_merge' | 'changes_addressed' | 'ci_blocked';
74
+ /** How stale is the PR based on days since activity. Orthogonal to status. */
75
+ export type StalenessTier = 'active' | 'approaching_dormant' | 'dormant';
76
+ /**
77
+ * Top-level classification of a PR's state. Only two values:
78
+ * - `needs_addressing` Contributor's turn. See `actionReason` for what to do.
79
+ * - `waiting_on_maintainer` — Maintainer's turn. See `waitReason` for why.
80
80
  *
81
- * **Staleness warnings:**
82
- * - `approaching_dormant` — No activity for `approachingDormantDays` (default 25)
83
- * - `dormant` — No activity for `dormantThresholdDays` (default 30)
81
+ * Staleness (active/approaching_dormant/dormant) is tracked separately in `stalenessTier`.
84
82
  */
85
- export type FetchedPRStatus = 'needs_response' | 'failing_ci' | 'ci_blocked' | 'ci_not_running' | 'merge_conflict' | 'needs_rebase' | 'missing_required_files' | 'incomplete_checklist' | 'needs_changes' | 'changes_addressed' | 'waiting' | 'waiting_on_maintainer' | 'healthy' | 'approaching_dormant' | 'dormant';
83
+ export type FetchedPRStatus = 'needs_addressing' | 'waiting_on_maintainer';
86
84
  /**
87
85
  * Hints about what a maintainer is asking for in their review comments.
88
86
  * Extracted from comment text by keyword matching.
@@ -101,6 +99,12 @@ export interface FetchedPR {
101
99
  title: string;
102
100
  /** Computed by `PRMonitor.determineStatus()` based on the fields below. */
103
101
  status: FetchedPRStatus;
102
+ /** Granular reason for needs_addressing status. Undefined when waiting_on_maintainer. */
103
+ actionReason?: ActionReason;
104
+ /** Granular reason for waiting_on_maintainer status. Undefined when needs_addressing. */
105
+ waitReason?: WaitReason;
106
+ /** How stale the PR is based on activity age. Independent of status — a PR can be both needs_addressing and dormant. */
107
+ stalenessTier: StalenessTier;
104
108
  /** Human-readable status label for consistent display (#79). E.g., "[CI Failing]", "[Needs Response]". */
105
109
  displayLabel: string;
106
110
  /** Brief description of what's happening (#79). E.g., "3 checks failed", "@maintainer commented". */
@@ -321,21 +325,10 @@ export interface DailyDigest {
321
325
  generatedAt: string;
322
326
  /** All open PRs authored by the user, fetched from GitHub Search API. */
323
327
  openPRs: FetchedPR[];
324
- prsNeedingResponse: FetchedPR[];
325
- ciFailingPRs: FetchedPR[];
326
- ciBlockedPRs: FetchedPR[];
327
- ciNotRunningPRs: FetchedPR[];
328
- mergeConflictPRs: FetchedPR[];
329
- needsRebasePRs: FetchedPR[];
330
- missingRequiredFilesPRs: FetchedPR[];
331
- incompleteChecklistPRs: FetchedPR[];
332
- needsChangesPRs: FetchedPR[];
333
- changesAddressedPRs: FetchedPR[];
328
+ /** PRs where the contributor needs to take action. Subset of openPRs where status === 'needs_addressing'. */
329
+ needsAddressingPRs: FetchedPR[];
330
+ /** PRs waiting on the maintainer. Subset of openPRs where status === 'waiting_on_maintainer'. */
334
331
  waitingOnMaintainerPRs: FetchedPR[];
335
- /** PRs with no activity for 25+ days (configurable via `approachingDormantDays`). */
336
- approachingDormant: FetchedPR[];
337
- dormantPRs: FetchedPR[];
338
- healthyPRs: FetchedPR[];
339
332
  /** PRs closed without merge in the last 7 days. Surfaced to alert the contributor. */
340
333
  recentlyClosedPRs: ClosedPR[];
341
334
  /** PRs merged in the last 7 days. Surfaced as wins in the dashboard. */
@@ -354,7 +347,7 @@ export interface DailyDigest {
354
347
  totalActivePRs: number;
355
348
  /** Count of PRs requiring contributor action (response, CI fix, conflict resolution, etc.). */
356
349
  totalNeedingAttention: number;
357
- /** Lifetime merged PR count across all repos, derived from {@link RepoScore} data. */
350
+ /** Lifetime merged PR count across all repos, derived from RepoScore data. */
358
351
  totalMergedAllTime: number;
359
352
  /** Percentage of all-time PRs that were merged (merged / (merged + closed)). */
360
353
  mergeRate: number;
@@ -411,6 +404,24 @@ export interface SnoozeInfo {
411
404
  snoozedAt: string;
412
405
  expiresAt: string;
413
406
  }
407
+ /** Filter for excluding repos below a minimum star count from PR count queries. */
408
+ export interface StarFilter {
409
+ minStars: number;
410
+ knownStarCounts: ReadonlyMap<string, number>;
411
+ }
412
+ /**
413
+ * Check if a repo should be excluded based on its star count.
414
+ * Returns true if the repo is known to be below the threshold.
415
+ * Repos with unknown star counts pass through (fail-open).
416
+ */
417
+ export declare function isBelowMinStars(stargazersCount: number | undefined, minStars: number): boolean;
418
+ /** Manual status override for a PR, set via dashboard or CLI. Auto-clears when new activity is detected. */
419
+ export interface StatusOverride {
420
+ status: FetchedPRStatus;
421
+ setAt: string;
422
+ /** PR's updatedAt at the time the override was set. Used to detect new activity for auto-clear. */
423
+ lastActivityAt: string;
424
+ }
414
425
  /** User-configurable settings, populated via `/setup-oss` and stored in {@link AgentState}. */
415
426
  export interface AgentConfig {
416
427
  /** False until the user completes initial setup via `/setup-oss`. */
@@ -457,6 +468,8 @@ export interface AgentConfig {
457
468
  dismissedIssues?: Record<string, string>;
458
469
  /** PR URLs with snoozed CI failures, mapped to snooze metadata. Snoozed PRs are excluded from actionable CI failure list until expiry. */
459
470
  snoozedPRs?: Record<string, SnoozeInfo>;
471
+ /** Manual status overrides for PRs. Maps PR URL to override metadata. Auto-clears when the PR has new activity. */
472
+ statusOverrides?: Record<string, StatusOverride>;
460
473
  }
461
474
  /** Status of a user's comment thread on a GitHub issue. */
462
475
  export type IssueConversationStatus = 'new_response' | 'waiting' | 'acknowledged';
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Core types for the Open Source Contribution Agent
3
3
  */
4
+ /**
5
+ * Check if a repo should be excluded based on its star count.
6
+ * Returns true if the repo is known to be below the threshold.
7
+ * Repos with unknown star counts pass through (fail-open).
8
+ */
9
+ export function isBelowMinStars(stargazersCount, minStars) {
10
+ return stargazersCount !== undefined && stargazersCount < minStars;
11
+ }
4
12
  /** Default configuration applied to new state files. All fields can be overridden via `/setup-oss`. */
5
13
  export const DEFAULT_CONFIG = {
6
14
  setupComplete: false,
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Shared utility functions
3
3
  */
4
+ /** Default concurrency limit for parallel GitHub API requests. */
5
+ export declare const DEFAULT_CONCURRENCY = 5;
4
6
  /**
5
7
  * Returns the oss-autopilot data directory path, creating it if it does not exist.
6
8
  *