@foundation0/git 1.2.3 → 1.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/git",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
4
4
  "description": "Foundation 0 Git API and MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,616 @@
1
+ import type { GitServiceApi, GitServiceApiExecutionResult, GitServiceApiMethod } from './git-service-api'
2
+ import type { GitApiFeatureMapping } from './platform/gitea-adapter'
3
+ import type { GitPlatformConfig } from './platform/config'
4
+
5
+ const DEFAULT_LOG_TAIL_LINES = 200
6
+ const DEFAULT_HTTP_TIMEOUT_MS = 60_000
7
+
8
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
9
+ typeof value === 'object' && value !== null && !Array.isArray(value)
10
+
11
+ const toTrimmedString = (value: unknown): string | null => {
12
+ if (typeof value !== 'string') {
13
+ return null
14
+ }
15
+ const trimmed = value.trim()
16
+ return trimmed.length > 0 ? trimmed : null
17
+ }
18
+
19
+ const toPositiveInteger = (value: unknown): number | null => {
20
+ const candidate = Number(value)
21
+ if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
22
+ return null
23
+ }
24
+ return candidate
25
+ }
26
+
27
+ const buildApiBase = (host: string): string => {
28
+ const sanitized = host.replace(/\/$/, '')
29
+ if (sanitized.endsWith('/api/v1')) {
30
+ return sanitized
31
+ }
32
+ return `${sanitized}/api/v1`
33
+ }
34
+
35
+ const toQueryArray = (query: Record<string, string | number | boolean>): string[] =>
36
+ Object.entries(query).map(([key, value]) => `${key}=${String(value)}`)
37
+
38
+ const toHeaderRecord = (headers: string[]): Record<string, string> =>
39
+ Object.fromEntries(
40
+ headers
41
+ .map((entry) => {
42
+ const separatorIndex = entry.indexOf(':')
43
+ if (separatorIndex < 0) {
44
+ return null
45
+ }
46
+ const name = entry.slice(0, separatorIndex).trim()
47
+ const value = entry.slice(separatorIndex + 1).trim()
48
+ return [name, value]
49
+ })
50
+ .filter((entry): entry is [string, string] => Boolean(entry)),
51
+ )
52
+
53
+ type RawCall = {
54
+ args: string[]
55
+ options: Record<string, unknown>
56
+ }
57
+
58
+ const splitArgsAndOptions = (rawArgs: unknown[]): RawCall => {
59
+ if (rawArgs.length === 0 || !isRecord(rawArgs[rawArgs.length - 1])) {
60
+ return {
61
+ args: rawArgs.map((value) => String(value)),
62
+ options: {},
63
+ }
64
+ }
65
+
66
+ const last = rawArgs[rawArgs.length - 1] as Record<string, unknown>
67
+ return {
68
+ args: rawArgs.slice(0, -1).map((value) => String(value)),
69
+ options: { ...last },
70
+ }
71
+ }
72
+
73
+ type Scope = {
74
+ owner?: string
75
+ repo?: string
76
+ options: Record<string, unknown>
77
+ }
78
+
79
+ export interface GitActionsApiDefaults {
80
+ defaultOwner?: string
81
+ defaultRepo?: string
82
+ }
83
+
84
+ export interface GitActionsApiContext {
85
+ config: Pick<GitPlatformConfig, 'giteaHost' | 'giteaToken' | 'platform'>
86
+ defaults?: GitActionsApiDefaults
87
+ requestTimeoutMs?: number
88
+ log?: (message: string) => void
89
+ }
90
+
91
+ const normalizeScopeArgs = (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown): Scope => {
92
+ const options: Record<string, unknown> = {}
93
+ let owner: string | undefined
94
+ let repo: string | undefined
95
+
96
+ const absorb = (value: unknown): string | undefined => {
97
+ if (value === undefined || value === null) {
98
+ return undefined
99
+ }
100
+
101
+ if (isRecord(value)) {
102
+ Object.assign(options, value)
103
+ return undefined
104
+ }
105
+
106
+ const asText = String(value).trim()
107
+ return asText.length > 0 ? asText : undefined
108
+ }
109
+
110
+ owner = absorb(ownerOrOptions)
111
+ repo = absorb(repoOrOptions)
112
+
113
+ if (isRecord(maybeOptions)) {
114
+ Object.assign(options, maybeOptions)
115
+ }
116
+
117
+ return {
118
+ ...(owner ? { owner } : {}),
119
+ ...(repo ? { repo } : {}),
120
+ options,
121
+ }
122
+ }
123
+
124
+ const resolveOwnerRepo = (
125
+ scope: Pick<Scope, 'owner' | 'repo'>,
126
+ defaults: GitActionsApiDefaults,
127
+ ): { owner: string; repo: string } => {
128
+ const owner = scope.owner ?? defaults.defaultOwner
129
+ const repo = scope.repo ?? defaults.defaultRepo
130
+
131
+ if (!owner || !repo) {
132
+ throw new Error(
133
+ 'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
134
+ )
135
+ }
136
+
137
+ return { owner, repo }
138
+ }
139
+
140
+ const resolveOwnerRepoFromArgs = (
141
+ args: string[],
142
+ scope: Pick<Scope, 'owner' | 'repo'>,
143
+ defaults: GitActionsApiDefaults,
144
+ ): { owner: string; repo: string; rest: string[] } => {
145
+ if (scope.owner || scope.repo) {
146
+ const resolved = resolveOwnerRepo(scope, defaults)
147
+ return { ...resolved, rest: args }
148
+ }
149
+
150
+ if (args.length >= 2) {
151
+ return { owner: args[0], repo: args[1], rest: args.slice(2) }
152
+ }
153
+
154
+ if (args.length === 1) {
155
+ const split = args[0].split('/')
156
+ if (split.length === 2 && split[0] && split[1]) {
157
+ return { owner: split[0], repo: split[1], rest: [] }
158
+ }
159
+ }
160
+
161
+ const resolved = resolveOwnerRepo({}, defaults)
162
+ return { ...resolved, rest: args }
163
+ }
164
+
165
+ const resolveOwnerRepoAndId = (
166
+ rawArgs: unknown[],
167
+ defaults: GitActionsApiDefaults,
168
+ ): { owner: string; repo: string; id: string; options: Record<string, unknown> } => {
169
+ const parsed = splitArgsAndOptions(rawArgs)
170
+ const args = parsed.args.map((value) => value.trim()).filter((value) => value.length > 0)
171
+
172
+ if (args.length === 0) {
173
+ throw new Error('ID is required.')
174
+ }
175
+
176
+ if (args.length === 1) {
177
+ const { owner, repo } = resolveOwnerRepo({}, defaults)
178
+ return { owner, repo, id: args[0], options: parsed.options }
179
+ }
180
+
181
+ if (args.length === 2) {
182
+ if (args[0].includes('/')) {
183
+ const split = args[0].split('/')
184
+ if (split.length === 2 && split[0] && split[1]) {
185
+ return { owner: split[0], repo: split[1], id: args[1], options: parsed.options }
186
+ }
187
+ }
188
+
189
+ throw new Error(
190
+ 'Ambiguous arguments. Pass <id> (and rely on defaults), or pass <owner> <repo> <id>, or pass <owner/repo> <id>.',
191
+ )
192
+ }
193
+
194
+ return { owner: args[0], repo: args[1], id: args[2], options: parsed.options }
195
+ }
196
+
197
+ const resolveTimeoutMs = (value: unknown, fallback: number): number => {
198
+ const parsed = toPositiveInteger(value)
199
+ return parsed ?? fallback
200
+ }
201
+
202
+ const readTextBody = async (response: Response): Promise<string> => {
203
+ try {
204
+ return await response.text()
205
+ } catch {
206
+ return ''
207
+ }
208
+ }
209
+
210
+ const readBody = async (response: Response): Promise<unknown> => {
211
+ const contentType = response.headers.get('content-type') ?? ''
212
+ const text = await readTextBody(response)
213
+ if (contentType.toLowerCase().includes('application/json')) {
214
+ try {
215
+ return JSON.parse(text)
216
+ } catch {
217
+ return text
218
+ }
219
+ }
220
+ return text
221
+ }
222
+
223
+ const requestGitea = async (
224
+ ctx: GitActionsApiContext,
225
+ featurePath: string[],
226
+ owner: string,
227
+ repo: string,
228
+ method: string,
229
+ pathTail: string[],
230
+ options: Record<string, unknown> = {},
231
+ requestInitOverrides: Partial<RequestInit> = {},
232
+ ): Promise<GitServiceApiExecutionResult<unknown>> => {
233
+ if (ctx.config.platform !== 'GITEA') {
234
+ throw new Error(`Actions API only supports GITEA platform, got ${ctx.config.platform}`)
235
+ }
236
+
237
+ const apiBase = buildApiBase(ctx.config.giteaHost)
238
+ const mappedPath = ['repos', owner, repo, 'actions', ...pathTail]
239
+
240
+ const query = isRecord(options.query) ? (options.query as Record<string, unknown>) : {}
241
+ const queryRecord: Record<string, string | number | boolean> = {}
242
+ for (const [key, value] of Object.entries(query)) {
243
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
244
+ queryRecord[key] = value
245
+ }
246
+ }
247
+
248
+ const url = new URL(`${apiBase}/${mappedPath.join('/')}`)
249
+ for (const [key, value] of Object.entries(queryRecord)) {
250
+ url.searchParams.set(key, String(value))
251
+ }
252
+
253
+ const headersList: string[] = []
254
+ headersList.push('Accept: */*')
255
+ if (ctx.config.giteaToken) {
256
+ headersList.push(`Authorization: token ${ctx.config.giteaToken}`)
257
+ }
258
+
259
+ const headers = toHeaderRecord(headersList)
260
+
261
+ const requestTimeoutMs = resolveTimeoutMs(options.requestTimeoutMs ?? ctx.requestTimeoutMs, DEFAULT_HTTP_TIMEOUT_MS)
262
+ const timeoutSignal =
263
+ requestTimeoutMs > 0 && typeof (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout === 'function'
264
+ ? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
265
+ : null
266
+ const controller = !timeoutSignal && requestTimeoutMs > 0 ? new AbortController() : null
267
+ const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
268
+
269
+ try {
270
+ ctx.log?.(`actions:http:request ${method.toUpperCase()} ${url.toString()}`)
271
+ const response = await fetch(url.toString(), {
272
+ method: method.toUpperCase(),
273
+ headers,
274
+ ...(timeoutSignal ? { signal: timeoutSignal } : {}),
275
+ ...(controller ? { signal: controller.signal } : {}),
276
+ ...requestInitOverrides,
277
+ })
278
+
279
+ const body = await readBody(response)
280
+ const responseHeaders: Record<string, string> = {}
281
+ try {
282
+ response.headers.forEach((value, key) => {
283
+ responseHeaders[key.toLowerCase()] = value
284
+ })
285
+ } catch {
286
+ // best effort
287
+ }
288
+
289
+ const mapping: GitApiFeatureMapping = {
290
+ platform: 'GITEA',
291
+ featurePath,
292
+ mappedPath,
293
+ method: method.toUpperCase(),
294
+ query: toQueryArray(queryRecord),
295
+ headers: headersList,
296
+ apiBase,
297
+ swaggerPath: `/${mappedPath.join('/')}`,
298
+ mapped: true,
299
+ reason: 'Direct Gitea Actions API call',
300
+ }
301
+
302
+ return {
303
+ mapping,
304
+ request: {
305
+ url: url.toString(),
306
+ method: method.toUpperCase(),
307
+ headers,
308
+ query: toQueryArray(queryRecord),
309
+ },
310
+ response: {
311
+ headers: responseHeaders,
312
+ },
313
+ status: response.status,
314
+ ok: response.ok,
315
+ body,
316
+ }
317
+ } finally {
318
+ if (timeoutId) {
319
+ clearTimeout(timeoutId)
320
+ }
321
+ }
322
+ }
323
+
324
+ const tailText = (input: string, options: { contains?: string; maxLines?: number; maxBytes?: number }): string => {
325
+ const contains = toTrimmedString(options.contains ?? null) ?? null
326
+ const maxLines = toPositiveInteger(options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
327
+ const maxBytes = toPositiveInteger(options.maxBytes)
328
+
329
+ let text = input
330
+
331
+ if (contains) {
332
+ text = text
333
+ .split(/\r?\n/g)
334
+ .filter((line) => line.includes(contains))
335
+ .join('\n')
336
+ }
337
+
338
+ if (maxLines > 0) {
339
+ const lines = text.split(/\r?\n/g)
340
+ text = lines.slice(Math.max(0, lines.length - maxLines)).join('\n')
341
+ }
342
+
343
+ if (maxBytes && maxBytes > 0) {
344
+ const buf = Buffer.from(text, 'utf8')
345
+ if (buf.length > maxBytes) {
346
+ text = buf.subarray(buf.length - maxBytes).toString('utf8')
347
+ }
348
+ }
349
+
350
+ return text
351
+ }
352
+
353
+ export type GitActionsHelpBody = {
354
+ summary: string
355
+ suggestedCalls: Array<{
356
+ tool: string
357
+ args: string[]
358
+ options?: Record<string, unknown>
359
+ notes?: string
360
+ }>
361
+ assumptions: string[]
362
+ }
363
+
364
+ export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): Record<string, GitServiceApiMethod> => {
365
+ const defaults = ctx.defaults ?? {}
366
+
367
+ return {
368
+ helpActionsLogs: async (): Promise<GitServiceApiExecutionResult<GitActionsHelpBody>> => {
369
+ const body: GitActionsHelpBody = {
370
+ summary:
371
+ 'Use actions.tasks.list to locate the failing run, then actions.jobs.logsTail to pull the last N lines of job logs.',
372
+ suggestedCalls: [
373
+ {
374
+ tool: 'repo.actions.tasks.list',
375
+ args: ['<owner>', '<repo>'],
376
+ options: { query: { limit: 50 } },
377
+ notes: 'Filter client-side by head_sha and run_number from the response.',
378
+ },
379
+ {
380
+ tool: 'repo.actions.jobs.logsTail',
381
+ args: ['<job_id>'],
382
+ options: { maxLines: 250 },
383
+ notes: 'If you have default owner/repo configured, you can pass only <job_id>.',
384
+ },
385
+ {
386
+ tool: 'repo.actions.jobs.logsForRunTail',
387
+ args: ['<head_sha>', '<run_number>'],
388
+ options: { maxLines: 250 },
389
+ notes: 'Convenience helper: resolves a run from tasks and then fetches logs (assumes run.id is job_id).',
390
+ },
391
+ ],
392
+ assumptions: [
393
+ 'Gitea exposes job logs at GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs.',
394
+ 'This helper assumes ActionTask.id can be used as job_id for the logs endpoint. If that is false in your Gitea version, use the UI to find the job_id.',
395
+ ],
396
+ }
397
+
398
+ const mapping: GitApiFeatureMapping = {
399
+ platform: 'GITEA',
400
+ featurePath: ['help', 'actionsLogs'],
401
+ mappedPath: ['help', 'actionsLogs'],
402
+ method: 'GET',
403
+ query: [],
404
+ headers: ['Accept: application/json'],
405
+ apiBase: buildApiBase(ctx.config.giteaHost),
406
+ swaggerPath: '/help/actionsLogs',
407
+ mapped: true,
408
+ reason: 'Local help content',
409
+ }
410
+
411
+ return {
412
+ mapping,
413
+ request: {
414
+ url: 'local://help/actionsLogs',
415
+ method: 'GET',
416
+ headers: {},
417
+ query: [],
418
+ },
419
+ response: {
420
+ headers: {},
421
+ },
422
+ status: 200,
423
+ ok: true,
424
+ body,
425
+ }
426
+ },
427
+
428
+ tasks: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
429
+ const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
430
+ const { owner, repo } = resolveOwnerRepo(parsed, defaults)
431
+ return requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], parsed.options)
432
+ },
433
+
434
+ jobsLogs: async (...rawArgs: unknown[]) => {
435
+ const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
436
+ return requestGitea(
437
+ ctx,
438
+ ['actions', 'jobs', 'logs'],
439
+ resolved.owner,
440
+ resolved.repo,
441
+ 'GET',
442
+ ['jobs', resolved.id, 'logs'],
443
+ resolved.options,
444
+ )
445
+ },
446
+
447
+ jobsLogsTail: async (...rawArgs: unknown[]) => {
448
+ const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
449
+ const result = await requestGitea(
450
+ ctx,
451
+ ['actions', 'jobs', 'logs'],
452
+ resolved.owner,
453
+ resolved.repo,
454
+ 'GET',
455
+ ['jobs', resolved.id, 'logs'],
456
+ resolved.options,
457
+ )
458
+
459
+ const asText = typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)
460
+ const tailed = tailText(asText, {
461
+ contains: resolved.options.contains,
462
+ maxLines: resolved.options.maxLines,
463
+ maxBytes: resolved.options.maxBytes,
464
+ })
465
+
466
+ return {
467
+ ...result,
468
+ body: tailed,
469
+ }
470
+ },
471
+
472
+ jobsLogsForRunTail: async (headSha: unknown, runNumber: unknown, maybeOptions?: unknown) => {
473
+ const sha = toTrimmedString(headSha)
474
+ const run = toPositiveInteger(runNumber)
475
+ const options = isRecord(maybeOptions) ? (maybeOptions as Record<string, unknown>) : {}
476
+
477
+ if (!sha || !run) {
478
+ throw new Error('headSha (string) and runNumber (positive integer) are required.')
479
+ }
480
+
481
+ const scope = normalizeScopeArgs(options.owner, options.repo, options)
482
+ const { owner, repo } = resolveOwnerRepo(scope, defaults)
483
+ const tasks = await requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], scope.options)
484
+
485
+ const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
486
+ ? (tasks.body.workflow_runs as unknown[])
487
+ : []
488
+
489
+ const match = entries.find((entry) => {
490
+ if (!isRecord(entry)) return false
491
+ return String(entry.head_sha ?? '') === sha && Number(entry.run_number ?? NaN) === run
492
+ })
493
+
494
+ if (!match || !isRecord(match)) {
495
+ throw new Error(`No matching run found in actions/tasks for head_sha=${sha} run_number=${run}.`)
496
+ }
497
+
498
+ const jobId = match.id
499
+ const jobIdText = toTrimmedString(jobId)
500
+ if (!jobIdText) {
501
+ throw new Error('Matched run entry does not expose an id usable as job_id.')
502
+ }
503
+
504
+ const logs = await requestGitea(
505
+ ctx,
506
+ ['actions', 'jobs', 'logs'],
507
+ owner,
508
+ repo,
509
+ 'GET',
510
+ ['jobs', jobIdText, 'logs'],
511
+ scope.options,
512
+ )
513
+
514
+ const asText = typeof logs.body === 'string' ? logs.body : JSON.stringify(logs.body, null, 2)
515
+ const tailed = tailText(asText, {
516
+ contains: scope.options.contains,
517
+ maxLines: scope.options.maxLines,
518
+ maxBytes: scope.options.maxBytes,
519
+ })
520
+
521
+ return {
522
+ ...logs,
523
+ body: tailed,
524
+ }
525
+ },
526
+
527
+ artifacts: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
528
+ const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
529
+ const { owner, repo } = resolveOwnerRepo(parsed, defaults)
530
+ return requestGitea(ctx, ['actions', 'artifacts', 'list'], owner, repo, 'GET', ['artifacts'], parsed.options)
531
+ },
532
+
533
+ artifactsByRun: async (runId: unknown, ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
534
+ const id = toTrimmedString(runId)
535
+ if (!id) {
536
+ throw new Error('runId is required.')
537
+ }
538
+ const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
539
+ const { owner, repo } = resolveOwnerRepo(parsed, defaults)
540
+ return requestGitea(ctx, ['actions', 'runs', 'artifacts'], owner, repo, 'GET', ['runs', id, 'artifacts'], parsed.options)
541
+ },
542
+
543
+ artifactZipUrl: async (...rawArgs: unknown[]) => {
544
+ const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
545
+
546
+ const result = await requestGitea(
547
+ ctx,
548
+ ['actions', 'artifacts', 'downloadZipUrl'],
549
+ resolved.owner,
550
+ resolved.repo,
551
+ 'GET',
552
+ ['artifacts', resolved.id, 'zip'],
553
+ resolved.options,
554
+ { redirect: 'manual' },
555
+ )
556
+
557
+ const location =
558
+ typeof (result.response.headers.location) === 'string'
559
+ ? result.response.headers.location
560
+ : typeof (result.response.headers['location']) === 'string'
561
+ ? result.response.headers['location']
562
+ : null
563
+
564
+ return {
565
+ ...result,
566
+ body: {
567
+ status: result.status,
568
+ location,
569
+ },
570
+ }
571
+ },
572
+ }
573
+ }
574
+
575
+ const ensureNamespace = (root: GitServiceApi, path: string[]): GitServiceApi => {
576
+ let cursor: GitServiceApi = root
577
+ for (const segment of path) {
578
+ const current = cursor[segment]
579
+ if (!isRecord(current)) {
580
+ cursor[segment] = {}
581
+ }
582
+ cursor = cursor[segment] as GitServiceApi
583
+ }
584
+ return cursor
585
+ }
586
+
587
+ export const attachGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): void => {
588
+ const methods = createGitActionsApi(api, ctx)
589
+ const targets = [
590
+ ensureNamespace(api, ['actions']),
591
+ ensureNamespace(api, ['repo', 'actions']),
592
+ ensureNamespace(api, ['help']),
593
+ ensureNamespace(api, ['repo', 'help']),
594
+ ]
595
+
596
+ const mapping: Record<string, { to: string; description: string }> = {
597
+ helpActionsLogs: { to: 'actionsLogs', description: 'Help for fetching Gitea Actions job logs' },
598
+ tasks: { to: 'tasks.list', description: 'List repository action tasks (workflow runs)' },
599
+ jobsLogs: { to: 'jobs.logs', description: 'Download job logs for a workflow run job_id' },
600
+ jobsLogsTail: { to: 'jobs.logsTail', description: 'Download and tail job logs (bounded for LLM)' },
601
+ jobsLogsForRunTail: { to: 'jobs.logsForRunTail', description: 'Resolve run by sha+run_number and tail logs' },
602
+ artifacts: { to: 'artifacts.list', description: 'List repository artifacts' },
603
+ artifactsByRun: { to: 'runs.artifacts', description: 'List artifacts for a run' },
604
+ artifactZipUrl: { to: 'artifacts.downloadZipUrl', description: 'Return redirect URL for artifact zip download' },
605
+ }
606
+
607
+ for (const target of targets) {
608
+ for (const [name, method] of Object.entries(methods)) {
609
+ const exportName = mapping[name]?.to ?? name
610
+ if (!(exportName in target)) {
611
+ target[exportName] = method
612
+ }
613
+ }
614
+ }
615
+ }
616
+