@haystackeditor/cli 0.8.1 → 0.10.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 (63) hide show
  1. package/README.md +93 -87
  2. package/dist/assets/hooks/llm-rules-template.md +21 -0
  3. package/dist/assets/hooks/package.json +2 -2
  4. package/dist/assets/hooks/scripts/pre-push.sh +20 -0
  5. package/dist/assets/skills/prepare-haystack.md +323 -0
  6. package/dist/assets/skills/secrets.md +164 -0
  7. package/dist/assets/skills/setup-external-sandbox.md +243 -0
  8. package/dist/assets/skills/setup-haystack.md +639 -0
  9. package/dist/assets/skills/submit.md +154 -0
  10. package/dist/assets/templates/CLAUDE.md.snippet +42 -0
  11. package/dist/assets/templates/haystack.yml +193 -0
  12. package/dist/commands/check-pending.d.ts +19 -0
  13. package/dist/commands/check-pending.js +217 -0
  14. package/dist/commands/config.d.ts +13 -21
  15. package/dist/commands/config.js +278 -92
  16. package/dist/commands/dismiss.d.ts +17 -0
  17. package/dist/commands/dismiss.js +201 -0
  18. package/dist/commands/init.js +25 -28
  19. package/dist/commands/install-session-hooks.d.ts +16 -0
  20. package/dist/commands/install-session-hooks.js +302 -0
  21. package/dist/commands/login.js +1 -1
  22. package/dist/commands/policy.d.ts +31 -0
  23. package/dist/commands/policy.js +365 -0
  24. package/dist/commands/pr-status.d.ts +16 -0
  25. package/dist/commands/pr-status.js +188 -0
  26. package/dist/commands/setup.d.ts +13 -0
  27. package/dist/commands/setup.js +496 -0
  28. package/dist/commands/skills.d.ts +2 -2
  29. package/dist/commands/skills.js +51 -186
  30. package/dist/commands/submit.d.ts +23 -0
  31. package/dist/commands/submit.js +456 -0
  32. package/dist/commands/triage.d.ts +16 -0
  33. package/dist/commands/triage.js +354 -0
  34. package/dist/index.d.ts +7 -0
  35. package/dist/index.js +344 -4
  36. package/dist/tools/detect.d.ts +50 -0
  37. package/dist/tools/detect.js +853 -0
  38. package/dist/tools/fixtures.d.ts +38 -0
  39. package/dist/tools/fixtures.js +199 -0
  40. package/dist/tools/setup.d.ts +43 -0
  41. package/dist/tools/setup.js +597 -0
  42. package/dist/triage/prompts.d.ts +31 -0
  43. package/dist/triage/prompts.js +296 -0
  44. package/dist/triage/runner.d.ts +21 -0
  45. package/dist/triage/runner.js +339 -0
  46. package/dist/triage/traces.d.ts +20 -0
  47. package/dist/triage/traces.js +305 -0
  48. package/dist/triage/types.d.ts +47 -0
  49. package/dist/triage/types.js +7 -0
  50. package/dist/types.d.ts +1387 -191
  51. package/dist/types.js +254 -2
  52. package/dist/utils/analysis-api.d.ts +108 -0
  53. package/dist/utils/analysis-api.js +194 -0
  54. package/dist/utils/config.js +1 -1
  55. package/dist/utils/git.d.ts +80 -0
  56. package/dist/utils/git.js +302 -0
  57. package/dist/utils/github-api.d.ts +83 -0
  58. package/dist/utils/github-api.js +266 -0
  59. package/dist/utils/pending-state.d.ts +40 -0
  60. package/dist/utils/pending-state.js +86 -0
  61. package/dist/utils/secrets.js +3 -3
  62. package/dist/utils/skill.js +257 -0
  63. package/package.json +11 -9
@@ -1,14 +1,69 @@
1
1
  /**
2
- * Config commands - manage user preferences stored on Haystack Platform
2
+ * Config commands - manage preferences
3
+ *
4
+ * Repo-level preferences (sandbox_enabled, auto_merge) are stored in .haystack.json.
5
+ * User-level preferences (agentic_tool) are stored on the Haystack Platform API.
3
6
  */
4
7
  import chalk from 'chalk';
8
+ import { readFileSync, writeFileSync } from 'fs';
9
+ import { resolve } from 'path';
10
+ import { execSync } from 'child_process';
5
11
  import { loadToken } from './login.js';
12
+ import { AI_REVIEWER_SOURCES, AI_REVIEWER_DISPLAY_NAMES } from '../types.js';
6
13
  const API_BASE = 'https://haystackeditor.com/api/preferences';
7
14
  const AGENTIC_TOOL_LABELS = {
8
15
  'opencode': 'OpenCode (Haystack billing)',
9
16
  'claude-code': 'Claude Code (your Claude Max subscription)',
10
17
  'codex': 'Codex CLI (your ChatGPT subscription)',
11
18
  };
19
+ // ============================================================================
20
+ // .haystack.json helpers (repo-level preferences)
21
+ // ============================================================================
22
+ function findRepoRoot() {
23
+ try {
24
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
25
+ }
26
+ catch {
27
+ console.error(chalk.red('\nNot inside a git repository.\n'));
28
+ process.exit(1);
29
+ }
30
+ }
31
+ function loadHaystackConfig() {
32
+ const root = findRepoRoot();
33
+ const configPath = resolve(root, '.haystack.json');
34
+ try {
35
+ const raw = readFileSync(configPath, 'utf-8');
36
+ return { config: JSON.parse(raw), path: configPath };
37
+ }
38
+ catch (err) {
39
+ if (err.code === 'ENOENT') {
40
+ console.error(chalk.red(`\nNo .haystack.json found at ${configPath}`));
41
+ console.log(chalk.dim('Create one with: haystack init\n'));
42
+ process.exit(1);
43
+ }
44
+ throw err;
45
+ }
46
+ }
47
+ function saveHaystackConfig(config, configPath) {
48
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
49
+ }
50
+ function getPreference(key) {
51
+ const { config } = loadHaystackConfig();
52
+ // Default: sandbox_enabled=true, auto_merge=false
53
+ const defaults = { sandbox_enabled: true, auto_merge: false };
54
+ return config.preferences?.[key] ?? defaults[key];
55
+ }
56
+ function setPreference(key, value) {
57
+ const { config, path } = loadHaystackConfig();
58
+ if (!config.preferences) {
59
+ config.preferences = {};
60
+ }
61
+ config.preferences[key] = value;
62
+ saveHaystackConfig(config, path);
63
+ }
64
+ // ============================================================================
65
+ // API helpers (user-level preferences)
66
+ // ============================================================================
12
67
  async function requireAuth() {
13
68
  const token = await loadToken();
14
69
  if (!token) {
@@ -18,7 +73,7 @@ async function requireAuth() {
18
73
  return token;
19
74
  }
20
75
  async function apiRequest(method, token, body) {
21
- const response = await fetch(API_BASE, {
76
+ return fetch(API_BASE, {
22
77
  method,
23
78
  headers: {
24
79
  'Authorization': `Bearer ${token}`,
@@ -27,96 +82,41 @@ async function apiRequest(method, token, body) {
27
82
  },
28
83
  body: body ? JSON.stringify(body) : undefined,
29
84
  });
30
- return response;
31
85
  }
32
- /**
33
- * Get current sandbox status
34
- */
86
+ // ============================================================================
87
+ // Sandbox (repo-level via .haystack.json)
88
+ // ============================================================================
35
89
  export async function getSandboxStatus() {
36
- const token = await requireAuth();
37
- console.log(chalk.dim('\nFetching preferences...\n'));
38
- try {
39
- const response = await apiRequest('GET', token);
40
- if (!response.ok) {
41
- if (response.status === 401) {
42
- console.error(chalk.red('Session expired. Run `haystack login` again.\n'));
43
- process.exit(1);
44
- }
45
- throw new Error(`Failed to get preferences: ${response.status}`);
46
- }
47
- const data = await response.json();
48
- console.log(chalk.bold('Sandbox mode:'), data.sandbox_enabled
49
- ? chalk.green('enabled')
50
- : chalk.yellow('disabled'));
51
- console.log();
52
- if (data.sandbox_enabled) {
53
- console.log(chalk.dim('Haystack can clone and store your repos for verification.'));
54
- console.log(chalk.dim('All data is deleted after 24 hours.'));
55
- }
56
- else {
57
- console.log(chalk.dim('Sandbox mode is disabled. Automated verification will not work.'));
58
- console.log(chalk.dim('Enable with: haystack config sandbox on'));
59
- }
60
- console.log();
90
+ const enabled = getPreference('sandbox_enabled');
91
+ console.log(chalk.bold('\nSandbox mode:'), enabled
92
+ ? chalk.green('enabled')
93
+ : chalk.yellow('disabled'));
94
+ console.log();
95
+ if (enabled) {
96
+ console.log(chalk.dim('Haystack can clone and store your repos for verification.'));
97
+ console.log(chalk.dim('All data is deleted after 24 hours.'));
61
98
  }
62
- catch (error) {
63
- console.error(chalk.red(`\nError: ${error.message}\n`));
64
- process.exit(1);
99
+ else {
100
+ console.log(chalk.dim('Sandbox mode is disabled. Automated verification will not work.'));
101
+ console.log(chalk.dim('Enable with: haystack config sandbox on'));
65
102
  }
103
+ console.log(chalk.dim('(Stored in .haystack.json — applies to all repo contributors)'));
104
+ console.log();
66
105
  }
67
- /**
68
- * Enable sandbox mode
69
- */
70
106
  export async function enableSandbox() {
71
- const token = await requireAuth();
72
- console.log(chalk.dim('\nEnabling sandbox mode...'));
73
- try {
74
- const response = await apiRequest('PUT', token, { sandbox_enabled: true });
75
- if (!response.ok) {
76
- if (response.status === 401) {
77
- console.error(chalk.red('Session expired. Run `haystack login` again.\n'));
78
- process.exit(1);
79
- }
80
- const error = await response.json().catch(() => ({}));
81
- throw new Error(error.error || `Failed to update preferences: ${response.status}`);
82
- }
83
- console.log(chalk.green('\nSandbox mode enabled.\n'));
84
- console.log(chalk.dim('Haystack can now clone and store your repos for verification.'));
85
- console.log(chalk.dim('All data is deleted after 24 hours.\n'));
86
- }
87
- catch (error) {
88
- console.error(chalk.red(`\nError: ${error.message}\n`));
89
- process.exit(1);
90
- }
107
+ setPreference('sandbox_enabled', true);
108
+ console.log(chalk.green('\nSandbox mode enabled.\n'));
109
+ console.log(chalk.dim('Haystack can now clone and store your repos for verification.'));
110
+ console.log(chalk.dim('All data is deleted after 24 hours.'));
111
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
91
112
  }
92
- /**
93
- * Disable sandbox mode
94
- */
95
113
  export async function disableSandbox() {
96
- const token = await requireAuth();
97
- console.log(chalk.dim('\nDisabling sandbox mode...'));
98
- try {
99
- const response = await apiRequest('PUT', token, { sandbox_enabled: false });
100
- if (!response.ok) {
101
- if (response.status === 401) {
102
- console.error(chalk.red('Session expired. Run `haystack login` again.\n'));
103
- process.exit(1);
104
- }
105
- const error = await response.json().catch(() => ({}));
106
- throw new Error(error.error || `Failed to update preferences: ${response.status}`);
107
- }
108
- console.log(chalk.yellow('\nSandbox mode disabled.\n'));
109
- console.log(chalk.dim('Automated verification will not work until you re-enable sandbox mode.'));
110
- console.log(chalk.dim('Re-enable with: haystack config sandbox on\n'));
111
- }
112
- catch (error) {
113
- console.error(chalk.red(`\nError: ${error.message}\n`));
114
- process.exit(1);
115
- }
114
+ setPreference('sandbox_enabled', false);
115
+ console.log(chalk.yellow('\nSandbox mode disabled.\n'));
116
+ console.log(chalk.dim('Automated verification will not work until you re-enable sandbox mode.'));
117
+ console.log(chalk.dim('Re-enable with: haystack config sandbox on'));
118
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
116
119
  }
117
- /**
118
- * Handle sandbox subcommand
119
- */
120
120
  export async function handleSandbox(action) {
121
121
  switch (action?.toLowerCase()) {
122
122
  case 'on':
@@ -136,9 +136,9 @@ export async function handleSandbox(action) {
136
136
  process.exit(1);
137
137
  }
138
138
  }
139
- /**
140
- * Get current agentic tool setting
141
- */
139
+ // ============================================================================
140
+ // Agentic tool (user-level via API)
141
+ // ============================================================================
142
142
  export async function getAgenticToolStatus() {
143
143
  const token = await requireAuth();
144
144
  console.log(chalk.dim('\nFetching preferences...\n'));
@@ -169,9 +169,6 @@ export async function getAgenticToolStatus() {
169
169
  process.exit(1);
170
170
  }
171
171
  }
172
- /**
173
- * Set agentic tool preference
174
- */
175
172
  export async function setAgenticTool(tool) {
176
173
  const token = await requireAuth();
177
174
  console.log(chalk.dim(`\nSetting agentic tool to ${tool}...`));
@@ -200,9 +197,6 @@ export async function setAgenticTool(tool) {
200
197
  process.exit(1);
201
198
  }
202
199
  }
203
- /**
204
- * Handle agentic-tool subcommand
205
- */
206
200
  export async function handleAgenticTool(tool) {
207
201
  const validTools = ['opencode', 'claude-code', 'codex'];
208
202
  if (!tool || tool === 'status') {
@@ -220,3 +214,195 @@ export async function handleAgenticTool(tool) {
220
214
  }
221
215
  return setAgenticTool(normalizedTool);
222
216
  }
217
+ // ============================================================================
218
+ // Auto-merge (repo-level via .haystack.json)
219
+ // ============================================================================
220
+ /**
221
+ * Check if auto-merge is enabled for the current repo.
222
+ * Returns false if .haystack.json is missing or on error.
223
+ */
224
+ export async function isAutoMergeEnabled() {
225
+ try {
226
+ return getPreference('auto_merge');
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+ export async function getAutoMergeStatus() {
233
+ const enabled = getPreference('auto_merge');
234
+ console.log(chalk.bold('\nAuto-merge:'), enabled
235
+ ? chalk.green('enabled')
236
+ : chalk.yellow('disabled'));
237
+ console.log();
238
+ if (enabled) {
239
+ console.log(chalk.dim('PRs submitted via `haystack submit` will be auto-merged'));
240
+ console.log(chalk.dim('when analysis finds no issues (safe to merge).'));
241
+ }
242
+ else {
243
+ console.log(chalk.dim('Auto-merge is disabled. PRs require manual merge.'));
244
+ console.log(chalk.dim('Enable with: haystack config auto-merge on'));
245
+ }
246
+ console.log(chalk.dim('(Stored in .haystack.json — applies to all repo contributors)'));
247
+ console.log();
248
+ }
249
+ export async function enableAutoMerge() {
250
+ setPreference('auto_merge', true);
251
+ console.log(chalk.green('\nAuto-merge enabled.\n'));
252
+ console.log(chalk.dim('PRs submitted via `haystack submit` will be auto-merged'));
253
+ console.log(chalk.dim('when analysis finds no issues (safe to merge).'));
254
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
255
+ }
256
+ export async function disableAutoMerge() {
257
+ setPreference('auto_merge', false);
258
+ console.log(chalk.yellow('\nAuto-merge disabled.\n'));
259
+ console.log(chalk.dim('PRs will require manual merge after analysis.'));
260
+ console.log(chalk.dim('Re-enable with: haystack config auto-merge on'));
261
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
262
+ }
263
+ export async function handleAutoMerge(action) {
264
+ switch (action?.toLowerCase()) {
265
+ case 'on':
266
+ case 'enable':
267
+ case 'true':
268
+ return enableAutoMerge();
269
+ case 'off':
270
+ case 'disable':
271
+ case 'false':
272
+ return disableAutoMerge();
273
+ case 'status':
274
+ case undefined:
275
+ return getAutoMergeStatus();
276
+ default:
277
+ console.error(chalk.red(`\nUnknown action: ${action}`));
278
+ console.log('\nUsage: haystack config auto-merge [on|off|status]\n');
279
+ process.exit(1);
280
+ }
281
+ }
282
+ // ============================================================================
283
+ // Wait-for-reviewers (repo-level via .haystack.json)
284
+ // ============================================================================
285
+ function getBabysitterConfig() {
286
+ const { config, path } = loadHaystackConfig();
287
+ const babysitter = config.babysitter ?? {};
288
+ const reviewers = babysitter.expected_ai_reviewers ?? [];
289
+ return { config, path, reviewers };
290
+ }
291
+ function saveBabysitterReviewers(config, configPath, reviewers) {
292
+ const babysitter = config.babysitter ?? {};
293
+ babysitter.expected_ai_reviewers = reviewers.length > 0 ? reviewers : undefined;
294
+ config.babysitter = babysitter;
295
+ saveHaystackConfig(config, configPath);
296
+ }
297
+ function printAvailableReviewers(currentReviewers) {
298
+ const currentSet = new Set(currentReviewers);
299
+ console.log(chalk.dim('Available AI reviewer sources:'));
300
+ for (const source of AI_REVIEWER_SOURCES) {
301
+ const name = AI_REVIEWER_DISPLAY_NAMES[source];
302
+ const active = currentSet.has(source);
303
+ const marker = active ? chalk.green(' ✓ ') : ' ';
304
+ console.log(`${marker}${chalk.cyan(source)} — ${name}`);
305
+ }
306
+ console.log();
307
+ }
308
+ function validateReviewerSources(sources) {
309
+ const validSet = new Set(AI_REVIEWER_SOURCES);
310
+ const invalid = sources.filter(s => !validSet.has(s));
311
+ if (invalid.length > 0) {
312
+ console.error(chalk.red(`\nUnknown reviewer source(s): ${invalid.join(', ')}`));
313
+ console.log(chalk.dim(`\nValid sources: ${AI_REVIEWER_SOURCES.join(', ')}`));
314
+ process.exit(1);
315
+ }
316
+ return sources;
317
+ }
318
+ export async function handleWaitForReviewers(action, reviewers) {
319
+ const normalizedAction = action?.toLowerCase();
320
+ // Default: show status
321
+ if (!normalizedAction || normalizedAction === 'list' || normalizedAction === 'status') {
322
+ const { reviewers: current } = getBabysitterConfig();
323
+ console.log(chalk.bold('\nMerge queue — expected AI reviewers\n'));
324
+ if (current.length > 0) {
325
+ console.log('The merge queue will wait for these external reviewers before merging:');
326
+ for (const source of current) {
327
+ const name = AI_REVIEWER_DISPLAY_NAMES[source] ?? source;
328
+ console.log(` ${chalk.green(name)} ${chalk.dim(`(${source})`)}`);
329
+ }
330
+ }
331
+ else {
332
+ console.log('No external AI reviewers configured.');
333
+ console.log(chalk.dim('The merge queue will not wait for any external reviewers.'));
334
+ }
335
+ console.log();
336
+ printAvailableReviewers(current);
337
+ console.log(chalk.dim('Add with: haystack config wait-for-reviewers add <source>'));
338
+ console.log();
339
+ return;
340
+ }
341
+ if (normalizedAction === 'add') {
342
+ if (!reviewers || reviewers.length === 0) {
343
+ console.error(chalk.red('\nSpecify at least one reviewer source to add.'));
344
+ const { reviewers: current } = getBabysitterConfig();
345
+ console.log();
346
+ printAvailableReviewers(current);
347
+ process.exit(1);
348
+ }
349
+ const validated = validateReviewerSources(reviewers);
350
+ const { config, path, reviewers: current } = getBabysitterConfig();
351
+ const merged = Array.from(new Set([...current, ...validated]));
352
+ saveBabysitterReviewers(config, path, merged);
353
+ const added = validated.filter(s => !current.includes(s));
354
+ if (added.length > 0) {
355
+ console.log(chalk.green(`\nAdded: ${added.map(s => AI_REVIEWER_DISPLAY_NAMES[s] ?? s).join(', ')}`));
356
+ }
357
+ else {
358
+ console.log(chalk.dim('\nAll specified reviewers were already configured.'));
359
+ }
360
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
361
+ return;
362
+ }
363
+ if (normalizedAction === 'remove') {
364
+ if (!reviewers || reviewers.length === 0) {
365
+ console.error(chalk.red('\nSpecify at least one reviewer source to remove.'));
366
+ process.exit(1);
367
+ }
368
+ const validated = validateReviewerSources(reviewers);
369
+ const { config, path, reviewers: current } = getBabysitterConfig();
370
+ const removeSet = new Set(validated);
371
+ const remaining = current.filter(s => !removeSet.has(s));
372
+ saveBabysitterReviewers(config, path, remaining);
373
+ const removed = validated.filter(s => current.includes(s));
374
+ if (removed.length > 0) {
375
+ console.log(chalk.yellow(`\nRemoved: ${removed.map(s => AI_REVIEWER_DISPLAY_NAMES[s] ?? s).join(', ')}`));
376
+ }
377
+ else {
378
+ console.log(chalk.dim('\nNone of the specified reviewers were configured.'));
379
+ }
380
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
381
+ return;
382
+ }
383
+ if (normalizedAction === 'clear') {
384
+ const { config, path } = getBabysitterConfig();
385
+ saveBabysitterReviewers(config, path, []);
386
+ console.log(chalk.yellow('\nCleared all expected AI reviewers.'));
387
+ console.log(chalk.dim('The merge queue will not wait for any external reviewers.'));
388
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
389
+ return;
390
+ }
391
+ // Unknown action — maybe the user passed a reviewer name directly
392
+ // Try treating action + reviewers as all reviewer names for 'add'
393
+ const allNames = [action, ...(reviewers ?? [])];
394
+ const validSet = new Set(AI_REVIEWER_SOURCES);
395
+ if (allNames.every(n => validSet.has(n))) {
396
+ // Treat as implicit 'add'
397
+ const validated = validateReviewerSources(allNames);
398
+ const { config, path, reviewers: current } = getBabysitterConfig();
399
+ const merged = Array.from(new Set([...current, ...validated]));
400
+ saveBabysitterReviewers(config, path, merged);
401
+ console.log(chalk.green(`\nAdded: ${validated.map(s => AI_REVIEWER_DISPLAY_NAMES[s] ?? s).join(', ')}`));
402
+ console.log(chalk.dim('Commit .haystack.json to share with your team.\n'));
403
+ return;
404
+ }
405
+ console.error(chalk.red(`\nUnknown action: ${action}`));
406
+ console.log('\nUsage: haystack config wait-for-reviewers [list|add|remove|clear] [sources...]\n');
407
+ process.exit(1);
408
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * dismiss & mark-reviewed commands — User overrides for PR findings.
3
+ *
4
+ * haystack dismiss <pr> — Dismiss analysis findings for a PR
5
+ * haystack mark-reviewed <pr> — Mark human review as not needed for a PR
6
+ *
7
+ * Each posts a user override to the Haystack API so the PR moves toward
8
+ * "Good to Merge" in the feed.
9
+ *
10
+ * Accepts a PR identifier in several formats:
11
+ * 123 # Uses current repo's owner/repo
12
+ * owner/repo#123 # Fully qualified
13
+ * https://github.com/owner/repo/pull/123 # GitHub URL
14
+ */
15
+ export declare function dismissCommand(identifier: string): Promise<void>;
16
+ export declare function markReviewedCommand(identifier: string): Promise<void>;
17
+ export declare function undismissCommand(identifier: string): Promise<void>;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * dismiss & mark-reviewed commands — User overrides for PR findings.
3
+ *
4
+ * haystack dismiss <pr> — Dismiss analysis findings for a PR
5
+ * haystack mark-reviewed <pr> — Mark human review as not needed for a PR
6
+ *
7
+ * Each posts a user override to the Haystack API so the PR moves toward
8
+ * "Good to Merge" in the feed.
9
+ *
10
+ * Accepts a PR identifier in several formats:
11
+ * 123 # Uses current repo's owner/repo
12
+ * owner/repo#123 # Fully qualified
13
+ * https://github.com/owner/repo/pull/123 # GitHub URL
14
+ */
15
+ import chalk from 'chalk';
16
+ import { loadToken } from './login.js';
17
+ import { parseRemoteUrl } from '../utils/git.js';
18
+ // ============================================================================
19
+ // PR identifier parsing (shared with triage — duplicated to avoid circular deps)
20
+ // ============================================================================
21
+ function parsePRIdentifier(identifier, commandName) {
22
+ const urlMatch = identifier.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
23
+ if (urlMatch) {
24
+ return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
25
+ }
26
+ const qualifiedMatch = identifier.match(/^([^/]+)\/([^#]+)#(\d+)$/);
27
+ if (qualifiedMatch) {
28
+ return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
29
+ }
30
+ const numberMatch = identifier.match(/^#?(\d+)$/);
31
+ if (numberMatch) {
32
+ const prNumber = parseInt(numberMatch[1], 10);
33
+ try {
34
+ const remote = parseRemoteUrl('origin');
35
+ return { owner: remote.owner, repo: remote.repo, prNumber };
36
+ }
37
+ catch {
38
+ throw new Error(`Cannot determine repository for PR #${prNumber}.\n` +
39
+ `Use the full format: haystack ${commandName} owner/repo#${prNumber}`);
40
+ }
41
+ }
42
+ throw new Error(`Invalid PR identifier: "${identifier}"\n\n` +
43
+ `Accepted formats:\n` +
44
+ ` haystack ${commandName} 123\n` +
45
+ ` haystack ${commandName} owner/repo#123\n` +
46
+ ` haystack ${commandName} https://github.com/owner/repo/pull/123`);
47
+ }
48
+ // ============================================================================
49
+ // GitHub API helpers
50
+ // ============================================================================
51
+ async function getPRHeadSha(owner, repo, prNumber, token) {
52
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, {
53
+ headers: {
54
+ Authorization: `Bearer ${token}`,
55
+ Accept: 'application/vnd.github.v3+json',
56
+ 'User-Agent': 'Haystack-CLI',
57
+ },
58
+ });
59
+ if (!response.ok) {
60
+ if (response.status === 404) {
61
+ throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
62
+ }
63
+ throw new Error(`Failed to fetch PR: HTTP ${response.status}`);
64
+ }
65
+ const data = await response.json();
66
+ return data.head.sha;
67
+ }
68
+ // ============================================================================
69
+ // User override API
70
+ // ============================================================================
71
+ const HAYSTACK_API = 'https://haystackeditor.com';
72
+ async function postUserOverride(repoFullName, prNumber, commitSha, type, token) {
73
+ const response = await fetch(`${HAYSTACK_API}/api/user-override`, {
74
+ method: 'POST',
75
+ headers: {
76
+ Authorization: `Bearer ${token}`,
77
+ 'Content-Type': 'application/json',
78
+ 'User-Agent': 'Haystack-CLI',
79
+ },
80
+ body: JSON.stringify({ repoFullName, prNumber, commitSha, type }),
81
+ });
82
+ if (!response.ok) {
83
+ const body = await response.text().catch(() => '');
84
+ if (response.status === 401) {
85
+ throw new Error('Authentication failed. Run `haystack login` to re-authenticate.');
86
+ }
87
+ if (response.status === 403) {
88
+ throw new Error('Access denied — you may not have permission to this repository.');
89
+ }
90
+ throw new Error(`API error (${response.status}): ${body}`);
91
+ }
92
+ }
93
+ async function deleteUserOverrides(repoFullName, prNumber, token) {
94
+ const response = await fetch(`${HAYSTACK_API}/api/user-override`, {
95
+ method: 'DELETE',
96
+ headers: {
97
+ Authorization: `Bearer ${token}`,
98
+ 'Content-Type': 'application/json',
99
+ 'User-Agent': 'Haystack-CLI',
100
+ },
101
+ body: JSON.stringify({ repoFullName, prNumber }),
102
+ });
103
+ if (!response.ok) {
104
+ const body = await response.text().catch(() => '');
105
+ if (response.status === 401) {
106
+ throw new Error('Authentication failed. Run `haystack login` to re-authenticate.');
107
+ }
108
+ if (response.status === 403) {
109
+ throw new Error('Access denied — you may not have permission to this repository.');
110
+ }
111
+ throw new Error(`API error (${response.status}): ${body}`);
112
+ }
113
+ }
114
+ // ============================================================================
115
+ // Shared runner
116
+ // ============================================================================
117
+ async function runOverride(identifier, type, commandName) {
118
+ // Parse PR identifier
119
+ let pr;
120
+ try {
121
+ pr = parsePRIdentifier(identifier, commandName);
122
+ }
123
+ catch (err) {
124
+ console.error(chalk.red(`\n${err.message}\n`));
125
+ process.exit(1);
126
+ }
127
+ // Require auth
128
+ const token = await loadToken();
129
+ if (!token) {
130
+ console.error(chalk.red('\nNot logged in. Run `haystack login` first.\n'));
131
+ process.exit(1);
132
+ }
133
+ const prLabel = `${pr.owner}/${pr.repo}#${pr.prNumber}`;
134
+ const repoFullName = `${pr.owner}/${pr.repo}`;
135
+ const actionLabel = type === 'findings-dismissed'
136
+ ? 'Dismissing findings'
137
+ : 'Marking review as not needed';
138
+ console.log(chalk.dim(`\n${actionLabel} for ${prLabel}...`));
139
+ // Get PR HEAD SHA
140
+ let headSha;
141
+ try {
142
+ headSha = await getPRHeadSha(pr.owner, pr.repo, pr.prNumber, token);
143
+ }
144
+ catch (err) {
145
+ console.error(chalk.red(`\n${err.message}\n`));
146
+ process.exit(1);
147
+ }
148
+ // Post the override
149
+ try {
150
+ await postUserOverride(repoFullName, pr.prNumber, headSha, type, token);
151
+ }
152
+ catch (err) {
153
+ console.error(chalk.red(`\n${err.message}\n`));
154
+ process.exit(1);
155
+ }
156
+ // Success
157
+ if (type === 'findings-dismissed') {
158
+ console.log(chalk.green(`\n Findings dismissed for ${prLabel}`));
159
+ }
160
+ else {
161
+ console.log(chalk.green(`\n Review marked as not needed for ${prLabel}`));
162
+ }
163
+ console.log(chalk.dim(` Commit: ${headSha.slice(0, 7)}`));
164
+ console.log(chalk.dim(' The PR will move to "Good to Merge" in the feed.\n'));
165
+ }
166
+ // ============================================================================
167
+ // Exported commands
168
+ // ============================================================================
169
+ export async function dismissCommand(identifier) {
170
+ await runOverride(identifier, 'findings-dismissed', 'dismiss');
171
+ }
172
+ export async function markReviewedCommand(identifier) {
173
+ await runOverride(identifier, 'review-not-needed', 'mark-reviewed');
174
+ }
175
+ export async function undismissCommand(identifier) {
176
+ let pr;
177
+ try {
178
+ pr = parsePRIdentifier(identifier, 'undismiss');
179
+ }
180
+ catch (err) {
181
+ console.error(chalk.red(`\n${err.message}\n`));
182
+ process.exit(1);
183
+ }
184
+ const token = await loadToken();
185
+ if (!token) {
186
+ console.error(chalk.red('\nNot logged in. Run `haystack login` first.\n'));
187
+ process.exit(1);
188
+ }
189
+ const prLabel = `${pr.owner}/${pr.repo}#${pr.prNumber}`;
190
+ const repoFullName = `${pr.owner}/${pr.repo}`;
191
+ console.log(chalk.dim(`\nClearing overrides for ${prLabel}...`));
192
+ try {
193
+ await deleteUserOverrides(repoFullName, pr.prNumber, token);
194
+ }
195
+ catch (err) {
196
+ console.error(chalk.red(`\n${err.message}\n`));
197
+ process.exit(1);
198
+ }
199
+ console.log(chalk.green(`\n Overrides cleared for ${prLabel}`));
200
+ console.log(chalk.dim(' The PR will return to its original feed bucket.\n'));
201
+ }