@foundation0/git 1.2.5 → 1.3.1
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 +291 -291
- package/gitea-swagger.json +28627 -28627
- package/mcp/README.md +266 -250
- package/mcp/cli.mjs +37 -37
- package/mcp/src/cli.ts +76 -76
- package/mcp/src/client.ts +147 -147
- package/mcp/src/index.ts +7 -7
- package/mcp/src/redaction.ts +207 -207
- package/mcp/src/server.ts +1778 -718
- package/package.json +3 -1
- package/src/actions-api.ts +900 -531
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -0
- package/src/git-service-api.ts +822 -683
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -54
- package/src/issue-dependencies.ts +533 -469
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -448
- package/src/platform/gitea-rules.ts +129 -129
- package/src/platform/index.ts +44 -44
- package/src/repository.ts +151 -151
- package/src/spec-mock.ts +45 -45
|
@@ -1,469 +1,533 @@
|
|
|
1
|
-
import type { GitServiceApiExecutionResult } from './git-service-api'
|
|
2
|
-
import { spawn } from 'node:child_process'
|
|
3
|
-
import crypto from 'node:crypto'
|
|
4
|
-
|
|
5
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
|
|
6
|
-
|
|
7
|
-
const parseRequestTimeoutMs = (value: unknown): number | null => {
|
|
8
|
-
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
9
|
-
return Math.floor(value)
|
|
10
|
-
}
|
|
11
|
-
if (typeof value !== 'string') {
|
|
12
|
-
return null
|
|
13
|
-
}
|
|
14
|
-
const trimmed = value.trim()
|
|
15
|
-
if (!trimmed) return null
|
|
16
|
-
const parsed = Number(trimmed)
|
|
17
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
18
|
-
return Math.floor(parsed)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const resolveRequestTimeoutMs = (): number => {
|
|
22
|
-
const fromEnv =
|
|
23
|
-
parseRequestTimeoutMs(process.env.EXAMPLE_GIT_REQUEST_TIMEOUT_MS) ??
|
|
24
|
-
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_REQUEST_TIMEOUT_MS) ??
|
|
25
|
-
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_TIMEOUT_MS)
|
|
26
|
-
return fromEnv ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const canUseAbortSignalTimeout = (): boolean =>
|
|
30
|
-
typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
|
|
31
|
-
|
|
32
|
-
const isTestRuntime = (): boolean =>
|
|
33
|
-
Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test'
|
|
34
|
-
|
|
35
|
-
const resolveHttpTransport = (requested?: string): 'fetch' | 'curl' => {
|
|
36
|
-
const normalized = (requested ?? '').trim().toLowerCase()
|
|
37
|
-
if (normalized === 'fetch' || normalized === 'curl') {
|
|
38
|
-
return normalized
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const isBun = Boolean(process.versions?.bun)
|
|
42
|
-
if (!isTestRuntime() && isBun && process.platform === 'win32') {
|
|
43
|
-
return 'curl'
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return 'fetch'
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const fetchWithTimeout = async (url: string, init: RequestInit): Promise<Response> => {
|
|
50
|
-
const requestTimeoutMs = resolveRequestTimeoutMs()
|
|
51
|
-
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
52
|
-
return fetch(url, init)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const timeoutSignal =
|
|
56
|
-
requestTimeoutMs > 0 && canUseAbortSignalTimeout()
|
|
57
|
-
? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
|
|
58
|
-
: null
|
|
59
|
-
const controller = !timeoutSignal ? new AbortController() : null
|
|
60
|
-
const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
|
|
61
|
-
try {
|
|
62
|
-
return await fetch(url, {
|
|
63
|
-
...init,
|
|
64
|
-
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
|
65
|
-
...(controller ? { signal: controller.signal } : {}),
|
|
66
|
-
})
|
|
67
|
-
} catch (error) {
|
|
68
|
-
if ((timeoutSignal && timeoutSignal.aborted) || controller?.signal.aborted) {
|
|
69
|
-
throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method ?? 'GET'} ${url}`)
|
|
70
|
-
}
|
|
71
|
-
throw error
|
|
72
|
-
} finally {
|
|
73
|
-
if (timeoutId) {
|
|
74
|
-
clearTimeout(timeoutId)
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const readStream = async (stream: NodeJS.ReadableStream | null): Promise<Buffer> => {
|
|
80
|
-
if (!stream) return Buffer.from([])
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const
|
|
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
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
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
|
-
const
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
):
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
1
|
+
import type { GitServiceApiExecutionResult } from './git-service-api'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
|
|
6
|
+
|
|
7
|
+
const parseRequestTimeoutMs = (value: unknown): number | null => {
|
|
8
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
9
|
+
return Math.floor(value)
|
|
10
|
+
}
|
|
11
|
+
if (typeof value !== 'string') {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
const trimmed = value.trim()
|
|
15
|
+
if (!trimmed) return null
|
|
16
|
+
const parsed = Number(trimmed)
|
|
17
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
18
|
+
return Math.floor(parsed)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const resolveRequestTimeoutMs = (): number => {
|
|
22
|
+
const fromEnv =
|
|
23
|
+
parseRequestTimeoutMs(process.env.EXAMPLE_GIT_REQUEST_TIMEOUT_MS) ??
|
|
24
|
+
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_REQUEST_TIMEOUT_MS) ??
|
|
25
|
+
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_TIMEOUT_MS)
|
|
26
|
+
return fromEnv ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const canUseAbortSignalTimeout = (): boolean =>
|
|
30
|
+
typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
|
|
31
|
+
|
|
32
|
+
const isTestRuntime = (): boolean =>
|
|
33
|
+
Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test'
|
|
34
|
+
|
|
35
|
+
const resolveHttpTransport = (requested?: string): 'fetch' | 'curl' => {
|
|
36
|
+
const normalized = (requested ?? '').trim().toLowerCase()
|
|
37
|
+
if (normalized === 'fetch' || normalized === 'curl') {
|
|
38
|
+
return normalized
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isBun = Boolean(process.versions?.bun)
|
|
42
|
+
if (!isTestRuntime() && isBun && process.platform === 'win32') {
|
|
43
|
+
return 'curl'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return 'fetch'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const fetchWithTimeout = async (url: string, init: RequestInit): Promise<Response> => {
|
|
50
|
+
const requestTimeoutMs = resolveRequestTimeoutMs()
|
|
51
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
|
52
|
+
return fetch(url, init)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const timeoutSignal =
|
|
56
|
+
requestTimeoutMs > 0 && canUseAbortSignalTimeout()
|
|
57
|
+
? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
|
|
58
|
+
: null
|
|
59
|
+
const controller = !timeoutSignal ? new AbortController() : null
|
|
60
|
+
const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
|
|
61
|
+
try {
|
|
62
|
+
return await fetch(url, {
|
|
63
|
+
...init,
|
|
64
|
+
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
|
65
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
66
|
+
})
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if ((timeoutSignal && timeoutSignal.aborted) || controller?.signal.aborted) {
|
|
69
|
+
throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method ?? 'GET'} ${url}`)
|
|
70
|
+
}
|
|
71
|
+
throw error
|
|
72
|
+
} finally {
|
|
73
|
+
if (timeoutId) {
|
|
74
|
+
clearTimeout(timeoutId)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const readStream = async (stream: NodeJS.ReadableStream | null): Promise<Buffer> => {
|
|
80
|
+
if (!stream) return Buffer.from([])
|
|
81
|
+
|
|
82
|
+
return await new Promise<Buffer>((resolve) => {
|
|
83
|
+
const chunks: Buffer[] = []
|
|
84
|
+
let settled = false
|
|
85
|
+
|
|
86
|
+
const cleanup = () => {
|
|
87
|
+
stream.removeListener('data', onData)
|
|
88
|
+
stream.removeListener('end', onDone)
|
|
89
|
+
stream.removeListener('close', onDone)
|
|
90
|
+
stream.removeListener('error', onDone)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const settle = () => {
|
|
94
|
+
if (settled) return
|
|
95
|
+
settled = true
|
|
96
|
+
cleanup()
|
|
97
|
+
resolve(Buffer.concat(chunks))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const onDone = () => settle()
|
|
101
|
+
|
|
102
|
+
const onData = (chunk: unknown) => {
|
|
103
|
+
try {
|
|
104
|
+
if (typeof chunk === 'string') {
|
|
105
|
+
chunks.push(Buffer.from(chunk))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
chunks.push(Buffer.from(chunk as ArrayBufferView))
|
|
110
|
+
} catch {
|
|
111
|
+
// best effort
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
stream.on('data', onData)
|
|
116
|
+
stream.on('end', onDone)
|
|
117
|
+
stream.on('close', onDone)
|
|
118
|
+
stream.on('error', onDone)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const callCurl = async (
|
|
123
|
+
requestUrl: string,
|
|
124
|
+
init: { method: string; headers: Record<string, string>; body?: string },
|
|
125
|
+
requestTimeoutMs: number,
|
|
126
|
+
): Promise<{ status: number; ok: boolean; bodyText: string }> => {
|
|
127
|
+
const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl'
|
|
128
|
+
const statusToken = crypto.randomBytes(8).toString('hex')
|
|
129
|
+
const marker = `\n__EXAMPLE_CURL_STATUS_${statusToken}__`
|
|
130
|
+
const writeOut = `${marker}%{http_code}${marker}`
|
|
131
|
+
|
|
132
|
+
const args: string[] = [
|
|
133
|
+
'--silent',
|
|
134
|
+
'--show-error',
|
|
135
|
+
'--location',
|
|
136
|
+
'--request', init.method,
|
|
137
|
+
'--write-out', writeOut,
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
const timeoutSeconds = requestTimeoutMs > 0 ? Math.max(1, Math.ceil(requestTimeoutMs / 1000)) : null
|
|
141
|
+
if (timeoutSeconds !== null) {
|
|
142
|
+
args.push('--max-time', String(timeoutSeconds))
|
|
143
|
+
args.push('--connect-timeout', String(Math.max(1, Math.min(30, timeoutSeconds))))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const [name, value] of Object.entries(init.headers)) {
|
|
147
|
+
args.push('--header', `${name}: ${value}`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (init.body !== undefined) {
|
|
151
|
+
args.push('--data-binary', '@-')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
args.push(requestUrl)
|
|
155
|
+
|
|
156
|
+
const child = spawn(curlExe, args, {
|
|
157
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
158
|
+
windowsHide: true,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const hardTimeoutMs = requestTimeoutMs > 0 ? requestTimeoutMs + 2_000 : null
|
|
162
|
+
let hardTimedOut = false
|
|
163
|
+
const hardTimeoutId = hardTimeoutMs
|
|
164
|
+
? setTimeout(() => {
|
|
165
|
+
hardTimedOut = true
|
|
166
|
+
try {
|
|
167
|
+
child.kill()
|
|
168
|
+
} catch {
|
|
169
|
+
// best effort
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
child.stdout?.destroy()
|
|
173
|
+
} catch {
|
|
174
|
+
// best effort
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
child.stderr?.destroy()
|
|
178
|
+
} catch {
|
|
179
|
+
// best effort
|
|
180
|
+
}
|
|
181
|
+
}, hardTimeoutMs)
|
|
182
|
+
: null
|
|
183
|
+
|
|
184
|
+
if (init.body !== undefined) {
|
|
185
|
+
child.stdin.write(init.body)
|
|
186
|
+
}
|
|
187
|
+
child.stdin.end()
|
|
188
|
+
|
|
189
|
+
const stdoutPromise = readStream(child.stdout)
|
|
190
|
+
const stderrPromise = readStream(child.stderr)
|
|
191
|
+
|
|
192
|
+
let exitCode: number
|
|
193
|
+
try {
|
|
194
|
+
exitCode = await new Promise((resolve) => {
|
|
195
|
+
child.on('close', (code) => resolve(code ?? 0))
|
|
196
|
+
child.on('error', () => resolve(1))
|
|
197
|
+
})
|
|
198
|
+
} finally {
|
|
199
|
+
if (hardTimeoutId) {
|
|
200
|
+
clearTimeout(hardTimeoutId)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const [stdoutBytes, stderrBytes] = await Promise.all([stdoutPromise, stderrPromise])
|
|
205
|
+
|
|
206
|
+
if (hardTimedOut && requestTimeoutMs > 0) {
|
|
207
|
+
throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const stdout = stdoutBytes.toString('utf8')
|
|
211
|
+
const stderr = stderrBytes.toString('utf8').trim()
|
|
212
|
+
|
|
213
|
+
if (exitCode !== 0) {
|
|
214
|
+
const message = stderr || `curl failed with exit code ${exitCode}`
|
|
215
|
+
if (exitCode === 28 && requestTimeoutMs > 0) {
|
|
216
|
+
throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
|
|
217
|
+
}
|
|
218
|
+
throw new Error(message)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const endMarkerIndex = stdout.lastIndexOf(marker)
|
|
222
|
+
const startMarkerIndex = endMarkerIndex > -1 ? stdout.lastIndexOf(marker, endMarkerIndex - 1) : -1
|
|
223
|
+
if (startMarkerIndex < 0 || endMarkerIndex < 0) {
|
|
224
|
+
throw new Error('Failed to parse curl response status code.')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const statusText = stdout.slice(startMarkerIndex + marker.length, endMarkerIndex).trim()
|
|
228
|
+
const status = Number(statusText)
|
|
229
|
+
const bodyText = stdout.slice(0, startMarkerIndex)
|
|
230
|
+
|
|
231
|
+
if (!Number.isFinite(status) || status <= 0) {
|
|
232
|
+
throw new Error(`Invalid curl status code: ${statusText}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
status,
|
|
237
|
+
ok: status >= 200 && status < 300,
|
|
238
|
+
bodyText,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface GitIssueDependencyPayload {
|
|
243
|
+
index: number
|
|
244
|
+
owner: string
|
|
245
|
+
repo: string
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface GitIssueDependencySyncResult {
|
|
249
|
+
added: number
|
|
250
|
+
removed: number
|
|
251
|
+
unchanged: number
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type DependencyIssueRecord = {
|
|
255
|
+
number?: unknown
|
|
256
|
+
index?: unknown
|
|
257
|
+
issue?: unknown
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const toIssueNumber = (value: unknown): number | null => {
|
|
261
|
+
const candidate = Number(value)
|
|
262
|
+
if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
return candidate
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const collectFromIssueRecord = (record: Record<string, unknown> | null | undefined): number[] => {
|
|
269
|
+
const values: number[] = []
|
|
270
|
+
if (!record) return values
|
|
271
|
+
|
|
272
|
+
const direct = toIssueNumber(record.number)
|
|
273
|
+
if (direct !== null) values.push(direct)
|
|
274
|
+
|
|
275
|
+
const fallback = toIssueNumber(record.index)
|
|
276
|
+
if (fallback !== null) values.push(fallback)
|
|
277
|
+
|
|
278
|
+
const issue = record.issue
|
|
279
|
+
if (issue && typeof issue === 'object' && !Array.isArray(issue)) {
|
|
280
|
+
const nested = issue as Record<string, unknown>
|
|
281
|
+
const nestedNumber = toIssueNumber(nested.number)
|
|
282
|
+
if (nestedNumber !== null) values.push(nestedNumber)
|
|
283
|
+
|
|
284
|
+
const nestedIndex = toIssueNumber(nested.index)
|
|
285
|
+
if (nestedIndex !== null) values.push(nestedIndex)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return values
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function extractDependencyIssueNumbers(raw: unknown): number[] {
|
|
292
|
+
const values: number[] = []
|
|
293
|
+
|
|
294
|
+
if (Array.isArray(raw)) {
|
|
295
|
+
for (const item of raw) {
|
|
296
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const candidate = item as DependencyIssueRecord
|
|
301
|
+
const direct = toIssueNumber(candidate.number)
|
|
302
|
+
if (direct !== null) values.push(direct)
|
|
303
|
+
|
|
304
|
+
const fallback = toIssueNumber(candidate.index)
|
|
305
|
+
if (fallback !== null) {
|
|
306
|
+
values.push(fallback)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
values.push(...collectFromIssueRecord(candidate))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return [...new Set(values)]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
316
|
+
const container = raw as { dependencies?: unknown }
|
|
317
|
+
if (Array.isArray(container.dependencies)) {
|
|
318
|
+
return extractDependencyIssueNumbers(container.dependencies)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (Array.isArray((container as { blocks?: unknown }).blocks)) {
|
|
322
|
+
return extractDependencyIssueNumbers((container as { blocks?: unknown }).blocks)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (Array.isArray((container as { issues?: unknown }).issues)) {
|
|
326
|
+
return extractDependencyIssueNumbers((container as { issues?: unknown }).issues)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return collectFromIssueRecord(container as Record<string, unknown>)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return []
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const resolveGiteaApiBase = (host: string): string => {
|
|
336
|
+
const trimmed = host.trim().replace(/\/$/, '')
|
|
337
|
+
return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const resolveRequiredGiteaHost = (host: string | undefined): string => {
|
|
341
|
+
const resolved = host?.trim() ?? process.env.GITEA_HOST?.trim()
|
|
342
|
+
if (!resolved) {
|
|
343
|
+
throw new Error('GITEA_HOST is required. Pass host explicitly or set process.env.GITEA_HOST.')
|
|
344
|
+
}
|
|
345
|
+
return resolved
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const buildIssueDependenciesUrl = (host: string, owner: string, repo: string, issueNumber: number): string => {
|
|
349
|
+
return `${resolveGiteaApiBase(host)}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/dependencies`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function callIssueDependenciesApi(
|
|
353
|
+
method: 'GET' | 'POST' | 'DELETE',
|
|
354
|
+
owner: string,
|
|
355
|
+
repo: string,
|
|
356
|
+
issueNumber: number,
|
|
357
|
+
host: string | undefined,
|
|
358
|
+
token: string | undefined,
|
|
359
|
+
payload?: GitIssueDependencyPayload
|
|
360
|
+
): Promise<GitServiceApiExecutionResult<unknown>> {
|
|
361
|
+
const resolvedHost = resolveRequiredGiteaHost(host)
|
|
362
|
+
const requestUrl = buildIssueDependenciesUrl(resolvedHost, owner, repo, issueNumber)
|
|
363
|
+
const headers = {
|
|
364
|
+
Accept: 'application/json',
|
|
365
|
+
...(token ? { Authorization: `token ${token}` } : {}),
|
|
366
|
+
...(payload ? { 'Content-Type': 'application/json' } : {}),
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const requestTimeoutMs = resolveRequestTimeoutMs()
|
|
370
|
+
const transport = resolveHttpTransport(process.env.EXAMPLE_GIT_HTTP_TRANSPORT)
|
|
371
|
+
|
|
372
|
+
let ok = false
|
|
373
|
+
let status = 0
|
|
374
|
+
let parsedBody: unknown = ''
|
|
375
|
+
let responseText = ''
|
|
376
|
+
|
|
377
|
+
if (transport === 'curl') {
|
|
378
|
+
const result = await callCurl(
|
|
379
|
+
requestUrl,
|
|
380
|
+
{
|
|
381
|
+
method,
|
|
382
|
+
headers,
|
|
383
|
+
...(payload ? { body: JSON.stringify(payload) } : {}),
|
|
384
|
+
},
|
|
385
|
+
requestTimeoutMs,
|
|
386
|
+
)
|
|
387
|
+
ok = result.ok
|
|
388
|
+
status = result.status
|
|
389
|
+
responseText = result.bodyText
|
|
390
|
+
} else {
|
|
391
|
+
const response = await fetchWithTimeout(requestUrl, {
|
|
392
|
+
method,
|
|
393
|
+
headers,
|
|
394
|
+
body: payload ? JSON.stringify(payload) : undefined,
|
|
395
|
+
})
|
|
396
|
+
ok = response.ok
|
|
397
|
+
status = response.status
|
|
398
|
+
responseText = await response.text()
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
parsedBody = responseText
|
|
402
|
+
try {
|
|
403
|
+
parsedBody = JSON.parse(responseText)
|
|
404
|
+
} catch {
|
|
405
|
+
// keep raw text
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
ok,
|
|
410
|
+
status,
|
|
411
|
+
body: parsedBody,
|
|
412
|
+
mapping: {
|
|
413
|
+
featurePath: ['issue', 'dependencies'],
|
|
414
|
+
mappedPath: ['issue', 'dependencies'],
|
|
415
|
+
method,
|
|
416
|
+
query: [],
|
|
417
|
+
headers: [],
|
|
418
|
+
apiBase: resolveGiteaApiBase(resolvedHost),
|
|
419
|
+
swaggerPath: '/repos/{owner}/{repo}/issues/{index}/dependencies',
|
|
420
|
+
mapped: true,
|
|
421
|
+
},
|
|
422
|
+
request: {
|
|
423
|
+
url: requestUrl,
|
|
424
|
+
method,
|
|
425
|
+
headers: {
|
|
426
|
+
Accept: 'application/json',
|
|
427
|
+
...(payload ? { 'Content-Type': 'application/json' } : {}),
|
|
428
|
+
},
|
|
429
|
+
query: [],
|
|
430
|
+
body: payload,
|
|
431
|
+
},
|
|
432
|
+
response: {
|
|
433
|
+
headers: {},
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function syncIssueDependencies(
|
|
439
|
+
owner: string,
|
|
440
|
+
repo: string,
|
|
441
|
+
issueNumber: number,
|
|
442
|
+
desiredDependencyIssueNumbers: number[],
|
|
443
|
+
host: string | undefined,
|
|
444
|
+
token: string | undefined,
|
|
445
|
+
dryRun: boolean,
|
|
446
|
+
log?: ((message: string) => void) | null
|
|
447
|
+
): Promise<GitIssueDependencySyncResult> {
|
|
448
|
+
const existingResult = await callIssueDependenciesApi('GET', owner, repo, issueNumber, host, token)
|
|
449
|
+
if (!existingResult.ok) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`Failed to load issue dependencies for #${issueNumber}: status=${existingResult.status} url=${existingResult.request.url}`
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const existing = extractDependencyIssueNumbers(existingResult.body)
|
|
456
|
+
const desired = [...new Set(desiredDependencyIssueNumbers.filter((value): value is number => value > 0))]
|
|
457
|
+
const existingSet = new Set(existing)
|
|
458
|
+
const desiredSet = new Set(desired)
|
|
459
|
+
const toAdd = desired.filter((value) => !existingSet.has(value))
|
|
460
|
+
const toRemove = existing.filter((value) => !desiredSet.has(value))
|
|
461
|
+
const unchanged = desired.filter((value) => existingSet.has(value)).length
|
|
462
|
+
|
|
463
|
+
if (log) {
|
|
464
|
+
if (toAdd.length > 0 || toRemove.length > 0) {
|
|
465
|
+
log(`Issue #${issueNumber}: dependency sync [add=${toAdd.join(',') || 'none'}, remove=${toRemove.join(',') || 'none'}]`)
|
|
466
|
+
} else {
|
|
467
|
+
log(`Issue #${issueNumber}: dependency sync already correct`)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
let added = 0
|
|
472
|
+
let removed = 0
|
|
473
|
+
|
|
474
|
+
if (!dryRun) {
|
|
475
|
+
for (const dep of toRemove) {
|
|
476
|
+
const removedResult = await callIssueDependenciesApi(
|
|
477
|
+
'DELETE',
|
|
478
|
+
owner,
|
|
479
|
+
repo,
|
|
480
|
+
issueNumber,
|
|
481
|
+
host,
|
|
482
|
+
token,
|
|
483
|
+
{ index: dep, owner, repo },
|
|
484
|
+
)
|
|
485
|
+
if (!removedResult.ok) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
`Failed to remove dependency ${dep} from issue #${issueNumber}: status=${removedResult.status} url=${removedResult.request.url}`
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
removed += 1
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const dep of toAdd) {
|
|
494
|
+
const addedResult = await callIssueDependenciesApi(
|
|
495
|
+
'POST',
|
|
496
|
+
owner,
|
|
497
|
+
repo,
|
|
498
|
+
issueNumber,
|
|
499
|
+
host,
|
|
500
|
+
token,
|
|
501
|
+
{ index: dep, owner, repo },
|
|
502
|
+
)
|
|
503
|
+
if (!addedResult.ok) {
|
|
504
|
+
throw new Error(
|
|
505
|
+
`Failed to add dependency ${dep} to issue #${issueNumber}: status=${addedResult.status} url=${addedResult.request.url}`
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
added += 1
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const verifyResult = await callIssueDependenciesApi('GET', owner, repo, issueNumber, host, token)
|
|
512
|
+
if (!verifyResult.ok) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
`Failed to verify dependencies for issue #${issueNumber}: status=${verifyResult.status} url=${verifyResult.request.url}`
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const verified = extractDependencyIssueNumbers(verifyResult.body)
|
|
519
|
+
const verifiedSet = new Set(verified)
|
|
520
|
+
const missing = desired.filter((value) => !verifiedSet.has(value))
|
|
521
|
+
if (missing.length > 0) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`Dependency sync mismatch for issue #${issueNumber}. Missing dependencies: ${missing.join(', ')}`
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
added,
|
|
530
|
+
removed,
|
|
531
|
+
unchanged,
|
|
532
|
+
}
|
|
533
|
+
}
|