@foundation0/git 1.3.0 → 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 -266
- 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 -938
- package/package.json +3 -1
- package/src/actions-api.ts +860 -637
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -544
- package/src/git-service-api.ts +822 -754
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -55
- package/src/issue-dependencies.ts +533 -533
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -460
- 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
package/src/git-service-api.ts
CHANGED
|
@@ -1,754 +1,822 @@
|
|
|
1
|
-
import { createGitPlatformAdapter, type GitPlatformAdapterFactoryDeps } from './platform'
|
|
2
|
-
import { getGitPlatformConfig, type GitPlatformConfig } from './platform/config'
|
|
3
|
-
import { type GitServiceFlag, type GitServiceFeature, gitServiceFeatureSpec } from './git-service-feature-spec.generated'
|
|
4
|
-
import { type GitApiFeatureMapping } from './platform/gitea-adapter'
|
|
5
|
-
import { attachGitLabelManagementApi } from './label-management'
|
|
6
|
-
import { attachGitActionsApi } from './actions-api'
|
|
7
|
-
import { attachGitCiApi } from './ci-api'
|
|
8
|
-
import { spawn } from 'node:child_process'
|
|
9
|
-
import crypto from 'node:crypto'
|
|
10
|
-
|
|
11
|
-
export interface GitServiceApiFactoryOptions {
|
|
12
|
-
config?: Partial<GitPlatformConfig>
|
|
13
|
-
defaultOwner?: string
|
|
14
|
-
defaultRepo?: string
|
|
15
|
-
swaggerSpec?: GitPlatformAdapterFactoryDeps['swaggerSpec']
|
|
16
|
-
requestTimeoutMs?: number
|
|
17
|
-
httpTransport?: 'fetch' | 'curl'
|
|
18
|
-
log?: (message: string) => void
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface GitServiceApiExecutionResult<T = unknown> {
|
|
22
|
-
mapping: GitApiFeatureMapping
|
|
23
|
-
request: {
|
|
24
|
-
url: string
|
|
25
|
-
method: string
|
|
26
|
-
headers: Record<string, string>
|
|
27
|
-
query: string[]
|
|
28
|
-
body?: unknown
|
|
29
|
-
}
|
|
30
|
-
response: {
|
|
31
|
-
headers: Record<string, string>
|
|
32
|
-
}
|
|
33
|
-
status: number
|
|
34
|
-
ok: boolean
|
|
35
|
-
body: T
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type GitServiceApiMethod = (...args: unknown[]) => Promise<GitServiceApiExecutionResult>
|
|
39
|
-
|
|
40
|
-
export type GitServiceApi = {
|
|
41
|
-
[key: string]: GitServiceApi | GitServiceApiMethod
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
type ApiCallOptions = {
|
|
45
|
-
method?: string
|
|
46
|
-
requestBody?: unknown
|
|
47
|
-
requestJson?: unknown
|
|
48
|
-
requestData?: unknown
|
|
49
|
-
requestPayload?: unknown
|
|
50
|
-
json?: unknown
|
|
51
|
-
data?: unknown
|
|
52
|
-
payload?: unknown
|
|
53
|
-
headers?: Record<string, string>
|
|
54
|
-
query?: Record<string, string | number | boolean>
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface NormalizedCall {
|
|
58
|
-
args: string[]
|
|
59
|
-
options: Record<string, unknown>
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
|
|
63
|
-
|
|
64
|
-
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
65
|
-
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
66
|
-
|
|
67
|
-
const parseRequestTimeoutMs = (value: unknown): number | null => {
|
|
68
|
-
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
69
|
-
return Math.floor(value)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (typeof value !== 'string') {
|
|
73
|
-
return null
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const trimmed = value.trim()
|
|
77
|
-
if (!trimmed) return null
|
|
78
|
-
const parsed = Number(trimmed)
|
|
79
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
80
|
-
return Math.floor(parsed)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const resolveDefaultRequestTimeoutMs = (): number => {
|
|
84
|
-
const fromEnv =
|
|
85
|
-
parseRequestTimeoutMs(process.env.EXAMPLE_GIT_REQUEST_TIMEOUT_MS) ??
|
|
86
|
-
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_REQUEST_TIMEOUT_MS) ??
|
|
87
|
-
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_TIMEOUT_MS)
|
|
88
|
-
|
|
89
|
-
return fromEnv ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const toHeaderRecord = (headers: string[]): Record<string, string> =>
|
|
93
|
-
Object.fromEntries(
|
|
94
|
-
headers
|
|
95
|
-
.map((entry) => {
|
|
96
|
-
const separatorIndex = entry.indexOf(':')
|
|
97
|
-
if (separatorIndex < 0) {
|
|
98
|
-
return null
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const name = entry.slice(0, separatorIndex).trim()
|
|
102
|
-
const value = entry.slice(separatorIndex + 1).trim()
|
|
103
|
-
return [name, value]
|
|
104
|
-
})
|
|
105
|
-
.filter((entry): entry is [string, string] => Boolean(entry)),
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
const splitArgsAndOptions = (rawArgs: unknown[]): NormalizedCall => {
|
|
109
|
-
if (rawArgs.length === 0 || !isPlainObject(rawArgs[rawArgs.length - 1])) {
|
|
110
|
-
return {
|
|
111
|
-
args: rawArgs.map((value) => String(value)),
|
|
112
|
-
options: {},
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const last = rawArgs[rawArgs.length - 1] as Record<string, unknown>
|
|
117
|
-
return {
|
|
118
|
-
args: rawArgs.slice(0, -1).map((value) => String(value)),
|
|
119
|
-
options: last,
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const normalizeFlagLookup = (name: string): string =>
|
|
124
|
-
name.replace(/^--/, '').trim().toLowerCase()
|
|
125
|
-
|
|
126
|
-
const mapFlagValues = (
|
|
127
|
-
feature: GitServiceFeature,
|
|
128
|
-
options: Record<string, unknown>,
|
|
129
|
-
): {
|
|
130
|
-
flags: Record<string, string | boolean>
|
|
131
|
-
unhandled: Record<string, unknown>
|
|
132
|
-
} => {
|
|
133
|
-
const flags: Record<string, string | boolean> = {}
|
|
134
|
-
const unhandled: Record<string, unknown> = {}
|
|
135
|
-
const alias = new Map<string, string>()
|
|
136
|
-
|
|
137
|
-
for (const flag of feature.flags) {
|
|
138
|
-
const canonical = normalizeFlagLookup(flag.name)
|
|
139
|
-
alias.set(canonical, flag.name)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const reserved = new Set([
|
|
143
|
-
'json',
|
|
144
|
-
'data',
|
|
145
|
-
'payload',
|
|
146
|
-
'headers',
|
|
147
|
-
'query',
|
|
148
|
-
'method',
|
|
149
|
-
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
'
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
continue
|
|
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
|
-
if (
|
|
204
|
-
return
|
|
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
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
const
|
|
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
|
-
const
|
|
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
|
-
const
|
|
424
|
-
const
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
)
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1
|
+
import { createGitPlatformAdapter, type GitPlatformAdapterFactoryDeps } from './platform'
|
|
2
|
+
import { getGitPlatformConfig, type GitPlatformConfig } from './platform/config'
|
|
3
|
+
import { type GitServiceFlag, type GitServiceFeature, gitServiceFeatureSpec } from './git-service-feature-spec.generated'
|
|
4
|
+
import { type GitApiFeatureMapping } from './platform/gitea-adapter'
|
|
5
|
+
import { attachGitLabelManagementApi } from './label-management'
|
|
6
|
+
import { attachGitActionsApi } from './actions-api'
|
|
7
|
+
import { attachGitCiApi } from './ci-api'
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
|
|
11
|
+
export interface GitServiceApiFactoryOptions {
|
|
12
|
+
config?: Partial<GitPlatformConfig>
|
|
13
|
+
defaultOwner?: string
|
|
14
|
+
defaultRepo?: string
|
|
15
|
+
swaggerSpec?: GitPlatformAdapterFactoryDeps['swaggerSpec']
|
|
16
|
+
requestTimeoutMs?: number
|
|
17
|
+
httpTransport?: 'fetch' | 'curl'
|
|
18
|
+
log?: (message: string) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GitServiceApiExecutionResult<T = unknown> {
|
|
22
|
+
mapping: GitApiFeatureMapping
|
|
23
|
+
request: {
|
|
24
|
+
url: string
|
|
25
|
+
method: string
|
|
26
|
+
headers: Record<string, string>
|
|
27
|
+
query: string[]
|
|
28
|
+
body?: unknown
|
|
29
|
+
}
|
|
30
|
+
response: {
|
|
31
|
+
headers: Record<string, string>
|
|
32
|
+
}
|
|
33
|
+
status: number
|
|
34
|
+
ok: boolean
|
|
35
|
+
body: T
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type GitServiceApiMethod = (...args: unknown[]) => Promise<GitServiceApiExecutionResult>
|
|
39
|
+
|
|
40
|
+
export type GitServiceApi = {
|
|
41
|
+
[key: string]: GitServiceApi | GitServiceApiMethod
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ApiCallOptions = {
|
|
45
|
+
method?: string
|
|
46
|
+
requestBody?: unknown
|
|
47
|
+
requestJson?: unknown
|
|
48
|
+
requestData?: unknown
|
|
49
|
+
requestPayload?: unknown
|
|
50
|
+
json?: unknown
|
|
51
|
+
data?: unknown
|
|
52
|
+
payload?: unknown
|
|
53
|
+
headers?: Record<string, string>
|
|
54
|
+
query?: Record<string, string | number | boolean>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface NormalizedCall {
|
|
58
|
+
args: string[]
|
|
59
|
+
options: Record<string, unknown>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
|
|
63
|
+
|
|
64
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
65
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
66
|
+
|
|
67
|
+
const parseRequestTimeoutMs = (value: unknown): number | null => {
|
|
68
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
69
|
+
return Math.floor(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof value !== 'string') {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const trimmed = value.trim()
|
|
77
|
+
if (!trimmed) return null
|
|
78
|
+
const parsed = Number(trimmed)
|
|
79
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null
|
|
80
|
+
return Math.floor(parsed)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const resolveDefaultRequestTimeoutMs = (): number => {
|
|
84
|
+
const fromEnv =
|
|
85
|
+
parseRequestTimeoutMs(process.env.EXAMPLE_GIT_REQUEST_TIMEOUT_MS) ??
|
|
86
|
+
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_REQUEST_TIMEOUT_MS) ??
|
|
87
|
+
parseRequestTimeoutMs(process.env.EXAMPLE_HTTP_TIMEOUT_MS)
|
|
88
|
+
|
|
89
|
+
return fromEnv ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const toHeaderRecord = (headers: string[]): Record<string, string> =>
|
|
93
|
+
Object.fromEntries(
|
|
94
|
+
headers
|
|
95
|
+
.map((entry) => {
|
|
96
|
+
const separatorIndex = entry.indexOf(':')
|
|
97
|
+
if (separatorIndex < 0) {
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const name = entry.slice(0, separatorIndex).trim()
|
|
102
|
+
const value = entry.slice(separatorIndex + 1).trim()
|
|
103
|
+
return [name, value]
|
|
104
|
+
})
|
|
105
|
+
.filter((entry): entry is [string, string] => Boolean(entry)),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const splitArgsAndOptions = (rawArgs: unknown[]): NormalizedCall => {
|
|
109
|
+
if (rawArgs.length === 0 || !isPlainObject(rawArgs[rawArgs.length - 1])) {
|
|
110
|
+
return {
|
|
111
|
+
args: rawArgs.map((value) => String(value)),
|
|
112
|
+
options: {},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const last = rawArgs[rawArgs.length - 1] as Record<string, unknown>
|
|
117
|
+
return {
|
|
118
|
+
args: rawArgs.slice(0, -1).map((value) => String(value)),
|
|
119
|
+
options: last,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const normalizeFlagLookup = (name: string): string =>
|
|
124
|
+
name.replace(/^--/, '').trim().toLowerCase()
|
|
125
|
+
|
|
126
|
+
const mapFlagValues = (
|
|
127
|
+
feature: GitServiceFeature,
|
|
128
|
+
options: Record<string, unknown>,
|
|
129
|
+
): {
|
|
130
|
+
flags: Record<string, string | boolean>
|
|
131
|
+
unhandled: Record<string, unknown>
|
|
132
|
+
} => {
|
|
133
|
+
const flags: Record<string, string | boolean> = {}
|
|
134
|
+
const unhandled: Record<string, unknown> = {}
|
|
135
|
+
const alias = new Map<string, string>()
|
|
136
|
+
|
|
137
|
+
for (const flag of feature.flags) {
|
|
138
|
+
const canonical = normalizeFlagLookup(flag.name)
|
|
139
|
+
alias.set(canonical, flag.name)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const reserved = new Set([
|
|
143
|
+
'json',
|
|
144
|
+
'data',
|
|
145
|
+
'payload',
|
|
146
|
+
'headers',
|
|
147
|
+
'query',
|
|
148
|
+
'method',
|
|
149
|
+
// Common path params / context keys (do not leak into query params for GET tools).
|
|
150
|
+
'owner',
|
|
151
|
+
'repo',
|
|
152
|
+
'index',
|
|
153
|
+
'number',
|
|
154
|
+
'prnumber',
|
|
155
|
+
'issuenumber',
|
|
156
|
+
'mergemethod',
|
|
157
|
+
'requestbody',
|
|
158
|
+
'requestjson',
|
|
159
|
+
'requestdata',
|
|
160
|
+
'requestpayload',
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
for (const [key, value] of Object.entries(options)) {
|
|
164
|
+
const normalizedKey = normalizeFlagLookup(key)
|
|
165
|
+
|
|
166
|
+
if (value === undefined) {
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (reserved.has(normalizedKey)) {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (alias.has(normalizedKey)) {
|
|
175
|
+
const canonical = alias.get(normalizedKey)
|
|
176
|
+
if (canonical) {
|
|
177
|
+
flags[canonical] = typeof value === 'boolean' ? value : String(value)
|
|
178
|
+
}
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
unhandled[key] = value
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { flags, unhandled }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const buildRequestBody = (
|
|
189
|
+
method: string,
|
|
190
|
+
options: Record<string, unknown>,
|
|
191
|
+
defaultBody: Record<string, unknown>,
|
|
192
|
+
): unknown | undefined => {
|
|
193
|
+
const normalizedMethod = method.toUpperCase()
|
|
194
|
+
const explicit =
|
|
195
|
+
options.requestBody ??
|
|
196
|
+
options.requestJson ??
|
|
197
|
+
options.requestData ??
|
|
198
|
+
options.requestPayload ??
|
|
199
|
+
options.json ??
|
|
200
|
+
options.data ??
|
|
201
|
+
options.payload
|
|
202
|
+
|
|
203
|
+
if (explicit !== undefined) {
|
|
204
|
+
return explicit
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!['POST', 'PUT', 'PATCH'].includes(normalizedMethod)) {
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (Object.keys(defaultBody).length > 0) {
|
|
212
|
+
return defaultBody
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const buildUrl = (
|
|
219
|
+
apiBase: string,
|
|
220
|
+
mappedPath: string[],
|
|
221
|
+
query: string[],
|
|
222
|
+
additionalQuery: Record<string, string | number | boolean> = {},
|
|
223
|
+
): string => {
|
|
224
|
+
const path = `${apiBase}/${mappedPath.join('/')}`
|
|
225
|
+
const url = new URL(path)
|
|
226
|
+
|
|
227
|
+
for (const queryPair of query) {
|
|
228
|
+
const [name, value = ''] = queryPair.split('=', 2)
|
|
229
|
+
if (!name) {
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
url.searchParams.set(name, value)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const [name, value] of Object.entries(additionalQuery)) {
|
|
237
|
+
url.searchParams.set(name, String(value))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return url.toString()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const unresolvedPathParamPattern = /^\{[^{}]+\}$/
|
|
244
|
+
|
|
245
|
+
const extractPathParamValue = (value: unknown): string | null => {
|
|
246
|
+
if (typeof value === 'string') {
|
|
247
|
+
const trimmed = value.trim()
|
|
248
|
+
return trimmed ? trimmed : null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
252
|
+
return String(Math.trunc(value))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (typeof value === 'boolean') {
|
|
256
|
+
return value ? 'true' : 'false'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const hydrateMappedPath = (
|
|
263
|
+
mappedPath: string[],
|
|
264
|
+
defaults: { defaultOwner?: string; defaultRepo?: string },
|
|
265
|
+
options: Record<string, unknown>,
|
|
266
|
+
): string[] => {
|
|
267
|
+
const owner = extractPathParamValue(options.owner) ?? defaults.defaultOwner ?? null
|
|
268
|
+
const repo = extractPathParamValue(options.repo) ?? defaults.defaultRepo ?? null
|
|
269
|
+
|
|
270
|
+
return mappedPath.map((segment) => {
|
|
271
|
+
if (segment === '{owner}' && owner) return owner
|
|
272
|
+
if (segment === '{repo}' && repo) return repo
|
|
273
|
+
|
|
274
|
+
if (!unresolvedPathParamPattern.test(segment)) {
|
|
275
|
+
return segment
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const key = segment.slice(1, -1).trim()
|
|
279
|
+
if (!key) return segment
|
|
280
|
+
|
|
281
|
+
// Common aliases used by humans/LLMs.
|
|
282
|
+
const candidates: unknown[] =
|
|
283
|
+
key === 'index'
|
|
284
|
+
? [options.index, options.number, (options as Record<string, unknown>).prNumber, (options as Record<string, unknown>).issueNumber]
|
|
285
|
+
: [options[key]]
|
|
286
|
+
|
|
287
|
+
const hydrated = candidates.map(extractPathParamValue).find((value): value is string => Boolean(value)) ?? null
|
|
288
|
+
return hydrated ?? segment
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const assertResolvedMappedPath = (
|
|
293
|
+
mappedPath: string[],
|
|
294
|
+
featurePath: string[],
|
|
295
|
+
): void => {
|
|
296
|
+
const unresolved = mappedPath.filter((segment) => unresolvedPathParamPattern.test(segment))
|
|
297
|
+
if (unresolved.length === 0) {
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Missing required path arguments for "${featurePath.join('.')}". Unresolved parameters: ${unresolved.join(', ')}`,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const canUseAbortSignalTimeout = (): boolean =>
|
|
307
|
+
typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
|
|
308
|
+
|
|
309
|
+
const isTestRuntime = (): boolean =>
|
|
310
|
+
Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test'
|
|
311
|
+
|
|
312
|
+
const toQueryRecord = (raw: Record<string, unknown>): Record<string, string | number | boolean> => {
|
|
313
|
+
const query: Record<string, string | number | boolean> = {}
|
|
314
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
315
|
+
if (typeof value === 'string') {
|
|
316
|
+
query[key] = value
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
320
|
+
query[key] = value
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
if (typeof value === 'boolean') {
|
|
324
|
+
query[key] = value
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return query
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const buildDefaultWriteBody = (options: Record<string, unknown>): Record<string, unknown> => {
|
|
331
|
+
const reserved = new Set([
|
|
332
|
+
'owner',
|
|
333
|
+
'repo',
|
|
334
|
+
'index',
|
|
335
|
+
'number',
|
|
336
|
+
'prNumber',
|
|
337
|
+
'issueNumber',
|
|
338
|
+
'mergeMethod',
|
|
339
|
+
'headers',
|
|
340
|
+
'query',
|
|
341
|
+
'method',
|
|
342
|
+
'requestBody',
|
|
343
|
+
'requestJson',
|
|
344
|
+
'requestData',
|
|
345
|
+
'requestPayload',
|
|
346
|
+
'json',
|
|
347
|
+
'data',
|
|
348
|
+
'payload',
|
|
349
|
+
])
|
|
350
|
+
|
|
351
|
+
const body: Record<string, unknown> = {}
|
|
352
|
+
for (const [key, value] of Object.entries(options)) {
|
|
353
|
+
if (reserved.has(key)) continue
|
|
354
|
+
if (value !== undefined) {
|
|
355
|
+
body[key] = value
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return body
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const resolveHttpTransport = (requested?: string): 'fetch' | 'curl' => {
|
|
362
|
+
const normalized = (requested ?? '').trim().toLowerCase()
|
|
363
|
+
if (normalized === 'fetch' || normalized === 'curl') {
|
|
364
|
+
return normalized
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const isBun = Boolean(process.versions?.bun)
|
|
368
|
+
if (!isTestRuntime() && isBun && process.platform === 'win32') {
|
|
369
|
+
return 'curl'
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return 'fetch'
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const readStream = async (stream: NodeJS.ReadableStream | null): Promise<Buffer> => {
|
|
376
|
+
if (!stream) return Buffer.from([])
|
|
377
|
+
|
|
378
|
+
return await new Promise<Buffer>((resolve) => {
|
|
379
|
+
const chunks: Buffer[] = []
|
|
380
|
+
let settled = false
|
|
381
|
+
|
|
382
|
+
const cleanup = () => {
|
|
383
|
+
stream.removeListener('data', onData)
|
|
384
|
+
stream.removeListener('end', onDone)
|
|
385
|
+
stream.removeListener('close', onDone)
|
|
386
|
+
stream.removeListener('error', onDone)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const settle = () => {
|
|
390
|
+
if (settled) return
|
|
391
|
+
settled = true
|
|
392
|
+
cleanup()
|
|
393
|
+
resolve(Buffer.concat(chunks))
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const onDone = () => settle()
|
|
397
|
+
|
|
398
|
+
const onData = (chunk: unknown) => {
|
|
399
|
+
try {
|
|
400
|
+
if (typeof chunk === 'string') {
|
|
401
|
+
chunks.push(Buffer.from(chunk))
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
chunks.push(Buffer.from(chunk as ArrayBufferView))
|
|
406
|
+
} catch {
|
|
407
|
+
// best effort
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
stream.on('data', onData)
|
|
412
|
+
stream.on('end', onDone)
|
|
413
|
+
stream.on('close', onDone)
|
|
414
|
+
stream.on('error', onDone)
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const callCurl = async (
|
|
419
|
+
requestUrl: string,
|
|
420
|
+
init: { method: string; headers: Record<string, string>; body?: string },
|
|
421
|
+
requestTimeoutMs: number,
|
|
422
|
+
): Promise<{ status: number; ok: boolean; bodyText: string; responseHeaders: Record<string, string> }> => {
|
|
423
|
+
const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl'
|
|
424
|
+
const statusToken = crypto.randomBytes(8).toString('hex')
|
|
425
|
+
const marker = `\n__EXAMPLE_CURL_STATUS_${statusToken}__`
|
|
426
|
+
const writeOut = `${marker}%{http_code}${marker}`
|
|
427
|
+
|
|
428
|
+
const args: string[] = [
|
|
429
|
+
'--silent',
|
|
430
|
+
'--show-error',
|
|
431
|
+
'--location',
|
|
432
|
+
'--request', init.method,
|
|
433
|
+
'--write-out', writeOut,
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
const timeoutSeconds = requestTimeoutMs > 0 ? Math.max(1, Math.ceil(requestTimeoutMs / 1000)) : null
|
|
437
|
+
if (timeoutSeconds !== null) {
|
|
438
|
+
args.push('--max-time', String(timeoutSeconds))
|
|
439
|
+
args.push('--connect-timeout', String(Math.max(1, Math.min(30, timeoutSeconds))))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
for (const [name, value] of Object.entries(init.headers)) {
|
|
443
|
+
args.push('--header', `${name}: ${value}`)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (init.body !== undefined) {
|
|
447
|
+
args.push('--data-binary', '@-')
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
args.push(requestUrl)
|
|
451
|
+
|
|
452
|
+
const child = spawn(curlExe, args, {
|
|
453
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
454
|
+
windowsHide: true,
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const hardTimeoutMs = requestTimeoutMs > 0 ? requestTimeoutMs + 2_000 : null
|
|
458
|
+
let hardTimedOut = false
|
|
459
|
+
const hardTimeoutId = hardTimeoutMs
|
|
460
|
+
? setTimeout(() => {
|
|
461
|
+
hardTimedOut = true
|
|
462
|
+
try {
|
|
463
|
+
child.kill()
|
|
464
|
+
} catch {
|
|
465
|
+
// best effort
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
child.stdout?.destroy()
|
|
469
|
+
} catch {
|
|
470
|
+
// best effort
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
child.stderr?.destroy()
|
|
474
|
+
} catch {
|
|
475
|
+
// best effort
|
|
476
|
+
}
|
|
477
|
+
}, hardTimeoutMs)
|
|
478
|
+
: null
|
|
479
|
+
|
|
480
|
+
if (init.body !== undefined) {
|
|
481
|
+
child.stdin.write(init.body)
|
|
482
|
+
}
|
|
483
|
+
child.stdin.end()
|
|
484
|
+
|
|
485
|
+
const stdoutPromise = readStream(child.stdout)
|
|
486
|
+
const stderrPromise = readStream(child.stderr)
|
|
487
|
+
|
|
488
|
+
let exitCode: number
|
|
489
|
+
try {
|
|
490
|
+
exitCode = await new Promise((resolve) => {
|
|
491
|
+
child.on('close', (code) => resolve(code ?? 0))
|
|
492
|
+
child.on('error', () => resolve(1))
|
|
493
|
+
})
|
|
494
|
+
} finally {
|
|
495
|
+
if (hardTimeoutId) {
|
|
496
|
+
clearTimeout(hardTimeoutId)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const [stdoutBytes, stderrBytes] = await Promise.all([stdoutPromise, stderrPromise])
|
|
501
|
+
|
|
502
|
+
if (hardTimedOut && requestTimeoutMs > 0) {
|
|
503
|
+
throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const stdout = stdoutBytes.toString('utf8')
|
|
507
|
+
const stderr = stderrBytes.toString('utf8').trim()
|
|
508
|
+
|
|
509
|
+
if (exitCode !== 0) {
|
|
510
|
+
const message = stderr || `curl failed with exit code ${exitCode}`
|
|
511
|
+
if (exitCode === 28 && requestTimeoutMs > 0) {
|
|
512
|
+
throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
|
|
513
|
+
}
|
|
514
|
+
throw new Error(message)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const endMarkerIndex = stdout.lastIndexOf(marker)
|
|
518
|
+
const startMarkerIndex = endMarkerIndex > -1 ? stdout.lastIndexOf(marker, endMarkerIndex - 1) : -1
|
|
519
|
+
if (startMarkerIndex < 0 || endMarkerIndex < 0) {
|
|
520
|
+
throw new Error('Failed to parse curl response status code.')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const statusText = stdout.slice(startMarkerIndex + marker.length, endMarkerIndex).trim()
|
|
524
|
+
const status = Number(statusText)
|
|
525
|
+
const bodyText = stdout.slice(0, startMarkerIndex)
|
|
526
|
+
|
|
527
|
+
if (!Number.isFinite(status) || status <= 0) {
|
|
528
|
+
throw new Error(`Invalid curl status code: ${statusText}`)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
status,
|
|
533
|
+
ok: status >= 200 && status < 300,
|
|
534
|
+
bodyText,
|
|
535
|
+
responseHeaders: {},
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const createMethod = (
|
|
540
|
+
feature: GitServiceFeature,
|
|
541
|
+
adapter: ReturnType<typeof createGitPlatformAdapter>,
|
|
542
|
+
defaults: { defaultOwner?: string; defaultRepo?: string },
|
|
543
|
+
requestTimeoutMs: number,
|
|
544
|
+
httpTransport: 'fetch' | 'curl',
|
|
545
|
+
log?: (message: string) => void,
|
|
546
|
+
): GitServiceApiMethod => {
|
|
547
|
+
return async (...rawArgs: unknown[]) => {
|
|
548
|
+
const { args, options } = splitArgsAndOptions(rawArgs)
|
|
549
|
+
const { query: additionalQuery, method: methodOverride, ...bodyOptions } = (options as ApiCallOptions)
|
|
550
|
+
|
|
551
|
+
const baseMapping = await adapter.mapFeature({
|
|
552
|
+
feature,
|
|
553
|
+
args,
|
|
554
|
+
flagValues: {},
|
|
555
|
+
method: methodOverride,
|
|
556
|
+
})
|
|
557
|
+
const normalizedMethod = baseMapping.method.toUpperCase()
|
|
558
|
+
|
|
559
|
+
const { mapping, extraQuery } = normalizedMethod === 'GET'
|
|
560
|
+
? (() => {
|
|
561
|
+
const { flags, unhandled } = mapFlagValues(feature, options)
|
|
562
|
+
return {
|
|
563
|
+
mapping: adapter.mapFeature({ feature, args, flagValues: flags, method: methodOverride }),
|
|
564
|
+
extraQuery: toQueryRecord(unhandled),
|
|
565
|
+
}
|
|
566
|
+
})()
|
|
567
|
+
: { mapping: Promise.resolve(baseMapping), extraQuery: {} }
|
|
568
|
+
|
|
569
|
+
const resolvedMapping = await mapping
|
|
570
|
+
const mergedQuery = {
|
|
571
|
+
...(additionalQuery ?? {}),
|
|
572
|
+
...(normalizedMethod === 'GET' ? extraQuery : {}),
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const hydratedPath = hydrateMappedPath(resolvedMapping.mappedPath, defaults, options)
|
|
576
|
+
assertResolvedMappedPath(hydratedPath, feature.path)
|
|
577
|
+
|
|
578
|
+
const defaultWriteBody = normalizedMethod === 'GET' ? {} : buildDefaultWriteBody(options)
|
|
579
|
+
let requestBody = buildRequestBody(
|
|
580
|
+
resolvedMapping.method,
|
|
581
|
+
bodyOptions,
|
|
582
|
+
defaultWriteBody,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
// LLM-friendly default: merging a PR should not require the caller to know Gitea's
|
|
586
|
+
// merge payload shape. If no explicit body is provided, default to "merge".
|
|
587
|
+
if (
|
|
588
|
+
requestBody === undefined &&
|
|
589
|
+
normalizedMethod !== 'GET' &&
|
|
590
|
+
feature.path.length >= 2 &&
|
|
591
|
+
feature.path[feature.path.length - 2] === 'pr' &&
|
|
592
|
+
feature.path[feature.path.length - 1] === 'merge'
|
|
593
|
+
) {
|
|
594
|
+
requestBody = {
|
|
595
|
+
Do: extractPathParamValue((options as Record<string, unknown>).mergeMethod) ?? 'merge',
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const headers = {
|
|
599
|
+
...toHeaderRecord(resolvedMapping.headers),
|
|
600
|
+
...(options.headers ?? {}),
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const isWriteMethod = ['POST', 'PUT', 'PATCH'].includes(normalizedMethod)
|
|
604
|
+
if (isWriteMethod && !headers['Content-Type']) {
|
|
605
|
+
headers['Content-Type'] = 'application/json'
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const requestUrl = buildUrl(resolvedMapping.apiBase, hydratedPath, resolvedMapping.query, mergedQuery)
|
|
609
|
+
const requestInit: RequestInit = {
|
|
610
|
+
method: resolvedMapping.method,
|
|
611
|
+
headers,
|
|
612
|
+
body: requestBody !== undefined ? JSON.stringify(requestBody) : undefined,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const startedAt = Date.now()
|
|
616
|
+
log?.(`http:request ${resolvedMapping.method} ${requestUrl}`)
|
|
617
|
+
try {
|
|
618
|
+
const responseHeaders: Record<string, string> = {}
|
|
619
|
+
let status = 0
|
|
620
|
+
let ok = false
|
|
621
|
+
let parsedBody: unknown = ''
|
|
622
|
+
|
|
623
|
+
if (httpTransport === 'curl') {
|
|
624
|
+
const curlResult = await callCurl(
|
|
625
|
+
requestUrl,
|
|
626
|
+
{
|
|
627
|
+
method: resolvedMapping.method,
|
|
628
|
+
headers,
|
|
629
|
+
...(requestInit.body !== undefined ? { body: String(requestInit.body) } : {}),
|
|
630
|
+
},
|
|
631
|
+
requestTimeoutMs,
|
|
632
|
+
)
|
|
633
|
+
status = curlResult.status
|
|
634
|
+
ok = curlResult.ok
|
|
635
|
+
const responseText = curlResult.bodyText
|
|
636
|
+
parsedBody = responseText
|
|
637
|
+
try {
|
|
638
|
+
parsedBody = JSON.parse(responseText)
|
|
639
|
+
} catch {
|
|
640
|
+
parsedBody = responseText
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
const timeoutSignal =
|
|
644
|
+
requestTimeoutMs > 0 && canUseAbortSignalTimeout()
|
|
645
|
+
? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
|
|
646
|
+
: null
|
|
647
|
+
const controller = !timeoutSignal && requestTimeoutMs > 0 ? new AbortController() : null
|
|
648
|
+
const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
const response = await fetch(requestUrl, {
|
|
652
|
+
...requestInit,
|
|
653
|
+
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
|
654
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
655
|
+
})
|
|
656
|
+
status = response.status
|
|
657
|
+
ok = response.ok
|
|
658
|
+
|
|
659
|
+
const responseText = await response.text()
|
|
660
|
+
parsedBody = responseText
|
|
661
|
+
try {
|
|
662
|
+
parsedBody = JSON.parse(responseText)
|
|
663
|
+
} catch {
|
|
664
|
+
parsedBody = responseText
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
response.headers.forEach((value, key) => {
|
|
669
|
+
responseHeaders[key.toLowerCase()] = value
|
|
670
|
+
})
|
|
671
|
+
} catch {
|
|
672
|
+
// best effort
|
|
673
|
+
}
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if ((timeoutSignal && timeoutSignal.aborted) || controller?.signal.aborted) {
|
|
676
|
+
throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${resolvedMapping.method} ${requestUrl}`)
|
|
677
|
+
}
|
|
678
|
+
throw error
|
|
679
|
+
} finally {
|
|
680
|
+
if (timeoutId) {
|
|
681
|
+
clearTimeout(timeoutId)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
log?.(`http:response ${resolvedMapping.method} ${requestUrl} -> ${status} (${Date.now() - startedAt}ms)`)
|
|
687
|
+
return {
|
|
688
|
+
mapping: {
|
|
689
|
+
...resolvedMapping,
|
|
690
|
+
mappedPath: hydratedPath,
|
|
691
|
+
},
|
|
692
|
+
request: {
|
|
693
|
+
url: requestUrl,
|
|
694
|
+
method: resolvedMapping.method,
|
|
695
|
+
headers,
|
|
696
|
+
query: [...resolvedMapping.query],
|
|
697
|
+
body: requestBody,
|
|
698
|
+
},
|
|
699
|
+
response: {
|
|
700
|
+
headers: responseHeaders,
|
|
701
|
+
},
|
|
702
|
+
status,
|
|
703
|
+
ok,
|
|
704
|
+
body: parsedBody,
|
|
705
|
+
}
|
|
706
|
+
} catch (error) {
|
|
707
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
708
|
+
log?.(`http:error ${mapping.method} ${requestUrl} (${message})`)
|
|
709
|
+
throw error
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const addFeatureToNamespace = (
|
|
715
|
+
root: GitServiceApi,
|
|
716
|
+
mountedPath: string[],
|
|
717
|
+
mappedFeature: GitServiceFeature,
|
|
718
|
+
adapter: ReturnType<typeof createGitPlatformAdapter>,
|
|
719
|
+
defaults: { defaultOwner?: string; defaultRepo?: string },
|
|
720
|
+
requestTimeoutMs: number,
|
|
721
|
+
httpTransport: 'fetch' | 'curl',
|
|
722
|
+
log?: (message: string) => void,
|
|
723
|
+
) => {
|
|
724
|
+
let cursor: GitServiceApi = root
|
|
725
|
+
for (let i = 0; i < mountedPath.length; i += 1) {
|
|
726
|
+
const segment = mountedPath[i]
|
|
727
|
+
const isLeaf = i === mountedPath.length - 1
|
|
728
|
+
|
|
729
|
+
if (isLeaf) {
|
|
730
|
+
cursor[segment] = createMethod(mappedFeature, adapter, defaults, requestTimeoutMs, httpTransport, log)
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (!cursor[segment]) {
|
|
735
|
+
cursor[segment] = {}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const node = cursor[segment]
|
|
739
|
+
if (typeof node !== 'object' || node === null) {
|
|
740
|
+
cursor[segment] = {}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
cursor = cursor[segment] as GitServiceApi
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export const createGitServiceApi = (options: GitServiceApiFactoryOptions = {}): GitServiceApi => {
|
|
748
|
+
const config = getGitPlatformConfig(options.config)
|
|
749
|
+
const adapter = createGitPlatformAdapter({
|
|
750
|
+
config,
|
|
751
|
+
...(options.swaggerSpec ? { swaggerSpec: options.swaggerSpec } : {}),
|
|
752
|
+
})
|
|
753
|
+
const requestTimeoutMs = parseRequestTimeoutMs(options.requestTimeoutMs) ?? resolveDefaultRequestTimeoutMs()
|
|
754
|
+
const httpTransport = resolveHttpTransport(options.httpTransport ?? process.env.EXAMPLE_GIT_HTTP_TRANSPORT)
|
|
755
|
+
const log = options.log
|
|
756
|
+
|
|
757
|
+
const defaults = {
|
|
758
|
+
defaultOwner: options.defaultOwner,
|
|
759
|
+
defaultRepo: options.defaultRepo,
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const root: GitServiceApi = {}
|
|
763
|
+
|
|
764
|
+
for (const feature of gitServiceFeatureSpec.features) {
|
|
765
|
+
if (feature.path.length === 0) {
|
|
766
|
+
continue
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
addFeatureToNamespace(root, feature.path, feature, adapter, defaults, requestTimeoutMs, httpTransport, log)
|
|
770
|
+
|
|
771
|
+
if (feature.path[0] !== 'repo') {
|
|
772
|
+
addFeatureToNamespace(
|
|
773
|
+
root,
|
|
774
|
+
['repo', ...feature.path],
|
|
775
|
+
feature,
|
|
776
|
+
adapter,
|
|
777
|
+
defaults,
|
|
778
|
+
requestTimeoutMs,
|
|
779
|
+
httpTransport,
|
|
780
|
+
log,
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
attachGitLabelManagementApi(root, defaults)
|
|
786
|
+
attachGitActionsApi(root, {
|
|
787
|
+
config,
|
|
788
|
+
defaults,
|
|
789
|
+
requestTimeoutMs,
|
|
790
|
+
log,
|
|
791
|
+
})
|
|
792
|
+
attachGitCiApi(root, {
|
|
793
|
+
config,
|
|
794
|
+
defaults,
|
|
795
|
+
requestTimeoutMs,
|
|
796
|
+
log,
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
return root
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const createUnavailableGitServiceApi = (error: Error): GitServiceApi => {
|
|
803
|
+
return new Proxy(
|
|
804
|
+
{},
|
|
805
|
+
{
|
|
806
|
+
get: (): never => {
|
|
807
|
+
throw error
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
) as GitServiceApi
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export const gitServiceApi: GitServiceApi = (() => {
|
|
814
|
+
try {
|
|
815
|
+
return createGitServiceApi()
|
|
816
|
+
} catch (error) {
|
|
817
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
818
|
+
return createUnavailableGitServiceApi(
|
|
819
|
+
new Error(`Failed to initialize gitServiceApi singleton: ${message}`),
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
})()
|