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