@oss-autopilot/core 0.51.0 → 0.52.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.
@@ -1,174 +1,26 @@
1
1
  /**
2
- * State management for the OSS Contribution Agent
3
- * Persists state to a JSON file in ~/.oss-autopilot/
2
+ * State management for the OSS Contribution Agent.
3
+ * Thin coordinator that delegates persistence to state-persistence.ts
4
+ * and scoring logic to repo-score-manager.ts.
4
5
  */
5
- import * as fs from 'fs';
6
- import * as path from 'path';
7
- import { INITIAL_STATE, isBelowMinStars, } from './types.js';
8
- import { getStatePath, getBackupDir, getDataDir } from './utils.js';
9
- import { errorMessage } from './errors.js';
10
- import { debug, warn } from './logger.js';
6
+ import { loadState, saveState, reloadStateIfChanged, createFreshState } from './state-persistence.js';
7
+ import * as repoScoring from './repo-score-manager.js';
8
+ import { debug } from './logger.js';
9
+ export { acquireLock, releaseLock, atomicWriteFileSync } from './state-persistence.js';
11
10
  const MODULE = 'state';
12
- // Current state version
13
- const CURRENT_STATE_VERSION = 2;
14
11
  // Maximum number of events to retain in the event log
15
12
  const MAX_EVENTS = 1000;
16
- /** Repo scores older than this are considered stale and excluded from low-scoring lists. */
17
- const SCORE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
18
- // Lock file timeout: if a lock is older than this, it is considered stale
19
- const LOCK_TIMEOUT_MS = 30_000; // 30 seconds
20
- // Legacy path for migration
21
- const LEGACY_STATE_FILE = path.join(process.cwd(), 'data', 'state.json');
22
- const LEGACY_BACKUP_DIR = path.join(process.cwd(), 'data', 'backups');
23
- /**
24
- * Check whether an existing lock file is stale (expired or corrupt).
25
- * Returns true if the lock should be considered stale and can be removed.
26
- */
27
- function isLockStale(lockPath) {
28
- try {
29
- const existing = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
30
- return Date.now() - existing.timestamp > LOCK_TIMEOUT_MS;
31
- }
32
- catch (err) {
33
- // Lock file is unreadable or contains invalid JSON — treat as stale
34
- debug(MODULE, 'Lock file unreadable or invalid JSON, treating as stale', err);
35
- return true;
36
- }
37
- }
38
- /**
39
- * Acquire an advisory file lock using exclusive-create (`wx` flag).
40
- * If the lock file already exists but is stale (older than LOCK_TIMEOUT_MS or corrupt),
41
- * it is removed and re-acquired.
42
- * @throws Error if the lock is held by another active process.
43
- */
44
- export function acquireLock(lockPath) {
45
- const lockData = JSON.stringify({ pid: process.pid, timestamp: Date.now() });
46
- try {
47
- fs.writeFileSync(lockPath, lockData, { flag: 'wx' }); // Fails if file exists
48
- return;
49
- }
50
- catch (err) {
51
- // Lock file exists (EEXIST from 'wx' flag) — check if it is stale
52
- debug(MODULE, 'Lock file already exists, checking staleness', err);
53
- }
54
- if (!isLockStale(lockPath)) {
55
- throw new Error('State file is locked by another process');
56
- }
57
- // Stale lock detected — remove it and try to re-acquire
58
- try {
59
- fs.unlinkSync(lockPath);
60
- }
61
- catch (err) {
62
- // Another process may have removed the stale lock first — proceed to re-acquire regardless
63
- debug(MODULE, 'Stale lock already removed by another process', err);
64
- }
65
- try {
66
- fs.writeFileSync(lockPath, lockData, { flag: 'wx' });
67
- }
68
- catch (err) {
69
- // Another process grabbed the lock between unlink and write
70
- debug(MODULE, 'Lock re-acquire failed (race condition)', err);
71
- throw new Error('State file is locked by another process', { cause: err });
72
- }
73
- }
74
- /**
75
- * Release an advisory file lock, but only if this process owns it.
76
- * Silently ignores missing lock files or locks owned by other processes.
77
- */
78
- export function releaseLock(lockPath) {
79
- try {
80
- const data = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
81
- if (data.pid === process.pid) {
82
- fs.unlinkSync(lockPath);
83
- }
84
- }
85
- catch (err) {
86
- // Lock already removed or unreadable — nothing to do
87
- debug(MODULE, 'Lock file already removed or unreadable during release', err);
88
- }
89
- }
90
- /**
91
- * Write data to `filePath` atomically by first writing to a temporary file
92
- * in the same directory and then renaming. Rename is atomic on POSIX filesystems,
93
- * preventing partial/corrupt state files if the process crashes mid-write.
94
- */
95
- export function atomicWriteFileSync(filePath, data, mode) {
96
- const tmpPath = filePath + '.tmp';
97
- fs.writeFileSync(tmpPath, data, { mode: mode ?? 0o600 });
98
- fs.renameSync(tmpPath, filePath);
99
- // Ensure permissions are correct (rename preserves the tmp file's mode,
100
- // but on some systems the mode from writeFileSync is masked by umask)
101
- if (mode !== undefined) {
102
- fs.chmodSync(filePath, mode);
103
- }
104
- }
105
- /**
106
- * Migrate state from v1 (local PR tracking) to v2 (fresh GitHub fetching).
107
- * Preserves repoScores and config; drops the legacy PR arrays.
108
- */
109
- function migrateV1ToV2(rawState) {
110
- debug(MODULE, 'Migrating state from v1 to v2 (fresh GitHub fetching)...');
111
- // Extract merged/closed PR arrays from v1 state to seed repo scores
112
- const mergedPRs = rawState.mergedPRs || [];
113
- const closedPRs = rawState.closedPRs || [];
114
- // Update repo scores from historical PR data if not already present
115
- const repoScores = { ...(rawState.repoScores || {}) };
116
- for (const pr of mergedPRs) {
117
- if (!repoScores[pr.repo]) {
118
- repoScores[pr.repo] = {
119
- repo: pr.repo,
120
- score: 5,
121
- mergedPRCount: 0,
122
- closedWithoutMergeCount: 0,
123
- avgResponseDays: null,
124
- lastEvaluatedAt: new Date().toISOString(),
125
- signals: {
126
- hasActiveMaintainers: true,
127
- isResponsive: false,
128
- hasHostileComments: false,
129
- },
130
- };
131
- }
132
- // Note: Don't increment here as the score may already reflect these PRs
133
- }
134
- for (const pr of closedPRs) {
135
- if (!repoScores[pr.repo]) {
136
- repoScores[pr.repo] = {
137
- repo: pr.repo,
138
- score: 5,
139
- mergedPRCount: 0,
140
- closedWithoutMergeCount: 0,
141
- avgResponseDays: null,
142
- lastEvaluatedAt: new Date().toISOString(),
143
- signals: {
144
- hasActiveMaintainers: true,
145
- isResponsive: false,
146
- hasHostileComments: false,
147
- },
148
- };
149
- }
150
- }
151
- const migratedState = {
152
- version: 2,
153
- activeIssues: rawState.activeIssues || [],
154
- repoScores,
155
- config: rawState.config,
156
- events: rawState.events || [],
157
- lastRunAt: new Date().toISOString(),
158
- };
159
- debug(MODULE, `Migration complete. Preserved ${Object.keys(repoScores).length} repo scores.`);
160
- return migratedState;
161
- }
162
13
  /**
163
14
  * Singleton manager for persistent agent state stored in ~/.oss-autopilot/state.json.
164
15
  *
165
- * Handles loading, saving, backup/restore, and v1-to-v2 migration of state. Supports
166
- * an in-memory mode (no disk I/O) for use in tests. In v2 architecture, PR arrays are
167
- * legacy -- open PRs are fetched fresh from GitHub on each run rather than stored locally.
16
+ * Delegates file I/O to state-persistence.ts and scoring logic to repo-score-manager.ts.
17
+ * Retains lightweight CRUD operations for config, events, issues, shelving, dismissal,
18
+ * and status overrides.
168
19
  */
169
20
  export class StateManager {
170
21
  state;
171
22
  inMemoryOnly;
23
+ lastLoadedMtimeMs = 0;
172
24
  /**
173
25
  * Create a new StateManager instance.
174
26
  * @param inMemoryOnly - When true, state is held only in memory and never read from or
@@ -177,33 +29,17 @@ export class StateManager {
177
29
  */
178
30
  constructor(inMemoryOnly = false) {
179
31
  this.inMemoryOnly = inMemoryOnly;
180
- this.state = inMemoryOnly ? this.createFreshState() : this.load();
181
- }
182
- /**
183
- * Create a fresh state (v2: fresh GitHub fetching)
184
- */
185
- createFreshState() {
186
- return {
187
- version: CURRENT_STATE_VERSION,
188
- activeIssues: [],
189
- repoScores: {},
190
- config: {
191
- ...INITIAL_STATE.config,
192
- setupComplete: false,
193
- languages: [...INITIAL_STATE.config.languages],
194
- labels: [...INITIAL_STATE.config.labels],
195
- excludeRepos: [],
196
- trustedProjects: [],
197
- shelvedPRUrls: [],
198
- dismissedIssues: {},
199
- },
200
- events: [],
201
- lastRunAt: new Date().toISOString(),
202
- };
32
+ if (inMemoryOnly) {
33
+ this.state = createFreshState();
34
+ }
35
+ else {
36
+ const result = loadState();
37
+ this.state = result.state;
38
+ this.lastLoadedMtimeMs = result.mtimeMs;
39
+ }
203
40
  }
204
41
  /**
205
42
  * Check if initial setup has been completed.
206
- * @returns true if the user has run `/setup-oss` and completed configuration.
207
43
  */
208
44
  isSetupComplete() {
209
45
  return this.state.config.setupComplete === true;
@@ -217,9 +53,7 @@ export class StateManager {
217
53
  }
218
54
  /**
219
55
  * Initialize state with sensible defaults for zero-config onboarding.
220
- * Sets the GitHub username, marks setup as complete, and persists.
221
- * No-op if setup is already complete (prevents overwriting existing config).
222
- * @param username - The GitHub username to configure.
56
+ * No-op if setup is already complete.
223
57
  */
224
58
  initializeWithDefaults(username) {
225
59
  if (this.state.config.setupComplete) {
@@ -231,360 +65,66 @@ export class StateManager {
231
65
  debug(MODULE, `Initialized with defaults for user "${username}"`);
232
66
  this.save();
233
67
  }
234
- /**
235
- * Migrate state from legacy ./data/ location to ~/.oss-autopilot/
236
- * Returns true if migration was performed
237
- */
238
- migrateFromLegacyLocation() {
239
- const newStatePath = getStatePath();
240
- // If new state already exists, no migration needed
241
- if (fs.existsSync(newStatePath)) {
242
- return false;
243
- }
244
- // Check for legacy state file
245
- if (!fs.existsSync(LEGACY_STATE_FILE)) {
246
- return false;
247
- }
248
- debug(MODULE, 'Migrating state from ./data/ to ~/.oss-autopilot/...');
249
- try {
250
- // Ensure the new data directory exists
251
- getDataDir();
252
- // Copy state file
253
- fs.copyFileSync(LEGACY_STATE_FILE, newStatePath);
254
- debug(MODULE, `Migrated state file to ${newStatePath}`);
255
- // Copy backups if they exist
256
- if (fs.existsSync(LEGACY_BACKUP_DIR)) {
257
- const newBackupDir = getBackupDir();
258
- const backupFiles = fs
259
- .readdirSync(LEGACY_BACKUP_DIR)
260
- .filter((f) => f.startsWith('state-') && f.endsWith('.json'));
261
- for (const backupFile of backupFiles) {
262
- const srcPath = path.join(LEGACY_BACKUP_DIR, backupFile);
263
- const destPath = path.join(newBackupDir, backupFile);
264
- fs.copyFileSync(srcPath, destPath);
265
- }
266
- debug(MODULE, `Migrated ${backupFiles.length} backup files`);
267
- }
268
- // Remove legacy files
269
- fs.unlinkSync(LEGACY_STATE_FILE);
270
- debug(MODULE, 'Removed legacy state file');
271
- // Remove legacy backup files
272
- if (fs.existsSync(LEGACY_BACKUP_DIR)) {
273
- const backupFiles = fs.readdirSync(LEGACY_BACKUP_DIR);
274
- for (const file of backupFiles) {
275
- fs.unlinkSync(path.join(LEGACY_BACKUP_DIR, file));
276
- }
277
- fs.rmdirSync(LEGACY_BACKUP_DIR);
278
- }
279
- // Try to remove legacy data directory if empty
280
- const legacyDataDir = path.dirname(LEGACY_STATE_FILE);
281
- if (fs.existsSync(legacyDataDir)) {
282
- const remaining = fs.readdirSync(legacyDataDir);
283
- if (remaining.length === 0) {
284
- fs.rmdirSync(legacyDataDir);
285
- debug(MODULE, 'Removed empty legacy data directory');
286
- }
287
- }
288
- debug(MODULE, 'Migration complete!');
289
- return true;
290
- }
291
- catch (error) {
292
- warn(MODULE, `Failed to migrate state: ${errorMessage(error)}`);
293
- // Clean up partial migration to avoid inconsistent state
294
- const newStatePath = getStatePath();
295
- if (fs.existsSync(newStatePath) && fs.existsSync(LEGACY_STATE_FILE)) {
296
- // If both files exist, the migration was partial - remove the new file
297
- try {
298
- fs.unlinkSync(newStatePath);
299
- debug(MODULE, 'Cleaned up partial migration - removed incomplete new state file');
300
- }
301
- catch (cleanupErr) {
302
- warn(MODULE, 'Could not clean up partial migration file');
303
- debug(MODULE, 'Partial migration cleanup failed', cleanupErr);
304
- }
305
- }
306
- warn(MODULE, 'To resolve this issue:');
307
- warn(MODULE, ' 1. Ensure you have write permissions to ~/.oss-autopilot/');
308
- warn(MODULE, ' 2. Check available disk space');
309
- warn(MODULE, ' 3. Manually copy ./data/state.json to ~/.oss-autopilot/state.json');
310
- warn(MODULE, ' 4. Or delete ./data/state.json to start fresh');
311
- return false;
312
- }
313
- }
314
- /**
315
- * Load state from file, or create initial state if none exists.
316
- * If the main state file is corrupted, attempts to restore from the most recent backup.
317
- * Performs migration from legacy ./data/ location if needed.
318
- */
319
- load() {
320
- // Try to migrate from legacy location first
321
- this.migrateFromLegacyLocation();
322
- const statePath = getStatePath();
323
- try {
324
- if (fs.existsSync(statePath)) {
325
- const data = fs.readFileSync(statePath, 'utf-8');
326
- let state = JSON.parse(data);
327
- // Validate required fields exist
328
- if (!this.isValidState(state)) {
329
- warn(MODULE, 'Invalid state file structure, attempting to restore from backup...');
330
- const restoredState = this.tryRestoreFromBackup();
331
- if (restoredState) {
332
- return restoredState;
333
- }
334
- warn(MODULE, 'No valid backup found, starting fresh');
335
- return this.createFreshState();
336
- }
337
- // Migrate from v1 to v2 if needed
338
- if (state.version === 1) {
339
- state = migrateV1ToV2(state);
340
- // Save the migrated state immediately (atomic write)
341
- atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
342
- debug(MODULE, 'Migrated state saved');
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
- }
372
- // Log appropriate message based on version
373
- const repoCount = Object.keys(state.repoScores).length;
374
- debug(MODULE, `Loaded state v${state.version}: ${repoCount} repo scores tracked`);
375
- return state;
376
- }
377
- }
378
- catch (error) {
379
- warn(MODULE, 'Error loading state:', error);
380
- warn(MODULE, 'Attempting to restore from backup...');
381
- const restoredState = this.tryRestoreFromBackup();
382
- if (restoredState) {
383
- return restoredState;
384
- }
385
- warn(MODULE, 'No valid backup found, starting fresh');
386
- }
387
- debug(MODULE, 'No existing state found, initializing...');
388
- return this.createFreshState();
389
- }
390
- /**
391
- * Attempt to restore state from the most recent valid backup.
392
- * Returns the restored state if successful, or null if no valid backup is found.
393
- */
394
- tryRestoreFromBackup() {
395
- const backupDir = getBackupDir();
396
- if (!fs.existsSync(backupDir)) {
397
- return null;
398
- }
399
- // Get backup files sorted by name (most recent first, since names include timestamps)
400
- const backupFiles = fs
401
- .readdirSync(backupDir)
402
- .filter((f) => f.startsWith('state-') && f.endsWith('.json'))
403
- .sort()
404
- .reverse();
405
- for (const backupFile of backupFiles) {
406
- const backupPath = path.join(backupDir, backupFile);
407
- try {
408
- const data = fs.readFileSync(backupPath, 'utf-8');
409
- let state = JSON.parse(data);
410
- if (this.isValidState(state)) {
411
- debug(MODULE, `Successfully restored state from backup: ${backupFile}`);
412
- // Migrate from v1 to v2 if needed
413
- if (state.version === 1) {
414
- state = migrateV1ToV2(state);
415
- }
416
- const repoCount = Object.keys(state.repoScores).length;
417
- debug(MODULE, `Restored state v${state.version}: ${repoCount} repo scores`);
418
- // Overwrite the corrupted main state file with the restored backup (atomic write)
419
- const statePath = getStatePath();
420
- atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
421
- debug(MODULE, 'Restored backup written to main state file');
422
- return state;
423
- }
424
- }
425
- catch (backupErr) {
426
- // This backup is also corrupted, try the next one
427
- warn(MODULE, `Backup ${backupFile} is corrupted, trying next...`);
428
- debug(MODULE, `Backup ${backupFile} parse failed`, backupErr);
429
- }
430
- }
431
- return null;
432
- }
433
- /**
434
- * Validate that a loaded state has the required structure
435
- * Handles both v1 (with PR arrays) and v2 (without)
436
- */
437
- isValidState(state) {
438
- if (!state || typeof state !== 'object')
439
- return false;
440
- const s = state;
441
- // Migrate older states that don't have repoScores
442
- if (s.repoScores === undefined) {
443
- s.repoScores = {};
444
- }
445
- // Migrate older states that don't have events
446
- if (s.events === undefined) {
447
- s.events = [];
448
- }
449
- // Migrate older states that don't have mergedPRs
450
- if (s.mergedPRs === undefined) {
451
- s.mergedPRs = [];
452
- }
453
- // Base requirements for all versions
454
- const hasBaseFields = typeof s.version === 'number' &&
455
- typeof s.repoScores === 'object' &&
456
- s.repoScores !== null &&
457
- Array.isArray(s.events) &&
458
- typeof s.config === 'object' &&
459
- s.config !== null;
460
- if (!hasBaseFields)
461
- return false;
462
- // v1 requires base PR arrays to be present (they will be dropped during migration)
463
- if (s.version === 1) {
464
- return (Array.isArray(s.activePRs) &&
465
- Array.isArray(s.dormantPRs) &&
466
- Array.isArray(s.mergedPRs) &&
467
- Array.isArray(s.closedPRs));
468
- }
469
- // v2+ doesn't require PR arrays
470
- return true;
471
- }
472
68
  /**
473
69
  * Persist the current state to disk, creating a timestamped backup of the previous
474
- * state file first. Updates `lastRunAt` to the current time. In in-memory mode,
475
- * only updates `lastRunAt` without any file I/O. Retains at most 10 backup files.
70
+ * state file first. In in-memory mode, only updates `lastRunAt` without any file I/O.
476
71
  */
477
72
  save() {
478
- // Update lastRunAt
479
73
  this.state.lastRunAt = new Date().toISOString();
480
- // Skip file operations in in-memory mode
481
74
  if (this.inMemoryOnly) {
482
75
  return;
483
76
  }
484
- const statePath = getStatePath();
485
- const lockPath = statePath + '.lock';
486
- const backupDir = getBackupDir();
487
- // Acquire advisory lock to prevent concurrent writes
488
- acquireLock(lockPath);
489
- try {
490
- // Create backup of existing state
491
- if (fs.existsSync(statePath)) {
492
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
493
- const randomSuffix = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
494
- const backupFile = path.join(backupDir, `state-${timestamp}-${randomSuffix}.json`);
495
- fs.copyFileSync(statePath, backupFile);
496
- fs.chmodSync(backupFile, 0o600);
497
- // Keep only last 10 backups
498
- this.cleanupBackups();
499
- }
500
- // Atomic write: write to temp file then rename to prevent corruption on crash
501
- atomicWriteFileSync(statePath, JSON.stringify(this.state, null, 2), 0o600);
502
- debug(MODULE, 'State saved successfully');
503
- }
504
- finally {
505
- releaseLock(lockPath);
506
- }
507
- }
508
- cleanupBackups() {
509
- const backupDir = getBackupDir();
510
- try {
511
- const files = fs
512
- .readdirSync(backupDir)
513
- .filter((f) => f.startsWith('state-'))
514
- .sort()
515
- .reverse();
516
- // Keep only the 10 most recent backups
517
- for (const file of files.slice(10)) {
518
- try {
519
- fs.unlinkSync(path.join(backupDir, file));
520
- }
521
- catch (error) {
522
- warn(MODULE, `Could not delete old backup ${file}:`, errorMessage(error));
523
- }
524
- }
525
- }
526
- catch (error) {
527
- warn(MODULE, 'Could not clean up backups:', errorMessage(error));
528
- }
77
+ this.lastLoadedMtimeMs = saveState(this.state);
529
78
  }
530
79
  /**
531
80
  * Get the current state as a read-only snapshot.
532
- * @returns The full agent state. Callers should not mutate the returned object;
533
- * use the StateManager methods to make changes.
534
81
  */
535
82
  getState() {
536
83
  return this.state;
537
84
  }
538
85
  /**
539
- * Store the latest daily digest for dashboard rendering.
540
- * @param digest - The freshly generated digest from the current daily run.
86
+ * Re-read state from disk if the file has been modified since the last load/save.
87
+ * Returns true if state was reloaded, false if unchanged or in-memory mode.
541
88
  */
89
+ reloadIfChanged() {
90
+ if (this.inMemoryOnly)
91
+ return false;
92
+ const result = reloadStateIfChanged(this.lastLoadedMtimeMs);
93
+ if (!result)
94
+ return false;
95
+ this.state = result.state;
96
+ this.lastLoadedMtimeMs = result.mtimeMs;
97
+ return true;
98
+ }
99
+ // === Dashboard Data Setters ===
542
100
  setLastDigest(digest) {
543
101
  this.state.lastDigest = digest;
544
102
  this.state.lastDigestAt = digest.generatedAt;
545
103
  }
546
- /**
547
- * Store monthly merged PR counts for the contribution timeline chart.
548
- * @param counts - Map of "YYYY-MM" strings to merged PR counts for that month.
549
- */
550
104
  setMonthlyMergedCounts(counts) {
551
105
  this.state.monthlyMergedCounts = counts;
552
106
  }
553
- /**
554
- * Store monthly closed (without merge) PR counts for the contribution timeline and success rate charts.
555
- * @param counts - Map of "YYYY-MM" strings to closed PR counts for that month.
556
- */
557
107
  setMonthlyClosedCounts(counts) {
558
108
  this.state.monthlyClosedCounts = counts;
559
109
  }
560
- /**
561
- * Store monthly opened PR counts for the contribution timeline chart.
562
- * @param counts - Map of "YYYY-MM" strings to opened PR counts for that month.
563
- */
564
110
  setMonthlyOpenedCounts(counts) {
565
111
  this.state.monthlyOpenedCounts = counts;
566
112
  }
567
113
  setDailyActivityCounts(counts) {
568
114
  this.state.dailyActivityCounts = counts;
569
115
  }
116
+ setLocalRepoCache(cache) {
117
+ this.state.localRepoCache = cache;
118
+ }
570
119
  // === Merged PR Storage ===
571
- /**
572
- * Get all stored merged PRs.
573
- * @returns Array of stored merged PRs, sorted by mergedAt desc.
574
- */
575
120
  getMergedPRs() {
576
121
  return this.state.mergedPRs ?? [];
577
122
  }
578
- /**
579
- * Add new merged PRs to the stored list. Deduplicates by URL and sorts by mergedAt desc.
580
- * @param prs - New merged PRs to add.
581
- */
582
123
  addMergedPRs(prs) {
583
124
  if (prs.length === 0)
584
125
  return;
585
- if (!this.state.mergedPRs) {
126
+ if (!this.state.mergedPRs)
586
127
  this.state.mergedPRs = [];
587
- }
588
128
  const existingUrls = new Set(this.state.mergedPRs.map((pr) => pr.url));
589
129
  const newPRs = prs.filter((pr) => !existingUrls.has(pr.url));
590
130
  if (newPRs.length === 0)
@@ -593,35 +133,18 @@ export class StateManager {
593
133
  this.state.mergedPRs.sort((a, b) => b.mergedAt.localeCompare(a.mergedAt));
594
134
  debug(MODULE, `Added ${newPRs.length} merged PRs (total: ${this.state.mergedPRs.length})`);
595
135
  }
596
- /**
597
- * Get the most recent mergedAt timestamp from stored merged PRs.
598
- * Used as the watermark for incremental fetching.
599
- * @returns ISO date string of the most recent merge, or undefined if no stored PRs.
600
- */
601
136
  getMergedPRWatermark() {
602
- const prs = this.state.mergedPRs;
603
- if (!prs || prs.length === 0)
604
- return undefined;
605
- // List is sorted desc by mergedAt, so first element is most recent
606
- return prs[0].mergedAt || undefined;
137
+ return this.state.mergedPRs?.[0]?.mergedAt || undefined;
607
138
  }
608
- /**
609
- * Get all stored closed PRs.
610
- * @returns Array of stored closed PRs, sorted by closedAt desc.
611
- */
139
+ // === Closed PR Storage ===
612
140
  getClosedPRs() {
613
141
  return this.state.closedPRs ?? [];
614
142
  }
615
- /**
616
- * Add new closed PRs to the stored list. Deduplicates by URL and sorts by closedAt desc.
617
- * @param prs - New closed PRs to add.
618
- */
619
143
  addClosedPRs(prs) {
620
144
  if (prs.length === 0)
621
145
  return;
622
- if (!this.state.closedPRs) {
146
+ if (!this.state.closedPRs)
623
147
  this.state.closedPRs = [];
624
- }
625
148
  const existingUrls = new Set(this.state.closedPRs.map((pr) => pr.url));
626
149
  const newPRs = prs.filter((pr) => !existingUrls.has(pr.url));
627
150
  if (newPRs.length === 0)
@@ -630,39 +153,14 @@ export class StateManager {
630
153
  this.state.closedPRs.sort((a, b) => b.closedAt.localeCompare(a.closedAt));
631
154
  debug(MODULE, `Added ${newPRs.length} closed PRs (total: ${this.state.closedPRs.length})`);
632
155
  }
633
- /**
634
- * Get the most recent closedAt timestamp from stored closed PRs.
635
- * Used as the watermark for incremental fetching.
636
- * @returns ISO date string of the most recent close, or undefined if no stored PRs.
637
- */
638
156
  getClosedPRWatermark() {
639
- const prs = this.state.closedPRs;
640
- if (!prs || prs.length === 0)
641
- return undefined;
642
- // List is sorted desc by closedAt, so first element is most recent
643
- return prs[0].closedAt || undefined;
644
- }
645
- /**
646
- * Store cached local repo scan results (#84).
647
- * @param cache - The scan results, paths scanned, and timestamp.
648
- */
649
- setLocalRepoCache(cache) {
650
- this.state.localRepoCache = cache;
157
+ return this.state.closedPRs?.[0]?.closedAt || undefined;
651
158
  }
652
- /**
653
- * Shallow-merge partial configuration updates into the current config.
654
- * @param config - Partial config object whose properties override existing values.
655
- */
159
+ // === Configuration ===
656
160
  updateConfig(config) {
657
161
  this.state.config = { ...this.state.config, ...config };
658
162
  }
659
163
  // === Event Logging ===
660
- /**
661
- * Append an event to the event log. Events are capped at {@link MAX_EVENTS} (1000);
662
- * when the cap is exceeded, the oldest events are trimmed to stay within the limit.
663
- * @param type - The event type (e.g. 'pr_tracked').
664
- * @param data - Arbitrary key-value payload for the event.
665
- */
666
164
  appendEvent(type, data) {
667
165
  const event = {
668
166
  id: `evt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
@@ -676,20 +174,9 @@ export class StateManager {
676
174
  this.state.events = this.state.events.slice(-MAX_EVENTS);
677
175
  }
678
176
  }
679
- /**
680
- * Filter the event log to events of a specific type.
681
- * @param type - The event type to filter by.
682
- * @returns All events matching the given type, in chronological order.
683
- */
684
177
  getEventsByType(type) {
685
178
  return this.state.events.filter((e) => e.type === type);
686
179
  }
687
- /**
688
- * Filter the event log to events within an inclusive time range.
689
- * @param since - Start of the range (inclusive).
690
- * @param until - End of the range (inclusive). Defaults to now.
691
- * @returns Events whose timestamps fall within [since, until].
692
- */
693
180
  getEventsInRange(since, until = new Date()) {
694
181
  return this.state.events.filter((e) => {
695
182
  const eventTime = new Date(e.at);
@@ -697,11 +184,6 @@ export class StateManager {
697
184
  });
698
185
  }
699
186
  // === Issue Management ===
700
- /**
701
- * Add an issue to the active tracking list. If an issue with the same URL is
702
- * already tracked, the call is a no-op.
703
- * @param issue - The issue to begin tracking.
704
- */
705
187
  addIssue(issue) {
706
188
  const existing = this.state.activeIssues.find((i) => i.url === issue.url);
707
189
  if (existing) {
@@ -712,24 +194,12 @@ export class StateManager {
712
194
  debug(MODULE, `Added issue: ${issue.repo}#${issue.number}`);
713
195
  }
714
196
  // === Trusted Projects ===
715
- /**
716
- * Add a repository to the trusted projects list. Trusted projects are prioritized
717
- * in issue search results. No-op if the repo is already trusted.
718
- * @param repo - Repository in "owner/repo" format.
719
- */
720
197
  addTrustedProject(repo) {
721
198
  if (!this.state.config.trustedProjects.includes(repo)) {
722
199
  this.state.config.trustedProjects.push(repo);
723
200
  debug(MODULE, `Added trusted project: ${repo}`);
724
201
  }
725
202
  }
726
- /**
727
- * Test whether a repo matches any of the given exclusion lists.
728
- * Both repo and org comparisons are case-insensitive (GitHub names are case-insensitive).
729
- * @param repo - Repository in "owner/repo" format.
730
- * @param repos - Full "owner/repo" strings (case-insensitive match).
731
- * @param orgs - Org names (case-insensitive match against the owner segment of the repo).
732
- */
733
203
  static matchesExclusion(repo, repos, orgs) {
734
204
  const repoLower = repo.toLowerCase();
735
205
  if (repos.some((r) => r.toLowerCase() === repoLower))
@@ -738,15 +208,6 @@ export class StateManager {
738
208
  return true;
739
209
  return false;
740
210
  }
741
- /**
742
- * Remove repositories matching the given exclusion lists from `trustedProjects`.
743
- * Called when a repo or org is newly excluded.
744
- *
745
- * Note: `repoScores` are intentionally preserved so historical stats (merge rate,
746
- * total merged) remain accurate. Exclusion only affects issue discovery (#591).
747
- * @param repos - Full "owner/repo" strings to exclude (case-insensitive match).
748
- * @param orgs - Org names to exclude (case-insensitive match against owner segment).
749
- */
750
211
  cleanupExcludedData(repos, orgs) {
751
212
  const matches = (repo) => StateManager.matchesExclusion(repo, repos, orgs);
752
213
  const beforeTrusted = this.state.config.trustedProjects.length;
@@ -757,26 +218,14 @@ export class StateManager {
757
218
  }
758
219
  }
759
220
  // === Starred Repos Management ===
760
- /**
761
- * Get the cached list of the user's GitHub starred repositories.
762
- * @returns Array of "owner/repo" strings, or an empty array if never fetched.
763
- */
764
221
  getStarredRepos() {
765
222
  return this.state.config.starredRepos || [];
766
223
  }
767
- /**
768
- * Replace the cached starred repositories list and update the fetch timestamp.
769
- * @param repos - Array of "owner/repo" strings from the user's GitHub stars.
770
- */
771
224
  setStarredRepos(repos) {
772
225
  this.state.config.starredRepos = repos;
773
226
  this.state.config.starredReposLastFetched = new Date().toISOString();
774
227
  debug(MODULE, `Updated starred repos: ${repos.length} repositories`);
775
228
  }
776
- /**
777
- * Check if the starred repos cache is stale (older than 24 hours) or has never been fetched.
778
- * @returns true if the cache should be refreshed.
779
- */
780
229
  isStarredReposStale() {
781
230
  const lastFetched = this.state.config.starredReposLastFetched;
782
231
  if (!lastFetched) {
@@ -788,12 +237,6 @@ export class StateManager {
788
237
  return now.getTime() - lastFetchedDate.getTime() > staleThresholdMs;
789
238
  }
790
239
  // === Shelve/Unshelve ===
791
- /**
792
- * Shelve a PR by URL. Shelved PRs are excluded from capacity and actionable issues.
793
- * They are auto-unshelved when a maintainer engages (needs_response, needs_changes, etc.).
794
- * @param url - The full GitHub PR URL.
795
- * @returns true if newly added, false if already shelved.
796
- */
797
240
  shelvePR(url) {
798
241
  if (!this.state.config.shelvedPRUrls) {
799
242
  this.state.config.shelvedPRUrls = [];
@@ -804,11 +247,6 @@ export class StateManager {
804
247
  this.state.config.shelvedPRUrls.push(url);
805
248
  return true;
806
249
  }
807
- /**
808
- * Unshelve a PR by URL.
809
- * @param url - The full GitHub PR URL.
810
- * @returns true if found and removed, false if not shelved.
811
- */
812
250
  unshelvePR(url) {
813
251
  if (!this.state.config.shelvedPRUrls) {
814
252
  return false;
@@ -820,22 +258,10 @@ export class StateManager {
820
258
  this.state.config.shelvedPRUrls.splice(index, 1);
821
259
  return true;
822
260
  }
823
- /**
824
- * Check if a PR is shelved.
825
- * @param url - The full GitHub PR URL.
826
- * @returns true if the URL is in the shelved list.
827
- */
828
261
  isPRShelved(url) {
829
262
  return this.state.config.shelvedPRUrls?.includes(url) ?? false;
830
263
  }
831
264
  // === Dismiss / Undismiss Issues ===
832
- /**
833
- * Dismiss an issue by URL. Dismissed issues are excluded from `new_response` notifications
834
- * until new activity occurs after the dismiss timestamp.
835
- * @param url - The full GitHub issue URL.
836
- * @param timestamp - ISO timestamp of when the issue was dismissed.
837
- * @returns true if newly dismissed, false if already dismissed.
838
- */
839
265
  dismissIssue(url, timestamp) {
840
266
  if (!this.state.config.dismissedIssues) {
841
267
  this.state.config.dismissedIssues = {};
@@ -846,11 +272,6 @@ export class StateManager {
846
272
  this.state.config.dismissedIssues[url] = timestamp;
847
273
  return true;
848
274
  }
849
- /**
850
- * Undismiss an issue by URL.
851
- * @param url - The full GitHub issue URL.
852
- * @returns true if found and removed, false if not dismissed.
853
- */
854
275
  undismissIssue(url) {
855
276
  if (!this.state.config.dismissedIssues || !(url in this.state.config.dismissedIssues)) {
856
277
  return false;
@@ -858,21 +279,10 @@ export class StateManager {
858
279
  delete this.state.config.dismissedIssues[url];
859
280
  return true;
860
281
  }
861
- /**
862
- * Get the timestamp when an issue was dismissed.
863
- * @param url - The full GitHub issue URL.
864
- * @returns The ISO dismiss timestamp, or undefined if not dismissed.
865
- */
866
282
  getIssueDismissedAt(url) {
867
283
  return this.state.config.dismissedIssues?.[url];
868
284
  }
869
285
  // === Status Overrides ===
870
- /**
871
- * Set a manual status override for a PR.
872
- * @param url - The full GitHub PR URL.
873
- * @param status - The target status to override to.
874
- * @param lastActivityAt - The PR's current updatedAt timestamp (for auto-clear detection).
875
- */
876
286
  setStatusOverride(url, status, lastActivityAt) {
877
287
  if (!this.state.config.statusOverrides) {
878
288
  this.state.config.statusOverrides = {};
@@ -883,11 +293,6 @@ export class StateManager {
883
293
  lastActivityAt,
884
294
  };
885
295
  }
886
- /**
887
- * Clear a status override for a PR.
888
- * @param url - The full GitHub PR URL.
889
- * @returns true if found and removed, false if no override existed.
890
- */
891
296
  clearStatusOverride(url) {
892
297
  if (!this.state.config.statusOverrides || !(url in this.state.config.statusOverrides)) {
893
298
  return false;
@@ -895,13 +300,6 @@ export class StateManager {
895
300
  delete this.state.config.statusOverrides[url];
896
301
  return true;
897
302
  }
898
- /**
899
- * Get the status override for a PR, if one exists and hasn't been auto-cleared.
900
- * @param url - The full GitHub PR URL.
901
- * @param currentUpdatedAt - The PR's current updatedAt from GitHub. If newer than
902
- * the stored lastActivityAt, the override is stale and auto-cleared.
903
- * @returns The override metadata, or undefined if none exists or it was auto-cleared.
904
- */
905
303
  getStatusOverride(url, currentUpdatedAt) {
906
304
  const override = this.state.config.statusOverrides?.[url];
907
305
  if (!override)
@@ -913,247 +311,42 @@ export class StateManager {
913
311
  }
914
312
  return override;
915
313
  }
916
- // === Repository Scoring ===
917
- /**
918
- * Get the score record for a repository.
919
- * @param repo - Repository in "owner/repo" format.
920
- * @returns The RepoScore if the repo has been scored, or undefined if never evaluated.
921
- */
314
+ // === Repository Scoring (delegated to repo-score-manager) ===
922
315
  getRepoScore(repo) {
923
- return this.state.repoScores[repo];
924
- }
925
- /**
926
- * Create a default repo score for a new repository
927
- */
928
- createDefaultRepoScore(repo) {
929
- return {
930
- repo,
931
- score: 5, // Base score
932
- mergedPRCount: 0,
933
- closedWithoutMergeCount: 0,
934
- avgResponseDays: null,
935
- lastEvaluatedAt: new Date().toISOString(),
936
- signals: {
937
- hasActiveMaintainers: true, // Assume positive by default
938
- isResponsive: false,
939
- hasHostileComments: false,
940
- },
941
- };
942
- }
943
- /**
944
- * Calculate the score based on the repo's metrics.
945
- * Base 5, logarithmic merge bonus (max +5), -1 per closed without merge (max -3),
946
- * +1 if recently merged (within 90 days), +1 if responsive, -2 if hostile. Clamp 1-10.
947
- */
948
- calculateScore(repoScore) {
949
- let score = 5; // Base score
950
- // Logarithmic merge bonus (max +5): 1→+2, 2→+3, 3→+4, 5+→+5
951
- if (repoScore.mergedPRCount > 0) {
952
- const mergedBonus = Math.min(Math.round(Math.log2(repoScore.mergedPRCount + 1) * 2), 5);
953
- score += mergedBonus;
954
- }
955
- // -1 per closed without merge (max -3)
956
- const closedPenalty = Math.min(repoScore.closedWithoutMergeCount, 3);
957
- score -= closedPenalty;
958
- // +1 if lastMergedAt is set and within 90 days (recency)
959
- if (repoScore.lastMergedAt) {
960
- const lastMergedDate = new Date(repoScore.lastMergedAt);
961
- if (isNaN(lastMergedDate.getTime())) {
962
- warn(MODULE, `Invalid lastMergedAt date for ${repoScore.repo}: "${repoScore.lastMergedAt}". Skipping recency bonus.`);
963
- }
964
- else {
965
- const msPerDay = 1000 * 60 * 60 * 24;
966
- const daysSince = Math.floor((Date.now() - lastMergedDate.getTime()) / msPerDay);
967
- if (daysSince <= 90) {
968
- score += 1;
969
- }
970
- }
971
- }
972
- // +1 if responsive
973
- if (repoScore.signals.isResponsive) {
974
- score += 1;
975
- }
976
- // -2 if hostile
977
- if (repoScore.signals.hasHostileComments) {
978
- score -= 2;
979
- }
980
- // Clamp to 1-10
981
- return Math.max(1, Math.min(10, score));
316
+ return repoScoring.getRepoScore(this.state, repo);
982
317
  }
983
- /**
984
- * Update a repository's score with partial updates. If the repo has no existing score,
985
- * a default score record is created first (base score 5). After applying updates, the
986
- * numeric score is recalculated using the formula: base 5, logarithmic merge bonus (max +5),
987
- * -1 per closed-without-merge (max -3), +1 if recently merged, +1 if responsive, -2 if hostile, clamped to [1, 10].
988
- * @param repo - Repository in "owner/repo" format.
989
- * @param updates - Updatable RepoScore fields to merge. The `score`, `repo`, and
990
- * `lastEvaluatedAt` fields are not accepted — score is always derived via
991
- * calculateScore(), and repo/lastEvaluatedAt are managed internally.
992
- */
993
318
  updateRepoScore(repo, updates) {
994
- if (!this.state.repoScores[repo]) {
995
- this.state.repoScores[repo] = this.createDefaultRepoScore(repo);
996
- }
997
- const repoScore = this.state.repoScores[repo];
998
- // Apply updates
999
- if (updates.mergedPRCount !== undefined) {
1000
- repoScore.mergedPRCount = updates.mergedPRCount;
1001
- }
1002
- if (updates.closedWithoutMergeCount !== undefined) {
1003
- repoScore.closedWithoutMergeCount = updates.closedWithoutMergeCount;
1004
- }
1005
- if (updates.avgResponseDays !== undefined) {
1006
- repoScore.avgResponseDays = updates.avgResponseDays;
1007
- }
1008
- if (updates.lastMergedAt !== undefined) {
1009
- repoScore.lastMergedAt = updates.lastMergedAt;
1010
- }
1011
- if (updates.stargazersCount !== undefined) {
1012
- repoScore.stargazersCount = updates.stargazersCount;
1013
- }
1014
- if (updates.signals) {
1015
- repoScore.signals = { ...repoScore.signals, ...updates.signals };
1016
- }
1017
- // Recalculate score
1018
- repoScore.score = this.calculateScore(repoScore);
1019
- repoScore.lastEvaluatedAt = new Date().toISOString();
1020
- debug(MODULE, `Updated repo score for ${repo}: ${repoScore.score}/10`);
319
+ repoScoring.updateRepoScore(this.state, repo, updates);
1021
320
  }
1022
- /**
1023
- * Increment the merged PR count for a repository and recalculate its score.
1024
- * Routes through {@link updateRepoScore} for a single mutation path.
1025
- * @param repo - Repository in "owner/repo" format.
1026
- */
1027
321
  incrementMergedCount(repo) {
1028
- const current = this.state.repoScores[repo];
1029
- const newCount = (current?.mergedPRCount ?? 0) + 1;
1030
- this.updateRepoScore(repo, {
1031
- mergedPRCount: newCount,
1032
- lastMergedAt: new Date().toISOString(),
1033
- });
1034
- debug(MODULE, `Incremented merged count for ${repo}: ${newCount}`);
322
+ repoScoring.incrementMergedCount(this.state, repo);
1035
323
  }
1036
- /**
1037
- * Increment the closed-without-merge count for a repository and recalculate its score.
1038
- * Routes through {@link updateRepoScore} for a single mutation path.
1039
- * @param repo - Repository in "owner/repo" format.
1040
- */
1041
324
  incrementClosedCount(repo) {
1042
- const current = this.state.repoScores[repo];
1043
- const newCount = (current?.closedWithoutMergeCount ?? 0) + 1;
1044
- this.updateRepoScore(repo, {
1045
- closedWithoutMergeCount: newCount,
1046
- });
1047
- debug(MODULE, `Incremented closed count for ${repo}: ${newCount}`);
325
+ repoScoring.incrementClosedCount(this.state, repo);
1048
326
  }
1049
- /**
1050
- * Mark a repository as having hostile maintainer comments and recalculate its score.
1051
- * This applies a -2 penalty to the score. Creates a default score record if needed.
1052
- * @param repo - Repository in "owner/repo" format.
1053
- */
1054
327
  markRepoHostile(repo) {
1055
- this.updateRepoScore(repo, { signals: { hasHostileComments: true } });
1056
- debug(MODULE, `Marked ${repo} as hostile, score: ${this.state.repoScores[repo].score}/10`);
328
+ repoScoring.markRepoHostile(this.state, repo);
1057
329
  }
1058
- /**
1059
- * Get repositories where the user has at least one merged PR, sorted by merged count descending.
1060
- * These repos represent proven relationships with high merge probability.
1061
- * @returns Array of "owner/repo" strings for repos with mergedPRCount > 0.
1062
- */
1063
330
  getReposWithMergedPRs() {
1064
- return Object.values(this.state.repoScores)
1065
- .filter((rs) => rs.mergedPRCount > 0)
1066
- .sort((a, b) => b.mergedPRCount - a.mergedPRCount)
1067
- .map((rs) => rs.repo);
331
+ return repoScoring.getReposWithMergedPRs(this.state);
1068
332
  }
1069
- /**
1070
- * Get repositories where the user has interacted (has a score record) but has NOT
1071
- * yet had a PR merged, excluding repos where the only interaction was rejection.
1072
- * These represent repos with open or in-progress PRs — relationships that benefit
1073
- * from continued search attention.
1074
- * @returns Array of "owner/repo" strings, sorted by score descending.
1075
- */
1076
333
  getReposWithOpenPRs() {
1077
- return Object.values(this.state.repoScores)
1078
- .filter((rs) => rs.mergedPRCount === 0 && rs.closedWithoutMergeCount === 0)
1079
- .sort((a, b) => b.score - a.score)
1080
- .map((rs) => rs.repo);
334
+ return repoScoring.getReposWithOpenPRs(this.state);
1081
335
  }
1082
- /**
1083
- * Get repositories with a score at or above the given threshold, sorted highest first.
1084
- * @param minScore - Minimum score (inclusive). Defaults to `config.minRepoScoreThreshold`.
1085
- * @returns Array of "owner/repo" strings for repos meeting the threshold.
1086
- */
1087
336
  getHighScoringRepos(minScore) {
1088
- const threshold = minScore ?? this.state.config.minRepoScoreThreshold;
1089
- return Object.values(this.state.repoScores)
1090
- .filter((rs) => rs.score >= threshold)
1091
- .sort((a, b) => b.score - a.score)
1092
- .map((rs) => rs.repo);
337
+ return repoScoring.getHighScoringRepos(this.state, minScore);
1093
338
  }
1094
- /**
1095
- * Get repositories with a score at or below the given threshold, sorted lowest first.
1096
- * @param maxScore - Maximum score (inclusive). Defaults to `config.minRepoScoreThreshold`.
1097
- * @returns Array of "owner/repo" strings for repos at or below the threshold.
1098
- */
1099
339
  getLowScoringRepos(maxScore) {
1100
- const threshold = maxScore ?? this.state.config.minRepoScoreThreshold;
1101
- const now = Date.now();
1102
- return Object.values(this.state.repoScores)
1103
- .filter((rs) => {
1104
- if (rs.score > threshold)
1105
- return false;
1106
- // Stale scores (>30 days) should not permanently block repos (#487)
1107
- const age = now - new Date(rs.lastEvaluatedAt).getTime();
1108
- if (!Number.isFinite(age)) {
1109
- warn(MODULE, `Invalid lastEvaluatedAt for repo ${rs.repo}: "${rs.lastEvaluatedAt}", treating as stale`);
1110
- return false;
1111
- }
1112
- return age <= SCORE_TTL_MS;
1113
- })
1114
- .sort((a, b) => a.score - b.score)
1115
- .map((rs) => rs.repo);
340
+ return repoScoring.getLowScoringRepos(this.state, maxScore);
1116
341
  }
1117
- // === Statistics ===
1118
- /**
1119
- * Compute aggregate statistics from the current state. `mergedPRs` and `closedPRs` counts
1120
- * are summed from repo score records. `totalTracked` reflects the number of repositories with
1121
- * score records above the minStars threshold.
1122
- *
1123
- * Note: `excludeRepos`/`excludeOrgs` only affect issue discovery, not stats (#591).
1124
- * @returns A Stats snapshot computed from the current state.
1125
- */
1126
342
  getStats() {
1127
- let totalMerged = 0;
1128
- let totalClosed = 0;
1129
- let totalTracked = 0;
1130
- for (const score of Object.values(this.state.repoScores)) {
1131
- if (isBelowMinStars(score.stargazersCount, this.state.config.minStars ?? 50))
1132
- continue;
1133
- totalTracked++;
1134
- totalMerged += score.mergedPRCount;
1135
- totalClosed += score.closedWithoutMergeCount;
1136
- }
1137
- const completed = totalMerged + totalClosed;
1138
- const mergeRate = completed > 0 ? (totalMerged / completed) * 100 : 0;
1139
- return {
1140
- mergedPRs: totalMerged,
1141
- closedPRs: totalClosed,
1142
- activeIssues: 0,
1143
- trustedProjects: this.state.config.trustedProjects.length,
1144
- mergeRate: mergeRate.toFixed(1) + '%',
1145
- totalTracked,
1146
- needsResponse: 0,
1147
- };
343
+ return repoScoring.getStats(this.state);
1148
344
  }
1149
345
  }
1150
346
  // Singleton instance
1151
347
  let stateManager = null;
1152
348
  /**
1153
349
  * Get the singleton StateManager instance, creating it on first call.
1154
- * Subsequent calls return the same instance. Use {@link resetStateManager} to
1155
- * clear the singleton (primarily for testing).
1156
- * @returns The shared StateManager instance.
1157
350
  */
1158
351
  export function getStateManager() {
1159
352
  if (!stateManager) {
@@ -1162,9 +355,7 @@ export function getStateManager() {
1162
355
  return stateManager;
1163
356
  }
1164
357
  /**
1165
- * Reset the singleton StateManager instance to null. The next call to
1166
- * {@link getStateManager} will create a fresh instance. Intended for test
1167
- * isolation -- should not be called in production code.
358
+ * Reset the singleton StateManager instance to null. Intended for test isolation.
1168
359
  */
1169
360
  export function resetStateManager() {
1170
361
  stateManager = null;