@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.
package/src/api.ts CHANGED
@@ -1,69 +1,69 @@
1
- import {
2
- GIT_API_VERSION,
3
- buildGitApiMockResponse,
4
- GitApiHeaders,
5
- GitApiMockResponse,
6
- GitApiPath,
7
- GitApiQuery,
8
- MockInvocation,
9
- } from './spec-mock'
10
-
11
- export type GitApiInvocationPath = GitApiPath
12
- export type GitApiInvocationQuery = GitApiQuery
13
- export type GitApiInvocationHeaders = GitApiHeaders
14
-
15
- export interface GitApiInvocation {
16
- path: GitApiInvocationPath
17
- method?: string
18
- query?: GitApiInvocationQuery
19
- headers?: GitApiInvocationHeaders
20
- }
21
-
22
- export interface GitApiClientOptions {
23
- apiBase?: string
24
- apiVersion?: string
25
- defaultMethod?: string
26
- }
27
-
28
- export class RemoteGitApiClient {
29
- private readonly apiBase: string
30
- private readonly apiVersion: string
31
- private readonly defaultMethod: string
32
-
33
- public constructor(options: GitApiClientOptions = {}) {
34
- this.apiBase = options.apiBase ?? 'https://api.github.com'
35
- this.apiVersion = options.apiVersion ?? GIT_API_VERSION
36
- this.defaultMethod = options.defaultMethod ?? 'GET'
37
- }
38
-
39
- public buildInvocation(invocation: GitApiInvocation): MockInvocation {
40
- return {
41
- path: invocation.path,
42
- method: invocation.method ?? this.defaultMethod,
43
- query: invocation.query,
44
- headers: invocation.headers,
45
- apiBase: this.apiBase,
46
- apiVersion: this.apiVersion,
47
- }
48
- }
49
-
50
- public request(invocation: GitApiInvocation): GitApiMockResponse {
51
- return buildGitApiMockResponse(this.buildInvocation(invocation))
52
- }
53
-
54
- public requestAsync(invocation: GitApiInvocation): Promise<GitApiMockResponse> {
55
- return Promise.resolve(this.request(invocation))
56
- }
57
-
58
- public get metadata() {
59
- return {
60
- apiBase: this.apiBase,
61
- apiVersion: this.apiVersion,
62
- defaultMethod: this.defaultMethod,
63
- }
64
- }
65
- }
66
-
67
- export function createRemoteGitApiClient(options: GitApiClientOptions = {}): RemoteGitApiClient {
68
- return new RemoteGitApiClient(options)
69
- }
1
+ import {
2
+ GIT_API_VERSION,
3
+ buildGitApiMockResponse,
4
+ GitApiHeaders,
5
+ GitApiMockResponse,
6
+ GitApiPath,
7
+ GitApiQuery,
8
+ MockInvocation,
9
+ } from './spec-mock'
10
+
11
+ export type GitApiInvocationPath = GitApiPath
12
+ export type GitApiInvocationQuery = GitApiQuery
13
+ export type GitApiInvocationHeaders = GitApiHeaders
14
+
15
+ export interface GitApiInvocation {
16
+ path: GitApiInvocationPath
17
+ method?: string
18
+ query?: GitApiInvocationQuery
19
+ headers?: GitApiInvocationHeaders
20
+ }
21
+
22
+ export interface GitApiClientOptions {
23
+ apiBase?: string
24
+ apiVersion?: string
25
+ defaultMethod?: string
26
+ }
27
+
28
+ export class RemoteGitApiClient {
29
+ private readonly apiBase: string
30
+ private readonly apiVersion: string
31
+ private readonly defaultMethod: string
32
+
33
+ public constructor(options: GitApiClientOptions = {}) {
34
+ this.apiBase = options.apiBase ?? 'https://api.github.com'
35
+ this.apiVersion = options.apiVersion ?? GIT_API_VERSION
36
+ this.defaultMethod = options.defaultMethod ?? 'GET'
37
+ }
38
+
39
+ public buildInvocation(invocation: GitApiInvocation): MockInvocation {
40
+ return {
41
+ path: invocation.path,
42
+ method: invocation.method ?? this.defaultMethod,
43
+ query: invocation.query,
44
+ headers: invocation.headers,
45
+ apiBase: this.apiBase,
46
+ apiVersion: this.apiVersion,
47
+ }
48
+ }
49
+
50
+ public request(invocation: GitApiInvocation): GitApiMockResponse {
51
+ return buildGitApiMockResponse(this.buildInvocation(invocation))
52
+ }
53
+
54
+ public requestAsync(invocation: GitApiInvocation): Promise<GitApiMockResponse> {
55
+ return Promise.resolve(this.request(invocation))
56
+ }
57
+
58
+ public get metadata() {
59
+ return {
60
+ apiBase: this.apiBase,
61
+ apiVersion: this.apiVersion,
62
+ defaultMethod: this.defaultMethod,
63
+ }
64
+ }
65
+ }
66
+
67
+ export function createRemoteGitApiClient(options: GitApiClientOptions = {}): RemoteGitApiClient {
68
+ return new RemoteGitApiClient(options)
69
+ }
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
+ }