@foundation0/git 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,538 +1,697 @@
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
- }
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
+ }
30
378
 
31
- const trimmed = value.trim()
32
- return trimmed.length > 0 ? trimmed : null
379
+ return text
33
380
  }
34
381
 
35
- const toPositiveInteger = (value: unknown): number | null => {
36
- const candidate = Number(value)
37
- 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 {
38
386
  return null
39
387
  }
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
388
  }
50
389
 
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: {},
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()
79
394
  }
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
395
+ if (typeof body.error === 'string' && body.error.trim().length > 0) {
396
+ return body.error.trim()
115
397
  }
116
-
117
- if (isRecord(value)) {
118
- Object.assign(options, value)
119
- return undefined
398
+ if (isRecord(body.error)) {
399
+ const nestedMessage = extractErrorMessage(body.error)
400
+ if (nestedMessage) return nestedMessage
120
401
  }
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
402
  }
138
- }
139
403
 
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
- )
404
+ if (typeof body !== 'string') {
405
+ return null
151
406
  }
152
407
 
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 }
408
+ const trimmed = body.trim()
409
+ if (!trimmed) {
410
+ return null
164
411
  }
165
412
 
166
- if (args.length >= 2) {
167
- return { owner: args[0], repo: args[1], rest: args.slice(2) }
168
- }
413
+ const parsed =
414
+ trimmed.startsWith('{') || trimmed.startsWith('[')
415
+ ? tryParseJsonLikeText(trimmed)
416
+ : null
169
417
 
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: [] }
418
+ if (parsed && isRecord(parsed)) {
419
+ const parsedMessage = extractErrorMessage(parsed)
420
+ if (parsedMessage) {
421
+ return parsedMessage
174
422
  }
175
423
  }
176
424
 
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
- )
425
+ const firstLine = trimmed.split(/\r?\n/, 1)[0]?.trim() ?? ''
426
+ if (firstLine === '{' || firstLine === '[') {
427
+ return null
221
428
  }
222
429
 
223
- return { owner: args[0], repo: args[1], id: args[2], options: parsed.options }
430
+ return firstLine.length > 0 ? firstLine : null
224
431
  }
225
432
 
226
- const resolveTimeoutMs = (value: unknown, fallback: number): number => {
227
- const parsed = toPositiveInteger(value)
228
- return parsed ?? fallback
229
- }
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
+ ]
230
447
 
231
- const readTextBody = async (response: Response): Promise<string> => {
232
- try {
233
- return await response.text()
234
- } catch {
235
- return ''
236
- }
448
+ const normalized = candidates.map((candidate) => toTrimmedString(candidate)).filter((value): value is string => Boolean(value))
449
+ return Array.from(new Set(normalized))
237
450
  }
238
451
 
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
452
+ type JobLogLookupAttempt = {
453
+ jobId: string
454
+ status: number
455
+ ok: boolean
456
+ message: string | null
250
457
  }
251
458
 
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]
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()}` : ''
268
471
 
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}`)
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,
286
494
  }
495
+ }
287
496
 
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
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
+ )
297
518
 
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,
519
+ attempts.push({
520
+ jobId,
521
+ status: logs.status,
522
+ ok: logs.ok,
523
+ message: extractErrorMessage(logs.body),
306
524
  })
307
525
 
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
- }
526
+ lastResult = logs
527
+ lastJobId = jobId
317
528
 
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',
529
+ if (logs.ok && logs.status < 400) {
530
+ return { logs, selectedJobId: jobId, attempts }
329
531
  }
330
532
 
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)
533
+ if (!shouldTryNextJobIdCandidate(logs.status)) {
534
+ return { logs, selectedJobId: jobId, attempts }
349
535
  }
350
536
  }
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
537
 
367
- if (maxLines > 0) {
368
- const lines = text.split(/\r?\n/g)
369
- text = lines.slice(Math.max(0, lines.length - maxLines)).join('\n')
538
+ if (lastResult) {
539
+ return { logs: lastResult, selectedJobId: lastJobId, attempts }
370
540
  }
371
541
 
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
- }
378
-
379
- return text
542
+ throw new Error('No job id candidates were available for log lookup.')
380
543
  }
381
544
 
382
545
  export type GitActionsHelpBody = {
383
546
  summary: string
384
547
  suggestedCalls: Array<{
385
- tool: string
386
- args: string[]
387
- options?: Record<string, unknown>
388
- notes?: string
389
- }>
390
- assumptions: string[]
391
- }
392
-
393
- export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): Record<string, GitServiceApiMethod> => {
394
- const defaults = ctx.defaults ?? {}
395
-
396
- return {
397
- helpActionsLogs: async (): Promise<GitServiceApiExecutionResult<GitActionsHelpBody>> => {
398
- const body: GitActionsHelpBody = {
399
- summary:
400
- 'Use actions.tasks.list to locate the failing run, then actions.jobs.logsTail to pull the last N lines of job logs.',
401
- suggestedCalls: [
402
- {
403
- tool: 'repo.actions.tasks.list',
404
- args: ['<owner>', '<repo>'],
405
- options: { query: { limit: 50 } },
406
- notes: 'Filter client-side by head_sha and run_number from the response.',
407
- },
408
- {
409
- tool: 'repo.actions.jobs.logsTail',
410
- args: ['<job_id>'],
411
- options: { maxLines: 250 },
412
- notes: 'If you have default owner/repo configured, you can pass only <job_id>.',
413
- },
414
- {
415
- tool: 'repo.actions.jobs.logsForRunTail',
416
- args: ['<head_sha>', '<run_number>'],
417
- options: { maxLines: 250 },
418
- notes: 'Convenience helper: resolves a run from tasks and then fetches logs (assumes run.id is job_id).',
419
- },
420
- ],
421
- assumptions: [
422
- 'Gitea exposes job logs at GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs.',
423
- '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.',
424
- ],
425
- }
426
-
427
- const mapping: GitApiFeatureMapping = {
428
- platform: 'GITEA',
429
- featurePath: ['help', 'actionsLogs'],
430
- mappedPath: ['help', 'actionsLogs'],
431
- method: 'GET',
432
- query: [],
433
- headers: ['Accept: application/json'],
434
- apiBase: buildApiBase(ctx.config.giteaHost),
435
- swaggerPath: '/help/actionsLogs',
436
- mapped: true,
437
- reason: 'Local help content',
438
- }
439
-
440
- return {
441
- mapping,
442
- request: {
443
- url: 'local://help/actionsLogs',
444
- method: 'GET',
445
- headers: {},
446
- query: [],
447
- },
448
- response: {
449
- headers: {},
450
- },
451
- status: 200,
452
- ok: true,
453
- body,
454
- }
455
- },
456
-
457
- tasks: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
458
- const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
459
- const { owner, repo } = resolveOwnerRepo(parsed, defaults)
460
- return requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], parsed.options)
461
- },
462
-
463
- jobsLogs: async (...rawArgs: unknown[]) => {
464
- const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
465
- return requestGitea(
466
- ctx,
467
- ['actions', 'jobs', 'logs'],
468
- resolved.owner,
469
- resolved.repo,
470
- 'GET',
471
- ['jobs', resolved.id, 'logs'],
472
- resolved.options,
473
- )
474
- },
475
-
476
- jobsLogsTail: async (...rawArgs: unknown[]) => {
477
- const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
478
- const result = await requestGitea(
479
- ctx,
480
- ['actions', 'jobs', 'logs'],
481
- resolved.owner,
482
- resolved.repo,
483
- 'GET',
484
- ['jobs', resolved.id, 'logs'],
485
- resolved.options,
486
- )
487
-
488
- const asText = typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)
489
- const tailed = tailText(asText, {
490
- contains: resolved.options.contains,
491
- maxLines: resolved.options.maxLines,
492
- maxBytes: resolved.options.maxBytes,
493
- })
494
-
495
- return {
496
- ...result,
497
- body: tailed,
498
- }
499
- },
500
-
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
+
501
664
  jobsLogsForRunTail: async (headSha: unknown, runNumber: unknown, maybeOptions?: unknown) => {
502
665
  const sha = toTrimmedString(headSha)
503
666
  const run = toPositiveInteger(runNumber)
504
667
  const options = isRecord(maybeOptions) ? (maybeOptions as Record<string, unknown>) : {}
505
-
506
- if (!sha || !run) {
507
- throw new Error('headSha (string) and runNumber (positive integer) are required.')
508
- }
509
-
510
- const scope = normalizeScopeArgs(options.owner, options.repo, options)
511
- const { owner, repo } = resolveOwnerRepo(scope, defaults)
512
- const tasks = await requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], scope.options)
513
-
514
- const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
515
- ? (tasks.body.workflow_runs as unknown[])
516
- : []
517
-
518
- const match = entries.find((entry) => {
519
- if (!isRecord(entry)) return false
520
- return String(entry.head_sha ?? '') === sha && Number(entry.run_number ?? NaN) === run
521
- })
522
-
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
+
523
686
  if (!match || !isRecord(match)) {
524
- 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
+ )
525
690
  }
526
691
 
527
- const jobIdCandidate =
528
- (match as Record<string, unknown>).job_id ??
529
- (match as Record<string, unknown>).jobId ??
530
- (match as Record<string, unknown>).task_id ??
531
- (match as Record<string, unknown>).taskId ??
532
- (match as Record<string, unknown>).id
533
-
534
- const jobIdText = toTrimmedString(jobIdCandidate)
535
- if (!jobIdText) {
692
+ const runEntry = match as Record<string, unknown>
693
+ const jobIdCandidates = collectJobIdCandidates(runEntry)
694
+ if (jobIdCandidates.length === 0) {
536
695
  const keys = Object.keys(match).slice(0, 32).join(',')
537
696
  const preview = (() => {
538
697
  try {
@@ -541,20 +700,39 @@ export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
541
700
  return ''
542
701
  }
543
702
  })()
544
- throw new Error(`Matched run entry does not expose an id usable as job_id. keys=[${keys}] preview=${preview}`)
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
+ )
545
706
  }
546
707
 
547
- const logs = await requestGitea(
708
+ const maxLines = toPositiveInteger(scope.options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
709
+ const lookup = await fetchJobLogsByCandidates({
548
710
  ctx,
549
- ['actions', 'jobs', 'logs'],
550
711
  owner,
551
712
  repo,
552
- 'GET',
553
- ['jobs', jobIdText, 'logs'],
554
- scope.options,
555
- )
713
+ options: scope.options,
714
+ jobIds: jobIdCandidates,
715
+ })
556
716
 
557
- 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)
558
736
  const tailed = tailText(asText, {
559
737
  contains: scope.options.contains,
560
738
  maxLines: scope.options.maxLines,
@@ -562,102 +740,147 @@ export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
562
740
  })
563
741
 
564
742
  return {
565
- ...logs,
743
+ ...lookup.logs,
566
744
  body: tailed,
567
745
  }
568
746
  },
569
-
570
- diagnoseLatestFailure: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
571
- const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
572
- // Allow calling with a single object payload: { owner, repo, workflowName, ... }.
573
- const scope = normalizeScopeArgs(parsed.options.owner, parsed.options.repo, parsed.options)
574
- const { owner, repo } = resolveOwnerRepo(scope, defaults)
575
-
576
- const workflowNameRaw = typeof scope.options.workflowName === 'string' ? scope.options.workflowName.trim() : ''
577
- const workflowName = workflowNameRaw.length > 0 ? workflowNameRaw : null
578
- const maxLines = toPositiveInteger(scope.options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
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
579
757
  const maxBytes = toPositiveInteger(scope.options.maxBytes)
580
758
  const contains = typeof scope.options.contains === 'string' ? scope.options.contains : undefined
581
759
  const limit = toPositiveInteger(scope.options.limit) ?? 50
582
760
 
583
- const tasks = await requestGitea(
761
+ const baseQuery = isRecord(scope.options.query) ? (scope.options.query as Record<string, unknown>) : {}
762
+ const withLimitOptions = {
763
+ ...scope.options,
764
+ query: { ...baseQuery, limit },
765
+ }
766
+
767
+ let tasks = await requestGitea(
584
768
  ctx,
585
769
  ['actions', 'tasks', 'list'],
586
770
  owner,
587
771
  repo,
588
772
  'GET',
589
773
  ['tasks'],
590
- { ...scope.options, query: { ...(isRecord(scope.options.query) ? scope.options.query : {}), limit } },
774
+ withLimitOptions,
591
775
  )
592
776
 
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
+ }
794
+
593
795
  const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
594
796
  ? (tasks.body.workflow_runs as unknown[])
595
- : []
596
-
597
- const normalizedWorkflowName = workflowName ? workflowName.toLowerCase() : null
598
- const failing = entries
599
- .filter((entry) => isRecord(entry))
600
- .filter((entry) => normalizeActionsState((entry as Record<string, unknown>).status) === 'failure')
601
- .filter((entry) => {
602
- if (!normalizedWorkflowName) return true
603
- const name = String((entry as Record<string, unknown>).name ?? '').toLowerCase()
604
- const displayTitle = String((entry as Record<string, unknown>).display_title ?? '').toLowerCase()
605
- return name.includes(normalizedWorkflowName) || displayTitle.includes(normalizedWorkflowName)
606
- })
607
- .sort((a, b) => {
608
- const aRecord = a as Record<string, unknown>
609
- const bRecord = b as Record<string, unknown>
610
- const aUpdated = Date.parse(String(aRecord.updated_at ?? '')) || 0
611
- const bUpdated = Date.parse(String(bRecord.updated_at ?? '')) || 0
612
- return bUpdated - aUpdated
613
- })
614
-
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
+
615
817
  const match = failing[0]
616
818
  if (!match || !isRecord(match)) {
617
819
  throw new Error(
618
820
  workflowName
619
- ? `No failing workflow run found matching workflowName=${workflowName}.`
620
- : 'No failing workflow run found.',
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.',
621
823
  )
622
824
  }
623
825
 
624
826
  const sha = String((match as Record<string, unknown>).head_sha ?? '').trim()
625
827
  const run = toPositiveInteger((match as Record<string, unknown>).run_number)
626
828
  if (!sha || !run) {
627
- throw new Error('Matched run entry is missing head_sha or run_number.')
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}`)
628
837
  }
629
838
 
630
- const jobIdCandidate =
631
- (match as Record<string, unknown>).job_id ??
632
- (match as Record<string, unknown>).jobId ??
633
- (match as Record<string, unknown>).task_id ??
634
- (match as Record<string, unknown>).taskId ??
635
- (match as Record<string, unknown>).id
636
-
637
- const jobIdText = toTrimmedString(jobIdCandidate)
638
- if (!jobIdText) {
639
- throw new Error('Matched run entry does not expose an id usable as job_id.')
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
+ )
640
846
  }
641
847
 
642
- const logs = await requestGitea(
848
+ const lookup = await fetchJobLogsByCandidates({
643
849
  ctx,
644
- ['actions', 'jobs', 'logs'],
645
850
  owner,
646
851
  repo,
647
- 'GET',
648
- ['jobs', jobIdText, 'logs'],
649
- scope.options,
650
- )
852
+ options: scope.options,
853
+ jobIds: jobIdCandidates,
854
+ })
651
855
 
652
- const asText = typeof logs.body === 'string' ? logs.body : JSON.stringify(logs.body, null, 2)
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
+ }
870
+ }
871
+
872
+ const asText =
873
+ typeof lookup.logs.body === 'string'
874
+ ? lookup.logs.body
875
+ : JSON.stringify(lookup.logs.body, null, 2)
653
876
  const tailed = tailText(asText, {
654
877
  contains,
655
878
  maxLines,
656
- ...(maxBytes ? { maxBytes } : {}),
657
- })
658
-
879
+ ...(maxBytes ? { maxBytes } : {}),
880
+ })
881
+
659
882
  return {
660
- ...logs,
883
+ ...lookup.logs,
661
884
  body: {
662
885
  run: match,
663
886
  logsTail: tailed,
@@ -665,98 +888,98 @@ export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
665
888
  repo,
666
889
  headSha: sha,
667
890
  runNumber: run,
668
- jobId: jobIdText,
669
- },
670
- }
671
- },
672
-
673
- artifacts: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
674
- const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
675
- const { owner, repo } = resolveOwnerRepo(parsed, defaults)
676
- return requestGitea(ctx, ['actions', 'artifacts', 'list'], owner, repo, 'GET', ['artifacts'], parsed.options)
677
- },
678
-
679
- artifactsByRun: async (runId: unknown, ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
680
- const id = toTrimmedString(runId)
681
- if (!id) {
682
- throw new Error('runId is required.')
683
- }
684
- const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
685
- const { owner, repo } = resolveOwnerRepo(parsed, defaults)
686
- return requestGitea(ctx, ['actions', 'runs', 'artifacts'], owner, repo, 'GET', ['runs', id, 'artifacts'], parsed.options)
687
- },
688
-
689
- artifactZipUrl: async (...rawArgs: unknown[]) => {
690
- const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
691
-
692
- const result = await requestGitea(
693
- ctx,
694
- ['actions', 'artifacts', 'downloadZipUrl'],
695
- resolved.owner,
696
- resolved.repo,
697
- 'GET',
698
- ['artifacts', resolved.id, 'zip'],
699
- resolved.options,
700
- { redirect: 'manual' },
701
- )
702
-
703
- const location =
704
- typeof (result.response.headers.location) === 'string'
705
- ? result.response.headers.location
706
- : typeof (result.response.headers['location']) === 'string'
707
- ? result.response.headers['location']
708
- : null
709
-
710
- return {
711
- ...result,
712
- body: {
713
- status: result.status,
714
- location,
891
+ jobId: lookup.selectedJobId,
715
892
  },
716
893
  }
717
894
  },
718
- }
719
- }
720
-
721
- const ensureNamespace = (root: GitServiceApi, path: string[]): GitServiceApi => {
722
- let cursor: GitServiceApi = root
723
- for (const segment of path) {
724
- const current = cursor[segment]
725
- if (!isRecord(current)) {
726
- cursor[segment] = {}
727
- }
728
- cursor = cursor[segment] as GitServiceApi
729
- }
730
- return cursor
731
- }
732
-
733
- export const attachGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): void => {
734
- const methods = createGitActionsApi(api, ctx)
735
- const targets = [
736
- ensureNamespace(api, ['actions']),
737
- ensureNamespace(api, ['repo', 'actions']),
738
- ensureNamespace(api, ['help']),
739
- ensureNamespace(api, ['repo', 'help']),
740
- ]
741
-
742
- const mapping: Record<string, { to: string; description: string }> = {
743
- helpActionsLogs: { to: 'actionsLogs', description: 'Help for fetching Gitea Actions job logs' },
744
- tasks: { to: 'tasks.list', description: 'List repository action tasks (workflow runs)' },
745
- jobsLogs: { to: 'jobs.logs', description: 'Download job logs for a workflow run job_id' },
746
- jobsLogsTail: { to: 'jobs.logsTail', description: 'Download and tail job logs (bounded for LLM)' },
747
- jobsLogsForRunTail: { to: 'jobs.logsForRunTail', description: 'Resolve run by sha+run_number and tail logs' },
748
- diagnoseLatestFailure: { to: 'diagnoseLatestFailure', description: 'Find latest failing run and return log tail' },
749
- artifacts: { to: 'artifacts.list', description: 'List repository artifacts' },
750
- artifactsByRun: { to: 'runs.artifacts', description: 'List artifacts for a run' },
751
- artifactZipUrl: { to: 'artifacts.downloadZipUrl', description: 'Return redirect URL for artifact zip download' },
752
- }
753
-
754
- for (const target of targets) {
755
- for (const [name, method] of Object.entries(methods)) {
756
- const exportName = mapping[name]?.to ?? name
757
- if (!(exportName in target)) {
758
- target[exportName] = method
759
- }
760
- }
761
- }
762
- }
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
+ }