@haystackeditor/cli 0.8.0 → 0.9.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 (76) hide show
  1. package/README.md +105 -12
  2. package/dist/assets/hooks/agent-context/detect.ts +136 -0
  3. package/dist/assets/hooks/agent-context/format.ts +99 -0
  4. package/dist/assets/hooks/agent-context/index.ts +39 -0
  5. package/dist/assets/hooks/agent-context/parsers/claude.ts +253 -0
  6. package/dist/assets/hooks/agent-context/parsers/gemini.ts +155 -0
  7. package/dist/assets/hooks/agent-context/parsers/opencode.ts +174 -0
  8. package/dist/assets/hooks/agent-context/tsconfig.json +13 -0
  9. package/dist/assets/hooks/agent-context/types.ts +58 -0
  10. package/dist/assets/hooks/llm-rules-template.md +56 -0
  11. package/dist/assets/hooks/package.json +11 -0
  12. package/dist/assets/hooks/scripts/commit-msg.sh +4 -0
  13. package/dist/assets/hooks/scripts/post-commit.sh +4 -0
  14. package/dist/assets/hooks/scripts/pre-commit.sh +92 -0
  15. package/dist/assets/hooks/scripts/pre-push.sh +25 -0
  16. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
  17. package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +528 -0
  18. package/dist/assets/hooks/truncation-checker/index.ts +595 -0
  19. package/dist/assets/hooks/truncation-checker/tsconfig.json +13 -0
  20. package/dist/assets/skills/prepare-haystack.md +323 -0
  21. package/dist/assets/skills/secrets.md +164 -0
  22. package/dist/assets/skills/setup-external-sandbox.md +243 -0
  23. package/dist/assets/skills/setup-haystack.md +639 -0
  24. package/dist/assets/skills/submit.md +154 -0
  25. package/dist/assets/templates/CLAUDE.md.snippet +42 -0
  26. package/dist/assets/templates/haystack.yml +193 -0
  27. package/dist/commands/check-pending.d.ts +19 -0
  28. package/dist/commands/check-pending.js +217 -0
  29. package/dist/commands/config.d.ts +18 -12
  30. package/dist/commands/config.js +327 -52
  31. package/dist/commands/hooks.d.ts +17 -0
  32. package/dist/commands/hooks.js +269 -0
  33. package/dist/commands/install-session-hooks.d.ts +16 -0
  34. package/dist/commands/install-session-hooks.js +302 -0
  35. package/dist/commands/login.js +1 -1
  36. package/dist/commands/policy.d.ts +31 -0
  37. package/dist/commands/policy.js +365 -0
  38. package/dist/commands/skills.d.ts +8 -0
  39. package/dist/commands/skills.js +80 -0
  40. package/dist/commands/submit.d.ts +22 -0
  41. package/dist/commands/submit.js +428 -0
  42. package/dist/commands/triage.d.ts +16 -0
  43. package/dist/commands/triage.js +354 -0
  44. package/dist/index.d.ts +3 -0
  45. package/dist/index.js +317 -2
  46. package/dist/tools/detect.d.ts +50 -0
  47. package/dist/tools/detect.js +853 -0
  48. package/dist/tools/fixtures.d.ts +38 -0
  49. package/dist/tools/fixtures.js +199 -0
  50. package/dist/tools/setup.d.ts +43 -0
  51. package/dist/tools/setup.js +597 -0
  52. package/dist/triage/prompts.d.ts +31 -0
  53. package/dist/triage/prompts.js +266 -0
  54. package/dist/triage/runner.d.ts +21 -0
  55. package/dist/triage/runner.js +325 -0
  56. package/dist/triage/traces.d.ts +20 -0
  57. package/dist/triage/traces.js +305 -0
  58. package/dist/triage/types.d.ts +47 -0
  59. package/dist/triage/types.js +7 -0
  60. package/dist/types.d.ts +1387 -191
  61. package/dist/types.js +254 -2
  62. package/dist/utils/analysis-api.d.ts +108 -0
  63. package/dist/utils/analysis-api.js +194 -0
  64. package/dist/utils/config.js +1 -1
  65. package/dist/utils/git.d.ts +80 -0
  66. package/dist/utils/git.js +302 -0
  67. package/dist/utils/github-api.d.ts +83 -0
  68. package/dist/utils/github-api.js +266 -0
  69. package/dist/utils/hooks.d.ts +26 -0
  70. package/dist/utils/hooks.js +226 -0
  71. package/dist/utils/pending-state.d.ts +38 -0
  72. package/dist/utils/pending-state.js +86 -0
  73. package/dist/utils/secrets.js +3 -3
  74. package/dist/utils/skill.d.ts +1 -1
  75. package/dist/utils/skill.js +658 -1
  76. package/package.json +5 -3
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Git utilities for haystack submit command.
3
+ *
4
+ * Provides functions for git operations: branch management, push, remote parsing.
5
+ */
6
+ import { execSync } from 'child_process';
7
+ // Re-export findGitRoot from hooks for consistency
8
+ export { findGitRoot } from './hooks.js';
9
+ // ============================================================================
10
+ // Branch operations
11
+ // ============================================================================
12
+ /**
13
+ * Get the current branch name
14
+ */
15
+ export function getCurrentBranch() {
16
+ try {
17
+ return execSync('git rev-parse --abbrev-ref HEAD', {
18
+ encoding: 'utf-8',
19
+ stdio: ['pipe', 'pipe', 'pipe'],
20
+ }).trim();
21
+ }
22
+ catch {
23
+ throw new Error('Failed to get current branch. Are you in a git repository?');
24
+ }
25
+ }
26
+ /**
27
+ * Get the default branch (main or master)
28
+ */
29
+ export function getDefaultBranch() {
30
+ try {
31
+ // Try to get from remote HEAD
32
+ const remoteHead = execSync('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo ""', {
33
+ encoding: 'utf-8',
34
+ stdio: ['pipe', 'pipe', 'pipe'],
35
+ }).trim();
36
+ if (remoteHead) {
37
+ // refs/remotes/origin/main -> main
38
+ return remoteHead.replace('refs/remotes/origin/', '');
39
+ }
40
+ // Fallback: check if main or master exists
41
+ const branches = execSync('git branch -r', {
42
+ encoding: 'utf-8',
43
+ stdio: ['pipe', 'pipe', 'pipe'],
44
+ });
45
+ if (branches.includes('origin/main'))
46
+ return 'main';
47
+ if (branches.includes('origin/master'))
48
+ return 'master';
49
+ return 'main'; // Default fallback
50
+ }
51
+ catch {
52
+ return 'main';
53
+ }
54
+ }
55
+ /**
56
+ * Check if working directory has uncommitted changes
57
+ */
58
+ export function hasUncommittedChanges() {
59
+ try {
60
+ const status = execSync('git status --porcelain', {
61
+ encoding: 'utf-8',
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ }).trim();
64
+ return status.length > 0;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Check if current branch has unpushed commits
72
+ */
73
+ export function hasUnpushedCommits() {
74
+ try {
75
+ const currentBranch = getCurrentBranch();
76
+ const unpushed = execSync(`git log origin/${currentBranch}..HEAD --oneline 2>/dev/null || echo ""`, {
77
+ encoding: 'utf-8',
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ }).trim();
80
+ return unpushed.length > 0;
81
+ }
82
+ catch {
83
+ // Branch may not have an upstream, assume there are commits to push
84
+ return true;
85
+ }
86
+ }
87
+ /**
88
+ * Get comprehensive branch info
89
+ */
90
+ export function getBranchInfo() {
91
+ return {
92
+ currentBranch: getCurrentBranch(),
93
+ defaultBranch: getDefaultBranch(),
94
+ isClean: !hasUncommittedChanges(),
95
+ hasUncommittedChanges: hasUncommittedChanges(),
96
+ hasUnpushedCommits: hasUnpushedCommits(),
97
+ };
98
+ }
99
+ /**
100
+ * Create a new branch from current HEAD
101
+ */
102
+ export function createBranch(branchName) {
103
+ try {
104
+ execSync(`git checkout -b "${branchName}"`, {
105
+ encoding: 'utf-8',
106
+ stdio: ['pipe', 'pipe', 'pipe'],
107
+ });
108
+ }
109
+ catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ if (message.includes('already exists')) {
112
+ throw new Error(`Branch '${branchName}' already exists`);
113
+ }
114
+ throw new Error(`Failed to create branch: ${message}`);
115
+ }
116
+ }
117
+ /**
118
+ * Check if a branch exists locally
119
+ */
120
+ export function branchExists(branchName) {
121
+ try {
122
+ execSync(`git rev-parse --verify "${branchName}" 2>/dev/null`, {
123
+ encoding: 'utf-8',
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ });
126
+ return true;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /**
133
+ * Check if a branch exists on remote
134
+ */
135
+ export function remoteBranchExists(remoteName, branchName) {
136
+ try {
137
+ execSync(`git ls-remote --heads ${remoteName} ${branchName}`, {
138
+ encoding: 'utf-8',
139
+ stdio: ['pipe', 'pipe', 'pipe'],
140
+ });
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ /**
148
+ * Checkout an existing branch
149
+ */
150
+ export function checkoutBranch(branchName) {
151
+ try {
152
+ execSync(`git checkout "${branchName}"`, {
153
+ encoding: 'utf-8',
154
+ stdio: ['pipe', 'pipe', 'pipe'],
155
+ });
156
+ }
157
+ catch (err) {
158
+ const message = err instanceof Error ? err.message : String(err);
159
+ throw new Error(`Failed to checkout branch: ${message}`);
160
+ }
161
+ }
162
+ // ============================================================================
163
+ // Push operations
164
+ // ============================================================================
165
+ /**
166
+ * Push current branch to remote with upstream tracking
167
+ */
168
+ export function pushBranch(remoteName, branchName) {
169
+ try {
170
+ execSync(`git push -u ${remoteName} "${branchName}"`, {
171
+ encoding: 'utf-8',
172
+ stdio: ['pipe', 'pipe', 'pipe'],
173
+ });
174
+ }
175
+ catch (err) {
176
+ const message = err instanceof Error ? err.message : String(err);
177
+ if (message.includes('permission denied') || message.includes('403')) {
178
+ throw new Error('Permission denied. Check your GitHub credentials.');
179
+ }
180
+ if (message.includes('remote rejected')) {
181
+ throw new Error('Remote rejected the push. The branch may be protected.');
182
+ }
183
+ throw new Error(`Failed to push: ${message}`);
184
+ }
185
+ }
186
+ // ============================================================================
187
+ // Remote operations
188
+ // ============================================================================
189
+ /**
190
+ * Parse remote URL to extract owner and repo
191
+ * Supports both SSH and HTTPS URLs
192
+ */
193
+ export function parseRemoteUrl(remoteName = 'origin') {
194
+ try {
195
+ const url = execSync(`git remote get-url ${remoteName}`, {
196
+ encoding: 'utf-8',
197
+ stdio: ['pipe', 'pipe', 'pipe'],
198
+ }).trim();
199
+ // SSH format: git@github.com:owner/repo.git
200
+ const sshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
201
+ if (sshMatch) {
202
+ return {
203
+ owner: sshMatch[1],
204
+ repo: sshMatch[2],
205
+ remoteName,
206
+ };
207
+ }
208
+ // HTTPS format: https://github.com/owner/repo.git
209
+ const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
210
+ if (httpsMatch) {
211
+ return {
212
+ owner: httpsMatch[1],
213
+ repo: httpsMatch[2],
214
+ remoteName,
215
+ };
216
+ }
217
+ throw new Error(`Unsupported remote URL format: ${url}`);
218
+ }
219
+ catch (err) {
220
+ if (err instanceof Error && err.message.includes('Unsupported')) {
221
+ throw err;
222
+ }
223
+ throw new Error(`Failed to get remote URL for '${remoteName}'`);
224
+ }
225
+ }
226
+ // ============================================================================
227
+ // Commit operations
228
+ // ============================================================================
229
+ /**
230
+ * Get the last commit message (first line only)
231
+ */
232
+ export function getLastCommitMessage() {
233
+ try {
234
+ return execSync('git log -1 --format=%s', {
235
+ encoding: 'utf-8',
236
+ stdio: ['pipe', 'pipe', 'pipe'],
237
+ }).trim();
238
+ }
239
+ catch {
240
+ return 'Update';
241
+ }
242
+ }
243
+ /**
244
+ * Get the last commit SHA (short)
245
+ */
246
+ export function getLastCommitSha() {
247
+ try {
248
+ return execSync('git rev-parse --short HEAD', {
249
+ encoding: 'utf-8',
250
+ stdio: ['pipe', 'pipe', 'pipe'],
251
+ }).trim();
252
+ }
253
+ catch {
254
+ return '';
255
+ }
256
+ }
257
+ /**
258
+ * Get full commit messages (subject + body) for all commits since base branch.
259
+ * Returns the combined log, or empty string if no commits or on error.
260
+ */
261
+ export function getCommitMessagesSinceBase(baseBranch) {
262
+ try {
263
+ // Use origin/<base> to compare against remote base branch
264
+ const mergeBase = execSync(`git merge-base origin/${baseBranch} HEAD`, {
265
+ encoding: 'utf-8',
266
+ stdio: ['pipe', 'pipe', 'pipe'],
267
+ }).trim();
268
+ if (!mergeBase)
269
+ return '';
270
+ return execSync(`git log ${mergeBase}..HEAD --format=%B --reverse`, {
271
+ encoding: 'utf-8',
272
+ stdio: ['pipe', 'pipe', 'pipe'],
273
+ }).trim();
274
+ }
275
+ catch {
276
+ // Fallback: try just the last commit's full message
277
+ try {
278
+ return execSync('git log -1 --format=%B', {
279
+ encoding: 'utf-8',
280
+ stdio: ['pipe', 'pipe', 'pipe'],
281
+ }).trim();
282
+ }
283
+ catch {
284
+ return '';
285
+ }
286
+ }
287
+ }
288
+ /**
289
+ * Get the number of commits ahead of base branch
290
+ */
291
+ export function getCommitsAhead(baseBranch) {
292
+ try {
293
+ const output = execSync(`git rev-list --count ${baseBranch}..HEAD`, {
294
+ encoding: 'utf-8',
295
+ stdio: ['pipe', 'pipe', 'pipe'],
296
+ }).trim();
297
+ return parseInt(output, 10) || 0;
298
+ }
299
+ catch {
300
+ return 0;
301
+ }
302
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * GitHub API utilities for haystack submit command.
3
+ *
4
+ * Provides functions for creating PRs, managing labels, etc.
5
+ */
6
+ export interface CreatePROptions {
7
+ owner: string;
8
+ repo: string;
9
+ title: string;
10
+ head: string;
11
+ base: string;
12
+ body?: string;
13
+ draft?: boolean;
14
+ }
15
+ export interface PRResponse {
16
+ number: number;
17
+ html_url: string;
18
+ title: string;
19
+ state: string;
20
+ head: {
21
+ ref: string;
22
+ };
23
+ base: {
24
+ ref: string;
25
+ };
26
+ }
27
+ export interface LabelInfo {
28
+ name: string;
29
+ color: string;
30
+ description?: string;
31
+ }
32
+ export declare const HAYSTACK_LABELS: Record<string, LabelInfo>;
33
+ /**
34
+ * Get stored GitHub token, throw if not authenticated
35
+ */
36
+ export declare function requireGitHubAuth(): Promise<string>;
37
+ /**
38
+ * Create a pull request
39
+ */
40
+ export declare function createPullRequest(options: CreatePROptions): Promise<PRResponse>;
41
+ /**
42
+ * Check if a PR already exists for a branch
43
+ */
44
+ export declare function findExistingPR(owner: string, repo: string, head: string): Promise<PRResponse | null>;
45
+ /**
46
+ * Add labels to an issue/PR
47
+ */
48
+ export declare function addLabelsToIssue(owner: string, repo: string, issueNumber: number, labels: string[]): Promise<void>;
49
+ /**
50
+ * Get all labels from an issue/PR
51
+ */
52
+ export declare function getLabelsFromIssue(owner: string, repo: string, issueNumber: number): Promise<string[]>;
53
+ /**
54
+ * Remove a label from an issue/PR
55
+ */
56
+ export declare function removeLabelFromIssue(owner: string, repo: string, issueNumber: number, labelName: string): Promise<void>;
57
+ /**
58
+ * Check if a label exists in the repo
59
+ */
60
+ export declare function labelExists(owner: string, repo: string, labelName: string): Promise<boolean>;
61
+ /**
62
+ * Create a label in the repo
63
+ */
64
+ export declare function createLabel(owner: string, repo: string, label: LabelInfo): Promise<void>;
65
+ /**
66
+ * Ensure all required Haystack labels exist in the repo
67
+ */
68
+ export declare function ensureHaystackLabels(owner: string, repo: string, labelNames: string[]): Promise<void>;
69
+ /**
70
+ * Request reviewers on a pull request
71
+ */
72
+ export declare function requestReviewers(owner: string, repo: string, prNumber: number, reviewers: string[]): Promise<void>;
73
+ /**
74
+ * Get the authenticated user's info
75
+ */
76
+ export declare function getCurrentUser(): Promise<{
77
+ login: string;
78
+ id: number;
79
+ }>;
80
+ /**
81
+ * Check if user has push access to a repo
82
+ */
83
+ export declare function hasPushAccess(owner: string, repo: string): Promise<boolean>;
@@ -0,0 +1,266 @@
1
+ /**
2
+ * GitHub API utilities for haystack submit command.
3
+ *
4
+ * Provides functions for creating PRs, managing labels, etc.
5
+ */
6
+ import { loadToken } from '../commands/login.js';
7
+ const GITHUB_API_BASE = 'https://api.github.com';
8
+ // Label definitions for Haystack workflow
9
+ export const HAYSTACK_LABELS = {
10
+ 'haystack:auto-merge': {
11
+ name: 'haystack:auto-merge',
12
+ color: '0E8A16', // Green
13
+ description: 'Auto-merge after Haystack approval',
14
+ },
15
+ 'haystack:needs-review': {
16
+ name: 'haystack:needs-review',
17
+ color: 'FBCA04', // Yellow
18
+ description: 'Requires Haystack review',
19
+ },
20
+ 'haystack:ready-to-merge': {
21
+ name: 'haystack:ready-to-merge',
22
+ color: '2EA44F', // GitHub green
23
+ description: 'Passed Haystack analysis, ready to merge',
24
+ },
25
+ };
26
+ // ============================================================================
27
+ // Authentication
28
+ // ============================================================================
29
+ /**
30
+ * Get stored GitHub token, throw if not authenticated
31
+ */
32
+ export async function requireGitHubAuth() {
33
+ const token = await loadToken();
34
+ if (!token) {
35
+ throw new Error('Not logged in. Run `haystack login` first.');
36
+ }
37
+ return token;
38
+ }
39
+ // ============================================================================
40
+ // API helpers
41
+ // ============================================================================
42
+ /**
43
+ * Make an authenticated GitHub API request
44
+ */
45
+ async function githubApiRequest(method, path, token, body) {
46
+ const url = `${GITHUB_API_BASE}${path}`;
47
+ const response = await fetch(url, {
48
+ method,
49
+ headers: {
50
+ Authorization: `Bearer ${token}`,
51
+ Accept: 'application/vnd.github.v3+json',
52
+ 'Content-Type': 'application/json',
53
+ 'User-Agent': 'Haystack-CLI',
54
+ },
55
+ body: body ? JSON.stringify(body) : undefined,
56
+ });
57
+ return response;
58
+ }
59
+ /**
60
+ * Handle API errors consistently
61
+ */
62
+ async function handleApiError(response, context) {
63
+ if (response.status === 401) {
64
+ throw new Error('GitHub token expired or invalid. Run `haystack login` again.');
65
+ }
66
+ if (response.status === 403) {
67
+ const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
68
+ if (rateLimitRemaining === '0') {
69
+ throw new Error('GitHub API rate limit exceeded. Please wait and try again.');
70
+ }
71
+ throw new Error(`Permission denied: ${context}`);
72
+ }
73
+ if (response.status === 404) {
74
+ throw new Error(`Not found: ${context}`);
75
+ }
76
+ if (response.status === 422) {
77
+ const data = await response.json().catch(() => ({}));
78
+ const errorMsg = data.errors?.[0]?.message || data.message || 'Validation failed';
79
+ throw new Error(`GitHub API error: ${errorMsg}`);
80
+ }
81
+ const data = await response.json().catch(() => ({}));
82
+ throw new Error(data.message || `GitHub API error (${response.status}): ${context}`);
83
+ }
84
+ // ============================================================================
85
+ // Pull Request operations
86
+ // ============================================================================
87
+ /**
88
+ * Create a pull request
89
+ */
90
+ export async function createPullRequest(options) {
91
+ const token = await requireGitHubAuth();
92
+ const response = await githubApiRequest('POST', `/repos/${options.owner}/${options.repo}/pulls`, token, {
93
+ title: options.title,
94
+ head: options.head,
95
+ base: options.base,
96
+ body: options.body || '',
97
+ draft: options.draft || false,
98
+ });
99
+ if (!response.ok) {
100
+ // GitHub returns 500 when creating a PR from a branch that was the head
101
+ // of a recently merged PR. Check for this case and give an actionable error.
102
+ if (response.status >= 500) {
103
+ const mergedPR = await findMergedPRForBranch(options.owner, options.repo, options.head, token);
104
+ if (mergedPR) {
105
+ throw new Error(`Branch "${options.head}" was already used for merged PR #${mergedPR.number}. ` +
106
+ `Push to a new branch name and try again.`);
107
+ }
108
+ }
109
+ await handleApiError(response, `creating PR in ${options.owner}/${options.repo}`);
110
+ }
111
+ return response.json();
112
+ }
113
+ /**
114
+ * Check if a merged PR exists for a given branch (used for diagnosing 500s on PR creation).
115
+ *
116
+ * Only searches the exact qualified head (owner:branch). We intentionally do NOT
117
+ * fall back to a bare branch name because that can match a different fork's branch
118
+ * and produce a misleading "branch already used" diagnosis.
119
+ *
120
+ * Returns null if the lookup fails (5xx, network error) so the caller falls through
121
+ * to the generic error handler rather than surfacing a wrong diagnosis.
122
+ */
123
+ async function findMergedPRForBranch(owner, repo, head, token) {
124
+ const headParam = head.includes(':') ? head : `${owner}:${head}`;
125
+ try {
126
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/pulls?head=${encodeURIComponent(headParam)}&state=closed`, token);
127
+ if (!response.ok) {
128
+ console.error(`[github-api] Merged-PR lookup failed (head=${headParam}): HTTP ${response.status}`);
129
+ return null;
130
+ }
131
+ const prs = await response.json();
132
+ return prs.find((pr) => pr.merged_at) ?? null;
133
+ }
134
+ catch (err) {
135
+ console.error(`[github-api] Merged-PR lookup error (head=${headParam}): ${err}`);
136
+ return null;
137
+ }
138
+ }
139
+ /**
140
+ * Check if a PR already exists for a branch
141
+ */
142
+ export async function findExistingPR(owner, repo, head) {
143
+ const token = await requireGitHubAuth();
144
+ // head format should be "owner:branch" for cross-repo or just "branch" for same-repo
145
+ const headParam = head.includes(':') ? head : `${owner}:${head}`;
146
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/pulls?head=${encodeURIComponent(headParam)}&state=open`, token);
147
+ if (!response.ok) {
148
+ if (response.status === 404)
149
+ return null;
150
+ await handleApiError(response, `checking for existing PR`);
151
+ }
152
+ const prs = await response.json();
153
+ return prs.length > 0 ? prs[0] : null;
154
+ }
155
+ // ============================================================================
156
+ // Label operations
157
+ // ============================================================================
158
+ /**
159
+ * Add labels to an issue/PR
160
+ */
161
+ export async function addLabelsToIssue(owner, repo, issueNumber, labels) {
162
+ const token = await requireGitHubAuth();
163
+ const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, token, { labels });
164
+ if (!response.ok) {
165
+ await handleApiError(response, `adding labels to PR #${issueNumber}`);
166
+ }
167
+ }
168
+ /**
169
+ * Get all labels from an issue/PR
170
+ */
171
+ export async function getLabelsFromIssue(owner, repo, issueNumber) {
172
+ const token = await requireGitHubAuth();
173
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/issues/${issueNumber}/labels`, token);
174
+ if (!response.ok) {
175
+ await handleApiError(response, `getting labels from PR #${issueNumber}`);
176
+ }
177
+ const labels = await response.json();
178
+ return labels.map((l) => l.name);
179
+ }
180
+ /**
181
+ * Remove a label from an issue/PR
182
+ */
183
+ export async function removeLabelFromIssue(owner, repo, issueNumber, labelName) {
184
+ const token = await requireGitHubAuth();
185
+ const response = await githubApiRequest('DELETE', `/repos/${owner}/${repo}/issues/${issueNumber}/labels/${encodeURIComponent(labelName)}`, token);
186
+ // 404 is fine - label wasn't on the issue
187
+ if (!response.ok && response.status !== 404) {
188
+ await handleApiError(response, `removing label '${labelName}' from PR #${issueNumber}`);
189
+ }
190
+ }
191
+ /**
192
+ * Check if a label exists in the repo
193
+ */
194
+ export async function labelExists(owner, repo, labelName) {
195
+ const token = await requireGitHubAuth();
196
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}/labels/${encodeURIComponent(labelName)}`, token);
197
+ return response.ok;
198
+ }
199
+ /**
200
+ * Create a label in the repo
201
+ */
202
+ export async function createLabel(owner, repo, label) {
203
+ const token = await requireGitHubAuth();
204
+ const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/labels`, token, {
205
+ name: label.name,
206
+ color: label.color,
207
+ description: label.description,
208
+ });
209
+ if (!response.ok && response.status !== 422) {
210
+ // 422 means label already exists, which is fine
211
+ await handleApiError(response, `creating label '${label.name}'`);
212
+ }
213
+ }
214
+ /**
215
+ * Ensure all required Haystack labels exist in the repo
216
+ */
217
+ export async function ensureHaystackLabels(owner, repo, labelNames) {
218
+ for (const name of labelNames) {
219
+ const labelInfo = HAYSTACK_LABELS[name];
220
+ if (!labelInfo)
221
+ continue;
222
+ const exists = await labelExists(owner, repo, name);
223
+ if (!exists) {
224
+ await createLabel(owner, repo, labelInfo);
225
+ }
226
+ }
227
+ }
228
+ // ============================================================================
229
+ // Review request operations
230
+ // ============================================================================
231
+ /**
232
+ * Request reviewers on a pull request
233
+ */
234
+ export async function requestReviewers(owner, repo, prNumber, reviewers) {
235
+ const token = await requireGitHubAuth();
236
+ const response = await githubApiRequest('POST', `/repos/${owner}/${repo}/pulls/${prNumber}/requested_reviewers`, token, { reviewers });
237
+ if (!response.ok) {
238
+ await handleApiError(response, `requesting reviewers on PR #${prNumber}`);
239
+ }
240
+ }
241
+ // ============================================================================
242
+ // User operations
243
+ // ============================================================================
244
+ /**
245
+ * Get the authenticated user's info
246
+ */
247
+ export async function getCurrentUser() {
248
+ const token = await requireGitHubAuth();
249
+ const response = await githubApiRequest('GET', '/user', token);
250
+ if (!response.ok) {
251
+ await handleApiError(response, 'getting current user');
252
+ }
253
+ return response.json();
254
+ }
255
+ /**
256
+ * Check if user has push access to a repo
257
+ */
258
+ export async function hasPushAccess(owner, repo) {
259
+ const token = await requireGitHubAuth();
260
+ const response = await githubApiRequest('GET', `/repos/${owner}/${repo}`, token);
261
+ if (!response.ok) {
262
+ return false;
263
+ }
264
+ const data = await response.json();
265
+ return data.permissions?.push === true;
266
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Utilities for haystack hooks install/status/update commands.
3
+ *
4
+ * Handles Entire CLI binary download, hook file copying, and git configuration.
5
+ */
6
+ export declare const ENTIRE_DEFAULT_VERSION = "0.4.8";
7
+ export declare const ENTIRE_GITHUB_REPO = "entireio/cli";
8
+ export declare const HAYSTACK_BIN_DIR: string;
9
+ export declare const ENTIRE_BIN_PATH: string;
10
+ export declare function getPlatformAssetName(): string;
11
+ export declare function findGitRoot(): string | null;
12
+ export declare function getInstalledEntireVersion(): Promise<string | null>;
13
+ export declare function getLatestEntireVersion(): Promise<string>;
14
+ export declare function downloadEntireBinary(version: string): Promise<void>;
15
+ /** Resolves the path to dist/assets/hooks/ (or src/assets/hooks/ in dev) */
16
+ export declare function getAssetsDir(): string;
17
+ /** Copy hook scripts and TypeScript modules into the target hooks directory */
18
+ export declare function copyHookFiles(hooksDir: string): Promise<void>;
19
+ /** Install npm dependencies in the hooks directory */
20
+ export declare function installHookDeps(hooksDir: string): Promise<void>;
21
+ /** Copy LLM rules template if none exists */
22
+ export declare function copyLlmRulesTemplate(repoRoot: string): Promise<boolean>;
23
+ /** Create .entire/settings.json and .entire/.gitignore */
24
+ export declare function createEntireConfig(repoRoot: string): Promise<void>;
25
+ /** Add .entire/metadata/ to repo .gitignore if not already present */
26
+ export declare function updateGitignore(repoRoot: string): Promise<boolean>;