claude-git-hooks 2.20.0 → 2.21.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/CHANGELOG.md +18 -0
- package/CLAUDE.md +37 -5
- package/README.md +48 -5
- package/bin/claude-hooks +1 -0
- package/lib/cli-metadata.js +21 -1
- package/lib/commands/analyze-pr.js +683 -0
- package/lib/commands/help.js +70 -38
- package/lib/commands/setup-linear.js +96 -0
- package/lib/config.js +15 -0
- package/lib/utils/claude-client.js +7 -3
- package/lib/utils/github-api.js +176 -112
- package/lib/utils/judge.js +1 -1
- package/lib/utils/linear-connector.js +287 -0
- package/lib/utils/pr-statistics.js +85 -0
- package/lib/utils/token-store.js +159 -0
- package/package.json +1 -1
- package/templates/ANALYZE_PR.md +79 -0
- package/templates/config.advanced.example.json +44 -3
- package/templates/settings.local.example.json +2 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: linear-connector.js
|
|
3
|
+
* Purpose: Linear API connector for fetching ticket context
|
|
4
|
+
*
|
|
5
|
+
* Connection flow (resilient):
|
|
6
|
+
* 1. testConnection() → verify token and API reachability
|
|
7
|
+
* 2. fetchTicket(identifier) → get ticket details
|
|
8
|
+
* 3. On failure: retry → reconnect → graceful degradation
|
|
9
|
+
*
|
|
10
|
+
* Token priority (same pattern as github-api.js):
|
|
11
|
+
* 1. LINEAR_API_TOKEN env var
|
|
12
|
+
* 2. .claude/settings.local.json → linearToken
|
|
13
|
+
*
|
|
14
|
+
* Dependencies:
|
|
15
|
+
* - https: Node.js built-in HTTP client
|
|
16
|
+
* - logger: Debug and error logging
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import https from 'https';
|
|
20
|
+
import logger from './logger.js';
|
|
21
|
+
import { loadToken } from './token-store.js';
|
|
22
|
+
|
|
23
|
+
const LINEAR_HOSTNAME = 'api.linear.app';
|
|
24
|
+
const LINEAR_PATH = '/graphql';
|
|
25
|
+
const LINEAR_TIMEOUT = 10000;
|
|
26
|
+
const MAX_RETRIES = 2;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Custom error for Linear connector failures
|
|
30
|
+
*/
|
|
31
|
+
export class LinearConnectorError extends Error {
|
|
32
|
+
constructor(message, { cause, context } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'LinearConnectorError';
|
|
35
|
+
this.cause = cause;
|
|
36
|
+
this.context = context;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load Linear API token from settings
|
|
42
|
+
* Delegates to token-store.js for centralized token management.
|
|
43
|
+
* @returns {string|null} Token or null if not found
|
|
44
|
+
*/
|
|
45
|
+
export const loadLinearToken = () => loadToken('linearToken', ['LINEAR_API_TOKEN']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Execute a GraphQL query against Linear API
|
|
49
|
+
* @param {string} token - Linear API token
|
|
50
|
+
* @param {string} query - GraphQL query
|
|
51
|
+
* @param {Object} variables - Query variables
|
|
52
|
+
* @returns {Promise<Object>} Response data
|
|
53
|
+
*/
|
|
54
|
+
const graphqlRequest = (token, query, variables = {}) =>
|
|
55
|
+
new Promise((resolve, reject) => {
|
|
56
|
+
const body = JSON.stringify({ query, variables });
|
|
57
|
+
|
|
58
|
+
const req = https.request(
|
|
59
|
+
{
|
|
60
|
+
hostname: LINEAR_HOSTNAME,
|
|
61
|
+
path: LINEAR_PATH,
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
Authorization: token,
|
|
66
|
+
'Content-Length': Buffer.byteLength(body)
|
|
67
|
+
},
|
|
68
|
+
timeout: LINEAR_TIMEOUT
|
|
69
|
+
},
|
|
70
|
+
(res) => {
|
|
71
|
+
let data = '';
|
|
72
|
+
res.on('data', (chunk) => (data += chunk));
|
|
73
|
+
res.on('end', () => {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(data);
|
|
76
|
+
|
|
77
|
+
if (parsed.errors) {
|
|
78
|
+
reject(
|
|
79
|
+
new LinearConnectorError(
|
|
80
|
+
`Linear API error: ${parsed.errors[0].message}`,
|
|
81
|
+
{
|
|
82
|
+
context: { errors: parsed.errors }
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
resolve(parsed.data);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
reject(
|
|
92
|
+
new LinearConnectorError('Failed to parse Linear API response', {
|
|
93
|
+
cause: e
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
req.on('error', (error) => {
|
|
102
|
+
reject(
|
|
103
|
+
new LinearConnectorError('Linear API request failed', {
|
|
104
|
+
cause: error,
|
|
105
|
+
context: { hostname: LINEAR_HOSTNAME }
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
req.on('timeout', () => {
|
|
111
|
+
req.destroy();
|
|
112
|
+
reject(
|
|
113
|
+
new LinearConnectorError('Linear API request timed out', {
|
|
114
|
+
context: { timeout: LINEAR_TIMEOUT }
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
req.write(body);
|
|
120
|
+
req.end();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Test Linear API connection health
|
|
125
|
+
* @param {string} token - Linear API token
|
|
126
|
+
* @returns {Promise<boolean>} True if connection is healthy
|
|
127
|
+
*/
|
|
128
|
+
export const testConnection = async (token) => {
|
|
129
|
+
logger.debug('linear-connector - testConnection', 'Testing Linear API connection');
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const data = await graphqlRequest(token, '{ viewer { id name } }');
|
|
133
|
+
const isHealthy = !!data?.viewer?.id;
|
|
134
|
+
|
|
135
|
+
logger.debug('linear-connector - testConnection', 'Connection test result', {
|
|
136
|
+
healthy: isHealthy,
|
|
137
|
+
user: data?.viewer?.name
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return isHealthy;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
logger.debug('linear-connector - testConnection', 'Connection test failed', {
|
|
143
|
+
error: error.message
|
|
144
|
+
});
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Extract team key and issue number from Linear identifier
|
|
151
|
+
* @param {string} identifier - e.g., "AUT-1234"
|
|
152
|
+
* @returns {{ teamKey: string, number: number }|null}
|
|
153
|
+
*/
|
|
154
|
+
export const parseLinearIdentifier = (identifier) => {
|
|
155
|
+
const match = identifier.match(/^([A-Z]+)-(\d+)$/i);
|
|
156
|
+
if (!match) return null;
|
|
157
|
+
return { teamKey: match[1].toUpperCase(), number: parseInt(match[2], 10) };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Extract Linear ticket identifier from PR title
|
|
162
|
+
* @param {string} title - PR title (e.g., "[AUT-1234] feat: add feature")
|
|
163
|
+
* @returns {string|null} Linear identifier or null
|
|
164
|
+
*/
|
|
165
|
+
export const extractLinearTicketFromTitle = (title) => {
|
|
166
|
+
if (!title) return null;
|
|
167
|
+
|
|
168
|
+
// Match [AUT-1234] pattern at start of title
|
|
169
|
+
const match = title.match(/^\[([A-Z]+-\d+)\]/i);
|
|
170
|
+
if (match) {
|
|
171
|
+
return match[1].toUpperCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Fetch a Linear ticket by identifier with retry logic
|
|
179
|
+
*
|
|
180
|
+
* Flow: test connection → fetch → retry → reconnect → graceful degradation
|
|
181
|
+
*
|
|
182
|
+
* @param {string} identifier - Linear identifier (e.g., "AUT-1234")
|
|
183
|
+
* @returns {Promise<Object|null>} Ticket data or null on failure
|
|
184
|
+
*/
|
|
185
|
+
export const fetchTicket = async (identifier) => {
|
|
186
|
+
logger.debug('linear-connector - fetchTicket', 'Fetching Linear ticket', { identifier });
|
|
187
|
+
|
|
188
|
+
const token = loadLinearToken();
|
|
189
|
+
if (!token) {
|
|
190
|
+
logger.debug('linear-connector - fetchTicket', 'No Linear token configured, skipping');
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const parsed = parseLinearIdentifier(identifier);
|
|
195
|
+
if (!parsed) {
|
|
196
|
+
logger.debug('linear-connector - fetchTicket', 'Invalid identifier format', { identifier });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const query = `
|
|
201
|
+
query FetchTicket($teamKey: String!, $number: Float!) {
|
|
202
|
+
issues(filter: {
|
|
203
|
+
team: { key: { eq: $teamKey } }
|
|
204
|
+
number: { eq: $number }
|
|
205
|
+
}, first: 1) {
|
|
206
|
+
nodes {
|
|
207
|
+
id
|
|
208
|
+
identifier
|
|
209
|
+
title
|
|
210
|
+
description
|
|
211
|
+
priority
|
|
212
|
+
state { name }
|
|
213
|
+
labels { nodes { name } }
|
|
214
|
+
assignee { name }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
const variables = { teamKey: parsed.teamKey, number: parsed.number };
|
|
221
|
+
|
|
222
|
+
// Step a: Test connection health
|
|
223
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
224
|
+
try {
|
|
225
|
+
// Test connection on first attempt
|
|
226
|
+
if (attempt === 0) {
|
|
227
|
+
const healthy = await testConnection(token);
|
|
228
|
+
if (!healthy) {
|
|
229
|
+
logger.debug(
|
|
230
|
+
'linear-connector - fetchTicket',
|
|
231
|
+
'Connection unhealthy, retrying',
|
|
232
|
+
{ attempt }
|
|
233
|
+
);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Step b: Fetch ticket
|
|
239
|
+
const data = await graphqlRequest(token, query, variables);
|
|
240
|
+
const ticket = data?.issues?.nodes?.[0];
|
|
241
|
+
|
|
242
|
+
if (!ticket) {
|
|
243
|
+
logger.debug('linear-connector - fetchTicket', 'Ticket not found', { identifier });
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
logger.debug('linear-connector - fetchTicket', 'Ticket fetched successfully', {
|
|
248
|
+
identifier: ticket.identifier,
|
|
249
|
+
title: ticket.title,
|
|
250
|
+
state: ticket.state?.name
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
id: ticket.id,
|
|
255
|
+
identifier: ticket.identifier,
|
|
256
|
+
title: ticket.title,
|
|
257
|
+
description: ticket.description || '',
|
|
258
|
+
priority: ticket.priority,
|
|
259
|
+
state: ticket.state?.name || 'unknown',
|
|
260
|
+
labels: (ticket.labels?.nodes || []).map((l) => l.name),
|
|
261
|
+
assignee: ticket.assignee?.name || null
|
|
262
|
+
};
|
|
263
|
+
} catch (error) {
|
|
264
|
+
logger.debug('linear-connector - fetchTicket', `Attempt ${attempt + 1} failed`, {
|
|
265
|
+
error: error.message,
|
|
266
|
+
attempt
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (attempt < MAX_RETRIES) {
|
|
270
|
+
// Steps c/d: Retry with reconnection
|
|
271
|
+
logger.debug(
|
|
272
|
+
'linear-connector - fetchTicket',
|
|
273
|
+
'Retrying after connection failure'
|
|
274
|
+
);
|
|
275
|
+
// Small delay before retry
|
|
276
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Graceful degradation
|
|
282
|
+
logger.warning(
|
|
283
|
+
'linear-connector - fetchTicket',
|
|
284
|
+
`Could not fetch Linear ticket ${identifier} after ${MAX_RETRIES + 1} attempts`
|
|
285
|
+
);
|
|
286
|
+
return null;
|
|
287
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: pr-statistics.js
|
|
3
|
+
* Purpose: Write-only JSONL statistics for PR analyses
|
|
4
|
+
*
|
|
5
|
+
* Design: Append-only JSONL file at .claude/statistics/pr/stats.jsonl
|
|
6
|
+
* No read/query functions (YAGNI).
|
|
7
|
+
*
|
|
8
|
+
* Dependencies:
|
|
9
|
+
* - fs: File system operations
|
|
10
|
+
* - path: Cross-platform path handling
|
|
11
|
+
* - logger: Debug logging
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import logger from './logger.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get repository root directory
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
const getRepoRoot = () => {
|
|
24
|
+
try {
|
|
25
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return process.cwd();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record a PR analysis result as a JSONL entry
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} data - Analysis data
|
|
35
|
+
* @param {string} data.url - PR URL
|
|
36
|
+
* @param {number} data.number - PR number
|
|
37
|
+
* @param {string} data.repo - Repository full name (owner/repo)
|
|
38
|
+
* @param {string} data.preset - Preset used
|
|
39
|
+
* @param {string} data.verdict - Analysis verdict
|
|
40
|
+
* @param {number} data.totalIssues - Total issues found
|
|
41
|
+
* @param {Object} data.issuesByCategory - Issues grouped by category
|
|
42
|
+
* @param {number} data.inlineCount - Number of inline comments
|
|
43
|
+
* @param {number} data.generalCount - Number of general comments
|
|
44
|
+
* @param {number} data.commentsPosted - Comments actually posted
|
|
45
|
+
* @param {number} data.commentsSkipped - Comments skipped by user
|
|
46
|
+
*/
|
|
47
|
+
export const recordPRAnalysis = (data) => {
|
|
48
|
+
try {
|
|
49
|
+
const repoRoot = getRepoRoot();
|
|
50
|
+
const statsDir = path.join(repoRoot, '.claude', 'statistics', 'pr');
|
|
51
|
+
const statsFile = path.join(statsDir, 'stats.jsonl');
|
|
52
|
+
|
|
53
|
+
// Ensure directory exists
|
|
54
|
+
if (!fs.existsSync(statsDir)) {
|
|
55
|
+
fs.mkdirSync(statsDir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const record = {
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
url: data.url,
|
|
61
|
+
number: data.number,
|
|
62
|
+
repo: data.repo,
|
|
63
|
+
preset: data.preset,
|
|
64
|
+
verdict: data.verdict,
|
|
65
|
+
totalIssues: data.totalIssues,
|
|
66
|
+
issuesByCategory: data.issuesByCategory,
|
|
67
|
+
inlineCount: data.inlineCount,
|
|
68
|
+
generalCount: data.generalCount,
|
|
69
|
+
commentsPosted: data.commentsPosted,
|
|
70
|
+
commentsSkipped: data.commentsSkipped
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
fs.appendFileSync(statsFile, `${JSON.stringify(record)}\n`, 'utf8');
|
|
74
|
+
|
|
75
|
+
logger.debug('pr-statistics - recordPRAnalysis', 'Statistics recorded', {
|
|
76
|
+
statsFile,
|
|
77
|
+
totalIssues: data.totalIssues
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Non-fatal: statistics failure should never block the command
|
|
81
|
+
logger.debug('pr-statistics - recordPRAnalysis', 'Failed to record statistics', {
|
|
82
|
+
error: error.message
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: token-store.js
|
|
3
|
+
* Purpose: Abstract utility for storing and loading tokens from .claude/settings.local.json
|
|
4
|
+
*
|
|
5
|
+
* Centralizes token persistence for all integrations (GitHub, Linear, etc.)
|
|
6
|
+
* Single source of truth for settings.local.json read/write operations.
|
|
7
|
+
*
|
|
8
|
+
* Token storage: .claude/settings.local.json (gitignored)
|
|
9
|
+
*
|
|
10
|
+
* Dependencies:
|
|
11
|
+
* - fs: File system operations
|
|
12
|
+
* - path: Cross-platform path handling
|
|
13
|
+
* - child_process: Git repo root detection
|
|
14
|
+
* - logger: Debug logging
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { execSync } from 'child_process';
|
|
20
|
+
import logger from './logger.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get repository root directory
|
|
24
|
+
* @returns {string} Absolute path to repo root
|
|
25
|
+
*/
|
|
26
|
+
const getRepoRoot = () => {
|
|
27
|
+
try {
|
|
28
|
+
return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return process.cwd();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the path to settings.local.json
|
|
36
|
+
* @returns {string} Absolute path
|
|
37
|
+
*/
|
|
38
|
+
const getSettingsPath = () => {
|
|
39
|
+
const repoRoot = getRepoRoot();
|
|
40
|
+
return path.join(repoRoot, '.claude', 'settings.local.json');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load all local settings from .claude/settings.local.json
|
|
45
|
+
* Why: Gitignored file for sensitive data like tokens
|
|
46
|
+
*
|
|
47
|
+
* @returns {Object} Settings object or empty object
|
|
48
|
+
*/
|
|
49
|
+
export const loadLocalSettings = () => {
|
|
50
|
+
try {
|
|
51
|
+
const settingsPath = getSettingsPath();
|
|
52
|
+
|
|
53
|
+
logger.debug('token-store - loadLocalSettings', 'Checking for settings file', {
|
|
54
|
+
settingsPath
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (fs.existsSync(settingsPath)) {
|
|
58
|
+
const content = fs.readFileSync(settingsPath, 'utf8');
|
|
59
|
+
const settings = JSON.parse(content);
|
|
60
|
+
logger.debug('token-store - loadLocalSettings', 'Settings loaded successfully');
|
|
61
|
+
return settings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logger.debug('token-store - loadLocalSettings', 'Settings file not found');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.debug('token-store - loadLocalSettings', 'Could not load local settings', {
|
|
67
|
+
error: error.message,
|
|
68
|
+
stack: error.stack
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return {};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Load a specific token from settings or environment variable
|
|
76
|
+
*
|
|
77
|
+
* @param {string} settingsKey - Key in settings.local.json (e.g., 'githubToken', 'linearToken')
|
|
78
|
+
* @param {string[]} envVars - Environment variable names to check, in priority order
|
|
79
|
+
* @returns {string|null} Token value or null if not found
|
|
80
|
+
*/
|
|
81
|
+
export const loadToken = (settingsKey, envVars = []) => {
|
|
82
|
+
// Priority 1: Environment variables (in order)
|
|
83
|
+
for (const envVar of envVars) {
|
|
84
|
+
if (process.env[envVar]) {
|
|
85
|
+
logger.debug('token-store - loadToken', `Using ${envVar} env var`);
|
|
86
|
+
return process.env[envVar];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Priority 2: Local settings file
|
|
91
|
+
const settings = loadLocalSettings();
|
|
92
|
+
if (settings[settingsKey]) {
|
|
93
|
+
logger.debug('token-store - loadToken', `Using ${settingsKey} from settings.local.json`);
|
|
94
|
+
return settings[settingsKey];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Save a token to .claude/settings.local.json
|
|
102
|
+
*
|
|
103
|
+
* @param {string} settingsKey - Key in settings.local.json (e.g., 'githubToken', 'linearToken')
|
|
104
|
+
* @param {string} token - Token value to save
|
|
105
|
+
* @returns {{ success: boolean, path: string, error?: string }}
|
|
106
|
+
*/
|
|
107
|
+
export const saveToken = (settingsKey, token) => {
|
|
108
|
+
try {
|
|
109
|
+
const repoRoot = getRepoRoot();
|
|
110
|
+
const claudeDir = path.join(repoRoot, '.claude');
|
|
111
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
112
|
+
|
|
113
|
+
logger.debug('token-store - saveToken', 'Saving token', { settingsKey, settingsPath });
|
|
114
|
+
|
|
115
|
+
// Ensure .claude directory exists
|
|
116
|
+
if (!fs.existsSync(claudeDir)) {
|
|
117
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Load existing settings or create new
|
|
121
|
+
let settings = {};
|
|
122
|
+
if (fs.existsSync(settingsPath)) {
|
|
123
|
+
try {
|
|
124
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
125
|
+
} catch {
|
|
126
|
+
logger.debug('token-store - saveToken', 'Invalid existing settings, starting fresh');
|
|
127
|
+
settings = {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Update token
|
|
132
|
+
settings[settingsKey] = token;
|
|
133
|
+
|
|
134
|
+
// Add comment if new file
|
|
135
|
+
if (!settings._comment) {
|
|
136
|
+
settings._comment = 'Local settings - DO NOT COMMIT. This file is gitignored.';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Save settings with restricted permissions (owner read/write only)
|
|
140
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
141
|
+
|
|
142
|
+
logger.debug('token-store - saveToken', 'Token saved successfully');
|
|
143
|
+
|
|
144
|
+
return { success: true, path: settingsPath };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
logger.error('token-store - saveToken', 'Failed to save token', error);
|
|
147
|
+
return { success: false, path: null, error: error.message };
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a token exists (in env vars or settings)
|
|
153
|
+
*
|
|
154
|
+
* @param {string} settingsKey - Key in settings.local.json
|
|
155
|
+
* @param {string[]} envVars - Environment variable names to check
|
|
156
|
+
* @returns {boolean}
|
|
157
|
+
*/
|
|
158
|
+
export const hasToken = (settingsKey, envVars = []) =>
|
|
159
|
+
loadToken(settingsKey, envVars) !== null;
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# PR Analysis
|
|
2
|
+
|
|
3
|
+
You are a senior code reviewer analyzing a Pull Request. Your goal is to identify issues, suggest improvements, and provide structured feedback.
|
|
4
|
+
|
|
5
|
+
## PR Context
|
|
6
|
+
|
|
7
|
+
**Title:** {{PR_TITLE}}
|
|
8
|
+
|
|
9
|
+
**Description:**
|
|
10
|
+
{{PR_BODY}}
|
|
11
|
+
|
|
12
|
+
## Ticket Context
|
|
13
|
+
|
|
14
|
+
{{TICKET_CONTEXT}}
|
|
15
|
+
|
|
16
|
+
## Changed Files
|
|
17
|
+
|
|
18
|
+
{{PR_FILES}}
|
|
19
|
+
|
|
20
|
+
## Diff
|
|
21
|
+
|
|
22
|
+
{{PR_DIFF}}
|
|
23
|
+
|
|
24
|
+
## Preset Guidelines
|
|
25
|
+
|
|
26
|
+
{{PRESET_GUIDELINES}}
|
|
27
|
+
|
|
28
|
+
## Category Definitions
|
|
29
|
+
|
|
30
|
+
### Inline Categories (attached to specific file:line)
|
|
31
|
+
{{INLINE_CATEGORIES}}
|
|
32
|
+
|
|
33
|
+
### General Categories (review-level observations)
|
|
34
|
+
{{GENERAL_CATEGORIES}}
|
|
35
|
+
|
|
36
|
+
## Instructions
|
|
37
|
+
|
|
38
|
+
1. Review the PR diff thoroughly, considering the ticket context and preset guidelines.
|
|
39
|
+
2. Classify each finding into EXACTLY one of the categories listed above. Use ONLY the exact category values listed — do not invent new categories.
|
|
40
|
+
3. For code-level issues (bugs, security vulnerabilities, performance problems, complex hotspots), create **inline comments** with the exact file path and line number from the diff.
|
|
41
|
+
4. For review-level observations (scope creep, style consistency, missing tests, documentation gaps, ticket alignment), create **general comments**.
|
|
42
|
+
5. Assign severity: `critical`, `major`, `minor`, or `info`.
|
|
43
|
+
6. For inline comments, include a `suggestion` field with the recommended fix when applicable.
|
|
44
|
+
|
|
45
|
+
## Output Format
|
|
46
|
+
|
|
47
|
+
Return a JSON object with this exact structure:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"verdict": "approve" | "request-changes" | "comment",
|
|
52
|
+
"summary": "Brief overall assessment (1-2 sentences)",
|
|
53
|
+
"inlineComments": [
|
|
54
|
+
{
|
|
55
|
+
"path": "src/file.js",
|
|
56
|
+
"line": 42,
|
|
57
|
+
"side": "RIGHT",
|
|
58
|
+
"category": "<inline category>",
|
|
59
|
+
"severity": "critical|major|minor|info",
|
|
60
|
+
"body": "Description of the issue",
|
|
61
|
+
"suggestion": "Recommended fix (optional)"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"generalComments": [
|
|
65
|
+
{
|
|
66
|
+
"category": "<general category>",
|
|
67
|
+
"severity": "critical|major|minor|info",
|
|
68
|
+
"body": "Description of the observation"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
IMPORTANT:
|
|
75
|
+
- Use ONLY the exact category values from the lists above
|
|
76
|
+
- `line` must reference a line number visible in the diff (added or context lines)
|
|
77
|
+
- `side` should be "RIGHT" for added/modified lines, "LEFT" for removed lines
|
|
78
|
+
- If no issues found, return verdict "approve" with empty arrays
|
|
79
|
+
- Be strict but fair — only flag genuine issues, not style preferences unless the preset guidelines explicitly require them
|
|
@@ -31,6 +31,22 @@
|
|
|
31
31
|
"judge": {
|
|
32
32
|
"enabled": true,
|
|
33
33
|
"model": "opus"
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
"prAnalysis": {
|
|
37
|
+
"model": "sonnet",
|
|
38
|
+
"timeout": 300000,
|
|
39
|
+
"inlineCategories": ["bug", "security", "performance", "hotspot"],
|
|
40
|
+
"generalCategories": [
|
|
41
|
+
"ticket-alignment",
|
|
42
|
+
"scope",
|
|
43
|
+
"style",
|
|
44
|
+
"good-practice",
|
|
45
|
+
"extensibility",
|
|
46
|
+
"observability",
|
|
47
|
+
"documentation",
|
|
48
|
+
"testing"
|
|
49
|
+
]
|
|
34
50
|
}
|
|
35
51
|
},
|
|
36
52
|
|
|
@@ -61,9 +77,34 @@
|
|
|
61
77
|
|
|
62
78
|
"judge.model": {
|
|
63
79
|
"description": "Model for the judge LLM call",
|
|
64
|
-
"default": "
|
|
65
|
-
"examples": ["
|
|
66
|
-
"use_case": "
|
|
80
|
+
"default": "sonnet",
|
|
81
|
+
"examples": ["sonnet", "opus", "haiku"],
|
|
82
|
+
"use_case": "Override the default sonnet model for judge passes"
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
"prAnalysis.model": {
|
|
86
|
+
"description": "Claude model for PR analysis",
|
|
87
|
+
"default": "sonnet",
|
|
88
|
+
"examples": ["sonnet", "opus", "haiku"],
|
|
89
|
+
"use_case": "Use a more capable model for deeper analysis or a cheaper one for speed"
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
"prAnalysis.timeout": {
|
|
93
|
+
"description": "Timeout in milliseconds for the PR analysis Claude call",
|
|
94
|
+
"default": "300000",
|
|
95
|
+
"use_case": "Increase for very large PRs"
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
"prAnalysis.inlineCategories": {
|
|
99
|
+
"description": "Categories for inline (file:line) comments",
|
|
100
|
+
"default": "[\"bug\", \"security\", \"performance\", \"hotspot\"]",
|
|
101
|
+
"use_case": "Customize which issue types generate inline PR comments"
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
"prAnalysis.generalCategories": {
|
|
105
|
+
"description": "Categories for general (review-level) comments",
|
|
106
|
+
"default": "[\"ticket-alignment\", \"scope\", \"style\", \"good-practice\", \"extensibility\", \"observability\", \"documentation\", \"testing\"]",
|
|
107
|
+
"use_case": "Customize which observation types appear in the review body"
|
|
67
108
|
}
|
|
68
109
|
},
|
|
69
110
|
|