@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/mcp/README.md +51 -1
- package/mcp/src/client.ts +10 -1
- package/mcp/src/server.ts +621 -39
- package/package.json +1 -1
- package/src/actions-api.ts +150 -4
- package/src/ci-api.ts +544 -0
- package/src/git-service-api.ts +87 -16
- package/src/index.ts +1 -0
- package/src/issue-dependencies.ts +80 -16
- package/src/platform/gitea-adapter.ts +16 -4
package/package.json
CHANGED
package/src/actions-api.ts
CHANGED
|
@@ -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
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|