Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ build
.env.local
.env.test
coverage/
*.log
*.log

# ai
.opencode*
.aider*
24 changes: 24 additions & 0 deletions OpenCode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# OpenCode Configuration

## Build/Test Commands
- `npm run build` - Compile TypeScript to build/
- `npm run dev` - Build and run the server
- `npm run watch` - Watch mode compilation
- `npm test` - Run API validation tests
- `npm run test:integration` - Run integration tests
- `npm run test:server` - Build and test all transport servers
- `npm run lint` - ESLint check
- `npm run lint:fix` - ESLint auto-fix
- `npm run format` - Prettier format all files
- `npm run format:check` - Check formatting

## Code Style Guidelines
- **Language**: TypeScript with strict mode enabled
- **Module System**: ES modules (type: "module" in package.json)
- **Imports**: Use .js extensions for local imports, named imports preferred
- **Types**: Explicit typing with Zod schemas, avoid `any` types
- **Naming**: camelCase for variables/functions, PascalCase for types/schemas
- **Error Handling**: Use structured error responses with proper HTTP status codes
- **Logging**: Use pino logger with structured logging
- **Schemas**: Define Zod schemas for all API inputs/outputs, export both type and schema
- **File Structure**: Separate schemas in schemas.ts, custom utilities in customSchemas.ts
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ $ sh scripts/image_push.sh docker_user_name
`delete_issue` - Delete an issue from a GitLab project
`delete_issue_link` - Delete an issue link
`delete_draft_note` - Delete a draft note
`download_attachment` - Download an uploaded file from a GitLab project by secret and filename
`create_wiki_page` - Create a new wiki page in a GitLab project
`create_repository` - Create a new GitLab project
`create_pipeline` - Create a new pipeline for a branch or tag
Expand Down
43 changes: 43 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ import {
ListWikiPagesOptions,
ListWikiPagesSchema,
MarkdownUploadSchema,
DownloadAttachmentSchema,
MergeMergeRequestSchema,
type MergeRequestThreadPosition,
type MergeRequestThreadPositionCreate,
Expand Down Expand Up @@ -810,6 +811,11 @@ const allTools = [
description: "Upload a file to a GitLab project for use in markdown content",
inputSchema: zodToJsonSchema(MarkdownUploadSchema),
},
{
name: "download_attachment",
description: "Download an uploaded file from a GitLab project by secret and filename",
inputSchema: zodToJsonSchema(DownloadAttachmentSchema),
},
];

// Define which tools are read-only
Expand Down Expand Up @@ -856,6 +862,7 @@ const readOnlyTools = [
"get_commit_diff",
"list_group_iterations",
"get_group_iteration",
"download_attachment",
];

// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
Expand Down Expand Up @@ -4054,6 +4061,34 @@ async function markdownUpload(projectId: string, filePath: string): Promise<GitL
return GitLabMarkdownUploadSchema.parse(data);
}

async function downloadAttachment(projectId: string, secret: string, filename: string, localPath?: string): Promise<string> {
const effectiveProjectId = getEffectiveProjectId(projectId);

const url = new URL(
`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`
);

const response = await fetch(url.toString(), {
method: "GET",
headers: DEFAULT_HEADERS,
});

if (!response.ok) {
await handleGitLabError(response);
}

// Get the file content as buffer
const buffer = await response.arrayBuffer();

// Determine the save path
const savePath = localPath ? path.join(localPath, filename) : filename;

// Write the file to disk
fs.writeFileSync(savePath, Buffer.from(buffer));

return savePath;
}

server.setRequestHandler(ListToolsRequestSchema, async () => {
// Apply read-only filter first
const tools0 = GITLAB_READ_ONLY_MODE
Expand Down Expand Up @@ -5133,6 +5168,14 @@ server.setRequestHandler(CallToolRequestSchema, async request => {
};
}

case "download_attachment": {
const args = DownloadAttachmentSchema.parse(request.params.arguments);
const filePath = await downloadAttachment(args.project_id, args.secret, args.filename, args.local_path);
return {
content: [{ type: "text", text: JSON.stringify({ success: true, file_path: filePath }, null, 2) }],
};
}

default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
Expand Down
7 changes: 7 additions & 0 deletions schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,13 @@ export const MarkdownUploadSchema = z.object({
file_path: z.string().describe("Path to the file to upload"),
});

export const DownloadAttachmentSchema = z.object({
project_id: z.string().describe("Project ID or URL-encoded path of the project"),
secret: z.string().describe("The 32-character secret of the upload"),
filename: z.string().describe("The filename of the upload"),
local_path: z.string().optional().describe("Local path to save the file (optional, defaults to current directory)"),
});

export const GroupIteration = z.object({
id: z.coerce.string(),
iid: z.coerce.string(),
Expand Down