@foundation0/git 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ci-api.ts ADDED
@@ -0,0 +1,544 @@
1
+ import type { GitApiFeatureMapping } from './platform/gitea-adapter'
2
+ import type { GitPlatformConfig } from './platform/config'
3
+ import type { GitServiceApi, GitServiceApiExecutionResult } from './git-service-api'
4
+
5
+ type CiApiDefaults = {
6
+ defaultOwner?: string
7
+ defaultRepo?: string
8
+ }
9
+
10
+ export type GitCiApiContext = {
11
+ config: GitPlatformConfig
12
+ defaults: CiApiDefaults
13
+ requestTimeoutMs: number
14
+ log?: (message: string) => void
15
+ }
16
+
17
+ type NormalizedCall = {
18
+ args: string[]
19
+ options: Record<string, unknown>
20
+ }
21
+
22
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
23
+ typeof value === 'object' && value !== null && !Array.isArray(value)
24
+
25
+ const toTrimmedString = (value: unknown): string =>
26
+ value === null || value === undefined ? '' : String(value).trim()
27
+
28
+ const toPositiveInteger = (value: unknown): number | null => {
29
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
30
+ return Math.floor(value)
31
+ }
32
+
33
+ if (typeof value !== 'string') return null
34
+ const trimmed = value.trim()
35
+ if (!trimmed) return null
36
+ const parsed = Number(trimmed)
37
+ if (!Number.isFinite(parsed) || parsed <= 0) return null
38
+ return Math.floor(parsed)
39
+ }
40
+
41
+ const splitArgsAndOptions = (rawArgs: unknown[]): NormalizedCall => {
42
+ if (rawArgs.length === 0 || !isRecord(rawArgs[rawArgs.length - 1])) {
43
+ return {
44
+ args: rawArgs.map((value) => String(value)),
45
+ options: {},
46
+ }
47
+ }
48
+
49
+ const last = rawArgs[rawArgs.length - 1] as Record<string, unknown>
50
+ return {
51
+ args: rawArgs.slice(0, -1).map((value) => String(value)),
52
+ options: last,
53
+ }
54
+ }
55
+
56
+ const buildApiBase = (host: string): string => {
57
+ const sanitizedHost = host.replace(/\/$/, '')
58
+ if (sanitizedHost.endsWith('/api/v1')) {
59
+ return sanitizedHost
60
+ }
61
+ return `${sanitizedHost}/api/v1`
62
+ }
63
+
64
+ const toQueryArray = (query: Record<string, string | number | boolean>): string[] =>
65
+ Object.entries(query).map(([key, value]) => `${key}=${value}`)
66
+
67
+ const toHeaderRecord = (headers: string[]): Record<string, string> =>
68
+ Object.fromEntries(
69
+ headers
70
+ .map((entry) => {
71
+ const separatorIndex = entry.indexOf(':')
72
+ if (separatorIndex < 0) return null
73
+ const name = entry.slice(0, separatorIndex).trim()
74
+ const value = entry.slice(separatorIndex + 1).trim()
75
+ return [name, value]
76
+ })
77
+ .filter((entry): entry is [string, string] => Boolean(entry)),
78
+ )
79
+
80
+ const fetchJson = async (
81
+ ctx: GitCiApiContext,
82
+ path: string[],
83
+ query: Record<string, string | number | boolean> = {},
84
+ ): Promise<{ status: number; ok: boolean; headers: Record<string, string>; body: unknown; url: string }> => {
85
+ const apiBase = buildApiBase(ctx.config.giteaHost)
86
+ const url = new URL(`${apiBase}/${path.join('/')}`)
87
+ for (const [key, value] of Object.entries(query)) {
88
+ url.searchParams.set(key, String(value))
89
+ }
90
+
91
+ const headersList: string[] = []
92
+ headersList.push('Accept: application/json')
93
+ if (ctx.config.giteaToken) {
94
+ headersList.push(`Authorization: token ${ctx.config.giteaToken}`)
95
+ }
96
+
97
+ ctx.log?.(`ci:http:request GET ${url.toString()}`)
98
+ const response = await fetch(url.toString(), { headers: toHeaderRecord(headersList) })
99
+ const responseHeaders: Record<string, string> = {}
100
+ try {
101
+ response.headers.forEach((value, key) => {
102
+ responseHeaders[key.toLowerCase()] = value
103
+ })
104
+ } catch {
105
+ // best effort
106
+ }
107
+
108
+ const contentType = response.headers.get('content-type') ?? ''
109
+ const text = await response.text().catch(() => '')
110
+ let parsed: unknown = text
111
+ if (contentType.toLowerCase().includes('application/json')) {
112
+ try {
113
+ parsed = JSON.parse(text)
114
+ } catch {
115
+ parsed = text
116
+ }
117
+ }
118
+
119
+ ctx.log?.(`ci:http:response GET ${url.toString()} -> ${response.status}`)
120
+ return {
121
+ status: response.status,
122
+ ok: response.ok,
123
+ headers: responseHeaders,
124
+ body: parsed,
125
+ url: url.toString(),
126
+ }
127
+ }
128
+
129
+ const resolveOwnerRepo = (
130
+ scope: { owner?: string; repo?: string },
131
+ defaults: CiApiDefaults,
132
+ ): { owner: string; repo: string } => {
133
+ const owner = scope.owner ?? defaults.defaultOwner
134
+ const repo = scope.repo ?? defaults.defaultRepo
135
+
136
+ if (!owner || !repo) {
137
+ throw new Error(
138
+ 'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
139
+ )
140
+ }
141
+
142
+ return { owner, repo }
143
+ }
144
+
145
+ type NormalizedCheckState = 'success' | 'failure' | 'pending'
146
+
147
+ const normalizeGiteaStatusState = (raw: unknown): NormalizedCheckState => {
148
+ const value = toTrimmedString(raw).toLowerCase()
149
+ if (value === 'success') return 'success'
150
+ if (value === 'pending') return 'pending'
151
+ if (value === 'failure') return 'failure'
152
+ if (value === 'failed') return 'failure'
153
+ if (value === 'error') return 'failure'
154
+ if (value === 'cancelled' || value === 'canceled') return 'failure'
155
+ if (value === 'skipped') return 'success'
156
+ if (value === 'running' || value === 'in_progress' || value === 'queued' || value === 'waiting') return 'pending'
157
+ return 'pending'
158
+ }
159
+
160
+ const summarizeOverall = (states: NormalizedCheckState[]) => {
161
+ const total = states.length
162
+ const failed = states.filter((state) => state === 'failure').length
163
+ const pending = states.filter((state) => state === 'pending').length
164
+ const successful = states.filter((state) => state === 'success').length
165
+
166
+ const state: NormalizedCheckState =
167
+ failed > 0 ? 'failure' : pending > 0 ? 'pending' : total > 0 ? 'success' : 'success'
168
+
169
+ return { state, total, failed, pending, successful }
170
+ }
171
+
172
+ const listWorkflowFiles = async (
173
+ ctx: GitCiApiContext,
174
+ owner: string,
175
+ repo: string,
176
+ ): Promise<Array<{ id: string; name: string; path: string; state: string; url?: string; html_url?: string }>> => {
177
+ const directories = ['.gitea/workflows', '.github/workflows']
178
+ const workflows: Array<{ id: string; name: string; path: string; state: string; url?: string; html_url?: string }> = []
179
+
180
+ for (const directory of directories) {
181
+ const response = await fetchJson(ctx, ['repos', owner, repo, 'contents', directory])
182
+ if (!response.ok) {
183
+ continue
184
+ }
185
+
186
+ if (!Array.isArray(response.body)) {
187
+ continue
188
+ }
189
+
190
+ for (const entry of response.body) {
191
+ if (!isRecord(entry)) continue
192
+ const type = toTrimmedString(entry.type).toLowerCase()
193
+ if (type !== 'file') continue
194
+ const name = toTrimmedString(entry.name)
195
+ if (!name.toLowerCase().endsWith('.yml') && !name.toLowerCase().endsWith('.yaml')) continue
196
+ const path = toTrimmedString(entry.path) || `${directory}/${name}`
197
+
198
+ workflows.push({
199
+ id: path,
200
+ name,
201
+ path,
202
+ state: 'active',
203
+ ...(typeof entry.url === 'string' ? { url: entry.url } : {}),
204
+ ...(typeof entry.html_url === 'string' ? { html_url: entry.html_url } : {}),
205
+ })
206
+ }
207
+ }
208
+
209
+ const unique = new Map<string, { id: string; name: string; path: string; state: string; url?: string; html_url?: string }>()
210
+ for (const workflow of workflows) {
211
+ unique.set(workflow.id, workflow)
212
+ }
213
+ return [...unique.values()]
214
+ }
215
+
216
+ export const attachGitCiApi = (api: GitServiceApi, ctx: GitCiApiContext): void => {
217
+ const prChecks = async (...rawArgs: unknown[]): Promise<GitServiceApiExecutionResult> => {
218
+ if (ctx.config.platform !== 'GITEA') {
219
+ throw new Error(`pr.checks only supports GITEA platform, got ${ctx.config.platform}`)
220
+ }
221
+
222
+ const parsed = splitArgsAndOptions(rawArgs)
223
+ const args = parsed.args.map((value) => value.trim()).filter((value) => value.length > 0)
224
+ const options = parsed.options
225
+
226
+ let owner: string | undefined
227
+ let repo: string | undefined
228
+ let prNumber: number | null = null
229
+
230
+ if (args.length >= 3) {
231
+ owner = args[0]
232
+ repo = args[1]
233
+ prNumber = toPositiveInteger(args[2])
234
+ } else if (args.length === 2) {
235
+ if (args[0].includes('/')) {
236
+ const split = args[0].split('/')
237
+ if (split.length === 2 && split[0] && split[1]) {
238
+ owner = split[0]
239
+ repo = split[1]
240
+ prNumber = toPositiveInteger(args[1])
241
+ }
242
+ }
243
+ } else if (args.length === 1) {
244
+ prNumber = toPositiveInteger(args[0])
245
+ }
246
+
247
+ if (!prNumber) {
248
+ prNumber = toPositiveInteger(
249
+ (options.prNumber ?? options.pr_number ?? options.number ?? options.pull ?? options.pull_number) as unknown,
250
+ )
251
+ }
252
+
253
+ if (!owner) {
254
+ owner = typeof options.owner === 'string' ? options.owner : undefined
255
+ }
256
+
257
+ if (!repo) {
258
+ repo = typeof options.repo === 'string' ? options.repo : undefined
259
+ }
260
+
261
+ const scope = resolveOwnerRepo({ owner, repo }, ctx.defaults)
262
+
263
+ if (!prNumber) {
264
+ throw new Error('PR number is required.')
265
+ }
266
+
267
+ const prResponse = await fetchJson(ctx, ['repos', scope.owner, scope.repo, 'pulls', String(prNumber)])
268
+ if (!prResponse.ok) {
269
+ return {
270
+ mapping: {
271
+ platform: 'GITEA',
272
+ featurePath: ['pr', 'checks'],
273
+ mappedPath: ['repos', scope.owner, scope.repo, 'pulls', String(prNumber)],
274
+ method: 'GET',
275
+ query: [],
276
+ headers: [],
277
+ apiBase: buildApiBase(ctx.config.giteaHost),
278
+ swaggerPath: '/repos/{owner}/{repo}/pulls/{index}',
279
+ mapped: true,
280
+ reason: 'Fetch PR for checks computation',
281
+ },
282
+ request: {
283
+ url: prResponse.url,
284
+ method: 'GET',
285
+ headers: {},
286
+ query: [],
287
+ },
288
+ response: {
289
+ headers: prResponse.headers,
290
+ },
291
+ status: prResponse.status,
292
+ ok: prResponse.ok,
293
+ body: prResponse.body,
294
+ }
295
+ }
296
+
297
+ const prBody = prResponse.body
298
+ const head = isRecord(prBody) ? prBody.head : null
299
+ const headSha =
300
+ toTrimmedString(isRecord(head) ? head.sha : null) ||
301
+ toTrimmedString(isRecord(prBody) ? prBody.head_sha : null) ||
302
+ toTrimmedString(isRecord(prBody) ? prBody.headSha : null)
303
+ const headBranch =
304
+ toTrimmedString(isRecord(head) ? head.ref : null) ||
305
+ toTrimmedString(isRecord(prBody) ? prBody.head_branch : null) ||
306
+ toTrimmedString(isRecord(prBody) ? prBody.headBranch : null)
307
+
308
+ if (!headSha) {
309
+ throw new Error('Unable to determine PR head SHA from pull request response.')
310
+ }
311
+
312
+ const [tasksResponse, statusesResponse] = await Promise.all([
313
+ fetchJson(ctx, ['repos', scope.owner, scope.repo, 'actions', 'tasks'], { limit: 50 }),
314
+ fetchJson(ctx, ['repos', scope.owner, scope.repo, 'statuses', headSha]),
315
+ ])
316
+
317
+ const actionRuns =
318
+ isRecord(tasksResponse.body) && Array.isArray(tasksResponse.body.workflow_runs)
319
+ ? (tasksResponse.body.workflow_runs as unknown[])
320
+ : []
321
+
322
+ const actionChecks = actionRuns
323
+ .filter((entry) => isRecord(entry))
324
+ .filter((entry) => toTrimmedString((entry as Record<string, unknown>).head_sha) === headSha)
325
+ .map((entry) => {
326
+ const record = entry as Record<string, unknown>
327
+ const state = normalizeGiteaStatusState(record.status)
328
+ const name =
329
+ toTrimmedString(record.name) ||
330
+ toTrimmedString(record.display_title) ||
331
+ toTrimmedString(record.workflow_id) ||
332
+ `run:${toTrimmedString(record.id)}`
333
+ const detailsUrl = toTrimmedString(record.url)
334
+ const runNumber = toPositiveInteger(record.run_number)
335
+ const jobId = toTrimmedString(record.id)
336
+ return {
337
+ source: 'actions' as const,
338
+ name,
339
+ state,
340
+ ...(detailsUrl ? { details_url: detailsUrl } : {}),
341
+ ...(runNumber ? { run_number: runNumber } : {}),
342
+ ...(jobId ? { job_id: jobId } : {}),
343
+ ...(typeof record.updated_at === 'string' ? { updated_at: record.updated_at } : {}),
344
+ }
345
+ })
346
+
347
+ const combinedState = isRecord(statusesResponse.body) ? normalizeGiteaStatusState(statusesResponse.body.state) : null
348
+ const commitStatuses =
349
+ isRecord(statusesResponse.body) && Array.isArray(statusesResponse.body.statuses)
350
+ ? (statusesResponse.body.statuses as unknown[])
351
+ : []
352
+
353
+ const statusChecks = commitStatuses
354
+ .filter((entry) => isRecord(entry))
355
+ .map((entry) => {
356
+ const record = entry as Record<string, unknown>
357
+ const name =
358
+ toTrimmedString(record.context) ||
359
+ toTrimmedString(record.name) ||
360
+ toTrimmedString(record.id) ||
361
+ 'status'
362
+ const state = normalizeGiteaStatusState(record.status ?? record.state)
363
+ const detailsUrl = toTrimmedString(record.target_url ?? record.targetUrl)
364
+ return {
365
+ source: 'status' as const,
366
+ name,
367
+ state,
368
+ ...(detailsUrl ? { details_url: detailsUrl } : {}),
369
+ ...(typeof record.description === 'string' ? { description: record.description } : {}),
370
+ ...(typeof record.updated_at === 'string' ? { updated_at: record.updated_at } : {}),
371
+ ...(typeof record.created_at === 'string' ? { created_at: record.created_at } : {}),
372
+ }
373
+ })
374
+
375
+ const checks = [...actionChecks, ...statusChecks]
376
+ const states = checks.map((check) => check.state)
377
+ const overallBase = summarizeOverall(states)
378
+ const overall =
379
+ checks.length === 0 && (!tasksResponse.ok || !statusesResponse.ok)
380
+ ? { ...overallBase, state: 'pending' as const }
381
+ : overallBase
382
+
383
+ const body = {
384
+ pr: {
385
+ number: prNumber,
386
+ headSha,
387
+ ...(headBranch ? { headBranch } : {}),
388
+ ...(isRecord(prBody) && typeof prBody.html_url === 'string' ? { url: prBody.html_url } : {}),
389
+ ...(isRecord(prBody) && typeof prBody.title === 'string' ? { title: prBody.title } : {}),
390
+ },
391
+ overall: {
392
+ ...overall,
393
+ ...(combinedState ? { combined_state: combinedState } : {}),
394
+ ...(tasksResponse.ok ? {} : { actions_error: tasksResponse.body }),
395
+ ...(statusesResponse.ok ? {} : { statuses_error: statusesResponse.body }),
396
+ },
397
+ checks,
398
+ meta: {
399
+ actions_total_count: isRecord(tasksResponse.body) ? tasksResponse.body.total_count : undefined,
400
+ },
401
+ }
402
+
403
+ const mapping: GitApiFeatureMapping = {
404
+ platform: 'GITEA',
405
+ featurePath: ['pr', 'checks'],
406
+ mappedPath: ['repos', scope.owner, scope.repo, 'pulls', String(prNumber), 'checks'],
407
+ method: 'GET',
408
+ query: [],
409
+ headers: [],
410
+ apiBase: buildApiBase(ctx.config.giteaHost),
411
+ swaggerPath: '/repos/{owner}/{repo}/pulls/{index} + /actions/tasks + /statuses/{sha}',
412
+ mapped: true,
413
+ reason: 'Computed PR checks from actions tasks and commit statuses',
414
+ }
415
+
416
+ return {
417
+ mapping,
418
+ request: {
419
+ url: 'computed://repo/pr/checks',
420
+ method: 'GET',
421
+ headers: {},
422
+ query: [],
423
+ },
424
+ response: { headers: {} },
425
+ status: 200,
426
+ ok: true,
427
+ body,
428
+ }
429
+ }
430
+
431
+ const workflowList = async (...rawArgs: unknown[]): Promise<GitServiceApiExecutionResult> => {
432
+ if (ctx.config.platform !== 'GITEA') {
433
+ throw new Error(`workflow.list only supports GITEA platform, got ${ctx.config.platform}`)
434
+ }
435
+
436
+ const parsed = splitArgsAndOptions(rawArgs)
437
+ const args = parsed.args.map((value) => value.trim()).filter((value) => value.length > 0)
438
+ const options = parsed.options
439
+
440
+ let owner: string | undefined
441
+ let repo: string | undefined
442
+
443
+ if (args.length >= 2) {
444
+ owner = args[0]
445
+ repo = args[1]
446
+ } else if (args.length === 1 && args[0].includes('/')) {
447
+ const split = args[0].split('/')
448
+ if (split.length === 2 && split[0] && split[1]) {
449
+ owner = split[0]
450
+ repo = split[1]
451
+ }
452
+ }
453
+
454
+ if (!owner) {
455
+ owner = typeof options.owner === 'string' ? options.owner : undefined
456
+ }
457
+ if (!repo) {
458
+ repo = typeof options.repo === 'string' ? options.repo : undefined
459
+ }
460
+
461
+ const scope = resolveOwnerRepo({ owner, repo }, ctx.defaults)
462
+
463
+ const apiResponse = await fetchJson(ctx, ['repos', scope.owner, scope.repo, 'actions', 'workflows'])
464
+
465
+ const workflowsFromApi =
466
+ isRecord(apiResponse.body) && Array.isArray(apiResponse.body.workflows)
467
+ ? (apiResponse.body.workflows as unknown[])
468
+ : []
469
+
470
+ if (apiResponse.ok && workflowsFromApi.length > 0) {
471
+ return {
472
+ mapping: {
473
+ platform: 'GITEA',
474
+ featurePath: ['workflow', 'list'],
475
+ mappedPath: ['repos', scope.owner, scope.repo, 'actions', 'workflows'],
476
+ method: 'GET',
477
+ query: [],
478
+ headers: [],
479
+ apiBase: buildApiBase(ctx.config.giteaHost),
480
+ swaggerPath: '/repos/{owner}/{repo}/actions/workflows',
481
+ mapped: true,
482
+ reason: 'Gitea actions workflows API',
483
+ },
484
+ request: {
485
+ url: apiResponse.url,
486
+ method: 'GET',
487
+ headers: {},
488
+ query: [],
489
+ },
490
+ response: { headers: apiResponse.headers },
491
+ status: apiResponse.status,
492
+ ok: apiResponse.ok,
493
+ body: apiResponse.body,
494
+ }
495
+ }
496
+
497
+ const fallbackWorkflows = await listWorkflowFiles(ctx, scope.owner, scope.repo)
498
+ const fallbackBody = {
499
+ total_count: fallbackWorkflows.length,
500
+ workflows: fallbackWorkflows,
501
+ ...(apiResponse.ok ? {} : { api_error: apiResponse.body }),
502
+ }
503
+
504
+ return {
505
+ mapping: {
506
+ platform: 'GITEA',
507
+ featurePath: ['workflow', 'list'],
508
+ mappedPath: ['repos', scope.owner, scope.repo, 'actions', 'workflows'],
509
+ method: 'GET',
510
+ query: [],
511
+ headers: [],
512
+ apiBase: buildApiBase(ctx.config.giteaHost),
513
+ swaggerPath: '/repos/{owner}/{repo}/actions/workflows',
514
+ mapped: true,
515
+ reason: 'Fallback to repository workflow files when API returns empty',
516
+ },
517
+ request: {
518
+ url: apiResponse.url,
519
+ method: 'GET',
520
+ headers: {},
521
+ query: [],
522
+ },
523
+ response: { headers: apiResponse.headers },
524
+ status: apiResponse.ok ? 200 : apiResponse.status,
525
+ ok: true,
526
+ body: fallbackBody,
527
+ }
528
+ }
529
+
530
+ const root = api as Record<string, any>
531
+
532
+ // Override "workflow.list" if the platform adapter returns an empty list in Gitea environments.
533
+ root.workflow ??= {}
534
+ root.workflow.list = workflowList
535
+ root.repo ??= {}
536
+ root.repo.workflow ??= {}
537
+ root.repo.workflow.list = workflowList
538
+
539
+ // Override "pr.checks" to return a CI-friendly summary with per-check states.
540
+ root.pr ??= {}
541
+ root.pr.checks = prChecks
542
+ root.repo.pr ??= {}
543
+ root.repo.pr.checks = prChecks
544
+ }
@@ -4,6 +4,7 @@ import { type GitServiceFlag, type GitServiceFeature, gitServiceFeatureSpec } fr
4
4
  import { type GitApiFeatureMapping } from './platform/gitea-adapter'
5
5
  import { attachGitLabelManagementApi } from './label-management'
6
6
  import { attachGitActionsApi } from './actions-api'
7
+ import { attachGitCiApi } from './ci-api'
7
8
  import { spawn } from 'node:child_process'
8
9
  import crypto from 'node:crypto'
9
10
 
@@ -312,15 +313,44 @@ const resolveHttpTransport = (requested?: string): 'fetch' | 'curl' => {
312
313
  const readStream = async (stream: NodeJS.ReadableStream | null): Promise<Buffer> => {
313
314
  if (!stream) return Buffer.from([])
314
315
 
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))
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)
321
325
  }
322
- }
323
- return Buffer.concat(chunks)
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
+ })
324
354
  }
325
355
 
326
356
  const callCurl = async (
@@ -344,6 +374,7 @@ const callCurl = async (
344
374
  const timeoutSeconds = requestTimeoutMs > 0 ? Math.max(1, Math.ceil(requestTimeoutMs / 1000)) : null
345
375
  if (timeoutSeconds !== null) {
346
376
  args.push('--max-time', String(timeoutSeconds))
377
+ args.push('--connect-timeout', String(Math.max(1, Math.min(30, timeoutSeconds))))
347
378
  }
348
379
 
349
380
  for (const [name, value] of Object.entries(init.headers)) {
@@ -361,20 +392,54 @@ const callCurl = async (
361
392
  windowsHide: true,
362
393
  })
363
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
+
364
418
  if (init.body !== undefined) {
365
419
  child.stdin.write(init.body)
366
420
  }
367
421
  child.stdin.end()
368
422
 
369
- const [stdoutBytes, stderrBytes] = await Promise.all([
370
- readStream(child.stdout),
371
- readStream(child.stderr),
372
- ])
423
+ const stdoutPromise = readStream(child.stdout)
424
+ const stderrPromise = readStream(child.stderr)
373
425
 
374
- const exitCode: number = await new Promise((resolve) => {
375
- child.on('close', (code) => resolve(code ?? 0))
376
- child.on('error', () => resolve(1))
377
- })
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
+ }
378
443
 
379
444
  const stdout = stdoutBytes.toString('utf8')
380
445
  const stderr = stderrBytes.toString('utf8').trim()
@@ -656,6 +721,12 @@ export const createGitServiceApi = (options: GitServiceApiFactoryOptions = {}):
656
721
  requestTimeoutMs,
657
722
  log,
658
723
  })
724
+ attachGitCiApi(root, {
725
+ config,
726
+ defaults,
727
+ requestTimeoutMs,
728
+ log,
729
+ })
659
730
 
660
731
  return root
661
732
  }
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export {
45
45
  type GitRepositoryLabel,
46
46
  } from './label-management'
47
47
  export { attachGitActionsApi, createGitActionsApi, type GitActionsApiContext, type GitActionsApiDefaults, type GitActionsHelpBody } from './actions-api'
48
+ export { attachGitCiApi, type GitCiApiContext } from './ci-api'
48
49
  export { resolveProjectRepoIdentity, type GitRepositoryIdentity } from './repository'
49
50
  export type {
50
51
  GitServiceApi,