@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.
- package/README.md +93 -87
- package/dist/assets/hooks/llm-rules-template.md +21 -0
- package/dist/assets/hooks/package.json +2 -2
- package/dist/assets/hooks/scripts/pre-push.sh +20 -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 +13 -21
- package/dist/commands/config.js +278 -92
- package/dist/commands/dismiss.d.ts +17 -0
- package/dist/commands/dismiss.js +201 -0
- package/dist/commands/init.js +25 -28
- 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/pr-status.d.ts +16 -0
- package/dist/commands/pr-status.js +188 -0
- package/dist/commands/setup.d.ts +13 -0
- package/dist/commands/setup.js +496 -0
- package/dist/commands/skills.d.ts +2 -2
- package/dist/commands/skills.js +51 -186
- package/dist/commands/submit.d.ts +23 -0
- package/dist/commands/submit.js +456 -0
- package/dist/commands/triage.d.ts +16 -0
- package/dist/commands/triage.js +354 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +344 -4
- 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 +296 -0
- package/dist/triage/runner.d.ts +21 -0
- package/dist/triage/runner.js +339 -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/pending-state.d.ts +40 -0
- package/dist/utils/pending-state.js +86 -0
- package/dist/utils/secrets.js +3 -3
- package/dist/utils/skill.js +257 -0
- package/package.json +11 -9
|
@@ -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,40 @@
|
|
|
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 type { AnalysisVerdict } from './analysis-api.js';
|
|
9
|
+
export interface PendingSubmit {
|
|
10
|
+
version: 1;
|
|
11
|
+
prNumber: number;
|
|
12
|
+
prUrl: string;
|
|
13
|
+
owner: string;
|
|
14
|
+
repo: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
baseBranch: string;
|
|
17
|
+
submittedAt: string;
|
|
18
|
+
status: 'polling' | 'completed' | 'failed' | 'stale';
|
|
19
|
+
analysisResult?: AnalysisVerdict;
|
|
20
|
+
resolvedAt?: string;
|
|
21
|
+
autoFix?: boolean;
|
|
22
|
+
autoFixStatus?: 'pending' | 'fixing' | 'fixed' | 'failed' | 'timed_out';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Save pending submit state to disk.
|
|
26
|
+
*/
|
|
27
|
+
export declare function savePendingSubmit(state: PendingSubmit, gitRoot?: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Load pending submit state from disk.
|
|
30
|
+
* Returns null if no pending state exists or if it's stale.
|
|
31
|
+
*/
|
|
32
|
+
export declare function loadPendingSubmit(gitRoot?: string): PendingSubmit | null;
|
|
33
|
+
/**
|
|
34
|
+
* Clear pending submit state (delete the file).
|
|
35
|
+
*/
|
|
36
|
+
export declare function clearPendingSubmit(gitRoot?: string): void;
|
|
37
|
+
/**
|
|
38
|
+
* Update the status and optionally the analysis result of a pending submit.
|
|
39
|
+
*/
|
|
40
|
+
export declare function updatePendingSubmit(updates: Partial<Pick<PendingSubmit, 'status' | 'analysisResult' | 'resolvedAt'>>, gitRoot?: string): void;
|