@foundation0/git 1.2.3 → 1.2.5
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 +9 -3
- package/mcp/README.md +39 -0
- package/mcp/src/client.ts +10 -1
- package/mcp/src/server.ts +401 -39
- package/package.json +1 -1
- package/src/actions-api.ts +616 -0
- package/src/git-service-api.ts +116 -35
- package/src/index.ts +1 -0
- package/src/platform/gitea-rules.ts +3 -1
package/package.json
CHANGED
|
@@ -0,0 +1,616 @@
|
|
|
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 (typeof value !== 'string') {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
const trimmed = value.trim()
|
|
16
|
+
return trimmed.length > 0 ? trimmed : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const toPositiveInteger = (value: unknown): number | null => {
|
|
20
|
+
const candidate = Number(value)
|
|
21
|
+
if (!Number.isFinite(candidate) || candidate <= 0 || Math.floor(candidate) !== candidate) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
return candidate
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const buildApiBase = (host: string): string => {
|
|
28
|
+
const sanitized = host.replace(/\/$/, '')
|
|
29
|
+
if (sanitized.endsWith('/api/v1')) {
|
|
30
|
+
return sanitized
|
|
31
|
+
}
|
|
32
|
+
return `${sanitized}/api/v1`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toQueryArray = (query: Record<string, string | number | boolean>): string[] =>
|
|
36
|
+
Object.entries(query).map(([key, value]) => `${key}=${String(value)}`)
|
|
37
|
+
|
|
38
|
+
const toHeaderRecord = (headers: string[]): Record<string, string> =>
|
|
39
|
+
Object.fromEntries(
|
|
40
|
+
headers
|
|
41
|
+
.map((entry) => {
|
|
42
|
+
const separatorIndex = entry.indexOf(':')
|
|
43
|
+
if (separatorIndex < 0) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
const name = entry.slice(0, separatorIndex).trim()
|
|
47
|
+
const value = entry.slice(separatorIndex + 1).trim()
|
|
48
|
+
return [name, value]
|
|
49
|
+
})
|
|
50
|
+
.filter((entry): entry is [string, string] => Boolean(entry)),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
type RawCall = {
|
|
54
|
+
args: string[]
|
|
55
|
+
options: Record<string, unknown>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const splitArgsAndOptions = (rawArgs: unknown[]): RawCall => {
|
|
59
|
+
if (rawArgs.length === 0 || !isRecord(rawArgs[rawArgs.length - 1])) {
|
|
60
|
+
return {
|
|
61
|
+
args: rawArgs.map((value) => String(value)),
|
|
62
|
+
options: {},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const last = rawArgs[rawArgs.length - 1] as Record<string, unknown>
|
|
67
|
+
return {
|
|
68
|
+
args: rawArgs.slice(0, -1).map((value) => String(value)),
|
|
69
|
+
options: { ...last },
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Scope = {
|
|
74
|
+
owner?: string
|
|
75
|
+
repo?: string
|
|
76
|
+
options: Record<string, unknown>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface GitActionsApiDefaults {
|
|
80
|
+
defaultOwner?: string
|
|
81
|
+
defaultRepo?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface GitActionsApiContext {
|
|
85
|
+
config: Pick<GitPlatformConfig, 'giteaHost' | 'giteaToken' | 'platform'>
|
|
86
|
+
defaults?: GitActionsApiDefaults
|
|
87
|
+
requestTimeoutMs?: number
|
|
88
|
+
log?: (message: string) => void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const normalizeScopeArgs = (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown): Scope => {
|
|
92
|
+
const options: Record<string, unknown> = {}
|
|
93
|
+
let owner: string | undefined
|
|
94
|
+
let repo: string | undefined
|
|
95
|
+
|
|
96
|
+
const absorb = (value: unknown): string | undefined => {
|
|
97
|
+
if (value === undefined || value === null) {
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (isRecord(value)) {
|
|
102
|
+
Object.assign(options, value)
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const asText = String(value).trim()
|
|
107
|
+
return asText.length > 0 ? asText : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
owner = absorb(ownerOrOptions)
|
|
111
|
+
repo = absorb(repoOrOptions)
|
|
112
|
+
|
|
113
|
+
if (isRecord(maybeOptions)) {
|
|
114
|
+
Object.assign(options, maybeOptions)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
...(owner ? { owner } : {}),
|
|
119
|
+
...(repo ? { repo } : {}),
|
|
120
|
+
options,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolveOwnerRepo = (
|
|
125
|
+
scope: Pick<Scope, 'owner' | 'repo'>,
|
|
126
|
+
defaults: GitActionsApiDefaults,
|
|
127
|
+
): { owner: string; repo: string } => {
|
|
128
|
+
const owner = scope.owner ?? defaults.defaultOwner
|
|
129
|
+
const repo = scope.repo ?? defaults.defaultRepo
|
|
130
|
+
|
|
131
|
+
if (!owner || !repo) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'Owner/repo is required. Pass owner and repo args or set defaultOwner/defaultRepo when creating the API.',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { owner, repo }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const resolveOwnerRepoFromArgs = (
|
|
141
|
+
args: string[],
|
|
142
|
+
scope: Pick<Scope, 'owner' | 'repo'>,
|
|
143
|
+
defaults: GitActionsApiDefaults,
|
|
144
|
+
): { owner: string; repo: string; rest: string[] } => {
|
|
145
|
+
if (scope.owner || scope.repo) {
|
|
146
|
+
const resolved = resolveOwnerRepo(scope, defaults)
|
|
147
|
+
return { ...resolved, rest: args }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (args.length >= 2) {
|
|
151
|
+
return { owner: args[0], repo: args[1], rest: args.slice(2) }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (args.length === 1) {
|
|
155
|
+
const split = args[0].split('/')
|
|
156
|
+
if (split.length === 2 && split[0] && split[1]) {
|
|
157
|
+
return { owner: split[0], repo: split[1], rest: [] }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resolved = resolveOwnerRepo({}, defaults)
|
|
162
|
+
return { ...resolved, rest: args }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const resolveOwnerRepoAndId = (
|
|
166
|
+
rawArgs: unknown[],
|
|
167
|
+
defaults: GitActionsApiDefaults,
|
|
168
|
+
): { owner: string; repo: string; id: string; options: Record<string, unknown> } => {
|
|
169
|
+
const parsed = splitArgsAndOptions(rawArgs)
|
|
170
|
+
const args = parsed.args.map((value) => value.trim()).filter((value) => value.length > 0)
|
|
171
|
+
|
|
172
|
+
if (args.length === 0) {
|
|
173
|
+
throw new Error('ID is required.')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (args.length === 1) {
|
|
177
|
+
const { owner, repo } = resolveOwnerRepo({}, defaults)
|
|
178
|
+
return { owner, repo, id: args[0], options: parsed.options }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (args.length === 2) {
|
|
182
|
+
if (args[0].includes('/')) {
|
|
183
|
+
const split = args[0].split('/')
|
|
184
|
+
if (split.length === 2 && split[0] && split[1]) {
|
|
185
|
+
return { owner: split[0], repo: split[1], id: args[1], options: parsed.options }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Error(
|
|
190
|
+
'Ambiguous arguments. Pass <id> (and rely on defaults), or pass <owner> <repo> <id>, or pass <owner/repo> <id>.',
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { owner: args[0], repo: args[1], id: args[2], options: parsed.options }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const resolveTimeoutMs = (value: unknown, fallback: number): number => {
|
|
198
|
+
const parsed = toPositiveInteger(value)
|
|
199
|
+
return parsed ?? fallback
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const readTextBody = async (response: Response): Promise<string> => {
|
|
203
|
+
try {
|
|
204
|
+
return await response.text()
|
|
205
|
+
} catch {
|
|
206
|
+
return ''
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const readBody = async (response: Response): Promise<unknown> => {
|
|
211
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
212
|
+
const text = await readTextBody(response)
|
|
213
|
+
if (contentType.toLowerCase().includes('application/json')) {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(text)
|
|
216
|
+
} catch {
|
|
217
|
+
return text
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return text
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const requestGitea = async (
|
|
224
|
+
ctx: GitActionsApiContext,
|
|
225
|
+
featurePath: string[],
|
|
226
|
+
owner: string,
|
|
227
|
+
repo: string,
|
|
228
|
+
method: string,
|
|
229
|
+
pathTail: string[],
|
|
230
|
+
options: Record<string, unknown> = {},
|
|
231
|
+
requestInitOverrides: Partial<RequestInit> = {},
|
|
232
|
+
): Promise<GitServiceApiExecutionResult<unknown>> => {
|
|
233
|
+
if (ctx.config.platform !== 'GITEA') {
|
|
234
|
+
throw new Error(`Actions API only supports GITEA platform, got ${ctx.config.platform}`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const apiBase = buildApiBase(ctx.config.giteaHost)
|
|
238
|
+
const mappedPath = ['repos', owner, repo, 'actions', ...pathTail]
|
|
239
|
+
|
|
240
|
+
const query = isRecord(options.query) ? (options.query as Record<string, unknown>) : {}
|
|
241
|
+
const queryRecord: Record<string, string | number | boolean> = {}
|
|
242
|
+
for (const [key, value] of Object.entries(query)) {
|
|
243
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
244
|
+
queryRecord[key] = value
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const url = new URL(`${apiBase}/${mappedPath.join('/')}`)
|
|
249
|
+
for (const [key, value] of Object.entries(queryRecord)) {
|
|
250
|
+
url.searchParams.set(key, String(value))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const headersList: string[] = []
|
|
254
|
+
headersList.push('Accept: */*')
|
|
255
|
+
if (ctx.config.giteaToken) {
|
|
256
|
+
headersList.push(`Authorization: token ${ctx.config.giteaToken}`)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const headers = toHeaderRecord(headersList)
|
|
260
|
+
|
|
261
|
+
const requestTimeoutMs = resolveTimeoutMs(options.requestTimeoutMs ?? ctx.requestTimeoutMs, DEFAULT_HTTP_TIMEOUT_MS)
|
|
262
|
+
const timeoutSignal =
|
|
263
|
+
requestTimeoutMs > 0 && typeof (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout === 'function'
|
|
264
|
+
? (AbortSignal as unknown as { timeout: (ms: number) => AbortSignal }).timeout(requestTimeoutMs)
|
|
265
|
+
: null
|
|
266
|
+
const controller = !timeoutSignal && requestTimeoutMs > 0 ? new AbortController() : null
|
|
267
|
+
const timeoutId = controller ? setTimeout(() => controller.abort(), requestTimeoutMs) : null
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
ctx.log?.(`actions:http:request ${method.toUpperCase()} ${url.toString()}`)
|
|
271
|
+
const response = await fetch(url.toString(), {
|
|
272
|
+
method: method.toUpperCase(),
|
|
273
|
+
headers,
|
|
274
|
+
...(timeoutSignal ? { signal: timeoutSignal } : {}),
|
|
275
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
276
|
+
...requestInitOverrides,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const body = await readBody(response)
|
|
280
|
+
const responseHeaders: Record<string, string> = {}
|
|
281
|
+
try {
|
|
282
|
+
response.headers.forEach((value, key) => {
|
|
283
|
+
responseHeaders[key.toLowerCase()] = value
|
|
284
|
+
})
|
|
285
|
+
} catch {
|
|
286
|
+
// best effort
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const mapping: GitApiFeatureMapping = {
|
|
290
|
+
platform: 'GITEA',
|
|
291
|
+
featurePath,
|
|
292
|
+
mappedPath,
|
|
293
|
+
method: method.toUpperCase(),
|
|
294
|
+
query: toQueryArray(queryRecord),
|
|
295
|
+
headers: headersList,
|
|
296
|
+
apiBase,
|
|
297
|
+
swaggerPath: `/${mappedPath.join('/')}`,
|
|
298
|
+
mapped: true,
|
|
299
|
+
reason: 'Direct Gitea Actions API call',
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
mapping,
|
|
304
|
+
request: {
|
|
305
|
+
url: url.toString(),
|
|
306
|
+
method: method.toUpperCase(),
|
|
307
|
+
headers,
|
|
308
|
+
query: toQueryArray(queryRecord),
|
|
309
|
+
},
|
|
310
|
+
response: {
|
|
311
|
+
headers: responseHeaders,
|
|
312
|
+
},
|
|
313
|
+
status: response.status,
|
|
314
|
+
ok: response.ok,
|
|
315
|
+
body,
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
if (timeoutId) {
|
|
319
|
+
clearTimeout(timeoutId)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const tailText = (input: string, options: { contains?: string; maxLines?: number; maxBytes?: number }): string => {
|
|
325
|
+
const contains = toTrimmedString(options.contains ?? null) ?? null
|
|
326
|
+
const maxLines = toPositiveInteger(options.maxLines) ?? DEFAULT_LOG_TAIL_LINES
|
|
327
|
+
const maxBytes = toPositiveInteger(options.maxBytes)
|
|
328
|
+
|
|
329
|
+
let text = input
|
|
330
|
+
|
|
331
|
+
if (contains) {
|
|
332
|
+
text = text
|
|
333
|
+
.split(/\r?\n/g)
|
|
334
|
+
.filter((line) => line.includes(contains))
|
|
335
|
+
.join('\n')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (maxLines > 0) {
|
|
339
|
+
const lines = text.split(/\r?\n/g)
|
|
340
|
+
text = lines.slice(Math.max(0, lines.length - maxLines)).join('\n')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (maxBytes && maxBytes > 0) {
|
|
344
|
+
const buf = Buffer.from(text, 'utf8')
|
|
345
|
+
if (buf.length > maxBytes) {
|
|
346
|
+
text = buf.subarray(buf.length - maxBytes).toString('utf8')
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return text
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export type GitActionsHelpBody = {
|
|
354
|
+
summary: string
|
|
355
|
+
suggestedCalls: Array<{
|
|
356
|
+
tool: string
|
|
357
|
+
args: string[]
|
|
358
|
+
options?: Record<string, unknown>
|
|
359
|
+
notes?: string
|
|
360
|
+
}>
|
|
361
|
+
assumptions: string[]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): Record<string, GitServiceApiMethod> => {
|
|
365
|
+
const defaults = ctx.defaults ?? {}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
helpActionsLogs: async (): Promise<GitServiceApiExecutionResult<GitActionsHelpBody>> => {
|
|
369
|
+
const body: GitActionsHelpBody = {
|
|
370
|
+
summary:
|
|
371
|
+
'Use actions.tasks.list to locate the failing run, then actions.jobs.logsTail to pull the last N lines of job logs.',
|
|
372
|
+
suggestedCalls: [
|
|
373
|
+
{
|
|
374
|
+
tool: 'repo.actions.tasks.list',
|
|
375
|
+
args: ['<owner>', '<repo>'],
|
|
376
|
+
options: { query: { limit: 50 } },
|
|
377
|
+
notes: 'Filter client-side by head_sha and run_number from the response.',
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
tool: 'repo.actions.jobs.logsTail',
|
|
381
|
+
args: ['<job_id>'],
|
|
382
|
+
options: { maxLines: 250 },
|
|
383
|
+
notes: 'If you have default owner/repo configured, you can pass only <job_id>.',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
tool: 'repo.actions.jobs.logsForRunTail',
|
|
387
|
+
args: ['<head_sha>', '<run_number>'],
|
|
388
|
+
options: { maxLines: 250 },
|
|
389
|
+
notes: 'Convenience helper: resolves a run from tasks and then fetches logs (assumes run.id is job_id).',
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
assumptions: [
|
|
393
|
+
'Gitea exposes job logs at GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs.',
|
|
394
|
+
'This helper assumes ActionTask.id can be used as job_id for the logs endpoint. If that is false in your Gitea version, use the UI to find the job_id.',
|
|
395
|
+
],
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const mapping: GitApiFeatureMapping = {
|
|
399
|
+
platform: 'GITEA',
|
|
400
|
+
featurePath: ['help', 'actionsLogs'],
|
|
401
|
+
mappedPath: ['help', 'actionsLogs'],
|
|
402
|
+
method: 'GET',
|
|
403
|
+
query: [],
|
|
404
|
+
headers: ['Accept: application/json'],
|
|
405
|
+
apiBase: buildApiBase(ctx.config.giteaHost),
|
|
406
|
+
swaggerPath: '/help/actionsLogs',
|
|
407
|
+
mapped: true,
|
|
408
|
+
reason: 'Local help content',
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
mapping,
|
|
413
|
+
request: {
|
|
414
|
+
url: 'local://help/actionsLogs',
|
|
415
|
+
method: 'GET',
|
|
416
|
+
headers: {},
|
|
417
|
+
query: [],
|
|
418
|
+
},
|
|
419
|
+
response: {
|
|
420
|
+
headers: {},
|
|
421
|
+
},
|
|
422
|
+
status: 200,
|
|
423
|
+
ok: true,
|
|
424
|
+
body,
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
|
|
428
|
+
tasks: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
429
|
+
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
430
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
431
|
+
return requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], parsed.options)
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
jobsLogs: async (...rawArgs: unknown[]) => {
|
|
435
|
+
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
436
|
+
return requestGitea(
|
|
437
|
+
ctx,
|
|
438
|
+
['actions', 'jobs', 'logs'],
|
|
439
|
+
resolved.owner,
|
|
440
|
+
resolved.repo,
|
|
441
|
+
'GET',
|
|
442
|
+
['jobs', resolved.id, 'logs'],
|
|
443
|
+
resolved.options,
|
|
444
|
+
)
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
jobsLogsTail: async (...rawArgs: unknown[]) => {
|
|
448
|
+
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
449
|
+
const result = await requestGitea(
|
|
450
|
+
ctx,
|
|
451
|
+
['actions', 'jobs', 'logs'],
|
|
452
|
+
resolved.owner,
|
|
453
|
+
resolved.repo,
|
|
454
|
+
'GET',
|
|
455
|
+
['jobs', resolved.id, 'logs'],
|
|
456
|
+
resolved.options,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
const asText = typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)
|
|
460
|
+
const tailed = tailText(asText, {
|
|
461
|
+
contains: resolved.options.contains,
|
|
462
|
+
maxLines: resolved.options.maxLines,
|
|
463
|
+
maxBytes: resolved.options.maxBytes,
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
...result,
|
|
468
|
+
body: tailed,
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
jobsLogsForRunTail: async (headSha: unknown, runNumber: unknown, maybeOptions?: unknown) => {
|
|
473
|
+
const sha = toTrimmedString(headSha)
|
|
474
|
+
const run = toPositiveInteger(runNumber)
|
|
475
|
+
const options = isRecord(maybeOptions) ? (maybeOptions as Record<string, unknown>) : {}
|
|
476
|
+
|
|
477
|
+
if (!sha || !run) {
|
|
478
|
+
throw new Error('headSha (string) and runNumber (positive integer) are required.')
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const scope = normalizeScopeArgs(options.owner, options.repo, options)
|
|
482
|
+
const { owner, repo } = resolveOwnerRepo(scope, defaults)
|
|
483
|
+
const tasks = await requestGitea(ctx, ['actions', 'tasks', 'list'], owner, repo, 'GET', ['tasks'], scope.options)
|
|
484
|
+
|
|
485
|
+
const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
|
|
486
|
+
? (tasks.body.workflow_runs as unknown[])
|
|
487
|
+
: []
|
|
488
|
+
|
|
489
|
+
const match = entries.find((entry) => {
|
|
490
|
+
if (!isRecord(entry)) return false
|
|
491
|
+
return String(entry.head_sha ?? '') === sha && Number(entry.run_number ?? NaN) === run
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
if (!match || !isRecord(match)) {
|
|
495
|
+
throw new Error(`No matching run found in actions/tasks for head_sha=${sha} run_number=${run}.`)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const jobId = match.id
|
|
499
|
+
const jobIdText = toTrimmedString(jobId)
|
|
500
|
+
if (!jobIdText) {
|
|
501
|
+
throw new Error('Matched run entry does not expose an id usable as job_id.')
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const logs = await requestGitea(
|
|
505
|
+
ctx,
|
|
506
|
+
['actions', 'jobs', 'logs'],
|
|
507
|
+
owner,
|
|
508
|
+
repo,
|
|
509
|
+
'GET',
|
|
510
|
+
['jobs', jobIdText, 'logs'],
|
|
511
|
+
scope.options,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
const asText = typeof logs.body === 'string' ? logs.body : JSON.stringify(logs.body, null, 2)
|
|
515
|
+
const tailed = tailText(asText, {
|
|
516
|
+
contains: scope.options.contains,
|
|
517
|
+
maxLines: scope.options.maxLines,
|
|
518
|
+
maxBytes: scope.options.maxBytes,
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
...logs,
|
|
523
|
+
body: tailed,
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
artifacts: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
528
|
+
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
529
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
530
|
+
return requestGitea(ctx, ['actions', 'artifacts', 'list'], owner, repo, 'GET', ['artifacts'], parsed.options)
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
artifactsByRun: async (runId: unknown, ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
|
|
534
|
+
const id = toTrimmedString(runId)
|
|
535
|
+
if (!id) {
|
|
536
|
+
throw new Error('runId is required.')
|
|
537
|
+
}
|
|
538
|
+
const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
|
|
539
|
+
const { owner, repo } = resolveOwnerRepo(parsed, defaults)
|
|
540
|
+
return requestGitea(ctx, ['actions', 'runs', 'artifacts'], owner, repo, 'GET', ['runs', id, 'artifacts'], parsed.options)
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
artifactZipUrl: async (...rawArgs: unknown[]) => {
|
|
544
|
+
const resolved = resolveOwnerRepoAndId(rawArgs, defaults)
|
|
545
|
+
|
|
546
|
+
const result = await requestGitea(
|
|
547
|
+
ctx,
|
|
548
|
+
['actions', 'artifacts', 'downloadZipUrl'],
|
|
549
|
+
resolved.owner,
|
|
550
|
+
resolved.repo,
|
|
551
|
+
'GET',
|
|
552
|
+
['artifacts', resolved.id, 'zip'],
|
|
553
|
+
resolved.options,
|
|
554
|
+
{ redirect: 'manual' },
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const location =
|
|
558
|
+
typeof (result.response.headers.location) === 'string'
|
|
559
|
+
? result.response.headers.location
|
|
560
|
+
: typeof (result.response.headers['location']) === 'string'
|
|
561
|
+
? result.response.headers['location']
|
|
562
|
+
: null
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
...result,
|
|
566
|
+
body: {
|
|
567
|
+
status: result.status,
|
|
568
|
+
location,
|
|
569
|
+
},
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const ensureNamespace = (root: GitServiceApi, path: string[]): GitServiceApi => {
|
|
576
|
+
let cursor: GitServiceApi = root
|
|
577
|
+
for (const segment of path) {
|
|
578
|
+
const current = cursor[segment]
|
|
579
|
+
if (!isRecord(current)) {
|
|
580
|
+
cursor[segment] = {}
|
|
581
|
+
}
|
|
582
|
+
cursor = cursor[segment] as GitServiceApi
|
|
583
|
+
}
|
|
584
|
+
return cursor
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export const attachGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContext): void => {
|
|
588
|
+
const methods = createGitActionsApi(api, ctx)
|
|
589
|
+
const targets = [
|
|
590
|
+
ensureNamespace(api, ['actions']),
|
|
591
|
+
ensureNamespace(api, ['repo', 'actions']),
|
|
592
|
+
ensureNamespace(api, ['help']),
|
|
593
|
+
ensureNamespace(api, ['repo', 'help']),
|
|
594
|
+
]
|
|
595
|
+
|
|
596
|
+
const mapping: Record<string, { to: string; description: string }> = {
|
|
597
|
+
helpActionsLogs: { to: 'actionsLogs', description: 'Help for fetching Gitea Actions job logs' },
|
|
598
|
+
tasks: { to: 'tasks.list', description: 'List repository action tasks (workflow runs)' },
|
|
599
|
+
jobsLogs: { to: 'jobs.logs', description: 'Download job logs for a workflow run job_id' },
|
|
600
|
+
jobsLogsTail: { to: 'jobs.logsTail', description: 'Download and tail job logs (bounded for LLM)' },
|
|
601
|
+
jobsLogsForRunTail: { to: 'jobs.logsForRunTail', description: 'Resolve run by sha+run_number and tail logs' },
|
|
602
|
+
artifacts: { to: 'artifacts.list', description: 'List repository artifacts' },
|
|
603
|
+
artifactsByRun: { to: 'runs.artifacts', description: 'List artifacts for a run' },
|
|
604
|
+
artifactZipUrl: { to: 'artifacts.downloadZipUrl', description: 'Return redirect URL for artifact zip download' },
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const target of targets) {
|
|
608
|
+
for (const [name, method] of Object.entries(methods)) {
|
|
609
|
+
const exportName = mapping[name]?.to ?? name
|
|
610
|
+
if (!(exportName in target)) {
|
|
611
|
+
target[exportName] = method
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|