@oss-autopilot/core 0.41.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -0
  3. package/dist/cli.bundle.cjs +17657 -0
  4. package/dist/cli.d.ts +12 -0
  5. package/dist/cli.js +325 -0
  6. package/dist/commands/check-integration.d.ts +10 -0
  7. package/dist/commands/check-integration.js +192 -0
  8. package/dist/commands/comments.d.ts +24 -0
  9. package/dist/commands/comments.js +311 -0
  10. package/dist/commands/config.d.ts +11 -0
  11. package/dist/commands/config.js +82 -0
  12. package/dist/commands/daily.d.ts +29 -0
  13. package/dist/commands/daily.js +433 -0
  14. package/dist/commands/dashboard-data.d.ts +45 -0
  15. package/dist/commands/dashboard-data.js +132 -0
  16. package/dist/commands/dashboard-templates.d.ts +23 -0
  17. package/dist/commands/dashboard-templates.js +1627 -0
  18. package/dist/commands/dashboard.d.ts +18 -0
  19. package/dist/commands/dashboard.js +134 -0
  20. package/dist/commands/dismiss.d.ts +13 -0
  21. package/dist/commands/dismiss.js +49 -0
  22. package/dist/commands/init.d.ts +10 -0
  23. package/dist/commands/init.js +27 -0
  24. package/dist/commands/local-repos.d.ts +14 -0
  25. package/dist/commands/local-repos.js +155 -0
  26. package/dist/commands/parse-list.d.ts +13 -0
  27. package/dist/commands/parse-list.js +139 -0
  28. package/dist/commands/read.d.ts +12 -0
  29. package/dist/commands/read.js +33 -0
  30. package/dist/commands/search.d.ts +10 -0
  31. package/dist/commands/search.js +74 -0
  32. package/dist/commands/setup.d.ts +15 -0
  33. package/dist/commands/setup.js +276 -0
  34. package/dist/commands/shelve.d.ts +13 -0
  35. package/dist/commands/shelve.js +49 -0
  36. package/dist/commands/snooze.d.ts +18 -0
  37. package/dist/commands/snooze.js +83 -0
  38. package/dist/commands/startup.d.ts +33 -0
  39. package/dist/commands/startup.js +197 -0
  40. package/dist/commands/status.d.ts +10 -0
  41. package/dist/commands/status.js +43 -0
  42. package/dist/commands/track.d.ts +16 -0
  43. package/dist/commands/track.js +59 -0
  44. package/dist/commands/validation.d.ts +43 -0
  45. package/dist/commands/validation.js +112 -0
  46. package/dist/commands/vet.d.ts +10 -0
  47. package/dist/commands/vet.js +36 -0
  48. package/dist/core/checklist-analysis.d.ts +17 -0
  49. package/dist/core/checklist-analysis.js +39 -0
  50. package/dist/core/ci-analysis.d.ts +78 -0
  51. package/dist/core/ci-analysis.js +163 -0
  52. package/dist/core/comment-utils.d.ts +15 -0
  53. package/dist/core/comment-utils.js +52 -0
  54. package/dist/core/concurrency.d.ts +5 -0
  55. package/dist/core/concurrency.js +15 -0
  56. package/dist/core/daily-logic.d.ts +77 -0
  57. package/dist/core/daily-logic.js +512 -0
  58. package/dist/core/display-utils.d.ts +10 -0
  59. package/dist/core/display-utils.js +100 -0
  60. package/dist/core/errors.d.ts +24 -0
  61. package/dist/core/errors.js +34 -0
  62. package/dist/core/github-stats.d.ts +73 -0
  63. package/dist/core/github-stats.js +272 -0
  64. package/dist/core/github.d.ts +19 -0
  65. package/dist/core/github.js +60 -0
  66. package/dist/core/http-cache.d.ts +97 -0
  67. package/dist/core/http-cache.js +269 -0
  68. package/dist/core/index.d.ts +15 -0
  69. package/dist/core/index.js +15 -0
  70. package/dist/core/issue-conversation.d.ts +29 -0
  71. package/dist/core/issue-conversation.js +231 -0
  72. package/dist/core/issue-discovery.d.ts +85 -0
  73. package/dist/core/issue-discovery.js +589 -0
  74. package/dist/core/issue-filtering.d.ts +51 -0
  75. package/dist/core/issue-filtering.js +103 -0
  76. package/dist/core/issue-scoring.d.ts +40 -0
  77. package/dist/core/issue-scoring.js +92 -0
  78. package/dist/core/issue-vetting.d.ts +49 -0
  79. package/dist/core/issue-vetting.js +536 -0
  80. package/dist/core/logger.d.ts +21 -0
  81. package/dist/core/logger.js +49 -0
  82. package/dist/core/maintainer-analysis.d.ts +10 -0
  83. package/dist/core/maintainer-analysis.js +59 -0
  84. package/dist/core/pagination.d.ts +11 -0
  85. package/dist/core/pagination.js +20 -0
  86. package/dist/core/pr-monitor.d.ts +109 -0
  87. package/dist/core/pr-monitor.js +594 -0
  88. package/dist/core/review-analysis.d.ts +72 -0
  89. package/dist/core/review-analysis.js +163 -0
  90. package/dist/core/state.d.ts +371 -0
  91. package/dist/core/state.js +1089 -0
  92. package/dist/core/types.d.ts +507 -0
  93. package/dist/core/types.js +34 -0
  94. package/dist/core/utils.d.ts +249 -0
  95. package/dist/core/utils.js +422 -0
  96. package/dist/formatters/json.d.ts +269 -0
  97. package/dist/formatters/json.js +88 -0
  98. package/package.json +67 -0
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Shared utility functions
3
+ */
4
+ /**
5
+ * Returns the oss-autopilot data directory path, creating it if it does not exist.
6
+ *
7
+ * The directory is located at `~/.oss-autopilot/` and serves as the root for
8
+ * all persisted user data (state, backups, dashboard).
9
+ *
10
+ * @returns Absolute path to the data directory (e.g., `/Users/you/.oss-autopilot`)
11
+ *
12
+ * @example
13
+ * const dir = getDataDir();
14
+ * // "/Users/you/.oss-autopilot"
15
+ */
16
+ export declare function getDataDir(): string;
17
+ /**
18
+ * Returns the path to the state file (`~/.oss-autopilot/state.json`).
19
+ *
20
+ * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
21
+ *
22
+ * @returns Absolute path to `state.json`
23
+ *
24
+ * @example
25
+ * const statePath = getStatePath();
26
+ * // "/Users/you/.oss-autopilot/state.json"
27
+ */
28
+ export declare function getStatePath(): string;
29
+ /**
30
+ * Returns the backup directory path, creating it if it does not exist.
31
+ *
32
+ * Located at `~/.oss-autopilot/backups/`. Used for automatic state backups
33
+ * before each write operation.
34
+ *
35
+ * @returns Absolute path to the backups directory
36
+ *
37
+ * @example
38
+ * const backupDir = getBackupDir();
39
+ * // "/Users/you/.oss-autopilot/backups"
40
+ */
41
+ export declare function getBackupDir(): string;
42
+ /**
43
+ * Returns the HTTP cache directory path, creating it if it does not exist.
44
+ *
45
+ * Located at `~/.oss-autopilot/cache/`. Used by {@link HttpCache} to store
46
+ * ETag-based response caches for GitHub API endpoints.
47
+ *
48
+ * @returns Absolute path to the cache directory
49
+ *
50
+ * @example
51
+ * const cacheDir = getCacheDir();
52
+ * // "/Users/you/.oss-autopilot/cache"
53
+ */
54
+ export declare function getCacheDir(): string;
55
+ /**
56
+ * Returns the path to the generated HTML dashboard file (`~/.oss-autopilot/dashboard.html`).
57
+ *
58
+ * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
59
+ *
60
+ * @returns Absolute path to `dashboard.html`
61
+ *
62
+ * @example
63
+ * const dashPath = getDashboardPath();
64
+ * // "/Users/you/.oss-autopilot/dashboard.html"
65
+ */
66
+ export declare function getDashboardPath(): string;
67
+ /**
68
+ * Represents a parsed GitHub pull request or issue URL.
69
+ *
70
+ * @property owner - The repository owner (e.g., `"facebook"`)
71
+ * @property repo - The repository name (e.g., `"react"`)
72
+ * @property number - The PR or issue number
73
+ * @property type - Whether the URL points to a pull request or an issue
74
+ */
75
+ interface ParsedGitHubUrl {
76
+ owner: string;
77
+ repo: string;
78
+ number: number;
79
+ type: 'pull' | 'issues';
80
+ }
81
+ /**
82
+ * Parses a GitHub pull request or issue URL into its components.
83
+ *
84
+ * Only accepts HTTPS GitHub URLs (`https://github.com/...`). Returns `null` for
85
+ * invalid URLs, non-GitHub URLs, or URLs with invalid owner/repo characters.
86
+ *
87
+ * @param url - Full GitHub URL (e.g., `"https://github.com/owner/repo/pull/42"`)
88
+ * @returns Parsed URL components, or `null` if the URL is invalid or not a recognized GitHub PR/issue URL
89
+ *
90
+ * @example
91
+ * parseGitHubUrl('https://github.com/facebook/react/pull/123')
92
+ * // { owner: "facebook", repo: "react", number: 123, type: "pull" }
93
+ *
94
+ * @example
95
+ * parseGitHubUrl('https://github.com/vercel/next.js/issues/456')
96
+ * // { owner: "vercel", repo: "next.js", number: 456, type: "issues" }
97
+ *
98
+ * @example
99
+ * parseGitHubUrl('https://example.com/not-github')
100
+ * // null
101
+ */
102
+ export declare function parseGitHubUrl(url: string): ParsedGitHubUrl | null;
103
+ /**
104
+ * Extracts the owner and repo from a GitHub web URL
105
+ * (e.g. `https://github.com/owner/repo/pull/42`, `https://github.com/owner/repo/`).
106
+ *
107
+ * Unlike {@link parseGitHubUrl}, this does **not** require a PR or issue number in the URL.
108
+ * Like `parseGitHubUrl`, it enforces an `https://github.com/` prefix.
109
+ *
110
+ * @param url - An HTTPS GitHub URL containing at least `github.com/owner/repo`
111
+ * @returns `{ owner, repo }` or `null` if the URL cannot be parsed or contains invalid owner/repo characters
112
+ *
113
+ * @example
114
+ * extractOwnerRepo('https://github.com/facebook/react/pull/123')
115
+ * // { owner: "facebook", repo: "react" }
116
+ *
117
+ * @example
118
+ * extractOwnerRepo('https://github.com/vercel/next.js/')
119
+ * // { owner: "vercel", repo: "next.js" }
120
+ */
121
+ export declare function extractOwnerRepo(url: string): {
122
+ owner: string;
123
+ repo: string;
124
+ } | null;
125
+ /**
126
+ * Calculates the number of whole days between two dates, using floor rounding.
127
+ *
128
+ * Can return negative values if `from` is after `to`. Partial days are truncated
129
+ * (e.g., 1.9 days returns 1).
130
+ *
131
+ * @param from - The start date
132
+ * @param to - The end date (defaults to the current date/time)
133
+ * @returns Number of whole days between the two dates (may be negative)
134
+ *
135
+ * @example
136
+ * daysBetween(new Date('2024-01-01'), new Date('2024-01-10'))
137
+ * // 9
138
+ *
139
+ * @example
140
+ * daysBetween(new Date('2024-01-10'), new Date('2024-01-01'))
141
+ * // -9
142
+ */
143
+ export declare function daysBetween(from: Date, to?: Date): number;
144
+ /**
145
+ * Splits an `"owner/repo"` string into its owner and repo components.
146
+ *
147
+ * Does not validate the input format; if no `/` is present, `repo` will be `undefined`.
148
+ *
149
+ * @param repoFullName - Full repository name in `"owner/repo"` format
150
+ * @returns Object with `owner` and `repo` string properties
151
+ *
152
+ * @example
153
+ * splitRepo('facebook/react')
154
+ * // { owner: "facebook", repo: "react" }
155
+ */
156
+ export declare function splitRepo(repoFullName: string): {
157
+ owner: string;
158
+ repo: string;
159
+ };
160
+ /**
161
+ * Formats a timestamp as a human-readable relative time string.
162
+ *
163
+ * Returns minutes for < 1 hour, hours for < 1 day, days for < 30 days,
164
+ * and a locale-formatted date string for anything older.
165
+ *
166
+ * @param dateStr - ISO 8601 date string
167
+ * @returns Relative time like `"5m ago"`, `"3h ago"`, `"12d ago"`, or a formatted date
168
+ *
169
+ * @example
170
+ * formatRelativeTime('2024-01-20T10:00:00Z')
171
+ * // "5d ago" (if called on Jan 25)
172
+ *
173
+ * @example
174
+ * formatRelativeTime(new Date(Date.now() - 120000).toISOString())
175
+ * // "2m ago"
176
+ */
177
+ export declare function formatRelativeTime(dateStr: string): string;
178
+ /**
179
+ * Creates a descending date comparator function for use with `Array.prototype.sort()`.
180
+ *
181
+ * Items with `null` or `undefined` dates are treated as epoch (sorted last).
182
+ *
183
+ * @param getDate - Accessor function that extracts a date value from each item
184
+ * @returns A comparator function that sorts items from newest to oldest
185
+ *
186
+ * @example
187
+ * const prs = [{ createdAt: '2024-01-01' }, { createdAt: '2024-06-15' }];
188
+ * prs.sort(byDateDescending(pr => pr.createdAt));
189
+ * // [{ createdAt: '2024-06-15' }, { createdAt: '2024-01-01' }]
190
+ */
191
+ export declare function byDateDescending<T>(getDate: (item: T) => string | number | null | undefined): (a: T, b: T) => number;
192
+ /**
193
+ * Retrieves a GitHub authentication token, checking sources in priority order.
194
+ *
195
+ * Checks `GITHUB_TOKEN` environment variable first, then falls back to `gh auth token`
196
+ * from the GitHub CLI. The result is cached after the first successful lookup (or first
197
+ * failed attempt), so subsequent calls are instant and do not spawn subprocesses.
198
+ *
199
+ * @returns The GitHub token string, or `null` if no token is available
200
+ *
201
+ * @example
202
+ * const token = getGitHubToken();
203
+ * if (token) {
204
+ * // use token for API calls
205
+ * }
206
+ */
207
+ export declare function getGitHubToken(): string | null;
208
+ /**
209
+ * Returns a GitHub token or throws an error with setup instructions.
210
+ *
211
+ * Delegates to {@link getGitHubToken} and throws if no token is found. Use this
212
+ * in commands that cannot proceed without authentication.
213
+ *
214
+ * @returns The GitHub token string (guaranteed non-null)
215
+ * @throws {ConfigurationError} If no token is available, with instructions for `gh auth login` or setting `GITHUB_TOKEN`
216
+ *
217
+ * @example
218
+ * const token = requireGitHubToken(); // throws if not authenticated
219
+ */
220
+ export declare function requireGitHubToken(): string;
221
+ /**
222
+ * Resets the cached GitHub token and fetch-attempted flag.
223
+ *
224
+ * Intended for use in tests to ensure a clean state between test cases.
225
+ * After calling this, the next call to {@link getGitHubToken} will re-fetch the token.
226
+ *
227
+ * @example
228
+ * afterEach(() => {
229
+ * resetGitHubTokenCache();
230
+ * });
231
+ */
232
+ export declare function resetGitHubTokenCache(): void;
233
+ /**
234
+ * Asynchronous version of {@link getGitHubToken}.
235
+ *
236
+ * Uses `execFile` (non-blocking) instead of `execFileSync` to avoid blocking
237
+ * the event loop during CLI cold start. Shares the same cache as the synchronous
238
+ * version, so a successful async fetch makes subsequent sync calls instant.
239
+ *
240
+ * @returns The GitHub token string, or `null` if no token is available
241
+ *
242
+ * @example
243
+ * const token = await getGitHubTokenAsync();
244
+ * if (token) {
245
+ * // use token for API calls
246
+ * }
247
+ */
248
+ export declare function getGitHubTokenAsync(): Promise<string | null>;
249
+ export {};
@@ -0,0 +1,422 @@
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
+ const MODULE = 'utils';
11
+ // Cached GitHub token (fetched once per session)
12
+ let cachedGitHubToken = null;
13
+ let tokenFetchAttempted = false;
14
+ /**
15
+ * Returns the oss-autopilot data directory path, creating it if it does not exist.
16
+ *
17
+ * The directory is located at `~/.oss-autopilot/` and serves as the root for
18
+ * all persisted user data (state, backups, dashboard).
19
+ *
20
+ * @returns Absolute path to the data directory (e.g., `/Users/you/.oss-autopilot`)
21
+ *
22
+ * @example
23
+ * const dir = getDataDir();
24
+ * // "/Users/you/.oss-autopilot"
25
+ */
26
+ export function getDataDir() {
27
+ const dir = path.join(os.homedir(), '.oss-autopilot');
28
+ if (!fs.existsSync(dir)) {
29
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
30
+ }
31
+ return dir;
32
+ }
33
+ /**
34
+ * Returns the path to the state file (`~/.oss-autopilot/state.json`).
35
+ *
36
+ * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
37
+ *
38
+ * @returns Absolute path to `state.json`
39
+ *
40
+ * @example
41
+ * const statePath = getStatePath();
42
+ * // "/Users/you/.oss-autopilot/state.json"
43
+ */
44
+ export function getStatePath() {
45
+ return path.join(getDataDir(), 'state.json');
46
+ }
47
+ /**
48
+ * Returns the backup directory path, creating it if it does not exist.
49
+ *
50
+ * Located at `~/.oss-autopilot/backups/`. Used for automatic state backups
51
+ * before each write operation.
52
+ *
53
+ * @returns Absolute path to the backups directory
54
+ *
55
+ * @example
56
+ * const backupDir = getBackupDir();
57
+ * // "/Users/you/.oss-autopilot/backups"
58
+ */
59
+ export function getBackupDir() {
60
+ const dir = path.join(getDataDir(), 'backups');
61
+ if (!fs.existsSync(dir)) {
62
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ }
64
+ return dir;
65
+ }
66
+ /**
67
+ * Returns the HTTP cache directory path, creating it if it does not exist.
68
+ *
69
+ * Located at `~/.oss-autopilot/cache/`. Used by {@link HttpCache} to store
70
+ * ETag-based response caches for GitHub API endpoints.
71
+ *
72
+ * @returns Absolute path to the cache directory
73
+ *
74
+ * @example
75
+ * const cacheDir = getCacheDir();
76
+ * // "/Users/you/.oss-autopilot/cache"
77
+ */
78
+ export function getCacheDir() {
79
+ const dir = path.join(getDataDir(), 'cache');
80
+ if (!fs.existsSync(dir)) {
81
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ }
83
+ return dir;
84
+ }
85
+ /**
86
+ * Returns the path to the generated HTML dashboard file (`~/.oss-autopilot/dashboard.html`).
87
+ *
88
+ * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
89
+ *
90
+ * @returns Absolute path to `dashboard.html`
91
+ *
92
+ * @example
93
+ * const dashPath = getDashboardPath();
94
+ * // "/Users/you/.oss-autopilot/dashboard.html"
95
+ */
96
+ export function getDashboardPath() {
97
+ return path.join(getDataDir(), 'dashboard.html');
98
+ }
99
+ // Validation patterns for GitHub owner and repo names
100
+ const OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
101
+ const REPO_PATTERN = /^[a-zA-Z0-9_.-]+$/;
102
+ /**
103
+ * Validate that owner and repo names contain only safe characters
104
+ */
105
+ function isValidOwnerRepo(owner, repo) {
106
+ return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo);
107
+ }
108
+ /**
109
+ * Parses a GitHub pull request or issue URL into its components.
110
+ *
111
+ * Only accepts HTTPS GitHub URLs (`https://github.com/...`). Returns `null` for
112
+ * invalid URLs, non-GitHub URLs, or URLs with invalid owner/repo characters.
113
+ *
114
+ * @param url - Full GitHub URL (e.g., `"https://github.com/owner/repo/pull/42"`)
115
+ * @returns Parsed URL components, or `null` if the URL is invalid or not a recognized GitHub PR/issue URL
116
+ *
117
+ * @example
118
+ * parseGitHubUrl('https://github.com/facebook/react/pull/123')
119
+ * // { owner: "facebook", repo: "react", number: 123, type: "pull" }
120
+ *
121
+ * @example
122
+ * parseGitHubUrl('https://github.com/vercel/next.js/issues/456')
123
+ * // { owner: "vercel", repo: "next.js", number: 456, type: "issues" }
124
+ *
125
+ * @example
126
+ * parseGitHubUrl('https://example.com/not-github')
127
+ * // null
128
+ */
129
+ export function parseGitHubUrl(url) {
130
+ // URL must start with https://github.com/
131
+ if (!url.startsWith('https://github.com/')) {
132
+ return null;
133
+ }
134
+ const prMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
135
+ if (prMatch) {
136
+ const owner = prMatch[1];
137
+ const repo = prMatch[2];
138
+ if (!isValidOwnerRepo(owner, repo)) {
139
+ return null;
140
+ }
141
+ return {
142
+ owner,
143
+ repo,
144
+ number: parseInt(prMatch[3], 10),
145
+ type: 'pull',
146
+ };
147
+ }
148
+ const issueMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
149
+ if (issueMatch) {
150
+ const owner = issueMatch[1];
151
+ const repo = issueMatch[2];
152
+ if (!isValidOwnerRepo(owner, repo)) {
153
+ return null;
154
+ }
155
+ return {
156
+ owner,
157
+ repo,
158
+ number: parseInt(issueMatch[3], 10),
159
+ type: 'issues',
160
+ };
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * Extracts the owner and repo from a GitHub web URL
166
+ * (e.g. `https://github.com/owner/repo/pull/42`, `https://github.com/owner/repo/`).
167
+ *
168
+ * Unlike {@link parseGitHubUrl}, this does **not** require a PR or issue number in the URL.
169
+ * Like `parseGitHubUrl`, it enforces an `https://github.com/` prefix.
170
+ *
171
+ * @param url - An HTTPS GitHub URL containing at least `github.com/owner/repo`
172
+ * @returns `{ owner, repo }` or `null` if the URL cannot be parsed or contains invalid owner/repo characters
173
+ *
174
+ * @example
175
+ * extractOwnerRepo('https://github.com/facebook/react/pull/123')
176
+ * // { owner: "facebook", repo: "react" }
177
+ *
178
+ * @example
179
+ * extractOwnerRepo('https://github.com/vercel/next.js/')
180
+ * // { owner: "vercel", repo: "next.js" }
181
+ */
182
+ export function extractOwnerRepo(url) {
183
+ if (!url.startsWith('https://github.com/'))
184
+ return null;
185
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
186
+ if (!match)
187
+ return null;
188
+ const owner = match[1];
189
+ const repo = match[2];
190
+ if (!isValidOwnerRepo(owner, repo))
191
+ return null;
192
+ return { owner, repo };
193
+ }
194
+ /**
195
+ * Calculates the number of whole days between two dates, using floor rounding.
196
+ *
197
+ * Can return negative values if `from` is after `to`. Partial days are truncated
198
+ * (e.g., 1.9 days returns 1).
199
+ *
200
+ * @param from - The start date
201
+ * @param to - The end date (defaults to the current date/time)
202
+ * @returns Number of whole days between the two dates (may be negative)
203
+ *
204
+ * @example
205
+ * daysBetween(new Date('2024-01-01'), new Date('2024-01-10'))
206
+ * // 9
207
+ *
208
+ * @example
209
+ * daysBetween(new Date('2024-01-10'), new Date('2024-01-01'))
210
+ * // -9
211
+ */
212
+ export function daysBetween(from, to = new Date()) {
213
+ return Math.floor((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
214
+ }
215
+ /**
216
+ * Splits an `"owner/repo"` string into its owner and repo components.
217
+ *
218
+ * Does not validate the input format; if no `/` is present, `repo` will be `undefined`.
219
+ *
220
+ * @param repoFullName - Full repository name in `"owner/repo"` format
221
+ * @returns Object with `owner` and `repo` string properties
222
+ *
223
+ * @example
224
+ * splitRepo('facebook/react')
225
+ * // { owner: "facebook", repo: "react" }
226
+ */
227
+ export function splitRepo(repoFullName) {
228
+ const [owner, repo] = repoFullName.split('/');
229
+ return { owner, repo };
230
+ }
231
+ /**
232
+ * Formats a timestamp as a human-readable relative time string.
233
+ *
234
+ * Returns minutes for < 1 hour, hours for < 1 day, days for < 30 days,
235
+ * and a locale-formatted date string for anything older.
236
+ *
237
+ * @param dateStr - ISO 8601 date string
238
+ * @returns Relative time like `"5m ago"`, `"3h ago"`, `"12d ago"`, or a formatted date
239
+ *
240
+ * @example
241
+ * formatRelativeTime('2024-01-20T10:00:00Z')
242
+ * // "5d ago" (if called on Jan 25)
243
+ *
244
+ * @example
245
+ * formatRelativeTime(new Date(Date.now() - 120000).toISOString())
246
+ * // "2m ago"
247
+ */
248
+ export function formatRelativeTime(dateStr) {
249
+ const date = new Date(dateStr);
250
+ const diffMs = Date.now() - date.getTime();
251
+ const diffMins = Math.floor(diffMs / 60000);
252
+ const diffHours = Math.floor(diffMs / 3600000);
253
+ const diffDays = Math.floor(diffMs / 86400000);
254
+ if (diffMins < 60)
255
+ return `${diffMins}m ago`;
256
+ if (diffHours < 24)
257
+ return `${diffHours}h ago`;
258
+ if (diffDays < 30)
259
+ return `${diffDays}d ago`;
260
+ return date.toLocaleDateString();
261
+ }
262
+ /**
263
+ * Creates a descending date comparator function for use with `Array.prototype.sort()`.
264
+ *
265
+ * Items with `null` or `undefined` dates are treated as epoch (sorted last).
266
+ *
267
+ * @param getDate - Accessor function that extracts a date value from each item
268
+ * @returns A comparator function that sorts items from newest to oldest
269
+ *
270
+ * @example
271
+ * const prs = [{ createdAt: '2024-01-01' }, { createdAt: '2024-06-15' }];
272
+ * prs.sort(byDateDescending(pr => pr.createdAt));
273
+ * // [{ createdAt: '2024-06-15' }, { createdAt: '2024-01-01' }]
274
+ */
275
+ export function byDateDescending(getDate) {
276
+ return (a, b) => {
277
+ const dateA = new Date(getDate(a) || 0).getTime();
278
+ const dateB = new Date(getDate(b) || 0).getTime();
279
+ return dateB - dateA;
280
+ };
281
+ }
282
+ /**
283
+ * Retrieves a GitHub authentication token, checking sources in priority order.
284
+ *
285
+ * Checks `GITHUB_TOKEN` environment variable first, then falls back to `gh auth token`
286
+ * from the GitHub CLI. The result is cached after the first successful lookup (or first
287
+ * failed attempt), so subsequent calls are instant and do not spawn subprocesses.
288
+ *
289
+ * @returns The GitHub token string, or `null` if no token is available
290
+ *
291
+ * @example
292
+ * const token = getGitHubToken();
293
+ * if (token) {
294
+ * // use token for API calls
295
+ * }
296
+ */
297
+ export function getGitHubToken() {
298
+ // Return cached token if we already have one
299
+ if (cachedGitHubToken) {
300
+ return cachedGitHubToken;
301
+ }
302
+ // Don't retry if we already tried and failed
303
+ if (tokenFetchAttempted) {
304
+ return null;
305
+ }
306
+ tokenFetchAttempted = true;
307
+ // 1. Check environment variable first
308
+ if (process.env.GITHUB_TOKEN) {
309
+ cachedGitHubToken = process.env.GITHUB_TOKEN;
310
+ return cachedGitHubToken;
311
+ }
312
+ // 2. Try gh CLI (using execFileSync to avoid shell injection - no user input here anyway)
313
+ try {
314
+ const token = execFileSync('gh', ['auth', 'token'], {
315
+ encoding: 'utf-8',
316
+ stdio: ['pipe', 'pipe', 'pipe'], // Suppress stderr
317
+ timeout: 2000, // 2 second timeout
318
+ }).trim();
319
+ if (token && token.length > 0) {
320
+ cachedGitHubToken = token;
321
+ debug(MODULE, 'Using GitHub token from gh CLI');
322
+ return cachedGitHubToken;
323
+ }
324
+ }
325
+ catch (err) {
326
+ // gh CLI not available or not authenticated — fall through to return null
327
+ debug(MODULE, 'gh auth token failed (CLI unavailable or not authenticated)', err);
328
+ }
329
+ return null;
330
+ }
331
+ /**
332
+ * Returns a GitHub token or throws an error with setup instructions.
333
+ *
334
+ * Delegates to {@link getGitHubToken} and throws if no token is found. Use this
335
+ * in commands that cannot proceed without authentication.
336
+ *
337
+ * @returns The GitHub token string (guaranteed non-null)
338
+ * @throws {ConfigurationError} If no token is available, with instructions for `gh auth login` or setting `GITHUB_TOKEN`
339
+ *
340
+ * @example
341
+ * const token = requireGitHubToken(); // throws if not authenticated
342
+ */
343
+ export function requireGitHubToken() {
344
+ const token = getGitHubToken();
345
+ if (!token) {
346
+ throw new ConfigurationError('GitHub authentication required.\n\n' +
347
+ 'Options:\n' +
348
+ ' 1. Use gh CLI: gh auth login\n' +
349
+ ' 2. Set GITHUB_TOKEN environment variable\n\n' +
350
+ 'The gh CLI is recommended - install from https://cli.github.com');
351
+ }
352
+ return token;
353
+ }
354
+ /**
355
+ * Resets the cached GitHub token and fetch-attempted flag.
356
+ *
357
+ * Intended for use in tests to ensure a clean state between test cases.
358
+ * After calling this, the next call to {@link getGitHubToken} will re-fetch the token.
359
+ *
360
+ * @example
361
+ * afterEach(() => {
362
+ * resetGitHubTokenCache();
363
+ * });
364
+ */
365
+ export function resetGitHubTokenCache() {
366
+ cachedGitHubToken = null;
367
+ tokenFetchAttempted = false;
368
+ }
369
+ /**
370
+ * Asynchronous version of {@link getGitHubToken}.
371
+ *
372
+ * Uses `execFile` (non-blocking) instead of `execFileSync` to avoid blocking
373
+ * the event loop during CLI cold start. Shares the same cache as the synchronous
374
+ * version, so a successful async fetch makes subsequent sync calls instant.
375
+ *
376
+ * @returns The GitHub token string, or `null` if no token is available
377
+ *
378
+ * @example
379
+ * const token = await getGitHubTokenAsync();
380
+ * if (token) {
381
+ * // use token for API calls
382
+ * }
383
+ */
384
+ export async function getGitHubTokenAsync() {
385
+ // Return cached token if we already have one
386
+ if (cachedGitHubToken) {
387
+ return cachedGitHubToken;
388
+ }
389
+ // Don't retry if we already tried and failed
390
+ if (tokenFetchAttempted) {
391
+ return null;
392
+ }
393
+ tokenFetchAttempted = true;
394
+ // 1. Check environment variable first
395
+ if (process.env.GITHUB_TOKEN) {
396
+ cachedGitHubToken = process.env.GITHUB_TOKEN;
397
+ return cachedGitHubToken;
398
+ }
399
+ // 2. Try gh CLI asynchronously (non-blocking)
400
+ try {
401
+ const token = await new Promise((resolve, reject) => {
402
+ execFile('gh', ['auth', 'token'], { encoding: 'utf-8', timeout: 2000 }, (error, stdout) => {
403
+ if (error) {
404
+ reject(error);
405
+ }
406
+ else {
407
+ resolve(stdout.trim());
408
+ }
409
+ });
410
+ });
411
+ if (token && token.length > 0) {
412
+ cachedGitHubToken = token;
413
+ debug(MODULE, 'Using GitHub token from gh CLI (async)');
414
+ return cachedGitHubToken;
415
+ }
416
+ }
417
+ catch (err) {
418
+ // gh CLI not available or not authenticated — fall through to return null
419
+ debug(MODULE, 'gh auth token failed (CLI unavailable or not authenticated)', err);
420
+ }
421
+ return null;
422
+ }