@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.
- package/README.md +105 -12
- package/dist/assets/hooks/agent-context/detect.ts +136 -0
- package/dist/assets/hooks/agent-context/format.ts +99 -0
- package/dist/assets/hooks/agent-context/index.ts +39 -0
- package/dist/assets/hooks/agent-context/parsers/claude.ts +253 -0
- package/dist/assets/hooks/agent-context/parsers/gemini.ts +155 -0
- package/dist/assets/hooks/agent-context/parsers/opencode.ts +174 -0
- package/dist/assets/hooks/agent-context/tsconfig.json +13 -0
- package/dist/assets/hooks/agent-context/types.ts +58 -0
- package/dist/assets/hooks/llm-rules-template.md +56 -0
- package/dist/assets/hooks/package.json +11 -0
- package/dist/assets/hooks/scripts/commit-msg.sh +4 -0
- package/dist/assets/hooks/scripts/post-commit.sh +4 -0
- package/dist/assets/hooks/scripts/pre-commit.sh +92 -0
- package/dist/assets/hooks/scripts/pre-push.sh +25 -0
- package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
- package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +528 -0
- package/dist/assets/hooks/truncation-checker/index.ts +595 -0
- package/dist/assets/hooks/truncation-checker/tsconfig.json +13 -0
- package/dist/assets/skills/prepare-haystack.md +323 -0
- package/dist/assets/skills/secrets.md +164 -0
- package/dist/assets/skills/setup-external-sandbox.md +243 -0
- package/dist/assets/skills/setup-haystack.md +639 -0
- package/dist/assets/skills/submit.md +154 -0
- package/dist/assets/templates/CLAUDE.md.snippet +42 -0
- package/dist/assets/templates/haystack.yml +193 -0
- package/dist/commands/check-pending.d.ts +19 -0
- package/dist/commands/check-pending.js +217 -0
- package/dist/commands/config.d.ts +18 -12
- package/dist/commands/config.js +327 -52
- package/dist/commands/hooks.d.ts +17 -0
- package/dist/commands/hooks.js +269 -0
- package/dist/commands/install-session-hooks.d.ts +16 -0
- package/dist/commands/install-session-hooks.js +302 -0
- package/dist/commands/login.js +1 -1
- package/dist/commands/policy.d.ts +31 -0
- package/dist/commands/policy.js +365 -0
- package/dist/commands/skills.d.ts +8 -0
- package/dist/commands/skills.js +80 -0
- package/dist/commands/submit.d.ts +22 -0
- package/dist/commands/submit.js +428 -0
- package/dist/commands/triage.d.ts +16 -0
- package/dist/commands/triage.js +354 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +317 -2
- package/dist/tools/detect.d.ts +50 -0
- package/dist/tools/detect.js +853 -0
- package/dist/tools/fixtures.d.ts +38 -0
- package/dist/tools/fixtures.js +199 -0
- package/dist/tools/setup.d.ts +43 -0
- package/dist/tools/setup.js +597 -0
- package/dist/triage/prompts.d.ts +31 -0
- package/dist/triage/prompts.js +266 -0
- package/dist/triage/runner.d.ts +21 -0
- package/dist/triage/runner.js +325 -0
- package/dist/triage/traces.d.ts +20 -0
- package/dist/triage/traces.js +305 -0
- package/dist/triage/types.d.ts +47 -0
- package/dist/triage/types.js +7 -0
- package/dist/types.d.ts +1387 -191
- package/dist/types.js +254 -2
- package/dist/utils/analysis-api.d.ts +108 -0
- package/dist/utils/analysis-api.js +194 -0
- package/dist/utils/config.js +1 -1
- package/dist/utils/git.d.ts +80 -0
- package/dist/utils/git.js +302 -0
- package/dist/utils/github-api.d.ts +83 -0
- package/dist/utils/github-api.js +266 -0
- package/dist/utils/hooks.d.ts +26 -0
- package/dist/utils/hooks.js +226 -0
- package/dist/utils/pending-state.d.ts +38 -0
- package/dist/utils/pending-state.js +86 -0
- package/dist/utils/secrets.js +3 -3
- package/dist/utils/skill.d.ts +1 -1
- package/dist/utils/skill.js +658 -1
- 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>;
|