@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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/cli.bundle.cjs +17657 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +325 -0
- package/dist/commands/check-integration.d.ts +10 -0
- package/dist/commands/check-integration.js +192 -0
- package/dist/commands/comments.d.ts +24 -0
- package/dist/commands/comments.js +311 -0
- package/dist/commands/config.d.ts +11 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/daily.d.ts +29 -0
- package/dist/commands/daily.js +433 -0
- package/dist/commands/dashboard-data.d.ts +45 -0
- package/dist/commands/dashboard-data.js +132 -0
- package/dist/commands/dashboard-templates.d.ts +23 -0
- package/dist/commands/dashboard-templates.js +1627 -0
- package/dist/commands/dashboard.d.ts +18 -0
- package/dist/commands/dashboard.js +134 -0
- package/dist/commands/dismiss.d.ts +13 -0
- package/dist/commands/dismiss.js +49 -0
- package/dist/commands/init.d.ts +10 -0
- package/dist/commands/init.js +27 -0
- package/dist/commands/local-repos.d.ts +14 -0
- package/dist/commands/local-repos.js +155 -0
- package/dist/commands/parse-list.d.ts +13 -0
- package/dist/commands/parse-list.js +139 -0
- package/dist/commands/read.d.ts +12 -0
- package/dist/commands/read.js +33 -0
- package/dist/commands/search.d.ts +10 -0
- package/dist/commands/search.js +74 -0
- package/dist/commands/setup.d.ts +15 -0
- package/dist/commands/setup.js +276 -0
- package/dist/commands/shelve.d.ts +13 -0
- package/dist/commands/shelve.js +49 -0
- package/dist/commands/snooze.d.ts +18 -0
- package/dist/commands/snooze.js +83 -0
- package/dist/commands/startup.d.ts +33 -0
- package/dist/commands/startup.js +197 -0
- package/dist/commands/status.d.ts +10 -0
- package/dist/commands/status.js +43 -0
- package/dist/commands/track.d.ts +16 -0
- package/dist/commands/track.js +59 -0
- package/dist/commands/validation.d.ts +43 -0
- package/dist/commands/validation.js +112 -0
- package/dist/commands/vet.d.ts +10 -0
- package/dist/commands/vet.js +36 -0
- package/dist/core/checklist-analysis.d.ts +17 -0
- package/dist/core/checklist-analysis.js +39 -0
- package/dist/core/ci-analysis.d.ts +78 -0
- package/dist/core/ci-analysis.js +163 -0
- package/dist/core/comment-utils.d.ts +15 -0
- package/dist/core/comment-utils.js +52 -0
- package/dist/core/concurrency.d.ts +5 -0
- package/dist/core/concurrency.js +15 -0
- package/dist/core/daily-logic.d.ts +77 -0
- package/dist/core/daily-logic.js +512 -0
- package/dist/core/display-utils.d.ts +10 -0
- package/dist/core/display-utils.js +100 -0
- package/dist/core/errors.d.ts +24 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/github-stats.d.ts +73 -0
- package/dist/core/github-stats.js +272 -0
- package/dist/core/github.d.ts +19 -0
- package/dist/core/github.js +60 -0
- package/dist/core/http-cache.d.ts +97 -0
- package/dist/core/http-cache.js +269 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +15 -0
- package/dist/core/issue-conversation.d.ts +29 -0
- package/dist/core/issue-conversation.js +231 -0
- package/dist/core/issue-discovery.d.ts +85 -0
- package/dist/core/issue-discovery.js +589 -0
- package/dist/core/issue-filtering.d.ts +51 -0
- package/dist/core/issue-filtering.js +103 -0
- package/dist/core/issue-scoring.d.ts +40 -0
- package/dist/core/issue-scoring.js +92 -0
- package/dist/core/issue-vetting.d.ts +49 -0
- package/dist/core/issue-vetting.js +536 -0
- package/dist/core/logger.d.ts +21 -0
- package/dist/core/logger.js +49 -0
- package/dist/core/maintainer-analysis.d.ts +10 -0
- package/dist/core/maintainer-analysis.js +59 -0
- package/dist/core/pagination.d.ts +11 -0
- package/dist/core/pagination.js +20 -0
- package/dist/core/pr-monitor.d.ts +109 -0
- package/dist/core/pr-monitor.js +594 -0
- package/dist/core/review-analysis.d.ts +72 -0
- package/dist/core/review-analysis.js +163 -0
- package/dist/core/state.d.ts +371 -0
- package/dist/core/state.js +1089 -0
- package/dist/core/types.d.ts +507 -0
- package/dist/core/types.js +34 -0
- package/dist/core/utils.d.ts +249 -0
- package/dist/core/utils.js +422 -0
- package/dist/formatters/json.d.ts +269 -0
- package/dist/formatters/json.js +88 -0
- 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
|
+
}
|