diff --git a/.gitignore b/.gitignore index beca273..639f24e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,8 @@ build .env.local .env.test coverage/ -*.log \ No newline at end of file +*.log + +# ai +.opencode* +.aider* diff --git a/OpenCode.md b/OpenCode.md new file mode 100644 index 0000000..3b84f18 --- /dev/null +++ b/OpenCode.md @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index b585b68..253a814 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/index.ts b/index.ts index 71dd167..370e29d 100644 --- a/index.ts +++ b/index.ts @@ -167,6 +167,7 @@ import { ListWikiPagesOptions, ListWikiPagesSchema, MarkdownUploadSchema, + DownloadAttachmentSchema, MergeMergeRequestSchema, type MergeRequestThreadPosition, type MergeRequestThreadPositionCreate, @@ -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 @@ -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 @@ -4054,6 +4061,34 @@ async function markdownUpload(projectId: string, filePath: string): Promise { + 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 @@ -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}`); } diff --git a/schemas.ts b/schemas.ts index 53fa83b..370f2f7 100644 --- a/schemas.ts +++ b/schemas.ts @@ -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(),