@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,106 @@
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
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as os from 'os';
12
+ /**
13
+ * Returns the oss-autopilot data directory path, creating it if it does not exist.
14
+ *
15
+ * The directory is located at `~/.oss-autopilot/` and serves as the root for
16
+ * all persisted user data (state, backups, cache).
17
+ *
18
+ * @returns Absolute path to the data directory (e.g., `/Users/you/.oss-autopilot`)
19
+ *
20
+ * @example
21
+ * const dir = getDataDir();
22
+ * // "/Users/you/.oss-autopilot"
23
+ */
24
+ export function getDataDir() {
25
+ const dir = path.join(os.homedir(), '.oss-autopilot');
26
+ if (!fs.existsSync(dir)) {
27
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
28
+ }
29
+ return dir;
30
+ }
31
+ /**
32
+ * Returns the path to the state file (`~/.oss-autopilot/state.json`).
33
+ *
34
+ * Implicitly creates the data directory via {@link getDataDir} if it does not exist.
35
+ */
36
+ export function getStatePath() {
37
+ return path.join(getDataDir(), 'state.json');
38
+ }
39
+ /**
40
+ * Returns the backup directory path, creating it if it does not exist.
41
+ *
42
+ * Located at `~/.oss-autopilot/backups/`. Used for automatic state backups
43
+ * before each write operation.
44
+ */
45
+ export function getBackupDir() {
46
+ const dir = path.join(getDataDir(), 'backups');
47
+ if (!fs.existsSync(dir)) {
48
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ }
50
+ return dir;
51
+ }
52
+ /**
53
+ * Returns the HTTP cache directory path, creating it if it does not exist.
54
+ *
55
+ * Located at `~/.oss-autopilot/cache/`. Used by HttpCache to store
56
+ * ETag-based response caches for GitHub API endpoints.
57
+ */
58
+ export function getCacheDir() {
59
+ const dir = path.join(getDataDir(), 'cache');
60
+ if (!fs.existsSync(dir)) {
61
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
62
+ }
63
+ return dir;
64
+ }
65
+ /**
66
+ * Returns the path to the local Gist ID file (`~/.oss-autopilot/gist-id`).
67
+ *
68
+ * Stores the GitHub Gist ID used by the Gist-based persistence layer,
69
+ * avoiding a search-by-description API call on every session.
70
+ */
71
+ export function getGistIdPath() {
72
+ return path.join(getDataDir(), 'gist-id');
73
+ }
74
+ /**
75
+ * Returns the path to the local state cache file (`~/.oss-autopilot/state-cache.json`).
76
+ *
77
+ * A write-through cache of the Gist-hosted state, used as a fallback when
78
+ * the GitHub API is unreachable (degraded mode).
79
+ */
80
+ export function getStateCachePath() {
81
+ return path.join(getDataDir(), 'state-cache.json');
82
+ }
83
+ /**
84
+ * Check whether the state file exists without creating the data directory.
85
+ *
86
+ * Used for first-run detection in the CLI — we don't want to create
87
+ * `~/.oss-autopilot/` just to check if the user has ever run the tool.
88
+ */
89
+ export function stateFileExists() {
90
+ const stateFile = path.join(os.homedir(), '.oss-autopilot', 'state.json');
91
+ return fs.existsSync(stateFile);
92
+ }
93
+ /**
94
+ * Read the CLI package version from package.json relative to the running CLI bundle.
95
+ * Resolves `../package.json` from `process.argv[1]` (the bundle entry point).
96
+ * Falls back to '0.0.0' if the file is unreadable.
97
+ */
98
+ export function getCLIVersion() {
99
+ try {
100
+ const pkgPath = path.join(path.dirname(process.argv[1]), '..', 'package.json');
101
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
102
+ }
103
+ catch {
104
+ return '0.0.0';
105
+ }
106
+ }
@@ -14,7 +14,9 @@
14
14
  */
15
15
  import { getOctokit } from './github.js';
16
16
  import { getStateManager } from './state.js';
17
- import { daysBetween, parseGitHubUrl, extractOwnerRepo, isOwnRepo, DEFAULT_CONCURRENCY } from './utils.js';
17
+ import { daysBetween } from './dates.js';
18
+ import { parseGitHubUrl, extractOwnerRepo, isOwnRepo } from './urls.js';
19
+ import { DEFAULT_CONCURRENCY } from './concurrency.js';
18
20
  import { determineStatus } from './status-determination.js';
19
21
  import { runWorkerPool } from './concurrency.js';
20
22
  import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitOrAuthError, } from './errors.js';
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { isBelowMinStars } from './types.js';
11
11
  import { debug, warn } from './logger.js';
12
- import { parseGitHubUrl } from './utils.js';
12
+ import { parseGitHubUrl } from './urls.js';
13
13
  const MODULE = 'scoring';
14
14
  // ── Scoring constants (#1054) ─────────────────────────────────────────
15
15
  // Previously inlined as magic numbers in `calculateScore`. Extracted with
@@ -6,7 +6,7 @@
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
  import { AgentStateSchema } from './state-schema.js';
9
- import { getStatePath, getBackupDir, getDataDir } from './utils.js';
9
+ import { getStatePath, getBackupDir, getDataDir } from './paths.js';
10
10
  import { errorMessage, ConcurrencyError } from './errors.js';
11
11
  import { debug, warn } from './logger.js';
12
12
  const MODULE = 'state';
@@ -148,18 +148,32 @@ export declare class StateManager {
148
148
  getMergedPRs(): StoredMergedPR[];
149
149
  /**
150
150
  * Add merged PRs to storage, deduplicating by URL.
151
+ * Entries with URLs that fail {@link parseGitHubUrl} are dropped before
152
+ * persistence (read-side filters in dashboard-data already skip them, but
153
+ * this prevents the bad data from reaching disk in the first place).
151
154
  * @param prs - Merged PRs to add (duplicates by URL are ignored)
155
+ * @returns count of entries added vs. dropped (invalid URL)
152
156
  */
153
- addMergedPRs(prs: StoredMergedPR[]): void;
157
+ addMergedPRs(prs: StoredMergedPR[]): {
158
+ added: number;
159
+ dropped: number;
160
+ };
154
161
  /** Returns the most recent merge date, used as a watermark for incremental fetching. */
155
162
  getMergedPRWatermark(): string | undefined;
156
163
  /** Returns all stored closed-without-merge PRs (sorted by close date descending via addClosedPRs). */
157
164
  getClosedPRs(): StoredClosedPR[];
158
165
  /**
159
166
  * Add closed PRs to storage, deduplicating by URL.
167
+ * Entries with URLs that fail {@link parseGitHubUrl} are dropped before
168
+ * persistence (read-side filters in dashboard-data already skip them, but
169
+ * this prevents the bad data from reaching disk in the first place).
160
170
  * @param prs - Closed PRs to add (duplicates by URL are ignored)
171
+ * @returns count of entries added vs. dropped (invalid URL)
161
172
  */
162
- addClosedPRs(prs: StoredClosedPR[]): void;
173
+ addClosedPRs(prs: StoredClosedPR[]): {
174
+ added: number;
175
+ dropped: number;
176
+ };
163
177
  /** Returns the most recent close date, used as a watermark for incremental fetching. */
164
178
  getClosedPRWatermark(): string | undefined;
165
179
  /**
@@ -10,9 +10,28 @@ import * as repoScoring from './repo-score-manager.js';
10
10
  import { debug, warn } from './logger.js';
11
11
  import { errorMessage, ConfigurationError, ConcurrencyError } from './errors.js';
12
12
  import { GistStateStore } from './gist-state-store.js';
13
- import { getStatePath, getStateCachePath } from './utils.js';
13
+ import { getStatePath, getStateCachePath } from './paths.js';
14
+ import { parseGitHubUrl } from './urls.js';
14
15
  export { acquireLock, releaseLock, atomicWriteFileSync } from './state-persistence.js';
15
16
  const MODULE = 'state';
17
+ /**
18
+ * Validate stored-PR URL shape before persistence (mirror of the read-side
19
+ * filter in dashboard-data#storedToMergedPRs/storedToClosedPRs). Bad URLs are
20
+ * logged at warn level so operators see the drop in the daily output.
21
+ */
22
+ function filterValidUrlEntries(entries, kind) {
23
+ const valid = [];
24
+ let dropped = 0;
25
+ for (const entry of entries) {
26
+ if (parseGitHubUrl(entry.url) === null) {
27
+ warn(MODULE, `Dropping ${kind} PR with invalid URL: ${entry.url}`);
28
+ dropped++;
29
+ continue;
30
+ }
31
+ valid.push(entry);
32
+ }
33
+ return { valid, dropped };
34
+ }
16
35
  /**
17
36
  * Push state to the backing Gist when Gist mode is active. Best-effort:
18
37
  * network/auth failures are logged via `warn()` but never propagated —
@@ -352,21 +371,29 @@ export class StateManager {
352
371
  }
353
372
  /**
354
373
  * Add merged PRs to storage, deduplicating by URL.
374
+ * Entries with URLs that fail {@link parseGitHubUrl} are dropped before
375
+ * persistence (read-side filters in dashboard-data already skip them, but
376
+ * this prevents the bad data from reaching disk in the first place).
355
377
  * @param prs - Merged PRs to add (duplicates by URL are ignored)
378
+ * @returns count of entries added vs. dropped (invalid URL)
356
379
  */
357
380
  addMergedPRs(prs) {
358
381
  if (prs.length === 0)
359
- return;
382
+ return { added: 0, dropped: 0 };
383
+ const { valid, dropped } = filterValidUrlEntries(prs, 'merged');
384
+ if (valid.length === 0)
385
+ return { added: 0, dropped };
360
386
  if (!this.state.mergedPRs)
361
387
  this.state.mergedPRs = [];
362
388
  const existingUrls = new Set(this.state.mergedPRs.map((pr) => pr.url));
363
- const newPRs = prs.filter((pr) => !existingUrls.has(pr.url));
389
+ const newPRs = valid.filter((pr) => !existingUrls.has(pr.url));
364
390
  if (newPRs.length === 0)
365
- return;
391
+ return { added: 0, dropped };
366
392
  this.state.mergedPRs.push(...newPRs);
367
393
  this.state.mergedPRs.sort((a, b) => b.mergedAt.localeCompare(a.mergedAt));
368
394
  debug(MODULE, `Added ${newPRs.length} merged PRs (total: ${this.state.mergedPRs.length})`);
369
395
  this.autoSave();
396
+ return { added: newPRs.length, dropped };
370
397
  }
371
398
  /** Returns the most recent merge date, used as a watermark for incremental fetching. */
372
399
  getMergedPRWatermark() {
@@ -379,21 +406,29 @@ export class StateManager {
379
406
  }
380
407
  /**
381
408
  * Add closed PRs to storage, deduplicating by URL.
409
+ * Entries with URLs that fail {@link parseGitHubUrl} are dropped before
410
+ * persistence (read-side filters in dashboard-data already skip them, but
411
+ * this prevents the bad data from reaching disk in the first place).
382
412
  * @param prs - Closed PRs to add (duplicates by URL are ignored)
413
+ * @returns count of entries added vs. dropped (invalid URL)
383
414
  */
384
415
  addClosedPRs(prs) {
385
416
  if (prs.length === 0)
386
- return;
417
+ return { added: 0, dropped: 0 };
418
+ const { valid, dropped } = filterValidUrlEntries(prs, 'closed');
419
+ if (valid.length === 0)
420
+ return { added: 0, dropped };
387
421
  if (!this.state.closedPRs)
388
422
  this.state.closedPRs = [];
389
423
  const existingUrls = new Set(this.state.closedPRs.map((pr) => pr.url));
390
- const newPRs = prs.filter((pr) => !existingUrls.has(pr.url));
424
+ const newPRs = valid.filter((pr) => !existingUrls.has(pr.url));
391
425
  if (newPRs.length === 0)
392
- return;
426
+ return { added: 0, dropped };
393
427
  this.state.closedPRs.push(...newPRs);
394
428
  this.state.closedPRs.sort((a, b) => b.closedAt.localeCompare(a.closedAt));
395
429
  debug(MODULE, `Added ${newPRs.length} closed PRs (total: ${this.state.closedPRs.length})`);
396
430
  this.autoSave();
431
+ return { added: newPRs.length, dropped };
397
432
  }
398
433
  /** Returns the most recent close date, used as a watermark for incremental fetching. */
399
434
  getClosedPRWatermark() {
@@ -251,3 +251,60 @@ export interface IssueCandidate {
251
251
  viabilityScore: number;
252
252
  searchPriority: SearchPriority;
253
253
  }
254
+ export interface CapacityAssessment {
255
+ hasCapacity: boolean;
256
+ activePRCount: number;
257
+ maxActivePRs: number;
258
+ shelvedPRCount: number;
259
+ criticalIssueCount: number;
260
+ reason: string;
261
+ }
262
+ export type ActionableIssueType = 'ci_failing' | 'merge_conflict' | 'needs_response' | 'needs_changes' | 'incomplete_checklist';
263
+ export interface ActionableIssue {
264
+ type: ActionableIssueType;
265
+ pr: FetchedPR;
266
+ label: string;
267
+ /** True if the PR was created after the last daily digest (first time seen). */
268
+ isNewContribution: boolean;
269
+ }
270
+ /**
271
+ * Compact version of ActionableIssue for JSON output.
272
+ * References the PR by URL instead of embedding the full object,
273
+ * since the full PR is already available in digest.openPRs.
274
+ */
275
+ export interface CompactActionableIssue {
276
+ type: ActionableIssueType;
277
+ prUrl: string;
278
+ label: string;
279
+ /** True if the PR was created after the last daily digest (first time seen). */
280
+ isNewContribution: boolean;
281
+ }
282
+ /**
283
+ * A single action menu item pre-computed by the CLI.
284
+ * The orchestration layer can use these directly in AskUserQuestion prompts.
285
+ */
286
+ export interface ActionMenuItem {
287
+ /** Stable identifier for routing (e.g., "address_all", "search", "done"). */
288
+ key: string;
289
+ /** Display text for the option (e.g., "Work through all 3 issues (Recommended)"). */
290
+ label: string;
291
+ /** Explanation shown below the label. */
292
+ description: string;
293
+ /** Present when the action would exceed the user's PR capacity limit (#765). */
294
+ capacityWarning?: string;
295
+ }
296
+ /**
297
+ * Pre-computed action menu for the orchestration layer.
298
+ */
299
+ export interface ActionMenu {
300
+ /** Ordered list of menu items. */
301
+ items: ActionMenuItem[];
302
+ /** Context flags for the orchestration layer to decide on issue-list options. */
303
+ context: {
304
+ hasActionableIssues: boolean;
305
+ actionableCount: number;
306
+ hasCapacity: boolean;
307
+ hasIssueResponses: boolean;
308
+ issueResponseCount: number;
309
+ };
310
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * GitHub URL parsing + owner/repo helpers.
3
+ *
4
+ * All functions enforce an `https://github.com/` prefix and validate
5
+ * owner/repo characters. Extracted from utils.ts under #1116.
6
+ */
7
+ /**
8
+ * Represents a parsed GitHub pull request or issue URL.
9
+ *
10
+ * @property owner - The repository owner (e.g., `"facebook"`)
11
+ * @property repo - The repository name (e.g., `"react"`)
12
+ * @property number - The PR or issue number
13
+ * @property type - Whether the URL points to a pull request or an issue
14
+ */
15
+ export interface ParsedGitHubUrl {
16
+ owner: string;
17
+ repo: string;
18
+ number: number;
19
+ type: 'pull' | 'issues';
20
+ }
21
+ /**
22
+ * Parses a GitHub pull request or issue URL into its components.
23
+ *
24
+ * Only accepts HTTPS GitHub URLs (`https://github.com/...`). Returns `null` for
25
+ * invalid URLs, non-GitHub URLs, or URLs with invalid owner/repo characters.
26
+ *
27
+ * @example
28
+ * parseGitHubUrl('https://github.com/facebook/react/pull/123')
29
+ * // { owner: "facebook", repo: "react", number: 123, type: "pull" }
30
+ *
31
+ * @example
32
+ * parseGitHubUrl('https://example.com/not-github')
33
+ * // null
34
+ */
35
+ export declare function parseGitHubUrl(url: string): ParsedGitHubUrl | null;
36
+ /**
37
+ * Extracts the owner and repo from a GitHub web URL
38
+ * (e.g. `https://github.com/owner/repo/pull/42`, `https://github.com/owner/repo/`).
39
+ *
40
+ * Unlike {@link parseGitHubUrl}, this does **not** require a PR or issue number.
41
+ *
42
+ * @example
43
+ * extractOwnerRepo('https://github.com/vercel/next.js/')
44
+ * // { owner: "vercel", repo: "next.js" }
45
+ */
46
+ export declare function extractOwnerRepo(url: string): {
47
+ owner: string;
48
+ repo: string;
49
+ } | null;
50
+ /**
51
+ * Splits an `"owner/repo"` string into its owner and repo components.
52
+ *
53
+ * @throws {Error} If the input is not in the form `"owner/repo"`.
54
+ */
55
+ export declare function splitRepo(repoFullName: string): {
56
+ owner: string;
57
+ repo: string;
58
+ };
59
+ /**
60
+ * Case-insensitive check whether a repo owner matches the given GitHub username.
61
+ * Used to skip a user's own repos (PRs to your own repos aren't OSS contributions).
62
+ */
63
+ export declare function isOwnRepo(owner: string, username: string): boolean;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * GitHub URL parsing + owner/repo helpers.
3
+ *
4
+ * All functions enforce an `https://github.com/` prefix and validate
5
+ * owner/repo characters. Extracted from utils.ts under #1116.
6
+ */
7
+ // Validation patterns for GitHub owner and repo names
8
+ const OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
9
+ const REPO_PATTERN = /^[a-zA-Z0-9_.-]+$/;
10
+ function isValidOwnerRepo(owner, repo) {
11
+ return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo);
12
+ }
13
+ /**
14
+ * Parses a GitHub pull request or issue URL into its components.
15
+ *
16
+ * Only accepts HTTPS GitHub URLs (`https://github.com/...`). Returns `null` for
17
+ * invalid URLs, non-GitHub URLs, or URLs with invalid owner/repo characters.
18
+ *
19
+ * @example
20
+ * parseGitHubUrl('https://github.com/facebook/react/pull/123')
21
+ * // { owner: "facebook", repo: "react", number: 123, type: "pull" }
22
+ *
23
+ * @example
24
+ * parseGitHubUrl('https://example.com/not-github')
25
+ * // null
26
+ */
27
+ export function parseGitHubUrl(url) {
28
+ if (!url.startsWith('https://github.com/')) {
29
+ return null;
30
+ }
31
+ const prMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
32
+ if (prMatch) {
33
+ const owner = prMatch[1];
34
+ const repo = prMatch[2];
35
+ if (!isValidOwnerRepo(owner, repo)) {
36
+ return null;
37
+ }
38
+ return {
39
+ owner,
40
+ repo,
41
+ number: parseInt(prMatch[3], 10),
42
+ type: 'pull',
43
+ };
44
+ }
45
+ const issueMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
46
+ if (issueMatch) {
47
+ const owner = issueMatch[1];
48
+ const repo = issueMatch[2];
49
+ if (!isValidOwnerRepo(owner, repo)) {
50
+ return null;
51
+ }
52
+ return {
53
+ owner,
54
+ repo,
55
+ number: parseInt(issueMatch[3], 10),
56
+ type: 'issues',
57
+ };
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Extracts the owner and repo from a GitHub web URL
63
+ * (e.g. `https://github.com/owner/repo/pull/42`, `https://github.com/owner/repo/`).
64
+ *
65
+ * Unlike {@link parseGitHubUrl}, this does **not** require a PR or issue number.
66
+ *
67
+ * @example
68
+ * extractOwnerRepo('https://github.com/vercel/next.js/')
69
+ * // { owner: "vercel", repo: "next.js" }
70
+ */
71
+ export function extractOwnerRepo(url) {
72
+ if (!url.startsWith('https://github.com/'))
73
+ return null;
74
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
75
+ if (!match)
76
+ return null;
77
+ const owner = match[1];
78
+ const repo = match[2];
79
+ if (!isValidOwnerRepo(owner, repo))
80
+ return null;
81
+ return { owner, repo };
82
+ }
83
+ /**
84
+ * Splits an `"owner/repo"` string into its owner and repo components.
85
+ *
86
+ * @throws {Error} If the input is not in the form `"owner/repo"`.
87
+ */
88
+ export function splitRepo(repoFullName) {
89
+ const [owner, repo] = repoFullName.split('/');
90
+ if (!owner || !repo) {
91
+ throw new Error(`Invalid repo format: expected "owner/repo", got "${repoFullName}"`);
92
+ }
93
+ return { owner, repo };
94
+ }
95
+ /**
96
+ * Case-insensitive check whether a repo owner matches the given GitHub username.
97
+ * Used to skip a user's own repos (PRs to your own repos aren't OSS contributions).
98
+ */
99
+ export function isOwnRepo(owner, username) {
100
+ return owner.toLowerCase() === username.toLowerCase();
101
+ }