@haystackeditor/cli 0.9.0 → 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 +51 -91
- package/dist/assets/hooks/package.json +2 -2
- 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/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/submit.d.ts +1 -0
- package/dist/commands/submit.js +29 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +113 -3
- package/dist/triage/prompts.d.ts +3 -3
- package/dist/triage/prompts.js +46 -16
- package/dist/triage/runner.js +21 -7
- package/dist/utils/pending-state.d.ts +2 -0
- package/package.json +11 -11
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pr-status command — Show the current Haystack status of a PR.
|
|
3
|
+
*
|
|
4
|
+
* Calls the classify-debug endpoint to determine which inbox bucket the PR
|
|
5
|
+
* is in (analyzing, auto-fixing, good-to-merge, issues, needs-assignment, etc.)
|
|
6
|
+
* and displays a human-readable summary.
|
|
7
|
+
*
|
|
8
|
+
* Accepts a PR identifier in several formats:
|
|
9
|
+
* 123 # Uses current repo's owner/repo
|
|
10
|
+
* owner/repo#123 # Fully qualified
|
|
11
|
+
* https://github.com/owner/repo/pull/123 # GitHub URL
|
|
12
|
+
*/
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { loadToken } from './login.js';
|
|
15
|
+
import { parseRemoteUrl } from '../utils/git.js';
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// PR identifier parsing
|
|
18
|
+
// ============================================================================
|
|
19
|
+
function parsePRIdentifier(identifier) {
|
|
20
|
+
const urlMatch = identifier.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
|
|
21
|
+
if (urlMatch) {
|
|
22
|
+
return { owner: urlMatch[1], repo: urlMatch[2], prNumber: parseInt(urlMatch[3], 10) };
|
|
23
|
+
}
|
|
24
|
+
const qualifiedMatch = identifier.match(/^([^/]+)\/([^#]+)#(\d+)$/);
|
|
25
|
+
if (qualifiedMatch) {
|
|
26
|
+
return { owner: qualifiedMatch[1], repo: qualifiedMatch[2], prNumber: parseInt(qualifiedMatch[3], 10) };
|
|
27
|
+
}
|
|
28
|
+
const numberMatch = identifier.match(/^#?(\d+)$/);
|
|
29
|
+
if (numberMatch) {
|
|
30
|
+
const prNumber = parseInt(numberMatch[1], 10);
|
|
31
|
+
try {
|
|
32
|
+
const remote = parseRemoteUrl('origin');
|
|
33
|
+
return { owner: remote.owner, repo: remote.repo, prNumber };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error(`Cannot determine repository for PR #${prNumber}.\n` +
|
|
37
|
+
`Use the full format: haystack pr-status owner/repo#${prNumber}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Invalid PR identifier: "${identifier}"\n\n` +
|
|
41
|
+
`Accepted formats:\n` +
|
|
42
|
+
` haystack pr-status 123\n` +
|
|
43
|
+
` haystack pr-status owner/repo#123\n` +
|
|
44
|
+
` haystack pr-status https://github.com/owner/repo/pull/123`);
|
|
45
|
+
}
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Display helpers
|
|
48
|
+
// ============================================================================
|
|
49
|
+
const BUCKET_DISPLAY = {
|
|
50
|
+
'analyzing': { label: 'Analyzing', color: chalk.blue, icon: '...' },
|
|
51
|
+
'auto-fixing': { label: 'Auto-fixing', color: chalk.blue, icon: '>>>' },
|
|
52
|
+
'good-to-merge': { label: 'Good to Merge', color: chalk.green, icon: '[+]' },
|
|
53
|
+
'failed-when-merging': { label: 'Failed When Merging', color: chalk.red, icon: '[!]' },
|
|
54
|
+
'issues': { label: 'Issues Found', color: chalk.yellow, icon: '[?]' },
|
|
55
|
+
'needs-assignment': { label: 'Needs Reviewer Assignment', color: chalk.yellow, icon: '[?]' },
|
|
56
|
+
'needs-shepherding': { label: 'Needs Manual Merge', color: chalk.yellow, icon: '[!]' },
|
|
57
|
+
'needs-fixing': { label: 'Agent Fixing CI', color: chalk.blue, icon: '>>>' },
|
|
58
|
+
'awaiting-review': { label: 'Awaiting Review', color: chalk.cyan, icon: '[-]' },
|
|
59
|
+
'review-requested': { label: 'Review Requested', color: chalk.cyan, icon: '[-]' },
|
|
60
|
+
'snoozed': { label: 'Snoozed', color: chalk.dim, icon: '[-]' },
|
|
61
|
+
};
|
|
62
|
+
function formatBucket(bucket) {
|
|
63
|
+
if (!bucket)
|
|
64
|
+
return chalk.dim('Not classified (PR may be closed, or not yet analyzed)');
|
|
65
|
+
const info = BUCKET_DISPLAY[bucket];
|
|
66
|
+
if (!info)
|
|
67
|
+
return chalk.dim(bucket);
|
|
68
|
+
return `${info.icon} ${info.color(info.label)}`;
|
|
69
|
+
}
|
|
70
|
+
function formatAnalysisStatus(status) {
|
|
71
|
+
switch (status) {
|
|
72
|
+
case 'ready': return chalk.green('Ready');
|
|
73
|
+
case 'in_queue': return chalk.blue('In queue');
|
|
74
|
+
case 'pending': return chalk.dim('Pending');
|
|
75
|
+
case 'error': return chalk.red('Error');
|
|
76
|
+
default: return chalk.dim(status ?? 'unknown');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function formatVerdict(verdict) {
|
|
80
|
+
switch (verdict) {
|
|
81
|
+
case 'clean': return chalk.green('Clean');
|
|
82
|
+
case 'has-issues': return chalk.yellow('Has issues');
|
|
83
|
+
case 'needs-review': return chalk.cyan('Needs review');
|
|
84
|
+
default: return chalk.dim(verdict ?? 'n/a');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// API call
|
|
89
|
+
// ============================================================================
|
|
90
|
+
const HAYSTACK_API = 'https://haystackeditor.com';
|
|
91
|
+
async function fetchClassification(owner, repo, prNumber, token) {
|
|
92
|
+
const prId = `${owner}/${repo}#${prNumber}`;
|
|
93
|
+
const response = await fetch(`${HAYSTACK_API}/auth/classify-debug?pr=${encodeURIComponent(prId)}`, {
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${token}`,
|
|
96
|
+
'User-Agent': 'Haystack-CLI',
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
if (response.status === 401) {
|
|
101
|
+
throw new Error('Authentication failed. Run `haystack login` to re-authenticate.');
|
|
102
|
+
}
|
|
103
|
+
if (response.status === 403) {
|
|
104
|
+
throw new Error('Access denied — you may not have permission to this repository.');
|
|
105
|
+
}
|
|
106
|
+
const body = await response.text().catch(() => '');
|
|
107
|
+
throw new Error(`API error (${response.status}): ${body}`);
|
|
108
|
+
}
|
|
109
|
+
return response.json();
|
|
110
|
+
}
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Command
|
|
113
|
+
// ============================================================================
|
|
114
|
+
export async function prStatusCommand(identifier, options) {
|
|
115
|
+
let pr;
|
|
116
|
+
try {
|
|
117
|
+
pr = parsePRIdentifier(identifier);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.error(chalk.red(`\n${err.message}\n`));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const token = await loadToken();
|
|
124
|
+
if (!token) {
|
|
125
|
+
console.error(chalk.red('\nNot logged in. Run `haystack login` first.\n'));
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
const prLabel = `${pr.owner}/${pr.repo}#${pr.prNumber}`;
|
|
129
|
+
if (!options.json) {
|
|
130
|
+
console.log(chalk.dim(`\nFetching status for ${prLabel}...\n`));
|
|
131
|
+
}
|
|
132
|
+
let data;
|
|
133
|
+
try {
|
|
134
|
+
data = await fetchClassification(pr.owner, pr.repo, pr.prNumber, token);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
console.error(chalk.red(`\n${err.message}\n`));
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
// JSON mode — pure JSON to stdout, nothing else
|
|
141
|
+
if (options.json) {
|
|
142
|
+
console.log(JSON.stringify(data, null, 2));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Human-readable output
|
|
146
|
+
console.log(` ${chalk.bold('PR')}: ${data.prId}`);
|
|
147
|
+
console.log(` ${chalk.bold('Status')}: ${formatBucket(data.bucket)}`);
|
|
148
|
+
console.log(` ${chalk.bold('Analysis')}: ${formatAnalysisStatus(data.inputs.analysisStatus)}`);
|
|
149
|
+
if (data.inputs.analysisVerdict) {
|
|
150
|
+
console.log(` ${chalk.bold('Verdict')}: ${formatVerdict(data.inputs.analysisVerdict)}`);
|
|
151
|
+
}
|
|
152
|
+
if (data.inputs.haystackRating != null) {
|
|
153
|
+
console.log(` ${chalk.bold('Rating')}: ${data.inputs.haystackRating}/5`);
|
|
154
|
+
}
|
|
155
|
+
console.log(` ${chalk.bold('Reason')}: ${chalk.dim(data.reason)}`);
|
|
156
|
+
// Extra context
|
|
157
|
+
if (!data.inputs.isOpen) {
|
|
158
|
+
console.log(chalk.dim(`\n PR is closed or merged.`));
|
|
159
|
+
}
|
|
160
|
+
if (!data.inputs.isAuthor) {
|
|
161
|
+
console.log(chalk.dim(`\n You are not the author (author: ${data.inputs.author}).`));
|
|
162
|
+
}
|
|
163
|
+
// Labels of interest
|
|
164
|
+
const haystackLabels = (data.inputs.labels || []).filter(l => l.startsWith('haystack:'));
|
|
165
|
+
if (haystackLabels.length > 0) {
|
|
166
|
+
console.log(` ${chalk.bold('Labels')}: ${haystackLabels.join(', ')}`);
|
|
167
|
+
}
|
|
168
|
+
// User overrides
|
|
169
|
+
const overrides = [];
|
|
170
|
+
if (data.inputs.dismissed)
|
|
171
|
+
overrides.push('dismissed');
|
|
172
|
+
if (data.inputs.dismissedAgentAlerts)
|
|
173
|
+
overrides.push('agent alerts dismissed');
|
|
174
|
+
if (data.inputs.dismissedNeedsAssignment)
|
|
175
|
+
overrides.push('needs-assignment dismissed');
|
|
176
|
+
if (data.inputs.manuallyGoodToMerge)
|
|
177
|
+
overrides.push('manually marked good-to-merge');
|
|
178
|
+
if (data.inputs.snoozed)
|
|
179
|
+
overrides.push('snoozed');
|
|
180
|
+
if (overrides.length > 0) {
|
|
181
|
+
console.log(` ${chalk.bold('Overrides')}: ${overrides.join(', ')}`);
|
|
182
|
+
}
|
|
183
|
+
// Stale warning
|
|
184
|
+
if (data.staleWarning) {
|
|
185
|
+
console.log(chalk.yellow(`\n Warning: ${data.staleWarning}`));
|
|
186
|
+
}
|
|
187
|
+
console.log('');
|
|
188
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haystack setup - Interactive onboarding wizard
|
|
3
|
+
*
|
|
4
|
+
* CLI equivalent of the web onboarding wizard. Walks through:
|
|
5
|
+
* 1. Login check
|
|
6
|
+
* 2. Repository selection
|
|
7
|
+
* 3. Scan for coding rules
|
|
8
|
+
* 4. Scan for wait-for signals (CI checks, bot reviews)
|
|
9
|
+
* 5. Scan for review policies
|
|
10
|
+
* 6. Review discovered items (toggle on/off)
|
|
11
|
+
* 7. Confirm & write .haystack.json to selected repos
|
|
12
|
+
*/
|
|
13
|
+
export declare function setupCommand(): Promise<void>;
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haystack setup - Interactive onboarding wizard
|
|
3
|
+
*
|
|
4
|
+
* CLI equivalent of the web onboarding wizard. Walks through:
|
|
5
|
+
* 1. Login check
|
|
6
|
+
* 2. Repository selection
|
|
7
|
+
* 3. Scan for coding rules
|
|
8
|
+
* 4. Scan for wait-for signals (CI checks, bot reviews)
|
|
9
|
+
* 5. Scan for review policies
|
|
10
|
+
* 6. Review discovered items (toggle on/off)
|
|
11
|
+
* 7. Confirm & write .haystack.json to selected repos
|
|
12
|
+
*/
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import inquirer from 'inquirer';
|
|
15
|
+
import { loadToken } from './login.js';
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Constants
|
|
18
|
+
// =============================================================================
|
|
19
|
+
const HAYSTACK_API = 'https://haystackeditor.com';
|
|
20
|
+
const GITHUB_API = 'https://api.github.com';
|
|
21
|
+
const CATEGORY_LABELS = {
|
|
22
|
+
testing: 'Testing',
|
|
23
|
+
style: 'Style',
|
|
24
|
+
security: 'Security',
|
|
25
|
+
performance: 'Performance',
|
|
26
|
+
docs: 'Documentation',
|
|
27
|
+
other: 'Other',
|
|
28
|
+
};
|
|
29
|
+
const SIGNAL_TYPE_LABELS = {
|
|
30
|
+
ci_check: 'CI Check',
|
|
31
|
+
bot_review: 'Bot Review',
|
|
32
|
+
status_check: 'Status Check',
|
|
33
|
+
};
|
|
34
|
+
const POLICY_TYPE_LABELS = {
|
|
35
|
+
approval_count: 'Approval Count',
|
|
36
|
+
codeowner_review: 'Codeowner Review',
|
|
37
|
+
team_review: 'Team Review',
|
|
38
|
+
area_owner: 'Area Owner',
|
|
39
|
+
};
|
|
40
|
+
async function fetchUserRepos(token) {
|
|
41
|
+
const repos = [];
|
|
42
|
+
let page = 1;
|
|
43
|
+
// Fetch up to 200 repos (non-archived, sorted by recent push)
|
|
44
|
+
while (page <= 2) {
|
|
45
|
+
const response = await fetch(`${GITHUB_API}/user/repos?sort=pushed&per_page=100&page=${page}&type=owner`, {
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${token}`,
|
|
48
|
+
Accept: 'application/vnd.github.v3+json',
|
|
49
|
+
'User-Agent': 'Haystack-CLI',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new Error(`Failed to fetch repos: ${response.status}`);
|
|
54
|
+
}
|
|
55
|
+
const batch = (await response.json());
|
|
56
|
+
if (batch.length === 0)
|
|
57
|
+
break;
|
|
58
|
+
repos.push(...batch);
|
|
59
|
+
if (batch.length < 100)
|
|
60
|
+
break;
|
|
61
|
+
page++;
|
|
62
|
+
}
|
|
63
|
+
return repos
|
|
64
|
+
.filter((r) => !r.archived)
|
|
65
|
+
.map((r) => r.full_name);
|
|
66
|
+
}
|
|
67
|
+
async function fetchExistingConfig(owner, repo, token) {
|
|
68
|
+
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.haystack.json`, {
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${token}`,
|
|
71
|
+
Accept: 'application/vnd.github.v3+json',
|
|
72
|
+
'User-Agent': 'Haystack-CLI',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (response.status === 404)
|
|
76
|
+
return null;
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`Failed to fetch config: ${response.status}`);
|
|
79
|
+
const data = (await response.json());
|
|
80
|
+
const raw = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
81
|
+
try {
|
|
82
|
+
return { config: JSON.parse(raw), sha: data.sha };
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error('existing .haystack.json is malformed JSON — delete or fix it manually');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function writeConfigToRepo(owner, repo, config, existingSha, token) {
|
|
89
|
+
const content = JSON.stringify(config, null, 2) + '\n';
|
|
90
|
+
const body = {
|
|
91
|
+
message: 'chore: configure Haystack via CLI setup wizard',
|
|
92
|
+
content: Buffer.from(content).toString('base64'),
|
|
93
|
+
};
|
|
94
|
+
if (existingSha)
|
|
95
|
+
body.sha = existingSha;
|
|
96
|
+
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/.haystack.json`, {
|
|
97
|
+
method: 'PUT',
|
|
98
|
+
headers: {
|
|
99
|
+
Authorization: `Bearer ${token}`,
|
|
100
|
+
Accept: 'application/vnd.github.v3+json',
|
|
101
|
+
'Content-Type': 'application/json',
|
|
102
|
+
'User-Agent': 'Haystack-CLI',
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify(body),
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const err = await response.text();
|
|
108
|
+
throw new Error(`Failed to write config: ${response.status} — ${err}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// Merge helpers
|
|
113
|
+
// =============================================================================
|
|
114
|
+
/**
|
|
115
|
+
* Deep merge two plain objects. Arrays are replaced (not concatenated)
|
|
116
|
+
* since config arrays like `rules` and `wait_for` are complete sets from
|
|
117
|
+
* the wizard and should override, not append to, existing values.
|
|
118
|
+
*/
|
|
119
|
+
function deepMerge(target, source) {
|
|
120
|
+
const result = { ...target };
|
|
121
|
+
for (const key of Object.keys(source)) {
|
|
122
|
+
const srcVal = source[key];
|
|
123
|
+
const tgtVal = result[key];
|
|
124
|
+
if (srcVal !== null &&
|
|
125
|
+
typeof srcVal === 'object' &&
|
|
126
|
+
!Array.isArray(srcVal) &&
|
|
127
|
+
tgtVal !== null &&
|
|
128
|
+
typeof tgtVal === 'object' &&
|
|
129
|
+
!Array.isArray(tgtVal)) {
|
|
130
|
+
result[key] = deepMerge(tgtVal, srcVal);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
result[key] = srcVal;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
// =============================================================================
|
|
139
|
+
// Onboarding scan (SSE client)
|
|
140
|
+
// =============================================================================
|
|
141
|
+
async function runScan(repos, scanType, token) {
|
|
142
|
+
const response = await fetch(`${HAYSTACK_API}/api/onboarding/scan`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: {
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
Authorization: `Bearer ${token}`,
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify({ repos, scanType }),
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) {
|
|
151
|
+
throw new Error(`Scan failed: ${response.status}`);
|
|
152
|
+
}
|
|
153
|
+
return parseSseStream(response);
|
|
154
|
+
}
|
|
155
|
+
async function parseSseStream(response) {
|
|
156
|
+
const text = await response.text();
|
|
157
|
+
const lines = text.split('\n');
|
|
158
|
+
let items = [];
|
|
159
|
+
let eventType = '';
|
|
160
|
+
const eventDataLines = [];
|
|
161
|
+
function flushEvent() {
|
|
162
|
+
if (!eventType || eventDataLines.length === 0)
|
|
163
|
+
return;
|
|
164
|
+
const eventData = eventDataLines.join('\n');
|
|
165
|
+
const parsed = JSON.parse(eventData);
|
|
166
|
+
if (eventType === 'progress') {
|
|
167
|
+
const progress = parsed;
|
|
168
|
+
process.stdout.write(`\r${chalk.dim(' ' + progress.message)}`);
|
|
169
|
+
if (progress.detail) {
|
|
170
|
+
process.stdout.write(chalk.dim(` — ${progress.detail}`));
|
|
171
|
+
}
|
|
172
|
+
process.stdout.write('\x1b[K');
|
|
173
|
+
}
|
|
174
|
+
else if (eventType === 'result') {
|
|
175
|
+
items = parsed.items;
|
|
176
|
+
}
|
|
177
|
+
else if (eventType === 'error') {
|
|
178
|
+
throw new Error(parsed.message);
|
|
179
|
+
}
|
|
180
|
+
eventType = '';
|
|
181
|
+
eventDataLines.length = 0;
|
|
182
|
+
}
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
if (line.startsWith('event: ')) {
|
|
185
|
+
eventType = line.slice(7).trim();
|
|
186
|
+
}
|
|
187
|
+
else if (line.startsWith('data: ')) {
|
|
188
|
+
eventDataLines.push(line.slice(6));
|
|
189
|
+
}
|
|
190
|
+
else if (line === '') {
|
|
191
|
+
flushEvent();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Flush any remaining buffered event at EOF (stream may not end with blank line)
|
|
195
|
+
flushEvent();
|
|
196
|
+
// Clear progress line
|
|
197
|
+
process.stdout.write('\r\x1b[K');
|
|
198
|
+
return items;
|
|
199
|
+
}
|
|
200
|
+
// =============================================================================
|
|
201
|
+
// Display helpers
|
|
202
|
+
// =============================================================================
|
|
203
|
+
function printSectionHeader(title, count, total) {
|
|
204
|
+
console.log(`\n ${chalk.bold(title)} ${chalk.dim(`${count}/${total} enabled`)}`);
|
|
205
|
+
console.log(chalk.dim(' ' + '─'.repeat(50)));
|
|
206
|
+
}
|
|
207
|
+
function printRule(rule, index) {
|
|
208
|
+
const status = rule.enabled ? chalk.green('ON ') : chalk.dim('OFF');
|
|
209
|
+
const cat = chalk.dim(`[${CATEGORY_LABELS[rule.category] || rule.category}]`);
|
|
210
|
+
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${rule.name} ${cat}`);
|
|
211
|
+
console.log(` ${chalk.dim(rule.description)}`);
|
|
212
|
+
}
|
|
213
|
+
function printSignal(signal, index) {
|
|
214
|
+
const status = signal.enabled ? chalk.green('ON ') : chalk.dim('OFF');
|
|
215
|
+
const type = chalk.dim(`[${SIGNAL_TYPE_LABELS[signal.type] || signal.type}]`);
|
|
216
|
+
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${signal.name} ${type}`);
|
|
217
|
+
if (signal.pattern) {
|
|
218
|
+
console.log(` ${chalk.dim(`Pattern: ${signal.pattern}`)}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function printPolicy(policy, index) {
|
|
222
|
+
const status = policy.enabled ? chalk.green('ON ') : chalk.dim('OFF');
|
|
223
|
+
const type = chalk.dim(`[${POLICY_TYPE_LABELS[policy.type] || policy.type}]`);
|
|
224
|
+
console.log(` ${chalk.dim(`${index + 1}.`)} ${status} ${policy.name} ${type}`);
|
|
225
|
+
console.log(` ${chalk.dim(policy.description)}`);
|
|
226
|
+
}
|
|
227
|
+
// =============================================================================
|
|
228
|
+
// Wizard steps
|
|
229
|
+
// =============================================================================
|
|
230
|
+
async function stepSelectRepos(token) {
|
|
231
|
+
console.log(chalk.bold('\n Step 1: Select repositories\n'));
|
|
232
|
+
console.log(chalk.dim(' Fetching your repositories...'));
|
|
233
|
+
const repos = await fetchUserRepos(token);
|
|
234
|
+
if (repos.length === 0) {
|
|
235
|
+
console.log(chalk.yellow('\n No repositories found.\n'));
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
// Clear the "Fetching" message
|
|
239
|
+
process.stdout.write('\x1b[1A\x1b[K');
|
|
240
|
+
const { selectedRepos } = await inquirer.prompt([
|
|
241
|
+
{
|
|
242
|
+
type: 'checkbox',
|
|
243
|
+
name: 'selectedRepos',
|
|
244
|
+
message: 'Select repositories to configure:',
|
|
245
|
+
choices: repos.map((r) => ({ name: r, value: r })),
|
|
246
|
+
pageSize: 15,
|
|
247
|
+
validate: (answer) => {
|
|
248
|
+
if (answer.length === 0)
|
|
249
|
+
return 'Select at least one repository.';
|
|
250
|
+
return true;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
]);
|
|
254
|
+
console.log(chalk.green(` ${selectedRepos.length} repo(s) selected\n`));
|
|
255
|
+
return selectedRepos;
|
|
256
|
+
}
|
|
257
|
+
async function stepScan(repos, scanType, stepNum, token) {
|
|
258
|
+
const labels = {
|
|
259
|
+
rules: 'coding rules',
|
|
260
|
+
wait_for: 'CI/bot signals',
|
|
261
|
+
policies: 'review policies',
|
|
262
|
+
};
|
|
263
|
+
console.log(chalk.bold(`\n Step ${stepNum}: Scanning for ${labels[scanType]}\n`));
|
|
264
|
+
try {
|
|
265
|
+
const items = await runScan(repos, scanType, token);
|
|
266
|
+
if (items.length === 0) {
|
|
267
|
+
console.log(chalk.dim(` No ${labels[scanType]} discovered.`));
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
console.log(chalk.green(` Found ${items.length} ${labels[scanType]}`));
|
|
271
|
+
}
|
|
272
|
+
return items;
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
console.log(chalk.yellow(` Scan failed: ${err.message}`));
|
|
276
|
+
console.log(chalk.dim(' Skipping this scan — you can configure manually later.\n'));
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function stepReview(rules, signals, policies) {
|
|
281
|
+
const totalItems = rules.length + signals.length + policies.length;
|
|
282
|
+
if (totalItems === 0) {
|
|
283
|
+
console.log(chalk.dim('\n No items discovered — skipping review.\n'));
|
|
284
|
+
return { rules, signals, policies };
|
|
285
|
+
}
|
|
286
|
+
console.log(chalk.bold('\n Step 5: Review discovered configuration\n'));
|
|
287
|
+
console.log(chalk.dim(' Toggle items on/off. Only enabled items will be written.\n'));
|
|
288
|
+
// Show current state
|
|
289
|
+
if (rules.length > 0) {
|
|
290
|
+
printSectionHeader('Coding Rules', rules.filter((r) => r.enabled).length, rules.length);
|
|
291
|
+
rules.forEach((r, i) => printRule(r, i));
|
|
292
|
+
}
|
|
293
|
+
if (signals.length > 0) {
|
|
294
|
+
printSectionHeader('Wait-For Signals', signals.filter((s) => s.enabled).length, signals.length);
|
|
295
|
+
signals.forEach((s, i) => printSignal(s, i));
|
|
296
|
+
}
|
|
297
|
+
if (policies.length > 0) {
|
|
298
|
+
printSectionHeader('Review Policies', policies.filter((p) => p.enabled).length, policies.length);
|
|
299
|
+
policies.forEach((p, i) => printPolicy(p, i));
|
|
300
|
+
}
|
|
301
|
+
// Ask if they want to toggle anything
|
|
302
|
+
const { wantToToggle } = await inquirer.prompt([
|
|
303
|
+
{
|
|
304
|
+
type: 'confirm',
|
|
305
|
+
name: 'wantToToggle',
|
|
306
|
+
message: 'Would you like to toggle any items?',
|
|
307
|
+
default: false,
|
|
308
|
+
},
|
|
309
|
+
]);
|
|
310
|
+
if (wantToToggle) {
|
|
311
|
+
// Build a list of all items for toggling
|
|
312
|
+
const allItems = [];
|
|
313
|
+
if (rules.length > 0) {
|
|
314
|
+
allItems.push(new inquirer.Separator('── Coding Rules ──'));
|
|
315
|
+
rules.forEach((r) => {
|
|
316
|
+
allItems.push({
|
|
317
|
+
name: `${r.name} ${chalk.dim(`[${CATEGORY_LABELS[r.category] || r.category}]`)}`,
|
|
318
|
+
value: `rule:${r.id}`,
|
|
319
|
+
checked: r.enabled,
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (signals.length > 0) {
|
|
324
|
+
allItems.push(new inquirer.Separator('── Wait-For Signals ──'));
|
|
325
|
+
signals.forEach((s) => {
|
|
326
|
+
allItems.push({
|
|
327
|
+
name: `${s.name} ${chalk.dim(`[${SIGNAL_TYPE_LABELS[s.type] || s.type}]`)}`,
|
|
328
|
+
value: `signal:${s.id}`,
|
|
329
|
+
checked: s.enabled,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (policies.length > 0) {
|
|
334
|
+
allItems.push(new inquirer.Separator('── Review Policies ──'));
|
|
335
|
+
policies.forEach((p) => {
|
|
336
|
+
allItems.push({
|
|
337
|
+
name: `${p.name} ${chalk.dim(`[${POLICY_TYPE_LABELS[p.type] || p.type}]`)}`,
|
|
338
|
+
value: `policy:${p.id}`,
|
|
339
|
+
checked: p.enabled,
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const { enabled } = await inquirer.prompt([
|
|
344
|
+
{
|
|
345
|
+
type: 'checkbox',
|
|
346
|
+
name: 'enabled',
|
|
347
|
+
message: 'Select items to enable:',
|
|
348
|
+
choices: allItems,
|
|
349
|
+
pageSize: 20,
|
|
350
|
+
},
|
|
351
|
+
]);
|
|
352
|
+
// Apply toggling
|
|
353
|
+
rules.forEach((r) => {
|
|
354
|
+
r.enabled = enabled.includes(`rule:${r.id}`);
|
|
355
|
+
});
|
|
356
|
+
signals.forEach((s) => {
|
|
357
|
+
s.enabled = enabled.includes(`signal:${s.id}`);
|
|
358
|
+
});
|
|
359
|
+
policies.forEach((p) => {
|
|
360
|
+
p.enabled = enabled.includes(`policy:${p.id}`);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return { rules, signals, policies };
|
|
364
|
+
}
|
|
365
|
+
function buildConfig(rules, signals, policies) {
|
|
366
|
+
const config = {
|
|
367
|
+
version: 1,
|
|
368
|
+
};
|
|
369
|
+
const enabledRules = rules.filter((r) => r.enabled);
|
|
370
|
+
const enabledSignals = signals.filter((s) => s.enabled);
|
|
371
|
+
const enabledPolicies = policies.filter((p) => p.enabled);
|
|
372
|
+
if (enabledRules.length > 0) {
|
|
373
|
+
config.rules = enabledRules.map((r) => ({
|
|
374
|
+
name: r.name,
|
|
375
|
+
description: r.description,
|
|
376
|
+
category: r.category,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
if (enabledSignals.length > 0) {
|
|
380
|
+
config.wait_for = enabledSignals.map((s) => ({
|
|
381
|
+
name: s.name,
|
|
382
|
+
type: s.type,
|
|
383
|
+
source: s.source,
|
|
384
|
+
pattern: s.pattern,
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
if (enabledPolicies.length > 0) {
|
|
388
|
+
config.review_policies = enabledPolicies.map((p) => ({
|
|
389
|
+
name: p.name,
|
|
390
|
+
type: p.type,
|
|
391
|
+
description: p.description,
|
|
392
|
+
config: p.config,
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
return config;
|
|
396
|
+
}
|
|
397
|
+
async function stepConfirm(selectedRepos, rules, signals, policies, token) {
|
|
398
|
+
const config = buildConfig(rules, signals, policies);
|
|
399
|
+
const enabledRules = rules.filter((r) => r.enabled);
|
|
400
|
+
const enabledSignals = signals.filter((s) => s.enabled);
|
|
401
|
+
const enabledPolicies = policies.filter((p) => p.enabled);
|
|
402
|
+
const totalEnabled = enabledRules.length + enabledSignals.length + enabledPolicies.length;
|
|
403
|
+
console.log(chalk.bold('\n Step 6: Confirm & write configuration\n'));
|
|
404
|
+
// Summary
|
|
405
|
+
const parts = [];
|
|
406
|
+
if (enabledRules.length > 0)
|
|
407
|
+
parts.push(`${enabledRules.length} rules`);
|
|
408
|
+
if (enabledSignals.length > 0)
|
|
409
|
+
parts.push(`${enabledSignals.length} signals`);
|
|
410
|
+
if (enabledPolicies.length > 0)
|
|
411
|
+
parts.push(`${enabledPolicies.length} policies`);
|
|
412
|
+
if (totalEnabled === 0) {
|
|
413
|
+
console.log(chalk.dim(' No items enabled. Config will contain version only.\n'));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
console.log(` ${chalk.dim('Items:')} ${parts.join(', ')}`);
|
|
417
|
+
}
|
|
418
|
+
console.log(` ${chalk.dim('Repos:')} ${selectedRepos.join(', ')}`);
|
|
419
|
+
// Preview
|
|
420
|
+
console.log(chalk.dim('\n ┌─ .haystack.json ─────────────────────────────────'));
|
|
421
|
+
const preview = JSON.stringify(config, null, 2);
|
|
422
|
+
preview.split('\n').forEach((line) => {
|
|
423
|
+
console.log(chalk.dim(' │ ') + chalk.white(line));
|
|
424
|
+
});
|
|
425
|
+
console.log(chalk.dim(' └─────────────────────────────────────────────────\n'));
|
|
426
|
+
const { confirmed } = await inquirer.prompt([
|
|
427
|
+
{
|
|
428
|
+
type: 'confirm',
|
|
429
|
+
name: 'confirmed',
|
|
430
|
+
message: `Write .haystack.json to ${selectedRepos.length} repo(s)?`,
|
|
431
|
+
default: true,
|
|
432
|
+
},
|
|
433
|
+
]);
|
|
434
|
+
if (!confirmed) {
|
|
435
|
+
console.log(chalk.yellow('\n Setup cancelled.\n'));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// Write to each repo
|
|
439
|
+
const failedRepos = [];
|
|
440
|
+
for (const repoFullName of selectedRepos) {
|
|
441
|
+
const [owner, repo] = repoFullName.split('/');
|
|
442
|
+
try {
|
|
443
|
+
process.stdout.write(chalk.dim(` Writing to ${repoFullName}...`));
|
|
444
|
+
const existing = await fetchExistingConfig(owner, repo, token);
|
|
445
|
+
const mergedConfig = existing ? deepMerge(existing.config, config) : config;
|
|
446
|
+
await writeConfigToRepo(owner, repo, mergedConfig, existing?.sha ?? null, token);
|
|
447
|
+
process.stdout.write('\r\x1b[K');
|
|
448
|
+
console.log(chalk.green(` ✓ ${repoFullName}`));
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
process.stdout.write('\r\x1b[K');
|
|
452
|
+
console.log(chalk.red(` ✗ ${repoFullName}: ${err.message}`));
|
|
453
|
+
failedRepos.push(repoFullName);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (failedRepos.length > 0) {
|
|
457
|
+
console.log(chalk.red(`\n Setup failed for ${failedRepos.length} repo(s).\n`));
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
console.log(chalk.green('\n Setup complete!\n'));
|
|
461
|
+
}
|
|
462
|
+
// =============================================================================
|
|
463
|
+
// Main command
|
|
464
|
+
// =============================================================================
|
|
465
|
+
export async function setupCommand() {
|
|
466
|
+
console.log(chalk.cyan('\n Haystack Setup Wizard\n'));
|
|
467
|
+
// Step 0: Check login
|
|
468
|
+
const token = await loadToken();
|
|
469
|
+
if (!token) {
|
|
470
|
+
console.log(chalk.yellow(' Not logged in. Run `haystack login` first.\n'));
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
// Verify token
|
|
474
|
+
const userResponse = await fetch(`${GITHUB_API}/user`, {
|
|
475
|
+
headers: {
|
|
476
|
+
Authorization: `Bearer ${token}`,
|
|
477
|
+
'User-Agent': 'Haystack-CLI',
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
if (!userResponse.ok) {
|
|
481
|
+
console.log(chalk.yellow(' Token expired. Run `haystack login` again.\n'));
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
const user = (await userResponse.json());
|
|
485
|
+
console.log(chalk.dim(` Logged in as ${chalk.bold(user.login)}\n`));
|
|
486
|
+
// Step 1: Select repos
|
|
487
|
+
const selectedRepos = await stepSelectRepos(token);
|
|
488
|
+
// Steps 2-4: Scan for rules, wait-for signals, and review policies
|
|
489
|
+
const rules = (await stepScan(selectedRepos, 'rules', 2, token));
|
|
490
|
+
const signals = (await stepScan(selectedRepos, 'wait_for', 3, token));
|
|
491
|
+
const policies = (await stepScan(selectedRepos, 'policies', 4, token));
|
|
492
|
+
// Step 5: Review
|
|
493
|
+
const reviewed = await stepReview(rules, signals, policies);
|
|
494
|
+
// Step 6: Confirm & write
|
|
495
|
+
await stepConfirm(selectedRepos, reviewed.rules, reviewed.signals, reviewed.policies, token);
|
|
496
|
+
}
|