diff --git a/activate-fork.sh b/activate-fork.sh new file mode 100644 index 00000000..49945d83 --- /dev/null +++ b/activate-fork.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Point Claude Code's `gitlab` MCP server at the hodyhq fork (binary + update support). +# RUN THIS WITH CLAUDE CODE FULLY QUIT, then relaunch โ€” the running app owns +# ~/.claude.json and will clobber the edit otherwise. +# +# bash ~/.dev/Projects/gitlab-mcp/activate-fork.sh # activate fork +# bash ~/.dev/Projects/gitlab-mcp/activate-fork.sh --revert # back to upstream npx +set -euo pipefail + +CFG="$HOME/.claude.json" +BUILD="/home/hody/.dev/Projects/gitlab-mcp/build/index.js" +MODE="${1:-activate}" + +[ -f "$CFG" ] || { echo "no $CFG"; exit 1; } +[ "$MODE" = "activate" ] && [ ! -f "$BUILD" ] && { echo "build missing: $BUILD (run: cd ~/.dev/Projects/gitlab-mcp && npm install && npm run build)"; exit 1; } + +cp "$CFG" "$CFG.bak.$(date +%s)" + +python3 - "$CFG" "$BUILD" "$MODE" <<'PY' +import json, sys +cfg, build, mode = sys.argv[1], sys.argv[2], sys.argv[3] +d = json.load(open(cfg)); n = 0 +def walk(o): + global n + if isinstance(o, dict): + ms = o.get("mcpServers") + if isinstance(ms, dict) and "gitlab" in ms: + g = ms["gitlab"] + if mode == "revert": + g["command"] = "npx"; g["args"] = ["-y", "@zereight/mcp-gitlab"] + else: + g["command"] = "node"; g["args"] = [build] + n += 1 + for v in o.values(): walk(v) +walk(d) +json.dump(d, open(cfg, "w"), indent=2); open(cfg, "a").write("\n") +print(f"{'reverted' if mode=='revert' else 'activated fork on'} {n} gitlab server(s). Backup saved. Now relaunch Claude Code.") +PY diff --git a/index.ts b/index.ts index 97e417b8..df642761 100644 --- a/index.ts +++ b/index.ts @@ -4464,7 +4464,8 @@ async function createOrUpdateFile( branch: string, previousPath?: string, last_commit_id?: string, - commit_id?: string + commit_id?: string, + encoding?: "text" | "base64" ): Promise { projectId = decodeURIComponent(projectId); // Decode project ID const encodedPath = encodeURIComponent(filePath); @@ -4474,9 +4475,11 @@ async function createOrUpdateFile( const body: Record = { branch, - content: encodeRepoFilePayloadContent(content), + // An explicit per-call encoding wins (text or base64, content sent as-is); + // when omitted, fall back to the global text/base64 convenience toggle. + content: encoding !== undefined ? content : encodeRepoFilePayloadContent(content), commit_message: commitMessage, - encoding: GITLAB_REPO_FILE_ENCODING, + encoding: encoding ?? GITLAB_REPO_FILE_ENCODING, ...(previousPath ? { previous_path: previousPath } : {}), }; @@ -4558,12 +4561,33 @@ async function createCommit( body: JSON.stringify({ branch, commit_message: message, - actions: actions.map(action => ({ - action: "create", - file_path: action.path, - content: encodeRepoFilePayloadContent(action.content), - encoding: GITLAB_REPO_FILE_ENCODING, - })), + actions: actions.map(action => { + // Honour per-file action (create/update/delete/move) instead of + // hardcoding "create", and pass base64 content through untouched so + // binary files commit correctly. + const act = action.action ?? "create"; + const entry: Record = { action: act, file_path: action.path }; + if (act === "move" && action.previous_path) { + entry.previous_path = action.previous_path; + } + if (act !== "delete") { + const hasContent = action.content !== undefined; + // A `move` with no content must keep the original file's content โ€” omit the + // content field entirely rather than blanking it with "". + if (!(act === "move" && !hasContent)) { + if (action.encoding !== undefined) { + // explicit text or base64 โ€” send content as-is + entry.content = action.content ?? ""; + entry.encoding = action.encoding; + } else { + // fall back to the global text/base64 convenience toggle + entry.content = encodeRepoFilePayloadContent(action.content ?? ""); + entry.encoding = GITLAB_REPO_FILE_ENCODING; + } + } + } + return entry; + }), }), }); @@ -9120,7 +9144,8 @@ async function handleToolCall(params: any) { args.branch, args.previous_path, args.last_commit_id, - args.commit_id + args.commit_id, + args.encoding ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -9133,7 +9158,13 @@ async function handleToolCall(params: any) { args.project_id, args.commit_message, args.branch, - args.files.map(f => ({ path: f.file_path, content: f.content })) + args.files.map(f => ({ + path: f.file_path, + content: f.content, + action: f.action, + encoding: f.encoding, + previous_path: f.previous_path, + })) ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/schemas.ts b/schemas.ts index 593914a7..ee46ef4d 100644 --- a/schemas.ts +++ b/schemas.ts @@ -822,7 +822,12 @@ export const GitLabContentSchema = z.union([ // Operation schemas export const FileOperationSchema = z.object({ path: z.string(), - content: z.string(), + content: z.string().optional(), + // Per-file action + encoding so push_files can update/delete/move and carry + // binary files (base64). Defaults preserve existing behaviour. + action: z.enum(["create", "update", "delete", "move"]).optional(), + encoding: z.enum(["text", "base64"]).optional(), + previous_path: z.string().optional(), }); // Tree and commit schemas @@ -1473,6 +1478,10 @@ export const CreateOrUpdateFileSchema = ProjectParamsSchema.extend({ previous_path: z.string().optional().describe("Path of the file to move/rename"), last_commit_id: z.string().optional().describe("Last known file commit ID"), commit_id: z.string().optional().describe("Current file commit ID (for update operations)"), + encoding: z + .enum(["text", "base64"]) + .optional() + .describe("Content encoding. Use 'base64' for binary files (content must already be base64-encoded); defaults to text."), }); export const SearchRepositoriesSchema = z @@ -1544,11 +1553,20 @@ export const PushFilesSchema = ProjectParamsSchema.extend({ files: z .array( z.object({ - file_path: z.string().describe("Path where to create the file"), - content: z.string().describe("Content of the file"), + file_path: z.string().describe("Path of the file in the repo"), + content: z.string().optional().describe("File content (base64-encoded when encoding='base64'; omit for action='delete')"), + action: z + .enum(["create", "update", "delete", "move"]) + .optional() + .describe("Commit action for this file. Defaults to 'create'."), + encoding: z + .enum(["text", "base64"]) + .optional() + .describe("Use 'base64' for binary files (content must already be base64-encoded); defaults to text."), + previous_path: z.string().optional().describe("Source path when action='move'"), }) ) - .describe("Array of files to push"), + .describe("Array of files to commit in a single commit (create/update/delete/move, text or binary)"), commit_message: z.string().describe("Commit message"), }); diff --git a/test/schema-tests.ts b/test/schema-tests.ts index 4c12f19b..5344eb88 100644 --- a/test/schema-tests.ts +++ b/test/schema-tests.ts @@ -2,6 +2,8 @@ import { GetFileContentsSchema, + PushFilesSchema, + CreateOrUpdateFileSchema, GitLabFileContentSchema, GitLabRepositorySchema, CreatePipelineSchema, @@ -156,6 +158,109 @@ function runGetFileContentsSchemaTests(): { passed: number; failed: number } { return { passed, failed }; } +function runPushFilesSchemaTests(): { passed: number; failed: number } { + console.log('๐Ÿงช Testing PushFilesSchema / CreateOrUpdateFileSchema (per-file action + encoding)...'); + + interface Case { + name: string; + input: Record; + shouldFail?: boolean; + check?: (data: any) => boolean; + } + + const base = { project_id: '1', branch: 'main', commit_message: 'm' }; + const cases: Case[] = [ + { + name: 'schema:push_files:plain-text-default', + input: { ...base, files: [{ file_path: 'a.txt', content: 'hi' }] }, + check: d => d.files[0].action === undefined && d.files[0].encoding === undefined, + }, + { + name: 'schema:push_files:binary-base64', + input: { ...base, files: [{ file_path: 'logo.png', content: 'aGk=', encoding: 'base64' }] }, + check: d => d.files[0].encoding === 'base64', + }, + { + name: 'schema:push_files:action-update', + input: { ...base, files: [{ file_path: 'a.txt', content: 'new', action: 'update' }] }, + check: d => d.files[0].action === 'update', + }, + { + name: 'schema:push_files:action-delete-content-optional', + input: { ...base, files: [{ file_path: 'a.txt', action: 'delete' }] }, + check: d => d.files[0].action === 'delete' && d.files[0].content === undefined, + }, + { + name: 'schema:push_files:action-move-previous-path', + input: { ...base, files: [{ file_path: 'b.txt', action: 'move', previous_path: 'a.txt' }] }, + check: d => d.files[0].action === 'move' && d.files[0].previous_path === 'a.txt', + }, + { + // schema permits move without previous_path (GitLab's API validates it); + // this documents that contract. + name: 'schema:push_files:move-without-previous-path-parses', + input: { ...base, files: [{ file_path: 'b.txt', action: 'move' }] }, + check: d => d.files[0].action === 'move' && d.files[0].previous_path === undefined, + }, + { + name: 'schema:push_files:explicit-text-encoding', + input: { ...base, files: [{ file_path: 'a.txt', content: 'hi', encoding: 'text' }] }, + check: d => d.files[0].encoding === 'text', + }, + { + name: 'schema:push_files:reject-bad-action', + input: { ...base, files: [{ file_path: 'a.txt', content: 'x', action: 'frobnicate' }] }, + shouldFail: true, + }, + { + name: 'schema:push_files:reject-bad-encoding', + input: { ...base, files: [{ file_path: 'a.txt', content: 'x', encoding: 'rot13' }] }, + shouldFail: true, + }, + { + name: 'schema:create_or_update_file:encoding-base64', + input: { project_id: '1', file_path: 'logo.png', content: 'aGk=', commit_message: 'm', branch: 'main', encoding: 'base64' }, + check: d => d.encoding === 'base64', + }, + ]; + + let passed = 0; + let failed = 0; + + cases.forEach(testCase => { + const result: TestResult = { name: testCase.name, status: 'failed' }; + const schema = testCase.name.startsWith('schema:create_or_update_file') + ? CreateOrUpdateFileSchema + : PushFilesSchema; + const parsed = schema.safeParse(testCase.input); + + if (testCase.shouldFail) { + result.status = parsed.success ? 'failed' : 'passed'; + if (parsed.success) result.error = 'Expected schema validation to fail'; + } else if (parsed.success) { + if (!testCase.check || testCase.check(parsed.data)) { + result.status = 'passed'; + } else { + result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`; + } + } else { + result.error = parsed.error?.message || 'Schema validation failed'; + } + + if (result.status === 'passed') { + passed++; + console.log(`โœ… ${result.name}`); + } else { + failed++; + console.log(`โŒ ${result.name}: ${result.error}`); + } + }); + + console.log(`\nResults: ${passed} passed, ${failed} failed`); + + return { passed, failed }; +} + function runGitLabFileContentSchemaTests(): { passed: number; failed: number } { console.log('\n๐Ÿงช Testing GitLabFileContentSchema...'); @@ -1658,6 +1763,7 @@ function runGitLabDependencyProxyBlobSchemaTests(): { passed: number; failed: nu if (import.meta.url === `file://${process.argv[1]}`) { const getFileContentsResult = runGetFileContentsSchemaTests(); + const pushFilesResult = runPushFilesSchemaTests(); const fileContentResult = runGitLabFileContentSchemaTests(); const createPipelineResult = runCreatePipelineSchemaTests(); const commitStatusResult = runCommitStatusSchemaTests(); @@ -1677,8 +1783,8 @@ if (import.meta.url === `file://${process.argv[1]}`) { const dependencyProxyResult = runGitLabDependencyProxySchemaTests(); const dependencyProxyBlobResult = runGitLabDependencyProxyBlobSchemaTests(); - const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + approvedByUsernamesResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed + dependencyProxyResult.passed + dependencyProxyBlobResult.passed; - const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + approvedByUsernamesResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed + dependencyProxyResult.failed + dependencyProxyBlobResult.failed; + const totalPassed = getFileContentsResult.passed + pushFilesResult.passed + fileContentResult.passed + createPipelineResult.passed + commitStatusResult.passed + createIssueNoteResult.passed + getMergeRequestResult.passed + listMergeRequestPipelinesResult.passed + gitLabMergeRequestResult.passed + emojiReactionResult.passed + repositorySchemaResult.passed + labelsCoercionResult.passed + approvedByUsernamesResult.passed + listLabelsResult.passed + treeItemResult.passed + repositoryTreeResult.passed + gitLabUserFullResult.passed + gitLabMarkdownUploadResult.passed + dependencyProxyResult.passed + dependencyProxyBlobResult.passed; + const totalFailed = getFileContentsResult.failed + pushFilesResult.failed + fileContentResult.failed + createPipelineResult.failed + commitStatusResult.failed + createIssueNoteResult.failed + getMergeRequestResult.failed + listMergeRequestPipelinesResult.failed + gitLabMergeRequestResult.failed + emojiReactionResult.failed + repositorySchemaResult.failed + labelsCoercionResult.failed + approvedByUsernamesResult.failed + listLabelsResult.failed + treeItemResult.failed + repositoryTreeResult.failed + gitLabUserFullResult.failed + gitLabMarkdownUploadResult.failed + dependencyProxyResult.failed + dependencyProxyBlobResult.failed; console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);