@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
@@ -1,529 +0,0 @@
1
- /**
2
- * Shared utility functions
3
- */
4
- import * as fs from 'fs';
5
- import * as path from 'path';
6
- import * as os from 'os';
7
- import { execFileSync, execFile } from 'child_process';
8
- import { ConfigurationError } from './errors.js';
9
- import { debug } from './logger.js';
10
- /** Default concurrency limit for parallel GitHub API requests. */
11
- export const DEFAULT_CONCURRENCY = 5;
12
- /** Async sleep — exported for mockability in tests. */
13
- export function sleep(ms) {
14
- return new Promise((resolve) => setTimeout(resolve, ms));
15
- }
16
- const MODULE = 'utils';
17
- // Cached GitHub token (fetched once per session)
18
- let cachedGitHubToken = null;
19
- let tokenFetchAttempted = false;
20
- /**
21
- * Returns the oss-autopilot data directory path, creating it if it does not exist.
22
- *
23
- * The directory is located at `~/.oss-autopilot/` and serves as the root for
24
- * all persisted user data (state, backups, cache).
25
- *
26
- * @returns Absolute path to the data directory (e.g., `/Users/you/.oss-autopilot`)
27
- *
28
- * @example
29
- * const dir = getDataDir();
30
- * // "/Users/you/.oss-autopilot"
31
- */
32
- export function getDataDir() {
33
- const dir = path.join(os.homedir(), '.oss-autopilot');
34
- if (!fs.existsSync(dir)) {
35
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
- }
37
- return dir;
38
- }
39
- /**
40
- * Returns the path to the state file (`~/.oss-autopilot/state.json`).
41
- *
42
- * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
43
- *
44
- * @returns Absolute path to `state.json`
45
- *
46
- * @example
47
- * const statePath = getStatePath();
48
- * // "/Users/you/.oss-autopilot/state.json"
49
- */
50
- export function getStatePath() {
51
- return path.join(getDataDir(), 'state.json');
52
- }
53
- /**
54
- * Returns the backup directory path, creating it if it does not exist.
55
- *
56
- * Located at `~/.oss-autopilot/backups/`. Used for automatic state backups
57
- * before each write operation.
58
- *
59
- * @returns Absolute path to the backups directory
60
- *
61
- * @example
62
- * const backupDir = getBackupDir();
63
- * // "/Users/you/.oss-autopilot/backups"
64
- */
65
- export function getBackupDir() {
66
- const dir = path.join(getDataDir(), 'backups');
67
- if (!fs.existsSync(dir)) {
68
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
69
- }
70
- return dir;
71
- }
72
- /**
73
- * Returns the HTTP cache directory path, creating it if it does not exist.
74
- *
75
- * Located at `~/.oss-autopilot/cache/`. Used by {@link HttpCache} to store
76
- * ETag-based response caches for GitHub API endpoints.
77
- *
78
- * @returns Absolute path to the cache directory
79
- *
80
- * @example
81
- * const cacheDir = getCacheDir();
82
- * // "/Users/you/.oss-autopilot/cache"
83
- */
84
- export function getCacheDir() {
85
- const dir = path.join(getDataDir(), 'cache');
86
- if (!fs.existsSync(dir)) {
87
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
88
- }
89
- return dir;
90
- }
91
- /**
92
- * Returns the path to the local Gist ID file (`~/.oss-autopilot/gist-id`).
93
- *
94
- * This file stores the GitHub Gist ID used by the Gist-based persistence layer,
95
- * avoiding a search-by-description API call on every session.
96
- *
97
- * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
98
- *
99
- * @returns Absolute path to `gist-id`
100
- *
101
- * @example
102
- * const gistIdPath = getGistIdPath();
103
- * // "/Users/you/.oss-autopilot/gist-id"
104
- */
105
- export function getGistIdPath() {
106
- const dir = getDataDir();
107
- return path.join(dir, 'gist-id');
108
- }
109
- /**
110
- * Returns the path to the local state cache file (`~/.oss-autopilot/state-cache.json`).
111
- *
112
- * This file is a write-through cache of the Gist-hosted state, used as a fallback
113
- * when the GitHub API is unreachable (degraded mode).
114
- *
115
- * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
116
- *
117
- * @returns Absolute path to `state-cache.json`
118
- *
119
- * @example
120
- * const cachePath = getStateCachePath();
121
- * // "/Users/you/.oss-autopilot/state-cache.json"
122
- */
123
- export function getStateCachePath() {
124
- const dir = getDataDir();
125
- return path.join(dir, 'state-cache.json');
126
- }
127
- // Validation patterns for GitHub owner and repo names
128
- const OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
129
- const REPO_PATTERN = /^[a-zA-Z0-9_.-]+$/;
130
- /**
131
- * Validate that owner and repo names contain only safe characters
132
- */
133
- function isValidOwnerRepo(owner, repo) {
134
- return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo);
135
- }
136
- /**
137
- * Parses a GitHub pull request or issue URL into its components.
138
- *
139
- * Only accepts HTTPS GitHub URLs (`https://github.com/...`). Returns `null` for
140
- * invalid URLs, non-GitHub URLs, or URLs with invalid owner/repo characters.
141
- *
142
- * @param url - Full GitHub URL (e.g., `"https://github.com/owner/repo/pull/42"`)
143
- * @returns Parsed URL components, or `null` if the URL is invalid or not a recognized GitHub PR/issue URL
144
- *
145
- * @example
146
- * parseGitHubUrl('https://github.com/facebook/react/pull/123')
147
- * // { owner: "facebook", repo: "react", number: 123, type: "pull" }
148
- *
149
- * @example
150
- * parseGitHubUrl('https://github.com/vercel/next.js/issues/456')
151
- * // { owner: "vercel", repo: "next.js", number: 456, type: "issues" }
152
- *
153
- * @example
154
- * parseGitHubUrl('https://example.com/not-github')
155
- * // null
156
- */
157
- export function parseGitHubUrl(url) {
158
- // URL must start with https://github.com/
159
- if (!url.startsWith('https://github.com/')) {
160
- return null;
161
- }
162
- const prMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
163
- if (prMatch) {
164
- const owner = prMatch[1];
165
- const repo = prMatch[2];
166
- if (!isValidOwnerRepo(owner, repo)) {
167
- return null;
168
- }
169
- return {
170
- owner,
171
- repo,
172
- number: parseInt(prMatch[3], 10),
173
- type: 'pull',
174
- };
175
- }
176
- const issueMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
177
- if (issueMatch) {
178
- const owner = issueMatch[1];
179
- const repo = issueMatch[2];
180
- if (!isValidOwnerRepo(owner, repo)) {
181
- return null;
182
- }
183
- return {
184
- owner,
185
- repo,
186
- number: parseInt(issueMatch[3], 10),
187
- type: 'issues',
188
- };
189
- }
190
- return null;
191
- }
192
- /**
193
- * Extracts the owner and repo from a GitHub web URL
194
- * (e.g. `https://github.com/owner/repo/pull/42`, `https://github.com/owner/repo/`).
195
- *
196
- * Unlike {@link parseGitHubUrl}, this does **not** require a PR or issue number in the URL.
197
- * Like `parseGitHubUrl`, it enforces an `https://github.com/` prefix.
198
- *
199
- * @param url - An HTTPS GitHub URL containing at least `github.com/owner/repo`
200
- * @returns `{ owner, repo }` or `null` if the URL cannot be parsed or contains invalid owner/repo characters
201
- *
202
- * @example
203
- * extractOwnerRepo('https://github.com/facebook/react/pull/123')
204
- * // { owner: "facebook", repo: "react" }
205
- *
206
- * @example
207
- * extractOwnerRepo('https://github.com/vercel/next.js/')
208
- * // { owner: "vercel", repo: "next.js" }
209
- */
210
- export function extractOwnerRepo(url) {
211
- if (!url.startsWith('https://github.com/'))
212
- return null;
213
- const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
214
- if (!match)
215
- return null;
216
- const owner = match[1];
217
- const repo = match[2];
218
- if (!isValidOwnerRepo(owner, repo))
219
- return null;
220
- return { owner, repo };
221
- }
222
- /**
223
- * Calculates the number of whole days between two dates, clamped to zero.
224
- *
225
- * Returns `0` if `from` is after `to` — reversed ranges and clock-skew do not
226
- * produce negative values. Partial days are truncated (e.g., 1.9 days -> 1).
227
- *
228
- * @param from - The start date
229
- * @param to - The end date (defaults to the current date/time)
230
- * @returns Number of whole days between the two dates, minimum `0`
231
- *
232
- * @example
233
- * daysBetween(new Date('2024-01-01'), new Date('2024-01-10'))
234
- * // 9
235
- *
236
- * @example
237
- * daysBetween(new Date('2024-01-10'), new Date('2024-01-01'))
238
- * // 0 (clamped; reversed ranges are not signed)
239
- */
240
- export function daysBetween(from, to = new Date()) {
241
- return Math.max(0, Math.floor((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)));
242
- }
243
- /**
244
- * Splits an `"owner/repo"` string into its owner and repo components.
245
- *
246
- * @param repoFullName - Full repository name in `"owner/repo"` format
247
- * @returns Object with `owner` and `repo` string properties
248
- * @throws {Error} If the input does not contain both an owner and repo separated by `/`
249
- *
250
- * @example
251
- * splitRepo('facebook/react')
252
- * // { owner: "facebook", repo: "react" }
253
- */
254
- export function splitRepo(repoFullName) {
255
- const [owner, repo] = repoFullName.split('/');
256
- if (!owner || !repo) {
257
- throw new Error(`Invalid repo format: expected "owner/repo", got "${repoFullName}"`);
258
- }
259
- return { owner, repo };
260
- }
261
- /**
262
- * Case-insensitive check whether a repo owner matches the given GitHub username.
263
- * Used to skip a user's own repos (PRs to your own repos aren't OSS contributions).
264
- */
265
- export function isOwnRepo(owner, username) {
266
- return owner.toLowerCase() === username.toLowerCase();
267
- }
268
- /**
269
- * Read the CLI package version from package.json relative to the running CLI bundle.
270
- * Resolves `../package.json` from `process.argv[1]` (the bundle entry point).
271
- * Falls back to '0.0.0' if the file is unreadable.
272
- */
273
- export function getCLIVersion() {
274
- try {
275
- const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
276
- return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
277
- }
278
- catch {
279
- return '0.0.0';
280
- }
281
- }
282
- /**
283
- * Formats a timestamp as a human-readable relative time string.
284
- *
285
- * Returns minutes for < 1 hour, hours for < 1 day, days for < 30 days,
286
- * and a locale-formatted date string for anything older.
287
- *
288
- * @param dateStr - ISO 8601 date string
289
- * @returns Relative time like `"5m ago"`, `"3h ago"`, `"12d ago"`, or a formatted date
290
- *
291
- * @example
292
- * formatRelativeTime('2024-01-20T10:00:00Z')
293
- * // "5d ago" (if called on Jan 25)
294
- *
295
- * @example
296
- * formatRelativeTime(new Date(Date.now() - 120000).toISOString())
297
- * // "2m ago"
298
- */
299
- export function formatRelativeTime(dateStr) {
300
- const date = new Date(dateStr);
301
- const diffMs = Date.now() - date.getTime();
302
- if (diffMs < 0)
303
- return 'just now';
304
- const diffMins = Math.floor(diffMs / 60000);
305
- const diffHours = Math.floor(diffMs / 3600000);
306
- const diffDays = Math.floor(diffMs / 86400000);
307
- if (diffMins < 60)
308
- return `${diffMins}m ago`;
309
- if (diffHours < 24)
310
- return `${diffHours}h ago`;
311
- if (diffDays < 30)
312
- return `${diffDays}d ago`;
313
- return date.toLocaleDateString();
314
- }
315
- /**
316
- * Creates a descending date comparator function for use with `Array.prototype.sort()`.
317
- *
318
- * Items with `null` or `undefined` dates are treated as epoch (sorted last).
319
- *
320
- * @param getDate - Accessor function that extracts a date value from each item
321
- * @returns A comparator function that sorts items from newest to oldest
322
- *
323
- * @example
324
- * const prs = [{ createdAt: '2024-01-01' }, { createdAt: '2024-06-15' }];
325
- * prs.sort(byDateDescending(pr => pr.createdAt));
326
- * // [{ createdAt: '2024-06-15' }, { createdAt: '2024-01-01' }]
327
- */
328
- export function byDateDescending(getDate) {
329
- return (a, b) => {
330
- const dateA = new Date(getDate(a) || 0).getTime();
331
- const dateB = new Date(getDate(b) || 0).getTime();
332
- return dateB - dateA;
333
- };
334
- }
335
- /**
336
- * Retrieves a GitHub authentication token, checking sources in priority order.
337
- *
338
- * Checks `GITHUB_TOKEN` environment variable first, then falls back to `gh auth token`
339
- * from the GitHub CLI. The result is cached after the first successful lookup (or first
340
- * failed attempt), so subsequent calls are instant and do not spawn subprocesses.
341
- *
342
- * @returns The GitHub token string, or `null` if no token is available
343
- *
344
- * @example
345
- * const token = getGitHubToken();
346
- * if (token) {
347
- * // use token for API calls
348
- * }
349
- */
350
- export function getGitHubToken() {
351
- // Return cached token if we already have one
352
- if (cachedGitHubToken) {
353
- return cachedGitHubToken;
354
- }
355
- // Don't retry if we already tried and failed
356
- if (tokenFetchAttempted) {
357
- return null;
358
- }
359
- tokenFetchAttempted = true;
360
- // 1. Check environment variable first
361
- if (process.env.GITHUB_TOKEN) {
362
- cachedGitHubToken = process.env.GITHUB_TOKEN;
363
- return cachedGitHubToken;
364
- }
365
- // 2. Try gh CLI (using execFileSync to avoid shell injection - no user input here anyway)
366
- try {
367
- const token = execFileSync('gh', ['auth', 'token'], {
368
- encoding: 'utf-8',
369
- stdio: ['pipe', 'pipe', 'pipe'], // Suppress stderr
370
- timeout: 2000, // 2 second timeout
371
- }).trim();
372
- if (token && token.length > 0) {
373
- cachedGitHubToken = token;
374
- debug(MODULE, 'Using GitHub token from gh CLI');
375
- return cachedGitHubToken;
376
- }
377
- }
378
- catch (err) {
379
- // gh CLI not available or not authenticated — fall through to return null
380
- debug(MODULE, 'gh auth token failed (CLI unavailable or not authenticated)', err);
381
- }
382
- return null;
383
- }
384
- /**
385
- * Returns a GitHub token or throws an error with setup instructions.
386
- *
387
- * Delegates to {@link getGitHubToken} and throws if no token is found. Use this
388
- * in commands that cannot proceed without authentication.
389
- *
390
- * @returns The GitHub token string (guaranteed non-null)
391
- * @throws {ConfigurationError} If no token is available, with instructions for `gh auth login` or setting `GITHUB_TOKEN`
392
- *
393
- * @example
394
- * const token = requireGitHubToken(); // throws if not authenticated
395
- */
396
- export function requireGitHubToken() {
397
- const token = getGitHubToken();
398
- if (!token) {
399
- throw new ConfigurationError('GitHub authentication required.\n\n' +
400
- 'Options:\n' +
401
- ' 1. Use gh CLI: gh auth login\n' +
402
- ' 2. Set GITHUB_TOKEN environment variable\n\n' +
403
- 'The gh CLI is recommended - install from https://cli.github.com');
404
- }
405
- return token;
406
- }
407
- /**
408
- * Resets the cached GitHub token and fetch-attempted flag.
409
- *
410
- * Intended for use in tests to ensure a clean state between test cases.
411
- * After calling this, the next call to {@link getGitHubToken} will re-fetch the token.
412
- *
413
- * @example
414
- * afterEach(() => {
415
- * resetGitHubTokenCache();
416
- * });
417
- */
418
- export function resetGitHubTokenCache() {
419
- cachedGitHubToken = null;
420
- tokenFetchAttempted = false;
421
- }
422
- /**
423
- * Asynchronous version of {@link getGitHubToken}.
424
- *
425
- * Uses `execFile` (non-blocking) instead of `execFileSync` to avoid blocking
426
- * the event loop during CLI cold start. Shares the same cache as the synchronous
427
- * version, so a successful async fetch makes subsequent sync calls instant.
428
- *
429
- * @returns The GitHub token string, or `null` if no token is available
430
- *
431
- * @example
432
- * const token = await getGitHubTokenAsync();
433
- * if (token) {
434
- * // use token for API calls
435
- * }
436
- */
437
- export async function getGitHubTokenAsync() {
438
- // Return cached token if we already have one
439
- if (cachedGitHubToken) {
440
- return cachedGitHubToken;
441
- }
442
- // Don't retry if we already tried and failed
443
- if (tokenFetchAttempted) {
444
- return null;
445
- }
446
- tokenFetchAttempted = true;
447
- // 1. Check environment variable first
448
- if (process.env.GITHUB_TOKEN) {
449
- cachedGitHubToken = process.env.GITHUB_TOKEN;
450
- return cachedGitHubToken;
451
- }
452
- // 2. Try gh CLI asynchronously (non-blocking)
453
- try {
454
- const token = await new Promise((resolve, reject) => {
455
- execFile('gh', ['auth', 'token'], { encoding: 'utf-8', timeout: 2000 }, (error, stdout) => {
456
- if (error) {
457
- reject(error);
458
- }
459
- else {
460
- resolve(stdout.trim());
461
- }
462
- });
463
- });
464
- if (token && token.length > 0) {
465
- cachedGitHubToken = token;
466
- debug(MODULE, 'Using GitHub token from gh CLI (async)');
467
- return cachedGitHubToken;
468
- }
469
- }
470
- catch (err) {
471
- // gh CLI not available or not authenticated — fall through to return null
472
- debug(MODULE, 'gh auth token failed (CLI unavailable or not authenticated)', err);
473
- }
474
- return null;
475
- }
476
- /**
477
- * GitHub username validation pattern.
478
- * Usernames must start with an alphanumeric character, can contain hyphens
479
- * (but not consecutive ones and not at the end), and be 1-39 characters.
480
- */
481
- const GITHUB_USERNAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/;
482
- /**
483
- * Check whether the state file exists without creating the data directory.
484
- * Used for first-run detection in the CLI — we don't want to create
485
- * `~/.oss-autopilot/` just to check if the user has ever run the tool.
486
- */
487
- export function stateFileExists() {
488
- const stateFile = path.join(os.homedir(), '.oss-autopilot', 'state.json');
489
- return fs.existsSync(stateFile);
490
- }
491
- /**
492
- * Detect the authenticated GitHub username via the `gh` CLI.
493
- *
494
- * Runs `gh api user --jq '.login'` asynchronously and validates the result
495
- * against GitHub's username rules. Never throws — returns `null` on any failure
496
- * (gh not installed, not authenticated, invalid output, etc.).
497
- *
498
- * @returns The GitHub username string, or `null` if detection fails
499
- *
500
- * @example
501
- * const username = await detectGitHubUsername();
502
- * if (username) {
503
- * console.log(`Logged in as ${username}`);
504
- * }
505
- */
506
- export async function detectGitHubUsername() {
507
- try {
508
- const login = await new Promise((resolve, reject) => {
509
- execFile('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf-8', timeout: 5000 }, (error, stdout) => {
510
- if (error) {
511
- reject(error);
512
- }
513
- else {
514
- resolve(stdout.trim());
515
- }
516
- });
517
- });
518
- if (login && GITHUB_USERNAME_RE.test(login)) {
519
- debug(MODULE, `Detected GitHub username: ${login}`);
520
- return login;
521
- }
522
- debug(MODULE, `gh api user returned invalid username: "${login}"`);
523
- return null;
524
- }
525
- catch (err) {
526
- debug(MODULE, 'detectGitHubUsername failed', err);
527
- return null;
528
- }
529
- }