@haystackeditor/cli 0.15.10 → 0.15.12
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/dist/commands/mcp.d.ts +1 -0
- package/dist/commands/mcp.js +185 -0
- package/dist/commands/pr.d.ts +10 -0
- package/dist/commands/pr.js +103 -0
- package/dist/commands/rules.d.ts +4 -0
- package/dist/commands/rules.js +98 -0
- package/dist/commands/schema-cmd.d.ts +2 -0
- package/dist/commands/schema-cmd.js +41 -0
- package/dist/commands/setup.js +62 -7
- package/dist/commands/submit.js +32 -4
- package/dist/commands/tokens.d.ts +14 -0
- package/dist/commands/tokens.js +184 -0
- package/dist/commands/triage.js +3 -2
- package/dist/commands/webhooks.d.ts +18 -0
- package/dist/commands/webhooks.js +210 -0
- package/dist/index.js +223 -4
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +20 -0
- package/dist/triage/runner.js +15 -5
- package/dist/utils/analysis-api.d.ts +12 -5
- package/dist/utils/analysis-api.js +155 -82
- package/dist/utils/auth.d.ts +29 -0
- package/dist/utils/auth.js +70 -2
- package/dist/utils/git.d.ts +36 -0
- package/dist/utils/git.js +113 -2
- package/dist/utils/prompter.js +4 -1
- package/package.json +6 -3
- package/schemas/pr.v1.json +34 -0
- package/schemas/setup.v1.json +21 -0
- package/schemas/triage.v1.json +50 -0
|
@@ -10,17 +10,54 @@
|
|
|
10
10
|
// ============================================================================
|
|
11
11
|
// Constants
|
|
12
12
|
// ============================================================================
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Analysis API base.
|
|
15
|
+
*
|
|
16
|
+
* IMPORTANT: this used to point directly at AWS API Gateway. That worked
|
|
17
|
+
* when the CLI carried a GitHub token (X-GitHub-Token header to the
|
|
18
|
+
* Lambda), but BROKE for `haystack login --headless` users — an
|
|
19
|
+
* hsk_live_* token would land on AWS as if it were a GH token, the
|
|
20
|
+
* Lambda would 401, and triage / pr / chat / MCP would silently return
|
|
21
|
+
* nothing. The right move is to route reads through the auth-worker,
|
|
22
|
+
* which:
|
|
23
|
+
*
|
|
24
|
+
* 1. Knows how to introspect hsk_live tokens (and GH tokens, agent JWTs,
|
|
25
|
+
* session cookies) — single normalized auth path.
|
|
26
|
+
* 2. Applies the hsk_live scope gate (denyForCliScope) before forwarding
|
|
27
|
+
* to AWS, so a repo-scoped CI token can't read other repos' synthesis.
|
|
28
|
+
* 3. Adds the right backend token (install token for org-installed reads,
|
|
29
|
+
* fallback otherwise) — so private-repo reads work without the CLI
|
|
30
|
+
* ever holding a GH token.
|
|
31
|
+
*
|
|
32
|
+
* Override with HAYSTACK_API_BASE for local dev (e.g., http://localhost:8788
|
|
33
|
+
* pointing at a local auth-worker).
|
|
34
|
+
*/
|
|
35
|
+
const ANALYSIS_API_BASE = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
|
|
36
|
+
/** Build the auth-worker URL for a v3 result file (rating_synthesis.json, etc). */
|
|
37
|
+
function v3ResultUrl(owner, repo, prNumber, file) {
|
|
38
|
+
// The auth-worker exposes `/api/analysis/{prIdentifier}/{file}` (see
|
|
39
|
+
// V3_API_PATTERNS in infra/auth-worker/index.js). prIdentifier format:
|
|
40
|
+
// owner/repo#prnum, URL-encoded as %2F + %23.
|
|
41
|
+
const prId = encodeURIComponent(`${owner}/${repo}#${prNumber}`);
|
|
42
|
+
return `${ANALYSIS_API_BASE}/api/analysis/${prId}${file ? `/${file}` : ''}`;
|
|
43
|
+
}
|
|
14
44
|
// ============================================================================
|
|
15
45
|
// API helpers
|
|
16
46
|
// ============================================================================
|
|
47
|
+
/**
|
|
48
|
+
* Build headers for an auth-worker-proxied analysis request.
|
|
49
|
+
*
|
|
50
|
+
* Auth-worker accepts hsk_live_*, GH tokens, agent JWTs, and session
|
|
51
|
+
* cookies on `Authorization: Bearer`. The legacy `X-GitHub-Token` header
|
|
52
|
+
* targeted AWS directly and is no longer used.
|
|
53
|
+
*/
|
|
17
54
|
function apiHeaders(token) {
|
|
18
55
|
const headers = {
|
|
19
56
|
'Cache-Control': 'no-cache',
|
|
20
57
|
'User-Agent': 'Haystack-CLI',
|
|
21
58
|
};
|
|
22
59
|
if (token) {
|
|
23
|
-
headers['
|
|
60
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
24
61
|
}
|
|
25
62
|
return headers;
|
|
26
63
|
}
|
|
@@ -32,7 +69,7 @@ function apiHeaders(token) {
|
|
|
32
69
|
* Returns 'ready' if complete, 'pending' if not yet available, or 'error' for server failures.
|
|
33
70
|
*/
|
|
34
71
|
export async function checkAnalysisReady(owner, repo, prNumber, token) {
|
|
35
|
-
const url =
|
|
72
|
+
const url = v3ResultUrl(owner, repo, prNumber);
|
|
36
73
|
let response;
|
|
37
74
|
try {
|
|
38
75
|
response = await fetch(url, {
|
|
@@ -57,97 +94,130 @@ export async function checkAnalysisReady(owner, repo, prNumber, token) {
|
|
|
57
94
|
return data.status === 'COMPLETED' ? { status: 'ready' } : { status: 'pending' };
|
|
58
95
|
}
|
|
59
96
|
/**
|
|
60
|
-
* Fetch analysis results once complete.
|
|
97
|
+
* Fetch analysis results once complete. Used by `haystack submit`'s
|
|
98
|
+
* wait-for-triage loop to render a verdict block.
|
|
99
|
+
*
|
|
100
|
+
* Verdict semantics — three distinct signals, never conflated:
|
|
101
|
+
*
|
|
102
|
+
* state = is the analysis itself clean?
|
|
103
|
+
* → 'good-to-merge' iff analysisVerdict === 'clean'
|
|
104
|
+
* AND !needsHumanReview. canAutoMerge is NOT
|
|
105
|
+
* the source of truth (it only means "analysis
|
|
106
|
+
* would let this through" — review policy,
|
|
107
|
+
* grace period, or a missing auto-merge label
|
|
108
|
+
* can still block).
|
|
109
|
+
* Falls back to haystackRating ≥ 5 only when
|
|
110
|
+
* analysisVerdict is absent (legacy payloads).
|
|
111
|
+
* needsHumanReview = preserved on the return shape so callers can
|
|
112
|
+
* distinguish "clean + needs human" from clean.
|
|
113
|
+
* autoMerged = was the PR ACTUALLY merged? Read from the
|
|
114
|
+
* GitHub REST endpoint, not from anything in
|
|
115
|
+
* the synthesis. canAutoMerge is eligibility,
|
|
116
|
+
* not outcome — never claim "auto-merged" from
|
|
117
|
+
* the analysis side.
|
|
118
|
+
*
|
|
119
|
+
* Sources from the v3 rating-synthesis path (auth-worker-proxied — so
|
|
120
|
+
* hsk_live tokens, GH tokens, agent JWTs, and cookies all work, and the
|
|
121
|
+
* scope gate runs). The legacy v2 bug-detection / rule-violation /
|
|
122
|
+
* merge-decision JSONs are no longer queried (no auth-worker proxy).
|
|
61
123
|
*/
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const bugResp = await fetch(bugUrl, {
|
|
71
|
-
headers: apiHeaders(token),
|
|
72
|
-
});
|
|
73
|
-
if (bugResp.ok) {
|
|
74
|
-
const bugData = await bugResp.json();
|
|
75
|
-
if (bugData.bugs) {
|
|
76
|
-
for (const bug of bugData.bugs) {
|
|
77
|
-
issues.push({
|
|
78
|
-
file: bug.file || 'unknown',
|
|
79
|
-
line: bug.line,
|
|
80
|
-
message: bug.description || 'Bug detected',
|
|
81
|
-
severity: bug.severity === 'critical' || bug.severity === 'high' ? 'error' : 'warning',
|
|
82
|
-
source: 'bug-detection',
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
// Bug detection not available — not fatal
|
|
90
|
-
}
|
|
91
|
-
// Fetch rule violations
|
|
124
|
+
async function isPrMerged(owner, repo, prNumber, token) {
|
|
125
|
+
// Authoritative merge check. We hit the same auth-worker proxy as the
|
|
126
|
+
// rest of the CLI so hsk_live / OAuth / install tokens all work, AND
|
|
127
|
+
// scope enforcement happens here too (a token not scoped to this repo
|
|
128
|
+
// would 403; we treat that as "unknown → not merged" rather than
|
|
129
|
+
// crashing the submit flow).
|
|
130
|
+
if (!token)
|
|
131
|
+
return false;
|
|
92
132
|
try {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
headers:
|
|
133
|
+
const base = process.env.HAYSTACK_API_BASE ?? 'https://haystackeditor.com';
|
|
134
|
+
const res = await fetch(`${base}/api/github/repos/${owner}/${repo}/pulls/${prNumber}`, {
|
|
135
|
+
headers: {
|
|
136
|
+
Accept: 'application/vnd.github+json',
|
|
137
|
+
Authorization: `Bearer ${token}`,
|
|
138
|
+
'User-Agent': 'Haystack-CLI',
|
|
139
|
+
},
|
|
96
140
|
});
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
issues.push({
|
|
102
|
-
file: v.file || 'unknown',
|
|
103
|
-
line: v.line,
|
|
104
|
-
message: v.message || `Rule violation: ${v.rule || 'unknown'}`,
|
|
105
|
-
severity: v.severity === 'error' ? 'error' : 'warning',
|
|
106
|
-
source: 'rule-violation',
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
141
|
+
if (!res.ok)
|
|
142
|
+
return false;
|
|
143
|
+
const data = (await res.json());
|
|
144
|
+
return data.merged === true;
|
|
111
145
|
}
|
|
112
146
|
catch {
|
|
113
|
-
|
|
147
|
+
return false;
|
|
114
148
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
}
|
|
150
|
+
export async function fetchAnalysisResults(owner, repo, prNumber, token) {
|
|
151
|
+
const reviewUrl = `https://haystackeditor.com/review/${owner}/${repo}/${prNumber}`;
|
|
152
|
+
const [synthesis, autoMerged] = await Promise.all([
|
|
153
|
+
fetchRatingSynthesis(owner, repo, prNumber, token),
|
|
154
|
+
isPrMerged(owner, repo, prNumber, token),
|
|
155
|
+
]);
|
|
156
|
+
if (!synthesis) {
|
|
157
|
+
return {
|
|
158
|
+
state: 'needs-input',
|
|
159
|
+
bugCount: 0,
|
|
160
|
+
warningCount: 0,
|
|
161
|
+
ruleViolationCount: 0,
|
|
162
|
+
summary: 'Analysis is ready but synthesis could not be loaded.',
|
|
163
|
+
issues: [],
|
|
164
|
+
reviewUrl,
|
|
165
|
+
autoMerged,
|
|
166
|
+
};
|
|
126
167
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
168
|
+
const display = synthesis.synthesisDisplay ?? [];
|
|
169
|
+
const issues = display.map((d) => ({
|
|
170
|
+
file: 'unknown', // synthesis findings don't always carry a path; the rich data lives on the feed
|
|
171
|
+
message: d.summary || d.detail || `Finding (${d.category})`,
|
|
172
|
+
severity: 'warning',
|
|
173
|
+
// Map the synthesis source onto the verdict's two-bucket model.
|
|
174
|
+
// Anything other than an explicit rule violation lands on bug-detection.
|
|
175
|
+
source: d.source === 'rule-violation' ? 'rule-violation' : 'bug-detection',
|
|
176
|
+
}));
|
|
177
|
+
const bugCount = issues.filter((i) => i.source === 'bug-detection').length;
|
|
178
|
+
const ruleViolationCount = issues.filter((i) => i.source === 'rule-violation').length;
|
|
179
|
+
const warningCount = issues.length;
|
|
180
|
+
// Three discrete signals, NEVER conflated:
|
|
181
|
+
// verdictKind : 'clean' | 'has-issues' | 'needs-review'
|
|
182
|
+
// (legacy payloads without analysisVerdict fall back
|
|
183
|
+
// to haystackRating ≥ 5 → 'clean')
|
|
184
|
+
// needsHumanReview: true if review policy demands a human, OR if the
|
|
185
|
+
// orchestrator escalated verdict to 'needs-review'
|
|
186
|
+
// (which carries the same semantic).
|
|
187
|
+
// analysisIsClean : true iff verdict is 'clean' OR 'needs-review'
|
|
188
|
+
// (both mean: no bugs/violations were found; the
|
|
189
|
+
// review-policy gate is the separate axis).
|
|
190
|
+
//
|
|
191
|
+
// good-to-merge = analysis clean AND no human required.
|
|
192
|
+
// 'has-issues' → state 'needs-input'.
|
|
193
|
+
// 'needs-review' → state 'needs-input' + needsHumanReview true.
|
|
194
|
+
// 'clean' + needsHumanReview → same as 'needs-review' (defensive — the
|
|
195
|
+
// orchestrator should already have escalated, but we handle the case
|
|
196
|
+
// where it didn't).
|
|
197
|
+
const verdictKind = synthesis.analysisVerdict
|
|
198
|
+
?? (synthesis.haystackRating >= 5 ? 'clean' : 'has-issues');
|
|
199
|
+
const analysisIsClean = verdictKind === 'clean' || verdictKind === 'needs-review';
|
|
200
|
+
const needsHumanReview = synthesis.needsHumanReview === true || verdictKind === 'needs-review';
|
|
201
|
+
const isGoodToMerge = analysisIsClean && !needsHumanReview;
|
|
202
|
+
const state = isGoodToMerge ? 'good-to-merge' : 'needs-input';
|
|
136
203
|
let summary;
|
|
137
204
|
if (state === 'good-to-merge') {
|
|
138
|
-
summary = autoMerged
|
|
205
|
+
summary = autoMerged
|
|
206
|
+
? 'No blocking issues. PR was merged.'
|
|
207
|
+
: 'No blocking issues. Safe to merge.';
|
|
208
|
+
}
|
|
209
|
+
else if (analysisIsClean && needsHumanReview) {
|
|
210
|
+
summary = 'Analysis is clean, but review policy requires a human reviewer.';
|
|
139
211
|
}
|
|
140
212
|
else {
|
|
141
213
|
const parts = [];
|
|
142
|
-
if (errorCount > 0)
|
|
143
|
-
parts.push(`${errorCount} error(s)`);
|
|
144
|
-
if (warningCount > 0)
|
|
145
|
-
parts.push(`${warningCount} warning(s)`);
|
|
146
214
|
if (bugCount > 0)
|
|
147
|
-
parts.push(`${bugCount}
|
|
215
|
+
parts.push(`${bugCount} finding(s)`);
|
|
148
216
|
if (ruleViolationCount > 0)
|
|
149
217
|
parts.push(`${ruleViolationCount} rule violation(s)`);
|
|
150
|
-
summary = parts.
|
|
218
|
+
summary = parts.length > 0
|
|
219
|
+
? parts.join(', ') + ' found.'
|
|
220
|
+
: `Rating ${synthesis.haystackRating}/5 — see the feed for details.`;
|
|
151
221
|
}
|
|
152
222
|
return {
|
|
153
223
|
state,
|
|
@@ -158,10 +228,11 @@ export async function fetchAnalysisResults(owner, repo, prNumber, token) {
|
|
|
158
228
|
issues,
|
|
159
229
|
reviewUrl,
|
|
160
230
|
autoMerged,
|
|
231
|
+
needsHumanReview,
|
|
161
232
|
};
|
|
162
233
|
}
|
|
163
234
|
export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
|
|
164
|
-
const url =
|
|
235
|
+
const url = v3ResultUrl(owner, repo, prNumber, 'auto-fix-manifest.json');
|
|
165
236
|
try {
|
|
166
237
|
const response = await fetch(url, {
|
|
167
238
|
headers: apiHeaders(token),
|
|
@@ -184,8 +255,10 @@ export async function fetchAutoFixManifest(owner, repo, prNumber, token) {
|
|
|
184
255
|
}
|
|
185
256
|
}
|
|
186
257
|
export async function fetchRatingSynthesis(owner, repo, prNumber, token) {
|
|
187
|
-
//
|
|
188
|
-
|
|
258
|
+
// Route through the auth-worker (/api/analysis/{prId}/rating_synthesis.json)
|
|
259
|
+
// so hsk_live tokens are introspected and the scope gate applies — see
|
|
260
|
+
// ANALYSIS_API_BASE comment above.
|
|
261
|
+
const url = v3ResultUrl(owner, repo, prNumber, 'rating_synthesis.json');
|
|
189
262
|
try {
|
|
190
263
|
const response = await fetch(url, {
|
|
191
264
|
headers: apiHeaders(token),
|
package/dist/utils/auth.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path to a long-lived `hsk_live_*` token created by `haystack login --headless`.
|
|
3
|
+
* If present (or HAYSTACK_CLI_TOKEN is set), it takes priority over the
|
|
4
|
+
* regular OAuth credentials. CI environments use this exclusively.
|
|
5
|
+
*
|
|
6
|
+
* Why a separate file (not just an entry in credentials.json):
|
|
7
|
+
* 1. Different lifecycle — the OAuth flow renews + refreshes; hsk_live
|
|
8
|
+
* tokens are mint-once-revoke-never until explicit revocation.
|
|
9
|
+
* 2. Easier to commit accidentally and easier to .gitignore (one file).
|
|
10
|
+
* 3. Different shape — hsk_live tokens carry server-side scope that the
|
|
11
|
+
* OAuth token doesn't, and we don't want to model both in one schema.
|
|
12
|
+
*/
|
|
13
|
+
export declare const HEADLESS_TOKEN_FILE: string;
|
|
14
|
+
interface HeadlessTokenFile {
|
|
15
|
+
token: string;
|
|
16
|
+
id?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
created_at?: string;
|
|
19
|
+
}
|
|
1
20
|
export interface StoredAccount {
|
|
2
21
|
github_token: string;
|
|
3
22
|
created_at: string;
|
|
@@ -46,5 +65,15 @@ export declare function removeAccount(login?: string): Promise<string | null>;
|
|
|
46
65
|
export declare function getActiveLogin(): Promise<string | null>;
|
|
47
66
|
export declare function getRepoDefaultAccount(owner: string, repo: string): Promise<string | null>;
|
|
48
67
|
export declare function setRepoDefaultAccount(owner: string, repo: string, login: string): Promise<void>;
|
|
68
|
+
/** Thrown by {@link resolveAuthContext} when no usable credential is found. */
|
|
69
|
+
export declare class NotLoggedInError extends Error {
|
|
70
|
+
constructor(message?: string);
|
|
71
|
+
}
|
|
72
|
+
/** Read the headless CLI token if one is configured. Env var wins over the
|
|
73
|
+
* on-disk file so CI can override without touching the home directory. */
|
|
74
|
+
export declare function readHeadlessToken(): Promise<HeadlessTokenFile | null>;
|
|
75
|
+
export declare function writeHeadlessToken(record: HeadlessTokenFile): Promise<string>;
|
|
76
|
+
export declare function clearHeadlessToken(): Promise<void>;
|
|
49
77
|
export declare function resolveAuthContext(options?: ResolveAuthOptions): Promise<ResolvedAuthContext>;
|
|
50
78
|
export declare function loadToken(options?: ResolveAuthOptions): Promise<string | null>;
|
|
79
|
+
export {};
|
package/dist/utils/auth.js
CHANGED
|
@@ -4,6 +4,20 @@ import * as path from 'path';
|
|
|
4
4
|
const CONFIG_DIR = path.join(os.homedir(), '.haystack');
|
|
5
5
|
const TOKEN_FILE = path.join(CONFIG_DIR, 'credentials.json');
|
|
6
6
|
const TOKEN_FILE_DISPLAY = '~/.haystack/credentials.json';
|
|
7
|
+
/**
|
|
8
|
+
* Path to a long-lived `hsk_live_*` token created by `haystack login --headless`.
|
|
9
|
+
* If present (or HAYSTACK_CLI_TOKEN is set), it takes priority over the
|
|
10
|
+
* regular OAuth credentials. CI environments use this exclusively.
|
|
11
|
+
*
|
|
12
|
+
* Why a separate file (not just an entry in credentials.json):
|
|
13
|
+
* 1. Different lifecycle — the OAuth flow renews + refreshes; hsk_live
|
|
14
|
+
* tokens are mint-once-revoke-never until explicit revocation.
|
|
15
|
+
* 2. Easier to commit accidentally and easier to .gitignore (one file).
|
|
16
|
+
* 3. Different shape — hsk_live tokens carry server-side scope that the
|
|
17
|
+
* OAuth token doesn't, and we don't want to model both in one schema.
|
|
18
|
+
*/
|
|
19
|
+
export const HEADLESS_TOKEN_FILE = path.join(CONFIG_DIR, 'cli-token');
|
|
20
|
+
const HEADLESS_TOKEN_FILE_DISPLAY = '~/.haystack/cli-token';
|
|
7
21
|
const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}$/;
|
|
8
22
|
function emptyStore() {
|
|
9
23
|
return {
|
|
@@ -337,7 +351,60 @@ export async function setRepoDefaultAccount(owner, repo, login) {
|
|
|
337
351
|
store.repoDefaults[key] = storedLogin;
|
|
338
352
|
await saveStore(store);
|
|
339
353
|
}
|
|
354
|
+
/** Thrown by {@link resolveAuthContext} when no usable credential is found. */
|
|
355
|
+
export class NotLoggedInError extends Error {
|
|
356
|
+
constructor(message = 'Not logged in. Run `haystack login` first.') {
|
|
357
|
+
super(message);
|
|
358
|
+
this.name = 'NotLoggedInError';
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Read the headless CLI token if one is configured. Env var wins over the
|
|
362
|
+
* on-disk file so CI can override without touching the home directory. */
|
|
363
|
+
export async function readHeadlessToken() {
|
|
364
|
+
const envToken = process.env.HAYSTACK_CLI_TOKEN;
|
|
365
|
+
if (envToken && envToken.startsWith('hsk_live_')) {
|
|
366
|
+
return { token: envToken, label: 'env:HAYSTACK_CLI_TOKEN' };
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const raw = await fs.readFile(HEADLESS_TOKEN_FILE, 'utf8');
|
|
370
|
+
const parsed = JSON.parse(raw);
|
|
371
|
+
if (typeof parsed.token === 'string' && parsed.token.startsWith('hsk_live_')) {
|
|
372
|
+
return parsed;
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
export async function writeHeadlessToken(record) {
|
|
381
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
382
|
+
// 0600 so other users on a shared machine can't read the token. The file
|
|
383
|
+
// contains a long-lived credential — mode matters here in a way that doesn't
|
|
384
|
+
// for credentials.json, which can be re-minted via OAuth at any time.
|
|
385
|
+
await fs.writeFile(HEADLESS_TOKEN_FILE, JSON.stringify(record, null, 2) + '\n', {
|
|
386
|
+
mode: 0o600,
|
|
387
|
+
});
|
|
388
|
+
return HEADLESS_TOKEN_FILE_DISPLAY;
|
|
389
|
+
}
|
|
390
|
+
export async function clearHeadlessToken() {
|
|
391
|
+
try {
|
|
392
|
+
await fs.unlink(HEADLESS_TOKEN_FILE);
|
|
393
|
+
}
|
|
394
|
+
catch { /* not present is fine */ }
|
|
395
|
+
}
|
|
340
396
|
export async function resolveAuthContext(options = {}) {
|
|
397
|
+
// hsk_live tokens win unconditionally. They were created explicitly by
|
|
398
|
+
// the operator with `haystack login --headless` (or set via env in CI)
|
|
399
|
+
// — that intent should not be overridden by a stale OAuth cookie.
|
|
400
|
+
const headless = await readHeadlessToken();
|
|
401
|
+
if (headless) {
|
|
402
|
+
return {
|
|
403
|
+
login: headless.label ?? 'headless',
|
|
404
|
+
token: headless.token,
|
|
405
|
+
source: 'active',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
341
408
|
const store = await loadCredentialsStore();
|
|
342
409
|
const { preferredLogin, owner, repo } = options;
|
|
343
410
|
if (preferredLogin) {
|
|
@@ -370,7 +437,7 @@ export async function resolveAuthContext(options = {}) {
|
|
|
370
437
|
source: 'active',
|
|
371
438
|
};
|
|
372
439
|
}
|
|
373
|
-
throw new
|
|
440
|
+
throw new NotLoggedInError();
|
|
374
441
|
}
|
|
375
442
|
export async function loadToken(options = {}) {
|
|
376
443
|
try {
|
|
@@ -378,7 +445,8 @@ export async function loadToken(options = {}) {
|
|
|
378
445
|
return context.token;
|
|
379
446
|
}
|
|
380
447
|
catch (error) {
|
|
381
|
-
|
|
448
|
+
// Classify by type, not message text (see pr-rules.yml PR007).
|
|
449
|
+
if (error instanceof NotLoggedInError) {
|
|
382
450
|
return null;
|
|
383
451
|
}
|
|
384
452
|
throw error;
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -68,6 +68,10 @@ export interface PushAuth {
|
|
|
68
68
|
* Without `auth`, falls back to `git push -u <remote>` using ambient creds.
|
|
69
69
|
*/
|
|
70
70
|
export declare function pushBranch(remoteName: string, branchName: string, auth?: PushAuth): void;
|
|
71
|
+
/** Thrown when a git remote URL is not a recognized GitHub SSH/HTTPS URL. */
|
|
72
|
+
export declare class UnsupportedRemoteUrlError extends Error {
|
|
73
|
+
constructor(url: string);
|
|
74
|
+
}
|
|
71
75
|
/**
|
|
72
76
|
* Parse remote URL to extract owner and repo
|
|
73
77
|
* Supports both SSH and HTTPS URLs
|
|
@@ -86,6 +90,38 @@ export declare function getLastCommitSha(): string;
|
|
|
86
90
|
* Returns the combined log, or empty string if no commits or on error.
|
|
87
91
|
*/
|
|
88
92
|
export declare function getCommitMessagesSinceBase(baseBranch: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the git ref to diff a PR branch against for review.
|
|
95
|
+
*
|
|
96
|
+
* A bare local `<base>` ref is frequently stale (not checked out or pulled in a
|
|
97
|
+
* while). When it sits behind HEAD's true fork point, the three-dot merge-base
|
|
98
|
+
* jumps far back in history and inflates the review diff to include code from
|
|
99
|
+
* already-merged PRs — reviewers then mis-attribute pre-existing code to this
|
|
100
|
+
* PR. So we prefer the freshly-fetched remote base. This mirrors
|
|
101
|
+
* getCommitMessagesSinceBase, which already compares against the remote base.
|
|
102
|
+
*
|
|
103
|
+
* Returns a fully-qualified ref (`refs/remotes/origin/<base>` or
|
|
104
|
+
* `refs/heads/<base>`) so a tag that happens to share the name can never shadow
|
|
105
|
+
* the ref we mean — `git rev-parse <name>` prefers refs/tags/* over both
|
|
106
|
+
* refs/heads/* and refs/remotes/*. The bare `<base>` name is only returned as a
|
|
107
|
+
* last resort when nothing qualified resolves (the eventual `git diff` then
|
|
108
|
+
* errors and the caller falls back to running it itself).
|
|
109
|
+
*
|
|
110
|
+
* Resolution:
|
|
111
|
+
* 1. Best-effort fetch the remote base (`haystack submit` is already online —
|
|
112
|
+
* it pushes and opens a PR — so one ref fetch is cheap).
|
|
113
|
+
* 2. If the fetch succeeded, the remote-tracking ref is the authoritative
|
|
114
|
+
* remote tip — use it (even over a local <base> with unpushed commits; the
|
|
115
|
+
* PR will merge into the remote base, not the local one).
|
|
116
|
+
* 3. If the fetch failed (offline, private origin without ambient git creds,
|
|
117
|
+
* base not on remote), don't blindly trust a possibly-stale *cached*
|
|
118
|
+
* remote-tracking ref — pick whichever of it and local <base> is more
|
|
119
|
+
* advanced (the one that is not an ancestor of the other), since a base
|
|
120
|
+
* behind HEAD's fork point is exactly what balloons the diff.
|
|
121
|
+
* 4. Fall back to whatever single qualified ref resolves, then the bare
|
|
122
|
+
* <base> name (preserves prior behavior rather than breaking triage).
|
|
123
|
+
*/
|
|
124
|
+
export declare function resolveDiffBaseRef(baseBranch: string): string;
|
|
89
125
|
/**
|
|
90
126
|
* Get the number of commits ahead of base branch
|
|
91
127
|
*/
|
package/dist/utils/git.js
CHANGED
|
@@ -257,6 +257,13 @@ function translatePushError(err, repoSlug) {
|
|
|
257
257
|
// ============================================================================
|
|
258
258
|
// Remote operations
|
|
259
259
|
// ============================================================================
|
|
260
|
+
/** Thrown when a git remote URL is not a recognized GitHub SSH/HTTPS URL. */
|
|
261
|
+
export class UnsupportedRemoteUrlError extends Error {
|
|
262
|
+
constructor(url) {
|
|
263
|
+
super(`Unsupported remote URL format: ${url}`);
|
|
264
|
+
this.name = 'UnsupportedRemoteUrlError';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
260
267
|
/**
|
|
261
268
|
* Parse remote URL to extract owner and repo
|
|
262
269
|
* Supports both SSH and HTTPS URLs
|
|
@@ -285,10 +292,13 @@ export function parseRemoteUrl(remoteName = 'origin') {
|
|
|
285
292
|
remoteName,
|
|
286
293
|
};
|
|
287
294
|
}
|
|
288
|
-
throw new
|
|
295
|
+
throw new UnsupportedRemoteUrlError(url);
|
|
289
296
|
}
|
|
290
297
|
catch (err) {
|
|
291
|
-
|
|
298
|
+
// Propagate the "unrecognized URL" case as-is; wrap everything else (the
|
|
299
|
+
// `git remote get-url` command itself failing) in a friendlier message.
|
|
300
|
+
// Classify by type, not message text (see pr-rules.yml PR007).
|
|
301
|
+
if (err instanceof UnsupportedRemoteUrlError) {
|
|
292
302
|
throw err;
|
|
293
303
|
}
|
|
294
304
|
throw new Error(`Failed to get remote URL for '${remoteName}'`);
|
|
@@ -356,6 +366,107 @@ export function getCommitMessagesSinceBase(baseBranch) {
|
|
|
356
366
|
}
|
|
357
367
|
}
|
|
358
368
|
}
|
|
369
|
+
/** True if `ref` resolves to a commit in the current repo. */
|
|
370
|
+
function gitRefResolves(ref) {
|
|
371
|
+
try {
|
|
372
|
+
execSync(`git rev-parse --verify --quiet ${ref}^{commit}`, {
|
|
373
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
374
|
+
});
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** True if `ancestor` is an ancestor of (or equal to) `descendant`. */
|
|
382
|
+
function gitIsAncestor(ancestor, descendant) {
|
|
383
|
+
try {
|
|
384
|
+
// exit 0 => ancestor; exit 1 => not; both non-throwing for us via stdio pipe
|
|
385
|
+
execSync(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
|
|
386
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
387
|
+
});
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Resolve the git ref to diff a PR branch against for review.
|
|
396
|
+
*
|
|
397
|
+
* A bare local `<base>` ref is frequently stale (not checked out or pulled in a
|
|
398
|
+
* while). When it sits behind HEAD's true fork point, the three-dot merge-base
|
|
399
|
+
* jumps far back in history and inflates the review diff to include code from
|
|
400
|
+
* already-merged PRs — reviewers then mis-attribute pre-existing code to this
|
|
401
|
+
* PR. So we prefer the freshly-fetched remote base. This mirrors
|
|
402
|
+
* getCommitMessagesSinceBase, which already compares against the remote base.
|
|
403
|
+
*
|
|
404
|
+
* Returns a fully-qualified ref (`refs/remotes/origin/<base>` or
|
|
405
|
+
* `refs/heads/<base>`) so a tag that happens to share the name can never shadow
|
|
406
|
+
* the ref we mean — `git rev-parse <name>` prefers refs/tags/* over both
|
|
407
|
+
* refs/heads/* and refs/remotes/*. The bare `<base>` name is only returned as a
|
|
408
|
+
* last resort when nothing qualified resolves (the eventual `git diff` then
|
|
409
|
+
* errors and the caller falls back to running it itself).
|
|
410
|
+
*
|
|
411
|
+
* Resolution:
|
|
412
|
+
* 1. Best-effort fetch the remote base (`haystack submit` is already online —
|
|
413
|
+
* it pushes and opens a PR — so one ref fetch is cheap).
|
|
414
|
+
* 2. If the fetch succeeded, the remote-tracking ref is the authoritative
|
|
415
|
+
* remote tip — use it (even over a local <base> with unpushed commits; the
|
|
416
|
+
* PR will merge into the remote base, not the local one).
|
|
417
|
+
* 3. If the fetch failed (offline, private origin without ambient git creds,
|
|
418
|
+
* base not on remote), don't blindly trust a possibly-stale *cached*
|
|
419
|
+
* remote-tracking ref — pick whichever of it and local <base> is more
|
|
420
|
+
* advanced (the one that is not an ancestor of the other), since a base
|
|
421
|
+
* behind HEAD's fork point is exactly what balloons the diff.
|
|
422
|
+
* 4. Fall back to whatever single qualified ref resolves, then the bare
|
|
423
|
+
* <base> name (preserves prior behavior rather than breaking triage).
|
|
424
|
+
*/
|
|
425
|
+
export function resolveDiffBaseRef(baseBranch) {
|
|
426
|
+
const remoteRef = `refs/remotes/origin/${baseBranch}`;
|
|
427
|
+
const localRef = `refs/heads/${baseBranch}`;
|
|
428
|
+
// Freshen the remote base so the merge-base reflects the true fork point.
|
|
429
|
+
// Fetch with a fully-qualified refspec rather than a bare
|
|
430
|
+
// `git fetch origin <base>`:
|
|
431
|
+
// - Explicit destination: in clones whose configured fetch refspec does
|
|
432
|
+
// not cover <base> — a `--single-branch` clone, or `haystack submit
|
|
433
|
+
// --base release/x` — a bare fetch updates only FETCH_HEAD and leaves
|
|
434
|
+
// refs/remotes/origin/<base> stale or absent.
|
|
435
|
+
// - Qualified source (`refs/heads/<base>`): if the remote has both a
|
|
436
|
+
// branch and a tag named <base> (e.g. a `release` / `v1.0` base), an
|
|
437
|
+
// unqualified source could resolve to the tag and force the
|
|
438
|
+
// remote-tracking ref to it, making triage diff against the tag.
|
|
439
|
+
// `+` force-updates the remote-tracking ref to the remote tip (correct even
|
|
440
|
+
// if <base> was rewound on the remote).
|
|
441
|
+
let fetched = false;
|
|
442
|
+
try {
|
|
443
|
+
execSync(`git fetch origin +refs/heads/${baseBranch}:${remoteRef}`, {
|
|
444
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
445
|
+
});
|
|
446
|
+
fetched = true;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// Offline / no creds / base not on remote — fall through to degraded
|
|
450
|
+
// selection below using whatever refs already exist locally.
|
|
451
|
+
}
|
|
452
|
+
const remoteResolves = gitRefResolves(remoteRef);
|
|
453
|
+
// Happy path: we just refreshed the remote base, so it's authoritative.
|
|
454
|
+
if (fetched && remoteResolves)
|
|
455
|
+
return remoteRef;
|
|
456
|
+
// Degraded path: fetch failed (or didn't produce the ref). Prefer the more
|
|
457
|
+
// up-to-date of the cached remote ref and the local branch; only a base that
|
|
458
|
+
// lags HEAD's fork point balloons the diff, so pick the one that's ahead.
|
|
459
|
+
const localResolves = gitRefResolves(localRef);
|
|
460
|
+
if (remoteResolves && localResolves) {
|
|
461
|
+
// If the remote ref is an ancestor of the local ref, local is ahead → use it.
|
|
462
|
+
return gitIsAncestor(remoteRef, localRef) ? localRef : remoteRef;
|
|
463
|
+
}
|
|
464
|
+
if (remoteResolves)
|
|
465
|
+
return remoteRef;
|
|
466
|
+
if (localResolves)
|
|
467
|
+
return localRef;
|
|
468
|
+
return baseBranch;
|
|
469
|
+
}
|
|
359
470
|
/**
|
|
360
471
|
* Get the number of commits ahead of base branch
|
|
361
472
|
*/
|
package/dist/utils/prompter.js
CHANGED
|
@@ -139,7 +139,10 @@ class JsonPrompter {
|
|
|
139
139
|
return id in this.answers;
|
|
140
140
|
}
|
|
141
141
|
emit(event) {
|
|
142
|
-
|
|
142
|
+
// Stamp every NDJSON event with the setup schema version so agent
|
|
143
|
+
// consumers can pin a contract. See docs/SPEC-CLI-FIRST-CLASS.md.
|
|
144
|
+
const stamped = { schema_version: '1.0.0', ...event };
|
|
145
|
+
process.stdout.write(JSON.stringify(stamped) + '\n');
|
|
143
146
|
}
|
|
144
147
|
/** Lazily open stdin only when we actually need an answer the agent must send. */
|
|
145
148
|
ensureStdin() {
|