@oss-autopilot/core 1.17.4 → 3.0.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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.js +417 -326
  3. package/dist/cli.bundle.cjs +99 -96
  4. package/dist/commands/daily-render.d.ts +39 -0
  5. package/dist/commands/daily-render.js +189 -0
  6. package/dist/commands/dashboard-data.js +9 -3
  7. package/dist/commands/index.d.ts +4 -8
  8. package/dist/commands/index.js +3 -5
  9. package/dist/commands/list-move-tier.d.ts +46 -0
  10. package/dist/commands/list-move-tier.js +192 -0
  11. package/dist/commands/pr-template.js +2 -1
  12. package/dist/commands/state-cmd.d.ts +10 -1
  13. package/dist/commands/state-cmd.js +22 -3
  14. package/dist/commands/track.d.ts +7 -28
  15. package/dist/commands/track.js +8 -30
  16. package/dist/core/auth.d.ts +50 -0
  17. package/dist/core/auth.js +160 -0
  18. package/dist/core/concurrency.d.ts +7 -0
  19. package/dist/core/concurrency.js +9 -0
  20. package/dist/core/daily-logic.d.ts +10 -42
  21. package/dist/core/daily-logic.js +14 -201
  22. package/dist/core/dates.d.ts +37 -0
  23. package/dist/core/dates.js +60 -0
  24. package/dist/core/errors.d.ts +14 -0
  25. package/dist/core/errors.js +22 -0
  26. package/dist/core/gist-state-store.d.ts +48 -2
  27. package/dist/core/gist-state-store.js +120 -24
  28. package/dist/core/github-stats.js +1 -1
  29. package/dist/core/http-cache.js +1 -1
  30. package/dist/core/index.d.ts +5 -1
  31. package/dist/core/index.js +5 -1
  32. package/dist/core/issue-conversation.js +3 -2
  33. package/dist/core/paths.d.ts +68 -0
  34. package/dist/core/paths.js +106 -0
  35. package/dist/core/pr-monitor.js +3 -1
  36. package/dist/core/repo-score-manager.js +1 -1
  37. package/dist/core/state-persistence.js +1 -1
  38. package/dist/core/state.d.ts +16 -2
  39. package/dist/core/state.js +42 -7
  40. package/dist/core/types.d.ts +57 -0
  41. package/dist/core/urls.d.ts +63 -0
  42. package/dist/core/urls.js +101 -0
  43. package/dist/formatters/json.d.ts +464 -74
  44. package/dist/formatters/json.js +380 -0
  45. package/package.json +3 -3
  46. package/dist/commands/read.d.ts +0 -18
  47. package/dist/commands/read.js +0 -20
  48. package/dist/core/utils.d.ts +0 -303
  49. package/dist/core/utils.js +0 -529
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Date math + relative-time formatting helpers.
3
+ * Extracted from utils.ts under #1116.
4
+ */
5
+ /**
6
+ * Calculates the number of whole days between two dates, clamped to zero.
7
+ *
8
+ * Returns `0` if `from` is after `to` — reversed ranges and clock-skew do not
9
+ * produce negative values. Partial days are truncated (e.g., 1.9 days -> 1).
10
+ *
11
+ * @example
12
+ * daysBetween(new Date('2024-01-01'), new Date('2024-01-10'))
13
+ * // 9
14
+ */
15
+ export declare function daysBetween(from: Date, to?: Date): number;
16
+ /**
17
+ * Formats a timestamp as a human-readable relative time string.
18
+ *
19
+ * Returns minutes for < 1 hour, hours for < 1 day, days for < 30 days,
20
+ * and a locale-formatted date string for anything older.
21
+ *
22
+ * @example
23
+ * formatRelativeTime('2024-01-20T10:00:00Z')
24
+ * // "5d ago" (if called on Jan 25)
25
+ */
26
+ export declare function formatRelativeTime(dateStr: string): string;
27
+ /**
28
+ * Creates a descending date comparator function for use with `Array.prototype.sort()`.
29
+ *
30
+ * Items with `null` or `undefined` dates are treated as epoch (sorted last).
31
+ *
32
+ * @example
33
+ * const prs = [{ createdAt: '2024-01-01' }, { createdAt: '2024-06-15' }];
34
+ * prs.sort(byDateDescending(pr => pr.createdAt));
35
+ * // [{ createdAt: '2024-06-15' }, { createdAt: '2024-01-01' }]
36
+ */
37
+ export declare function byDateDescending<T>(getDate: (item: T) => string | number | null | undefined): (a: T, b: T) => number;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Date math + relative-time formatting helpers.
3
+ * Extracted from utils.ts under #1116.
4
+ */
5
+ /**
6
+ * Calculates the number of whole days between two dates, clamped to zero.
7
+ *
8
+ * Returns `0` if `from` is after `to` — reversed ranges and clock-skew do not
9
+ * produce negative values. Partial days are truncated (e.g., 1.9 days -> 1).
10
+ *
11
+ * @example
12
+ * daysBetween(new Date('2024-01-01'), new Date('2024-01-10'))
13
+ * // 9
14
+ */
15
+ export function daysBetween(from, to = new Date()) {
16
+ return Math.max(0, Math.floor((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)));
17
+ }
18
+ /**
19
+ * Formats a timestamp as a human-readable relative time string.
20
+ *
21
+ * Returns minutes for < 1 hour, hours for < 1 day, days for < 30 days,
22
+ * and a locale-formatted date string for anything older.
23
+ *
24
+ * @example
25
+ * formatRelativeTime('2024-01-20T10:00:00Z')
26
+ * // "5d ago" (if called on Jan 25)
27
+ */
28
+ export function formatRelativeTime(dateStr) {
29
+ const date = new Date(dateStr);
30
+ const diffMs = Date.now() - date.getTime();
31
+ if (diffMs < 0)
32
+ return 'just now';
33
+ const diffMins = Math.floor(diffMs / 60000);
34
+ const diffHours = Math.floor(diffMs / 3600000);
35
+ const diffDays = Math.floor(diffMs / 86400000);
36
+ if (diffMins < 60)
37
+ return `${diffMins}m ago`;
38
+ if (diffHours < 24)
39
+ return `${diffHours}h ago`;
40
+ if (diffDays < 30)
41
+ return `${diffDays}d ago`;
42
+ return date.toLocaleDateString();
43
+ }
44
+ /**
45
+ * Creates a descending date comparator function for use with `Array.prototype.sort()`.
46
+ *
47
+ * Items with `null` or `undefined` dates are treated as epoch (sorted last).
48
+ *
49
+ * @example
50
+ * const prs = [{ createdAt: '2024-01-01' }, { createdAt: '2024-06-15' }];
51
+ * prs.sort(byDateDescending(pr => pr.createdAt));
52
+ * // [{ createdAt: '2024-06-15' }, { createdAt: '2024-01-01' }]
53
+ */
54
+ export function byDateDescending(getDate) {
55
+ return (a, b) => {
56
+ const dateA = new Date(getDate(a) || 0).getTime();
57
+ const dateB = new Date(getDate(b) || 0).getTime();
58
+ return dateB - dateA;
59
+ };
60
+ }
@@ -46,6 +46,20 @@ export declare class ConcurrencyError extends OssAutopilotError {
46
46
  readonly actualMtimeMs: number;
47
47
  constructor(expectedMtimeMs: number, actualMtimeMs: number);
48
48
  }
49
+ /**
50
+ * Thrown when a Gist push collides with a concurrent write from another
51
+ * machine and the in-flight merge attempt also lost the race (#1115).
52
+ *
53
+ * Carries the ETag we tried to write against (`expectedEtag`) and the
54
+ * ETag we observed during the merge refetch (`remoteEtag`) so callers /
55
+ * tests can assert on the precise mismatch. The message is phrased for
56
+ * end-users; `state-sync` retries from a fresh fetch.
57
+ */
58
+ export declare class GistConcurrencyError extends OssAutopilotError {
59
+ readonly expectedEtag: string | null;
60
+ readonly remoteEtag: string | null;
61
+ constructor(expectedEtag: string | null, remoteEtag: string | null);
62
+ }
49
63
  /**
50
64
  * Extract a human-readable message from an unknown error value.
51
65
  */
@@ -69,6 +69,26 @@ export class ConcurrencyError extends OssAutopilotError {
69
69
  this.name = 'ConcurrencyError';
70
70
  }
71
71
  }
72
+ /**
73
+ * Thrown when a Gist push collides with a concurrent write from another
74
+ * machine and the in-flight merge attempt also lost the race (#1115).
75
+ *
76
+ * Carries the ETag we tried to write against (`expectedEtag`) and the
77
+ * ETag we observed during the merge refetch (`remoteEtag`) so callers /
78
+ * tests can assert on the precise mismatch. The message is phrased for
79
+ * end-users; `state-sync` retries from a fresh fetch.
80
+ */
81
+ export class GistConcurrencyError extends OssAutopilotError {
82
+ expectedEtag;
83
+ remoteEtag;
84
+ constructor(expectedEtag, remoteEtag) {
85
+ super('Another machine pushed to the state Gist while this push was in flight, and the merge attempt also collided. ' +
86
+ 'Re-run `oss-autopilot state --sync` to retry — your local mutations are still in memory.', 'CONCURRENCY_ERROR');
87
+ this.expectedEtag = expectedEtag;
88
+ this.remoteEtag = remoteEtag;
89
+ this.name = 'GistConcurrencyError';
90
+ }
91
+ }
72
92
  /**
73
93
  * Extract a human-readable message from an unknown error value.
74
94
  */
@@ -148,6 +168,8 @@ export function resolveErrorCode(err) {
148
168
  return 'VALIDATION';
149
169
  if (err instanceof ConcurrencyError)
150
170
  return 'CONCURRENCY';
171
+ if (err instanceof GistConcurrencyError)
172
+ return 'CONCURRENCY';
151
173
  // Check HTTP status codes (Octokit errors)
152
174
  const status = getHttpStatusCode(err);
153
175
  if (status === 401)
@@ -13,6 +13,23 @@
13
13
  * 5. If not found anywhere, create a new private Gist with seed files and store the ID
14
14
  * 6. Cache all Gist file contents in memory for session-scoped reads
15
15
  * 7. Write state to a local cache file for fallback
16
+ *
17
+ * Concurrency model (#1115):
18
+ *
19
+ * Each fetch captures the response's `ETag` and stores it as
20
+ * `lastFetchedEtag`. The next `push()` sends that ETag back as
21
+ * `If-Match` so the GitHub Gist API rejects the write with 412 Precondition
22
+ * Failed if another machine pushed in the interval.
23
+ *
24
+ * On 412, the store attempts a single merge: it re-fetches the Gist
25
+ * (refreshing the ETag), re-applies the in-memory dirty content on top of
26
+ * the now-current remote state, and pushes once more. If that second push
27
+ * also returns 412, `push()` throws {@link GistConcurrencyError} — local
28
+ * mutations stay in memory so the caller can decide whether to refresh and
29
+ * retry. The merge step is intentionally cheap (state is a single JSON
30
+ * blob) and treats local dirty changes as authoritative for conflict
31
+ * cells, which matches the "last-write-wins by intent" model the rest of
32
+ * oss-autopilot already assumes for state.json.
16
33
  */
17
34
  import { AgentState } from './types.js';
18
35
  /** Well-known Gist description used for search-based discovery. */
@@ -30,6 +47,11 @@ export interface BootstrapResult {
30
47
  /**
31
48
  * Minimal Octokit-shaped interface for the Gist API methods we use.
32
49
  * Accepts the real ThrottledOctokit or a plain mock object in tests.
50
+ *
51
+ * Responses include the optional `headers` envelope (ETag-based concurrency,
52
+ * #1115). Tests that don't care about ETags can omit the headers field — the
53
+ * code path treats a missing ETag as "no concurrency check available, fall
54
+ * back to last-write-wins".
33
55
  */
34
56
  export interface OctokitLike {
35
57
  gists: {
@@ -37,6 +59,7 @@ export interface OctokitLike {
37
59
  gist_id: string;
38
60
  }) => Promise<{
39
61
  data: GistResponseData;
62
+ headers?: Record<string, string | undefined>;
40
63
  }>;
41
64
  list: (params: {
42
65
  per_page: number;
@@ -52,14 +75,17 @@ export interface OctokitLike {
52
75
  }>;
53
76
  }) => Promise<{
54
77
  data: GistResponseData;
78
+ headers?: Record<string, string | undefined>;
55
79
  }>;
56
80
  update: (params: {
57
81
  gist_id: string;
58
82
  files: Record<string, {
59
83
  content: string;
60
84
  }>;
85
+ headers?: Record<string, string>;
61
86
  }) => Promise<{
62
87
  data: GistResponseData;
88
+ headers?: Record<string, string | undefined>;
63
89
  }>;
64
90
  };
65
91
  }
@@ -84,6 +110,14 @@ export declare class GistStateStore {
84
110
  private gistId;
85
111
  readonly cachedFiles: Map<string, string>;
86
112
  readonly dirtyFiles: Set<string>;
113
+ /**
114
+ * ETag captured from the most recent successful Gist fetch (#1115).
115
+ * Sent back as `If-Match` on `update` so concurrent writes from another
116
+ * machine surface as 412 Precondition Failed instead of silently winning
117
+ * the last-write-wins race. `null` when the SDK didn't surface the header
118
+ * (e.g. test mocks without headers) — in that case we skip the check.
119
+ */
120
+ private lastFetchedEtag;
87
121
  private readonly octokit;
88
122
  private lastRefreshAt;
89
123
  private static readonly REFRESH_THROTTLE_MS;
@@ -130,10 +164,22 @@ export declare class GistStateStore {
130
164
  */
131
165
  setState(stateJson: string): void;
132
166
  /**
133
- * Push all dirty files to the backing Gist. Retries once on failure.
167
+ * Push all dirty files to the backing Gist.
168
+ *
169
+ * Behavior:
170
+ * - When an ETag is available from the previous fetch, sends `If-Match`
171
+ * so a 412 surfaces if another machine pushed since we last fetched.
172
+ * - On 412, attempts a single merge: re-fetches the Gist (refreshing the
173
+ * ETag), re-applies the in-memory dirty content on top of the remote,
174
+ * and pushes once more. If the second push also 412s, throws
175
+ * {@link GistConcurrencyError} — the caller can decide whether to
176
+ * refreshFromGist + retry.
177
+ * - On any other error, retries once (existing behavior). If the retry
178
+ * also fails, returns `false`.
134
179
  *
135
180
  * Returns `true` on success (or when there is nothing to push).
136
- * Returns `false` if both attempts fail.
181
+ * Returns `false` if a non-conflict push fails on both attempts.
182
+ * Throws {@link GistConcurrencyError} on unresolvable conflicts (#1115).
137
183
  * Throws if the Gist ID has not been resolved yet (bootstrap not called).
138
184
  */
139
185
  push(): Promise<boolean>;
@@ -13,14 +13,41 @@
13
13
  * 5. If not found anywhere, create a new private Gist with seed files and store the ID
14
14
  * 6. Cache all Gist file contents in memory for session-scoped reads
15
15
  * 7. Write state to a local cache file for fallback
16
+ *
17
+ * Concurrency model (#1115):
18
+ *
19
+ * Each fetch captures the response's `ETag` and stores it as
20
+ * `lastFetchedEtag`. The next `push()` sends that ETag back as
21
+ * `If-Match` so the GitHub Gist API rejects the write with 412 Precondition
22
+ * Failed if another machine pushed in the interval.
23
+ *
24
+ * On 412, the store attempts a single merge: it re-fetches the Gist
25
+ * (refreshing the ETag), re-applies the in-memory dirty content on top of
26
+ * the now-current remote state, and pushes once more. If that second push
27
+ * also returns 412, `push()` throws {@link GistConcurrencyError} — local
28
+ * mutations stay in memory so the caller can decide whether to refresh and
29
+ * retry. The merge step is intentionally cheap (state is a single JSON
30
+ * blob) and treats local dirty changes as authoritative for conflict
31
+ * cells, which matches the "last-write-wins by intent" model the rest of
32
+ * oss-autopilot already assumes for state.json.
16
33
  */
17
34
  import * as fs from 'fs';
18
35
  import { AgentStateSchema } from './state-schema.js';
19
36
  import { atomicWriteFileSync, createFreshState, migrateV1ToV2, migrateV2ToV3 } from './state-persistence.js';
20
- import { getGistIdPath, getStateCachePath } from './utils.js';
37
+ import { getGistIdPath, getStateCachePath } from './paths.js';
21
38
  import { debug, warn } from './logger.js';
22
- import { GistPermissionError, isRateLimitError } from './errors.js';
39
+ import { GistPermissionError, GistConcurrencyError, isRateLimitError } from './errors.js';
23
40
  const MODULE = 'gist-store';
41
+ /**
42
+ * Extract the ETag header from an Octokit response, tolerating both lower-
43
+ * and upper-case header names. Returns `null` when no ETag is present (test
44
+ * mocks, degraded responses, etc.).
45
+ */
46
+ function extractEtag(headers) {
47
+ if (!headers)
48
+ return null;
49
+ return headers.etag ?? headers.ETag ?? null;
50
+ }
24
51
  /** Well-known Gist description used for search-based discovery. */
25
52
  export const GIST_DESCRIPTION = 'oss-autopilot-state';
26
53
  /** Primary state file name inside the Gist. */
@@ -32,6 +59,14 @@ export class GistStateStore {
32
59
  gistId = null;
33
60
  cachedFiles = new Map();
34
61
  dirtyFiles = new Set();
62
+ /**
63
+ * ETag captured from the most recent successful Gist fetch (#1115).
64
+ * Sent back as `If-Match` on `update` so concurrent writes from another
65
+ * machine surface as 412 Precondition Failed instead of silently winning
66
+ * the last-write-wins race. `null` when the SDK didn't surface the header
67
+ * (e.g. test mocks without headers) — in that case we skip the check.
68
+ */
69
+ lastFetchedEtag = null;
35
70
  octokit;
36
71
  lastRefreshAt = 0;
37
72
  static REFRESH_THROTTLE_MS = 30_000;
@@ -224,10 +259,22 @@ export class GistStateStore {
224
259
  this.markDirty(STATE_FILE_NAME);
225
260
  }
226
261
  /**
227
- * Push all dirty files to the backing Gist. Retries once on failure.
262
+ * Push all dirty files to the backing Gist.
263
+ *
264
+ * Behavior:
265
+ * - When an ETag is available from the previous fetch, sends `If-Match`
266
+ * so a 412 surfaces if another machine pushed since we last fetched.
267
+ * - On 412, attempts a single merge: re-fetches the Gist (refreshing the
268
+ * ETag), re-applies the in-memory dirty content on top of the remote,
269
+ * and pushes once more. If the second push also 412s, throws
270
+ * {@link GistConcurrencyError} — the caller can decide whether to
271
+ * refreshFromGist + retry.
272
+ * - On any other error, retries once (existing behavior). If the retry
273
+ * also fails, returns `false`.
228
274
  *
229
275
  * Returns `true` on success (or when there is nothing to push).
230
- * Returns `false` if both attempts fail.
276
+ * Returns `false` if a non-conflict push fails on both attempts.
277
+ * Throws {@link GistConcurrencyError} on unresolvable conflicts (#1115).
231
278
  * Throws if the Gist ID has not been resolved yet (bootstrap not called).
232
279
  */
233
280
  async push() {
@@ -237,28 +284,74 @@ export class GistStateStore {
237
284
  if (this.gistId === null) {
238
285
  throw new Error('GistStateStore: cannot push before bootstrap — gistId is null');
239
286
  }
240
- // Build PATCH payload from the dirty set
241
- const files = {};
242
- for (const filename of this.dirtyFiles) {
243
- const content = this.cachedFiles.get(filename);
244
- if (content !== undefined) {
245
- files[filename] = { content };
287
+ const buildFiles = () => {
288
+ const out = {};
289
+ for (const filename of this.dirtyFiles) {
290
+ const content = this.cachedFiles.get(filename);
291
+ if (content !== undefined) {
292
+ out[filename] = { content };
293
+ }
246
294
  }
247
- }
248
- const attempt = async () => {
249
- await this.octokit.gists.update({ gist_id: this.gistId, files });
250
- return true;
295
+ return out;
251
296
  };
252
- try {
253
- await attempt();
254
- }
255
- catch (firstErr) {
256
- debug(MODULE, `push failed on first attempt, retrying: ${firstErr}`);
297
+ /** Attempt a single push, capturing the response ETag for the next push. */
298
+ const attempt = async (etag) => {
257
299
  try {
258
- await attempt();
300
+ const params = {
301
+ gist_id: this.gistId,
302
+ files: buildFiles(),
303
+ };
304
+ if (etag) {
305
+ params.headers = { 'if-match': etag };
306
+ }
307
+ const response = await this.octokit.gists.update(params);
308
+ // Refresh our cached ETag so the next push uses the post-update value.
309
+ const newEtag = extractEtag(response.headers);
310
+ if (newEtag)
311
+ this.lastFetchedEtag = newEtag;
312
+ return { ok: true };
313
+ }
314
+ catch (err) {
315
+ const status = err.status;
316
+ return { ok: false, status, err };
259
317
  }
260
- catch (secondErr) {
261
- warn(MODULE, `push failed after retry, giving up: ${secondErr}`);
318
+ };
319
+ // ── Phase 1: best-shot push with current ETag ─────────────────────
320
+ let result = await attempt(this.lastFetchedEtag);
321
+ // ── Phase 2: ETag mismatch — try to merge once ────────────────────
322
+ if (!result.ok && result.status === 412) {
323
+ const expectedEtag = this.lastFetchedEtag;
324
+ debug(MODULE, `push hit 412 (ETag mismatch); attempting merge — refreshing and retrying once`);
325
+ // Snapshot the dirty contents before refresh (fetchAndCache wipes the cache).
326
+ const stagedContents = {};
327
+ for (const filename of this.dirtyFiles) {
328
+ const content = this.cachedFiles.get(filename);
329
+ if (content !== undefined)
330
+ stagedContents[filename] = content;
331
+ }
332
+ try {
333
+ await this.fetchAndCache(this.gistId);
334
+ }
335
+ catch (refreshErr) {
336
+ warn(MODULE, `Merge refresh after 412 failed: ${refreshErr}`);
337
+ throw new GistConcurrencyError(expectedEtag, null);
338
+ }
339
+ // Re-apply our staged dirty contents on top of the refreshed remote.
340
+ for (const [filename, content] of Object.entries(stagedContents)) {
341
+ this.cachedFiles.set(filename, content);
342
+ }
343
+ result = await attempt(this.lastFetchedEtag);
344
+ if (!result.ok && result.status === 412) {
345
+ warn(MODULE, 'Second push after merge also returned 412; surfacing GistConcurrencyError');
346
+ throw new GistConcurrencyError(expectedEtag, this.lastFetchedEtag);
347
+ }
348
+ }
349
+ // ── Phase 3: any other failure — retry once (existing behavior) ───
350
+ if (!result.ok) {
351
+ debug(MODULE, `push failed on first attempt, retrying: ${result.err}`);
352
+ result = await attempt(this.lastFetchedEtag);
353
+ if (!result.ok) {
354
+ warn(MODULE, `push failed after retry, giving up: ${result.err}`);
262
355
  return false;
263
356
  }
264
357
  }
@@ -320,11 +413,14 @@ export class GistStateStore {
320
413
  * and write the local cache file.
321
414
  */
322
415
  async fetchAndCache(gistId) {
323
- const { data } = await this.octokit.gists.get({ gist_id: gistId });
416
+ const response = await this.octokit.gists.get({ gist_id: gistId });
324
417
  this.gistId = gistId;
418
+ // Capture the ETag for optimistic-concurrency push (#1115). Header
419
+ // names from Octokit are lower-cased; tolerate both shapes for robustness.
420
+ this.lastFetchedEtag = extractEtag(response.headers);
325
421
  // Populate in-memory cache with ALL files from the Gist
326
422
  this.cachedFiles.clear();
327
- for (const [filename, file] of Object.entries(data.files)) {
423
+ for (const [filename, file] of Object.entries(response.data.files)) {
328
424
  if (file && file.content != null) {
329
425
  this.cachedFiles.set(filename, file.content);
330
426
  }
@@ -2,7 +2,7 @@
2
2
  * GitHub Stats - Fetching merged/closed PR counts with star-based filtering.
3
3
  * Extracted from PRMonitor to isolate statistics-gathering API calls (#263).
4
4
  */
5
- import { extractOwnerRepo, parseGitHubUrl, isOwnRepo } from './utils.js';
5
+ import { extractOwnerRepo, parseGitHubUrl, isOwnRepo } from './urls.js';
6
6
  import { isBelowMinStars } from './types.js';
7
7
  import { debug, warn } from './logger.js';
8
8
  import { getHttpCache } from './http-cache.js';
@@ -12,7 +12,7 @@
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import * as crypto from 'crypto';
15
- import { getCacheDir } from './utils.js';
15
+ import { getCacheDir } from './paths.js';
16
16
  import { debug } from './logger.js';
17
17
  import { getHttpStatusCode } from './errors.js';
18
18
  const MODULE = 'http-cache';
@@ -8,7 +8,11 @@ export { PRMonitor, type PRCheckFailure, type FetchPRsResult, computeDisplayLabe
8
8
  export { IssueConversationMonitor } from './issue-conversation.js';
9
9
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
10
  export { getOctokit, checkRateLimit, type RateLimitInfo } from './github.js';
11
- export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, stateFileExists, DEFAULT_CONCURRENCY, } from './utils.js';
11
+ export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
12
+ export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
13
+ export { getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, stateFileExists } from './paths.js';
14
+ export { getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, } from './auth.js';
15
+ export { DEFAULT_CONCURRENCY } from './concurrency.js';
12
16
  export { OssAutopilotError, ConfigurationError, ValidationError, GistPermissionError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, nonFatalCatch, resolveErrorCode, } from './errors.js';
13
17
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
14
18
  export { HttpCache, getHttpCache, cachedRequest, type CacheEntry } from './http-cache.js';
@@ -9,7 +9,11 @@ export { PRMonitor, computeDisplayLabel, classifyCICheck, classifyFailingChecks,
9
9
  export { IssueConversationMonitor } from './issue-conversation.js';
10
10
  export { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
11
11
  export { getOctokit, checkRateLimit } from './github.js';
12
- export { parseGitHubUrl, daysBetween, splitRepo, isOwnRepo, getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, formatRelativeTime, byDateDescending, getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, stateFileExists, DEFAULT_CONCURRENCY, } from './utils.js';
12
+ export { parseGitHubUrl, splitRepo, isOwnRepo } from './urls.js';
13
+ export { daysBetween, formatRelativeTime, byDateDescending } from './dates.js';
14
+ export { getCLIVersion, getDataDir, getStatePath, getBackupDir, getCacheDir, stateFileExists } from './paths.js';
15
+ export { getGitHubToken, getGitHubTokenAsync, requireGitHubToken, resetGitHubTokenCache, detectGitHubUsername, } from './auth.js';
16
+ export { DEFAULT_CONCURRENCY } from './concurrency.js';
13
17
  export { OssAutopilotError, ConfigurationError, ValidationError, GistPermissionError, errorMessage, getHttpStatusCode, isRateLimitError, isRateLimitOrAuthError, nonFatalCatch, resolveErrorCode, } from './errors.js';
14
18
  export { enableDebug, isDebugEnabled, debug, info, warn, timed } from './logger.js';
15
19
  export { HttpCache, getHttpCache, cachedRequest } from './http-cache.js';
@@ -9,8 +9,9 @@ import { getOctokit } from './github.js';
9
9
  import { isBotAuthor, isAcknowledgmentComment } from './comment-utils.js';
10
10
  import { paginateAll } from './pagination.js';
11
11
  import { getStateManager } from './state.js';
12
- import { daysBetween, splitRepo, extractOwnerRepo, isOwnRepo, DEFAULT_CONCURRENCY } from './utils.js';
13
- import { runWorkerPool } from './concurrency.js';
12
+ import { daysBetween } from './dates.js';
13
+ import { splitRepo, extractOwnerRepo, isOwnRepo } from './urls.js';
14
+ import { runWorkerPool, DEFAULT_CONCURRENCY } from './concurrency.js';
14
15
  import { ConfigurationError, errorMessage } from './errors.js';
15
16
  import { debug, warn } from './logger.js';
16
17
  const MODULE = 'issue-conversation';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Filesystem path helpers for the oss-autopilot user data directory.
3
+ *
4
+ * All paths root at `~/.oss-autopilot/`, and every getter that returns a
5
+ * directory path implicitly creates it (with mode 0o700) if missing.
6
+ *
7
+ * Extracted from utils.ts under #1116.
8
+ */
9
+ /**
10
+ * Returns the oss-autopilot data directory path, creating it if it does not exist.
11
+ *
12
+ * The directory is located at `~/.oss-autopilot/` and serves as the root for
13
+ * all persisted user data (state, backups, cache).
14
+ *
15
+ * @returns Absolute path to the data directory (e.g., `/Users/you/.oss-autopilot`)
16
+ *
17
+ * @example
18
+ * const dir = getDataDir();
19
+ * // "/Users/you/.oss-autopilot"
20
+ */
21
+ export declare function getDataDir(): string;
22
+ /**
23
+ * Returns the path to the state file (`~/.oss-autopilot/state.json`).
24
+ *
25
+ * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
26
+ */
27
+ export declare function getStatePath(): string;
28
+ /**
29
+ * Returns the backup directory path, creating it if it does not exist.
30
+ *
31
+ * Located at `~/.oss-autopilot/backups/`. Used for automatic state backups
32
+ * before each write operation.
33
+ */
34
+ export declare function getBackupDir(): string;
35
+ /**
36
+ * Returns the HTTP cache directory path, creating it if it does not exist.
37
+ *
38
+ * Located at `~/.oss-autopilot/cache/`. Used by HttpCache to store
39
+ * ETag-based response caches for GitHub API endpoints.
40
+ */
41
+ export declare function getCacheDir(): string;
42
+ /**
43
+ * Returns the path to the local Gist ID file (`~/.oss-autopilot/gist-id`).
44
+ *
45
+ * Stores the GitHub Gist ID used by the Gist-based persistence layer,
46
+ * avoiding a search-by-description API call on every session.
47
+ */
48
+ export declare function getGistIdPath(): string;
49
+ /**
50
+ * Returns the path to the local state cache file (`~/.oss-autopilot/state-cache.json`).
51
+ *
52
+ * A write-through cache of the Gist-hosted state, used as a fallback when
53
+ * the GitHub API is unreachable (degraded mode).
54
+ */
55
+ export declare function getStateCachePath(): string;
56
+ /**
57
+ * Check whether the state file exists without creating the data directory.
58
+ *
59
+ * Used for first-run detection in the CLI — we don't want to create
60
+ * `~/.oss-autopilot/` just to check if the user has ever run the tool.
61
+ */
62
+ export declare function stateFileExists(): boolean;
63
+ /**
64
+ * Read the CLI package version from package.json relative to the running CLI bundle.
65
+ * Resolves `../package.json` from `process.argv[1]` (the bundle entry point).
66
+ * Falls back to '0.0.0' if the file is unreadable.
67
+ */
68
+ export declare function getCLIVersion(): string;