@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/README.md +291 -291
- package/gitea-swagger.json +28627 -28627
- package/mcp/README.md +266 -250
- 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 -718
- package/package.json +3 -1
- package/src/actions-api.ts +900 -531
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -0
- package/src/git-service-api.ts +822 -683
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -54
- package/src/issue-dependencies.ts +533 -469
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -448
- 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/actions-api.ts
CHANGED
|
@@ -1,517 +1,738 @@
|
|
|
1
|
-
import type { GitServiceApi, GitServiceApiExecutionResult, GitServiceApiMethod } from './git-service-api'
|
|
2
|
-
import type { GitApiFeatureMapping } from './platform/gitea-adapter'
|
|
3
|
-
import type { GitPlatformConfig } from './platform/config'
|
|
4
|
-
|
|
5
|
-
const DEFAULT_LOG_TAIL_LINES = 200
|
|
6
|
-
const DEFAULT_HTTP_TIMEOUT_MS = 60_000
|
|
7
|
-
|
|
8
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
9
|
-
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1
|
+
import type { GitServiceApi, GitServiceApiExecutionResult, GitServiceApiMethod } from './git-service-api'
|
|
2
|
+
import type { GitApiFeatureMapping } from './platform/gitea-adapter'
|
|
3
|
+
import type { GitPlatformConfig } from './platform/config'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LOG_TAIL_LINES = 200
|
|
6
|
+
const DEFAULT_HTTP_TIMEOUT_MS = 60_000
|
|
7
|
+
|
|
8
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
9
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
10
|
+
|
|
11
|
+
const toTrimmedString = (value: unknown): string | null => {
|
|
12
|
+
if (value === null || value === undefined) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (typeof value === 'number') {
|
|
17
|
+
if (!Number.isFinite(value)) return null
|
|
18
|
+
const asText = String(Math.trunc(value)).trim()
|
|
19
|
+
return asText.length > 0 ? asText : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof value === 'bigint') {
|
|
23
|
+
const asText = value.toString().trim()
|
|
24
|
+
return asText.length > 0 ? asText : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (typeof value !== 'string') {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const trimmed = value.trim()
|
|
32
|
+
return trimmed.length > 0 ? trimmed : null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toPositiveInteger = (value: unknown): number | null => {
|
|
36
|
+
const candidate = Number(value)
|
|
37
|
+
if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
return candidate
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const buildApiBase = (host: string): string => {
|
|
44
|
+
const sanitized = host.replace(/\/$/, '')
|
|
45
|
+
if (sanitized.endsWith('/api/v1')) {
|
|
46
|
+
return sanitized
|
|
47
|
+
}
|
|
48
|
+
return `${sanitized}/api/v1`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toQueryArray = (query: Record<string, string | number | boolean>): string[] =>
|
|
52
|
+
Object.entries(query).map(([key, value]) => `${key}=${String(value)}`)
|
|
53
|
+
|
|
54
|
+
const toHeaderRecord = (headers: string[]): Record<string, string> =>
|
|
55
|
+
Object.fromEntries(
|
|
56
|
+
headers
|
|
57
|
+
.map((entry) => {
|
|
58
|
+
const separatorIndex = entry.indexOf(':')
|
|
59
|
+
if (separatorIndex < 0) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
const name = entry.slice(0, separatorIndex).trim()
|
|
63
|
+
const value = entry.slice(separatorIndex + 1).trim()
|
|
64
|
+
return [name, value]
|
|
65
|
+
})
|
|
66
|
+
.filter((entry): entry is [string, string] => Boolean(entry)),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
type RawCall = {
|
|
70
|
+
args: string[]
|
|
71
|
+
options: Record<string, unknown>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const splitArgsAndOptions = (rawArgs: unknown[]): RawCall => {
|
|
75
|
+
if (rawArgs.length === 0 || !isRecord(rawArgs[rawArgs.length - 1])) {
|
|
76
|
+
return {
|
|
77
|
+
args: rawArgs.map((value) => String(value)),
|
|
78
|
+
options: {},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const last = rawArgs[rawArgs.length - 1] as Record<string, unknown>
|
|
83
|
+
return {
|
|
84
|
+
args: rawArgs.slice(0, -1).map((value) => String(value)),
|
|
85
|
+
options: { ...last },
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type Scope = {
|
|
90
|
+
owner?: string
|
|
91
|
+
repo?: string
|
|
92
|
+
options: Record<string, unknown>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface GitActionsApiDefaults {
|
|
96
|
+
defaultOwner?: string
|
|
97
|
+
defaultRepo?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface GitActionsApiContext {
|
|
101
|
+
config: Pick<GitPlatformConfig, 'giteaHost' | 'giteaToken' | 'platform'>
|
|
102
|
+
defaults?: GitActionsApiDefaults
|
|
103
|
+
requestTimeoutMs?: number
|
|
104
|
+
log?: (message: string) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const normalizeScopeArgs = (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown): Scope => {
|
|
108
|
+
const options: Record<string, unknown> = {}
|
|
109
|
+
let owner: string | undefined
|
|
110
|
+
let repo: string | undefined
|
|
111
|
+
|
|
112
|
+
const absorb = (value: unknown): string | undefined => {
|
|
113
|
+
if (value === undefined || value === null) {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (isRecord(value)) {
|
|
118
|
+
Object.assign(options, value)
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const asText = String(value).trim()
|
|
123
|
+
return asText.length > 0 ? asText : undefined
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
owner = absorb(ownerOrOptions)
|
|
127
|
+
repo = absorb(repoOrOptions)
|
|
128
|
+
|
|
129
|
+
if (isRecord(maybeOptions)) {
|
|
130
|
+
Object.assign(options, maybeOptions)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
...(owner ? { owner } : {}),
|
|
135
|
+
...(repo ? { repo } : {}),
|
|
136
|
+
options,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const resolveOwnerRepo = (
|
|
141
|
+
scope: Pick<Scope, 'owner' | 'repo'>,
|
|
142
|
+
defaults: GitActionsApiDefaults,
|
|
143
|
+
): { owner: string; repo: string } => {
|
|
144
|
+
const owner = scope.owner ?? defaults.defaultOwner
|
|
145
|
+
const repo = scope.repo ?? defaults.defaultRepo
|
|
146
|
+
|
|
147
|
+
if (!owner || !repo) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { owner, repo }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const resolveOwnerRepoFromArgs = (
|
|
157
|
+
args: string[],
|
|
158
|
+
scope: Pick<Scope, 'owner' | 'repo'>,
|
|
159
|
+
defaults: GitActionsApiDefaults,
|
|
160
|
+
): { owner: string; repo: string; rest: string[] } => {
|
|
161
|
+
if (scope.owner || scope.repo) {
|
|
162
|
+
const resolved = resolveOwnerRepo(scope, defaults)
|
|
163
|
+
return { ...resolved, rest: args }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (args.length >= 2) {
|
|
167
|
+
return { owner: args[0], repo: args[1], rest: args.slice(2) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (args.length === 1) {
|
|
171
|
+
const split = args[0].split('/')
|
|
172
|
+
if (split.length === 2 && split[0] && split[1]) {
|
|
173
|
+
return { owner: split[0], repo: split[1], rest: [] }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const resolved = resolveOwnerRepo({}, defaults)
|
|
178
|
+
return { ...resolved, rest: args }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type NormalizedActionsState = 'success' | 'failure' | 'pending'
|
|
182
|
+
|
|
183
|
+
const normalizeActionsState = (raw: unknown): NormalizedActionsState => {
|
|
184
|
+
const value = toTrimmedString(raw).toLowerCase()
|
|
185
|
+
if (value === 'success') return 'success'
|
|
186
|
+
if (value === 'failure' || value === 'failed' || value === 'error') return 'failure'
|
|
187
|
+
if (value === 'cancelled' || value === 'canceled') return 'failure'
|
|
188
|
+
if (value === 'skipped') return 'success'
|
|
189
|
+
if (value === 'running' || value === 'in_progress' || value === 'queued' || value === 'waiting') return 'pending'
|
|
190
|
+
if (value === 'pending') return 'pending'
|
|
191
|
+
return 'pending'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const resolveOwnerRepoAndId = (
|
|
195
|
+
rawArgs: unknown[],
|
|
196
|
+
defaults: GitActionsApiDefaults,
|
|
197
|
+
): { owner: string; repo: string; id: string; options: Record<string, unknown> } => {
|
|
198
|
+
const parsed = splitArgsAndOptions(rawArgs)
|
|
199
|
+
const args = parsed.args.map((value) => value.trim()).filter((value) => value.length > 0)
|
|
200
|
+
|
|
201
|
+
if (args.length === 0) {
|
|
202
|
+
throw new Error('ID is required.')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (args.length === 1) {
|
|
206
|
+
const { owner, repo } = resolveOwnerRepo({}, defaults)
|
|
207
|
+
return { owner, repo, id: args[0], options: parsed.options }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (args.length === 2) {
|
|
211
|
+
if (args[0].includes('/')) {
|
|
212
|
+
const split = args[0].split('/')
|
|
213
|
+
if (split.length === 2 && split[0] && split[1]) {
|
|
214
|
+
return { owner: split[0], repo: split[1], id: args[1], options: parsed.options }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
throw new Error(
|
|
219
|
+
'Ambiguous arguments. Pass <id> (and rely on defaults), or pass <owner> <repo> <id>, or pass <owner/repo> <id>.',
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { owner: args[0], repo: args[1], id: args[2], options: parsed.options }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const resolveTimeoutMs = (value: unknown, fallback: number): number => {
|
|
227
|
+
const parsed = toPositiveInteger(value)
|
|
228
|
+
return parsed ?? fallback
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const readTextBody = async (response: Response): Promise<string> => {
|
|
232
|
+
try {
|
|
233
|
+
return await response.text()
|
|
234
|
+
} catch {
|
|
235
|
+
return ''
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const readBody = async (response: Response): Promise<unknown> => {
|
|
240
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
241
|
+
const text = await readTextBody(response)
|
|
242
|
+
if (contentType.toLowerCase().includes('application/json')) {
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(text)
|
|
245
|
+
} catch {
|
|
246
|
+
return text
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return text
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const requestGitea = async (
|
|
253
|
+
ctx: GitActionsApiContext,
|
|
254
|
+
featurePath: string[],
|
|
255
|
+
owner: string,
|
|
256
|
+
repo: string,
|
|
257
|
+
method: string,
|
|
258
|
+
pathTail: string[],
|
|
259
|
+
options: Record<string, unknown> = {},
|
|
260
|
+
requestInitOverrides: Partial<RequestInit> = {},
|
|
261
|
+
): Promise<GitServiceApiExecutionResult<unknown>> => {
|
|
262
|
+
if (ctx.config.platform !== 'GITEA') {
|
|
263
|
+
throw new Error(`Actions API only supports GITEA platform, got ${ctx.config.platform}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const apiBase = buildApiBase(ctx.config.giteaHost)
|
|
267
|
+
const mappedPath = ['repos', owner, repo, 'actions', ...pathTail]
|
|
268
|
+
|
|
269
|
+
const query = isRecord(options.query) ? (options.query as Record<string, unknown>) : {}
|
|
270
|
+
const queryRecord: Record<string, string | number | boolean> = {}
|
|
271
|
+
for (const [key, value] of Object.entries(query)) {
|
|
272
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
273
|
+
queryRecord[key] = value
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const url = new URL(`${apiBase}/${mappedPath.join('/')}`)
|
|
278
|
+
for (const [key, value] of Object.entries(queryRecord)) {
|
|
279
|
+
url.searchParams.set(key, String(value))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const headersList: string[] = []
|
|
283
|
+
headersList.push('Accept: */*')
|
|
284
|
+
if (ctx.config.giteaToken) {
|
|
285
|
+
headersList.push(`Authorization: token ${ctx.config.giteaToken}`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const headers = toHeaderRecord(headersList)
|
|
289
|
+
|
|
290
|
+
const requestTimeoutMs = resolveTimeoutMs(options.requestTimeoutMs ?? ctx.requestTimeoutMs, DEFAULT_HTTP_TIMEOUT_MS)
|
|
291
|
+
const timeoutSignal =
|
|
292
|
+
requestTimeoutMs > 0 && typeof (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout === 'function'
|
|
293
|
+
? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
|
|
294
|
+
: null
|
|
295
|
+
const controller = !timeoutSignal && requestTimeoutMs > 0 ? new AbortController() : null
|
|
296
|
+
const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
ctx.log?.(`actions:http:request ${method.toUpperCase()} ${url.toString()}`)
|
|
300
|
+
const response = await fetch(url.toString(), {
|
|
301
|
+
method: method.toUpperCase(),
|
|
302
|
+
headers,
|
|
303
|
+
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
|
304
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
305
|
+
...requestInitOverrides,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
const body = await readBody(response)
|
|
309
|
+
const responseHeaders: Record<string, string> = {}
|
|
310
|
+
try {
|
|
311
|
+
response.headers.forEach((value, key) => {
|
|
312
|
+
responseHeaders[key.toLowerCase()] = value
|
|
313
|
+
})
|
|
314
|
+
} catch {
|
|
315
|
+
// best effort
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const mapping: GitApiFeatureMapping = {
|
|
319
|
+
platform: 'GITEA',
|
|
320
|
+
featurePath,
|
|
321
|
+
mappedPath,
|
|
322
|
+
method: method.toUpperCase(),
|
|
323
|
+
query: toQueryArray(queryRecord),
|
|
324
|
+
headers: headersList,
|
|
325
|
+
apiBase,
|
|
326
|
+
swaggerPath: `/${mappedPath.join('/')}`,
|
|
327
|
+
mapped: true,
|
|
328
|
+
reason: 'Direct Gitea Actions API call',
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
mapping,
|
|
333
|
+
request: {
|
|
334
|
+
url: url.toString(),
|
|
335
|
+
method: method.toUpperCase(),
|
|
336
|
+
headers,
|
|
337
|
+
query: toQueryArray(queryRecord),
|
|
338
|
+
},
|
|
339
|
+
response: {
|
|
340
|
+
headers: responseHeaders,
|
|
341
|
+
},
|
|
342
|
+
status: response.status,
|
|
343
|
+
ok: response.ok,
|
|
344
|
+
body,
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
if (timeoutId) {
|
|
348
|
+
clearTimeout(timeoutId)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const tailText = (input: string, options: { contains?: string; maxLines?: number; maxBytes?: number }): string => {
|
|
354
|
+
const contains = toTrimmedString(options.contains ?? null) ?? null
|
|
355
|
+
const maxLines = toPositiveInteger(options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
|
|
356
|
+
const maxBytes = toPositiveInteger(options.maxBytes)
|
|
357
|
+
|
|
358
|
+
let text = input
|
|
359
|
+
|
|
360
|
+
if (contains) {
|
|
361
|
+
text = text
|
|
362
|
+
.split(/\r?\n/g)
|
|
363
|
+
.filter((line) => line.includes(contains))
|
|
364
|
+
.join('\n')
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (maxLines > 0) {
|
|
368
|
+
const lines = text.split(/\r?\n/g)
|
|
369
|
+
text = lines.slice(Math.max(0, lines.length - maxLines)).join('\n')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (maxBytes && maxBytes > 0) {
|
|
373
|
+
const buf = Buffer.from(text, 'utf8')
|
|
374
|
+
if (buf.length > maxBytes) {
|
|
375
|
+
text = buf.subarray(buf.length - maxBytes).toString('utf8')
|
|
376
|
+
}
|
|
377
|
+
}
|
|
10
378
|
|
|
11
|
-
|
|
12
|
-
if (typeof value !== 'string') {
|
|
13
|
-
return null
|
|
14
|
-
}
|
|
15
|
-
const trimmed = value.trim()
|
|
16
|
-
return trimmed.length > 0 ? trimmed : null
|
|
379
|
+
return text
|
|
17
380
|
}
|
|
18
381
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
382
|
+
const tryParseJsonLikeText = (value: string): unknown | null => {
|
|
383
|
+
try {
|
|
384
|
+
return JSON.parse(value) as unknown
|
|
385
|
+
} catch {
|
|
22
386
|
return null
|
|
23
387
|
}
|
|
24
|
-
return candidate
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const buildApiBase = (host: string): string => {
|
|
28
|
-
const sanitized = host.replace(/\/$/, '')
|
|
29
|
-
if (sanitized.endsWith('/api/v1')) {
|
|
30
|
-
return sanitized
|
|
31
|
-
}
|
|
32
|
-
return `${sanitized}/api/v1`
|
|
33
388
|
}
|
|
34
389
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Object.fromEntries(
|
|
40
|
-
headers
|
|
41
|
-
.map((entry) => {
|
|
42
|
-
const separatorIndex = entry.indexOf(':')
|
|
43
|
-
if (separatorIndex < 0) {
|
|
44
|
-
return null
|
|
45
|
-
}
|
|
46
|
-
const name = entry.slice(0, separatorIndex).trim()
|
|
47
|
-
const value = entry.slice(separatorIndex + 1).trim()
|
|
48
|
-
return [name, value]
|
|
49
|
-
})
|
|
50
|
-
.filter((entry): entry is [string, string] => Boolean(entry)),
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
type RawCall = {
|
|
54
|
-
args: string[]
|
|
55
|
-
options: Record<string, unknown>
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const splitArgsAndOptions = (rawArgs: unknown[]): RawCall => {
|
|
59
|
-
if (rawArgs.length === 0 || !isRecord(rawArgs[rawArgs.length - 1])) {
|
|
60
|
-
return {
|
|
61
|
-
args: rawArgs.map((value) => String(value)),
|
|
62
|
-
options: {},
|
|
390
|
+
const extractErrorMessage = (body: unknown): string | null => {
|
|
391
|
+
if (isRecord(body)) {
|
|
392
|
+
if (typeof body.message === 'string' && body.message.trim().length > 0) {
|
|
393
|
+
return body.message.trim()
|
|
63
394
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const last = rawArgs[rawArgs.length - 1] as Record<string, unknown>
|
|
67
|
-
return {
|
|
68
|
-
args: rawArgs.slice(0, -1).map((value) => String(value)),
|
|
69
|
-
options: { ...last },
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
type Scope = {
|
|
74
|
-
owner?: string
|
|
75
|
-
repo?: string
|
|
76
|
-
options: Record<string, unknown>
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface GitActionsApiDefaults {
|
|
80
|
-
defaultOwner?: string
|
|
81
|
-
defaultRepo?: string
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export interface GitActionsApiContext {
|
|
85
|
-
config: Pick<GitPlatformConfig, 'giteaHost' | 'giteaToken' | 'platform'>
|
|
86
|
-
defaults?: GitActionsApiDefaults
|
|
87
|
-
requestTimeoutMs?: number
|
|
88
|
-
log?: (message: string) => void
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const normalizeScopeArgs = (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown): Scope => {
|
|
92
|
-
const options: Record<string, unknown> = {}
|
|
93
|
-
let owner: string | undefined
|
|
94
|
-
let repo: string | undefined
|
|
95
|
-
|
|
96
|
-
const absorb = (value: unknown): string | undefined => {
|
|
97
|
-
if (value === undefined || value === null) {
|
|
98
|
-
return undefined
|
|
395
|
+
if (typeof body.error === 'string' && body.error.trim().length > 0) {
|
|
396
|
+
return body.error.trim()
|
|
99
397
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return undefined
|
|
398
|
+
if (isRecord(body.error)) {
|
|
399
|
+
const nestedMessage = extractErrorMessage(body.error)
|
|
400
|
+
if (nestedMessage) return nestedMessage
|
|
104
401
|
}
|
|
105
|
-
|
|
106
|
-
const asText = String(value).trim()
|
|
107
|
-
return asText.length > 0 ? asText : undefined
|
|
108
402
|
}
|
|
109
403
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (isRecord(maybeOptions)) {
|
|
114
|
-
Object.assign(options, maybeOptions)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
...(owner ? { owner } : {}),
|
|
119
|
-
...(repo ? { repo } : {}),
|
|
120
|
-
options,
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const resolveOwnerRepo = (
|
|
125
|
-
scope: Pick<Scope, 'owner' | 'repo'>,
|
|
126
|
-
defaults: GitActionsApiDefaults,
|
|
127
|
-
): { owner: string; repo: string } => {
|
|
128
|
-
const owner = scope.owner ?? defaults.defaultOwner
|
|
129
|
-
const repo = scope.repo ?? defaults.defaultRepo
|
|
130
|
-
|
|
131
|
-
if (!owner || !repo) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
|
|
134
|
-
)
|
|
404
|
+
if (typeof body !== 'string') {
|
|
405
|
+
return null
|
|
135
406
|
}
|
|
136
407
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const resolveOwnerRepoFromArgs = (
|
|
141
|
-
args: string[],
|
|
142
|
-
scope: Pick<Scope, 'owner' | 'repo'>,
|
|
143
|
-
defaults: GitActionsApiDefaults,
|
|
144
|
-
): { owner: string; repo: string; rest: string[] } => {
|
|
145
|
-
if (scope.owner || scope.repo) {
|
|
146
|
-
const resolved = resolveOwnerRepo(scope, defaults)
|
|
147
|
-
return { ...resolved, rest: args }
|
|
408
|
+
const trimmed = body.trim()
|
|
409
|
+
if (!trimmed) {
|
|
410
|
+
return null
|
|
148
411
|
}
|
|
149
412
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
413
|
+
const parsed =
|
|
414
|
+
trimmed.startsWith('{') || trimmed.startsWith('[')
|
|
415
|
+
? tryParseJsonLikeText(trimmed)
|
|
416
|
+
: null
|
|
153
417
|
|
|
154
|
-
if (
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
return
|
|
418
|
+
if (parsed && isRecord(parsed)) {
|
|
419
|
+
const parsedMessage = extractErrorMessage(parsed)
|
|
420
|
+
if (parsedMessage) {
|
|
421
|
+
return parsedMessage
|
|
158
422
|
}
|
|
159
423
|
}
|
|
160
424
|
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const resolveOwnerRepoAndId = (
|
|
166
|
-
rawArgs: unknown[],
|
|
167
|
-
defaults: GitActionsApiDefaults,
|
|
168
|
-
): { owner: string; repo: string; id: string; options: Record<string, unknown> } => {
|
|
169
|
-
const parsed = splitArgsAndOptions(rawArgs)
|
|
170
|
-
const args = parsed.args.map((value) => value.trim()).filter((value) => value.length > 0)
|
|
171
|
-
|
|
172
|
-
if (args.length === 0) {
|
|
173
|
-
throw new Error('ID is required.')
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
if (args.length === 1) {
|
|
177
|
-
const { owner, repo } = resolveOwnerRepo({}, defaults)
|
|
178
|
-
return { owner, repo, id: args[0], options: parsed.options }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (args.length === 2) {
|
|
182
|
-
if (args[0].includes('/')) {
|
|
183
|
-
const split = args[0].split('/')
|
|
184
|
-
if (split.length === 2 && split[0] && split[1]) {
|
|
185
|
-
return { owner: split[0], repo: split[1], id: args[1], options: parsed.options }
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
throw new Error(
|
|
190
|
-
'Ambiguous arguments. Pass <id> (and rely on defaults), or pass <owner> <repo> <id>, or pass <owner/repo> <id>.',
|
|
191
|
-
)
|
|
425
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ''
|
|
426
|
+
if (firstLine === '{' || firstLine === '[') {
|
|
427
|
+
return null
|
|
192
428
|
}
|
|
193
429
|
|
|
194
|
-
return
|
|
430
|
+
return firstLine.length > 0 ? firstLine : null
|
|
195
431
|
}
|
|
196
432
|
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
433
|
+
const shouldTryNextJobIdCandidate = (status: number): boolean => status >= 500 || status === 404 || status === 422
|
|
434
|
+
|
|
435
|
+
const collectJobIdCandidates = (run: Record<string, unknown>): string[] => {
|
|
436
|
+
const nestedJobRecord = isRecord(run.job) ? run.job : {}
|
|
437
|
+
const candidates = [
|
|
438
|
+
run.job_id,
|
|
439
|
+
run.jobId,
|
|
440
|
+
run.task_id,
|
|
441
|
+
run.taskId,
|
|
442
|
+
nestedJobRecord.id,
|
|
443
|
+
nestedJobRecord.job_id,
|
|
444
|
+
nestedJobRecord.jobId,
|
|
445
|
+
run.id,
|
|
446
|
+
]
|
|
201
447
|
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
return await response.text()
|
|
205
|
-
} catch {
|
|
206
|
-
return ''
|
|
207
|
-
}
|
|
448
|
+
const normalized = candidates.map((candidate) => toTrimmedString(candidate)).filter((value): value is string => Boolean(value))
|
|
449
|
+
return Array.from(new Set(normalized))
|
|
208
450
|
}
|
|
209
451
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return JSON.parse(text)
|
|
216
|
-
} catch {
|
|
217
|
-
return text
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return text
|
|
452
|
+
type JobLogLookupAttempt = {
|
|
453
|
+
jobId: string
|
|
454
|
+
status: number
|
|
455
|
+
ok: boolean
|
|
456
|
+
message: string | null
|
|
221
457
|
}
|
|
222
458
|
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
):
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const apiBase = buildApiBase(ctx.config.giteaHost)
|
|
238
|
-
const mappedPath = ['repos', owner, repo, 'actions', ...pathTail]
|
|
239
|
-
|
|
240
|
-
const query = isRecord(options.query) ? (options.query as Record<string, unknown>) : {}
|
|
241
|
-
const queryRecord: Record<string, string | number | boolean> = {}
|
|
242
|
-
for (const [key, value] of Object.entries(query)) {
|
|
243
|
-
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
244
|
-
queryRecord[key] = value
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const url = new URL(`${apiBase}/${mappedPath.join('/')}`)
|
|
249
|
-
for (const [key, value] of Object.entries(queryRecord)) {
|
|
250
|
-
url.searchParams.set(key, String(value))
|
|
251
|
-
}
|
|
459
|
+
const buildJobLogsFailureBody = (params: {
|
|
460
|
+
owner: string
|
|
461
|
+
repo: string
|
|
462
|
+
headSha: string
|
|
463
|
+
runNumber: number
|
|
464
|
+
workflowName?: string | null
|
|
465
|
+
attempts: JobLogLookupAttempt[]
|
|
466
|
+
upstreamBody: unknown
|
|
467
|
+
maxLines: number
|
|
468
|
+
}): Record<string, unknown> => {
|
|
469
|
+
const workflowSegment =
|
|
470
|
+
params.workflowName && params.workflowName.trim().length > 0 ? ` workflowName=${params.workflowName.trim()}` : ''
|
|
252
471
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
472
|
+
return {
|
|
473
|
+
message: `Auto-resolution failed for Actions logs: head_sha=${params.headSha} run_number=${params.runNumber}${workflowSegment}. None of the candidate ids worked for /actions/jobs/{job_id}/logs on this Gitea instance.`,
|
|
474
|
+
hint: `Use explicit job_id flow: 1) repo.actions.tasks.list { owner, repo, query:{ limit: 100 } } 2) extract a concrete job_id from workflow_runs (or from the Gitea UI run page) 3) repo.actions.jobs.logsTail { args:["<job_id>"], owner, repo, maxLines:${params.maxLines} }.`,
|
|
475
|
+
suggestedCalls: [
|
|
476
|
+
{
|
|
477
|
+
tool: 'repo.actions.tasks.list',
|
|
478
|
+
args: [params.owner, params.repo],
|
|
479
|
+
options: { query: { limit: 100 } },
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
tool: 'repo.actions.jobs.logsTail',
|
|
483
|
+
args: ['<job_id>'],
|
|
484
|
+
options: { owner: params.owner, repo: params.repo, maxLines: params.maxLines },
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
autoResolutionNote: 'workflow_runs.id/task_id is not guaranteed to equal job_id across all Gitea versions/configurations.',
|
|
488
|
+
attemptedJobIds: params.attempts.map((attempt) => ({
|
|
489
|
+
jobId: attempt.jobId,
|
|
490
|
+
status: attempt.status,
|
|
491
|
+
...(attempt.message ? { message: attempt.message } : {}),
|
|
492
|
+
})),
|
|
493
|
+
upstream: params.upstreamBody,
|
|
257
494
|
}
|
|
495
|
+
}
|
|
258
496
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
497
|
+
const fetchJobLogsByCandidates = async (params: {
|
|
498
|
+
ctx: GitActionsApiContext
|
|
499
|
+
owner: string
|
|
500
|
+
repo: string
|
|
501
|
+
options: Record<string, unknown>
|
|
502
|
+
jobIds: string[]
|
|
503
|
+
}): Promise<{ logs: GitServiceApiExecutionResult<unknown>; selectedJobId: string; attempts: JobLogLookupAttempt[] }> => {
|
|
504
|
+
let lastResult: GitServiceApiExecutionResult<unknown> | null = null
|
|
505
|
+
let lastJobId = ''
|
|
506
|
+
const attempts: JobLogLookupAttempt[] = []
|
|
507
|
+
|
|
508
|
+
for (const jobId of params.jobIds) {
|
|
509
|
+
const logs = await requestGitea(
|
|
510
|
+
params.ctx,
|
|
511
|
+
['actions', 'jobs', 'logs'],
|
|
512
|
+
params.owner,
|
|
513
|
+
params.repo,
|
|
514
|
+
'GET',
|
|
515
|
+
['jobs', jobId, 'logs'],
|
|
516
|
+
params.options,
|
|
517
|
+
)
|
|
268
518
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
|
275
|
-
...(controller ? { signal: controller.signal } : {}),
|
|
276
|
-
...requestInitOverrides,
|
|
519
|
+
attempts.push({
|
|
520
|
+
jobId,
|
|
521
|
+
status: logs.status,
|
|
522
|
+
ok: logs.ok,
|
|
523
|
+
message: extractErrorMessage(logs.body),
|
|
277
524
|
})
|
|
278
525
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
response.headers.forEach((value, key) => {
|
|
283
|
-
responseHeaders[key.toLowerCase()] = value
|
|
284
|
-
})
|
|
285
|
-
} catch {
|
|
286
|
-
// best effort
|
|
287
|
-
}
|
|
526
|
+
lastResult = logs
|
|
527
|
+
lastJobId = jobId
|
|
288
528
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
featurePath,
|
|
292
|
-
mappedPath,
|
|
293
|
-
method: method.toUpperCase(),
|
|
294
|
-
query: toQueryArray(queryRecord),
|
|
295
|
-
headers: headersList,
|
|
296
|
-
apiBase,
|
|
297
|
-
swaggerPath: `/${mappedPath.join('/')}`,
|
|
298
|
-
mapped: true,
|
|
299
|
-
reason: 'Direct Gitea Actions API call',
|
|
529
|
+
if (logs.ok && logs.status < 400) {
|
|
530
|
+
return { logs, selectedJobId: jobId, attempts }
|
|
300
531
|
}
|
|
301
532
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
request: {
|
|
305
|
-
url: url.toString(),
|
|
306
|
-
method: method.toUpperCase(),
|
|
307
|
-
headers,
|
|
308
|
-
query: toQueryArray(queryRecord),
|
|
309
|
-
},
|
|
310
|
-
response: {
|
|
311
|
-
headers: responseHeaders,
|
|
312
|
-
},
|
|
313
|
-
status: response.status,
|
|
314
|
-
ok: response.ok,
|
|
315
|
-
body,
|
|
533
|
+
if (!shouldTryNextJobIdCandidate(logs.status)) {
|
|
534
|
+
return { logs, selectedJobId: jobId, attempts }
|
|
316
535
|
}
|
|
317
|
-
} finally {
|
|
318
|
-
if (timeoutId) {
|
|
319
|
-
clearTimeout(timeoutId)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const tailText = (input: string, options: { contains?: string; maxLines?: number; maxBytes?: number }): string => {
|
|
325
|
-
const contains = toTrimmedString(options.contains ?? null) ?? null
|
|
326
|
-
const maxLines = toPositiveInteger(options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
|
|
327
|
-
const maxBytes = toPositiveInteger(options.maxBytes)
|
|
328
|
-
|
|
329
|
-
let text = input
|
|
330
|
-
|
|
331
|
-
if (contains) {
|
|
332
|
-
text = text
|
|
333
|
-
.split(/\r?\n/g)
|
|
334
|
-
.filter((line) => line.includes(contains))
|
|
335
|
-
.join('\n')
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (maxLines > 0) {
|
|
339
|
-
const lines = text.split(/\r?\n/g)
|
|
340
|
-
text = lines.slice(Math.max(0, lines.length - maxLines)).join('\n')
|
|
341
536
|
}
|
|
342
537
|
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
if (buf.length > maxBytes) {
|
|
346
|
-
text = buf.subarray(buf.length - maxBytes).toString('utf8')
|
|
347
|
-
}
|
|
538
|
+
if (lastResult) {
|
|
539
|
+
return { logs: lastResult, selectedJobId: lastJobId, attempts }
|
|
348
540
|
}
|
|
349
541
|
|
|
350
|
-
|
|
542
|
+
throw new Error('No job id candidates were available for log lookup.')
|
|
351
543
|
}
|
|
352
544
|
|
|
353
545
|
export type GitActionsHelpBody = {
|
|
354
546
|
summary: string
|
|
355
547
|
suggestedCalls: Array<{
|
|
356
|
-
tool: string
|
|
357
|
-
args: string[]
|
|
358
|
-
options?: Record<string, unknown>
|
|
359
|
-
notes?: string
|
|
360
|
-
}>
|
|
361
|
-
assumptions: string[]
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): Record<string, GitServiceApiMethod> => {
|
|
365
|
-
const defaults = ctx.defaults ?? {}
|
|
366
|
-
|
|
367
|
-
return {
|
|
368
|
-
helpActionsLogs: async (): Promise<GitServiceApiExecutionResult<GitActionsHelpBody>> => {
|
|
369
|
-
const body: GitActionsHelpBody = {
|
|
370
|
-
summary:
|
|
371
|
-
'Use actions.tasks.list to locate the failing run, then actions.jobs.logsTail to pull the last N lines of job logs.',
|
|
372
|
-
suggestedCalls: [
|
|
373
|
-
{
|
|
374
|
-
tool: 'repo.actions.tasks.list',
|
|
375
|
-
args: ['<owner>', '<repo>'],
|
|
376
|
-
options: { query: { limit: 50 } },
|
|
377
|
-
notes: 'Filter client-side by head_sha and run_number from the response.',
|
|
378
|
-
},
|
|
379
|
-
{
|
|
380
|
-
tool: 'repo.actions.jobs.logsTail',
|
|
381
|
-
args: ['<job_id>'],
|
|
382
|
-
options: { maxLines: 250 },
|
|
383
|
-
notes: 'If you have default owner/repo configured, you can pass only <job_id>.',
|
|
384
|
-
},
|
|
385
|
-
{
|
|
386
|
-
tool: 'repo.actions.jobs.logsForRunTail',
|
|
387
|
-
args: ['<head_sha>', '<run_number>'],
|
|
388
|
-
options: { maxLines: 250 },
|
|
389
|
-
notes: 'Convenience helper: resolves a run from tasks and then fetches logs (assumes run.id is job_id).',
|
|
390
|
-
},
|
|
391
|
-
],
|
|
392
|
-
assumptions: [
|
|
393
|
-
'Gitea exposes job logs at GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs.',
|
|
394
|
-
'This helper assumes ActionTask.id can be used as job_id for the logs endpoint. If that is false in your Gitea version, use the UI to find the job_id.',
|
|
395
|
-
],
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
const mapping: GitApiFeatureMapping = {
|
|
399
|
-
platform: 'GITEA',
|
|
400
|
-
featurePath: ['help', 'actionsLogs'],
|
|
401
|
-
mappedPath: ['help', 'actionsLogs'],
|
|
402
|
-
method: 'GET',
|
|
403
|
-
query: [],
|
|
404
|
-
headers: ['Accept: application/json'],
|
|
405
|
-
apiBase: buildApiBase(ctx.config.giteaHost),
|
|
406
|
-
swaggerPath: '/help/actionsLogs',
|
|
407
|
-
mapped: true,
|
|
408
|
-
reason: 'Local help content',
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return {
|
|
412
|
-
mapping,
|
|
413
|
-
request: {
|
|
414
|
-
url: 'local://help/actionsLogs',
|
|
415
|
-
method: 'GET',
|
|
416
|
-
headers: {},
|
|
417
|
-
query: [],
|
|
418
|
-
},
|
|
419
|
-
response: {
|
|
420
|
-
headers: {},
|
|
421
|
-
},
|
|
422
|
-
status: 200,
|
|
423
|
-
ok: true,
|
|
424
|
-
body,
|
|
425
|
-
}
|
|
426
|
-
},
|
|
427
|
-
|
|
428
|
-
tasks: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
429
|
-
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
430
|
-
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
431
|
-
return requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], parsed.options)
|
|
432
|
-
},
|
|
433
|
-
|
|
434
|
-
jobsLogs: async (...rawArgs: unknown[]) => {
|
|
435
|
-
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
436
|
-
return requestGitea(
|
|
437
|
-
ctx,
|
|
438
|
-
['actions', 'jobs', 'logs'],
|
|
439
|
-
resolved.owner,
|
|
440
|
-
resolved.repo,
|
|
441
|
-
'GET',
|
|
442
|
-
['jobs', resolved.id, 'logs'],
|
|
443
|
-
resolved.options,
|
|
444
|
-
)
|
|
445
|
-
},
|
|
446
|
-
|
|
447
|
-
jobsLogsTail: async (...rawArgs: unknown[]) => {
|
|
448
|
-
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
449
|
-
const result = await requestGitea(
|
|
450
|
-
ctx,
|
|
451
|
-
['actions', 'jobs', 'logs'],
|
|
452
|
-
resolved.owner,
|
|
453
|
-
resolved.repo,
|
|
454
|
-
'GET',
|
|
455
|
-
['jobs', resolved.id, 'logs'],
|
|
456
|
-
resolved.options,
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
const asText = typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)
|
|
460
|
-
const tailed = tailText(asText, {
|
|
461
|
-
contains: resolved.options.contains,
|
|
462
|
-
maxLines: resolved.options.maxLines,
|
|
463
|
-
maxBytes: resolved.options.maxBytes,
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
return {
|
|
467
|
-
...result,
|
|
468
|
-
body: tailed,
|
|
469
|
-
}
|
|
470
|
-
},
|
|
471
|
-
|
|
548
|
+
tool: string
|
|
549
|
+
args: string[]
|
|
550
|
+
options?: Record<string, unknown>
|
|
551
|
+
notes?: string
|
|
552
|
+
}>
|
|
553
|
+
assumptions: string[]
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): Record<string, GitServiceApiMethod> => {
|
|
557
|
+
const defaults = ctx.defaults ?? {}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
helpActionsLogs: async (): Promise<GitServiceApiExecutionResult<GitActionsHelpBody>> => {
|
|
561
|
+
const body: GitActionsHelpBody = {
|
|
562
|
+
summary:
|
|
563
|
+
'Use actions.tasks.list to locate the failing run, then actions.jobs.logsTail to pull the last N lines of job logs.',
|
|
564
|
+
suggestedCalls: [
|
|
565
|
+
{
|
|
566
|
+
tool: 'repo.actions.tasks.list',
|
|
567
|
+
args: ['<owner>', '<repo>'],
|
|
568
|
+
options: { query: { limit: 50 } },
|
|
569
|
+
notes: 'Filter client-side by head_sha and run_number from the response.',
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
tool: 'repo.actions.jobs.logsTail',
|
|
573
|
+
args: ['<job_id>'],
|
|
574
|
+
options: { maxLines: 250 },
|
|
575
|
+
notes: 'If you have default owner/repo configured, you can pass only <job_id>.',
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
tool: 'repo.actions.jobs.logsForRunTail',
|
|
579
|
+
args: ['<head_sha>', '<run_number>'],
|
|
580
|
+
options: { maxLines: 250 },
|
|
581
|
+
notes: 'Convenience helper: resolves a run from tasks and then fetches logs (assumes run.id is job_id).',
|
|
582
|
+
},
|
|
583
|
+
],
|
|
584
|
+
assumptions: [
|
|
585
|
+
'Gitea exposes job logs at GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs.',
|
|
586
|
+
'This helper assumes ActionTask.id can be used as job_id for the logs endpoint. If that is false in your Gitea version, use the UI to find the job_id.',
|
|
587
|
+
],
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const mapping: GitApiFeatureMapping = {
|
|
591
|
+
platform: 'GITEA',
|
|
592
|
+
featurePath: ['help', 'actionsLogs'],
|
|
593
|
+
mappedPath: ['help', 'actionsLogs'],
|
|
594
|
+
method: 'GET',
|
|
595
|
+
query: [],
|
|
596
|
+
headers: ['Accept: application/json'],
|
|
597
|
+
apiBase: buildApiBase(ctx.config.giteaHost),
|
|
598
|
+
swaggerPath: '/help/actionsLogs',
|
|
599
|
+
mapped: true,
|
|
600
|
+
reason: 'Local help content',
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return {
|
|
604
|
+
mapping,
|
|
605
|
+
request: {
|
|
606
|
+
url: 'local://help/actionsLogs',
|
|
607
|
+
method: 'GET',
|
|
608
|
+
headers: {},
|
|
609
|
+
query: [],
|
|
610
|
+
},
|
|
611
|
+
response: {
|
|
612
|
+
headers: {},
|
|
613
|
+
},
|
|
614
|
+
status: 200,
|
|
615
|
+
ok: true,
|
|
616
|
+
body,
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
tasks: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
621
|
+
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
622
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
623
|
+
return requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], parsed.options)
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
jobsLogs: async (...rawArgs: unknown[]) => {
|
|
627
|
+
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
628
|
+
return requestGitea(
|
|
629
|
+
ctx,
|
|
630
|
+
['actions', 'jobs', 'logs'],
|
|
631
|
+
resolved.owner,
|
|
632
|
+
resolved.repo,
|
|
633
|
+
'GET',
|
|
634
|
+
['jobs', resolved.id, 'logs'],
|
|
635
|
+
resolved.options,
|
|
636
|
+
)
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
jobsLogsTail: async (...rawArgs: unknown[]) => {
|
|
640
|
+
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
641
|
+
const result = await requestGitea(
|
|
642
|
+
ctx,
|
|
643
|
+
['actions', 'jobs', 'logs'],
|
|
644
|
+
resolved.owner,
|
|
645
|
+
resolved.repo,
|
|
646
|
+
'GET',
|
|
647
|
+
['jobs', resolved.id, 'logs'],
|
|
648
|
+
resolved.options,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
const asText = typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)
|
|
652
|
+
const tailed = tailText(asText, {
|
|
653
|
+
contains: resolved.options.contains,
|
|
654
|
+
maxLines: resolved.options.maxLines,
|
|
655
|
+
maxBytes: resolved.options.maxBytes,
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
...result,
|
|
660
|
+
body: tailed,
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
|
|
472
664
|
jobsLogsForRunTail: async (headSha: unknown, runNumber: unknown, maybeOptions?: unknown) => {
|
|
473
665
|
const sha = toTrimmedString(headSha)
|
|
474
666
|
const run = toPositiveInteger(runNumber)
|
|
475
667
|
const options = isRecord(maybeOptions) ? (maybeOptions as Record<string, unknown>) : {}
|
|
476
|
-
|
|
477
|
-
if (!sha || !run) {
|
|
478
|
-
throw new Error('headSha (string) and runNumber (positive integer) are required.')
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const scope = normalizeScopeArgs(options.owner, options.repo, options)
|
|
482
|
-
const { owner, repo } = resolveOwnerRepo(scope, defaults)
|
|
483
|
-
const tasks = await requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], scope.options)
|
|
484
|
-
|
|
485
|
-
const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
|
|
486
|
-
? (tasks.body.workflow_runs as unknown[])
|
|
487
|
-
: []
|
|
488
|
-
|
|
489
|
-
const match = entries.find((entry) => {
|
|
490
|
-
if (!isRecord(entry)) return false
|
|
491
|
-
return String(entry.head_sha ?? '') === sha && Number(entry.run_number ?? NaN) === run
|
|
492
|
-
})
|
|
493
|
-
|
|
668
|
+
|
|
669
|
+
if (!sha || !run) {
|
|
670
|
+
throw new Error('headSha (string) and runNumber (positive integer) are required.')
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const scope = normalizeScopeArgs(options.owner, options.repo, options)
|
|
674
|
+
const { owner, repo } = resolveOwnerRepo(scope, defaults)
|
|
675
|
+
const tasks = await requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], scope.options)
|
|
676
|
+
|
|
677
|
+
const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
|
|
678
|
+
? (tasks.body.workflow_runs as unknown[])
|
|
679
|
+
: []
|
|
680
|
+
|
|
681
|
+
const match = entries.find((entry) => {
|
|
682
|
+
if (!isRecord(entry)) return false
|
|
683
|
+
return String(entry.head_sha ?? '') === sha && Number(entry.run_number ?? NaN) === run
|
|
684
|
+
})
|
|
685
|
+
|
|
494
686
|
if (!match || !isRecord(match)) {
|
|
495
|
-
throw new Error(
|
|
687
|
+
throw new Error(
|
|
688
|
+
`No matching run found in actions/tasks for head_sha=${sha} run_number=${run}. Verify the run exists, then use repo.actions.tasks.list { owner, repo, query:{ limit: 100 } } to locate a valid run_number and job id.`,
|
|
689
|
+
)
|
|
496
690
|
}
|
|
497
691
|
|
|
498
|
-
const
|
|
499
|
-
const
|
|
500
|
-
if (
|
|
501
|
-
|
|
692
|
+
const runEntry = match as Record<string, unknown>
|
|
693
|
+
const jobIdCandidates = collectJobIdCandidates(runEntry)
|
|
694
|
+
if (jobIdCandidates.length === 0) {
|
|
695
|
+
const keys = Object.keys(match).slice(0, 32).join(',')
|
|
696
|
+
const preview = (() => {
|
|
697
|
+
try {
|
|
698
|
+
return JSON.stringify(match).slice(0, 500)
|
|
699
|
+
} catch {
|
|
700
|
+
return ''
|
|
701
|
+
}
|
|
702
|
+
})()
|
|
703
|
+
throw new Error(
|
|
704
|
+
`Matched run entry does not expose a usable job id field (job_id/jobId/task_id/taskId/id). keys=[${keys}] preview=${preview}. Use repo.actions.jobs.logsTail with an explicit job_id from the Gitea UI or from tasks metadata.`,
|
|
705
|
+
)
|
|
502
706
|
}
|
|
503
707
|
|
|
504
|
-
const
|
|
708
|
+
const maxLines = toPositiveInteger(scope.options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
|
|
709
|
+
const lookup = await fetchJobLogsByCandidates({
|
|
505
710
|
ctx,
|
|
506
|
-
['actions', 'jobs', 'logs'],
|
|
507
711
|
owner,
|
|
508
712
|
repo,
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
)
|
|
713
|
+
options: scope.options,
|
|
714
|
+
jobIds: jobIdCandidates,
|
|
715
|
+
})
|
|
513
716
|
|
|
514
|
-
|
|
717
|
+
if (!lookup.logs.ok || lookup.logs.status >= 400) {
|
|
718
|
+
return {
|
|
719
|
+
...lookup.logs,
|
|
720
|
+
body: buildJobLogsFailureBody({
|
|
721
|
+
owner,
|
|
722
|
+
repo,
|
|
723
|
+
headSha: sha,
|
|
724
|
+
runNumber: run,
|
|
725
|
+
attempts: lookup.attempts,
|
|
726
|
+
upstreamBody: lookup.logs.body,
|
|
727
|
+
maxLines,
|
|
728
|
+
}),
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const asText =
|
|
733
|
+
typeof lookup.logs.body === 'string'
|
|
734
|
+
? lookup.logs.body
|
|
735
|
+
: JSON.stringify(lookup.logs.body, null, 2)
|
|
515
736
|
const tailed = tailText(asText, {
|
|
516
737
|
contains: scope.options.contains,
|
|
517
738
|
maxLines: scope.options.maxLines,
|
|
@@ -519,98 +740,246 @@ export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
|
|
|
519
740
|
})
|
|
520
741
|
|
|
521
742
|
return {
|
|
522
|
-
...logs,
|
|
743
|
+
...lookup.logs,
|
|
523
744
|
body: tailed,
|
|
524
745
|
}
|
|
525
746
|
},
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
747
|
+
|
|
748
|
+
diagnoseLatestFailure: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
749
|
+
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
750
|
+
// Allow calling with a single object payload: { owner, repo, workflowName, ... }.
|
|
751
|
+
const scope = normalizeScopeArgs(parsed.options.owner, parsed.options.repo, parsed.options)
|
|
752
|
+
const { owner, repo } = resolveOwnerRepo(scope, defaults)
|
|
753
|
+
|
|
754
|
+
const workflowNameRaw = typeof scope.options.workflowName === 'string' ? scope.options.workflowName.trim() : ''
|
|
755
|
+
const workflowName = workflowNameRaw.length > 0 ? workflowNameRaw : null
|
|
756
|
+
const maxLines = toPositiveInteger(scope.options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
|
|
757
|
+
const maxBytes = toPositiveInteger(scope.options.maxBytes)
|
|
758
|
+
const contains = typeof scope.options.contains === 'string' ? scope.options.contains : undefined
|
|
759
|
+
const limit = toPositiveInteger(scope.options.limit) ?? 50
|
|
760
|
+
|
|
761
|
+
const baseQuery = isRecord(scope.options.query) ? (scope.options.query as Record<string, unknown>) : {}
|
|
762
|
+
const withLimitOptions = {
|
|
763
|
+
...scope.options,
|
|
764
|
+
query: { ...baseQuery, limit },
|
|
537
765
|
}
|
|
538
|
-
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
539
|
-
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
540
|
-
return requestGitea(ctx, ['actions', 'runs', 'artifacts'], owner, repo, 'GET', ['runs', id, 'artifacts'], parsed.options)
|
|
541
|
-
},
|
|
542
766
|
|
|
543
|
-
|
|
544
|
-
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
545
|
-
|
|
546
|
-
const result = await requestGitea(
|
|
767
|
+
let tasks = await requestGitea(
|
|
547
768
|
ctx,
|
|
548
|
-
['actions', '
|
|
549
|
-
|
|
550
|
-
|
|
769
|
+
['actions', 'tasks', 'list'],
|
|
770
|
+
owner,
|
|
771
|
+
repo,
|
|
551
772
|
'GET',
|
|
552
|
-
['
|
|
553
|
-
|
|
554
|
-
{ redirect: 'manual' },
|
|
773
|
+
['tasks'],
|
|
774
|
+
withLimitOptions,
|
|
555
775
|
)
|
|
556
776
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
777
|
+
if (!tasks.ok && (tasks.status >= 500 || tasks.status === 400 || tasks.status === 422)) {
|
|
778
|
+
const fallbackQuery = { ...baseQuery }
|
|
779
|
+
delete fallbackQuery.limit
|
|
780
|
+
|
|
781
|
+
tasks = await requestGitea(
|
|
782
|
+
ctx,
|
|
783
|
+
['actions', 'tasks', 'list'],
|
|
784
|
+
owner,
|
|
785
|
+
repo,
|
|
786
|
+
'GET',
|
|
787
|
+
['tasks'],
|
|
788
|
+
{
|
|
789
|
+
...scope.options,
|
|
790
|
+
query: fallbackQuery,
|
|
791
|
+
},
|
|
792
|
+
)
|
|
793
|
+
}
|
|
563
794
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
795
|
+
const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
|
|
796
|
+
? (tasks.body.workflow_runs as unknown[])
|
|
797
|
+
: []
|
|
798
|
+
|
|
799
|
+
const normalizedWorkflowName = workflowName ? workflowName.toLowerCase() : null
|
|
800
|
+
const failing = entries
|
|
801
|
+
.filter((entry) => isRecord(entry))
|
|
802
|
+
.filter((entry) => normalizeActionsState((entry as Record<string, unknown>).status) === 'failure')
|
|
803
|
+
.filter((entry) => {
|
|
804
|
+
if (!normalizedWorkflowName) return true
|
|
805
|
+
const name = String((entry as Record<string, unknown>).name ?? '').toLowerCase()
|
|
806
|
+
const displayTitle = String((entry as Record<string, unknown>).display_title ?? '').toLowerCase()
|
|
807
|
+
return name.includes(normalizedWorkflowName) || displayTitle.includes(normalizedWorkflowName)
|
|
808
|
+
})
|
|
809
|
+
.sort((a, b) => {
|
|
810
|
+
const aRecord = a as Record<string, unknown>
|
|
811
|
+
const bRecord = b as Record<string, unknown>
|
|
812
|
+
const aUpdated = Date.parse(String(aRecord.updated_at ?? '')) || 0
|
|
813
|
+
const bUpdated = Date.parse(String(bRecord.updated_at ?? '')) || 0
|
|
814
|
+
return bUpdated - aUpdated
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
const match = failing[0]
|
|
818
|
+
if (!match || !isRecord(match)) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
workflowName
|
|
821
|
+
? `No failing workflow run found matching workflowName=${workflowName}. Use repo.actions.tasks.list { owner, repo, query:{ limit: 100 } } and inspect workflow_runs[].name/display_title/status.`
|
|
822
|
+
: 'No failing workflow run found. Use repo.actions.tasks.list { owner, repo, query:{ limit: 100 } } and inspect workflow_runs[].status for failures.',
|
|
823
|
+
)
|
|
570
824
|
}
|
|
571
|
-
},
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
825
|
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
826
|
+
const sha = String((match as Record<string, unknown>).head_sha ?? '').trim()
|
|
827
|
+
const run = toPositiveInteger((match as Record<string, unknown>).run_number)
|
|
828
|
+
if (!sha || !run) {
|
|
829
|
+
const preview = (() => {
|
|
830
|
+
try {
|
|
831
|
+
return JSON.stringify(match).slice(0, 500)
|
|
832
|
+
} catch {
|
|
833
|
+
return ''
|
|
834
|
+
}
|
|
835
|
+
})()
|
|
836
|
+
throw new Error(`Matched run entry is missing head_sha or run_number. preview=${preview}`)
|
|
837
|
+
}
|
|
586
838
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
839
|
+
const runEntry = match as Record<string, unknown>
|
|
840
|
+
const jobIdCandidates = collectJobIdCandidates(runEntry)
|
|
841
|
+
if (jobIdCandidates.length === 0) {
|
|
842
|
+
const keys = Object.keys(runEntry).slice(0, 32).join(',')
|
|
843
|
+
throw new Error(
|
|
844
|
+
`Matched run entry does not expose a usable job id field (job_id/jobId/task_id/taskId/id). keys=[${keys}]. Use repo.actions.jobs.logsTail with an explicit job_id from the Gitea UI.`,
|
|
845
|
+
)
|
|
846
|
+
}
|
|
595
847
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
artifactsByRun: { to: 'runs.artifacts', description: 'List artifacts for a run' },
|
|
604
|
-
artifactZipUrl: { to: 'artifacts.downloadZipUrl', description: 'Return redirect URL for artifact zip download' },
|
|
605
|
-
}
|
|
848
|
+
const lookup = await fetchJobLogsByCandidates({
|
|
849
|
+
ctx,
|
|
850
|
+
owner,
|
|
851
|
+
repo,
|
|
852
|
+
options: scope.options,
|
|
853
|
+
jobIds: jobIdCandidates,
|
|
854
|
+
})
|
|
606
855
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
856
|
+
if (!lookup.logs.ok || lookup.logs.status >= 400) {
|
|
857
|
+
return {
|
|
858
|
+
...lookup.logs,
|
|
859
|
+
body: buildJobLogsFailureBody({
|
|
860
|
+
owner,
|
|
861
|
+
repo,
|
|
862
|
+
headSha: sha,
|
|
863
|
+
runNumber: run,
|
|
864
|
+
workflowName,
|
|
865
|
+
attempts: lookup.attempts,
|
|
866
|
+
upstreamBody: lookup.logs.body,
|
|
867
|
+
maxLines,
|
|
868
|
+
}),
|
|
869
|
+
}
|
|
612
870
|
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
871
|
|
|
872
|
+
const asText =
|
|
873
|
+
typeof lookup.logs.body === 'string'
|
|
874
|
+
? lookup.logs.body
|
|
875
|
+
: JSON.stringify(lookup.logs.body, null, 2)
|
|
876
|
+
const tailed = tailText(asText, {
|
|
877
|
+
contains,
|
|
878
|
+
maxLines,
|
|
879
|
+
...(maxBytes ? { maxBytes } : {}),
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
...lookup.logs,
|
|
884
|
+
body: {
|
|
885
|
+
run: match,
|
|
886
|
+
logsTail: tailed,
|
|
887
|
+
owner,
|
|
888
|
+
repo,
|
|
889
|
+
headSha: sha,
|
|
890
|
+
runNumber: run,
|
|
891
|
+
jobId: lookup.selectedJobId,
|
|
892
|
+
},
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
artifacts: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
897
|
+
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
898
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
899
|
+
return requestGitea(ctx, ['actions', 'artifacts', 'list'], owner, repo, 'GET', ['artifacts'], parsed.options)
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
artifactsByRun: async (runId: unknown, ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
903
|
+
const id = toTrimmedString(runId)
|
|
904
|
+
if (!id) {
|
|
905
|
+
throw new Error('runId is required.')
|
|
906
|
+
}
|
|
907
|
+
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
908
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
909
|
+
return requestGitea(ctx, ['actions', 'runs', 'artifacts'], owner, repo, 'GET', ['runs', id, 'artifacts'], parsed.options)
|
|
910
|
+
},
|
|
911
|
+
|
|
912
|
+
artifactZipUrl: async (...rawArgs: unknown[]) => {
|
|
913
|
+
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
914
|
+
|
|
915
|
+
const result = await requestGitea(
|
|
916
|
+
ctx,
|
|
917
|
+
['actions', 'artifacts', 'downloadZipUrl'],
|
|
918
|
+
resolved.owner,
|
|
919
|
+
resolved.repo,
|
|
920
|
+
'GET',
|
|
921
|
+
['artifacts', resolved.id, 'zip'],
|
|
922
|
+
resolved.options,
|
|
923
|
+
{ redirect: 'manual' },
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
const location =
|
|
927
|
+
typeof (result.response.headers.location) === 'string'
|
|
928
|
+
? result.response.headers.location
|
|
929
|
+
: typeof (result.response.headers['location']) === 'string'
|
|
930
|
+
? result.response.headers['location']
|
|
931
|
+
: null
|
|
932
|
+
|
|
933
|
+
return {
|
|
934
|
+
...result,
|
|
935
|
+
body: {
|
|
936
|
+
status: result.status,
|
|
937
|
+
location,
|
|
938
|
+
},
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const ensureNamespace = (root: GitServiceApi, path: string[]): GitServiceApi => {
|
|
945
|
+
let cursor: GitServiceApi = root
|
|
946
|
+
for (const segment of path) {
|
|
947
|
+
const current = cursor[segment]
|
|
948
|
+
if (!isRecord(current)) {
|
|
949
|
+
cursor[segment] = {}
|
|
950
|
+
}
|
|
951
|
+
cursor = cursor[segment] as GitServiceApi
|
|
952
|
+
}
|
|
953
|
+
return cursor
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export const attachGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): void => {
|
|
957
|
+
const methods = createGitActionsApi(api, ctx)
|
|
958
|
+
const targets = [
|
|
959
|
+
ensureNamespace(api, ['actions']),
|
|
960
|
+
ensureNamespace(api, ['repo', 'actions']),
|
|
961
|
+
ensureNamespace(api, ['help']),
|
|
962
|
+
ensureNamespace(api, ['repo', 'help']),
|
|
963
|
+
]
|
|
964
|
+
|
|
965
|
+
const mapping: Record<string, { to: string; description: string }> = {
|
|
966
|
+
helpActionsLogs: { to: 'actionsLogs', description: 'Help for fetching Gitea Actions job logs' },
|
|
967
|
+
tasks: { to: 'tasks.list', description: 'List repository action tasks (workflow runs)' },
|
|
968
|
+
jobsLogs: { to: 'jobs.logs', description: 'Download job logs for a workflow run job_id' },
|
|
969
|
+
jobsLogsTail: { to: 'jobs.logsTail', description: 'Download and tail job logs (bounded for LLM)' },
|
|
970
|
+
jobsLogsForRunTail: { to: 'jobs.logsForRunTail', description: 'Resolve run by sha+run_number and tail logs' },
|
|
971
|
+
diagnoseLatestFailure: { to: 'diagnoseLatestFailure', description: 'Find latest failing run and return log tail' },
|
|
972
|
+
artifacts: { to: 'artifacts.list', description: 'List repository artifacts' },
|
|
973
|
+
artifactsByRun: { to: 'runs.artifacts', description: 'List artifacts for a run' },
|
|
974
|
+
artifactZipUrl: { to: 'artifacts.downloadZipUrl', description: 'Return redirect URL for artifact zip download' },
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
for (const target of targets) {
|
|
978
|
+
for (const [name, method] of Object.entries(methods)) {
|
|
979
|
+
const exportName = mapping[name]?.to ?? name
|
|
980
|
+
if (!(exportName in target)) {
|
|
981
|
+
target[exportName] = method
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|