Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitLabCreateUpdateFileResponse> {
projectId = decodeURIComponent(projectId); // Decode project ID
const encodedPath = encodeURIComponent(filePath);
Expand All @@ -4474,9 +4475,11 @@ async function createOrUpdateFile(

const body: Record<string, any> = {
branch,
content: encodeRepoFilePayloadContent(content),
// base64 content is passed through untouched (binary-safe); otherwise fall
// back to the global text/base64 convenience toggle.
content: encoding === "base64" ? content : encodeRepoFilePayloadContent(content),
commit_message: commitMessage,
encoding: GITLAB_REPO_FILE_ENCODING,
encoding: encoding === "base64" ? "base64" : GITLAB_REPO_FILE_ENCODING,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
...(previousPath ? { previous_path: previousPath } : {}),
};

Expand Down Expand Up @@ -4558,12 +4561,26 @@ 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<string, any> = { action: act, file_path: action.path };
if (act === "move" && action.previous_path) {
entry.previous_path = action.previous_path;
}
if (act !== "delete") {
if (action.encoding === "base64") {
entry.content = action.content ?? ""; // already base64 (binary-safe)
entry.encoding = "base64";
} else {
entry.content = encodeRepoFilePayloadContent(action.content ?? "");
entry.encoding = GITLAB_REPO_FILE_ENCODING;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
return entry;
}),
}),
});

Expand Down Expand Up @@ -9120,7 +9137,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) }],
Expand All @@ -9133,7 +9151,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) }],
Expand Down
26 changes: 22 additions & 4 deletions schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Comment on lines 823 to 831

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Schema extension for per-file actions and encoding looks good.

The additions to FileOperationSchema correctly support the new functionality:

  • Making content optional enables delete operations
  • The action enum covers all commit operations (create/update/delete/move)
  • The encoding enum enables binary file handling via base64
  • previous_path supports move/rename operations

All fields are optional with sensible defaults, maintaining backward compatibility as stated in the PR objectives.

💡 Optional: Consider adding schema-level validation for field combinations

While the current implementation likely validates at the handler level (tests confirm this), adding Zod refinements would catch invalid combinations earlier and make the schema self-documenting:

-});
+}).refine(
+  (data) => {
+    // Require previous_path when action is 'move'
+    if (data.action === 'move' && !data.previous_path) {
+      return false;
+    }
+    return true;
+  },
+  { message: "action='move' requires previous_path to be specified" }
+);

This would provide clearer error messages at the schema validation stage rather than deferring to API-level errors.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(),
});
export const FileOperationSchema = z.object({
path: 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(),
}).refine(
(data) => {
// Require previous_path when action is 'move'
if (data.action === 'move' && !data.previous_path) {
return false;
}
return true;
},
{ message: "action='move' requires previous_path to be specified" }
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@schemas.ts` around lines 823 - 831, Add Zod refinements to the
FileOperationSchema to validate field combinations based on the action type.
This will catch invalid combinations at schema validation time rather than
deferring to handler-level validation. Use .refine() or .superRefine() on the
FileOperationSchema object to add constraints such as: when action is "delete",
content should not be present; when action is "move", previous_path must be
provided; when action is "create", content should likely be required; and when
action is "update", previous_path should not be present. This makes the schema
self-documenting and provides clearer error messages during validation.


// Tree and commit schemas
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
});

Expand Down
98 changes: 96 additions & 2 deletions test/schema-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import {
GetFileContentsSchema,
PushFilesSchema,
CreateOrUpdateFileSchema,
GitLabFileContentSchema,
GitLabRepositorySchema,
CreatePipelineSchema,
Expand Down Expand Up @@ -156,6 +158,97 @@ 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<string, any>;
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',
},
{
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 };
}
Comment on lines +161 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding tests for move action validation and content requirements.

The current test suite covers core scenarios well, but would benefit from a few additional edge cases:

  1. Move without previous_path: Add a test to verify whether move action fails or succeeds when previous_path is omitted. This clarifies the schema contract.

  2. Content requirement for create/update: Add tests verifying that content is required (or at least accepted) for create and update actions, not just delete.

  3. Explicit action 'create': Test explicitly setting action: 'create' rather than relying only on the default behavior.

These tests would strengthen validation coverage and help catch schema definition bugs.

✨ Example additional test cases
     {
       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',
     },
+    {
+      name: 'schema:push_files:action-move-requires-previous-path',
+      input: { ...base, files: [{ file_path: 'b.txt', action: 'move' }] },
+      shouldFail: true,  // or false, depending on schema design
+    },
+    {
+      name: 'schema:push_files:action-create-explicit',
+      input: { ...base, files: [{ file_path: 'a.txt', content: 'hi', action: 'create' }] },
+      check: d => d.files[0].action === 'create',
+    },
+    {
+      name: 'schema:push_files:create-requires-content',
+      input: { ...base, files: [{ file_path: 'a.txt', action: 'create' }] },
+      shouldFail: true,  // verify content is required for create
+    },
     {
       name: 'schema:push_files:reject-bad-action',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/schema-tests.ts` around lines 161 - 250, The test suite in the
runPushFilesSchemaTests() function lacks coverage for important edge cases
around move actions and content requirements. Add three new test cases to the
cases array: first, a test case for move action without previous_path to verify
the schema properly rejects or accepts this scenario; second, test cases that
verify content is required or properly handled for create and update actions
(not just delete); and third, a test case that explicitly sets action to
'create' rather than relying on default behavior. Each new test case should
follow the existing Case interface pattern with appropriate name, input,
shouldFail flag, and check function as needed.


function runGitLabFileContentSchemaTests(): { passed: number; failed: number } {
console.log('\n🧪 Testing GitLabFileContentSchema...');

Expand Down Expand Up @@ -1658,6 +1751,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();
Expand All @@ -1677,8 +1771,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`);

Expand Down