@foundation0/git 1.2.4 → 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/mcp/README.md +51 -1
- package/mcp/src/client.ts +10 -1
- package/mcp/src/server.ts +621 -39
- package/package.json +1 -1
- package/src/actions-api.ts +150 -4
- package/src/ci-api.ts +544 -0
- package/src/git-service-api.ts +87 -16
- package/src/index.ts +1 -0
- package/src/issue-dependencies.ts +80 -16
- package/src/platform/gitea-adapter.ts +16 -4
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
|
+
}
|
package/src/git-service-api.ts
CHANGED
|
@@ -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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
readStream(child.stderr),
|
|
372
|
-
])
|
|
423
|
+
const stdoutPromise = readStream(child.stdout)
|
|
424
|
+
const stderrPromise = readStream(child.stderr)
|
|
373
425
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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,
|