@haystackeditor/cli 0.25.0 → 0.26.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 (113) hide show
  1. package/README.md +39 -455
  2. package/dist/capture/adapters/django.js +7 -0
  3. package/dist/capture/adapters/index.js +10 -0
  4. package/dist/capture/adapters/next-build.js +113 -0
  5. package/dist/capture/adapters/next.js +272 -105
  6. package/dist/capture/adapters/nuxt.js +7 -0
  7. package/dist/capture/adapters/rails.js +7 -0
  8. package/dist/capture/adapters/react-router.js +7 -0
  9. package/dist/capture/adapters/sveltekit.js +7 -0
  10. package/dist/capture/adapters/vite.js +14 -0
  11. package/dist/capture/app-config.js +27 -2
  12. package/dist/capture/csp.js +7 -6
  13. package/dist/capture/url-rewrites.js +232 -0
  14. package/dist/capture-step.js +3 -0
  15. package/dist/commands/capture-brief.js +41 -32
  16. package/dist/commands/capture-manifest.js +25 -17
  17. package/dist/commands/feedback.js +66 -0
  18. package/dist/commands/init-capture.js +44 -27
  19. package/dist/commands/init-telemetry.js +77 -14
  20. package/dist/commands/init.js +19 -4
  21. package/dist/commands/lockfile-pin.js +307 -0
  22. package/dist/commands/verify-onboarding.js +16 -0
  23. package/dist/commands/verify.js +70 -15
  24. package/dist/index.js +29 -923
  25. package/dist/schema.js +4 -10
  26. package/dist/utils/haystack-api.js +0 -36
  27. package/package.json +1 -5
  28. package/schemas/feedback.v1.json +13 -0
  29. package/schemas/init.v1.json +1 -1
  30. package/schemas/pre-verify.v2.json +240 -0
  31. package/schemas/verify-raw.v1.json +1132 -0
  32. package/schemas/verify.v2.json +655 -0
  33. package/dist/assets/hooks/agent-context/detect.ts +0 -316
  34. package/dist/assets/hooks/agent-context/format.ts +0 -100
  35. package/dist/assets/hooks/agent-context/index.ts +0 -41
  36. package/dist/assets/hooks/agent-context/parsers/claude.ts +0 -262
  37. package/dist/assets/hooks/agent-context/parsers/codex.ts +0 -416
  38. package/dist/assets/hooks/agent-context/parsers/gemini.ts +0 -155
  39. package/dist/assets/hooks/agent-context/parsers/opencode.ts +0 -174
  40. package/dist/assets/hooks/agent-context/tsconfig.json +0 -14
  41. package/dist/assets/hooks/agent-context/types.ts +0 -58
  42. package/dist/assets/hooks/llm-rules-template.md +0 -59
  43. package/dist/assets/hooks/package-lock.json +0 -598
  44. package/dist/assets/hooks/package.json +0 -12
  45. package/dist/assets/hooks/scripts/commit-msg.sh +0 -5
  46. package/dist/assets/hooks/scripts/post-commit.sh +0 -5
  47. package/dist/assets/hooks/scripts/pre-commit.sh +0 -175
  48. package/dist/assets/hooks/scripts/pre-push.sh +0 -25
  49. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +0 -5
  50. package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +0 -528
  51. package/dist/assets/hooks/truncation-checker/index.ts +0 -595
  52. package/dist/assets/hooks/truncation-checker/tsconfig.json +0 -13
  53. package/dist/assets/skills/map-cloud-verifier-universe/SKILL.md +0 -2051
  54. package/dist/assets/skills/map-cloud-verifier-universe/agents/openai.yaml +0 -4
  55. package/dist/assets/skills/map-cloud-verifier-universe/references/output-contract.md +0 -3411
  56. package/dist/assets/skills/map-your-system.md +0 -143
  57. package/dist/assets/skills/submit.md +0 -200
  58. package/dist/commands/ask.js +0 -20
  59. package/dist/commands/cloud-verifier-behaviors.js +0 -218
  60. package/dist/commands/cloud-verifier-data-store-census.js +0 -539
  61. package/dist/commands/cloud-verifier-data-store-drift.js +0 -158
  62. package/dist/commands/cloud-verifier-identity-census.js +0 -4060
  63. package/dist/commands/cloud-verifier-materialization.js +0 -704
  64. package/dist/commands/cloud-verifier-pascal-selector-census.js +0 -1382
  65. package/dist/commands/cloud-verifier-python-manifest-selector-census.js +0 -2015
  66. package/dist/commands/cloud-verifier-specialized-operational-census.js +0 -11432
  67. package/dist/commands/cloud-verifier-universe.js +0 -10178
  68. package/dist/commands/config.js +0 -549
  69. package/dist/commands/design-verify.js +0 -311
  70. package/dist/commands/dismiss.js +0 -159
  71. package/dist/commands/hooks.js +0 -226
  72. package/dist/commands/inbox.js +0 -137
  73. package/dist/commands/mcp.js +0 -201
  74. package/dist/commands/policy.js +0 -371
  75. package/dist/commands/pr-status.js +0 -207
  76. package/dist/commands/pr.js +0 -105
  77. package/dist/commands/prepare-universe-review.js +0 -1092
  78. package/dist/commands/production-source-deny-policy.js +0 -100
  79. package/dist/commands/request-review.js +0 -74
  80. package/dist/commands/review.js +0 -191
  81. package/dist/commands/rules.js +0 -98
  82. package/dist/commands/scaffold-provisional-universe.js +0 -806
  83. package/dist/commands/setup.js +0 -1170
  84. package/dist/commands/skills.js +0 -447
  85. package/dist/commands/submit.js +0 -745
  86. package/dist/commands/system-map.js +0 -228
  87. package/dist/commands/triage.js +0 -598
  88. package/dist/commands/webhooks.js +0 -241
  89. package/dist/states.js +0 -46
  90. package/dist/tools/detect.js +0 -832
  91. package/dist/triage/astra.js +0 -202
  92. package/dist/triage/prompts.js +0 -188
  93. package/dist/triage/runner.js +0 -200
  94. package/dist/triage/types.js +0 -7
  95. package/dist/utils/action-output.js +0 -26
  96. package/dist/utils/analysis-api.js +0 -416
  97. package/dist/utils/design-verifier-api.js +0 -294
  98. package/dist/utils/design-verifier-history.js +0 -79
  99. package/dist/utils/design-verifier-result.js +0 -424
  100. package/dist/utils/github-api.js +0 -324
  101. package/dist/utils/pending-state.js +0 -86
  102. package/dist/utils/pr-ref.js +0 -56
  103. package/dist/utils/prompter.js +0 -328
  104. package/schemas/action.v1.json +0 -22
  105. package/schemas/ask.v1.json +0 -40
  106. package/schemas/inbox.v1.json +0 -27
  107. package/schemas/pr-status.v1.json +0 -61
  108. package/schemas/pr.v1.json +0 -97
  109. package/schemas/pr.v3.json +0 -45
  110. package/schemas/setup.v1.json +0 -75
  111. package/schemas/submit.v1.json +0 -90
  112. package/schemas/triage.v1.json +0 -103
  113. package/schemas/triage.v2.json +0 -64
@@ -1,324 +0,0 @@
1
- /**
2
- * GitHub API utilities for haystack submit command.
3
- *
4
- * Provides functions for creating PRs, managing labels, etc.
5
- */
6
- import { isHeadlessToken, resolveAuthContext } from './auth.js';
7
- import { trackError } from './telemetry.js';
8
- const GITHUB_API_BASE = 'https://api.github.com';
9
- // OAuth App Policy fallback. Orgs can restrict our OAuth app, which makes the
10
- // user's token 403 on every API call to that org — so `haystack submit` can't
11
- // open PRs, add labels, or request reviewers there. The auth-worker GitHub
12
- // proxy swaps in the GitHub App installation token server-side (the App isn't
13
- // subject to OAuth App Policy / SAML SSO enforcement the way the OAuth token
14
- // is), so on such a 403 we retry the same request through it. Direct calls are
15
- // tried first, so on unrestricted orgs nothing changes and PRs stay
16
- // user-authored; only the fallback path is performed by the App (haystack[bot]).
17
- const GITHUB_PROXY_BASE = 'https://haystackeditor.com/api/github';
18
- /**
19
- * A 403 that is NOT a rate-limit is almost always OAuth App Policy or SAML SSO
20
- * enforcement blocking our OAuth app on this org — exactly what the
21
- * installation-token proxy can bypass. Rate-limit 403s carry
22
- * `x-ratelimit-remaining: 0` and the proxy can't help with those, so we don't
23
- * retry them.
24
- */
25
- /**
26
- * REST base URL for a token. Haystack headless tokens (hsk_live_*) only work
27
- * through the Haystack proxy and must never reach api.github.com.
28
- */
29
- export function githubRestBaseFor(token) {
30
- return isHeadlessToken(token) ? GITHUB_PROXY_BASE : GITHUB_API_BASE;
31
- }
32
- function isOAuthPolicyForbidden(response) {
33
- return response.status === 403 && response.headers.get('x-ratelimit-remaining') !== '0';
34
- }
35
- // Label definitions for Haystack workflow
36
- export const HAYSTACK_LABELS = {
37
- 'haystack:auto-merge': {
38
- name: 'haystack:auto-merge',
39
- color: '0E8A16', // Green
40
- description: 'Auto-merge after Haystack approval',
41
- },
42
- 'haystack:needs-review': {
43
- name: 'haystack:needs-review',
44
- color: 'FBCA04', // Yellow
45
- description: 'Requires Haystack review',
46
- },
47
- 'haystack:ready-to-merge': {
48
- name: 'haystack:ready-to-merge',
49
- color: '2EA44F', // GitHub green
50
- description: 'Passed Haystack analysis, ready to merge',
51
- },
52
- };
53
- // ============================================================================
54
- // Authentication
55
- // ============================================================================
56
- /**
57
- * Get a GitHub token, throw if not authenticated
58
- */
59
- export async function requireGitHubAuth(options) {
60
- const context = await resolveAuthContext(options);
61
- return context.token;
62
- }
63
- async function resolveToken(token, options) {
64
- return token ?? requireGitHubAuth(options);
65
- }
66
- // ============================================================================
67
- // API helpers
68
- // ============================================================================
69
- /**
70
- * Make an authenticated GitHub API request
71
- */
72
- async function githubApiRequest(method, path, token, body) {
73
- // body is serialized to a string (not a stream), so the same init can be
74
- // reused for the proxy retry below.
75
- const init = {
76
- method,
77
- headers: {
78
- Authorization: `Bearer ${token}`,
79
- Accept: 'application/vnd.github.v3+json',
80
- 'Content-Type': 'application/json',
81
- 'User-Agent': 'Haystack-CLI',
82
- },
83
- body: body ? JSON.stringify(body) : undefined,
84
- };
85
- // A Haystack headless token (hsk_live_*) is not a GitHub credential: sending
86
- // it to api.github.com would leak it to GitHub and fail anyway. Only the
87
- // Haystack proxy understands it (it resolves an App installation token).
88
- if (isHeadlessToken(token)) {
89
- return fetch(`${GITHUB_PROXY_BASE}${path}`, init);
90
- }
91
- const direct = await fetch(`${GITHUB_API_BASE}${path}`, init);
92
- if (!isOAuthPolicyForbidden(direct))
93
- return direct;
94
- // Org restricts our OAuth app — retry through the proxy on the App's
95
- // installation token. Same `Authorization: Bearer <github-token>`; the proxy
96
- // validates it, then uses its own token upstream. If the proxy can't help
97
- // (e.g. the App isn't installed on this owner), fall back to the original
98
- // response so handleApiError surfaces GitHub's actual "org restricted" 403.
99
- const proxied = await fetch(`${GITHUB_PROXY_BASE}${path}`, init);
100
- return proxied.status === 403 ? direct : proxied;
101
- }
102
- async function handleApiError(response, context, operation) {
103
- if (response.status === 401) {
104
- throw new Error('GitHub token expired or invalid. Run `haystack login` again.');
105
- }
106
- if (response.status === 403) {
107
- const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
108
- if (rateLimitRemaining === '0') {
109
- // Log every rate-limit hit so we can watch real-world frequency and decide
110
- // whether the CLI needs its own token bucket. Routes to PostHog (and on to
111
- // Slack via the ops-alert pipeline). Fire-and-forget; never blocks.
112
- trackError('cli_github_rate_limited', {
113
- operation,
114
- status: 403,
115
- rate_limit_limit: response.headers.get('x-ratelimit-limit'),
116
- rate_limit_reset: response.headers.get('x-ratelimit-reset'),
117
- rate_limit_resource: response.headers.get('x-ratelimit-resource'),
118
- });
119
- throw new Error('GitHub API rate limit exceeded. Please wait and try again.');
120
- }
121
- throw new Error(`Permission denied: ${context}`);
122
- }
123
- if (response.status === 404) {
124
- throw new Error(`Not found: ${context}`);
125
- }
126
- if (response.status === 422) {
127
- const data = await response.json().catch(() => ({}));
128
- const errorMsg = data.errors?.[0]?.message || data.message || 'Validation failed';
129
- throw new Error(`GitHub API error: ${errorMsg}`);
130
- }
131
- const data = await response.json().catch(() => ({}));
132
- throw new Error(data.message || `GitHub API error (${response.status}): ${context}`);
133
- }
134
- // ============================================================================
135
- // Pull Request operations
136
- // ============================================================================
137
- /**
138
- * Create a pull request
139
- */
140
- export async function createPullRequest(options) {
141
- const token = await resolveToken(options.token, {
142
- owner: options.owner,
143
- repo: options.repo,
144
- });
145
- const response = await githubApiRequest('POST', `/repos/${options.owner}/${options.repo}/pulls`, token, {
146
- title: options.title,
147
- head: options.head,
148
- base: options.base,
149
- body: options.body || '',
150
- draft: options.draft || false,
151
- });
152
- if (!response.ok) {
153
- // GitHub returns 500 when creating a PR from a branch that was the head
154
- // of a recently merged PR. Check for this case and give an actionable error.
155
- if (response.status >= 500) {
156
- const mergedPR = await findMergedPRForBranch(options.owner, options.repo, options.head, token);
157
- if (mergedPR) {
158
- throw new Error(`Branch "${options.head}" was already used for merged PR #${mergedPR.number}. ` +
159
- `Push to a new branch name and try again.`);
160
- }
161
- }
162
- await handleApiError(response, `creating PR in ${options.owner}/${options.repo}`, 'create_pull_request');
163
- }
164
- return response.json();
165
- }
166
- /**
167
- * Check if a merged PR exists for a given branch (used for diagnosing 500s on PR creation).
168
- *
169
- * Only searches the exact qualified head (owner:branch). We intentionally do NOT
170
- * fall back to a bare branch name because that can match a different fork's branch
171
- * and produce a misleading "branch already used" diagnosis.
172
- *
173
- * Returns null if the lookup fails (5xx, network error) so the caller falls through
174
- * to the generic error handler rather than surfacing a wrong diagnosis.
175
- */
176
- async function findMergedPRForBranch(owner, repo, head, token) {
177
- const headParam = head.includes(':') ? head : `${owner}:${head}`;
178
- try {
179
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/pulls?head=${encodeURIComponent(headParam)}&state=closed`, token);
180
- if (!response.ok) {
181
- console.error(`[github-api] Merged-PR lookup failed (head=${headParam}): HTTP ${response.status}`);
182
- return null;
183
- }
184
- const prs = await response.json();
185
- return prs.find((pr) => pr.merged_at) ?? null;
186
- }
187
- catch (err) {
188
- console.error(`[github-api] Merged-PR lookup error (head=${headParam}): ${err}`);
189
- return null;
190
- }
191
- }
192
- /**
193
- * Check if a PR already exists for a branch
194
- */
195
- export async function findExistingPR(owner, repo, head, token) {
196
- const authToken = await resolveToken(token, { owner, repo });
197
- // head format should be "owner:branch" for cross-repo or just "branch" for same-repo
198
- const headParam = head.includes(':') ? head : `${owner}:${head}`;
199
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/pulls?head=${encodeURIComponent(headParam)}&state=open`, authToken);
200
- if (!response.ok) {
201
- if (response.status === 404)
202
- return null;
203
- await handleApiError(response, 'checking for existing PR', 'find_existing_pull_request');
204
- }
205
- const prs = await response.json();
206
- return prs.length > 0 ? prs[0] : null;
207
- }
208
- // ============================================================================
209
- // Label operations
210
- // ============================================================================
211
- /**
212
- * Add labels to an issue/PR
213
- */
214
- export async function addLabelsToIssue(owner, repo, issueNumber, labels, token) {
215
- const authToken = await resolveToken(token, { owner, repo });
216
- const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, authToken, { labels });
217
- if (!response.ok) {
218
- await handleApiError(response, `adding labels to PR #${issueNumber}`, 'add_labels');
219
- }
220
- }
221
- /**
222
- * Get all labels from an issue/PR
223
- */
224
- export async function getLabelsFromIssue(owner, repo, issueNumber, token) {
225
- const authToken = await resolveToken(token, { owner, repo });
226
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, authToken);
227
- if (!response.ok) {
228
- await handleApiError(response, `getting labels from PR #${issueNumber}`, 'get_labels');
229
- }
230
- const labels = await response.json();
231
- return labels.map((l) => l.name);
232
- }
233
- /**
234
- * Remove a label from an issue/PR
235
- */
236
- export async function removeLabelFromIssue(owner, repo, issueNumber, labelName, token) {
237
- const authToken = await resolveToken(token, { owner, repo });
238
- const response = await githubApiRequest('DELETE', `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(labelName)}`, authToken);
239
- // 404 is fine - label wasn't on the issue
240
- if (!response.ok && response.status !== 404) {
241
- await handleApiError(response, `removing label '${labelName}' from PR #${issueNumber}`, 'remove_label');
242
- }
243
- }
244
- /**
245
- * Check if a label exists in the repo
246
- */
247
- export async function labelExists(owner, repo, labelName, token) {
248
- const authToken = await resolveToken(token, { owner, repo });
249
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/labels/${encodeURIComponent(labelName)}`, authToken);
250
- return response.ok;
251
- }
252
- /**
253
- * Create a label in the repo
254
- */
255
- export async function createLabel(owner, repo, label, token) {
256
- const authToken = await resolveToken(token, { owner, repo });
257
- const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/labels`, authToken, {
258
- name: label.name,
259
- color: label.color,
260
- description: label.description,
261
- });
262
- if (!response.ok && response.status !== 422) {
263
- // 422 means label already exists, which is fine
264
- await handleApiError(response, `creating label '${label.name}'`, 'create_label');
265
- }
266
- }
267
- /**
268
- * Ensure all required Haystack labels exist in the repo
269
- */
270
- export async function ensureHaystackLabels(owner, repo, labelNames, token) {
271
- for (const name of labelNames) {
272
- const labelInfo = HAYSTACK_LABELS[name];
273
- if (!labelInfo)
274
- continue;
275
- const exists = await labelExists(owner, repo, name, token);
276
- if (!exists) {
277
- await createLabel(owner, repo, labelInfo, token);
278
- }
279
- }
280
- }
281
- // ============================================================================
282
- // Review request operations
283
- // ============================================================================
284
- /**
285
- * Request reviewers on a pull request
286
- */
287
- export async function requestReviewers(owner, repo, prNumber, reviewers, token) {
288
- const authToken = await resolveToken(token, { owner, repo });
289
- const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/pulls/${prNumber}/requested_reviewers`, authToken, { reviewers });
290
- if (!response.ok) {
291
- await handleApiError(response, `requesting reviewers on PR #${prNumber}`, 'request_reviewers');
292
- }
293
- }
294
- // ============================================================================
295
- // User operations
296
- // ============================================================================
297
- /**
298
- * Get the authenticated user's info
299
- */
300
- export async function getCurrentUser(token) {
301
- const authToken = await resolveToken(token);
302
- const response = await githubApiRequest('GET', '/user', authToken);
303
- if (!response.ok) {
304
- await handleApiError(response, 'getting current user', 'get_current_user');
305
- }
306
- return response.json();
307
- }
308
- export async function getRepositoryAccess(owner, repo, token) {
309
- const authToken = await resolveToken(token, { owner, repo });
310
- const response = await githubApiRequest('GET', `/repos/${owner}/${repo}`, authToken);
311
- if (!response.ok) {
312
- await handleApiError(response, `getting repository access for ${owner}/${repo}`, 'get_repository_access');
313
- }
314
- return response.json();
315
- }
316
- /**
317
- * Check if user has push access to a repo
318
- */
319
- export async function hasPushAccess(owner, repo, token) {
320
- const data = await getRepositoryAccess(owner, repo, token);
321
- return data.permissions?.admin === true
322
- || data.permissions?.maintain === true
323
- || data.permissions?.push === true;
324
- }
@@ -1,86 +0,0 @@
1
- /**
2
- * Pending submit state persistence.
3
- *
4
- * Manages `.haystack/pending-submit.json` in the git root directory.
5
- * This file persists across CLI sessions so that analysis results
6
- * can be retrieved after disconnection.
7
- */
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
9
- import { join } from 'path';
10
- import { findGitRoot } from './git.js';
11
- // ============================================================================
12
- // Constants
13
- // ============================================================================
14
- const PENDING_FILE = 'pending-submit.json';
15
- const HAYSTACK_DIR = '.haystack';
16
- const STALE_THRESHOLD_MS = 2 * 60 * 60 * 1000; // 2 hours
17
- // ============================================================================
18
- // Functions
19
- // ============================================================================
20
- function getPendingPath(gitRoot) {
21
- const root = gitRoot || findGitRoot();
22
- if (!root)
23
- return null;
24
- return join(root, HAYSTACK_DIR, PENDING_FILE);
25
- }
26
- /**
27
- * Save pending submit state to disk.
28
- */
29
- export function savePendingSubmit(state, gitRoot) {
30
- const root = gitRoot || findGitRoot();
31
- if (!root)
32
- throw new Error('Not a git repository');
33
- const dir = join(root, HAYSTACK_DIR);
34
- if (!existsSync(dir)) {
35
- mkdirSync(dir, { recursive: true });
36
- }
37
- const filePath = join(dir, PENDING_FILE);
38
- writeFileSync(filePath, JSON.stringify(state, null, 2));
39
- }
40
- /**
41
- * Load pending submit state from disk.
42
- * Returns null if no pending state exists or if it's stale.
43
- */
44
- export function loadPendingSubmit(gitRoot) {
45
- const filePath = getPendingPath(gitRoot);
46
- if (!filePath || !existsSync(filePath))
47
- return null;
48
- try {
49
- const content = readFileSync(filePath, 'utf-8');
50
- const state = JSON.parse(content);
51
- // Check version
52
- if (state.version !== 1)
53
- return null;
54
- // Auto-mark stale entries
55
- if (state.status === 'polling') {
56
- const age = Date.now() - new Date(state.submittedAt).getTime();
57
- if (age > STALE_THRESHOLD_MS) {
58
- state.status = 'stale';
59
- savePendingSubmit(state, gitRoot);
60
- }
61
- }
62
- return state;
63
- }
64
- catch {
65
- return null;
66
- }
67
- }
68
- /**
69
- * Clear pending submit state (delete the file).
70
- */
71
- export function clearPendingSubmit(gitRoot) {
72
- const filePath = getPendingPath(gitRoot);
73
- if (filePath && existsSync(filePath)) {
74
- unlinkSync(filePath);
75
- }
76
- }
77
- /**
78
- * Update the status and optionally the analysis result of a pending submit.
79
- */
80
- export function updatePendingSubmit(updates, gitRoot) {
81
- const state = loadPendingSubmit(gitRoot);
82
- if (!state)
83
- return;
84
- Object.assign(state, updates);
85
- savePendingSubmit(state, gitRoot);
86
- }
@@ -1,56 +0,0 @@
1
- /**
2
- * Shared PR-identifier parsing for every command that takes a PR ref.
3
- *
4
- * One grammar, one error message, one repo-inference path. This used to be
5
- * duplicated per command, and the copies drifted: different "accepted formats"
6
- * lists (none mentioned the `#123` form the regex accepted), and one copy let
7
- * a raw git error escape where the others gave a friendly hint. Commands must
8
- * import from here rather than reimplementing the grammar.
9
- *
10
- * Accepted formats, in the order they are tried:
11
- * https://github.com/owner/repo/pull/123 GitHub URL
12
- * owner/repo#123 Fully qualified
13
- * 123 or #123 PR number (repo inferred from the `origin` remote)
14
- */
15
- import { parseRemoteUrl } from './git.js';
16
- /** The accepted-formats block used in parse errors and help text. */
17
- export function acceptedPrRefFormats(commandName) {
18
- return (` haystack ${commandName} 123\n` +
19
- ` haystack ${commandName} #123\n` +
20
- ` haystack ${commandName} owner/repo#123\n` +
21
- ` haystack ${commandName} https://github.com/owner/repo/pull/123`);
22
- }
23
- /**
24
- * Parse a PR identifier. `commandName` is interpolated into error messages so
25
- * the agent can copy a corrected invocation verbatim (e.g. "triage",
26
- * "pr get", "traces list").
27
- */
28
- export function parsePrRef(identifier, commandName) {
29
- const urlMatch = identifier.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
30
- if (urlMatch) {
31
- return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
32
- }
33
- // owner/repo#123 — repo segment may not contain `/` (a stray extra segment
34
- // used to silently become part of the repo name and 404 downstream).
35
- const qualifiedMatch = identifier.match(/^([^/#\s]+)\/([^/#\s]+)#(\d+)$/);
36
- if (qualifiedMatch) {
37
- return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
38
- }
39
- const numberMatch = identifier.match(/^#?(\d+)$/);
40
- if (numberMatch) {
41
- const prNumber = parseInt(numberMatch[1], 10);
42
- try {
43
- const remote = parseRemoteUrl('origin');
44
- return { owner: remote.owner, repo: remote.repo, prNumber };
45
- }
46
- catch (err) {
47
- throw new Error(`Cannot determine the repository for PR #${prNumber} ` +
48
- `(${err instanceof Error ? err.message : String(err)}).\n` +
49
- `Run from inside a checkout with an "origin" remote, or use the full format:\n` +
50
- ` haystack ${commandName} owner/repo#${prNumber}`);
51
- }
52
- }
53
- throw new Error(`Invalid PR identifier: "${identifier}"\n\n` +
54
- `Accepted formats:\n${acceptedPrRefFormats(commandName)}\n\n` +
55
- `See \`haystack ${commandName.split(' ')[0]} --help\` for details.`);
56
- }