@oss-autopilot/core 1.17.0 → 1.17.2

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.
@@ -222,32 +222,46 @@ export async function runStartup() {
222
222
  }
223
223
  // 3. Run daily check
224
224
  const daily = await executeDailyCheck(token);
225
- // 4. Launch interactive SPA dashboard
226
- // Skip opening on first run (0 PRs) — the welcome flow handles onboarding
225
+ // 4. Launch interactive SPA dashboard.
226
+ //
227
+ // Launched unconditionally once setup and auth pass. A prior heuristic skipped
228
+ // launch whenever `totalActivePRs === 0`, assuming that meant a genuine first
229
+ // run and deferring to the CLI's welcome flow. That gate also swallowed the
230
+ // dashboard in three legitimate cases: a misconfigured/stale `githubUsername`
231
+ // (Search API returns zero), transient GitHub API flakes (state still holds
232
+ // merged PRs but the live count is zero), and users genuinely between PRs.
233
+ // The dashboard's own empty-state UI renders "no PRs" cleanly, so always
234
+ // surfacing it is the right default.
227
235
  let dashboardUrl;
228
236
  let dashboardStatus;
229
- if (daily.digest.summary.totalActivePRs > 0) {
230
- try {
231
- const spaResult = await launchDashboardServer();
232
- if (spaResult) {
233
- dashboardUrl = spaResult.url;
234
- if (spaResult.alreadyRunning) {
235
- const refreshed = await triggerDashboardRefresh(spaResult.port);
236
- dashboardStatus = refreshed ? 'refreshed' : 'running';
237
- }
238
- else {
239
- openInBrowser(spaResult.url);
240
- dashboardStatus = 'opened';
241
- }
237
+ let dashboardError;
238
+ try {
239
+ const spaResult = await launchDashboardServer();
240
+ if (spaResult) {
241
+ dashboardUrl = spaResult.url;
242
+ if (spaResult.alreadyRunning) {
243
+ const refreshed = await triggerDashboardRefresh(spaResult.port);
244
+ dashboardStatus = refreshed ? 'refreshed' : 'running';
242
245
  }
243
246
  else {
244
- console.error('[STARTUP] Dashboard SPA assets not found. Build with: cd packages/dashboard && pnpm run build');
247
+ dashboardStatus = 'opened';
245
248
  }
249
+ // `open`/`xdg-open`/`start` focus an existing tab matching the URL
250
+ // instead of duplicating it, so this is safe whether the server was
251
+ // just started or was already running. Closes #830 properly — a user
252
+ // can close the dashboard tab while the daemon keeps running, leaving
253
+ // subsequent /oss runs with no visible dashboard if we didn't re-open.
254
+ openInBrowser(spaResult.url);
246
255
  }
247
- catch (error) {
248
- console.error('[STARTUP] SPA dashboard launch failed:', errorMessage(error));
256
+ else {
257
+ dashboardError = 'Dashboard SPA assets not found. Build with: cd packages/dashboard && pnpm run build';
258
+ console.error(`[STARTUP] ${dashboardError}`);
249
259
  }
250
260
  }
261
+ catch (error) {
262
+ dashboardError = `SPA dashboard launch failed: ${errorMessage(error)}`;
263
+ console.error(`[STARTUP] ${dashboardError}`);
264
+ }
251
265
  // Append dashboard status to brief summary
252
266
  if (dashboardStatus === 'opened') {
253
267
  daily.briefSummary += ' | Dashboard opened in browser';
@@ -266,6 +280,7 @@ export async function runStartup() {
266
280
  autoDetected,
267
281
  daily,
268
282
  dashboardUrl,
283
+ dashboardError,
269
284
  issueList,
270
285
  };
271
286
  }
@@ -18,6 +18,8 @@ export { computeDisplayLabel } from './display-utils.js';
18
18
  export { classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
19
19
  export { isConditionalChecklistItem } from './checklist-analysis.js';
20
20
  export { determineStatus } from './status-determination.js';
21
+ declare function isPlaceholderUsername(username: string): boolean;
22
+ export { isPlaceholderUsername };
21
23
  /**
22
24
  * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
23
25
  * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
@@ -34,10 +36,13 @@ export interface FetchPRsResult {
34
36
  prs: FetchedPR[];
35
37
  failures: PRCheckFailure[];
36
38
  /**
37
- * Non-fatal warnings accumulated while fetching. Currently populated when
38
- * the GitHub Search API's 1000-result ceiling truncates the user's PR
39
- * list callers (daily, dashboard) surface these so users know the data
40
- * may be incomplete (#1057 M25).
39
+ * Non-fatal warnings accumulated while fetching. Populated by:
40
+ * - Placeholder auto-repair (stale/example `githubUsername` replaced with
41
+ * the authenticated viewer's login before the search runs).
42
+ * - Post-fetch viewer-mismatch guardrail (configured username differs
43
+ * from the authenticated viewer when the search returned zero PRs).
44
+ * - Search API 1000-result truncation (#1057 M25).
45
+ * Callers (daily, dashboard) surface these so users see the signal.
41
46
  */
42
47
  warnings?: string[];
43
48
  }
@@ -17,7 +17,7 @@ import { getStateManager } from './state.js';
17
17
  import { daysBetween, parseGitHubUrl, extractOwnerRepo, isOwnRepo, DEFAULT_CONCURRENCY } from './utils.js';
18
18
  import { determineStatus } from './status-determination.js';
19
19
  import { runWorkerPool } from './concurrency.js';
20
- import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode } from './errors.js';
20
+ import { ConfigurationError, ValidationError, errorMessage, getHttpStatusCode, isRateLimitOrAuthError, } from './errors.js';
21
21
  import { paginateAll } from './pagination.js';
22
22
  import { debug, warn, timed } from './logger.js';
23
23
  import { getHttpCache, cachedRequest } from './http-cache.js';
@@ -32,6 +32,29 @@ export { computeDisplayLabel } from './display-utils.js';
32
32
  export { classifyCICheck, classifyFailingChecks, getCIStatus } from './ci-analysis.js';
33
33
  export { isConditionalChecklistItem } from './checklist-analysis.js';
34
34
  export { determineStatus } from './status-determination.js';
35
+ /**
36
+ * Known placeholder values that can end up in `config.githubUsername` from
37
+ * doc snippets, example configs, or aborted setup flows. When the configured
38
+ * username matches one of these, the PR fetch silently returns zero results
39
+ * and the dashboard looks like a fresh install. Detecting these lets us
40
+ * auto-repair the config from the authenticated viewer before fetching.
41
+ *
42
+ * Entries must be lowercase — `Lowercase<string>` on the source tuple makes
43
+ * a non-lowercase entry a compile error, keeping the case-insensitive lookup
44
+ * contract type-checked instead of comment-documented.
45
+ */
46
+ const PLACEHOLDER_USERNAMES = [
47
+ 'example-user',
48
+ 'your-username',
49
+ 'your-github-username',
50
+ ];
51
+ const KNOWN_PLACEHOLDER_USERNAMES = new Set(PLACEHOLDER_USERNAMES);
52
+ function isPlaceholderUsername(username) {
53
+ return KNOWN_PLACEHOLDER_USERNAMES.has(username.toLowerCase());
54
+ }
55
+ // Module-private on purpose: callers should only use the predicate so the
56
+ // `.toLowerCase()` contract can't be bypassed by reading the set directly.
57
+ export { isPlaceholderUsername };
35
58
  /**
36
59
  * Check if a PR has a merge conflict based on GitHub's mergeable flag and mergeable_state.
37
60
  * Returns true when mergeable is explicitly false or the mergeable_state is 'dirty'.
@@ -78,17 +101,71 @@ export class PRMonitor {
78
101
  * ```
79
102
  */
80
103
  async fetchUserOpenPRs() {
81
- const config = this.stateManager.getState().config;
82
- if (!config.githubUsername) {
104
+ const initialConfig = this.stateManager.getState().config;
105
+ if (!initialConfig.githubUsername) {
83
106
  throw new ConfigurationError('No GitHub username configured. Run setup first.');
84
107
  }
85
- debug('pr-monitor', `Fetching open PRs for @${config.githubUsername}...`);
108
+ // Non-fatal warnings threaded into the result (#1057 M25). When the
109
+ // Search API's hard 1000-result ceiling truncates the user's PR list we
110
+ // previously silently dropped the overflow; now the caller can surface
111
+ // it so the daily digest doesn't quietly report a partial view.
112
+ const warnings = [];
113
+ // Username used for the search — mutated below if the pre-fetch placeholder
114
+ // repair fires. Writing to config is separate from rebinding this local.
115
+ let searchUsername = initialConfig.githubUsername;
116
+ // Proactive placeholder repair: if the configured username is a known
117
+ // placeholder (e.g. "example-user" carried over from docs or an aborted
118
+ // setup), cross-check against the authenticated viewer and persist the
119
+ // corrected name before fetching. Without this, the search silently
120
+ // returns zero results and the dashboard looks like a fresh install.
121
+ // Errors here are non-fatal; rate-limit/auth failures still abort so we
122
+ // don't mask a revoked token by downgrading to a no-op.
123
+ let didRepair = false;
124
+ if (isPlaceholderUsername(searchUsername)) {
125
+ try {
126
+ const { data: viewer } = await this.octokit.users.getAuthenticated();
127
+ const newLogin = viewer.login?.trim();
128
+ // Guard against an empty/whitespace viewer login (enterprise proxies,
129
+ // stubbed Octokit clients) and against the pathological case where the
130
+ // authenticated viewer's login is itself one of our placeholder strings
131
+ // — persisting either would swap one broken config for another.
132
+ if (!newLogin || isPlaceholderUsername(newLogin)) {
133
+ const message = `Placeholder username "${searchUsername}" detected but authenticated viewer ` +
134
+ `returned an unusable login (${JSON.stringify(viewer.login)}); skipping auto-repair.`;
135
+ warnings.push(message);
136
+ warn(MODULE, message);
137
+ }
138
+ else {
139
+ this.stateManager.updateConfig({ githubUsername: newLogin });
140
+ searchUsername = newLogin;
141
+ didRepair = true;
142
+ const message = `Configured GitHub username "${initialConfig.githubUsername}" looks like a placeholder. ` +
143
+ `Auto-repaired to "${newLogin}" using the authenticated viewer.`;
144
+ warnings.push(message);
145
+ warn(MODULE, message);
146
+ }
147
+ }
148
+ catch (err) {
149
+ if (isRateLimitOrAuthError(err))
150
+ throw err;
151
+ // Non-fatal viewer-lookup failures (5xx, network, unexpected shape):
152
+ // surface as a warning (not debug) so the daily digest shows that
153
+ // auto-repair was attempted and couldn't complete. Falls through to
154
+ // the normal fetch with the placeholder, which will then return zero
155
+ // results — the post-fetch guardrail skips its own getAuthenticated
156
+ // attempt since this one already failed the same way.
157
+ const message = `Could not auto-repair placeholder username "${searchUsername}": ${errorMessage(err)}`;
158
+ warnings.push(message);
159
+ warn(MODULE, message);
160
+ }
161
+ }
162
+ debug('pr-monitor', `Fetching open PRs for @${searchUsername}...`);
86
163
  // Search for all open PRs authored by the user with pagination
87
164
  const allItems = [];
88
165
  let page = 1;
89
166
  const perPage = 100;
90
167
  const firstPage = await this.octokit.search.issuesAndPullRequests({
91
- q: `is:pr is:open is:public author:${config.githubUsername}`,
168
+ q: `is:pr is:open is:public author:${searchUsername}`,
92
169
  sort: 'updated',
93
170
  order: 'desc',
94
171
  per_page: perPage,
@@ -101,13 +178,36 @@ export class PRMonitor {
101
178
  const SEARCH_API_RESULT_CAP = 1000;
102
179
  const MAX_PAGES = Math.ceil(SEARCH_API_RESULT_CAP / perPage); // 10 pages at per_page=100
103
180
  const totalPages = Math.min(Math.ceil(totalCount / perPage), MAX_PAGES);
104
- // Non-fatal warnings threaded into the result (#1057 M25). When the
105
- // Search API's hard 1000-result ceiling truncates the user's PR list we
106
- // previously silently dropped the overflow; now the caller can surface
107
- // it so the daily digest doesn't quietly report a partial view.
108
- const warnings = [];
181
+ // Guardrail: if the Search API returned zero PRs, cross-check the
182
+ // configured username against the authenticated viewer. This catches
183
+ // stale usernames (e.g. a renamed GitHub account) that are not in the
184
+ // known-placeholder set. Skipped when the pre-fetch repair already
185
+ // reconciled the two — no need to spend a second getAuthenticated call
186
+ // just to confirm a match we already established.
187
+ if (totalCount === 0 && !didRepair) {
188
+ try {
189
+ const { data: viewer } = await this.octokit.users.getAuthenticated();
190
+ if (viewer.login.toLowerCase() !== searchUsername.toLowerCase()) {
191
+ const message = `Configured GitHub username @${searchUsername} does not match ` +
192
+ `authenticated user @${viewer.login}. Did you mean to run ` +
193
+ `\`oss-autopilot config username ${viewer.login}\`? Zero PRs returned.`;
194
+ warnings.push(message);
195
+ warn(MODULE, message);
196
+ }
197
+ }
198
+ catch (err) {
199
+ // Rate-limit/401/403 errors must abort the run just like every sibling
200
+ // fetch in this pipeline — swallowing them here would mask the exact
201
+ // class of failure the guardrail is meant to surface (e.g. revoked
202
+ // token returning 401 while the unauthenticated Search above still
203
+ // succeeds with zero results).
204
+ if (isRateLimitOrAuthError(err))
205
+ throw err;
206
+ debug(MODULE, `Could not cross-check viewer login: ${errorMessage(err)}`);
207
+ }
208
+ }
109
209
  if (totalCount > SEARCH_API_RESULT_CAP) {
110
- warnings.push(`GitHub Search API returned ${totalCount} PRs for @${config.githubUsername}, ` +
210
+ warnings.push(`GitHub Search API returned ${totalCount} PRs for @${searchUsername}, ` +
111
211
  `but results are capped at ${SEARCH_API_RESULT_CAP}. ` +
112
212
  `Showing the ${SEARCH_API_RESULT_CAP} most recently updated PRs.`);
113
213
  warn(MODULE, warnings[warnings.length - 1]);
@@ -115,7 +215,7 @@ export class PRMonitor {
115
215
  while (page < totalPages) {
116
216
  page++;
117
217
  const nextPage = await this.octokit.search.issuesAndPullRequests({
118
- q: `is:pr is:open is:public author:${config.githubUsername}`,
218
+ q: `is:pr is:open is:public author:${searchUsername}`,
119
219
  sort: 'updated',
120
220
  order: 'desc',
121
221
  per_page: perPage,
@@ -135,7 +235,7 @@ export class PRMonitor {
135
235
  warn('pr-monitor', `Skipping PR with unparseable URL: ${item.html_url}`);
136
236
  return false;
137
237
  }
138
- if (isOwnRepo(parsed.owner, config.githubUsername))
238
+ if (isOwnRepo(parsed.owner, searchUsername))
139
239
  return false;
140
240
  return true;
141
241
  });
@@ -274,6 +274,13 @@ export interface StartupOutput {
274
274
  daily?: DailyOutput;
275
275
  /** URL of the interactive SPA dashboard server, when running (e.g., "http://localhost:3000") */
276
276
  dashboardUrl?: string;
277
+ /**
278
+ * Set when the dashboard launch or refresh failed (assets missing, port
279
+ * conflict, spawn error, etc.). The dashboard is always attempted, so JSON
280
+ * consumers — which previously saw only a missing `dashboardUrl` — now have
281
+ * a structured signal to surface or recover from the failure.
282
+ */
283
+ dashboardError?: string;
277
284
  issueList?: IssueListInfo;
278
285
  }
279
286
  /**
@@ -70,6 +70,7 @@ export function toCompactStartupOutput(output) {
70
70
  ...rest,
71
71
  daily: daily ? toCompactDailyOutput(daily) : undefined,
72
72
  dashboardUrl: output.dashboardUrl,
73
+ dashboardError: output.dashboardError,
73
74
  issueList: output.issueList,
74
75
  };
75
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-autopilot/core",
3
- "version": "1.17.0",
3
+ "version": "1.17.2",
4
4
  "description": "CLI and core library for managing open source contributions",
5
5
  "type": "module",
6
6
  "bin": {