@oss-autopilot/core 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/cli-registry.js +54 -0
  2. package/dist/cli.bundle.cjs +151 -108
  3. package/dist/commands/comments.d.ts +28 -0
  4. package/dist/commands/comments.js +28 -0
  5. package/dist/commands/config.d.ts +11 -0
  6. package/dist/commands/config.js +11 -0
  7. package/dist/commands/daily.d.ts +26 -2
  8. package/dist/commands/daily.js +26 -2
  9. package/dist/commands/detect-formatters.d.ts +11 -0
  10. package/dist/commands/detect-formatters.js +24 -0
  11. package/dist/commands/dismiss.d.ts +17 -0
  12. package/dist/commands/dismiss.js +17 -0
  13. package/dist/commands/index.d.ts +3 -1
  14. package/dist/commands/index.js +2 -0
  15. package/dist/commands/init.d.ts +8 -0
  16. package/dist/commands/init.js +8 -0
  17. package/dist/commands/move.d.ts +10 -0
  18. package/dist/commands/move.js +10 -0
  19. package/dist/commands/search.d.ts +18 -0
  20. package/dist/commands/search.js +18 -0
  21. package/dist/commands/setup.d.ts +17 -0
  22. package/dist/commands/setup.js +17 -0
  23. package/dist/commands/shelve.d.ts +16 -0
  24. package/dist/commands/shelve.js +16 -0
  25. package/dist/commands/startup.d.ts +16 -7
  26. package/dist/commands/startup.js +16 -7
  27. package/dist/commands/status.d.ts +8 -0
  28. package/dist/commands/status.js +8 -0
  29. package/dist/commands/track.d.ts +16 -0
  30. package/dist/commands/track.js +16 -0
  31. package/dist/commands/vet.d.ts +8 -0
  32. package/dist/commands/vet.js +8 -0
  33. package/dist/core/daily-logic.d.ts +60 -7
  34. package/dist/core/daily-logic.js +52 -7
  35. package/dist/core/formatter-detection.d.ts +61 -0
  36. package/dist/core/formatter-detection.js +360 -0
  37. package/dist/core/github.d.ts +25 -2
  38. package/dist/core/github.js +25 -2
  39. package/dist/core/index.d.ts +1 -0
  40. package/dist/core/index.js +1 -0
  41. package/dist/core/issue-discovery.d.ts +46 -6
  42. package/dist/core/issue-discovery.js +46 -6
  43. package/dist/core/logger.d.ts +13 -0
  44. package/dist/core/logger.js +13 -0
  45. package/dist/core/pr-monitor.d.ts +43 -8
  46. package/dist/core/pr-monitor.js +43 -8
  47. package/dist/core/state-persistence.d.ts +1 -0
  48. package/dist/core/state-persistence.js +46 -84
  49. package/dist/core/state-schema.d.ts +539 -0
  50. package/dist/core/state-schema.js +214 -0
  51. package/dist/core/state.d.ts +167 -0
  52. package/dist/core/state.js +167 -0
  53. package/dist/core/types.d.ts +4 -318
  54. package/dist/core/types.js +7 -41
  55. package/dist/formatters/json.d.ts +5 -0
  56. package/package.json +8 -4
@@ -9,18 +9,31 @@ export declare function enableDebug(): void;
9
9
  export declare function isDebugEnabled(): boolean;
10
10
  /**
11
11
  * Log a debug message. Only outputs when --debug is enabled.
12
+ * @param module - Module name for log prefix
13
+ * @param message - Log message
14
+ * @param args - Additional values to log
12
15
  */
13
16
  export declare function debug(module: string, message: string, ...args: unknown[]): void;
14
17
  /**
15
18
  * Log an informational message. Always outputs to stderr.
16
19
  * Use for user-facing progress indicators during long-running operations.
20
+ * @param module - Module name for log prefix
21
+ * @param message - Log message
22
+ * @param args - Additional values to log
17
23
  */
18
24
  export declare function info(module: string, message: string, ...args: unknown[]): void;
19
25
  /**
20
26
  * Log a warning. Always outputs.
27
+ * @param module - Module name for log prefix
28
+ * @param message - Warning message
29
+ * @param args - Additional values to log
21
30
  */
22
31
  export declare function warn(module: string, message: string, ...args: unknown[]): void;
23
32
  /**
24
33
  * Time an async operation and log duration in debug mode.
34
+ * @param module - Module name for log prefix
35
+ * @param label - Operation label for the timing log
36
+ * @param fn - Async function to time
37
+ * @returns The result of the async function
25
38
  */
26
39
  export declare function timed<T>(module: string, label: string, fn: () => Promise<T>): Promise<T>;
@@ -14,6 +14,9 @@ export function isDebugEnabled() {
14
14
  }
15
15
  /**
16
16
  * Log a debug message. Only outputs when --debug is enabled.
17
+ * @param module - Module name for log prefix
18
+ * @param message - Log message
19
+ * @param args - Additional values to log
17
20
  */
18
21
  export function debug(module, message, ...args) {
19
22
  if (!debugEnabled)
@@ -24,6 +27,9 @@ export function debug(module, message, ...args) {
24
27
  /**
25
28
  * Log an informational message. Always outputs to stderr.
26
29
  * Use for user-facing progress indicators during long-running operations.
30
+ * @param module - Module name for log prefix
31
+ * @param message - Log message
32
+ * @param args - Additional values to log
27
33
  */
28
34
  export function info(module, message, ...args) {
29
35
  const timestamp = new Date().toISOString();
@@ -31,6 +37,9 @@ export function info(module, message, ...args) {
31
37
  }
32
38
  /**
33
39
  * Log a warning. Always outputs.
40
+ * @param module - Module name for log prefix
41
+ * @param message - Warning message
42
+ * @param args - Additional values to log
34
43
  */
35
44
  export function warn(module, message, ...args) {
36
45
  const timestamp = new Date().toISOString();
@@ -38,6 +47,10 @@ export function warn(module, message, ...args) {
38
47
  }
39
48
  /**
40
49
  * Time an async operation and log duration in debug mode.
50
+ * @param module - Module name for log prefix
51
+ * @param label - Operation label for the timing log
52
+ * @param fn - Async function to time
53
+ * @returns The result of the async function
41
54
  */
42
55
  export async function timed(module, label, fn) {
43
56
  if (!debugEnabled)
@@ -21,6 +21,9 @@ export { determineStatus } from './status-determination.js';
21
21
  /**
22
22
  * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
23
23
  * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
24
+ *
25
+ * @param mergeable - GitHub's mergeable flag (null when not yet computed)
26
+ * @param mergeableState - GitHub's mergeable_state string
24
27
  */
25
28
  export declare function hasMergeConflict(mergeable: boolean | null, mergeableState: string | null): boolean;
26
29
  export interface PRCheckFailure {
@@ -31,13 +34,35 @@ export interface FetchPRsResult {
31
34
  prs: FetchedPR[];
32
35
  failures: PRCheckFailure[];
33
36
  }
37
+ /**
38
+ * Fetches and enriches open PRs from GitHub for the configured user.
39
+ *
40
+ * In v2, all PR data is fetched fresh on each run — no local PR tracking.
41
+ * CI status, reviews, merge conflicts, and maintainer comments are enriched
42
+ * for each PR to compute a {@link FetchedPRStatus}.
43
+ */
34
44
  export declare class PRMonitor {
35
45
  private octokit;
36
46
  private stateManager;
47
+ /**
48
+ * @param githubToken - GitHub personal access token or token from `gh auth token`
49
+ */
37
50
  constructor(githubToken: string);
38
51
  /**
39
- * Fetch all open PRs for the configured user fresh from GitHub
40
- * This is the main entry point for the v2 architecture
52
+ * Fetch all open PRs for the configured user fresh from GitHub.
53
+ * This is the main entry point for the v2 architecture.
54
+ *
55
+ * @returns All open PRs enriched with status, plus any failures
56
+ * @throws {ConfigurationError} If no GitHub username is configured
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * import { PRMonitor, requireGitHubToken } from '@oss-autopilot/core';
61
+ *
62
+ * const monitor = new PRMonitor(requireGitHubToken());
63
+ * const { prs, failures } = await monitor.fetchUserOpenPRs();
64
+ * console.log(`Found ${prs.length} open PRs, ${failures.length} failures`);
65
+ * ```
41
66
  */
42
67
  fetchUserOpenPRs(): Promise<FetchPRsResult>;
43
68
  /**
@@ -51,7 +76,8 @@ export declare class PRMonitor {
51
76
  private buildFetchedPR;
52
77
  /**
53
78
  * Fetch merged PR counts and latest merge dates per repository for the configured user.
54
- * Delegates to github-stats module.
79
+ * @param starFilter - Optional filter to exclude low-star repos
80
+ * @returns Per-repo merged counts with monthly breakdowns
55
81
  */
56
82
  fetchUserMergedPRCounts(starFilter?: StarFilter): Promise<PRCountsResult<{
57
83
  count: number;
@@ -59,7 +85,8 @@ export declare class PRMonitor {
59
85
  }>>;
60
86
  /**
61
87
  * Fetch closed-without-merge PR counts per repository for the configured user.
62
- * Delegates to github-stats module.
88
+ * @param starFilter - Optional filter to exclude low-star repos
89
+ * @returns Per-repo closed counts with monthly breakdowns
63
90
  */
64
91
  fetchUserClosedPRCounts(starFilter?: StarFilter): Promise<PRCountsResult<number>>;
65
92
  /**
@@ -72,20 +99,28 @@ export declare class PRMonitor {
72
99
  }>>;
73
100
  /**
74
101
  * Fetch PRs closed without merge in the last N days.
75
- * Delegates to github-stats module.
102
+ * @param days - Lookback window in days (default: 7)
103
+ * @returns Recently closed PRs
76
104
  */
77
105
  fetchRecentlyClosedPRs(days?: number): Promise<ClosedPR[]>;
78
106
  /**
79
107
  * Fetch PRs merged in the last N days.
80
- * Delegates to github-stats module.
108
+ * @param days - Lookback window in days (default: 7)
109
+ * @returns Recently merged PRs
81
110
  */
82
111
  fetchRecentlyMergedPRs(days?: number): Promise<MergedPR[]>;
83
112
  /**
84
- * Generate a daily digest from fetched PRs
113
+ * Generate a daily digest from fetched PRs.
114
+ * @param prs - All open PRs (active + shelved)
115
+ * @param recentlyClosedPRs - PRs closed without merge in the last 7 days
116
+ * @param recentlyMergedPRs - PRs merged in the last 7 days
117
+ * @returns Daily digest with categorized PRs and summary stats
85
118
  */
86
119
  generateDigest(prs: FetchedPR[], recentlyClosedPRs?: ClosedPR[], recentlyMergedPRs?: MergedPR[]): DailyDigest;
87
120
  /**
88
- * Update repository scores based on observed PR (called when we detect merged/closed PRs)
121
+ * Update repository scores based on observed PR (called when we detect merged/closed PRs).
122
+ * @param repo - Repository in "owner/repo" format
123
+ * @param wasMerged - true if the PR was merged, false if closed without merge
89
124
  */
90
125
  updateRepoScoreFromObservedPR(repo: string, wasMerged: boolean): Promise<void>;
91
126
  }
@@ -35,22 +35,47 @@ export { determineStatus } from './status-determination.js';
35
35
  /**
36
36
  * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
37
37
  * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
38
+ *
39
+ * @param mergeable - GitHub's mergeable flag (null when not yet computed)
40
+ * @param mergeableState - GitHub's mergeable_state string
38
41
  */
39
42
  export function hasMergeConflict(mergeable, mergeableState) {
40
43
  return mergeable === false || mergeableState === 'dirty';
41
44
  }
42
45
  const MODULE = 'pr-monitor';
43
46
  const MAX_CONCURRENT_REQUESTS = DEFAULT_CONCURRENCY;
47
+ /**
48
+ * Fetches and enriches open PRs from GitHub for the configured user.
49
+ *
50
+ * In v2, all PR data is fetched fresh on each run — no local PR tracking.
51
+ * CI status, reviews, merge conflicts, and maintainer comments are enriched
52
+ * for each PR to compute a {@link FetchedPRStatus}.
53
+ */
44
54
  export class PRMonitor {
45
55
  octokit;
46
56
  stateManager;
57
+ /**
58
+ * @param githubToken - GitHub personal access token or token from `gh auth token`
59
+ */
47
60
  constructor(githubToken) {
48
61
  this.octokit = getOctokit(githubToken);
49
62
  this.stateManager = getStateManager();
50
63
  }
51
64
  /**
52
- * Fetch all open PRs for the configured user fresh from GitHub
53
- * This is the main entry point for the v2 architecture
65
+ * Fetch all open PRs for the configured user fresh from GitHub.
66
+ * This is the main entry point for the v2 architecture.
67
+ *
68
+ * @returns All open PRs enriched with status, plus any failures
69
+ * @throws {ConfigurationError} If no GitHub username is configured
70
+ *
71
+ * @example
72
+ * ```typescript
73
+ * import { PRMonitor, requireGitHubToken } from '@oss-autopilot/core';
74
+ *
75
+ * const monitor = new PRMonitor(requireGitHubToken());
76
+ * const { prs, failures } = await monitor.fetchUserOpenPRs();
77
+ * console.log(`Found ${prs.length} open PRs, ${failures.length} failures`);
78
+ * ```
54
79
  */
55
80
  async fetchUserOpenPRs() {
56
81
  const config = this.stateManager.getState().config;
@@ -287,7 +312,8 @@ export class PRMonitor {
287
312
  }
288
313
  /**
289
314
  * Fetch merged PR counts and latest merge dates per repository for the configured user.
290
- * Delegates to github-stats module.
315
+ * @param starFilter - Optional filter to exclude low-star repos
316
+ * @returns Per-repo merged counts with monthly breakdowns
291
317
  */
292
318
  async fetchUserMergedPRCounts(starFilter) {
293
319
  const config = this.stateManager.getState().config;
@@ -295,7 +321,8 @@ export class PRMonitor {
295
321
  }
296
322
  /**
297
323
  * Fetch closed-without-merge PR counts per repository for the configured user.
298
- * Delegates to github-stats module.
324
+ * @param starFilter - Optional filter to exclude low-star repos
325
+ * @returns Per-repo closed counts with monthly breakdowns
299
326
  */
300
327
  async fetchUserClosedPRCounts(starFilter) {
301
328
  const config = this.stateManager.getState().config;
@@ -357,7 +384,8 @@ export class PRMonitor {
357
384
  }
358
385
  /**
359
386
  * Fetch PRs closed without merge in the last N days.
360
- * Delegates to github-stats module.
387
+ * @param days - Lookback window in days (default: 7)
388
+ * @returns Recently closed PRs
361
389
  */
362
390
  async fetchRecentlyClosedPRs(days = 7) {
363
391
  const config = this.stateManager.getState().config;
@@ -365,14 +393,19 @@ export class PRMonitor {
365
393
  }
366
394
  /**
367
395
  * Fetch PRs merged in the last N days.
368
- * Delegates to github-stats module.
396
+ * @param days - Lookback window in days (default: 7)
397
+ * @returns Recently merged PRs
369
398
  */
370
399
  async fetchRecentlyMergedPRs(days = 7) {
371
400
  const config = this.stateManager.getState().config;
372
401
  return fetchRecentlyMergedPRsImpl(this.octokit, config, days);
373
402
  }
374
403
  /**
375
- * Generate a daily digest from fetched PRs
404
+ * Generate a daily digest from fetched PRs.
405
+ * @param prs - All open PRs (active + shelved)
406
+ * @param recentlyClosedPRs - PRs closed without merge in the last 7 days
407
+ * @param recentlyMergedPRs - PRs merged in the last 7 days
408
+ * @returns Daily digest with categorized PRs and summary stats
376
409
  */
377
410
  generateDigest(prs, recentlyClosedPRs = [], recentlyMergedPRs = []) {
378
411
  const now = new Date().toISOString();
@@ -399,7 +432,9 @@ export class PRMonitor {
399
432
  };
400
433
  }
401
434
  /**
402
- * Update repository scores based on observed PR (called when we detect merged/closed PRs)
435
+ * Update repository scores based on observed PR (called when we detect merged/closed PRs).
436
+ * @param repo - Repository in "owner/repo" format
437
+ * @param wasMerged - true if the PR was merged, false if closed without merge
403
438
  */
404
439
  async updateRepoScoreFromObservedPR(repo, wasMerged) {
405
440
  if (wasMerged) {
@@ -24,6 +24,7 @@ export declare function releaseLock(lockPath: string): void;
24
24
  export declare function atomicWriteFileSync(filePath: string, data: string, mode?: number): void;
25
25
  /**
26
26
  * Create a fresh state (v2: fresh GitHub fetching).
27
+ * Leverages Zod schema defaults to produce a complete state.
27
28
  */
28
29
  export declare function createFreshState(): AgentState;
29
30
  /**
@@ -5,13 +5,11 @@
5
5
  */
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
- import { INITIAL_STATE } from './types.js';
8
+ import { AgentStateSchema } from './state-schema.js';
9
9
  import { getStatePath, getBackupDir, getDataDir } from './utils.js';
10
10
  import { errorMessage } from './errors.js';
11
11
  import { debug, warn } from './logger.js';
12
12
  const MODULE = 'state';
13
- // Current state version
14
- const CURRENT_STATE_VERSION = 2;
15
13
  // Lock file timeout: if a lock is older than this, it is considered stale
16
14
  const LOCK_TIMEOUT_MS = 30_000; // 30 seconds
17
15
  // Legacy path for migration
@@ -139,66 +137,12 @@ function migrateV1ToV2(rawState) {
139
137
  debug(MODULE, `Migration complete. Preserved ${Object.keys(repoScores).length} repo scores.`);
140
138
  return migratedState;
141
139
  }
142
- /**
143
- * Validate that a loaded state has the required structure.
144
- * Handles both v1 (with PR arrays) and v2 (without).
145
- */
146
- function isValidState(state) {
147
- if (!state || typeof state !== 'object')
148
- return false;
149
- const s = state;
150
- // Migrate older states that don't have repoScores
151
- if (s.repoScores === undefined) {
152
- s.repoScores = {};
153
- }
154
- // Migrate older states that don't have events
155
- if (s.events === undefined) {
156
- s.events = [];
157
- }
158
- // Migrate older states that don't have mergedPRs
159
- if (s.mergedPRs === undefined) {
160
- s.mergedPRs = [];
161
- }
162
- // Base requirements for all versions
163
- const hasBaseFields = typeof s.version === 'number' &&
164
- typeof s.repoScores === 'object' &&
165
- s.repoScores !== null &&
166
- Array.isArray(s.events) &&
167
- typeof s.config === 'object' &&
168
- s.config !== null;
169
- if (!hasBaseFields)
170
- return false;
171
- // v1 requires base PR arrays to be present (they will be dropped during migration)
172
- if (s.version === 1) {
173
- return (Array.isArray(s.activePRs) &&
174
- Array.isArray(s.dormantPRs) &&
175
- Array.isArray(s.mergedPRs) &&
176
- Array.isArray(s.closedPRs));
177
- }
178
- // v2+ doesn't require PR arrays
179
- return true;
180
- }
181
140
  /**
182
141
  * Create a fresh state (v2: fresh GitHub fetching).
142
+ * Leverages Zod schema defaults to produce a complete state.
183
143
  */
184
144
  export function createFreshState() {
185
- return {
186
- version: CURRENT_STATE_VERSION,
187
- activeIssues: [],
188
- repoScores: {},
189
- config: {
190
- ...INITIAL_STATE.config,
191
- setupComplete: false,
192
- languages: [...INITIAL_STATE.config.languages],
193
- labels: [...INITIAL_STATE.config.labels],
194
- excludeRepos: [],
195
- trustedProjects: [],
196
- shelvedPRUrls: [],
197
- dismissedIssues: {},
198
- },
199
- events: [],
200
- lastRunAt: new Date().toISOString(),
201
- };
145
+ return AgentStateSchema.parse({ version: 2 });
202
146
  }
203
147
  /**
204
148
  * Migrate state from legacy ./data/ location to ~/.oss-autopilot/.
@@ -298,13 +242,15 @@ function tryRestoreFromBackup() {
298
242
  const backupPath = path.join(backupDir, backupFile);
299
243
  try {
300
244
  const data = fs.readFileSync(backupPath, 'utf-8');
301
- let state = JSON.parse(data);
302
- if (isValidState(state)) {
245
+ let raw = JSON.parse(data);
246
+ // Migrate from v1 to v2 if needed (before schema validation)
247
+ if (typeof raw === 'object' && raw !== null && raw.version === 1) {
248
+ raw = migrateV1ToV2(raw);
249
+ }
250
+ const parsed = AgentStateSchema.safeParse(raw);
251
+ if (parsed.success) {
252
+ const state = parsed.data;
303
253
  debug(MODULE, `Successfully restored state from backup: ${backupFile}`);
304
- // Migrate from v1 to v2 if needed
305
- if (state.version === 1) {
306
- state = migrateV1ToV2(state);
307
- }
308
254
  const repoCount = Object.keys(state.repoScores).length;
309
255
  debug(MODULE, `Restored state v${state.version}: ${repoCount} repo scores`);
310
256
  // Overwrite the corrupted main state file with the restored backup (atomic write)
@@ -313,6 +259,10 @@ function tryRestoreFromBackup() {
313
259
  debug(MODULE, 'Restored backup written to main state file');
314
260
  return state;
315
261
  }
262
+ // safeParse failed — log and try next backup
263
+ const summary = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
264
+ warn(MODULE, `Backup ${backupFile} failed schema validation: ${summary}`);
265
+ debug(MODULE, `Backup ${backupFile} full validation errors:`, parsed.error.issues);
316
266
  }
317
267
  catch (backupErr) {
318
268
  // This backup is also corrupted, try the next one
@@ -335,10 +285,29 @@ export function loadState() {
335
285
  try {
336
286
  if (fs.existsSync(statePath)) {
337
287
  const data = fs.readFileSync(statePath, 'utf-8');
338
- let state = JSON.parse(data);
339
- // Validate required fields exist
340
- if (!isValidState(state)) {
341
- warn(MODULE, 'Invalid state file structure, attempting to restore from backup...');
288
+ let raw = JSON.parse(data);
289
+ // Migrate from v1 to v2 if needed (before schema validation)
290
+ let wasMigrated = false;
291
+ if (typeof raw === 'object' && raw !== null && raw.version === 1) {
292
+ raw = migrateV1ToV2(raw);
293
+ wasMigrated = true;
294
+ }
295
+ // Validate through Zod schema (strips unknown keys in memory; stale keys persist on disk until next save)
296
+ const parsed = AgentStateSchema.safeParse(raw);
297
+ if (!parsed.success) {
298
+ const summary = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
299
+ warn(MODULE, `Invalid state file structure: ${summary}`);
300
+ warn(MODULE, 'Attempting to restore from backup...');
301
+ debug(MODULE, 'Full validation errors:', parsed.error.issues);
302
+ // Preserve the rejected state file so the user can recover
303
+ try {
304
+ const rejectedPath = statePath + '.rejected-' + Date.now();
305
+ fs.copyFileSync(statePath, rejectedPath);
306
+ warn(MODULE, `Previous state preserved at: ${rejectedPath}`);
307
+ }
308
+ catch (preserveErr) {
309
+ warn(MODULE, `Could not preserve rejected state file: ${errorMessage(preserveErr)}`);
310
+ }
342
311
  const restoredState = tryRestoreFromBackup();
343
312
  if (restoredState) {
344
313
  const mtimeMs = safeGetMtimeMs(statePath);
@@ -347,23 +316,16 @@ export function loadState() {
347
316
  warn(MODULE, 'No valid backup found, starting fresh');
348
317
  return { state: createFreshState(), mtimeMs: 0 };
349
318
  }
350
- // Migrate from v1 to v2 if needed
351
- if (state.version === 1) {
352
- state = migrateV1ToV2(state);
353
- // Save the migrated state immediately (atomic write)
354
- atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
355
- debug(MODULE, 'Migrated state saved');
319
+ // Save migrated state only after validation succeeds
320
+ if (wasMigrated) {
321
+ atomicWriteFileSync(statePath, JSON.stringify(parsed.data, null, 2), 0o600);
322
+ debug(MODULE, 'Migrated and validated state saved');
356
323
  }
357
- // Strip legacy fields from persisted state (snoozedPRs and PR dismiss
358
- // entries were removed in the three-state PR model simplification)
324
+ const state = parsed.data;
325
+ // Strip PR URLs from dismissedIssues (PR dismiss removed).
326
+ // This filters values inside a known field — Zod .strip() only removes unknown keys.
359
327
  try {
360
328
  let needsCleanupSave = false;
361
- const rawConfig = state.config;
362
- if (rawConfig.snoozedPRs) {
363
- delete rawConfig.snoozedPRs;
364
- needsCleanupSave = true;
365
- }
366
- // Strip PR URLs from dismissedIssues (PR dismiss removed)
367
329
  if (state.config.dismissedIssues) {
368
330
  const PR_URL_RE = /\/pull\/\d+$/;
369
331
  for (const url of Object.keys(state.config.dismissedIssues)) {
@@ -375,7 +337,7 @@ export function loadState() {
375
337
  }
376
338
  if (needsCleanupSave) {
377
339
  atomicWriteFileSync(statePath, JSON.stringify(state, null, 2), 0o600);
378
- warn(MODULE, 'Cleaned up removed features (snoozedPRs, dismissed PR URLs) from persisted state');
340
+ warn(MODULE, 'Cleaned up dismissed PR URLs from persisted state');
379
341
  }
380
342
  }
381
343
  catch (cleanupError) {