@oss-autopilot/core 0.59.0 → 0.60.1

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.
@@ -5,14 +5,15 @@
5
5
  import type { Octokit } from '@octokit/rest';
6
6
  import { CIFailureCategory, ClassifiedCheck, CIStatusResult } from './types.js';
7
7
  /**
8
- * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145).
8
+ * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145, #743).
9
9
  * Default is 'actionable' — only known patterns get reclassified.
10
- * When conclusion is provided (cancelled, timed_out), the check is classified as infrastructure.
10
+ * Conclusion-based classification (cancelled, timed_out, action_required) takes precedence
11
+ * over name-based pattern matching.
11
12
  */
12
13
  export declare function classifyCICheck(name: string, description?: string, conclusion?: string): CIFailureCategory;
13
14
  /**
14
- * Classify all failing checks and return both the flat names array and classified array (#81, #145).
15
- * Accepts optional conclusion data to detect infrastructure failures.
15
+ * Classify all failing checks and return a ClassifiedCheck array (#81, #145, #743).
16
+ * Accepts optional conclusion data to detect infrastructure failures and auth gates.
16
17
  */
17
18
  export declare function classifyFailingChecks(failingCheckNames: string[], conclusions?: Map<string, string>): ClassifiedCheck[];
18
19
  /**
@@ -38,16 +38,21 @@ const INFRASTRUCTURE_PATTERNS = [
38
38
  /\bservice\s*unavailable/i,
39
39
  /\binfrastructure/i,
40
40
  /\bblacksmith\b/i,
41
+ /\breadthedocs\b/i,
41
42
  ];
42
43
  /**
43
- * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145).
44
+ * Classify a failing CI check as actionable, fork_limitation, auth_gate, or infrastructure (#81, #145, #743).
44
45
  * Default is 'actionable' — only known patterns get reclassified.
45
- * When conclusion is provided (cancelled, timed_out), the check is classified as infrastructure.
46
+ * Conclusion-based classification (cancelled, timed_out, action_required) takes precedence
47
+ * over name-based pattern matching.
46
48
  */
47
49
  export function classifyCICheck(name, description, conclusion) {
48
50
  // Infrastructure: cancelled or timed_out jobs are transient failures (#145)
49
51
  if (conclusion === 'cancelled' || conclusion === 'timed_out')
50
52
  return 'infrastructure';
53
+ // Auth gate: action_required means the workflow needs external approval (e.g., fork PR or first-time contributor)
54
+ if (conclusion === 'action_required')
55
+ return 'auth_gate';
51
56
  const nameLower = name.toLowerCase();
52
57
  // Check name first (more reliable than description)
53
58
  if (AUTH_GATE_PATTERNS.some((p) => p.test(nameLower)))
@@ -69,8 +74,8 @@ export function classifyCICheck(name, description, conclusion) {
69
74
  return 'actionable';
70
75
  }
71
76
  /**
72
- * Classify all failing checks and return both the flat names array and classified array (#81, #145).
73
- * Accepts optional conclusion data to detect infrastructure failures.
77
+ * Classify all failing checks and return a ClassifiedCheck array (#81, #145, #743).
78
+ * Accepts optional conclusion data to detect infrastructure failures and auth gates.
74
79
  */
75
80
  export function classifyFailingChecks(failingCheckNames, conclusions) {
76
81
  return failingCheckNames.map((name) => {
@@ -93,14 +98,14 @@ export function analyzeCheckRuns(checkRuns) {
93
98
  const failingCheckNames = [];
94
99
  const failingCheckConclusions = new Map();
95
100
  for (const check of checkRuns) {
96
- if (check.conclusion === 'failure' || check.conclusion === 'cancelled' || check.conclusion === 'timed_out') {
101
+ if (check.conclusion === 'failure' ||
102
+ check.conclusion === 'cancelled' ||
103
+ check.conclusion === 'timed_out' ||
104
+ check.conclusion === 'action_required') {
97
105
  hasFailingChecks = true;
98
106
  failingCheckNames.push(check.name);
99
107
  failingCheckConclusions.set(check.name, check.conclusion);
100
108
  }
101
- else if (check.conclusion === 'action_required') {
102
- hasPendingChecks = true; // Maintainer approval gate, not a real failure
103
- }
104
109
  else if (check.status === 'in_progress' || check.status === 'queued') {
105
110
  hasPendingChecks = true;
106
111
  }
@@ -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) {