@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.
@@ -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
- 'requestbody',
150
- 'requestjson',
151
- 'requestdata',
152
- 'requestpayload',
153
- ])
154
-
155
- for (const [key, value] of Object.entries(options)) {
156
- const normalizedKey = normalizeFlagLookup(key)
157
-
158
- if (value === undefined) {
159
- continue
160
- }
161
-
162
- if (reserved.has(normalizedKey)) {
163
- continue
164
- }
165
-
166
- if (alias.has(normalizedKey)) {
167
- const canonical = alias.get(normalizedKey)
168
- if (canonical) {
169
- flags[canonical] = typeof value === 'boolean' ? value : String(value)
170
- }
171
- continue
172
- }
173
-
174
- unhandled[key] = value
175
- }
176
-
177
- return { flags, unhandled }
178
- }
179
-
180
- const buildRequestBody = (
181
- method: string,
182
- options: Record<string, unknown>,
183
- defaultBody: Record<string, unknown>,
184
- ): unknown | undefined => {
185
- const normalizedMethod = method.toUpperCase()
186
- const explicit =
187
- options.requestBody ??
188
- options.requestJson ??
189
- options.requestData ??
190
- options.requestPayload ??
191
- options.json ??
192
- options.data ??
193
- options.payload
194
-
195
- if (explicit !== undefined) {
196
- return explicit
197
- }
198
-
199
- if (!['POST', 'PUT', 'PATCH'].includes(normalizedMethod)) {
200
- return undefined
201
- }
202
-
203
- if (Object.keys(defaultBody).length > 0) {
204
- return defaultBody
205
- }
206
-
207
- return undefined
208
- }
209
-
210
- const buildUrl = (
211
- apiBase: string,
212
- mappedPath: string[],
213
- query: string[],
214
- additionalQuery: Record<string, string | number | boolean> = {},
215
- ): string => {
216
- const path = `${apiBase}/${mappedPath.join('/')}`
217
- const url = new URL(path)
218
-
219
- for (const queryPair of query) {
220
- const [name, value = ''] = queryPair.split('=', 2)
221
- if (!name) {
222
- continue
223
- }
224
-
225
- url.searchParams.set(name, value)
226
- }
227
-
228
- for (const [name, value] of Object.entries(additionalQuery)) {
229
- url.searchParams.set(name, String(value))
230
- }
231
-
232
- return url.toString()
233
- }
234
-
235
- const unresolvedPathParamPattern = /^\{[^{}]+\}$/
236
-
237
- const assertResolvedMappedPath = (
238
- mappedPath: string[],
239
- featurePath: string[],
240
- ): void => {
241
- const unresolved = mappedPath.filter((segment) => unresolvedPathParamPattern.test(segment))
242
- if (unresolved.length === 0) {
243
- return
244
- }
245
-
246
- throw new Error(
247
- `Missing required path arguments for "${featurePath.join('.')}". Unresolved parameters: ${unresolved.join(', ')}`,
248
- )
249
- }
250
-
251
- const canUseAbortSignalTimeout = (): boolean =>
252
- typeof AbortSignal !== 'undefined' && typeof (AbortSignal as unknown as { timeout?: unknown }).timeout === 'function'
253
-
254
- const isTestRuntime = (): boolean =>
255
- Boolean(process.env.VITEST) || process.env.NODE_ENV === 'test'
256
-
257
- const toQueryRecord = (raw: Record<string, unknown>): Record<string, string | number | boolean> => {
258
- const query: Record<string, string | number | boolean> = {}
259
- for (const [key, value] of Object.entries(raw)) {
260
- if (typeof value === 'string') {
261
- query[key] = value
262
- continue
263
- }
264
- if (typeof value === 'number' && Number.isFinite(value)) {
265
- query[key] = value
266
- continue
267
- }
268
- if (typeof value === 'boolean') {
269
- query[key] = value
270
- }
271
- }
272
- return query
273
- }
274
-
275
- const buildDefaultWriteBody = (options: Record<string, unknown>): Record<string, unknown> => {
276
- const reserved = new Set([
277
- 'headers',
278
- 'query',
279
- 'method',
280
- 'requestBody',
281
- 'requestJson',
282
- 'requestData',
283
- 'requestPayload',
284
- 'json',
285
- 'data',
286
- 'payload',
287
- ])
288
-
289
- const body: Record<string, unknown> = {}
290
- for (const [key, value] of Object.entries(options)) {
291
- if (reserved.has(key)) continue
292
- if (value !== undefined) {
293
- body[key] = value
294
- }
295
- }
296
- return body
297
- }
298
-
299
- const resolveHttpTransport = (requested?: string): 'fetch' | 'curl' => {
300
- const normalized = (requested ?? '').trim().toLowerCase()
301
- if (normalized === 'fetch' || normalized === 'curl') {
302
- return normalized
303
- }
304
-
305
- const isBun = Boolean(process.versions?.bun)
306
- if (!isTestRuntime() && isBun && process.platform === 'win32') {
307
- return 'curl'
308
- }
309
-
310
- return 'fetch'
311
- }
312
-
313
- const readStream = async (stream: NodeJS.ReadableStream | null): Promise<Buffer> => {
314
- if (!stream) return Buffer.from([])
315
-
316
- return await new Promise<Buffer>((resolve) => {
317
- const chunks: Buffer[] = []
318
- let settled = false
319
-
320
- const cleanup = () => {
321
- stream.removeListener('data', onData)
322
- stream.removeListener('end', onDone)
323
- stream.removeListener('close', onDone)
324
- stream.removeListener('error', onDone)
325
- }
326
-
327
- const settle = () => {
328
- if (settled) return
329
- settled = true
330
- cleanup()
331
- resolve(Buffer.concat(chunks))
332
- }
333
-
334
- const onDone = () => settle()
335
-
336
- const onData = (chunk: unknown) => {
337
- try {
338
- if (typeof chunk === 'string') {
339
- chunks.push(Buffer.from(chunk))
340
- return
341
- }
342
-
343
- chunks.push(Buffer.from(chunk as ArrayBufferView))
344
- } catch {
345
- // best effort
346
- }
347
- }
348
-
349
- stream.on('data', onData)
350
- stream.on('end', onDone)
351
- stream.on('close', onDone)
352
- stream.on('error', onDone)
353
- })
354
- }
355
-
356
- const callCurl = async (
357
- requestUrl: string,
358
- init: { method: string; headers: Record<string, string>; body?: string },
359
- requestTimeoutMs: number,
360
- ): Promise<{ status: number; ok: boolean; bodyText: string; responseHeaders: Record<string, string> }> => {
361
- const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl'
362
- const statusToken = crypto.randomBytes(8).toString('hex')
363
- const marker = `\n__EXAMPLE_CURL_STATUS_${statusToken}__`
364
- const writeOut = `${marker}%{http_code}${marker}`
365
-
366
- const args: string[] = [
367
- '--silent',
368
- '--show-error',
369
- '--location',
370
- '--request', init.method,
371
- '--write-out', writeOut,
372
- ]
373
-
374
- const timeoutSeconds = requestTimeoutMs > 0 ? Math.max(1, Math.ceil(requestTimeoutMs / 1000)) : null
375
- if (timeoutSeconds !== null) {
376
- args.push('--max-time', String(timeoutSeconds))
377
- args.push('--connect-timeout', String(Math.max(1, Math.min(30, timeoutSeconds))))
378
- }
379
-
380
- for (const [name, value] of Object.entries(init.headers)) {
381
- args.push('--header', `${name}: ${value}`)
382
- }
383
-
384
- if (init.body !== undefined) {
385
- args.push('--data-binary', '@-')
386
- }
387
-
388
- args.push(requestUrl)
389
-
390
- const child = spawn(curlExe, args, {
391
- stdio: ['pipe', 'pipe', 'pipe'],
392
- windowsHide: true,
393
- })
394
-
395
- const hardTimeoutMs = requestTimeoutMs > 0 ? requestTimeoutMs + 2_000 : null
396
- let hardTimedOut = false
397
- const hardTimeoutId = hardTimeoutMs
398
- ? setTimeout(() => {
399
- hardTimedOut = true
400
- try {
401
- child.kill()
402
- } catch {
403
- // best effort
404
- }
405
- try {
406
- child.stdout?.destroy()
407
- } catch {
408
- // best effort
409
- }
410
- try {
411
- child.stderr?.destroy()
412
- } catch {
413
- // best effort
414
- }
415
- }, hardTimeoutMs)
416
- : null
417
-
418
- if (init.body !== undefined) {
419
- child.stdin.write(init.body)
420
- }
421
- child.stdin.end()
422
-
423
- const stdoutPromise = readStream(child.stdout)
424
- const stderrPromise = readStream(child.stderr)
425
-
426
- let exitCode: number
427
- try {
428
- exitCode = await new Promise((resolve) => {
429
- child.on('close', (code) => resolve(code ?? 0))
430
- child.on('error', () => resolve(1))
431
- })
432
- } finally {
433
- if (hardTimeoutId) {
434
- clearTimeout(hardTimeoutId)
435
- }
436
- }
437
-
438
- const [stdoutBytes, stderrBytes] = await Promise.all([stdoutPromise, stderrPromise])
439
-
440
- if (hardTimedOut && requestTimeoutMs > 0) {
441
- throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
442
- }
443
-
444
- const stdout = stdoutBytes.toString('utf8')
445
- const stderr = stderrBytes.toString('utf8').trim()
446
-
447
- if (exitCode !== 0) {
448
- const message = stderr || `curl failed with exit code ${exitCode}`
449
- if (exitCode === 28 && requestTimeoutMs > 0) {
450
- throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${init.method} ${requestUrl}`)
451
- }
452
- throw new Error(message)
453
- }
454
-
455
- const endMarkerIndex = stdout.lastIndexOf(marker)
456
- const startMarkerIndex = endMarkerIndex > -1 ? stdout.lastIndexOf(marker, endMarkerIndex - 1) : -1
457
- if (startMarkerIndex < 0 || endMarkerIndex < 0) {
458
- throw new Error('Failed to parse curl response status code.')
459
- }
460
-
461
- const statusText = stdout.slice(startMarkerIndex + marker.length, endMarkerIndex).trim()
462
- const status = Number(statusText)
463
- const bodyText = stdout.slice(0, startMarkerIndex)
464
-
465
- if (!Number.isFinite(status) || status <= 0) {
466
- throw new Error(`Invalid curl status code: ${statusText}`)
467
- }
468
-
469
- return {
470
- status,
471
- ok: status >= 200 && status < 300,
472
- bodyText,
473
- responseHeaders: {},
474
- }
475
- }
476
-
477
- const createMethod = (
478
- feature: GitServiceFeature,
479
- adapter: ReturnType<typeof createGitPlatformAdapter>,
480
- defaults: { defaultOwner?: string; defaultRepo?: string },
481
- requestTimeoutMs: number,
482
- httpTransport: 'fetch' | 'curl',
483
- log?: (message: string) => void,
484
- ): GitServiceApiMethod => {
485
- return async (...rawArgs: unknown[]) => {
486
- const { args, options } = splitArgsAndOptions(rawArgs)
487
- const { query: additionalQuery, method: methodOverride, ...bodyOptions } = (options as ApiCallOptions)
488
-
489
- const baseMapping = await adapter.mapFeature({
490
- feature,
491
- args,
492
- flagValues: {},
493
- method: methodOverride,
494
- })
495
- const normalizedMethod = baseMapping.method.toUpperCase()
496
-
497
- const { mapping, extraQuery } = normalizedMethod === 'GET'
498
- ? (() => {
499
- const { flags, unhandled } = mapFlagValues(feature, options)
500
- return {
501
- mapping: adapter.mapFeature({ feature, args, flagValues: flags, method: methodOverride }),
502
- extraQuery: toQueryRecord(unhandled),
503
- }
504
- })()
505
- : { mapping: Promise.resolve(baseMapping), extraQuery: {} }
506
-
507
- const resolvedMapping = await mapping
508
- const mergedQuery = {
509
- ...(additionalQuery ?? {}),
510
- ...(normalizedMethod === 'GET' ? extraQuery : {}),
511
- }
512
-
513
- const hydratedPath = resolvedMapping.mappedPath.map((segment) => {
514
- if (segment === '{owner}' && defaults.defaultOwner) {
515
- return defaults.defaultOwner
516
- }
517
-
518
- if (segment === '{repo}' && defaults.defaultRepo) {
519
- return defaults.defaultRepo
520
- }
521
-
522
- return segment
523
- })
524
- assertResolvedMappedPath(hydratedPath, feature.path)
525
-
526
- const requestBody = buildRequestBody(
527
- resolvedMapping.method,
528
- bodyOptions,
529
- normalizedMethod === 'GET' ? {} : buildDefaultWriteBody(options),
530
- )
531
- const headers = {
532
- ...toHeaderRecord(resolvedMapping.headers),
533
- ...(options.headers ?? {}),
534
- }
535
-
536
- if (requestBody !== undefined && !headers['Content-Type']) {
537
- headers['Content-Type'] = 'application/json'
538
- }
539
-
540
- const requestUrl = buildUrl(resolvedMapping.apiBase, hydratedPath, resolvedMapping.query, mergedQuery)
541
- const requestInit: RequestInit = {
542
- method: resolvedMapping.method,
543
- headers,
544
- body: requestBody !== undefined ? JSON.stringify(requestBody) : undefined,
545
- }
546
-
547
- const startedAt = Date.now()
548
- log?.(`http:request ${resolvedMapping.method} ${requestUrl}`)
549
- try {
550
- const responseHeaders: Record<string, string> = {}
551
- let status = 0
552
- let ok = false
553
- let parsedBody: unknown = ''
554
-
555
- if (httpTransport === 'curl') {
556
- const curlResult = await callCurl(
557
- requestUrl,
558
- {
559
- method: resolvedMapping.method,
560
- headers,
561
- ...(requestInit.body !== undefined ? { body: String(requestInit.body) } : {}),
562
- },
563
- requestTimeoutMs,
564
- )
565
- status = curlResult.status
566
- ok = curlResult.ok
567
- const responseText = curlResult.bodyText
568
- parsedBody = responseText
569
- try {
570
- parsedBody = JSON.parse(responseText)
571
- } catch {
572
- parsedBody = responseText
573
- }
574
- } else {
575
- const timeoutSignal =
576
- requestTimeoutMs > 0 && canUseAbortSignalTimeout()
577
- ? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
578
- : null
579
- const controller = !timeoutSignal && requestTimeoutMs > 0 ? new AbortController() : null
580
- const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
581
-
582
- try {
583
- const response = await fetch(requestUrl, {
584
- ...requestInit,
585
- ...(timeoutSignal ? { signal: timeoutSignal } : {}),
586
- ...(controller ? { signal: controller.signal } : {}),
587
- })
588
- status = response.status
589
- ok = response.ok
590
-
591
- const responseText = await response.text()
592
- parsedBody = responseText
593
- try {
594
- parsedBody = JSON.parse(responseText)
595
- } catch {
596
- parsedBody = responseText
597
- }
598
-
599
- try {
600
- response.headers.forEach((value, key) => {
601
- responseHeaders[key.toLowerCase()] = value
602
- })
603
- } catch {
604
- // best effort
605
- }
606
- } catch (error) {
607
- if ((timeoutSignal && timeoutSignal.aborted) || controller?.signal.aborted) {
608
- throw new Error(`Request timed out after ${requestTimeoutMs}ms: ${resolvedMapping.method} ${requestUrl}`)
609
- }
610
- throw error
611
- } finally {
612
- if (timeoutId) {
613
- clearTimeout(timeoutId)
614
- }
615
- }
616
- }
617
-
618
- log?.(`http:response ${resolvedMapping.method} ${requestUrl} -> ${status} (${Date.now() - startedAt}ms)`)
619
- return {
620
- mapping: {
621
- ...resolvedMapping,
622
- mappedPath: hydratedPath,
623
- },
624
- request: {
625
- url: requestUrl,
626
- method: resolvedMapping.method,
627
- headers,
628
- query: [...resolvedMapping.query],
629
- body: requestBody,
630
- },
631
- response: {
632
- headers: responseHeaders,
633
- },
634
- status,
635
- ok,
636
- body: parsedBody,
637
- }
638
- } catch (error) {
639
- const message = error instanceof Error ? error.message : String(error)
640
- log?.(`http:error ${mapping.method} ${requestUrl} (${message})`)
641
- throw error
642
- }
643
- }
644
- }
645
-
646
- const addFeatureToNamespace = (
647
- root: GitServiceApi,
648
- mountedPath: string[],
649
- mappedFeature: GitServiceFeature,
650
- adapter: ReturnType<typeof createGitPlatformAdapter>,
651
- defaults: { defaultOwner?: string; defaultRepo?: string },
652
- requestTimeoutMs: number,
653
- httpTransport: 'fetch' | 'curl',
654
- log?: (message: string) => void,
655
- ) => {
656
- let cursor: GitServiceApi = root
657
- for (let i = 0; i < mountedPath.length; i += 1) {
658
- const segment = mountedPath[i]
659
- const isLeaf = i === mountedPath.length - 1
660
-
661
- if (isLeaf) {
662
- cursor[segment] = createMethod(mappedFeature, adapter, defaults, requestTimeoutMs, httpTransport, log)
663
- continue
664
- }
665
-
666
- if (!cursor[segment]) {
667
- cursor[segment] = {}
668
- }
669
-
670
- const node = cursor[segment]
671
- if (typeof node !== 'object' || node === null) {
672
- cursor[segment] = {}
673
- }
674
-
675
- cursor = cursor[segment] as GitServiceApi
676
- }
677
- }
678
-
679
- export const createGitServiceApi = (options: GitServiceApiFactoryOptions = {}): GitServiceApi => {
680
- const config = getGitPlatformConfig(options.config)
681
- const adapter = createGitPlatformAdapter({
682
- config,
683
- ...(options.swaggerSpec ? { swaggerSpec: options.swaggerSpec } : {}),
684
- })
685
- const requestTimeoutMs = parseRequestTimeoutMs(options.requestTimeoutMs) ?? resolveDefaultRequestTimeoutMs()
686
- const httpTransport = resolveHttpTransport(options.httpTransport ?? process.env.EXAMPLE_GIT_HTTP_TRANSPORT)
687
- const log = options.log
688
-
689
- const defaults = {
690
- defaultOwner: options.defaultOwner,
691
- defaultRepo: options.defaultRepo,
692
- }
693
-
694
- const root: GitServiceApi = {}
695
-
696
- for (const feature of gitServiceFeatureSpec.features) {
697
- if (feature.path.length === 0) {
698
- continue
699
- }
700
-
701
- addFeatureToNamespace(root, feature.path, feature, adapter, defaults, requestTimeoutMs, httpTransport, log)
702
-
703
- if (feature.path[0] !== 'repo') {
704
- addFeatureToNamespace(
705
- root,
706
- ['repo', ...feature.path],
707
- feature,
708
- adapter,
709
- defaults,
710
- requestTimeoutMs,
711
- httpTransport,
712
- log,
713
- )
714
- }
715
- }
716
-
717
- attachGitLabelManagementApi(root, defaults)
718
- attachGitActionsApi(root, {
719
- config,
720
- defaults,
721
- requestTimeoutMs,
722
- log,
723
- })
724
- attachGitCiApi(root, {
725
- config,
726
- defaults,
727
- requestTimeoutMs,
728
- log,
729
- })
730
-
731
- return root
732
- }
733
-
734
- const createUnavailableGitServiceApi = (error: Error): GitServiceApi => {
735
- return new Proxy(
736
- {},
737
- {
738
- get: (): never => {
739
- throw error
740
- },
741
- },
742
- ) as GitServiceApi
743
- }
744
-
745
- export const gitServiceApi: GitServiceApi = (() => {
746
- try {
747
- return createGitServiceApi()
748
- } catch (error) {
749
- const message = error instanceof Error ? error.message : String(error)
750
- return createUnavailableGitServiceApi(
751
- new Error(`Failed to initialize gitServiceApi singleton: ${message}`),
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
+ })()