@oss-autopilot/core 0.42.6 → 0.43.1

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.
@@ -8,10 +8,12 @@
8
8
  */
9
9
  import * as fs from 'fs';
10
10
  import { execFile } from 'child_process';
11
- import { getStateManager, getGitHubToken, getCLIVersion } from '../core/index.js';
11
+ import { getStateManager, getGitHubToken, getCLIVersion, getStatePath, getDashboardPath } from '../core/index.js';
12
12
  import { errorMessage } from '../core/errors.js';
13
+ import { warn } from '../core/logger.js';
13
14
  import { executeDailyCheck } from './daily.js';
14
15
  import { writeDashboardFromState } from './dashboard.js';
16
+ import { launchDashboardServer } from './dashboard-lifecycle.js';
15
17
  /**
16
18
  * Parse issueListPath from a config file's YAML frontmatter.
17
19
  * Returns the path string or undefined if not found.
@@ -93,21 +95,53 @@ export function detectIssueList() {
93
95
  }
94
96
  }
95
97
  function openInBrowser(filePath) {
96
- const isWindows = process.platform === 'win32';
97
- const openCmd = process.platform === 'darwin' ? 'open' : isWindows ? 'cmd' : 'xdg-open';
98
- const args = isWindows ? ['/c', 'start', '', filePath] : [filePath];
98
+ let openCmd;
99
+ let args;
100
+ switch (process.platform) {
101
+ case 'darwin':
102
+ openCmd = 'open';
103
+ args = [filePath];
104
+ break;
105
+ case 'win32':
106
+ openCmd = 'cmd';
107
+ args = ['/c', 'start', '', filePath];
108
+ break;
109
+ default:
110
+ openCmd = 'xdg-open';
111
+ args = [filePath];
112
+ break;
113
+ }
99
114
  execFile(openCmd, args, (error) => {
100
115
  if (error) {
101
116
  console.error(`[STARTUP] Failed to open dashboard in browser: ${error.message}`);
102
117
  }
103
118
  });
104
119
  }
120
+ /**
121
+ * Check whether the dashboard HTML file is at least as recent as state.json.
122
+ * Returns true when the dashboard exists and its mtime >= state mtime,
123
+ * meaning there is no need to regenerate it.
124
+ */
125
+ function isDashboardFresh() {
126
+ try {
127
+ const dashPath = getDashboardPath();
128
+ if (!fs.existsSync(dashPath))
129
+ return false;
130
+ const dashMtime = fs.statSync(dashPath).mtimeMs;
131
+ const stateMtime = fs.statSync(getStatePath()).mtimeMs;
132
+ return dashMtime >= stateMtime;
133
+ }
134
+ catch (error) {
135
+ warn('startup', `Failed to check dashboard freshness, will regenerate: ${errorMessage(error)}`);
136
+ return false;
137
+ }
138
+ }
105
139
  /**
106
140
  * Run startup checks and return structured output.
107
141
  * Returns StartupOutput with one of three shapes:
108
142
  * 1. Setup incomplete: { version, setupComplete: false }
109
143
  * 2. Auth failure: { version, setupComplete: true, authError: "..." }
110
- * 3. Success: { version, setupComplete: true, daily, dashboardPath?, issueList? }
144
+ * 3. Success: { version, setupComplete: true, daily, dashboardPath?, dashboardUrl?, issueList? }
111
145
  *
112
146
  * Errors from the daily check propagate to the caller.
113
147
  */
@@ -129,31 +163,56 @@ export async function runStartup() {
129
163
  }
130
164
  // 3. Run daily check
131
165
  const daily = await executeDailyCheck(token);
132
- // 4. Generate dashboard from state (just saved by daily)
133
- // Skip opening on first run (0 PRs) the welcome flow handles onboarding
166
+ // 4. Generate static HTML dashboard (serves as fallback + snapshot).
167
+ // Skip regeneration if the dashboard HTML is already newer than state.json.
134
168
  let dashboardPath;
135
- let dashboardOpened = false;
136
169
  try {
137
- dashboardPath = writeDashboardFromState();
138
- if (daily.digest.summary.totalActivePRs > 0) {
139
- openInBrowser(dashboardPath);
140
- dashboardOpened = true;
170
+ if (isDashboardFresh()) {
171
+ dashboardPath = getDashboardPath();
172
+ console.error('[STARTUP] Dashboard HTML is fresh, skipping regeneration');
173
+ }
174
+ else {
175
+ dashboardPath = writeDashboardFromState();
141
176
  }
142
177
  }
143
178
  catch (error) {
144
179
  console.error('[STARTUP] Dashboard generation failed:', errorMessage(error));
145
180
  }
181
+ // 5. Launch interactive SPA dashboard (preferred) with static HTML fallback
182
+ // Skip opening on first run (0 PRs) — the welcome flow handles onboarding
183
+ let dashboardUrl;
184
+ let dashboardOpened = false;
185
+ if (daily.digest.summary.totalActivePRs > 0) {
186
+ let spaResult = null;
187
+ try {
188
+ spaResult = await launchDashboardServer();
189
+ }
190
+ catch (error) {
191
+ console.error('[STARTUP] SPA dashboard launch failed:', errorMessage(error));
192
+ }
193
+ if (spaResult) {
194
+ dashboardUrl = spaResult.url;
195
+ openInBrowser(spaResult.url);
196
+ dashboardOpened = true;
197
+ }
198
+ else if (dashboardPath) {
199
+ // SPA unavailable (assets not built) — fall back to static HTML
200
+ openInBrowser(dashboardPath);
201
+ dashboardOpened = true;
202
+ }
203
+ }
146
204
  // Append dashboard status to brief summary (only startup opens the browser, not daily)
147
205
  if (dashboardOpened) {
148
206
  daily.briefSummary += ' | Dashboard opened in browser';
149
207
  }
150
- // 5. Detect issue list
208
+ // 6. Detect issue list
151
209
  const issueList = detectIssueList();
152
210
  return {
153
211
  version,
154
212
  setupComplete: true,
155
213
  daily,
156
214
  dashboardPath,
215
+ dashboardUrl,
157
216
  issueList,
158
217
  };
159
218
  }
@@ -3,9 +3,10 @@
3
3
  * Vets a specific issue before working on it
4
4
  */
5
5
  import { IssueDiscovery, requireGitHubToken } from '../core/index.js';
6
- import { validateUrl } from './validation.js';
6
+ import { ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl } from './validation.js';
7
7
  export async function runVet(options) {
8
8
  validateUrl(options.issueUrl);
9
+ validateGitHubUrl(options.issueUrl, ISSUE_URL_PATTERN, 'issue');
9
10
  const token = requireGitHubToken();
10
11
  const discovery = new IssueDiscovery(token);
11
12
  const candidate = await discovery.vetIssue(options.issueUrl);
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Runs a worker pool that processes items with bounded concurrency.
3
- * N workers consume from a shared index simpler than Promise.race + splice.
3
+ * N workers consume from a shared index. On any worker error, remaining
4
+ * workers are aborted via a shared flag and the error is propagated.
4
5
  */
5
6
  export declare function runWorkerPool<T>(items: T[], worker: (item: T) => Promise<void>, concurrency: number): Promise<void>;
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * Runs a worker pool that processes items with bounded concurrency.
3
- * N workers consume from a shared index simpler than Promise.race + splice.
3
+ * N workers consume from a shared index. On any worker error, remaining
4
+ * workers are aborted via a shared flag and the error is propagated.
4
5
  */
5
6
  export async function runWorkerPool(items, worker, concurrency) {
6
7
  let index = 0;
8
+ let aborted = false;
7
9
  const poolWorker = async () => {
8
10
  while (index < items.length) {
11
+ if (aborted)
12
+ break;
9
13
  const item = items[index++];
10
- await worker(item);
14
+ try {
15
+ await worker(item);
16
+ }
17
+ catch (err) {
18
+ aborted = true;
19
+ throw err;
20
+ }
11
21
  }
12
22
  };
13
23
  const workerCount = Math.min(concurrency, items.length);
@@ -350,11 +350,7 @@ export class PRMonitor {
350
350
  * Check if PR has merge conflict
351
351
  */
352
352
  hasMergeConflict(mergeable, mergeableState) {
353
- if (mergeable === false)
354
- return true;
355
- if (mergeableState === 'dirty')
356
- return true;
357
- return false;
353
+ return mergeable === false || mergeableState === 'dirty';
358
354
  }
359
355
  /**
360
356
  * Get CI status from combined status API and check runs.
@@ -371,6 +367,14 @@ export class PRMonitor {
371
367
  // 404 is expected for repos without check runs configured; log other errors for debugging
372
368
  this.octokit.checks.listForRef({ owner, repo, ref: sha }).catch((err) => {
373
369
  const status = getHttpStatusCode(err);
370
+ // Rate limit errors must propagate — matches listReviewComments pattern (#481)
371
+ if (status === 429)
372
+ throw err;
373
+ if (status === 403) {
374
+ const msg = errorMessage(err).toLowerCase();
375
+ if (msg.includes('rate limit') || msg.includes('abuse detection'))
376
+ throw err;
377
+ }
374
378
  if (status === 404) {
375
379
  debug('pr-monitor', `Check runs 404 for ${owner}/${repo}@${sha.slice(0, 7)} (no checks configured)`);
376
380
  }
@@ -400,12 +404,8 @@ export class PRMonitor {
400
404
  }
401
405
  catch (error) {
402
406
  const statusCode = getHttpStatusCode(error);
403
- const errMsg = errorMessage(error);
404
- if (statusCode === 401) {
405
- warn('pr-monitor', `CI check failed for ${owner}/${repo}: Invalid token`);
406
- }
407
- else if (statusCode === 403) {
408
- warn('pr-monitor', `CI check failed for ${owner}/${repo}: Rate limit exceeded`);
407
+ if (statusCode === 401 || statusCode === 403 || statusCode === 429) {
408
+ throw error;
409
409
  }
410
410
  else if (statusCode === 404) {
411
411
  // Repo might not have CI configured, this is normal
@@ -413,7 +413,7 @@ export class PRMonitor {
413
413
  return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
414
414
  }
415
415
  else {
416
- warn('pr-monitor', `Failed to check CI for ${owner}/${repo}@${sha.slice(0, 7)}: ${errMsg}`);
416
+ warn('pr-monitor', `Failed to check CI for ${owner}/${repo}@${sha.slice(0, 7)}: ${errorMessage(error)}`);
417
417
  }
418
418
  return { status: 'unknown', failingCheckNames: [], failingCheckConclusions: new Map() };
419
419
  }
@@ -144,10 +144,9 @@ export declare function daysBetween(from: Date, to?: Date): number;
144
144
  /**
145
145
  * Splits an `"owner/repo"` string into its owner and repo components.
146
146
  *
147
- * Does not validate the input format; if no `/` is present, `repo` will be `undefined`.
148
- *
149
147
  * @param repoFullName - Full repository name in `"owner/repo"` format
150
148
  * @returns Object with `owner` and `repo` string properties
149
+ * @throws {Error} If the input does not contain both an owner and repo separated by `/`
151
150
  *
152
151
  * @example
153
152
  * splitRepo('facebook/react')
@@ -215,10 +215,9 @@ export function daysBetween(from, to = new Date()) {
215
215
  /**
216
216
  * Splits an `"owner/repo"` string into its owner and repo components.
217
217
  *
218
- * Does not validate the input format; if no `/` is present, `repo` will be `undefined`.
219
- *
220
218
  * @param repoFullName - Full repository name in `"owner/repo"` format
221
219
  * @returns Object with `owner` and `repo` string properties
220
+ * @throws {Error} If the input does not contain both an owner and repo separated by `/`
222
221
  *
223
222
  * @example
224
223
  * splitRepo('facebook/react')
@@ -226,6 +225,9 @@ export function daysBetween(from, to = new Date()) {
226
225
  */
227
226
  export function splitRepo(repoFullName) {
228
227
  const [owner, repo] = repoFullName.split('/');
228
+ if (!owner || !repo) {
229
+ throw new Error(`Invalid repo format: expected "owner/repo", got "${repoFullName}"`);
230
+ }
229
231
  return { owner, repo };
230
232
  }
231
233
  /**
@@ -200,7 +200,7 @@ export interface IssueListInfo {
200
200
  * Three valid shapes:
201
201
  * 1. Setup incomplete: { version, setupComplete: false }
202
202
  * 2. Auth failure: { version, setupComplete: true, authError: "..." }
203
- * 3. Success: { version, setupComplete: true, daily, dashboardPath?, issueList? }
203
+ * 3. Success: { version, setupComplete: true, daily, dashboardPath?, dashboardUrl?, issueList? }
204
204
  */
205
205
  export interface StartupOutput {
206
206
  version: string;
@@ -208,6 +208,8 @@ export interface StartupOutput {
208
208
  authError?: string;
209
209
  daily?: DailyOutput;
210
210
  dashboardPath?: string;
211
+ /** URL of the interactive SPA dashboard server, when running (e.g., "http://localhost:3000") */
212
+ dashboardUrl?: string;
211
213
  issueList?: IssueListInfo;
212
214
  }
213
215
  /** A single parsed issue from a markdown list (#82) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "0.42.6",
3
+ "version": "0.43.1",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc",
63
- "bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/cli.bundle.cjs",
63
+ "bundle": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=cjs --minify --outfile=dist/cli.bundle.cjs",
64
64
  "start": "tsx src/cli.ts",
65
65
  "dev": "tsx watch src/cli.ts",
66
66
  "typecheck": "tsc --noEmit",