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