@foundation0/git 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +291 -291
- package/gitea-swagger.json +28627 -28627
- package/mcp/README.md +266 -266
- package/mcp/cli.mjs +37 -37
- package/mcp/src/cli.ts +76 -76
- package/mcp/src/client.ts +147 -147
- package/mcp/src/index.ts +7 -7
- package/mcp/src/redaction.ts +207 -207
- package/mcp/src/server.ts +1778 -938
- package/package.json +3 -1
- package/src/actions-api.ts +860 -637
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -544
- package/src/git-service-api.ts +822 -754
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -55
- package/src/issue-dependencies.ts +533 -533
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -460
- package/src/platform/gitea-rules.ts +129 -129
- package/src/platform/index.ts +44 -44
- package/src/repository.ts +151 -151
- package/src/spec-mock.ts +45 -45
package/src/ci-api.ts
CHANGED
|
@@ -1,544 +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
|
-
}
|
|
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
|
+
}
|