@oss-autopilot/core 1.16.2 → 1.17.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 (58) hide show
  1. package/dist/cli-registry.js +53 -11
  2. package/dist/cli.bundle.cjs +82 -69
  3. package/dist/cli.js +22 -10
  4. package/dist/commands/comments.js +38 -20
  5. package/dist/commands/config.d.ts +9 -2
  6. package/dist/commands/config.js +12 -3
  7. package/dist/commands/daily.d.ts +3 -1
  8. package/dist/commands/daily.js +126 -37
  9. package/dist/commands/dashboard-data.d.ts +26 -2
  10. package/dist/commands/dashboard-data.js +45 -19
  11. package/dist/commands/dashboard-server.d.ts +1 -1
  12. package/dist/commands/dashboard-server.js +104 -19
  13. package/dist/commands/dismiss.js +4 -1
  14. package/dist/commands/doctor.d.ts +49 -0
  15. package/dist/commands/doctor.js +358 -0
  16. package/dist/commands/index.d.ts +2 -0
  17. package/dist/commands/index.js +2 -0
  18. package/dist/commands/move.d.ts +1 -2
  19. package/dist/commands/move.js +8 -4
  20. package/dist/commands/read.js +2 -1
  21. package/dist/commands/search.d.ts +0 -18
  22. package/dist/commands/search.js +38 -1
  23. package/dist/commands/setup.js +42 -2
  24. package/dist/commands/shelve.js +4 -1
  25. package/dist/commands/skip-add.js +1 -1
  26. package/dist/commands/startup.js +7 -3
  27. package/dist/commands/track.js +2 -1
  28. package/dist/commands/vet-list.d.ts +23 -2
  29. package/dist/commands/vet-list.js +57 -10
  30. package/dist/core/anti-llm-policy.d.ts +5 -0
  31. package/dist/core/anti-llm-policy.js +5 -0
  32. package/dist/core/ci-analysis.js +6 -1
  33. package/dist/core/config-registry.d.ts +44 -0
  34. package/dist/core/config-registry.js +286 -0
  35. package/dist/core/dashboard-data-schema.d.ts +78 -0
  36. package/dist/core/dashboard-data-schema.js +80 -0
  37. package/dist/core/errors.d.ts +14 -0
  38. package/dist/core/errors.js +22 -0
  39. package/dist/core/http-cache.d.ts +8 -1
  40. package/dist/core/http-cache.js +59 -1
  41. package/dist/core/index.d.ts +3 -1
  42. package/dist/core/index.js +3 -1
  43. package/dist/core/maintainer-analysis.js +9 -3
  44. package/dist/core/pr-monitor.d.ts +7 -0
  45. package/dist/core/pr-monitor.js +16 -3
  46. package/dist/core/repo-score-manager.d.ts +17 -3
  47. package/dist/core/repo-score-manager.js +48 -19
  48. package/dist/core/state-persistence.d.ts +14 -1
  49. package/dist/core/state-persistence.js +24 -2
  50. package/dist/core/state-schema.d.ts +2 -0
  51. package/dist/core/state-schema.js +5 -0
  52. package/dist/core/state.d.ts +26 -2
  53. package/dist/core/state.js +50 -5
  54. package/dist/core/status-determination.d.ts +16 -0
  55. package/dist/core/status-determination.js +44 -11
  56. package/dist/formatters/json.d.ts +40 -2
  57. package/dist/formatters/json.js +1 -0
  58. package/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { execFile } from 'child_process';
12
- import { getStateManager, getGitHubToken, getCLIVersion, detectGitHubUsername } from '../core/index.js';
12
+ import { getStateManager, getGitHubTokenAsync, getCLIVersion, detectGitHubUsername } from '../core/index.js';
13
13
  import { errorMessage } from '../core/errors.js';
14
14
  import { warn } from '../core/logger.js';
15
15
  import { executeDailyCheck } from './daily.js';
@@ -207,8 +207,12 @@ export async function runStartup() {
207
207
  return { version, setupComplete: false };
208
208
  }
209
209
  }
210
- // 2. Check auth
211
- const token = getGitHubToken();
210
+ // 2. Check auth — use the async variant so the `gh auth token` CLI fallback
211
+ // fires for users who ran `gh auth login` but never exported $GITHUB_TOKEN.
212
+ // The sync `getGitHubToken()` reads only the env var, matching the `preAction`
213
+ // token check that the CLI's `localOnly: true` flag on `startup` deliberately
214
+ // skips — the mismatch produced a spurious `authError` for valid users.
215
+ const token = await getGitHubTokenAsync();
212
216
  if (!token) {
213
217
  return {
214
218
  version,
@@ -13,6 +13,7 @@
13
13
  * a PR from the daily digest.
14
14
  */
15
15
  import { getOctokit, requireGitHubToken } from '../core/index.js';
16
+ import { ValidationError } from '../core/errors.js';
16
17
  import { validateUrl, PR_URL_PATTERN, validateGitHubUrl } from './validation.js';
17
18
  import { parseGitHubUrl } from '../core/utils.js';
18
19
  /**
@@ -34,7 +35,7 @@ export async function runTrack(options) {
34
35
  const octokit = getOctokit(token);
35
36
  const parsed = parseGitHubUrl(options.prUrl);
36
37
  if (!parsed || parsed.type !== 'pull') {
37
- throw new Error(`Invalid PR URL: ${options.prUrl}`);
38
+ throw new ValidationError(`Invalid PR URL: ${options.prUrl}`);
38
39
  }
39
40
  const { owner, repo, number } = parsed;
40
41
  const { data: ghPR } = await octokit.pulls.get({ owner, repo, pull_number: number });
@@ -8,11 +8,32 @@ interface VetListOptions {
8
8
  concurrency?: number;
9
9
  prune?: boolean;
10
10
  }
11
+ /**
12
+ * Scout-side enumerated skip reasons (#1043). If scout emits a `skipReason`
13
+ * field on its vet candidate, we route on that directly rather than doing
14
+ * fragile substring matches against free-text. The strings scout uses today
15
+ * (e.g. "Issue is closed", "Issue is already claimed") would silently stop
16
+ * matching on a rewording — the enum makes the data contract explicit.
17
+ *
18
+ * Scout has not yet landed the enum emitter; this PR lands the reader so the
19
+ * switchover is drop-in when scout ships it. The substring fallback below
20
+ * covers the transition period.
21
+ */
22
+ export type ScoutSkipReason = 'issue_closed' | 'has_linked_pr' | 'claimed' | 'score_too_low' | 'anti_llm_policy' | 'other';
23
+ /**
24
+ * Defensively extract a `skipReason` from a scout candidate. Scout's types
25
+ * don't expose it yet, and we want to ignore any value scout emits that
26
+ * isn't one of the expected enum members (forward-compat + poison guard).
27
+ */
28
+ export declare function extractSkipReason(candidate: unknown): ScoutSkipReason | undefined;
11
29
  /**
12
30
  * Determine the list status from vetting results.
13
- * Maps vetting recommendation + reasons to a list-level status.
31
+ *
32
+ * Prefers scout's structured `skipReason` enum when present; falls back to
33
+ * substring matching on the free-text `reasonsToSkip` for the transition
34
+ * period before scout emits the enum. See #1043.
14
35
  */
15
- export declare function classifyListStatus(vetResult: VetOutput): VetListItemStatus;
36
+ export declare function classifyListStatus(vetResult: VetOutput, skipReason?: ScoutSkipReason): VetListItemStatus;
16
37
  /**
17
38
  * Re-vet all available issues in a curated issue list.
18
39
  * Reads the list file, extracts available (non-done) issues,
@@ -9,18 +9,65 @@ import { detectIssueList } from './startup.js';
9
9
  import { computeSuccessGrade, gradeFromCandidate } from '../core/issue-grading.js';
10
10
  import { getStateManager } from '../core/index.js';
11
11
  const UNKNOWN_GRADE = computeSuccessGrade({ avgResponseDays: null, mergeRate: null, daysSinceLastCommit: null });
12
+ const KNOWN_SKIP_REASONS = new Set([
13
+ 'issue_closed',
14
+ 'has_linked_pr',
15
+ 'claimed',
16
+ 'score_too_low',
17
+ 'anti_llm_policy',
18
+ 'other',
19
+ ]);
20
+ function mapSkipReasonToStatus(reason) {
21
+ switch (reason) {
22
+ case 'issue_closed':
23
+ return 'closed';
24
+ case 'claimed':
25
+ return 'claimed';
26
+ case 'has_linked_pr':
27
+ return 'has_pr';
28
+ case 'score_too_low':
29
+ case 'anti_llm_policy':
30
+ case 'other':
31
+ return null; // fall through to recommendation / default
32
+ }
33
+ }
34
+ /**
35
+ * Defensively extract a `skipReason` from a scout candidate. Scout's types
36
+ * don't expose it yet, and we want to ignore any value scout emits that
37
+ * isn't one of the expected enum members (forward-compat + poison guard).
38
+ */
39
+ export function extractSkipReason(candidate) {
40
+ if (typeof candidate !== 'object' || candidate === null)
41
+ return undefined;
42
+ const raw = candidate.skipReason;
43
+ if (typeof raw !== 'string')
44
+ return undefined;
45
+ return KNOWN_SKIP_REASONS.has(raw) ? raw : undefined;
46
+ }
12
47
  /**
13
48
  * Determine the list status from vetting results.
14
- * Maps vetting recommendation + reasons to a list-level status.
49
+ *
50
+ * Prefers scout's structured `skipReason` enum when present; falls back to
51
+ * substring matching on the free-text `reasonsToSkip` for the transition
52
+ * period before scout emits the enum. See #1043.
15
53
  */
16
- export function classifyListStatus(vetResult) {
17
- const skipReasons = vetResult.reasonsToSkip.map((r) => r.toLowerCase());
18
- if (skipReasons.some((r) => r.includes('closed')))
19
- return 'closed';
20
- if (skipReasons.some((r) => r.includes('claimed') || r.includes('assigned')))
21
- return 'claimed';
22
- if (skipReasons.some((r) => r.includes('existing pr') || r.includes('linked pr') || r.includes('pull request')))
23
- return 'has_pr';
54
+ export function classifyListStatus(vetResult, skipReason) {
55
+ if (skipReason) {
56
+ const fromEnum = mapSkipReasonToStatus(skipReason);
57
+ if (fromEnum)
58
+ return fromEnum;
59
+ // skipReason was set but maps to 'other' / low-score / policy — let the
60
+ // recommendation-based branches below decide final status.
61
+ }
62
+ else {
63
+ const skipReasons = vetResult.reasonsToSkip.map((r) => r.toLowerCase());
64
+ if (skipReasons.some((r) => r.includes('closed')))
65
+ return 'closed';
66
+ if (skipReasons.some((r) => r.includes('claimed') || r.includes('assigned')))
67
+ return 'claimed';
68
+ if (skipReasons.some((r) => r.includes('existing pr') || r.includes('linked pr') || r.includes('pull request')))
69
+ return 'has_pr';
70
+ }
24
71
  if (vetResult.recommendation === 'approve' || vetResult.recommendation === 'needs_review') {
25
72
  return 'still_available';
26
73
  }
@@ -87,7 +134,7 @@ export async function runVetList(options = {}) {
87
134
  };
88
135
  results.push({
89
136
  ...vetResult,
90
- listStatus: classifyListStatus(vetResult),
137
+ listStatus: classifyListStatus(vetResult, extractSkipReason(candidate)),
91
138
  });
92
139
  }
93
140
  catch (error) {
@@ -16,6 +16,11 @@
16
16
  * contribution surface without recourse. We only match on phrases
17
17
  * that combine a rejection keyword (no / reject / will be closed /
18
18
  * don't accept) with an AI/LLM noun.
19
+ *
20
+ * **User-facing reference:** `docs/anti-llm-policy.md` — explains the
21
+ * three categories, example phrases per category, and the false-positive-
22
+ * resistance design (why "AI division will be closed at end of Q4"
23
+ * does NOT match).
19
24
  */
20
25
  export type AntiLLMCategory = 'explicit_ban' | 'tool_ban' | 'reject_framing';
21
26
  export interface AntiLLMMatch {
@@ -16,6 +16,11 @@
16
16
  * contribution surface without recourse. We only match on phrases
17
17
  * that combine a rejection keyword (no / reject / will be closed /
18
18
  * don't accept) with an AI/LLM noun.
19
+ *
20
+ * **User-facing reference:** `docs/anti-llm-policy.md` — explains the
21
+ * three categories, example phrases per category, and the false-positive-
22
+ * resistance design (why "AI division will be closed at end of Q4"
23
+ * does NOT match).
19
24
  */
20
25
  const PATTERNS = [
21
26
  // Explicit "no X" bans against AI/LLM nouns.
@@ -21,7 +21,12 @@ const FORK_LIMITATION_PATTERNS = [
21
21
  /chromatic/i,
22
22
  /percy/i,
23
23
  /cloudflare pages/i,
24
- /\binternal\b/i,
24
+ // Tightened from plain `\binternal\b` (#1057 M34). Still catches the
25
+ // known "Facebook Internal", "Meta Internal", and "Internal - <thing>"
26
+ // fork-limitation checks (#675), but the negative lookahead prevents
27
+ // misclassifying legitimate test checks like "Internal API tests" or
28
+ // "Internal Integration Tests".
29
+ /\binternal\b(?!\s+(?:api|test|integration|smoke|unit|e2e|regression|functional))/i,
25
30
  ];
26
31
  /**
27
32
  * Known CI check name patterns that indicate authorization gates (#81).
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Single source of truth for user-configurable state.json config keys.
3
+ *
4
+ * Keys fall into two CLI surfaces:
5
+ * - `oss-autopilot setup --set key=value` — direct scalar / list-replace sets
6
+ * - `oss-autopilot config <key> <value>` — list mutators (add-/remove-) and aliases
7
+ *
8
+ * A key may be settable via one, the other, or both. `auto` means the field is
9
+ * populated by internal code (e.g. starredRepos is fetched from GitHub), never
10
+ * by a user command — it's listed here so `scout-bridge.ts` reads are auditable.
11
+ *
12
+ * When adding a new user-configurable state.json field:
13
+ * 1. Add the field to `AgentConfigSchema` (state-schema.ts).
14
+ * 2. Add an entry here.
15
+ * 3. Wire the handler in `commands/setup.ts` and/or `commands/config.ts`.
16
+ * 4. The registry test (`config-registry.test.ts`) asserts both commands
17
+ * handle every non-`auto` key.
18
+ */
19
+ export type SettableVia = 'setup' | 'config' | 'both' | 'auto';
20
+ export interface ConfigKeyDef {
21
+ /** The key as users type it (may differ from the underlying state field, e.g. `dormantDays` → `dormantThresholdDays`). */
22
+ key: string;
23
+ /** One-line human description — shown by `config --list-keys`. */
24
+ description: string;
25
+ /** Which CLI surface accepts this key. */
26
+ settableVia: SettableVia;
27
+ /** Short hint for the expected value shape (e.g. `"number"`, `"owner/repo"`, `"comma-separated list"`). */
28
+ valueHint: string;
29
+ }
30
+ export declare const CONFIG_KEY_REGISTRY: readonly ConfigKeyDef[];
31
+ export declare function isKnownKey(key: string): boolean;
32
+ export declare function getKeyDef(key: string): ConfigKeyDef | undefined;
33
+ /** Keys accepted by the `setup --set` command (includes `both`). */
34
+ export declare function getSetupKeys(): readonly string[];
35
+ /** Keys accepted by the `config <key> <value>` command (includes `both`). */
36
+ export declare function getConfigKeys(): readonly string[];
37
+ /**
38
+ * Find the closest known key for a typo, limited to keys accepted by the given
39
+ * CLI surface. Returns `undefined` when no key is close enough (threshold ≤2
40
+ * edits, case-insensitive) — better to say nothing than suggest a wild guess.
41
+ */
42
+ export declare function suggestKey(key: string, surface: 'setup' | 'config'): string | undefined;
43
+ /** Format an "unknown key" error, appending a did-you-mean suggestion when confident. */
44
+ export declare function formatUnknownKeyError(key: string, surface: 'setup' | 'config'): string;
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Single source of truth for user-configurable state.json config keys.
3
+ *
4
+ * Keys fall into two CLI surfaces:
5
+ * - `oss-autopilot setup --set key=value` — direct scalar / list-replace sets
6
+ * - `oss-autopilot config <key> <value>` — list mutators (add-/remove-) and aliases
7
+ *
8
+ * A key may be settable via one, the other, or both. `auto` means the field is
9
+ * populated by internal code (e.g. starredRepos is fetched from GitHub), never
10
+ * by a user command — it's listed here so `scout-bridge.ts` reads are auditable.
11
+ *
12
+ * When adding a new user-configurable state.json field:
13
+ * 1. Add the field to `AgentConfigSchema` (state-schema.ts).
14
+ * 2. Add an entry here.
15
+ * 3. Wire the handler in `commands/setup.ts` and/or `commands/config.ts`.
16
+ * 4. The registry test (`config-registry.test.ts`) asserts both commands
17
+ * handle every non-`auto` key.
18
+ */
19
+ export const CONFIG_KEY_REGISTRY = [
20
+ // ── Identity ─────────────────────────────────────────────────────────
21
+ {
22
+ key: 'username',
23
+ description: 'Your GitHub username.',
24
+ settableVia: 'both',
25
+ valueHint: 'string',
26
+ },
27
+ // ── Capacity / dormancy ──────────────────────────────────────────────
28
+ {
29
+ key: 'maxActivePRs',
30
+ description: 'Soft cap on how many active PRs you want to juggle at once.',
31
+ settableVia: 'setup',
32
+ valueHint: 'positive integer',
33
+ },
34
+ {
35
+ key: 'dormantDays',
36
+ description: 'Alias for dormantThresholdDays: days of inactivity before a PR is considered dormant.',
37
+ settableVia: 'setup',
38
+ valueHint: 'positive integer',
39
+ },
40
+ {
41
+ key: 'approachingDays',
42
+ description: 'Alias for approachingDormantDays: days before dormancy threshold at which to warn.',
43
+ settableVia: 'setup',
44
+ valueHint: 'positive integer',
45
+ },
46
+ // ── Issue discovery ──────────────────────────────────────────────────
47
+ {
48
+ key: 'languages',
49
+ description: 'Programming languages to filter issue discovery by (whole-list replace).',
50
+ settableVia: 'setup',
51
+ valueHint: 'comma-separated list',
52
+ },
53
+ {
54
+ key: 'labels',
55
+ description: 'Issue labels to search for (whole-list replace).',
56
+ settableVia: 'setup',
57
+ valueHint: 'comma-separated list',
58
+ },
59
+ {
60
+ key: 'scope',
61
+ description: 'Issue complexity scope(s) — beginner, intermediate, advanced.',
62
+ settableVia: 'setup',
63
+ valueHint: 'comma-separated list of: beginner,intermediate,advanced',
64
+ },
65
+ {
66
+ key: 'minStars',
67
+ description: 'Minimum stargazers required for a repo to surface during discovery.',
68
+ settableVia: 'setup',
69
+ valueHint: 'non-negative integer',
70
+ },
71
+ {
72
+ key: 'includeDocIssues',
73
+ description: 'Whether documentation-only issues should appear in discovery.',
74
+ settableVia: 'setup',
75
+ valueHint: 'true|false',
76
+ },
77
+ {
78
+ key: 'maxIssueAgeDays',
79
+ description: 'Maximum age (in days) for an issue to be considered in discovery.',
80
+ settableVia: 'setup',
81
+ valueHint: 'positive integer',
82
+ },
83
+ {
84
+ key: 'minRepoScoreThreshold',
85
+ description: 'Minimum repo maintainer-health score required for discovery (0–10).',
86
+ settableVia: 'setup',
87
+ valueHint: 'non-negative integer',
88
+ },
89
+ {
90
+ key: 'projectCategories',
91
+ description: 'Project categories to prioritize (whole-list replace).',
92
+ settableVia: 'setup',
93
+ valueHint: 'comma-separated list of: nonprofit,devtools,infrastructure,web-frameworks,data-ml,education',
94
+ },
95
+ {
96
+ key: 'preferredOrgs',
97
+ description: 'GitHub orgs to prioritize during discovery (whole-list replace).',
98
+ settableVia: 'setup',
99
+ valueHint: 'comma-separated list',
100
+ },
101
+ {
102
+ key: 'aiPolicyBlocklist',
103
+ description: 'Repos (owner/repo) with anti-AI contribution policies to block from discovery.',
104
+ settableVia: 'setup',
105
+ valueHint: 'comma-separated list of owner/repo',
106
+ },
107
+ // ── Exclusion list mutators (config-only) ────────────────────────────
108
+ {
109
+ key: 'add-language',
110
+ description: 'Append a language to the discovery languages list.',
111
+ settableVia: 'config',
112
+ valueHint: 'string',
113
+ },
114
+ {
115
+ key: 'add-label',
116
+ description: 'Append a label to the discovery labels list.',
117
+ settableVia: 'config',
118
+ valueHint: 'string',
119
+ },
120
+ {
121
+ key: 'remove-label',
122
+ description: 'Remove a label from the discovery labels list.',
123
+ settableVia: 'config',
124
+ valueHint: 'string (must already be present)',
125
+ },
126
+ {
127
+ key: 'add-scope',
128
+ description: 'Append a scope to the discovery scope list.',
129
+ settableVia: 'config',
130
+ valueHint: 'one of: beginner,intermediate,advanced',
131
+ },
132
+ {
133
+ key: 'remove-scope',
134
+ description: 'Remove a scope from the discovery scope list.',
135
+ settableVia: 'config',
136
+ valueHint: 'one of: beginner,intermediate,advanced',
137
+ },
138
+ {
139
+ key: 'exclude-repo',
140
+ description: 'Exclude a specific repo (owner/repo) from discovery.',
141
+ settableVia: 'config',
142
+ valueHint: 'owner/repo',
143
+ },
144
+ {
145
+ key: 'exclude-org',
146
+ description: 'Exclude an entire org from discovery.',
147
+ settableVia: 'config',
148
+ valueHint: 'org name (no slash)',
149
+ },
150
+ // ── Tooling ──────────────────────────────────────────────────────────
151
+ {
152
+ key: 'issueListPath',
153
+ description: 'Path to a text file of extra issue URLs to surface.',
154
+ settableVia: 'both',
155
+ valueHint: 'filesystem path',
156
+ },
157
+ {
158
+ key: 'skippedIssuesPath',
159
+ description: 'Path to the skipped-issues file (auto-culls entries older than 90 days).',
160
+ settableVia: 'setup',
161
+ valueHint: 'filesystem path',
162
+ },
163
+ {
164
+ key: 'diffTool',
165
+ description: 'Default diff renderer for reviews.',
166
+ settableVia: 'both',
167
+ valueHint: 'one of: inline,sourcetree,vscode,custom',
168
+ },
169
+ {
170
+ key: 'diffToolCustomCommand',
171
+ description: 'Shell command template used when diffTool=custom.',
172
+ settableVia: 'both',
173
+ valueHint: 'shell command with {old}/{new} placeholders',
174
+ },
175
+ // ── Behavior ─────────────────────────────────────────────────────────
176
+ {
177
+ key: 'squashByDefault',
178
+ description: 'Default merge strategy — squash-merge, prompt, or standard merge.',
179
+ settableVia: 'setup',
180
+ valueHint: 'true|false|ask',
181
+ },
182
+ {
183
+ key: 'persistence',
184
+ description: 'Where to store state.json — local file or GitHub Gist.',
185
+ settableVia: 'setup',
186
+ valueHint: 'one of: local,gist',
187
+ },
188
+ {
189
+ key: 'autoFormatBeforePush',
190
+ description: 'Opt-in: run the project formatter and append a `style:` commit before every `git push`. Off by default because formatting commits surprise OSS maintainers; the hook also skips automatically when the branch tracks a fork upstream.',
191
+ settableVia: 'setup',
192
+ valueHint: 'true|false',
193
+ },
194
+ // ── Setup-only completion flag ──────────────────────────────────────
195
+ {
196
+ key: 'complete',
197
+ description: 'Internal marker that initial setup has finished. Normally set by the wizard — `setup --set complete=true` is a manual override.',
198
+ settableVia: 'setup',
199
+ valueHint: 'true',
200
+ },
201
+ // ── Auto-managed (listed for auditability; not user-settable) ────────
202
+ {
203
+ key: 'starredRepos',
204
+ description: 'Cache of the user’s starred repos. Refreshed automatically during discovery.',
205
+ settableVia: 'auto',
206
+ valueHint: '(managed internally)',
207
+ },
208
+ {
209
+ key: 'starredReposLastFetched',
210
+ description: 'Timestamp of the last starredRepos refresh.',
211
+ settableVia: 'auto',
212
+ valueHint: '(managed internally)',
213
+ },
214
+ ];
215
+ const KEY_INDEX = new Map(CONFIG_KEY_REGISTRY.map((def) => [def.key, def]));
216
+ export function isKnownKey(key) {
217
+ return KEY_INDEX.has(key);
218
+ }
219
+ export function getKeyDef(key) {
220
+ return KEY_INDEX.get(key);
221
+ }
222
+ /** Keys accepted by the `setup --set` command (includes `both`). */
223
+ export function getSetupKeys() {
224
+ return CONFIG_KEY_REGISTRY.filter((d) => d.settableVia === 'setup' || d.settableVia === 'both').map((d) => d.key);
225
+ }
226
+ /** Keys accepted by the `config <key> <value>` command (includes `both`). */
227
+ export function getConfigKeys() {
228
+ return CONFIG_KEY_REGISTRY.filter((d) => d.settableVia === 'config' || d.settableVia === 'both').map((d) => d.key);
229
+ }
230
+ /** Classic iterative Levenshtein. O(n*m) time, O(min(n,m)) space. */
231
+ function levenshtein(a, b) {
232
+ if (a === b)
233
+ return 0;
234
+ if (a.length === 0)
235
+ return b.length;
236
+ if (b.length === 0)
237
+ return a.length;
238
+ // Ensure b is the shorter (minimizes row width).
239
+ if (a.length < b.length)
240
+ [a, b] = [b, a];
241
+ let prev = new Array(b.length + 1);
242
+ let curr = new Array(b.length + 1);
243
+ for (let j = 0; j <= b.length; j++)
244
+ prev[j] = j;
245
+ for (let i = 1; i <= a.length; i++) {
246
+ curr[0] = i;
247
+ for (let j = 1; j <= b.length; j++) {
248
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
249
+ curr[j] = Math.min(prev[j] + 1, // deletion
250
+ curr[j - 1] + 1, // insertion
251
+ prev[j - 1] + cost);
252
+ }
253
+ [prev, curr] = [curr, prev];
254
+ }
255
+ return prev[b.length];
256
+ }
257
+ /**
258
+ * Find the closest known key for a typo, limited to keys accepted by the given
259
+ * CLI surface. Returns `undefined` when no key is close enough (threshold ≤2
260
+ * edits, case-insensitive) — better to say nothing than suggest a wild guess.
261
+ */
262
+ export function suggestKey(key, surface) {
263
+ const candidates = surface === 'setup' ? getSetupKeys() : getConfigKeys();
264
+ const lower = key.toLowerCase();
265
+ let best;
266
+ for (const candidate of candidates) {
267
+ const d = levenshtein(lower, candidate.toLowerCase());
268
+ if (best === undefined || d < best.distance) {
269
+ best = { key: candidate, distance: d };
270
+ }
271
+ }
272
+ if (!best)
273
+ return undefined;
274
+ // Allow up to 2 edits, or 3 when the typo is long (≥8 chars) — forgives one swap + one drop.
275
+ const threshold = key.length >= 8 ? 3 : 2;
276
+ return best.distance <= threshold ? best.key : undefined;
277
+ }
278
+ /** Format an "unknown key" error, appending a did-you-mean suggestion when confident. */
279
+ export function formatUnknownKeyError(key, surface) {
280
+ const suggestion = suggestKey(key, surface);
281
+ const base = surface === 'setup' ? `Unknown setting "${key}"` : `Unknown config key "${key}"`;
282
+ if (suggestion) {
283
+ return `${base}. Did you mean "${suggestion}"? Run \`oss-autopilot config --list-keys\` to see all keys.`;
284
+ }
285
+ return `${base}. Run \`oss-autopilot config --list-keys\` to see all keys.`;
286
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Runtime schema for the dashboard server's `GET /api/data` response (#1050).
3
+ *
4
+ * The server (`commands/dashboard-data.ts`) and client (`packages/dashboard/`)
5
+ * run in different processes. TypeScript can't cross the process boundary —
6
+ * if the server removes or renames a field, the client hits runtime
7
+ * `undefined` with no diagnostic. This schema is the shared runtime contract:
8
+ * the server can optionally self-check outgoing payloads, and the dashboard
9
+ * validates every `/api/data` response before committing to state.
10
+ *
11
+ * Intentional scope: this schema validates **top-level presence and primitive
12
+ * shape** (required vs optional fields, container types like array/object),
13
+ * but uses `z.array(z.unknown())` for nested PR/issue arrays rather than
14
+ * pinning every field. The top-level surface is the only place drift tends
15
+ * to go silently undetected — nested fields already blow up loudly at the
16
+ * render site when they change. Exhaustive nested validation would turn the
17
+ * schema into a maintenance burden.
18
+ */
19
+ import { z } from 'zod';
20
+ export declare const DashboardStatsSchema: z.ZodObject<{
21
+ activePRs: z.ZodNumber;
22
+ shelvedPRs: z.ZodNumber;
23
+ mergedPRs: z.ZodNumber;
24
+ closedPRs: z.ZodNumber;
25
+ mergeRate: z.ZodString;
26
+ availableIssues: z.ZodOptional<z.ZodNumber>;
27
+ }, z.core.$strip>;
28
+ export declare const DashboardDataSchema: z.ZodObject<{
29
+ stats: z.ZodObject<{
30
+ activePRs: z.ZodNumber;
31
+ shelvedPRs: z.ZodNumber;
32
+ mergedPRs: z.ZodNumber;
33
+ closedPRs: z.ZodNumber;
34
+ mergeRate: z.ZodString;
35
+ availableIssues: z.ZodOptional<z.ZodNumber>;
36
+ }, z.core.$strip>;
37
+ prsByRepo: z.ZodRecord<z.ZodString, z.ZodObject<{
38
+ active: z.ZodNumber;
39
+ merged: z.ZodNumber;
40
+ closed: z.ZodNumber;
41
+ }, z.core.$strip>>;
42
+ topRepos: z.ZodArray<z.ZodObject<{
43
+ repo: z.ZodString;
44
+ active: z.ZodNumber;
45
+ merged: z.ZodNumber;
46
+ closed: z.ZodNumber;
47
+ }, z.core.$strip>>;
48
+ monthlyMerged: z.ZodRecord<z.ZodString, z.ZodNumber>;
49
+ monthlyOpened: z.ZodRecord<z.ZodString, z.ZodNumber>;
50
+ monthlyClosed: z.ZodRecord<z.ZodString, z.ZodNumber>;
51
+ activePRs: z.ZodArray<z.ZodUnknown>;
52
+ shelvedPRUrls: z.ZodArray<z.ZodString>;
53
+ recentlyMergedPRs: z.ZodArray<z.ZodUnknown>;
54
+ recentlyClosedPRs: z.ZodArray<z.ZodUnknown>;
55
+ autoUnshelvedPRs: z.ZodArray<z.ZodUnknown>;
56
+ commentedIssues: z.ZodArray<z.ZodUnknown>;
57
+ issueResponses: z.ZodArray<z.ZodUnknown>;
58
+ allMergedPRs: z.ZodArray<z.ZodUnknown>;
59
+ allClosedPRs: z.ZodArray<z.ZodUnknown>;
60
+ repoMetadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
61
+ vettedIssues: z.ZodOptional<z.ZodNullable<z.ZodUnknown>>;
62
+ offline: z.ZodOptional<z.ZodBoolean>;
63
+ lastUpdated: z.ZodOptional<z.ZodString>;
64
+ partialFailures: z.ZodOptional<z.ZodArray<z.ZodString>>;
65
+ }, z.core.$strip>;
66
+ export type DashboardDataParsed = z.infer<typeof DashboardDataSchema>;
67
+ /**
68
+ * Validate a raw `/api/data` payload. Returns `{ok: true, data}` on success or
69
+ * `{ok: false, message}` with a condensed Zod error string on failure. Never
70
+ * throws. The dashboard's `useDashboard` hook surfaces the message in the UI.
71
+ */
72
+ export declare function validateDashboardData(raw: unknown): {
73
+ ok: true;
74
+ data: DashboardDataParsed;
75
+ } | {
76
+ ok: false;
77
+ message: string;
78
+ };