@foundation0/git 1.2.5 → 1.3.0

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/mcp/README.md CHANGED
@@ -231,7 +231,7 @@ Tips:
231
231
  `repo.label.listManaged`, `repo.label.getByName`, `repo.label.upsert`, and `repo.label.deleteByName`
232
232
  - Includes Gitea Actions convenience tools exposed from the API object (best-effort helpers):
233
233
  `repo.actions.tasks.list`, `repo.actions.jobs.logs`, `repo.actions.jobs.logsTail`, `repo.actions.jobs.logsForRunTail`,
234
- and `repo.actions.artifacts.downloadZipUrl`
234
+ `repo.actions.diagnoseLatestFailure`, and `repo.actions.artifacts.downloadZipUrl`
235
235
  - Includes a discovery helper:
236
236
  `help.actionsLogs` (also available under `repo.help.actionsLogs`)
237
237
 
@@ -248,3 +248,19 @@ Tips:
248
248
  "maxLines": 250
249
249
  }
250
250
  ```
251
+
252
+ ## Notes: actions.diagnoseLatestFailure
253
+
254
+ `repo.actions.diagnoseLatestFailure` is a convenience tool that finds the most recent failing Actions task and returns a log tail.
255
+
256
+ ```json
257
+ { "owner": "F0", "repo": "adl", "workflowName": "TypeScript", "maxLines": 250 }
258
+ ```
259
+
260
+ ## Notes: actions.runs.artifacts
261
+
262
+ `repo.actions.runs.artifacts` accepts a named form to avoid legacy positional ordering:
263
+
264
+ ```json
265
+ { "owner": "F0", "repo": "adl", "runId": 11 }
266
+ ```
package/mcp/src/server.ts CHANGED
@@ -363,6 +363,9 @@ const buildToolName = (tool: ToolDefinition, prefix?: string): string =>
363
363
  prefix ? `${prefix}.${tool.name}` : tool.name
364
364
 
365
365
  const isLogsForRunTailTool = (toolName: string): boolean => toolName.endsWith('jobs.logsForRunTail')
366
+ const isArtifactsByRunTool = (toolName: string): boolean => toolName.endsWith('runs.artifacts')
367
+ const isDiagnoseLatestFailureTool = (toolName: string): boolean =>
368
+ toolName.endsWith('diagnoseLatestFailure') && toolName.includes('.actions.')
366
369
 
367
370
  const buildGenericToolSchema = (): Record<string, unknown> => ({
368
371
  type: 'object',
@@ -386,6 +389,49 @@ const buildGenericToolSchema = (): Record<string, unknown> => ({
386
389
  },
387
390
  })
388
391
 
392
+ const buildArtifactsByRunSchema = (): Record<string, unknown> => ({
393
+ type: 'object',
394
+ additionalProperties: true,
395
+ properties: {
396
+ owner: {
397
+ type: 'string',
398
+ description: 'Repository owner/org. Optional if the server was started with a default owner.',
399
+ },
400
+ repo: {
401
+ type: 'string',
402
+ description: 'Repository name. Optional if the server was started with a default repo.',
403
+ },
404
+ runId: {
405
+ description: 'Workflow run id (alias: run_id).',
406
+ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
407
+ },
408
+ run_id: {
409
+ description: 'Alias for runId.',
410
+ anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
411
+ },
412
+ format: {
413
+ type: 'string',
414
+ enum: ['terse', 'debug'],
415
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
416
+ },
417
+ // Legacy positional forms:
418
+ // - Preferred by humans/LLMs: [owner, repo, runId]
419
+ // - Back-compat with the underlying helper signature: [runId, owner, repo]
420
+ args: {
421
+ type: 'array',
422
+ items: {},
423
+ description:
424
+ 'Legacy positional form. Prefer named params. If used, pass [owner, repo, runId] (recommended) or the legacy [runId, owner, repo]. You can also pass only [runId] if defaults are configured.',
425
+ },
426
+ options: {
427
+ type: 'object',
428
+ additionalProperties: true,
429
+ description: 'Options object (query/headers/body payload). Extra top-level keys are merged into options.',
430
+ },
431
+ },
432
+ anyOf: [{ required: ['runId'] }, { required: ['run_id'] }, { required: ['args'] }],
433
+ })
434
+
389
435
  const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
390
436
  type: 'object',
391
437
  additionalProperties: true,
@@ -459,8 +505,67 @@ const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
459
505
  ],
460
506
  })
461
507
 
508
+ const buildDiagnoseLatestFailureSchema = (): Record<string, unknown> => ({
509
+ type: 'object',
510
+ additionalProperties: true,
511
+ properties: {
512
+ owner: {
513
+ type: 'string',
514
+ description: 'Repository owner/org. Optional if the server was started with a default owner.',
515
+ },
516
+ repo: {
517
+ type: 'string',
518
+ description: 'Repository name. Optional if the server was started with a default repo.',
519
+ },
520
+ workflowName: {
521
+ type: 'string',
522
+ description: 'Optional filter: match failing runs by name/display_title (case-insensitive substring).',
523
+ },
524
+ limit: {
525
+ type: 'integer',
526
+ minimum: 1,
527
+ description: 'How many tasks/runs to fetch before filtering (default: 50).',
528
+ },
529
+ maxLines: {
530
+ type: 'integer',
531
+ minimum: 1,
532
+ description: 'Max lines to return from the end of the logs (default: 200).',
533
+ },
534
+ maxBytes: {
535
+ type: 'integer',
536
+ minimum: 1,
537
+ description: 'Max bytes to return from the end of the logs.',
538
+ },
539
+ contains: {
540
+ type: 'string',
541
+ description: 'If set, only return log lines containing this substring.',
542
+ },
543
+ format: {
544
+ type: 'string',
545
+ enum: ['terse', 'debug'],
546
+ description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
547
+ },
548
+ args: {
549
+ type: 'array',
550
+ items: { type: 'string' },
551
+ description: 'Legacy positional form: [owner, repo] plus options.workflowName/maxLines/etc.',
552
+ },
553
+ options: {
554
+ type: 'object',
555
+ additionalProperties: true,
556
+ description: 'Options object. Extra top-level keys are merged into options.',
557
+ },
558
+ },
559
+ })
560
+
462
561
  const buildToolInputSchema = (tool: ToolDefinition): Record<string, unknown> =>
463
- isLogsForRunTailTool(tool.name) ? buildLogsForRunTailSchema() : buildGenericToolSchema()
562
+ isLogsForRunTailTool(tool.name)
563
+ ? buildLogsForRunTailSchema()
564
+ : isArtifactsByRunTool(tool.name)
565
+ ? buildArtifactsByRunSchema()
566
+ : isDiagnoseLatestFailureTool(tool.name)
567
+ ? buildDiagnoseLatestFailureSchema()
568
+ : buildGenericToolSchema()
464
569
 
465
570
  const buildToolList = (tools: ToolDefinition[], batchToolName: string, prefix?: string) => {
466
571
  const toolNames = tools.map((tool) => ({
@@ -544,15 +649,67 @@ const invokeTool = async (
544
649
  return tool.method(...invocationArgs)
545
650
  }
546
651
 
652
+ const pickArgsFromNormalizedPayload = (normalized: unknown, record: Record<string, unknown>): unknown[] => {
653
+ if (Array.isArray(normalized)) {
654
+ return normalized
655
+ }
656
+ return Array.isArray(record.args) ? record.args : []
657
+ }
658
+
659
+ const pickNestedRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? (value as Record<string, unknown>) : {})
660
+
661
+ const normalizeQueryRecord = (query: unknown): Record<string, unknown> => {
662
+ if (isRecord(query)) {
663
+ return query
664
+ }
665
+
666
+ if (!Array.isArray(query)) {
667
+ return {}
668
+ }
669
+
670
+ const merged: Record<string, unknown> = {}
671
+
672
+ for (const entry of query) {
673
+ if (!isRecord(entry)) continue
674
+
675
+ const name = typeof entry.name === 'string' ? entry.name.trim() : ''
676
+ if (name) {
677
+ merged[name] = entry.value
678
+ continue
679
+ }
680
+
681
+ // Support [{ headSha: "..." }, { runNumber: 11 }] style arrays.
682
+ for (const [key, value] of Object.entries(entry)) {
683
+ if (value !== undefined) merged[key] = value
684
+ }
685
+ }
686
+
687
+ return merged
688
+ }
689
+
547
690
  const invokeLogsForRunTailTool = async (tool: ToolDefinition, payload: unknown): Promise<GitServiceApiExecutionResult> => {
548
691
  const normalized = normalizeArgumentPayload(payload)
549
692
  const record = isRecord(normalized) ? normalized : {}
550
- const args = Array.isArray(record.args) ? record.args : []
693
+ const args = pickArgsFromNormalizedPayload(normalized, record)
551
694
  const options = pickRecord(record.options)
552
-
553
- const headShaNamed = toTrimmedString(pickFirst(record.headSha, record.head_sha, options.headSha, options.head_sha))
695
+ const query = normalizeQueryRecord(options.query)
696
+ const data = pickNestedRecord(options.data)
697
+
698
+ const headShaNamed = toTrimmedString(
699
+ pickFirst(
700
+ record.headSha,
701
+ record.head_sha,
702
+ options.headSha,
703
+ options.head_sha,
704
+ query.headSha,
705
+ query.head_sha,
706
+ data.headSha,
707
+ data.head_sha,
708
+ ),
709
+ )
554
710
  const runNumberNamed = pickFirst(
555
711
  toPositiveInteger(pickFirst(record.runNumber, record.run_number, options.runNumber, options.run_number)),
712
+ toPositiveInteger(pickFirst(query.runNumber, query.run_number, data.runNumber, data.run_number)),
556
713
  null,
557
714
  )
558
715
 
@@ -570,20 +727,28 @@ const invokeLogsForRunTailTool = async (tool: ToolDefinition, payload: unknown):
570
727
  const ownerFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[0]) : ''
571
728
  const repoFromArgs = shouldTreatArgsAsOwnerRepo ? toTrimmedString(args[1]) : ''
572
729
 
573
- const owner = toTrimmedString(pickFirst(record.owner, options.owner, ownerFromArgs)) || undefined
574
- const repo = toTrimmedString(pickFirst(record.repo, options.repo, repoFromArgs)) || undefined
730
+ const owner =
731
+ toTrimmedString(pickFirst(record.owner, options.owner, query.owner, data.owner, ownerFromArgs)) || undefined
732
+ const repo = toTrimmedString(pickFirst(record.repo, options.repo, query.repo, data.repo, repoFromArgs)) || undefined
575
733
 
576
734
  const sha = toTrimmedString(pickFirst(headShaNamed || null, headShaPositional || null)) || ''
577
735
  const run = pickFirst(runNumberNamed, runNumberPositional) ?? null
578
736
 
579
737
  if (!sha || !run) {
580
738
  throw new Error(
581
- 'headSha (string) and runNumber (positive integer) are required. Preferred invocation: { owner, repo, headSha, runNumber, maxLines }.',
739
+ 'headSha (string) and runNumber (positive integer) are required. Preferred invocation: { owner, repo, headSha, runNumber, maxLines }. (Compatibility: options.query.{headSha,runNumber} is also accepted.)',
582
740
  )
583
741
  }
584
742
 
743
+ const containsFromQuery = typeof query.contains === 'string' ? query.contains : undefined
744
+ const maxLinesFromQuery = toPositiveInteger(query.maxLines)
745
+ const maxBytesFromQuery = toPositiveInteger(query.maxBytes)
746
+
585
747
  const cleanedOptions = stripMcpOnlyOptions({
586
748
  ...options,
749
+ ...(containsFromQuery ? { contains: containsFromQuery } : {}),
750
+ ...(maxLinesFromQuery ? { maxLines: maxLinesFromQuery } : {}),
751
+ ...(maxBytesFromQuery ? { maxBytes: maxBytesFromQuery } : {}),
587
752
  ...(owner ? { owner } : {}),
588
753
  ...(repo ? { repo } : {}),
589
754
  })
@@ -591,6 +756,57 @@ const invokeLogsForRunTailTool = async (tool: ToolDefinition, payload: unknown):
591
756
  return tool.method(sha, run, cleanedOptions)
592
757
  }
593
758
 
759
+ const invokeArtifactsByRunTool = async (
760
+ tool: ToolDefinition,
761
+ payload: unknown,
762
+ ): Promise<GitServiceApiExecutionResult> => {
763
+ const normalized = normalizeArgumentPayload(payload)
764
+ const record = isRecord(normalized) ? normalized : {}
765
+ const args = pickArgsFromNormalizedPayload(normalized, record)
766
+ const options = pickRecord(record.options)
767
+
768
+ const runIdNamed = toTrimmedString(pickFirst(record.runId, record.run_id, options.runId, options.run_id)) || ''
769
+
770
+ const a0 = args.length >= 1 ? toTrimmedString(args[0]) : ''
771
+ const a1 = args.length >= 2 ? toTrimmedString(args[1]) : ''
772
+ const a2 = args.length >= 3 ? toTrimmedString(args[2]) : ''
773
+
774
+ // Heuristic:
775
+ // - If named runId is set, use it.
776
+ // - Else if args look like [owner, repo, runId], use that.
777
+ // - Else treat args as legacy [runId, owner, repo] (or [runId] with defaults).
778
+ const shouldTreatAsOwnerRepoRunId = Boolean(a0) && Boolean(a1) && Boolean(a2) && !runIdNamed
779
+
780
+ const owner =
781
+ toTrimmedString(pickFirst(record.owner, options.owner, shouldTreatAsOwnerRepoRunId ? a0 : a1)) || undefined
782
+ const repo =
783
+ toTrimmedString(pickFirst(record.repo, options.repo, shouldTreatAsOwnerRepoRunId ? a1 : a2)) || undefined
784
+ const runId = toTrimmedString(pickFirst(runIdNamed || null, shouldTreatAsOwnerRepoRunId ? a2 : a0)) || ''
785
+
786
+ if (!runId) {
787
+ throw new Error('runId is required. Preferred invocation: { owner, repo, runId }.')
788
+ }
789
+
790
+ const cleanedOptions = stripMcpOnlyOptions({
791
+ ...options,
792
+ ...(owner ? { owner } : {}),
793
+ ...(repo ? { repo } : {}),
794
+ })
795
+
796
+ // Underlying helper signature is (runId, owner?, repo?, options?).
797
+ // We always pass runId first, and owner/repo if we have them.
798
+ if (owner && repo) {
799
+ return tool.method(runId, owner, repo, cleanedOptions)
800
+ }
801
+
802
+ if (owner && !repo) {
803
+ // Unusual: allow passing only owner explicitly.
804
+ return tool.method(runId, owner, cleanedOptions)
805
+ }
806
+
807
+ return tool.method(runId, cleanedOptions)
808
+ }
809
+
594
810
  export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpServerInstance => {
595
811
  const api = createGitServiceApi(options)
596
812
  const tools = collectGitTools(api)
@@ -650,6 +866,8 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
650
866
 
651
867
  const data = await (isLogsForRunTailTool(toolDefinition.name)
652
868
  ? invokeLogsForRunTailTool(toolDefinition, mergedPayload)
869
+ : isArtifactsByRunTool(toolDefinition.name)
870
+ ? invokeArtifactsByRunTool(toolDefinition, mergedPayload)
653
871
  : invokeTool(toolDefinition, mergedPayload))
654
872
 
655
873
  const { isError, envelope } = toMcpEnvelope(data, effectiveFormat)
@@ -705,6 +923,8 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
705
923
  const controls = extractOutputControls(request.params.arguments)
706
924
  const result = await (isLogsForRunTailTool(tool.name)
707
925
  ? invokeLogsForRunTailTool(tool, request.params.arguments)
926
+ : isArtifactsByRunTool(tool.name)
927
+ ? invokeArtifactsByRunTool(tool, request.params.arguments)
708
928
  : invokeTool(tool, request.params.arguments))
709
929
 
710
930
  const { isError, envelope } = toMcpEnvelope(result, controls.format ?? 'terse')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/git",
3
- "version": "1.2.5",
3
+ "version": "1.3.0",
4
4
  "description": "Foundation 0 Git API and MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,9 +9,25 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
9
9
  typeof value === 'object' && value !== null && !Array.isArray(value)
10
10
 
11
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
+
12
27
  if (typeof value !== 'string') {
13
28
  return null
14
29
  }
30
+
15
31
  const trimmed = value.trim()
16
32
  return trimmed.length > 0 ? trimmed : null
17
33
  }
@@ -162,6 +178,19 @@ const resolveOwnerRepoFromArgs = (
162
178
  return { ...resolved, rest: args }
163
179
  }
164
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
+
165
194
  const resolveOwnerRepoAndId = (
166
195
  rawArgs: unknown[],
167
196
  defaults: GitActionsApiDefaults,
@@ -495,10 +524,24 @@ export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
495
524
  throw new Error(`No matching run found in actions/tasks for head_sha=${sha} run_number=${run}.`)
496
525
  }
497
526
 
498
- const jobId = match.id
499
- const jobIdText = toTrimmedString(jobId)
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)
500
535
  if (!jobIdText) {
501
- throw new Error('Matched run entry does not expose an id usable as job_id.')
536
+ const keys = Object.keys(match).slice(0, 32).join(',')
537
+ const preview = (() => {
538
+ try {
539
+ return JSON.stringify(match).slice(0, 500)
540
+ } catch {
541
+ return ''
542
+ }
543
+ })()
544
+ throw new Error(`Matched run entry does not expose an id usable as job_id. keys=[${keys}] preview=${preview}`)
502
545
  }
503
546
 
504
547
  const logs = await requestGitea(
@@ -524,6 +567,109 @@ export const createGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
524
567
  }
525
568
  },
526
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
579
+ const maxBytes = toPositiveInteger(scope.options.maxBytes)
580
+ const contains = typeof scope.options.contains === 'string' ? scope.options.contains : undefined
581
+ const limit = toPositiveInteger(scope.options.limit) ?? 50
582
+
583
+ const tasks = await requestGitea(
584
+ ctx,
585
+ ['actions', 'tasks', 'list'],
586
+ owner,
587
+ repo,
588
+ 'GET',
589
+ ['tasks'],
590
+ { ...scope.options, query: { ...(isRecord(scope.options.query) ? scope.options.query : {}), limit } },
591
+ )
592
+
593
+ const entries = isRecord(tasks.body) && Array.isArray(tasks.body.workflow_runs)
594
+ ? (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
+
615
+ const match = failing[0]
616
+ if (!match || !isRecord(match)) {
617
+ throw new Error(
618
+ workflowName
619
+ ? `No failing workflow run found matching workflowName=${workflowName}.`
620
+ : 'No failing workflow run found.',
621
+ )
622
+ }
623
+
624
+ const sha = String((match as Record<string, unknown>).head_sha ?? '').trim()
625
+ const run = toPositiveInteger((match as Record<string, unknown>).run_number)
626
+ if (!sha || !run) {
627
+ throw new Error('Matched run entry is missing head_sha or run_number.')
628
+ }
629
+
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.')
640
+ }
641
+
642
+ const logs = await requestGitea(
643
+ ctx,
644
+ ['actions', 'jobs', 'logs'],
645
+ owner,
646
+ repo,
647
+ 'GET',
648
+ ['jobs', jobIdText, 'logs'],
649
+ scope.options,
650
+ )
651
+
652
+ const asText = typeof logs.body === 'string' ? logs.body : JSON.stringify(logs.body, null, 2)
653
+ const tailed = tailText(asText, {
654
+ contains,
655
+ maxLines,
656
+ ...(maxBytes ? { maxBytes } : {}),
657
+ })
658
+
659
+ return {
660
+ ...logs,
661
+ body: {
662
+ run: match,
663
+ logsTail: tailed,
664
+ owner,
665
+ repo,
666
+ headSha: sha,
667
+ runNumber: run,
668
+ jobId: jobIdText,
669
+ },
670
+ }
671
+ },
672
+
527
673
  artifacts: async (ownerOrOptions?: unknown, repoOrOptions?: unknown, maybeOptions?: unknown) => {
528
674
  const parsed = normalizeScopeArgs(ownerOrOptions, repoOrOptions, maybeOptions)
529
675
  const { owner, repo } = resolveOwnerRepo(parsed, defaults)
@@ -599,6 +745,7 @@ export const attachGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
599
745
  jobsLogs: { to: 'jobs.logs', description: 'Download job logs for a workflow run job_id' },
600
746
  jobsLogsTail: { to: 'jobs.logsTail', description: 'Download and tail job logs (bounded for LLM)' },
601
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' },
602
749
  artifacts: { to: 'artifacts.list', description: 'List repository artifacts' },
603
750
  artifactsByRun: { to: 'runs.artifacts', description: 'List artifacts for a run' },
604
751
  artifactZipUrl: { to: 'artifacts.downloadZipUrl', description: 'Return redirect URL for artifact zip download' },
@@ -613,4 +760,3 @@ export const attachGitActionsApi = (api: GitServiceApi, ctx: GitActionsApiContex
613
760
  }
614
761
  }
615
762
  }
616
-