@oss-autopilot/core 1.8.0 → 1.10.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.
@@ -185,6 +185,17 @@ export async function runClaim(options) {
185
185
  updatedAt: new Date().toISOString(),
186
186
  vetted: false,
187
187
  });
188
+ // Push state to Gist if in Gist mode.
189
+ // If getStateManagerAsync was not called before this command ran,
190
+ // isGistMode() will be false and checkpoint is correctly skipped.
191
+ try {
192
+ if (stateManager.isGistMode()) {
193
+ await stateManager.checkpoint();
194
+ }
195
+ }
196
+ catch {
197
+ /* best-effort */
198
+ }
188
199
  }
189
200
  catch (error) {
190
201
  console.error(`Warning: Comment posted on ${options.issueUrl} but failed to save to local state: ${error instanceof Error ? error.message : error}`);
@@ -3,7 +3,6 @@
3
3
  * Shows or updates configuration
4
4
  */
5
5
  import { getStateManager } from '../core/index.js';
6
- import { ValidationError } from '../core/errors.js';
7
6
  import { ISSUE_SCOPES } from '../core/types.js';
8
7
  import { validateGitHubUsername } from './validation.js';
9
8
  function validateScope(value) {
@@ -106,14 +105,6 @@ export async function runConfig(options) {
106
105
  case 'issueListPath':
107
106
  stateManager.updateConfig({ issueListPath: value || undefined });
108
107
  break;
109
- case 'scoreThreshold': {
110
- const threshold = Number(value);
111
- if (!Number.isInteger(threshold) || threshold < 1 || threshold > 10) {
112
- throw new ValidationError(`Invalid value for scoreThreshold: "${value}". Must be an integer between 1 and 10.`);
113
- }
114
- stateManager.updateConfig({ scoreThreshold: threshold });
115
- break;
116
- }
117
108
  default:
118
109
  throw new Error(`Unknown config key: ${options.key}`);
119
110
  }
@@ -424,11 +424,21 @@ async function executeDailyCheckInternal(token) {
424
424
  const { prs, failures, mergedCounts, closedCounts, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed, recentlyClosedPRs, recentlyMergedPRs, commentedIssues, } = await fetchPRData(prMonitor, token);
425
425
  // Phase 2: Update repo scores (signals, star counts, trust sync)
426
426
  await updateRepoScores(prMonitor, prs, mergedCounts, closedCounts);
427
- // Phase 3: Persist monthly analytics (batch the 3 monthly setter calls).
427
+ // Phase 3: Persist monthly analytics and store merged/closed PR history.
428
428
  // try-catch: analytics are supplementary — save failure should not crash the daily check.
429
429
  try {
430
430
  getStateManager().batch(() => {
431
431
  updateMonthlyAnalytics(prs, monthlyCounts, monthlyClosedCounts, openedFromMerged, openedFromClosed);
432
+ // Store recently merged/closed PRs in the persistent arrays.
433
+ // This ensures the mergedPRs/closedPRs ledger is populated even when
434
+ // the dashboard is never opened (which has its own fetch path).
435
+ // addMergedPRs/addClosedPRs deduplicate by URL, so overlaps are safe.
436
+ if (recentlyMergedPRs.length > 0) {
437
+ getStateManager().addMergedPRs(recentlyMergedPRs.map((pr) => ({ url: pr.url, title: pr.title, mergedAt: pr.mergedAt })));
438
+ }
439
+ if (recentlyClosedPRs.length > 0) {
440
+ getStateManager().addClosedPRs(recentlyClosedPRs.map((pr) => ({ url: pr.url, title: pr.title, closedAt: pr.closedAt })));
441
+ }
432
442
  });
433
443
  }
434
444
  catch (error) {
@@ -440,7 +450,20 @@ async function executeDailyCheckInternal(token) {
440
450
  // Phase 4: Partition PRs, generate and save digest
441
451
  const { activePRs, shelvedPRs, digest } = partitionPRs(prMonitor, prs, recentlyClosedPRs, recentlyMergedPRs);
442
452
  // Phase 5: Build structured output (capacity, dismiss filter, action menu)
443
- return generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt);
453
+ const result = generateDigestOutput(digest, activePRs, shelvedPRs, commentedIssues, failures, previousLastDigestAt);
454
+ // Checkpoint: push state to Gist if in Gist mode.
455
+ // If getStateManagerAsync was not called before this command ran,
456
+ // isGistMode() will be false and checkpoint is correctly skipped.
457
+ try {
458
+ const sm = getStateManager();
459
+ if (sm.isGistMode()) {
460
+ await sm.checkpoint();
461
+ }
462
+ }
463
+ catch (err) {
464
+ warn(MODULE, `Gist checkpoint failed: ${errorMessage(err)}`);
465
+ }
466
+ return result;
444
467
  }
445
468
  /**
446
469
  * Run the daily PR check and return a deduplicated digest.
@@ -63,6 +63,7 @@ export { runCheckIntegration } from './check-integration.js';
63
63
  export { runDetectFormatters } from './detect-formatters.js';
64
64
  /** Scan for locally cloned repos. */
65
65
  export { runLocalRepos } from './local-repos.js';
66
+ export type { ErrorCode } from '../formatters/json.js';
66
67
  export type { DailyOutput, SearchOutput, StartupOutput, StatusOutput, TrackOutput } from '../formatters/json.js';
67
68
  export type { VetOutput, CommentsOutput, PostOutput, ClaimOutput } from '../formatters/json.js';
68
69
  export type { ConfigOutput, DetectFormattersOutput, ParseIssueListOutput, ParsedIssueItem, CheckIntegrationOutput, LocalReposOutput, } from '../formatters/json.js';
@@ -24,7 +24,6 @@ export interface SetupCompleteOutput {
24
24
  projectCategories: ProjectCategory[];
25
25
  preferredOrgs: string[];
26
26
  scope: IssueScope[];
27
- scoreThreshold: number;
28
27
  };
29
28
  }
30
29
  export interface SetupPrompt {
@@ -14,14 +14,6 @@ function parsePositiveInt(value, settingName) {
14
14
  }
15
15
  return parsed;
16
16
  }
17
- /** Parse and validate an integer within a specific range [min, max]. */
18
- function parseBoundedInt(value, settingName, min, max) {
19
- const parsed = Number(value);
20
- if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
21
- throw new ValidationError(`Invalid value for ${settingName}: "${value}". Must be an integer between ${min} and ${max}.`);
22
- }
23
- return parsed;
24
- }
25
17
  /**
26
18
  * Interactive setup wizard or direct setting application.
27
19
  *
@@ -78,10 +70,6 @@ export async function runSetup(options) {
78
70
  stateManager.updateConfig({ labels: value.split(',').map((l) => l.trim()) });
79
71
  results[key] = value;
80
72
  break;
81
- case 'showHealthCheck':
82
- stateManager.updateConfig({ showHealthCheck: value !== 'false' });
83
- results[key] = value !== 'false' ? 'true' : 'false';
84
- break;
85
73
  case 'squashByDefault':
86
74
  if (value === 'ask') {
87
75
  stateManager.updateConfig({ squashByDefault: 'ask' });
@@ -92,12 +80,6 @@ export async function runSetup(options) {
92
80
  results[key] = value !== 'false' ? 'true' : 'false';
93
81
  }
94
82
  break;
95
- case 'scoreThreshold': {
96
- const threshold = parseBoundedInt(value, 'scoreThreshold', 1, 10);
97
- stateManager.updateConfig({ scoreThreshold: threshold });
98
- results[key] = String(threshold);
99
- break;
100
- }
101
83
  case 'minStars': {
102
84
  const stars = Number(value);
103
85
  if (!Number.isInteger(stars) || stars < 0) {
@@ -239,7 +221,6 @@ export async function runSetup(options) {
239
221
  projectCategories: config.projectCategories ?? [],
240
222
  preferredOrgs: config.preferredOrgs ?? [],
241
223
  scope: config.scope ?? [],
242
- scoreThreshold: config.scoreThreshold,
243
224
  },
244
225
  };
245
226
  }
@@ -296,13 +277,6 @@ export async function runSetup(options) {
296
277
  default: [],
297
278
  type: 'list',
298
279
  },
299
- {
300
- setting: 'scoreThreshold',
301
- prompt: 'Minimum vet score (1-10) for issues to keep after vetting? Issues below this are auto-filtered.',
302
- current: config.scoreThreshold,
303
- default: 6,
304
- type: 'number',
305
- },
306
280
  {
307
281
  setting: 'aiPolicyBlocklist',
308
282
  prompt: 'Repos with anti-AI contribution policies to block (owner/repo, comma-separated)?',
@@ -7,6 +7,7 @@
7
7
  * `node cli.bundle.cjs startup --json` call, reducing UI noise in Claude Code.
8
8
  */
9
9
  import * as fs from 'fs';
10
+ import * as path from 'path';
10
11
  import { execFile } from 'child_process';
11
12
  import { getStateManager, getGitHubToken, getCLIVersion, detectGitHubUsername } from '../core/index.js';
12
13
  import { errorMessage } from '../core/errors.js';
@@ -102,15 +103,36 @@ export function detectIssueList() {
102
103
  if (!issueListPath)
103
104
  return undefined;
104
105
  // 4. Count available/completed items
106
+ let availableCount = 0;
107
+ let completedCount = 0;
105
108
  try {
106
109
  const content = fs.readFileSync(issueListPath, 'utf-8');
107
- const { availableCount, completedCount } = countIssueListItems(content);
108
- return { path: issueListPath, source, availableCount, completedCount };
110
+ ({ availableCount, completedCount } = countIssueListItems(content));
109
111
  }
110
112
  catch (error) {
111
113
  console.error(`[STARTUP] Failed to read issue list at ${issueListPath}:`, errorMessage(error));
112
- return { path: issueListPath, source, availableCount: 0, completedCount: 0 };
113
114
  }
115
+ // 5. Detect skipped issues file
116
+ let skippedIssuesPath;
117
+ // Check config first
118
+ try {
119
+ const stateManager = getStateManager();
120
+ const configuredSkipPath = stateManager.getState().config.skippedIssuesPath;
121
+ if (configuredSkipPath && fs.existsSync(configuredSkipPath)) {
122
+ skippedIssuesPath = configuredSkipPath;
123
+ }
124
+ }
125
+ catch {
126
+ /* fall through */
127
+ }
128
+ // Probe default path: same directory as issue list, named skipped-issues.md
129
+ if (!skippedIssuesPath && issueListPath) {
130
+ const defaultSkipPath = path.join(path.dirname(issueListPath), 'skipped-issues.md');
131
+ if (fs.existsSync(defaultSkipPath)) {
132
+ skippedIssuesPath = defaultSkipPath;
133
+ }
134
+ }
135
+ return { path: issueListPath, source, availableCount, completedCount, skippedIssuesPath };
114
136
  }
115
137
  /**
116
138
  * Open a URL in the default system browser.
@@ -39,3 +39,9 @@ export declare function getHttpStatusCode(error: unknown): number | undefined;
39
39
  export declare function isRateLimitError(error: unknown): boolean;
40
40
  /** Return true for errors that should propagate (not degrade gracefully): rate limits, auth failures, abuse detection. */
41
41
  export declare function isRateLimitOrAuthError(err: unknown): boolean;
42
+ /**
43
+ * Map an unknown error to a structured ErrorCode for JSON output.
44
+ * Checks custom error classes, HTTP status codes (Octokit errors),
45
+ * and error message patterns in priority order.
46
+ */
47
+ export declare function resolveErrorCode(err: unknown): import('../formatters/json.js').ErrorCode;
@@ -75,3 +75,40 @@ export function isRateLimitOrAuthError(err) {
75
75
  }
76
76
  return false;
77
77
  }
78
+ /**
79
+ * Map an unknown error to a structured ErrorCode for JSON output.
80
+ * Checks custom error classes, HTTP status codes (Octokit errors),
81
+ * and error message patterns in priority order.
82
+ */
83
+ export function resolveErrorCode(err) {
84
+ // Check our custom error classes first
85
+ if (err instanceof ConfigurationError)
86
+ return 'CONFIGURATION';
87
+ if (err instanceof ValidationError)
88
+ return 'VALIDATION';
89
+ // Check HTTP status codes (Octokit errors)
90
+ const status = getHttpStatusCode(err);
91
+ if (status === 401)
92
+ return 'AUTH_REQUIRED';
93
+ if (status === 403) {
94
+ const msg = errorMessage(err).toLowerCase();
95
+ if (msg.includes('rate limit') || msg.includes('abuse detection'))
96
+ return 'RATE_LIMITED';
97
+ return 'AUTH_REQUIRED';
98
+ }
99
+ if (status === 404)
100
+ return 'NOT_FOUND';
101
+ if (status === 429)
102
+ return 'RATE_LIMITED';
103
+ // Check error message patterns
104
+ const msg = errorMessage(err).toLowerCase();
105
+ if (msg.includes('enotfound') ||
106
+ msg.includes('econnrefused') ||
107
+ msg.includes('econnreset') ||
108
+ msg.includes('etimedout') ||
109
+ msg.includes('fetch failed'))
110
+ return 'NETWORK';
111
+ if (msg.includes('state') && (msg.includes('corrupt') || msg.includes('invalid')))
112
+ return 'STATE_CORRUPTED';
113
+ return 'UNKNOWN';
114
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Gist-based persistence layer for oss-autopilot state.
3
+ *
4
+ * Manages a single private GitHub Gist that stores `state.json` (structured state)
5
+ * and potentially freeform markdown documents. Provides an in-memory file cache
6
+ * for session-scoped reads and a local cache write-through for degraded-mode fallback.
7
+ *
8
+ * Bootstrap flow:
9
+ * 1. Check for locally stored Gist ID file (`~/.oss-autopilot/gist-id`)
10
+ * 2. If found, fetch that Gist directly via `GET /gists/:id`
11
+ * 3. If not found locally, search the user's Gists for description `oss-autopilot-state`
12
+ * 4. If found via search, store the ID locally and fetch it
13
+ * 5. If not found anywhere, create a new private Gist with seed files and store the ID
14
+ * 6. Cache all Gist file contents in memory for session-scoped reads
15
+ * 7. Write state to a local cache file for fallback
16
+ */
17
+ import { AgentState } from './types.js';
18
+ /** Well-known Gist description used for search-based discovery. */
19
+ export declare const GIST_DESCRIPTION = "oss-autopilot-state";
20
+ /** Primary state file name inside the Gist. */
21
+ export declare const STATE_FILE_NAME = "state.json";
22
+ /** Result of a successful bootstrap. */
23
+ export interface BootstrapResult {
24
+ gistId: string;
25
+ state: AgentState;
26
+ created: boolean;
27
+ /** True when state was loaded from local cache due to API failure. */
28
+ degraded?: boolean;
29
+ }
30
+ /**
31
+ * Minimal Octokit-shaped interface for the Gist API methods we use.
32
+ * Accepts the real ThrottledOctokit or a plain mock object in tests.
33
+ */
34
+ export interface OctokitLike {
35
+ gists: {
36
+ get: (params: {
37
+ gist_id: string;
38
+ }) => Promise<{
39
+ data: GistResponseData;
40
+ }>;
41
+ list: (params: {
42
+ per_page: number;
43
+ page: number;
44
+ }) => Promise<{
45
+ data: GistListItem[];
46
+ }>;
47
+ create: (params: {
48
+ description: string;
49
+ public: boolean;
50
+ files: Record<string, {
51
+ content: string;
52
+ }>;
53
+ }) => Promise<{
54
+ data: GistResponseData;
55
+ }>;
56
+ update: (params: {
57
+ gist_id: string;
58
+ files: Record<string, {
59
+ content: string;
60
+ }>;
61
+ }) => Promise<{
62
+ data: GistResponseData;
63
+ }>;
64
+ };
65
+ }
66
+ /** Shape of a single Gist in a list response (subset). */
67
+ interface GistListItem {
68
+ id: string;
69
+ description: string | null;
70
+ }
71
+ /** Shape of a full Gist response (subset). */
72
+ interface GistResponseData {
73
+ id: string;
74
+ description: string | null;
75
+ files: Record<string, {
76
+ filename: string;
77
+ content?: string;
78
+ } | null>;
79
+ }
80
+ /**
81
+ * Gist-backed state store with in-memory file cache and local write-through.
82
+ */
83
+ export declare class GistStateStore {
84
+ private gistId;
85
+ readonly cachedFiles: Map<string, string>;
86
+ readonly dirtyFiles: Set<string>;
87
+ private readonly octokit;
88
+ constructor(octokit: OctokitLike);
89
+ /**
90
+ * Bootstrap the Gist store: locate or create the backing Gist,
91
+ * populate the in-memory cache, and write the local cache file.
92
+ */
93
+ bootstrap(): Promise<BootstrapResult>;
94
+ /**
95
+ * Bootstrap with migration from an existing local state.
96
+ * If a Gist already exists (found via local ID or search), uses it — no migration needed.
97
+ * If no Gist exists, creates one seeded with the provided existingState instead of a fresh state.
98
+ * @returns BootstrapResult extended with `migrated: true` if a new Gist was created from local state
99
+ */
100
+ bootstrapWithMigration(existingState: AgentState): Promise<BootstrapResult & {
101
+ migrated: boolean;
102
+ }>;
103
+ /** Return the resolved Gist ID (available after bootstrap). */
104
+ getGistId(): string | null;
105
+ /**
106
+ * Mark a file as dirty so it will be included in the next `push()` call.
107
+ */
108
+ markDirty(filename: string): void;
109
+ /**
110
+ * Read a freeform document from the in-memory cache.
111
+ * Returns null if the file has not been loaded (or does not exist in the Gist).
112
+ * Synchronous — all Gist contents are loaded into memory at bootstrap.
113
+ */
114
+ getDocument(filename: string): string | null;
115
+ /**
116
+ * Write a freeform document into the in-memory cache and mark it dirty
117
+ * so it will be included in the next `push()` call.
118
+ */
119
+ setDocument(filename: string, content: string): void;
120
+ /**
121
+ * Return all filenames in the in-memory cache whose names start with `prefix`.
122
+ * Useful for listing all guidelines files (e.g. prefix `guidelines--`).
123
+ */
124
+ listDocuments(prefix: string): string[];
125
+ /**
126
+ * Stage new state JSON for the next `push()`. Updates the in-memory cache
127
+ * for `state.json` and marks it dirty.
128
+ */
129
+ setState(stateJson: string): void;
130
+ /**
131
+ * Push all dirty files to the backing Gist. Retries once on failure.
132
+ *
133
+ * Returns `true` on success (or when there is nothing to push).
134
+ * Returns `false` if both attempts fail.
135
+ * Throws if the Gist ID has not been resolved yet (bootstrap not called).
136
+ */
137
+ push(): Promise<boolean>;
138
+ /**
139
+ * Fetch a Gist by ID, populate the in-memory cache, parse state,
140
+ * and write the local cache file.
141
+ */
142
+ private fetchAndCache;
143
+ /**
144
+ * Parse `state.json` from the in-memory cache. Handles v2 migration
145
+ * by running through the Zod schema (which requires version: 3).
146
+ * Falls back to fresh state if the file is missing or unparseable.
147
+ */
148
+ private parseStateFromCache;
149
+ /**
150
+ * Search the authenticated user's Gists for one with the well-known description.
151
+ * Pages through up to 10 pages (100 Gists per page) to find it.
152
+ */
153
+ private searchForGist;
154
+ /**
155
+ * Create a new private Gist with seed files and store it in memory.
156
+ */
157
+ private createGist;
158
+ /**
159
+ * Create a new private Gist seeded with the provided state (for migration).
160
+ */
161
+ private createGistFromState;
162
+ /** Read the locally persisted Gist ID, or return null if not found. */
163
+ private readLocalGistId;
164
+ /** Persist the Gist ID locally for fast lookup on next session. */
165
+ private writeLocalGistId;
166
+ /** Write state to the local cache file for degraded-mode fallback. */
167
+ private writeLocalStateCache;
168
+ }
169
+ export {};