@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.
@@ -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
- const toTrimmedString = (value: unknown): string | null => {
12
- if (typeof value !== 'string') {
13
- return null
14
- }
15
- const trimmed = value.trim()
16
- return trimmed.length > 0 ? trimmed : null
379
+ return text
17
380
  }
18
381
 
19
- const toPositiveInteger = (value: unknown): number | null => {
20
- const candidate = Number(value)
21
- if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
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 toQueryArray = (query: Record<string, string | number | boolean>): string[] =>
36
- Object.entries(query).map(([key, value]) => `${key}=${String(value)}`)
37
-
38
- const toHeaderRecord = (headers: string[]): Record<string, string> =>
39
- Object.fromEntries(
40
- headers
41
- .map((entry) => {
42
- const separatorIndex = entry.indexOf(':')
43
- if (separatorIndex < 0) {
44
- return null
45
- }
46
- const name = entry.slice(0, separatorIndex).trim()
47
- const value = entry.slice(separatorIndex + 1).trim()
48
- return [name, value]
49
- })
50
- .filter((entry): entry is [string, string] => Boolean(entry)),
51
- )
52
-
53
- type RawCall = {
54
- args: string[]
55
- options: Record<string, unknown>
56
- }
57
-
58
- const splitArgsAndOptions = (rawArgs: unknown[]): RawCall => {
59
- if (rawArgs.length === 0 || !isRecord(rawArgs[rawArgs.length - 1])) {
60
- return {
61
- args: rawArgs.map((value) => String(value)),
62
- options: {},
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
- if (isRecord(value)) {
102
- Object.assign(options, value)
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
- owner = absorb(ownerOrOptions)
111
- repo = absorb(repoOrOptions)
112
-
113
- if (isRecord(maybeOptions)) {
114
- Object.assign(options, maybeOptions)
115
- }
116
-
117
- return {
118
- ...(owner ? { owner } : {}),
119
- ...(repo ? { repo } : {}),
120
- options,
121
- }
122
- }
123
-
124
- const resolveOwnerRepo = (
125
- scope: Pick<Scope, 'owner' | 'repo'>,
126
- defaults: GitActionsApiDefaults,
127
- ): { owner: string; repo: string } => {
128
- const owner = scope.owner ?? defaults.defaultOwner
129
- const repo = scope.repo ?? defaults.defaultRepo
130
-
131
- if (!owner || !repo) {
132
- throw new Error(
133
- 'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
134
- )
404
+ if (typeof body !== 'string') {
405
+ return null
135
406
  }
136
407
 
137
- return { owner, repo }
138
- }
139
-
140
- const resolveOwnerRepoFromArgs = (
141
- args: string[],
142
- scope: Pick<Scope, 'owner' | 'repo'>,
143
- defaults: GitActionsApiDefaults,
144
- ): { owner: string; repo: string; rest: string[] } => {
145
- if (scope.owner || scope.repo) {
146
- const resolved = resolveOwnerRepo(scope, defaults)
147
- return { ...resolved, rest: args }
408
+ const trimmed = body.trim()
409
+ if (!trimmed) {
410
+ return null
148
411
  }
149
412
 
150
- if (args.length >= 2) {
151
- return { owner: args[0], repo: args[1], rest: args.slice(2) }
152
- }
413
+ const parsed =
414
+ trimmed.startsWith('{') || trimmed.startsWith('[')
415
+ ? tryParseJsonLikeText(trimmed)
416
+ : null
153
417
 
154
- if (args.length === 1) {
155
- const split = args[0].split('/')
156
- if (split.length === 2 && split[0] && split[1]) {
157
- return { owner: split[0], repo: split[1], rest: [] }
418
+ if (parsed && isRecord(parsed)) {
419
+ const parsedMessage = extractErrorMessage(parsed)
420
+ if (parsedMessage) {
421
+ return parsedMessage
158
422
  }
159
423
  }
160
424
 
161
- const resolved = resolveOwnerRepo({}, defaults)
162
- return { ...resolved, rest: args }
163
- }
164
-
165
- const resolveOwnerRepoAndId = (
166
- rawArgs: unknown[],
167
- defaults: GitActionsApiDefaults,
168
- ): { owner: string; repo: string; id: string; options: Record<string, unknown> } => {
169
- const parsed = splitArgsAndOptions(rawArgs)
170
- const args = parsed.args.map((value) => value.trim()).filter((value) => value.length > 0)
171
-
172
- if (args.length === 0) {
173
- throw new Error('ID is required.')
174
- }
175
-
176
- if (args.length === 1) {
177
- const { owner, repo } = resolveOwnerRepo({}, defaults)
178
- return { owner, repo, id: args[0], options: parsed.options }
179
- }
180
-
181
- if (args.length === 2) {
182
- if (args[0].includes('/')) {
183
- const split = args[0].split('/')
184
- if (split.length === 2 && split[0] && split[1]) {
185
- return { owner: split[0], repo: split[1], id: args[1], options: parsed.options }
186
- }
187
- }
188
-
189
- throw new Error(
190
- 'Ambiguous arguments. Pass <id> (and rely on defaults), or pass <owner> <repo> <id>, or pass <owner/repo> <id>.',
191
- )
425
+ const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ''
426
+ if (firstLine === '{' || firstLine === '[') {
427
+ return null
192
428
  }
193
429
 
194
- return { owner: args[0], repo: args[1], id: args[2], options: parsed.options }
430
+ return firstLine.length > 0 ? firstLine : null
195
431
  }
196
432
 
197
- const resolveTimeoutMs = (value: unknown, fallback: number): number => {
198
- const parsed = toPositiveInteger(value)
199
- return parsed ?? fallback
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 readTextBody = async (response: Response): Promise<string> => {
203
- try {
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
- const readBody = async (response: Response): Promise<unknown> => {
211
- const contentType = response.headers.get('content-type') ?? ''
212
- const text = await readTextBody(response)
213
- if (contentType.toLowerCase().includes('application/json')) {
214
- try {
215
- return JSON.parse(text)
216
- } catch {
217
- return text
218
- }
219
- }
220
- return text
452
+ type JobLogLookupAttempt = {
453
+ jobId: string
454
+ status: number
455
+ ok: boolean
456
+ message: string | null
221
457
  }
222
458
 
223
- const requestGitea = async (
224
- ctx: GitActionsApiContext,
225
- featurePath: string[],
226
- owner: string,
227
- repo: string,
228
- method: string,
229
- pathTail: string[],
230
- options: Record<string, unknown> = {},
231
- requestInitOverrides: Partial<RequestInit> = {},
232
- ): Promise<GitServiceApiExecutionResult<unknown>> => {
233
- if (ctx.config.platform !== 'GITEA') {
234
- throw new Error(`Actions API only supports GITEA platform, got ${ctx.config.platform}`)
235
- }
236
-
237
- const apiBase = buildApiBase(ctx.config.giteaHost)
238
- const mappedPath = ['repos', owner, repo, 'actions', ...pathTail]
239
-
240
- const query = isRecord(options.query) ? (options.query as Record<string, unknown>) : {}
241
- const queryRecord: Record<string, string | number | boolean> = {}
242
- for (const [key, value] of Object.entries(query)) {
243
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
244
- queryRecord[key] = value
245
- }
246
- }
247
-
248
- const url = new URL(`${apiBase}/${mappedPath.join('/')}`)
249
- for (const [key, value] of Object.entries(queryRecord)) {
250
- url.searchParams.set(key, String(value))
251
- }
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
- const headersList: string[] = []
254
- headersList.push('Accept: */*')
255
- if (ctx.config.giteaToken) {
256
- headersList.push(`Authorization: token ${ctx.config.giteaToken}`)
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
- const headers = toHeaderRecord(headersList)
260
-
261
- const requestTimeoutMs = resolveTimeoutMs(options.requestTimeoutMs ?? ctx.requestTimeoutMs, DEFAULT_HTTP_TIMEOUT_MS)
262
- const timeoutSignal =
263
- requestTimeoutMs > 0 && typeof (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout === 'function'
264
- ? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
265
- : null
266
- const controller = !timeoutSignal && requestTimeoutMs > 0 ? new AbortController() : null
267
- const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
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
- try {
270
- ctx.log?.(`actions:http:request ${method.toUpperCase()} ${url.toString()}`)
271
- const response = await fetch(url.toString(), {
272
- method: method.toUpperCase(),
273
- headers,
274
- ...(timeoutSignal ? { signal: timeoutSignal } : {}),
275
- ...(controller ? { signal: controller.signal } : {}),
276
- ...requestInitOverrides,
519
+ attempts.push({
520
+ jobId,
521
+ status: logs.status,
522
+ ok: logs.ok,
523
+ message: extractErrorMessage(logs.body),
277
524
  })
278
525
 
279
- const body = await readBody(response)
280
- const responseHeaders: Record<string, string> = {}
281
- try {
282
- response.headers.forEach((value, key) => {
283
- responseHeaders[key.toLowerCase()] = value
284
- })
285
- } catch {
286
- // best effort
287
- }
526
+ lastResult = logs
527
+ lastJobId = jobId
288
528
 
289
- const mapping: GitApiFeatureMapping = {
290
- platform: 'GITEA',
291
- featurePath,
292
- mappedPath,
293
- method: method.toUpperCase(),
294
- query: toQueryArray(queryRecord),
295
- headers: headersList,
296
- apiBase,
297
- swaggerPath: `/${mappedPath.join('/')}`,
298
- mapped: true,
299
- reason: 'Direct Gitea Actions API call',
529
+ if (logs.ok && logs.status < 400) {
530
+ return { logs, selectedJobId: jobId, attempts }
300
531
  }
301
532
 
302
- return {
303
- mapping,
304
- request: {
305
- url: url.toString(),
306
- method: method.toUpperCase(),
307
- headers,
308
- query: toQueryArray(queryRecord),
309
- },
310
- response: {
311
- headers: responseHeaders,
312
- },
313
- status: response.status,
314
- ok: response.ok,
315
- body,
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 (maxBytes && maxBytes > 0) {
344
- const buf = Buffer.from(text, 'utf8')
345
- if (buf.length > maxBytes) {
346
- text = buf.subarray(buf.length - maxBytes).toString('utf8')
347
- }
538
+ if (lastResult) {
539
+ return { logs: lastResult, selectedJobId: lastJobId, attempts }
348
540
  }
349
541
 
350
- return text
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(`No matching run found in actions/tasks for head_sha=${sha} run_number=${run}.`)
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 jobId = match.id
499
- const jobIdText = toTrimmedString(jobId)
500
- if (!jobIdText) {
501
- throw new Error('Matched run entry does not expose an id usable as job_id.')
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 logs = await requestGitea(
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
- 'GET',
510
- ['jobs', jobIdText, 'logs'],
511
- scope.options,
512
- )
713
+ options: scope.options,
714
+ jobIds: jobIdCandidates,
715
+ })
513
716
 
514
- const asText = typeof logs.body === 'string' ? logs.body : JSON.stringify(logs.body, null, 2)
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
- artifacts: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
528
- const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
529
- const { owner, repo } = resolveOwnerRepo(parsed, defaults)
530
- return requestGitea(ctx, ['actions', 'artifacts', 'list'], owner, repo, 'GET', ['artifacts'], parsed.options)
531
- },
532
-
533
- artifactsByRun: async (runId: unknown, ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
534
- const id = toTrimmedString(runId)
535
- if (!id) {
536
- throw new Error('runId is required.')
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
- artifactZipUrl: async (...rawArgs: unknown[]) => {
544
- const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
545
-
546
- const result = await requestGitea(
767
+ let tasks = await requestGitea(
547
768
  ctx,
548
- ['actions', 'artifacts', 'downloadZipUrl'],
549
- resolved.owner,
550
- resolved.repo,
769
+ ['actions', 'tasks', 'list'],
770
+ owner,
771
+ repo,
551
772
  'GET',
552
- ['artifacts', resolved.id, 'zip'],
553
- resolved.options,
554
- { redirect: 'manual' },
773
+ ['tasks'],
774
+ withLimitOptions,
555
775
  )
556
776
 
557
- const location =
558
- typeof (result.response.headers.location) === 'string'
559
- ? result.response.headers.location
560
- : typeof (result.response.headers['location']) === 'string'
561
- ? result.response.headers['location']
562
- : null
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
- return {
565
- ...result,
566
- body: {
567
- status: result.status,
568
- location,
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 ensureNamespace = (root: GitServiceApi, path: string[]): GitServiceApi => {
576
- let cursor: GitServiceApi = root
577
- for (const segment of path) {
578
- const current = cursor[segment]
579
- if (!isRecord(current)) {
580
- cursor[segment] = {}
581
- }
582
- cursor = cursor[segment] as GitServiceApi
583
- }
584
- return cursor
585
- }
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
- export const attachGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): void => {
588
- const methods = createGitActionsApi(api, ctx)
589
- const targets = [
590
- ensureNamespace(api, ['actions']),
591
- ensureNamespace(api, ['repo', 'actions']),
592
- ensureNamespace(api, ['help']),
593
- ensureNamespace(api, ['repo', 'help']),
594
- ]
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
- const mapping: Record<string, { to: string; description: string }> = {
597
- helpActionsLogs: { to: 'actionsLogs', description: 'Help for fetching Gitea Actions job logs' },
598
- tasks: { to: 'tasks.list', description: 'List repository action tasks (workflow runs)' },
599
- jobsLogs: { to: 'jobs.logs', description: 'Download job logs for a workflow run job_id' },
600
- jobsLogsTail: { to: 'jobs.logsTail', description: 'Download and tail job logs (bounded for LLM)' },
601
- jobsLogsForRunTail: { to: 'jobs.logsForRunTail', description: 'Resolve run by sha+run_number and tail logs' },
602
- artifacts: { to: 'artifacts.list', description: 'List repository artifacts' },
603
- artifactsByRun: { to: 'runs.artifacts', description: 'List artifacts for a run' },
604
- artifactZipUrl: { to: 'artifacts.downloadZipUrl', description: 'Return redirect URL for artifact zip download' },
605
- }
848
+ const lookup = await fetchJobLogsByCandidates({
849
+ ctx,
850
+ owner,
851
+ repo,
852
+ options: scope.options,
853
+ jobIds: jobIdCandidates,
854
+ })
606
855
 
607
- for (const target of targets) {
608
- for (const [name, method] of Object.entries(methods)) {
609
- const exportName = mapping[name]?.to ?? name
610
- if (!(exportName in target)) {
611
- target[exportName] = method
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
+ }