@foundation0/git 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +291 -291
- package/gitea-swagger.json +28627 -28627
- package/mcp/README.md +262 -247
- package/mcp/cli.mjs +37 -37
- package/mcp/src/cli.ts +76 -76
- package/mcp/src/client.ts +143 -134
- package/mcp/src/index.ts +7 -7
- package/mcp/src/redaction.ts +207 -207
- package/mcp/src/server.ts +2313 -814
- package/package.json +3 -1
- package/src/actions-api.ts +860 -637
- package/src/api.ts +69 -69
- package/src/ci-api.ts +544 -544
- package/src/git-service-api.ts +822 -754
- package/src/git-service-feature-spec.generated.ts +5341 -5341
- package/src/index.ts +55 -55
- package/src/issue-dependencies.ts +533 -533
- package/src/label-management.ts +587 -587
- package/src/platform/config.ts +62 -62
- package/src/platform/gitea-adapter.ts +460 -460
- package/src/platform/gitea-rules.ts +129 -129
- package/src/platform/index.ts +44 -44
- package/src/repository.ts +151 -151
- package/src/spec-mock.ts +45 -45
package/src/actions-api.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
return trimmed.length > 0 ? trimmed : null
|
|
379
|
+
return text
|
|
33
380
|
}
|
|
34
381
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
413
|
+
const parsed =
|
|
414
|
+
trimmed.startsWith('{') || trimmed.startsWith('[')
|
|
415
|
+
? tryParseJsonLikeText(trimmed)
|
|
416
|
+
: null
|
|
169
417
|
|
|
170
|
-
if (
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
return
|
|
418
|
+
if (parsed && isRecord(parsed)) {
|
|
419
|
+
const parsedMessage = extractErrorMessage(parsed)
|
|
420
|
+
if (parsedMessage) {
|
|
421
|
+
return parsedMessage
|
|
174
422
|
}
|
|
175
423
|
}
|
|
176
424
|
|
|
177
|
-
const
|
|
178
|
-
|
|
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
|
|
430
|
+
return firstLine.length > 0 ? firstLine : null
|
|
224
431
|
}
|
|
225
432
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
232
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
):
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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 (
|
|
368
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
528
|
-
|
|
529
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
)
|
|
713
|
+
options: scope.options,
|
|
714
|
+
jobIds: jobIdCandidates,
|
|
715
|
+
})
|
|
556
716
|
|
|
557
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
848
|
+
const lookup = await fetchJobLogsByCandidates({
|
|
643
849
|
ctx,
|
|
644
|
-
['actions', 'jobs', 'logs'],
|
|
645
850
|
owner,
|
|
646
851
|
repo,
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
)
|
|
852
|
+
options: scope.options,
|
|
853
|
+
jobIds: jobIdCandidates,
|
|
854
|
+
})
|
|
651
855
|
|
|
652
|
-
|
|
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:
|
|
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
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
+
}
|