@haystackeditor/cli 0.15.10 → 0.15.11
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 +13 -2
- 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/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 +4 -0
- package/dist/utils/git.js +12 -2
- package/dist/utils/prompter.js +4 -1
- package/package.json +4 -2
- package/schemas/pr.v1.json +34 -0
- package/schemas/setup.v1.json +21 -0
- package/schemas/triage.v1.json +50 -0
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { dirname, join } from 'node:path';
|
|
25
|
+
import chalk from 'chalk';
|
|
25
26
|
import { Command } from 'commander';
|
|
26
27
|
import { statusCommand } from './commands/status.js';
|
|
27
28
|
import { initCommand } from './commands/init.js';
|
|
@@ -37,6 +38,16 @@ import { dismissCommand, markReviewedCommand, undismissCommand } from './command
|
|
|
37
38
|
import { requestReviewCommand } from './commands/request-review.js';
|
|
38
39
|
import { prStatusCommand } from './commands/pr-status.js';
|
|
39
40
|
import { setupCommand } from './commands/setup.js';
|
|
41
|
+
import { schemaCommand, listSchemas } from './commands/schema-cmd.js';
|
|
42
|
+
import { registerWebhook, listWebhooks, rotateWebhookSecret, setWebhookEnabled, listDeliveries, replayDelivery, } from './commands/webhooks.js';
|
|
43
|
+
import { editRulesCommand, validateRulesCommand } from './commands/rules.js';
|
|
44
|
+
import { headlessLoginCommand, headlessLogoutCommand, listTokensCommand, revokeTokenCommand, } from './commands/tokens.js';
|
|
45
|
+
import { runMcpServer } from './commands/mcp.js';
|
|
46
|
+
// pr-state read helpers (parsePrRef, prReadCommand) are imported by the MCP
|
|
47
|
+
// server directly from ./commands/pr.js; no top-level CLI command is wired
|
|
48
|
+
// here. The previous `pr <ref> [show|merge|snooze|dismiss]` and `fix <ref>`
|
|
49
|
+
// surfaces were scope creep on the CLI-as-platform spec; `haystack pr-status`
|
|
50
|
+
// already covered the read use case.
|
|
40
51
|
/** Read the published version from package.json (dist/index.js → ../package.json)
|
|
41
52
|
* so `haystack --version` can't drift from the package version. Falls back
|
|
42
53
|
* gracefully so the flag never throws if the file can't be read. */
|
|
@@ -58,6 +69,16 @@ program
|
|
|
58
69
|
.name('haystack')
|
|
59
70
|
.description('Haystack CLI — automated PR review, triage, and merge queue')
|
|
60
71
|
.version(getVersion());
|
|
72
|
+
program
|
|
73
|
+
.command('schema [name]')
|
|
74
|
+
.description('Print the JSON Schema for a command\'s --json output (triage, setup, inbox, pr, chat)')
|
|
75
|
+
.action((name) => {
|
|
76
|
+
if (!name) {
|
|
77
|
+
listSchemas();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
schemaCommand(name);
|
|
81
|
+
});
|
|
61
82
|
program
|
|
62
83
|
.command('init')
|
|
63
84
|
.description('Create .haystack.json configuration')
|
|
@@ -122,12 +143,68 @@ program
|
|
|
122
143
|
.action(statusCommand);
|
|
123
144
|
program
|
|
124
145
|
.command('login')
|
|
125
|
-
.description('Add or refresh a saved GitHub account')
|
|
126
|
-
.
|
|
146
|
+
.description('Add or refresh a saved GitHub account (use --headless for CI tokens)')
|
|
147
|
+
.option('--headless', 'Mint a long-lived hsk_live_* token instead of an interactive OAuth session')
|
|
148
|
+
.option('--label <name>', '(--headless) Label to display in the dashboard for this token')
|
|
149
|
+
.option('--scope <repos>', '(--headless) Comma-separated repos to scope the token to (or "all")', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
|
|
150
|
+
.option('--token <hsk_live_...>', '(--headless) Skip the browser handshake — paste a token minted in the dashboard')
|
|
151
|
+
.action(async (opts) => {
|
|
152
|
+
if (opts.headless) {
|
|
153
|
+
try {
|
|
154
|
+
await headlessLoginCommand({ label: opts.label, scope: opts.scope, token: opts.token });
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.error(chalk.red('headless login failed:'), err instanceof Error ? err.message : err);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
return loginCommand();
|
|
163
|
+
});
|
|
127
164
|
program
|
|
128
165
|
.command('logout [login]')
|
|
129
|
-
.description('Remove a saved GitHub account (
|
|
130
|
-
.
|
|
166
|
+
.description('Remove a saved GitHub account (use --headless to remove the local CLI token)')
|
|
167
|
+
.option('--headless', 'Remove the local hsk_live_* token file (does not revoke server-side — use `haystack tokens revoke <id>`)')
|
|
168
|
+
.action(async (login, opts) => {
|
|
169
|
+
if (opts.headless) {
|
|
170
|
+
try {
|
|
171
|
+
await headlessLogoutCommand();
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
console.error(chalk.red('headless logout failed:'), err instanceof Error ? err.message : err);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
return logoutCommand(login);
|
|
180
|
+
});
|
|
181
|
+
// ─── tokens (server-side hsk_live_*) ─────────────────────────────────────────
|
|
182
|
+
const tokens = program.command('tokens').description('Manage server-side hsk_live_* tokens');
|
|
183
|
+
tokens
|
|
184
|
+
.command('list')
|
|
185
|
+
.description('List your CLI tokens')
|
|
186
|
+
.option('--json', 'Machine-readable output')
|
|
187
|
+
.action(async (opts) => {
|
|
188
|
+
try {
|
|
189
|
+
await listTokensCommand(opts);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
console.error(chalk.red('list failed:'), err instanceof Error ? err.message : err);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
tokens
|
|
197
|
+
.command('revoke <id>')
|
|
198
|
+
.description('Revoke a token by id (cannot be undone)')
|
|
199
|
+
.action(async (id) => {
|
|
200
|
+
try {
|
|
201
|
+
await revokeTokenCommand(id);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.error(chalk.red('revoke failed:'), err instanceof Error ? err.message : err);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
131
208
|
program
|
|
132
209
|
.command('whoami')
|
|
133
210
|
.description('Show the active Haystack GitHub account')
|
|
@@ -606,6 +683,148 @@ The generated policies appear in Haystack's "Human Review Needed"
|
|
|
606
683
|
section when a PR touches matching files.
|
|
607
684
|
`)
|
|
608
685
|
.action(initPolicies);
|
|
686
|
+
// ─── webhooks ────────────────────────────────────────────────────────────────
|
|
687
|
+
//
|
|
688
|
+
// `haystack webhooks ...` — register outbound webhook subscriptions and inspect
|
|
689
|
+
// delivery status. Talks to the haystack-webhooks worker.
|
|
690
|
+
//
|
|
691
|
+
// See docs/SPEC-CLI-FIRST-CLASS.md §1 for the wire protocol (HMAC, retries,
|
|
692
|
+
// idempotency).
|
|
693
|
+
const webhooks = program
|
|
694
|
+
.command('webhooks')
|
|
695
|
+
.description('Manage outbound webhooks (events: triage.completed, fixer.completed, ...)');
|
|
696
|
+
webhooks
|
|
697
|
+
.command('register')
|
|
698
|
+
.description('Register a webhook URL for the current repo and mint a fresh HMAC secret')
|
|
699
|
+
.requiredOption('-u, --url <url>', 'Receiver URL (https only)')
|
|
700
|
+
.option('-e, --events <events>', 'Comma-separated event list', (val) => val.split(',').map((s) => s.trim()).filter(Boolean))
|
|
701
|
+
.option('--secret-env <name>', 'Environment-variable name where you\'ll stash the secret (recorded in .haystack.json)')
|
|
702
|
+
.option('--repo <owner/name>', 'Override the inferred repo')
|
|
703
|
+
.action(async (opts) => {
|
|
704
|
+
try {
|
|
705
|
+
await registerWebhook(opts);
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
console.error(chalk.red('register failed:'), err instanceof Error ? err.message : err);
|
|
709
|
+
process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
webhooks
|
|
713
|
+
.command('list')
|
|
714
|
+
.description('List active webhook registrations for the current repo')
|
|
715
|
+
.option('--repo <owner/name>', 'Override the inferred repo')
|
|
716
|
+
.option('--json', 'Machine-readable JSON output')
|
|
717
|
+
.action(async (opts) => {
|
|
718
|
+
try {
|
|
719
|
+
await listWebhooks(opts);
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
console.error(chalk.red('list failed:'), err instanceof Error ? err.message : err);
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
webhooks
|
|
727
|
+
.command('rotate-secret <id>')
|
|
728
|
+
.description('Rotate the HMAC secret for a registration (prints the new secret once)')
|
|
729
|
+
.action(async (id) => {
|
|
730
|
+
try {
|
|
731
|
+
await rotateWebhookSecret(id);
|
|
732
|
+
}
|
|
733
|
+
catch (err) {
|
|
734
|
+
console.error(chalk.red('rotate-secret failed:'), err instanceof Error ? err.message : err);
|
|
735
|
+
process.exit(1);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
webhooks
|
|
739
|
+
.command('disable <id>')
|
|
740
|
+
.description('Disable a registration (stops delivery; existing in-flight retries continue)')
|
|
741
|
+
.action(async (id) => {
|
|
742
|
+
try {
|
|
743
|
+
await setWebhookEnabled(id, false);
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
console.error(chalk.red('disable failed:'), err instanceof Error ? err.message : err);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
webhooks
|
|
751
|
+
.command('enable <id>')
|
|
752
|
+
.description('Re-enable a previously disabled registration')
|
|
753
|
+
.action(async (id) => {
|
|
754
|
+
try {
|
|
755
|
+
await setWebhookEnabled(id, true);
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
console.error(chalk.red('enable failed:'), err instanceof Error ? err.message : err);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
webhooks
|
|
763
|
+
.command('deliveries')
|
|
764
|
+
.description('Show recent webhook delivery attempts for the current repo')
|
|
765
|
+
.option('--repo <owner/name>', 'Override the inferred repo')
|
|
766
|
+
.option('--limit <n>', 'Number of deliveries (default 20)', (v) => parseInt(v, 10))
|
|
767
|
+
.option('--json', 'Machine-readable JSON output')
|
|
768
|
+
.action(async (opts) => {
|
|
769
|
+
try {
|
|
770
|
+
await listDeliveries(opts);
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
console.error(chalk.red('deliveries failed:'), err instanceof Error ? err.message : err);
|
|
774
|
+
process.exit(1);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
webhooks
|
|
778
|
+
.command('replay <id>')
|
|
779
|
+
.description('Replay a previous delivery (idempotent via X-Haystack-Delivery)')
|
|
780
|
+
.action(async (id) => {
|
|
781
|
+
try {
|
|
782
|
+
await replayDelivery(id);
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
console.error(chalk.red('replay failed:'), err instanceof Error ? err.message : err);
|
|
786
|
+
process.exit(1);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
// ─── rules ───────────────────────────────────────────────────────────────────
|
|
790
|
+
const rules = program.command('rules').description('Manage .haystack/pr-rules.yml');
|
|
791
|
+
rules
|
|
792
|
+
.command('edit')
|
|
793
|
+
.description('Open .haystack/pr-rules.yml in $EDITOR (creates a starter if missing)')
|
|
794
|
+
.action(async () => {
|
|
795
|
+
try {
|
|
796
|
+
await editRulesCommand();
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
console.error(chalk.red('edit failed:'), err instanceof Error ? err.message : err);
|
|
800
|
+
process.exit(1);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
rules
|
|
804
|
+
.command('validate')
|
|
805
|
+
.description('Parse pr-rules.yml with the same parser the server uses')
|
|
806
|
+
.action(async () => {
|
|
807
|
+
try {
|
|
808
|
+
await validateRulesCommand();
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
console.error(chalk.red('validate failed:'), err instanceof Error ? err.message : err);
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
// ─── mcp ─────────────────────────────────────────────────────────────────────
|
|
816
|
+
program
|
|
817
|
+
.command('mcp')
|
|
818
|
+
.description('Run a stdio MCP server exposing Haystack tools to Claude Code, Cursor, etc.')
|
|
819
|
+
.action(async () => {
|
|
820
|
+
try {
|
|
821
|
+
await runMcpServer();
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
console.error(chalk.red('mcp server failed:'), err instanceof Error ? err.message : err);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
609
828
|
// Show help if no command provided
|
|
610
829
|
if (process.argv.length === 2) {
|
|
611
830
|
program.help();
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable machine-readable schema versions for each `--json` output.
|
|
3
|
+
*
|
|
4
|
+
* Bump semantics (per docs/SPEC-CLI-FIRST-CLASS.md):
|
|
5
|
+
* patch — additive fields only
|
|
6
|
+
* minor — field deprecated (kept emitting for one minor with _deprecated: true)
|
|
7
|
+
* major — removal / rename
|
|
8
|
+
*
|
|
9
|
+
* One schema, two transports: every webhook payload for the corresponding noun
|
|
10
|
+
* uses the same envelope as the `--json` output, byte-for-byte.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SCHEMA_VERSIONS: {
|
|
13
|
+
readonly triage: "1.0.0";
|
|
14
|
+
readonly setup: "1.0.0";
|
|
15
|
+
readonly pr: "1.0.0";
|
|
16
|
+
};
|
|
17
|
+
export type SchemaName = keyof typeof SCHEMA_VERSIONS;
|
|
18
|
+
/** Wrap a payload with the `schema_version` envelope. */
|
|
19
|
+
export declare function withSchema<T extends Record<string, unknown>>(name: SchemaName, payload: T): T & {
|
|
20
|
+
schema_version: string;
|
|
21
|
+
};
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable machine-readable schema versions for each `--json` output.
|
|
3
|
+
*
|
|
4
|
+
* Bump semantics (per docs/SPEC-CLI-FIRST-CLASS.md):
|
|
5
|
+
* patch — additive fields only
|
|
6
|
+
* minor — field deprecated (kept emitting for one minor with _deprecated: true)
|
|
7
|
+
* major — removal / rename
|
|
8
|
+
*
|
|
9
|
+
* One schema, two transports: every webhook payload for the corresponding noun
|
|
10
|
+
* uses the same envelope as the `--json` output, byte-for-byte.
|
|
11
|
+
*/
|
|
12
|
+
export const SCHEMA_VERSIONS = {
|
|
13
|
+
triage: '1.0.0',
|
|
14
|
+
setup: '1.0.0',
|
|
15
|
+
pr: '1.0.0',
|
|
16
|
+
};
|
|
17
|
+
/** Wrap a payload with the `schema_version` envelope. */
|
|
18
|
+
export function withSchema(name, payload) {
|
|
19
|
+
return { schema_version: SCHEMA_VERSIONS[name], ...payload };
|
|
20
|
+
}
|
|
@@ -15,8 +15,18 @@ export interface AnalysisVerdict {
|
|
|
15
15
|
summary: string;
|
|
16
16
|
issues: AnalysisIssue[];
|
|
17
17
|
reviewUrl: string;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Whether the PR has actually been merged on GitHub. Read from the GH
|
|
20
|
+
* REST PR endpoint — NOT inferred from review policy / canAutoMerge /
|
|
21
|
+
* haystackRating, all of which mean "eligible", not "merged".
|
|
22
|
+
*/
|
|
19
23
|
autoMerged?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Whether the rating synthesis flagged that a human reviewer is required
|
|
26
|
+
* (review policy, security-sensitive paths, etc.). Distinct from
|
|
27
|
+
* `state === 'needs-input'` — analysis can be clean AND still need a human.
|
|
28
|
+
*/
|
|
29
|
+
needsHumanReview?: boolean;
|
|
20
30
|
}
|
|
21
31
|
export interface AnalysisIssue {
|
|
22
32
|
file: string;
|
|
@@ -55,7 +65,7 @@ export interface HumanReviewReason {
|
|
|
55
65
|
}
|
|
56
66
|
export interface RatingSynthesis {
|
|
57
67
|
haystackRating: 1 | 2 | 3 | 4 | 5;
|
|
58
|
-
analysisVerdict?: 'clean' | 'has-issues';
|
|
68
|
+
analysisVerdict?: 'clean' | 'has-issues' | 'needs-review';
|
|
59
69
|
synthesis: string;
|
|
60
70
|
synthesisDisplay?: SynthesisDisplayIssue[];
|
|
61
71
|
verifiedBugs: VerifiedBug[];
|
|
@@ -99,9 +109,6 @@ export type AnalysisReadyResult = {
|
|
|
99
109
|
* Returns 'ready' if complete, 'pending' if not yet available, or 'error' for server failures.
|
|
100
110
|
*/
|
|
101
111
|
export declare function checkAnalysisReady(owner: string, repo: string, prNumber: number, token?: string): Promise<AnalysisReadyResult>;
|
|
102
|
-
/**
|
|
103
|
-
* Fetch analysis results once complete. Combines bug detection and rule violations.
|
|
104
|
-
*/
|
|
105
112
|
export declare function fetchAnalysisResults(owner: string, repo: string, prNumber: number, token?: string): Promise<AnalysisVerdict>;
|
|
106
113
|
/**
|
|
107
114
|
* Fetch the full rating synthesis for a PR.
|
|
@@ -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),
|