@oss-autopilot/core 0.51.1 → 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.
- package/dist/cli.bundle.cjs +42 -42
- package/dist/cli.bundle.cjs.map +4 -4
- package/dist/core/index.d.ts +1 -1
- package/dist/core/repo-score-manager.d.ts +76 -0
- package/dist/core/repo-score-manager.js +204 -0
- package/dist/core/state-persistence.d.ts +53 -0
- package/dist/core/state-persistence.js +510 -0
- package/dist/core/state.d.ts +14 -311
- package/dist/core/state.js +48 -901
- package/package.json +1 -1
package/dist/core/state.js
CHANGED
|
@@ -1,170 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* State management for the OSS Contribution Agent
|
|
3
|
-
*
|
|
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
|
|
6
|
-
import * as
|
|
7
|
-
import {
|
|
8
|
-
|
|
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
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|