@foundation0/git 1.2.4 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foundation0/git",
3
- "version": "1.2.4",
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
-