@foundation0/git 1.3.1 → 1.3.3
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 +5 -5
- package/mcp/README.md +31 -16
- package/mcp/src/client.ts +23 -14
- package/mcp/src/server.ts +986 -327
- package/package.json +2 -2
package/mcp/src/server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createGitServiceApi } from '@foundation0/git'
|
|
2
|
-
import type {
|
|
3
|
-
GitServiceApi,
|
|
4
|
-
GitServiceApiExecutionResult,
|
|
5
|
-
GitServiceApiFactoryOptions,
|
|
6
|
-
GitServiceApiMethod,
|
|
1
|
+
import { callIssueDependenciesApi, createGitServiceApi, extractDependencyIssueNumbers } from '@foundation0/git'
|
|
2
|
+
import type {
|
|
3
|
+
GitServiceApi,
|
|
4
|
+
GitServiceApiExecutionResult,
|
|
5
|
+
GitServiceApiFactoryOptions,
|
|
6
|
+
GitServiceApiMethod,
|
|
7
7
|
} from '@foundation0/git'
|
|
8
8
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
9
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
@@ -16,9 +16,16 @@ type ToolInvocationPayload = {
|
|
|
16
16
|
[key: string]: unknown
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
type McpToolOutputFormat = 'terse' | 'debug'
|
|
20
|
-
|
|
21
|
-
type McpFieldSelection = string[]
|
|
19
|
+
type McpToolOutputFormat = 'terse' | 'debug'
|
|
20
|
+
|
|
21
|
+
type McpFieldSelection = string[]
|
|
22
|
+
|
|
23
|
+
type McpCallControls = {
|
|
24
|
+
format: McpToolOutputFormat | null
|
|
25
|
+
validateOnly: boolean
|
|
26
|
+
fields: McpFieldSelection | null
|
|
27
|
+
full: boolean
|
|
28
|
+
}
|
|
22
29
|
|
|
23
30
|
type BatchToolCall = {
|
|
24
31
|
tool: string
|
|
@@ -161,28 +168,31 @@ const parseFieldSelection = (value: unknown): McpFieldSelection | null => {
|
|
|
161
168
|
return null
|
|
162
169
|
}
|
|
163
170
|
|
|
164
|
-
const extractMcpControls = (
|
|
165
|
-
payload: unknown,
|
|
166
|
-
):
|
|
167
|
-
const normalized = normalizeArgumentPayload(payload)
|
|
168
|
-
if (!isRecord(normalized)) {
|
|
169
|
-
return { format: null, validateOnly: false, fields: null }
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const topLevelFormat = parseOutputFormat(normalized.format)
|
|
173
|
-
const topLevelValidateOnly = parseBoolean(normalized.validateOnly)
|
|
174
|
-
const topLevelFields = parseFieldSelection(normalized.fields)
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
171
|
+
const extractMcpControls = (
|
|
172
|
+
payload: unknown,
|
|
173
|
+
): McpCallControls => {
|
|
174
|
+
const normalized = normalizeArgumentPayload(payload)
|
|
175
|
+
if (!isRecord(normalized)) {
|
|
176
|
+
return { format: null, validateOnly: false, fields: null, full: false }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const topLevelFormat = parseOutputFormat(normalized.format)
|
|
180
|
+
const topLevelValidateOnly = parseBoolean(normalized.validateOnly)
|
|
181
|
+
const topLevelFields = parseFieldSelection(normalized.fields)
|
|
182
|
+
const topLevelFull = parseBoolean(normalized.full)
|
|
183
|
+
const optionsFormat = parseOutputFormat(pickRecord(normalized.options).format)
|
|
184
|
+
const optionsValidateOnly = parseBoolean(pickRecord(normalized.options).validateOnly)
|
|
185
|
+
const optionsFields = parseFieldSelection(pickRecord(normalized.options).fields)
|
|
186
|
+
const optionsFull = parseBoolean(pickRecord(normalized.options).full)
|
|
187
|
+
|
|
188
|
+
// Support either { format:"debug" } or { options:{ format:"debug" } }.
|
|
189
|
+
return {
|
|
190
|
+
format: pickFirst(topLevelFormat, optionsFormat),
|
|
191
|
+
validateOnly: Boolean(pickFirst(topLevelValidateOnly, optionsValidateOnly)),
|
|
192
|
+
fields: pickFirst(topLevelFields, optionsFields),
|
|
193
|
+
full: Boolean(pickFirst(topLevelFull, optionsFull)),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
186
196
|
|
|
187
197
|
type McpTerseOk = {
|
|
188
198
|
ok: true
|
|
@@ -282,23 +292,298 @@ const buildErrorMessage = (status: number | undefined, body: unknown): string =>
|
|
|
282
292
|
if (typeof status === 'number') {
|
|
283
293
|
return `HTTP ${status}`
|
|
284
294
|
}
|
|
295
|
+
|
|
296
|
+
return 'Request failed'
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const normalizeGiteaApiBase = (host: string): string => {
|
|
300
|
+
const trimmed = host.trim().replace(/\/$/, '')
|
|
301
|
+
return trimmed.endsWith('/api/v1') ? trimmed : `${trimmed}/api/v1`
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const safeStringify = (value: unknown): string => {
|
|
305
|
+
try {
|
|
306
|
+
return JSON.stringify(value)
|
|
307
|
+
} catch {
|
|
308
|
+
return ''
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const blockingIssueMessagePattern = /close all issues blocking this pull request|issues blocking this pull request|blocking this pull request/i
|
|
313
|
+
|
|
314
|
+
const isBlockingIssueMessage = (status: number | undefined, body: unknown): boolean => {
|
|
315
|
+
const message = buildErrorMessage(status, body)
|
|
316
|
+
if (blockingIssueMessagePattern.test(message)) {
|
|
317
|
+
return true
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const bodyText = typeof body === 'string' ? body : safeStringify(body)
|
|
321
|
+
return blockingIssueMessagePattern.test(bodyText)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type MergeFailureClassification = {
|
|
325
|
+
code: string
|
|
326
|
+
message: string
|
|
327
|
+
retryable: boolean
|
|
328
|
+
blockedByIssues: boolean
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const classifyMergeFailure = (status: number | undefined, body: unknown): MergeFailureClassification => {
|
|
332
|
+
const message = buildErrorMessage(status, body)
|
|
333
|
+
const blockedByIssues = isBlockingIssueMessage(status, body)
|
|
334
|
+
const retryable =
|
|
335
|
+
!blockedByIssues &&
|
|
336
|
+
(status === 429 || (typeof status === 'number' && status >= 500) || /try again later/i.test(message))
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
code: blockedByIssues ? 'PR_BLOCKED_BY_ISSUES' : 'MERGE_FAILED',
|
|
340
|
+
message,
|
|
341
|
+
retryable,
|
|
342
|
+
blockedByIssues,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const inferHeadContainedFromCompareBody = (body: unknown): boolean | null => {
|
|
347
|
+
if (!isRecord(body)) {
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const totalCommits = Number((body as Record<string, unknown>).total_commits)
|
|
352
|
+
if (Number.isFinite(totalCommits)) {
|
|
353
|
+
return totalCommits === 0
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const commits = (body as Record<string, unknown>).commits
|
|
357
|
+
if (Array.isArray(commits)) {
|
|
358
|
+
return commits.length === 0
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return null
|
|
362
|
+
}
|
|
285
363
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
364
|
+
const isNonEmptyString = (value: unknown): boolean => typeof value === 'string' && value.trim().length > 0
|
|
365
|
+
|
|
366
|
+
const isEmptyObject = (value: unknown): boolean => isRecord(value) && Object.keys(value).length === 0
|
|
367
|
+
|
|
368
|
+
const isEffectivelyEmpty = (value: unknown): boolean => {
|
|
369
|
+
if (value === null || value === undefined) return true
|
|
370
|
+
if (typeof value === 'string') return value.trim().length === 0
|
|
371
|
+
if (Array.isArray(value)) return value.length === 0
|
|
372
|
+
if (isEmptyObject(value)) return true
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const hasKey = (value: Record<string, unknown>, key: string): boolean =>
|
|
377
|
+
Object.prototype.hasOwnProperty.call(value, key)
|
|
378
|
+
|
|
379
|
+
const isLikelyUserRecord = (value: Record<string, unknown>): boolean =>
|
|
380
|
+
(hasKey(value, 'login') || hasKey(value, 'username')) &&
|
|
381
|
+
(hasKey(value, 'avatar_url') || hasKey(value, 'html_url') || hasKey(value, 'is_admin'))
|
|
382
|
+
|
|
383
|
+
const isLikelyRepoRecord = (value: Record<string, unknown>): boolean =>
|
|
384
|
+
(hasKey(value, 'full_name') || hasKey(value, 'default_branch')) &&
|
|
385
|
+
(hasKey(value, 'private') || hasKey(value, 'clone_url') || hasKey(value, 'ssh_url'))
|
|
386
|
+
|
|
387
|
+
const isLikelyLabelRecord = (value: Record<string, unknown>): boolean =>
|
|
388
|
+
hasKey(value, 'name') && hasKey(value, 'color') && (hasKey(value, 'id') || hasKey(value, 'description'))
|
|
389
|
+
|
|
390
|
+
const isLikelyMilestoneRecord = (value: Record<string, unknown>): boolean =>
|
|
391
|
+
hasKey(value, 'title') && hasKey(value, 'state') && (hasKey(value, 'due_on') || hasKey(value, 'closed_at'))
|
|
392
|
+
|
|
393
|
+
const isLikelyBranchRefRecord = (value: Record<string, unknown>): boolean =>
|
|
394
|
+
hasKey(value, 'ref') && hasKey(value, 'sha') && hasKey(value, 'repo')
|
|
395
|
+
|
|
396
|
+
const isLikelyPullRequestRecord = (value: Record<string, unknown>): boolean =>
|
|
397
|
+
hasKey(value, 'number') && (hasKey(value, 'head') || hasKey(value, 'base') || hasKey(value, 'merged'))
|
|
398
|
+
|
|
399
|
+
const isLikelyIssueRecord = (value: Record<string, unknown>): boolean =>
|
|
400
|
+
hasKey(value, 'number') && hasKey(value, 'title') && hasKey(value, 'state') && !isLikelyPullRequestRecord(value)
|
|
401
|
+
|
|
402
|
+
const isLikelyReviewRecord = (value: Record<string, unknown>): boolean =>
|
|
403
|
+
(hasKey(value, 'commit_id') && hasKey(value, 'state') && hasKey(value, 'user')) || hasKey(value, 'submitted_at')
|
|
404
|
+
|
|
405
|
+
const compactRecordByKeys = (
|
|
406
|
+
value: Record<string, unknown>,
|
|
407
|
+
keys: string[],
|
|
408
|
+
compact: (entry: unknown, keyHint?: string) => unknown,
|
|
409
|
+
): Record<string, unknown> => {
|
|
410
|
+
const next: Record<string, unknown> = {}
|
|
411
|
+
|
|
412
|
+
for (const key of keys) {
|
|
413
|
+
if (!hasKey(value, key)) continue
|
|
414
|
+
const compacted = compact(value[key], key)
|
|
415
|
+
if (isEffectivelyEmpty(compacted)) continue
|
|
416
|
+
next[key] = compacted
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return next
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const compactUserRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> => {
|
|
423
|
+
const next: Record<string, unknown> = {}
|
|
424
|
+
const id = value.id
|
|
425
|
+
const login = toTrimmedString(pickFirst(value.login, value.username))
|
|
426
|
+
const fullName = toTrimmedString(value.full_name)
|
|
427
|
+
const htmlUrl = toTrimmedString(value.html_url)
|
|
428
|
+
|
|
429
|
+
if (id !== undefined && id !== null) next.id = id
|
|
430
|
+
if (login) next.login = login
|
|
431
|
+
if (fullName) next.full_name = fullName
|
|
432
|
+
if (htmlUrl) next.html_url = htmlUrl
|
|
433
|
+
|
|
434
|
+
if (Object.keys(next).length > 0) return next
|
|
435
|
+
return compactRecordByKeys(value, ['login', 'username', 'id'], compact)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const compactRepoRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
439
|
+
compactRecordByKeys(value, ['id', 'name', 'full_name', 'private', 'default_branch', 'archived', 'html_url'], compact)
|
|
440
|
+
|
|
441
|
+
const compactLabelRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
442
|
+
compactRecordByKeys(value, ['id', 'name', 'color', 'description'], compact)
|
|
443
|
+
|
|
444
|
+
const compactMilestoneRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
445
|
+
compactRecordByKeys(value, ['id', 'title', 'state', 'due_on', 'closed_at'], compact)
|
|
446
|
+
|
|
447
|
+
const compactBranchRefRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
448
|
+
compactRecordByKeys(value, ['label', 'ref', 'sha', 'repo'], compact)
|
|
449
|
+
|
|
450
|
+
const compactPullRequestRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
451
|
+
compactRecordByKeys(
|
|
452
|
+
value,
|
|
453
|
+
[
|
|
454
|
+
'id',
|
|
455
|
+
'number',
|
|
456
|
+
'title',
|
|
457
|
+
'body',
|
|
458
|
+
'state',
|
|
459
|
+
'draft',
|
|
460
|
+
'is_locked',
|
|
461
|
+
'comments',
|
|
462
|
+
'additions',
|
|
463
|
+
'deletions',
|
|
464
|
+
'changed_files',
|
|
465
|
+
'mergeable',
|
|
466
|
+
'merged',
|
|
467
|
+
'merged_at',
|
|
468
|
+
'merge_commit_sha',
|
|
469
|
+
'allow_maintainer_edit',
|
|
470
|
+
'html_url',
|
|
471
|
+
'user',
|
|
472
|
+
'labels',
|
|
473
|
+
'milestone',
|
|
474
|
+
'assignee',
|
|
475
|
+
'assignees',
|
|
476
|
+
'requested_reviewers',
|
|
477
|
+
'base',
|
|
478
|
+
'head',
|
|
479
|
+
'merge_base',
|
|
480
|
+
'created_at',
|
|
481
|
+
'updated_at',
|
|
482
|
+
'closed_at',
|
|
483
|
+
],
|
|
484
|
+
compact,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
const compactIssueRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
488
|
+
compactRecordByKeys(
|
|
489
|
+
value,
|
|
490
|
+
[
|
|
491
|
+
'id',
|
|
492
|
+
'number',
|
|
493
|
+
'title',
|
|
494
|
+
'body',
|
|
495
|
+
'state',
|
|
496
|
+
'user',
|
|
497
|
+
'labels',
|
|
498
|
+
'milestone',
|
|
499
|
+
'assignee',
|
|
500
|
+
'assignees',
|
|
501
|
+
'comments',
|
|
502
|
+
'html_url',
|
|
503
|
+
'created_at',
|
|
504
|
+
'updated_at',
|
|
505
|
+
'closed_at',
|
|
506
|
+
],
|
|
507
|
+
compact,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
const compactReviewRecord = (value: Record<string, unknown>, compact: (entry: unknown, keyHint?: string) => unknown): Record<string, unknown> =>
|
|
511
|
+
compactRecordByKeys(value, ['id', 'state', 'body', 'user', 'commit_id', 'submitted_at', 'html_url'], compact)
|
|
512
|
+
|
|
513
|
+
const compactResponseDataForDefaultMode = (value: unknown, keyHint?: string): unknown => {
|
|
514
|
+
const compact = compactResponseDataForDefaultMode
|
|
515
|
+
|
|
516
|
+
if (Array.isArray(value)) {
|
|
517
|
+
const next = value
|
|
518
|
+
.map((entry) => compact(entry, keyHint))
|
|
519
|
+
.filter((entry) => !isEffectivelyEmpty(entry))
|
|
520
|
+
return next
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (!isRecord(value)) {
|
|
524
|
+
if (typeof value === 'string') {
|
|
525
|
+
const trimmed = value.trim()
|
|
526
|
+
return trimmed.length > 0 ? value : undefined
|
|
527
|
+
}
|
|
528
|
+
return value
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (isLikelyPullRequestRecord(value)) return compactPullRequestRecord(value, compact)
|
|
532
|
+
if (isLikelyIssueRecord(value)) return compactIssueRecord(value, compact)
|
|
533
|
+
if (isLikelyReviewRecord(value)) return compactReviewRecord(value, compact)
|
|
534
|
+
if (isLikelyBranchRefRecord(value)) return compactBranchRefRecord(value, compact)
|
|
535
|
+
if (isLikelyMilestoneRecord(value)) return compactMilestoneRecord(value, compact)
|
|
536
|
+
if (isLikelyLabelRecord(value)) return compactLabelRecord(value, compact)
|
|
537
|
+
if (isLikelyRepoRecord(value)) return compactRepoRecord(value, compact)
|
|
538
|
+
if (isLikelyUserRecord(value)) return compactUserRecord(value, compact)
|
|
539
|
+
|
|
540
|
+
if (keyHint === 'user' || keyHint === 'owner' || keyHint === 'assignee' || keyHint === 'merged_by') {
|
|
541
|
+
return compactUserRecord(value, compact)
|
|
542
|
+
}
|
|
543
|
+
if (keyHint === 'repo') {
|
|
544
|
+
return compactRepoRecord(value, compact)
|
|
545
|
+
}
|
|
546
|
+
if (keyHint === 'base' || keyHint === 'head') {
|
|
547
|
+
return compactBranchRefRecord(value, compact)
|
|
548
|
+
}
|
|
549
|
+
if (keyHint === 'milestone') {
|
|
550
|
+
return compactMilestoneRecord(value, compact)
|
|
551
|
+
}
|
|
552
|
+
if (keyHint === 'label') {
|
|
553
|
+
return compactLabelRecord(value, compact)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const next: Record<string, unknown> = {}
|
|
557
|
+
const hasHtmlUrl = isNonEmptyString(value.html_url)
|
|
558
|
+
|
|
559
|
+
for (const [key, entryValue] of Object.entries(value)) {
|
|
560
|
+
if (key === 'url' && hasHtmlUrl) {
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const compacted = compact(entryValue, key)
|
|
565
|
+
if (isEffectivelyEmpty(compacted)) continue
|
|
566
|
+
next[key] = compacted
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return next
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const toMcpEnvelope = (
|
|
573
|
+
result: GitServiceApiExecutionResult,
|
|
574
|
+
format: McpToolOutputFormat,
|
|
575
|
+
full: boolean,
|
|
576
|
+
): { isError: boolean; envelope: McpTerseOk | McpTerseErr } => {
|
|
577
|
+
const sanitized = redactSecretsForMcpOutput(result)
|
|
578
|
+
const body = full ? result.body : compactResponseDataForDefaultMode(result.body)
|
|
579
|
+
|
|
580
|
+
if (result.ok && result.status < 400) {
|
|
581
|
+
const envelope: McpTerseOk = {
|
|
582
|
+
ok: true,
|
|
583
|
+
data: body,
|
|
584
|
+
meta: {
|
|
585
|
+
status: result.status,
|
|
586
|
+
},
|
|
302
587
|
...(format === 'debug' ? { debug: sanitized } : {}),
|
|
303
588
|
}
|
|
304
589
|
|
|
@@ -309,16 +594,16 @@ const toMcpEnvelope = (
|
|
|
309
594
|
const message = buildErrorMessage(status, result.body)
|
|
310
595
|
const retryable = status >= 500 || status === 429 || /try again later/i.test(message)
|
|
311
596
|
|
|
312
|
-
const envelope: McpTerseErr = {
|
|
313
|
-
ok: false,
|
|
314
|
-
error: {
|
|
315
|
-
code: httpErrorCodeForStatus(status),
|
|
316
|
-
status,
|
|
317
|
-
message,
|
|
318
|
-
details:
|
|
319
|
-
retryable,
|
|
320
|
-
},
|
|
321
|
-
meta: {
|
|
597
|
+
const envelope: McpTerseErr = {
|
|
598
|
+
ok: false,
|
|
599
|
+
error: {
|
|
600
|
+
code: httpErrorCodeForStatus(status),
|
|
601
|
+
status,
|
|
602
|
+
message,
|
|
603
|
+
details: body,
|
|
604
|
+
retryable,
|
|
605
|
+
},
|
|
606
|
+
meta: {
|
|
322
607
|
status,
|
|
323
608
|
},
|
|
324
609
|
...(format === 'debug' ? { debug: sanitized } : {}),
|
|
@@ -387,7 +672,7 @@ const normalizePayload = (payload: unknown): { args: string[]; options: Record<s
|
|
|
387
672
|
}
|
|
388
673
|
}
|
|
389
674
|
|
|
390
|
-
const OMITTED_OPTION_KEYS = new Set(['format', 'fields', 'validateOnly'])
|
|
675
|
+
const OMITTED_OPTION_KEYS = new Set(['format', 'fields', 'validateOnly', 'full'])
|
|
391
676
|
|
|
392
677
|
const stripMcpOnlyOptions = (options: Record<string, unknown>): Record<string, unknown> => {
|
|
393
678
|
const next: Record<string, unknown> = {}
|
|
@@ -610,15 +895,19 @@ const buildGenericToolSchema = (): Record<string, unknown> => ({
|
|
|
610
895
|
additionalProperties: true,
|
|
611
896
|
description: 'Method options (query/headers/body payload). Extra top-level keys are merged into options.',
|
|
612
897
|
},
|
|
613
|
-
validateOnly: {
|
|
614
|
-
type: 'boolean',
|
|
615
|
-
description:
|
|
616
|
-
'If true, do not execute the underlying HTTP request. Returns the normalized call payload (args/options) that would be sent.',
|
|
617
|
-
},
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
898
|
+
validateOnly: {
|
|
899
|
+
type: 'boolean',
|
|
900
|
+
description:
|
|
901
|
+
'If true, do not execute the underlying HTTP request. Returns the normalized call payload (args/options) that would be sent.',
|
|
902
|
+
},
|
|
903
|
+
full: {
|
|
904
|
+
type: 'boolean',
|
|
905
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
906
|
+
},
|
|
907
|
+
fields: {
|
|
908
|
+
description:
|
|
909
|
+
'Optional field selection for the response body to reduce token usage. Accepts a string[] or a comma-separated string of dot-paths.',
|
|
910
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
622
911
|
},
|
|
623
912
|
format: {
|
|
624
913
|
type: 'string',
|
|
@@ -648,12 +937,16 @@ const buildArtifactsByRunSchema = (): Record<string, unknown> => ({
|
|
|
648
937
|
description: 'Alias for runId.',
|
|
649
938
|
anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }],
|
|
650
939
|
},
|
|
651
|
-
format: {
|
|
652
|
-
type: 'string',
|
|
653
|
-
enum: ['terse', 'debug'],
|
|
654
|
-
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
655
|
-
},
|
|
656
|
-
|
|
940
|
+
format: {
|
|
941
|
+
type: 'string',
|
|
942
|
+
enum: ['terse', 'debug'],
|
|
943
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
944
|
+
},
|
|
945
|
+
full: {
|
|
946
|
+
type: 'boolean',
|
|
947
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
948
|
+
},
|
|
949
|
+
// Legacy positional forms:
|
|
657
950
|
// - Preferred by humans/LLMs: [owner, repo, runId]
|
|
658
951
|
// - Back-compat with the underlying helper signature: [runId, owner, repo]
|
|
659
952
|
args: {
|
|
@@ -716,11 +1009,15 @@ const buildLogsForRunTailSchema = (): Record<string, unknown> => ({
|
|
|
716
1009
|
type: 'string',
|
|
717
1010
|
description: 'If set, only return log lines containing this substring.',
|
|
718
1011
|
},
|
|
719
|
-
format: {
|
|
720
|
-
type: 'string',
|
|
721
|
-
enum: ['terse', 'debug'],
|
|
722
|
-
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
723
|
-
},
|
|
1012
|
+
format: {
|
|
1013
|
+
type: 'string',
|
|
1014
|
+
enum: ['terse', 'debug'],
|
|
1015
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
1016
|
+
},
|
|
1017
|
+
full: {
|
|
1018
|
+
type: 'boolean',
|
|
1019
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
1020
|
+
},
|
|
724
1021
|
|
|
725
1022
|
// Legacy / compatibility: allow calling with positional args.
|
|
726
1023
|
args: {
|
|
@@ -779,12 +1076,16 @@ const buildDiagnoseLatestFailureSchema = (): Record<string, unknown> => ({
|
|
|
779
1076
|
type: 'string',
|
|
780
1077
|
description: 'If set, only return log lines containing this substring.',
|
|
781
1078
|
},
|
|
782
|
-
format: {
|
|
783
|
-
type: 'string',
|
|
784
|
-
enum: ['terse', 'debug'],
|
|
785
|
-
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
786
|
-
},
|
|
787
|
-
|
|
1079
|
+
format: {
|
|
1080
|
+
type: 'string',
|
|
1081
|
+
enum: ['terse', 'debug'],
|
|
1082
|
+
description: 'Output format. Default: "terse". Use "debug" to include request/mapping/headers.',
|
|
1083
|
+
},
|
|
1084
|
+
full: {
|
|
1085
|
+
type: 'boolean',
|
|
1086
|
+
description: 'If true, return the unfiltered upstream response body.',
|
|
1087
|
+
},
|
|
1088
|
+
args: {
|
|
788
1089
|
type: 'array',
|
|
789
1090
|
items: { type: 'string' },
|
|
790
1091
|
description: 'Legacy positional form: [owner, repo] plus options.workflowName/maxLines/etc.',
|
|
@@ -847,14 +1148,18 @@ const buildToolList = (
|
|
|
847
1148
|
additionalProperties: true,
|
|
848
1149
|
description: 'Tool invocation options',
|
|
849
1150
|
},
|
|
850
|
-
validateOnly: {
|
|
851
|
-
type: 'boolean',
|
|
852
|
-
description: 'If true, validate and normalize without executing the underlying request.',
|
|
853
|
-
},
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1151
|
+
validateOnly: {
|
|
1152
|
+
type: 'boolean',
|
|
1153
|
+
description: 'If true, validate and normalize without executing the underlying request.',
|
|
1154
|
+
},
|
|
1155
|
+
full: {
|
|
1156
|
+
type: 'boolean',
|
|
1157
|
+
description: 'If true, return the unfiltered upstream response body for this call.',
|
|
1158
|
+
},
|
|
1159
|
+
fields: {
|
|
1160
|
+
description:
|
|
1161
|
+
'Optional field selection for the response body (reduces token usage). Accepts a string[] or comma-separated string.',
|
|
1162
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
858
1163
|
},
|
|
859
1164
|
format: {
|
|
860
1165
|
type: 'string',
|
|
@@ -876,14 +1181,18 @@ const buildToolList = (
|
|
|
876
1181
|
enum: ['terse', 'debug'],
|
|
877
1182
|
description: 'Default output format for calls that do not specify one (default: "terse").',
|
|
878
1183
|
},
|
|
879
|
-
validateOnly: {
|
|
880
|
-
type: 'boolean',
|
|
881
|
-
description: 'If true, validate and normalize calls without executing them.',
|
|
882
|
-
},
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1184
|
+
validateOnly: {
|
|
1185
|
+
type: 'boolean',
|
|
1186
|
+
description: 'If true, validate and normalize calls without executing them.',
|
|
1187
|
+
},
|
|
1188
|
+
full: {
|
|
1189
|
+
type: 'boolean',
|
|
1190
|
+
description: 'Default response mode for calls that do not specify one. If true, return unfiltered bodies.',
|
|
1191
|
+
},
|
|
1192
|
+
fields: {
|
|
1193
|
+
description:
|
|
1194
|
+
'Default field selection for calls that do not specify one. Accepts a string[] or comma-separated string.',
|
|
1195
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
887
1196
|
},
|
|
888
1197
|
},
|
|
889
1198
|
required: ['calls'],
|
|
@@ -1091,16 +1400,148 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1091
1400
|
return { owner, repo }
|
|
1092
1401
|
}
|
|
1093
1402
|
|
|
1094
|
-
const resolvePrNumber = (args: string[], options: Record<string, unknown>): string => {
|
|
1095
|
-
const fromArgs = args.length >= 1 ? toTrimmedString(args[0]) : ''
|
|
1096
|
-
const fromNamed = toTrimmedString(pickFirst(options.number, (options as Record<string, unknown>).prNumber, options.index))
|
|
1097
|
-
return fromArgs || fromNamed
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1403
|
+
const resolvePrNumber = (args: string[], options: Record<string, unknown>): string => {
|
|
1404
|
+
const fromArgs = args.length >= 1 ? toTrimmedString(args[0]) : ''
|
|
1405
|
+
const fromNamed = toTrimmedString(pickFirst(options.number, (options as Record<string, unknown>).prNumber, options.index))
|
|
1406
|
+
return fromArgs || fromNamed
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const resolvedConfig = pickRecord(options.config)
|
|
1410
|
+
const giteaHost = toTrimmedString(pickFirst(resolvedConfig.giteaHost, process.env.GITEA_HOST)) || ''
|
|
1411
|
+
const giteaToken = toTrimmedString(pickFirst(resolvedConfig.giteaToken, process.env.GITEA_TOKEN)) || ''
|
|
1412
|
+
const giteaApiBase = giteaHost ? normalizeGiteaApiBase(giteaHost) : ''
|
|
1413
|
+
|
|
1414
|
+
const checkPrBlockingIssues = async (
|
|
1415
|
+
owner: string | undefined,
|
|
1416
|
+
repo: string | undefined,
|
|
1417
|
+
prNumber: string,
|
|
1418
|
+
): Promise<{
|
|
1419
|
+
attempted: boolean
|
|
1420
|
+
ok: boolean
|
|
1421
|
+
status?: number
|
|
1422
|
+
blockingIssueNumbers: number[]
|
|
1423
|
+
details?: unknown
|
|
1424
|
+
reason?: string
|
|
1425
|
+
}> => {
|
|
1426
|
+
const issueNumber = toPositiveInteger(prNumber)
|
|
1427
|
+
if (!owner || !repo || !issueNumber) {
|
|
1428
|
+
return {
|
|
1429
|
+
attempted: false,
|
|
1430
|
+
ok: false,
|
|
1431
|
+
blockingIssueNumbers: [],
|
|
1432
|
+
reason: 'owner/repo/number is required',
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
try {
|
|
1437
|
+
const result = await callIssueDependenciesApi(
|
|
1438
|
+
'GET',
|
|
1439
|
+
owner,
|
|
1440
|
+
repo,
|
|
1441
|
+
issueNumber,
|
|
1442
|
+
giteaHost || undefined,
|
|
1443
|
+
giteaToken || undefined,
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
return {
|
|
1447
|
+
attempted: true,
|
|
1448
|
+
ok: result.ok,
|
|
1449
|
+
status: result.status,
|
|
1450
|
+
blockingIssueNumbers: result.ok ? extractDependencyIssueNumbers(result.body) : [],
|
|
1451
|
+
details: result.body,
|
|
1452
|
+
}
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
return {
|
|
1455
|
+
attempted: true,
|
|
1456
|
+
ok: false,
|
|
1457
|
+
blockingIssueNumbers: [],
|
|
1458
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const checkPrHeadContainedInBase = async (
|
|
1464
|
+
owner: string | undefined,
|
|
1465
|
+
repo: string | undefined,
|
|
1466
|
+
baseRef: string,
|
|
1467
|
+
headSha: string,
|
|
1468
|
+
): Promise<{
|
|
1469
|
+
attempted: boolean
|
|
1470
|
+
ok: boolean
|
|
1471
|
+
status?: number
|
|
1472
|
+
contained: boolean | null
|
|
1473
|
+
details?: unknown
|
|
1474
|
+
reason?: string
|
|
1475
|
+
}> => {
|
|
1476
|
+
if (!owner || !repo || !baseRef || !headSha) {
|
|
1477
|
+
return {
|
|
1478
|
+
attempted: false,
|
|
1479
|
+
ok: false,
|
|
1480
|
+
contained: null,
|
|
1481
|
+
reason: 'owner/repo/baseRef/headSha is required',
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (!giteaApiBase) {
|
|
1486
|
+
return {
|
|
1487
|
+
attempted: false,
|
|
1488
|
+
ok: false,
|
|
1489
|
+
contained: null,
|
|
1490
|
+
reason: 'Gitea API base is not configured',
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const basehead = `${baseRef}...${headSha}`
|
|
1495
|
+
const requestUrl = `${giteaApiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/compare/${encodeURIComponent(basehead)}`
|
|
1496
|
+
const headers: Record<string, string> = {
|
|
1497
|
+
Accept: 'application/json',
|
|
1498
|
+
}
|
|
1499
|
+
if (giteaToken) {
|
|
1500
|
+
headers.Authorization = `token ${giteaToken}`
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
try {
|
|
1504
|
+
const response = await fetch(requestUrl, { method: 'GET', headers })
|
|
1505
|
+
const responseText = await response.text()
|
|
1506
|
+
let parsedBody: unknown = responseText
|
|
1507
|
+
try {
|
|
1508
|
+
parsedBody = JSON.parse(responseText)
|
|
1509
|
+
} catch {
|
|
1510
|
+
parsedBody = responseText
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (!response.ok) {
|
|
1514
|
+
return {
|
|
1515
|
+
attempted: true,
|
|
1516
|
+
ok: false,
|
|
1517
|
+
status: response.status,
|
|
1518
|
+
contained: null,
|
|
1519
|
+
details: parsedBody,
|
|
1520
|
+
reason: buildErrorMessage(response.status, parsedBody),
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
return {
|
|
1525
|
+
attempted: true,
|
|
1526
|
+
ok: true,
|
|
1527
|
+
status: response.status,
|
|
1528
|
+
contained: inferHeadContainedFromCompareBody(parsedBody),
|
|
1529
|
+
details: parsedBody,
|
|
1530
|
+
}
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
return {
|
|
1533
|
+
attempted: true,
|
|
1534
|
+
ok: false,
|
|
1535
|
+
contained: null,
|
|
1536
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const normalizePayloadWithContext = (
|
|
1542
|
+
tool: ToolDefinition,
|
|
1543
|
+
payload: unknown,
|
|
1544
|
+
): { args: string[]; options: Record<string, unknown> } => {
|
|
1104
1545
|
const normalized = normalizePayload(payload)
|
|
1105
1546
|
const optionsWithDefaults = { ...normalized.options }
|
|
1106
1547
|
const { owner, repo } = resolveOwnerRepo(optionsWithDefaults)
|
|
@@ -1137,32 +1578,44 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1137
1578
|
items: { type: 'string' },
|
|
1138
1579
|
description: 'Legacy positional form: [owner, repo]',
|
|
1139
1580
|
},
|
|
1140
|
-
format: {
|
|
1141
|
-
type: 'string',
|
|
1142
|
-
enum: ['terse', 'debug'],
|
|
1143
|
-
description: 'Output format. Default: "terse".',
|
|
1144
|
-
},
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1581
|
+
format: {
|
|
1582
|
+
type: 'string',
|
|
1583
|
+
enum: ['terse', 'debug'],
|
|
1584
|
+
description: 'Output format. Default: "terse".',
|
|
1585
|
+
},
|
|
1586
|
+
full: {
|
|
1587
|
+
type: 'boolean',
|
|
1588
|
+
description: 'If true, return unfiltered helper payloads.',
|
|
1589
|
+
},
|
|
1590
|
+
},
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
{
|
|
1594
|
+
name: contextGetToolName,
|
|
1595
|
+
description: 'Get the current default {owner, repo} for this session.',
|
|
1596
|
+
inputSchema: {
|
|
1597
|
+
type: 'object',
|
|
1598
|
+
additionalProperties: true,
|
|
1599
|
+
properties: {
|
|
1600
|
+
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1601
|
+
full: { type: 'boolean', description: 'If true, return unfiltered helper payloads.' },
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
},
|
|
1153
1605
|
{
|
|
1154
1606
|
name: searchToolsToolName,
|
|
1155
1607
|
description: 'Search available git MCP tools by substring (returns names + descriptions).',
|
|
1156
|
-
inputSchema: {
|
|
1157
|
-
type: 'object',
|
|
1158
|
-
additionalProperties: true,
|
|
1159
|
-
properties: {
|
|
1160
|
-
query: { type: 'string', description: 'Search query (substring match on tool name/path)' },
|
|
1161
|
-
limit: { type: 'integer', minimum: 1, description: 'Max matches to return (default: 20)' },
|
|
1162
|
-
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1608
|
+
inputSchema: {
|
|
1609
|
+
type: 'object',
|
|
1610
|
+
additionalProperties: true,
|
|
1611
|
+
properties: {
|
|
1612
|
+
query: { type: 'string', description: 'Search query (substring match on tool name/path)' },
|
|
1613
|
+
limit: { type: 'integer', minimum: 1, description: 'Max matches to return (default: 20)' },
|
|
1614
|
+
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1615
|
+
full: { type: 'boolean', description: 'If true, return unfiltered helper payloads.' },
|
|
1616
|
+
},
|
|
1617
|
+
required: ['query'],
|
|
1618
|
+
},
|
|
1166
1619
|
},
|
|
1167
1620
|
{
|
|
1168
1621
|
name: prPreflightToolName,
|
|
@@ -1171,15 +1624,20 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1171
1624
|
type: 'object',
|
|
1172
1625
|
additionalProperties: true,
|
|
1173
1626
|
properties: {
|
|
1174
|
-
owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
|
|
1175
|
-
repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
|
|
1176
|
-
number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
|
|
1177
|
-
includeIssues: { type: 'boolean', description: 'If true, fetch referenced issues mentioned as "Fixes #123".' },
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
description: '
|
|
1181
|
-
|
|
1182
|
-
},
|
|
1627
|
+
owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
|
|
1628
|
+
repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
|
|
1629
|
+
number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
|
|
1630
|
+
includeIssues: { type: 'boolean', description: 'If true, fetch referenced issues mentioned as "Fixes #123".' },
|
|
1631
|
+
checkBlockingIssues: {
|
|
1632
|
+
type: 'boolean',
|
|
1633
|
+
description: 'If true (default), detect issue dependencies that block this PR from being merged.',
|
|
1634
|
+
},
|
|
1635
|
+
validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
|
|
1636
|
+
full: { type: 'boolean', description: 'If true, return unfiltered PR/check/review payloads.' },
|
|
1637
|
+
fields: {
|
|
1638
|
+
description: 'Optional field selection applied to pr/checks/review bodies.',
|
|
1639
|
+
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'string' }],
|
|
1640
|
+
},
|
|
1183
1641
|
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1184
1642
|
},
|
|
1185
1643
|
anyOf: [{ required: ['number'] }, { required: ['args'] }],
|
|
@@ -1195,14 +1653,23 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1195
1653
|
owner: { type: 'string', description: 'Repository owner/org (optional if context/defaults set)' },
|
|
1196
1654
|
repo: { type: 'string', description: 'Repository name (optional if context/defaults set)' },
|
|
1197
1655
|
number: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string' }], description: 'PR number' },
|
|
1198
|
-
mergeMethod: { type: 'string', description: 'Merge method. Maps to Gitea merge Do field (default: "merge").' },
|
|
1199
|
-
maxAttempts: { type: 'integer', minimum: 1, description: 'Max poll attempts (default: 6)' },
|
|
1200
|
-
delayMs: { type: 'integer', minimum: 0, description: 'Delay between polls in ms (default: 1000)' },
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1656
|
+
mergeMethod: { type: 'string', description: 'Merge method. Maps to Gitea merge Do field (default: "merge").' },
|
|
1657
|
+
maxAttempts: { type: 'integer', minimum: 1, description: 'Max poll attempts (default: 6)' },
|
|
1658
|
+
delayMs: { type: 'integer', minimum: 0, description: 'Delay between polls in ms (default: 1000)' },
|
|
1659
|
+
allowManualFinalize: {
|
|
1660
|
+
type: 'boolean',
|
|
1661
|
+
description: 'If true, attempt a manual metadata finalization when normal merge fails.',
|
|
1662
|
+
},
|
|
1663
|
+
manualMergeCommitSha: {
|
|
1664
|
+
type: 'string',
|
|
1665
|
+
description: 'Optional merge commit SHA used when finalizing with Do="manually-merged".',
|
|
1666
|
+
},
|
|
1667
|
+
validateOnly: { type: 'boolean', description: 'Validate and normalize without executing HTTP calls.' },
|
|
1668
|
+
full: { type: 'boolean', description: 'If true, return unfiltered merge/PR payloads.' },
|
|
1669
|
+
format: { type: 'string', enum: ['terse', 'debug'] },
|
|
1670
|
+
},
|
|
1671
|
+
anyOf: [{ required: ['number'] }, { required: ['args'] }],
|
|
1672
|
+
},
|
|
1206
1673
|
},
|
|
1207
1674
|
]
|
|
1208
1675
|
|
|
@@ -1213,13 +1680,20 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1213
1680
|
await new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
1214
1681
|
}
|
|
1215
1682
|
|
|
1216
|
-
const toOk = (
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1683
|
+
const toOk = (
|
|
1684
|
+
data: unknown,
|
|
1685
|
+
format: McpToolOutputFormat,
|
|
1686
|
+
debug?: unknown,
|
|
1687
|
+
status: number = 0,
|
|
1688
|
+
full: boolean = true,
|
|
1689
|
+
): McpTerseOk => {
|
|
1690
|
+
const outputData = full ? data : compactResponseDataForDefaultMode(data)
|
|
1691
|
+
const envelope: McpTerseOk = {
|
|
1692
|
+
ok: true,
|
|
1693
|
+
data: outputData,
|
|
1694
|
+
meta: {
|
|
1695
|
+
status,
|
|
1696
|
+
},
|
|
1223
1697
|
...(format === 'debug' && debug !== undefined ? { debug } : {}),
|
|
1224
1698
|
}
|
|
1225
1699
|
|
|
@@ -1234,16 +1708,19 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1234
1708
|
return redactSecretsForMcpOutput(enriched) as McpTerseErr
|
|
1235
1709
|
}
|
|
1236
1710
|
|
|
1237
|
-
const invokeCustomTool = async (
|
|
1238
|
-
toolName: string,
|
|
1239
|
-
payload: unknown,
|
|
1240
|
-
controls:
|
|
1241
|
-
): Promise<{ isError: boolean; envelope: McpTerseOk | McpTerseErr }> => {
|
|
1242
|
-
const format = controls.format ?? 'terse'
|
|
1243
|
-
|
|
1244
|
-
if (toolName === contextGetToolName) {
|
|
1245
|
-
return {
|
|
1246
|
-
|
|
1711
|
+
const invokeCustomTool = async (
|
|
1712
|
+
toolName: string,
|
|
1713
|
+
payload: unknown,
|
|
1714
|
+
controls: McpCallControls,
|
|
1715
|
+
): Promise<{ isError: boolean; envelope: McpTerseOk | McpTerseErr }> => {
|
|
1716
|
+
const format = controls.format ?? 'terse'
|
|
1717
|
+
|
|
1718
|
+
if (toolName === contextGetToolName) {
|
|
1719
|
+
return {
|
|
1720
|
+
isError: false,
|
|
1721
|
+
envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format, undefined, 0, true),
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1247
1724
|
|
|
1248
1725
|
if (toolName === contextSetToolName) {
|
|
1249
1726
|
const normalized = normalizePayload(payload)
|
|
@@ -1256,8 +1733,11 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1256
1733
|
context.owner = owner || undefined
|
|
1257
1734
|
context.repo = repo || undefined
|
|
1258
1735
|
|
|
1259
|
-
return {
|
|
1260
|
-
|
|
1736
|
+
return {
|
|
1737
|
+
isError: false,
|
|
1738
|
+
envelope: toOk({ owner: context.owner ?? null, repo: context.repo ?? null }, format, undefined, 0, true),
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1261
1741
|
|
|
1262
1742
|
if (toolName === searchToolsToolName) {
|
|
1263
1743
|
const normalized = normalizePayload(payload)
|
|
@@ -1299,8 +1779,8 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1299
1779
|
return meta
|
|
1300
1780
|
})
|
|
1301
1781
|
|
|
1302
|
-
return { isError: false, envelope: toOk({ matches }, format) }
|
|
1303
|
-
}
|
|
1782
|
+
return { isError: false, envelope: toOk({ matches }, format, undefined, 0, true) }
|
|
1783
|
+
}
|
|
1304
1784
|
|
|
1305
1785
|
if (toolName === prPreflightToolName) {
|
|
1306
1786
|
const normalized = normalizePayload(payload)
|
|
@@ -1327,36 +1807,43 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1327
1807
|
}
|
|
1328
1808
|
}
|
|
1329
1809
|
|
|
1330
|
-
const includeIssues = Boolean((optionsWithDefaults as Record<string, unknown>).includeIssues)
|
|
1810
|
+
const includeIssues = Boolean((optionsWithDefaults as Record<string, unknown>).includeIssues)
|
|
1811
|
+
const checkBlockingIssues =
|
|
1812
|
+
parseBoolean((optionsWithDefaults as Record<string, unknown>).checkBlockingIssues) ?? true
|
|
1331
1813
|
|
|
1332
1814
|
if (controls.validateOnly) {
|
|
1333
1815
|
const toolPrefix = prefix ? `${prefix}.` : ''
|
|
1334
1816
|
return {
|
|
1335
1817
|
isError: false,
|
|
1336
1818
|
envelope: toOk(
|
|
1337
|
-
{
|
|
1338
|
-
valid: true,
|
|
1339
|
-
owner: owner ?? null,
|
|
1819
|
+
{
|
|
1820
|
+
valid: true,
|
|
1821
|
+
owner: owner ?? null,
|
|
1340
1822
|
repo: repo ?? null,
|
|
1341
1823
|
number,
|
|
1342
|
-
calls: [
|
|
1343
|
-
{ tool: `${toolPrefix}repo.pr.view`, args: [number], options: { owner, repo } },
|
|
1344
|
-
{ tool: `${toolPrefix}repo.pr.checks`, args: [number], options: { owner, repo } },
|
|
1345
|
-
{ tool: `${toolPrefix}repo.pr.review`, args: [number], options: { owner, repo, method: 'GET' } },
|
|
1346
|
-
],
|
|
1347
|
-
includeIssues,
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1824
|
+
calls: [
|
|
1825
|
+
{ tool: `${toolPrefix}repo.pr.view`, args: [number], options: { owner, repo } },
|
|
1826
|
+
{ tool: `${toolPrefix}repo.pr.checks`, args: [number], options: { owner, repo } },
|
|
1827
|
+
{ tool: `${toolPrefix}repo.pr.review`, args: [number], options: { owner, repo, method: 'GET' } },
|
|
1828
|
+
],
|
|
1829
|
+
includeIssues,
|
|
1830
|
+
checkBlockingIssues,
|
|
1831
|
+
},
|
|
1832
|
+
format,
|
|
1833
|
+
undefined,
|
|
1834
|
+
0,
|
|
1835
|
+
true,
|
|
1836
|
+
),
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1353
1839
|
|
|
1354
1840
|
const callOptionsBase = stripMcpOnlyOptions(optionsWithDefaults)
|
|
1355
|
-
const callOptions: Record<string, unknown> = { ...callOptionsBase }
|
|
1356
|
-
delete (callOptions as any).includeIssues
|
|
1357
|
-
delete (callOptions as any).
|
|
1358
|
-
delete (callOptions as any).
|
|
1359
|
-
delete (callOptions as any).
|
|
1841
|
+
const callOptions: Record<string, unknown> = { ...callOptionsBase }
|
|
1842
|
+
delete (callOptions as any).includeIssues
|
|
1843
|
+
delete (callOptions as any).checkBlockingIssues
|
|
1844
|
+
delete (callOptions as any).number
|
|
1845
|
+
delete (callOptions as any).prNumber
|
|
1846
|
+
delete (callOptions as any).index
|
|
1360
1847
|
|
|
1361
1848
|
const pr = await (api as any).repo.pr.view(number, callOptions)
|
|
1362
1849
|
const checks = await (api as any).repo.pr.checks(number, callOptions)
|
|
@@ -1374,24 +1861,55 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1374
1861
|
|
|
1375
1862
|
const uniqueReferenced = Array.from(new Set(referencedIssueNumbers))
|
|
1376
1863
|
const issues: unknown[] = []
|
|
1377
|
-
if (includeIssues && uniqueReferenced.length > 0) {
|
|
1378
|
-
for (const n of uniqueReferenced) {
|
|
1379
|
-
try {
|
|
1380
|
-
const issue = await (api as any).repo.issue.view(String(n), callOptions)
|
|
1381
|
-
issues.push(issue.body)
|
|
1864
|
+
if (includeIssues && uniqueReferenced.length > 0) {
|
|
1865
|
+
for (const n of uniqueReferenced) {
|
|
1866
|
+
try {
|
|
1867
|
+
const issue = await (api as any).repo.issue.view(String(n), callOptions)
|
|
1868
|
+
issues.push(issue.body)
|
|
1382
1869
|
} catch {
|
|
1383
1870
|
// best effort
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const blockerCheck = checkBlockingIssues ? await checkPrBlockingIssues(owner, repo, number) : null
|
|
1876
|
+
const blockingIssueNumbers = blockerCheck ? blockerCheck.blockingIssueNumbers : []
|
|
1877
|
+
|
|
1878
|
+
if (blockingIssueNumbers.length > 0) {
|
|
1879
|
+
return {
|
|
1880
|
+
isError: true,
|
|
1881
|
+
envelope: toErr(
|
|
1882
|
+
{
|
|
1883
|
+
ok: false,
|
|
1884
|
+
error: {
|
|
1885
|
+
code: 'PR_BLOCKED_BY_ISSUES',
|
|
1886
|
+
message: 'Pull request is blocked by issue dependencies. Close all blocking issues before merging.',
|
|
1887
|
+
details: {
|
|
1888
|
+
blockingIssueNumbers,
|
|
1889
|
+
blockerCheck: {
|
|
1890
|
+
attempted: blockerCheck?.attempted ?? false,
|
|
1891
|
+
ok: blockerCheck?.ok ?? false,
|
|
1892
|
+
status: blockerCheck?.status,
|
|
1893
|
+
},
|
|
1894
|
+
},
|
|
1895
|
+
retryable: false,
|
|
1896
|
+
},
|
|
1897
|
+
},
|
|
1898
|
+
format,
|
|
1899
|
+
{ pr, checks, review, blockerCheck },
|
|
1900
|
+
),
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const data = {
|
|
1905
|
+
pr: applyFieldSelection(pr.body, controls.fields),
|
|
1906
|
+
checks: applyFieldSelection(checks.body, controls.fields),
|
|
1907
|
+
review: applyFieldSelection(review.body, controls.fields),
|
|
1908
|
+
referencedIssueNumbers: uniqueReferenced,
|
|
1909
|
+
...(checkBlockingIssues ? { blockingIssueNumbers } : {}),
|
|
1910
|
+
...(includeIssues ? { issues } : {}),
|
|
1911
|
+
}
|
|
1912
|
+
const full = controls.full || Boolean(controls.fields)
|
|
1395
1913
|
|
|
1396
1914
|
const allOk = pr.ok && checks.ok && review.ok
|
|
1397
1915
|
if (!allOk) {
|
|
@@ -1403,22 +1921,31 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1403
1921
|
error: {
|
|
1404
1922
|
code: 'PREFLIGHT_FAILED',
|
|
1405
1923
|
message: 'One or more preflight calls failed. See details.',
|
|
1406
|
-
details: {
|
|
1407
|
-
pr: { ok: pr.ok, status: pr.status },
|
|
1408
|
-
checks: { ok: checks.ok, status: checks.status },
|
|
1409
|
-
review: { ok: review.ok, status: review.status },
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1924
|
+
details: {
|
|
1925
|
+
pr: { ok: pr.ok, status: pr.status },
|
|
1926
|
+
checks: { ok: checks.ok, status: checks.status },
|
|
1927
|
+
review: { ok: review.ok, status: review.status },
|
|
1928
|
+
...(checkBlockingIssues
|
|
1929
|
+
? {
|
|
1930
|
+
blockerCheck: {
|
|
1931
|
+
attempted: blockerCheck?.attempted ?? false,
|
|
1932
|
+
ok: blockerCheck?.ok ?? false,
|
|
1933
|
+
status: blockerCheck?.status,
|
|
1934
|
+
},
|
|
1935
|
+
}
|
|
1936
|
+
: {}),
|
|
1937
|
+
},
|
|
1938
|
+
retryable: false,
|
|
1939
|
+
},
|
|
1940
|
+
},
|
|
1941
|
+
format,
|
|
1942
|
+
{ pr, checks, review, blockerCheck },
|
|
1943
|
+
),
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
return { isError: false, envelope: toOk(data, format, { pr, checks, review, blockerCheck }, 200, full) }
|
|
1948
|
+
}
|
|
1422
1949
|
|
|
1423
1950
|
if (toolName === prMergeAndVerifyToolName) {
|
|
1424
1951
|
const normalized = normalizePayload(payload)
|
|
@@ -1445,75 +1972,201 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1445
1972
|
}
|
|
1446
1973
|
}
|
|
1447
1974
|
|
|
1448
|
-
const maxAttempts = toPositiveInteger((optionsWithDefaults as Record<string, unknown>).maxAttempts) ?? 6
|
|
1449
|
-
const delayMs = toNonNegativeInteger((optionsWithDefaults as Record<string, unknown>).delayMs) ?? 1000
|
|
1450
|
-
const mergeMethod = toTrimmedString((optionsWithDefaults as Record<string, unknown>).mergeMethod) || 'merge'
|
|
1975
|
+
const maxAttempts = toPositiveInteger((optionsWithDefaults as Record<string, unknown>).maxAttempts) ?? 6
|
|
1976
|
+
const delayMs = toNonNegativeInteger((optionsWithDefaults as Record<string, unknown>).delayMs) ?? 1000
|
|
1977
|
+
const mergeMethod = toTrimmedString((optionsWithDefaults as Record<string, unknown>).mergeMethod) || 'merge'
|
|
1978
|
+
const allowManualFinalize = Boolean((optionsWithDefaults as Record<string, unknown>).allowManualFinalize)
|
|
1979
|
+
const manualMergeCommitSha =
|
|
1980
|
+
toTrimmedString(
|
|
1981
|
+
pickFirst(
|
|
1982
|
+
(optionsWithDefaults as Record<string, unknown>).manualMergeCommitSha,
|
|
1983
|
+
(optionsWithDefaults as Record<string, unknown>).mergeCommitSha,
|
|
1984
|
+
),
|
|
1985
|
+
) || ''
|
|
1451
1986
|
|
|
1452
1987
|
if (controls.validateOnly) {
|
|
1453
1988
|
return {
|
|
1454
1989
|
isError: false,
|
|
1455
1990
|
envelope: toOk(
|
|
1456
|
-
{
|
|
1457
|
-
valid: true,
|
|
1458
|
-
owner: owner ?? null,
|
|
1991
|
+
{
|
|
1992
|
+
valid: true,
|
|
1993
|
+
owner: owner ?? null,
|
|
1459
1994
|
repo: repo ?? null,
|
|
1460
|
-
number,
|
|
1461
|
-
mergeMethod,
|
|
1462
|
-
maxAttempts,
|
|
1463
|
-
delayMs,
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1995
|
+
number,
|
|
1996
|
+
mergeMethod,
|
|
1997
|
+
maxAttempts,
|
|
1998
|
+
delayMs,
|
|
1999
|
+
allowManualFinalize,
|
|
2000
|
+
manualMergeCommitSha: manualMergeCommitSha || null,
|
|
2001
|
+
},
|
|
2002
|
+
format,
|
|
2003
|
+
undefined,
|
|
2004
|
+
0,
|
|
2005
|
+
true,
|
|
2006
|
+
),
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
1469
2009
|
|
|
1470
2010
|
const callOptionsBase = stripMcpOnlyOptions(optionsWithDefaults)
|
|
1471
2011
|
const callOptions: Record<string, unknown> = { ...callOptionsBase }
|
|
1472
2012
|
delete (callOptions as any).maxAttempts
|
|
1473
2013
|
delete (callOptions as any).delayMs
|
|
1474
|
-
delete (callOptions as any).number
|
|
1475
|
-
delete (callOptions as any).prNumber
|
|
1476
|
-
delete (callOptions as any).index
|
|
1477
|
-
delete (callOptions as any).mergeMethod
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
2014
|
+
delete (callOptions as any).number
|
|
2015
|
+
delete (callOptions as any).prNumber
|
|
2016
|
+
delete (callOptions as any).index
|
|
2017
|
+
delete (callOptions as any).mergeMethod
|
|
2018
|
+
delete (callOptions as any).allowManualFinalize
|
|
2019
|
+
delete (callOptions as any).manualMergeCommitSha
|
|
2020
|
+
delete (callOptions as any).mergeCommitSha
|
|
2021
|
+
|
|
2022
|
+
let mergeResult: GitServiceApiExecutionResult | null = null
|
|
2023
|
+
let lastMergeError: unknown = null
|
|
2024
|
+
let lastFailure: MergeFailureClassification | null = null
|
|
2025
|
+
let manualFinalize: Record<string, unknown> | null = null
|
|
2026
|
+
|
|
2027
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1483
2028
|
mergeResult = await (api as any).repo.pr.merge(number, { ...callOptions, mergeMethod })
|
|
1484
2029
|
if (mergeResult.ok && mergeResult.status < 400) {
|
|
1485
2030
|
break
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
lastMergeError = mergeResult.body
|
|
1489
|
-
|
|
1490
|
-
if (
|
|
1491
|
-
break
|
|
1492
|
-
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
lastMergeError = mergeResult.body
|
|
2034
|
+
lastFailure = classifyMergeFailure(mergeResult.status, mergeResult.body)
|
|
2035
|
+
if (!lastFailure.retryable) {
|
|
2036
|
+
break
|
|
2037
|
+
}
|
|
1493
2038
|
|
|
1494
2039
|
await sleep(delayMs)
|
|
1495
2040
|
}
|
|
1496
2041
|
|
|
1497
|
-
if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
2042
|
+
if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
|
|
2043
|
+
const failure = classifyMergeFailure(mergeResult?.status, mergeResult?.body ?? lastMergeError)
|
|
2044
|
+
|
|
2045
|
+
if (failure.blockedByIssues) {
|
|
2046
|
+
return {
|
|
2047
|
+
isError: true,
|
|
2048
|
+
envelope: toErr(
|
|
2049
|
+
{
|
|
2050
|
+
ok: false,
|
|
2051
|
+
error: {
|
|
2052
|
+
code: 'PR_BLOCKED_BY_ISSUES',
|
|
2053
|
+
status: mergeResult?.status,
|
|
2054
|
+
message: failure.message,
|
|
2055
|
+
details: mergeResult?.body ?? lastMergeError,
|
|
2056
|
+
retryable: false,
|
|
2057
|
+
},
|
|
2058
|
+
meta: mergeResult?.status ? { status: mergeResult.status } : undefined,
|
|
2059
|
+
},
|
|
2060
|
+
format,
|
|
2061
|
+
{ mergeResult, lastFailure },
|
|
2062
|
+
),
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
if (allowManualFinalize) {
|
|
2067
|
+
const prBeforeFinalize = await (api as any).repo.pr.view(number, callOptions)
|
|
2068
|
+
const prBody = isRecord(prBeforeFinalize.body) ? (prBeforeFinalize.body as Record<string, unknown>) : null
|
|
2069
|
+
const baseRef = prBody && isRecord(prBody.base) ? toTrimmedString((prBody.base as Record<string, unknown>).ref) : ''
|
|
2070
|
+
const headSha = prBody && isRecord(prBody.head) ? toTrimmedString((prBody.head as Record<string, unknown>).sha) : ''
|
|
2071
|
+
|
|
2072
|
+
const containmentCheck = manualMergeCommitSha
|
|
2073
|
+
? {
|
|
2074
|
+
attempted: false,
|
|
2075
|
+
ok: true,
|
|
2076
|
+
contained: true,
|
|
2077
|
+
reason: 'manualMergeCommitSha provided by caller',
|
|
2078
|
+
}
|
|
2079
|
+
: await checkPrHeadContainedInBase(owner, repo, baseRef, headSha)
|
|
2080
|
+
|
|
2081
|
+
const canFinalize = manualMergeCommitSha.length > 0 || containmentCheck.contained === true
|
|
2082
|
+
manualFinalize = {
|
|
2083
|
+
attempted: true,
|
|
2084
|
+
canFinalize,
|
|
2085
|
+
manualMergeCommitSha: manualMergeCommitSha || null,
|
|
2086
|
+
baseRef: baseRef || null,
|
|
2087
|
+
headSha: headSha || null,
|
|
2088
|
+
containmentCheck,
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
if (canFinalize) {
|
|
2092
|
+
const finalizeBody: Record<string, unknown> = {
|
|
2093
|
+
Do: 'manually-merged',
|
|
2094
|
+
}
|
|
2095
|
+
if (manualMergeCommitSha) {
|
|
2096
|
+
finalizeBody.MergeCommitID = manualMergeCommitSha
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const manualMergeResult = await (api as any).repo.pr.merge(number, {
|
|
2100
|
+
...callOptions,
|
|
2101
|
+
mergeMethod: 'manually-merged',
|
|
2102
|
+
requestBody: finalizeBody,
|
|
2103
|
+
})
|
|
2104
|
+
|
|
2105
|
+
if (manualMergeResult.ok && manualMergeResult.status < 400) {
|
|
2106
|
+
mergeResult = manualMergeResult
|
|
2107
|
+
manualFinalize = {
|
|
2108
|
+
...manualFinalize,
|
|
2109
|
+
mode: 'manually-merged',
|
|
2110
|
+
status: manualMergeResult.status,
|
|
2111
|
+
ok: true,
|
|
2112
|
+
}
|
|
2113
|
+
} else {
|
|
2114
|
+
const finalizeFailure = classifyMergeFailure(manualMergeResult.status, manualMergeResult.body)
|
|
2115
|
+
return {
|
|
2116
|
+
isError: true,
|
|
2117
|
+
envelope: toErr(
|
|
2118
|
+
{
|
|
2119
|
+
ok: false,
|
|
2120
|
+
error: {
|
|
2121
|
+
code: finalizeFailure.blockedByIssues ? 'PR_BLOCKED_BY_ISSUES' : 'MERGE_FINALIZE_FAILED',
|
|
2122
|
+
status: manualMergeResult.status,
|
|
2123
|
+
message: finalizeFailure.message,
|
|
2124
|
+
details: {
|
|
2125
|
+
merge: manualMergeResult.body,
|
|
2126
|
+
manualFinalize,
|
|
2127
|
+
},
|
|
2128
|
+
retryable: finalizeFailure.retryable,
|
|
2129
|
+
},
|
|
2130
|
+
meta: manualMergeResult.status ? { status: manualMergeResult.status } : undefined,
|
|
2131
|
+
},
|
|
2132
|
+
format,
|
|
2133
|
+
{ mergeResult, manualMergeResult, manualFinalize, prBeforeFinalize },
|
|
2134
|
+
),
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
if (!mergeResult || !mergeResult.ok || mergeResult.status >= 400) {
|
|
2141
|
+
return {
|
|
2142
|
+
isError: true,
|
|
2143
|
+
envelope: toErr(
|
|
2144
|
+
{
|
|
2145
|
+
ok: false,
|
|
2146
|
+
error: {
|
|
2147
|
+
code: failure.code,
|
|
2148
|
+
status: mergeResult?.status,
|
|
2149
|
+
message: failure.message,
|
|
2150
|
+
details: {
|
|
2151
|
+
merge: mergeResult?.body ?? lastMergeError,
|
|
2152
|
+
manualFinalize,
|
|
2153
|
+
},
|
|
2154
|
+
hint:
|
|
2155
|
+
allowManualFinalize && !manualMergeCommitSha
|
|
2156
|
+
? 'Set allowManualFinalize=true and provide manualMergeCommitSha when the PR is already merged outside the API.'
|
|
2157
|
+
: undefined,
|
|
2158
|
+
retryable: failure.retryable,
|
|
2159
|
+
},
|
|
2160
|
+
meta: mergeResult?.status ? { status: mergeResult.status } : undefined,
|
|
2161
|
+
},
|
|
2162
|
+
format,
|
|
2163
|
+
{ mergeResult, lastFailure, manualFinalize },
|
|
2164
|
+
),
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
lastFailure = null
|
|
2169
|
+
}
|
|
1517
2170
|
|
|
1518
2171
|
const views: GitServiceApiExecutionResult[] = []
|
|
1519
2172
|
let prAfter: GitServiceApiExecutionResult | null = null
|
|
@@ -1530,12 +2183,14 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1530
2183
|
await sleep(delayMs)
|
|
1531
2184
|
}
|
|
1532
2185
|
|
|
1533
|
-
const data = {
|
|
1534
|
-
merge: mergeResult.body,
|
|
1535
|
-
pr: prAfter ? prAfter.body : null,
|
|
1536
|
-
polled: views.length,
|
|
1537
|
-
|
|
1538
|
-
|
|
2186
|
+
const data = {
|
|
2187
|
+
merge: mergeResult.body,
|
|
2188
|
+
pr: prAfter ? prAfter.body : null,
|
|
2189
|
+
polled: views.length,
|
|
2190
|
+
...(manualFinalize ? { finalization: manualFinalize } : {}),
|
|
2191
|
+
}
|
|
2192
|
+
const full = controls.full
|
|
2193
|
+
|
|
1539
2194
|
const merged = prAfter && isRecord(prAfter.body) ? Boolean((prAfter.body as any).merged) : false
|
|
1540
2195
|
const state = prAfter && isRecord(prAfter.body) && typeof (prAfter.body as any).state === 'string' ? String((prAfter.body as any).state) : ''
|
|
1541
2196
|
|
|
@@ -1543,23 +2198,23 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1543
2198
|
return {
|
|
1544
2199
|
isError: true,
|
|
1545
2200
|
envelope: toErr(
|
|
1546
|
-
{
|
|
1547
|
-
ok: false,
|
|
1548
|
-
error: {
|
|
1549
|
-
code: 'MERGE_VERIFY_FAILED',
|
|
1550
|
-
message: 'Merge request succeeded, but PR state did not transition to merged/closed within polling window.',
|
|
1551
|
-
details: { merged, state },
|
|
1552
|
-
retryable: true,
|
|
1553
|
-
},
|
|
1554
|
-
},
|
|
1555
|
-
format,
|
|
1556
|
-
{ mergeResult, prAfter, views },
|
|
1557
|
-
),
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
return { isError: false, envelope: toOk(data, format, { mergeResult, prAfter, views }, 200) }
|
|
1562
|
-
}
|
|
2201
|
+
{
|
|
2202
|
+
ok: false,
|
|
2203
|
+
error: {
|
|
2204
|
+
code: 'MERGE_VERIFY_FAILED',
|
|
2205
|
+
message: 'Merge request succeeded, but PR state did not transition to merged/closed within polling window.',
|
|
2206
|
+
details: { merged, state },
|
|
2207
|
+
retryable: true,
|
|
2208
|
+
},
|
|
2209
|
+
},
|
|
2210
|
+
format,
|
|
2211
|
+
{ mergeResult, prAfter, views, manualFinalize },
|
|
2212
|
+
),
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
return { isError: false, envelope: toOk(data, format, { mergeResult, prAfter, views, manualFinalize }, 200, full) }
|
|
2217
|
+
}
|
|
1563
2218
|
|
|
1564
2219
|
return {
|
|
1565
2220
|
isError: true,
|
|
@@ -1606,12 +2261,13 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1606
2261
|
if (customToolMetaByName.has(tool)) {
|
|
1607
2262
|
const mergedPayload = { args, options }
|
|
1608
2263
|
const callControls = extractMcpControls(mergedPayload)
|
|
1609
|
-
const effectiveControls = {
|
|
1610
|
-
...callControls,
|
|
1611
|
-
format: callControls.format ?? batchControls.format ?? null,
|
|
1612
|
-
fields: callControls.fields ?? batchControls.fields ?? null,
|
|
1613
|
-
validateOnly: callControls.validateOnly || batchControls.validateOnly,
|
|
1614
|
-
|
|
2264
|
+
const effectiveControls = {
|
|
2265
|
+
...callControls,
|
|
2266
|
+
format: callControls.format ?? batchControls.format ?? null,
|
|
2267
|
+
fields: callControls.fields ?? batchControls.fields ?? null,
|
|
2268
|
+
validateOnly: callControls.validateOnly || batchControls.validateOnly,
|
|
2269
|
+
full: callControls.full || batchControls.full,
|
|
2270
|
+
}
|
|
1615
2271
|
|
|
1616
2272
|
try {
|
|
1617
2273
|
const { isError, envelope } = await invokeCustomTool(tool, mergedPayload, effectiveControls)
|
|
@@ -1653,13 +2309,14 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1653
2309
|
|
|
1654
2310
|
try {
|
|
1655
2311
|
const mergedPayload = { args, options }
|
|
1656
|
-
const callControls = extractMcpControls(mergedPayload)
|
|
1657
|
-
const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
|
|
1658
|
-
const effectiveFields = callControls.fields ?? batchControls.fields ?? null
|
|
1659
|
-
const validateOnly = callControls.validateOnly || batchControls.validateOnly
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
2312
|
+
const callControls = extractMcpControls(mergedPayload)
|
|
2313
|
+
const effectiveFormat = callControls.format ?? batchControls.format ?? 'terse'
|
|
2314
|
+
const effectiveFields = callControls.fields ?? batchControls.fields ?? null
|
|
2315
|
+
const validateOnly = callControls.validateOnly || batchControls.validateOnly
|
|
2316
|
+
const full = callControls.full || batchControls.full
|
|
2317
|
+
|
|
2318
|
+
if (validateOnly) {
|
|
2319
|
+
const normalized = normalizePayloadWithContext(toolDefinition, mergedPayload)
|
|
1663
2320
|
const envelope: McpTerseOk = {
|
|
1664
2321
|
ok: true,
|
|
1665
2322
|
data: {
|
|
@@ -1688,13 +2345,14 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1688
2345
|
? invokeLogsForRunTailTool(toolDefinition, normalizedPayload)
|
|
1689
2346
|
: isArtifactsByRunTool(toolDefinition.name)
|
|
1690
2347
|
? invokeArtifactsByRunTool(toolDefinition, normalizedPayload)
|
|
1691
|
-
: invokeTool(toolDefinition, normalizedPayload))
|
|
1692
|
-
|
|
1693
|
-
const selected = effectiveFields ? { ...data, body: applyFieldSelection(data.body, effectiveFields) } : data
|
|
1694
|
-
const
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
2348
|
+
: invokeTool(toolDefinition, normalizedPayload))
|
|
2349
|
+
|
|
2350
|
+
const selected = effectiveFields ? { ...data, body: applyFieldSelection(data.body, effectiveFields) } : data
|
|
2351
|
+
const shouldReturnFull = full || Boolean(effectiveFields)
|
|
2352
|
+
const { isError, envelope } = toMcpEnvelope(selected, effectiveFormat, shouldReturnFull)
|
|
2353
|
+
return {
|
|
2354
|
+
index,
|
|
2355
|
+
tool,
|
|
1698
2356
|
isError,
|
|
1699
2357
|
...(envelope as McpTerseOk | McpTerseErr),
|
|
1700
2358
|
} as BatchResult
|
|
@@ -1746,16 +2404,17 @@ export const createGitMcpServer = (options: GitMcpServerOptions = {}): GitMcpSer
|
|
|
1746
2404
|
const normalized = normalizePayloadWithContext(tool, payload)
|
|
1747
2405
|
const normalizedPayload = { args: normalized.args, options: normalized.options }
|
|
1748
2406
|
|
|
1749
|
-
const result = await (isLogsForRunTailTool(tool.name)
|
|
1750
|
-
? invokeLogsForRunTailTool(tool, normalizedPayload)
|
|
1751
|
-
: isArtifactsByRunTool(tool.name)
|
|
1752
|
-
? invokeArtifactsByRunTool(tool, normalizedPayload)
|
|
1753
|
-
: invokeTool(tool, normalizedPayload))
|
|
1754
|
-
|
|
1755
|
-
const selected = controls.fields ? { ...result, body: applyFieldSelection(result.body, controls.fields) } : result
|
|
1756
|
-
const
|
|
1757
|
-
|
|
1758
|
-
|
|
2407
|
+
const result = await (isLogsForRunTailTool(tool.name)
|
|
2408
|
+
? invokeLogsForRunTailTool(tool, normalizedPayload)
|
|
2409
|
+
: isArtifactsByRunTool(tool.name)
|
|
2410
|
+
? invokeArtifactsByRunTool(tool, normalizedPayload)
|
|
2411
|
+
: invokeTool(tool, normalizedPayload))
|
|
2412
|
+
|
|
2413
|
+
const selected = controls.fields ? { ...result, body: applyFieldSelection(result.body, controls.fields) } : result
|
|
2414
|
+
const shouldReturnFull = controls.full || Boolean(controls.fields)
|
|
2415
|
+
const { isError, envelope } = toMcpEnvelope(selected, controls.format ?? 'terse', shouldReturnFull)
|
|
2416
|
+
return { isError, text: JSON.stringify(envelope) }
|
|
2417
|
+
}
|
|
1759
2418
|
|
|
1760
2419
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1761
2420
|
tools: listTools(),
|