-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.ts
More file actions
249 lines (218 loc) · 7.87 KB
/
Copy pathclient.ts
File metadata and controls
249 lines (218 loc) · 7.87 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import { readSSE } from './sse.ts';
import { parseFinding, type Finding, type ReviewRequest, type ReviewResult } from './types.ts';
const REVIEW_PATH = '/ptaas/ajax/lory-code-review.php';
export type LoryErrorKind =
| 'rate-limit'
| 'auth'
| 'transport'
| 'protocol'
| 'server'
| 'cancelled';
/** A failure worth showing the user, separated from a bug worth logging. */
export class LoryError extends Error {
// Declared and assigned rather than a constructor parameter property, so this
// module stays loadable by `node --test --experimental-strip-types`.
readonly kind: LoryErrorKind;
constructor(message: string, kind: LoryErrorKind) {
super(message);
this.name = 'LoryError';
this.kind = kind;
}
}
export interface ClientOptions {
baseUrl: string;
timeoutMs: number;
userAgent: string;
/**
* Optional `lkmcp_` token.
*
* Buys a higher rate limit keyed to the token rather than to your IP, which
* matters when a team shares one egress address. It unlocks no data: this
* endpoint reads nothing belonging to the company.
*/
token?: string | undefined;
}
/**
* Talks to the Lory code review endpoint. Stateless: every review is one
* request with no conversation history, so two reviews of the same bytes give
* the same answer and the caller can cache on content alone.
*/
export class LoryClient {
private options: ClientOptions;
constructor(options: ClientOptions) {
this.options = options;
}
update(options: Partial<ClientOptions>): void {
this.options = { ...this.options, ...options };
}
get reviewUrl(): string {
return this.options.baseUrl + REVIEW_PATH;
}
/**
* Review one fragment.
*
* When `onFinding` is supplied the request streams and each finding is handed
* over as it lands, so the first marker appears well before the review ends.
*/
async review(
request: ReviewRequest,
signal: AbortSignal,
onFinding?: (finding: Finding) => void
): Promise<ReviewResult> {
const stream = request.stream !== false && onFinding !== undefined;
// A caller-supplied signal cancels on the next keystroke; the timeout signal
// cancels a server that never answers. Either aborts the same fetch.
const timeout = AbortSignal.timeout(this.options.timeoutMs);
const combined = anySignal([signal, timeout]);
let response: Response;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: stream ? 'text/event-stream' : 'application/json',
'User-Agent': this.options.userAgent,
};
if (this.options.token) {
headers['Authorization'] = `Bearer ${this.options.token}`;
}
response = await fetch(this.reviewUrl, {
method: 'POST',
headers,
body: JSON.stringify({ ...request, stream }),
signal: combined,
});
} catch (err) {
if (signal.aborted) {
throw new LoryError('Review cancelled.', 'cancelled');
}
if (timeout.aborted) {
throw new LoryError(
`Lory did not answer within ${Math.round(this.options.timeoutMs / 1000)}s.`,
'transport'
);
}
throw new LoryError(`Could not reach ${this.reviewUrl}: ${errorText(err)}`, 'transport');
}
if (response.status === 401) {
throw new LoryError(
'Your Lory token was rejected. Run "Lory: Set Access Token" to replace it, ' +
'or clear it to fall back to the anonymous rate limit.',
'auth'
);
}
if (response.status === 429) {
const retry = response.headers.get('retry-after');
const limit = response.headers.get('x-ratelimit-limit');
throw new LoryError(
`Lory is rate limiting you${limit ? ` (${limit}/min)` : ''}.` +
`${retry ? ` Retry in ${retry}s.` : ''}` +
(this.options.token
? ''
: ' A Lory access token raises the ceiling and keys it to you rather than your IP.'),
'rate-limit'
);
}
if (response.status >= 500) {
throw new LoryError(`Lory returned HTTP ${response.status}.`, 'server');
}
if (!response.ok) {
throw new LoryError(`Lory returned HTTP ${response.status}.`, 'protocol');
}
const contentType = response.headers.get('content-type') ?? '';
// The endpoint falls back to a JSON error body when it rejects a streaming
// request, so content-type decides how to read it, not what we asked for.
if (stream && contentType.includes('text/event-stream')) {
return this.readStream(response, onFinding!, signal);
}
return this.readJson(response);
}
private async readJson(response: Response): Promise<ReviewResult> {
let body: unknown;
try {
body = await response.json();
} catch {
throw new LoryError('Lory returned a body that is not JSON.', 'protocol');
}
if (typeof body !== 'object' || body === null) {
throw new LoryError('Lory returned an unexpected body.', 'protocol');
}
const data = body as Record<string, unknown>;
if (data['success'] === false) {
throw new LoryError(String(data['error'] ?? 'Lory rejected the request.'), 'server');
}
if (!Array.isArray(data['findings'])) {
throw new LoryError('Lory returned no findings array.', 'protocol');
}
return {
findings: data['findings'].map(parseFinding).filter((f): f is Finding => f !== null),
summary: typeof data['summary'] === 'string' ? data['summary'] : '',
...(isMeta(data['meta']) ? { meta: data['meta'] } : {}),
};
}
private async readStream(
response: Response,
onFinding: (finding: Finding) => void,
signal: AbortSignal
): Promise<ReviewResult> {
if (!response.body) {
throw new LoryError('Lory returned an empty stream.', 'protocol');
}
const findings: Finding[] = [];
let summary = '';
let meta: ReviewResult['meta'];
let streamError: string | null = null;
for await (const event of readSSE(response.body, signal)) {
if (event.name === 'finding') {
const finding = parseFinding(event.data);
if (finding) {
findings.push(finding);
onFinding(finding);
}
} else if (event.name === 'done') {
const data = (event.data ?? {}) as Record<string, unknown>;
summary = typeof data['summary'] === 'string' ? data['summary'] : '';
if (isMeta(data['meta'])) {
meta = data['meta'];
}
} else if (event.name === 'error') {
const data = (event.data ?? {}) as Record<string, unknown>;
streamError = String(data['error'] ?? 'Lory reported an error.');
}
}
// readSSE stops quietly when the signal trips, so the distinction between
// "cancelled" and "the review ended" is drawn here.
if (signal.aborted) {
throw new LoryError('Review cancelled.', 'cancelled');
}
// A mid-stream error invalidates the whole review: the findings received so
// far are a prefix, and showing a prefix as if it were complete tells the
// developer their file is clean past the point the stream broke.
if (streamError) {
throw new LoryError(streamError, 'server');
}
return { findings, summary, ...(meta ? { meta } : {}) };
}
}
function isMeta(value: unknown): value is ReviewResult['meta'] & object {
return typeof value === 'object' && value !== null;
}
/**
* Combine abort signals.
*
* `AbortSignal.any` landed in Node 20 and the extension targets Node 18, so
* this is the portable equivalent.
*/
function anySignal(signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
const abort = () => controller.abort();
for (const signal of signals) {
if (signal.aborted) {
controller.abort();
break;
}
signal.addEventListener('abort', abort, { once: true });
}
return controller.signal;
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}