From b3f34e2760db6cd08908552bb695f692b448b712 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:23:11 +0000 Subject: [PATCH 1/6] Initial plan From 93fb648f0b19e92f0b593975d0165a01bf98fbef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:30:26 +0000 Subject: [PATCH 2/6] Add Git Bridge API v0 controller and routes Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- .../GitBridge/GitBridgeApiController.mjs | 297 ++++++++++++++++++ services/web/app/src/router.mjs | 23 ++ 2 files changed, 320 insertions(+) create mode 100644 services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs diff --git a/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs new file mode 100644 index 00000000000..2d18eef42e8 --- /dev/null +++ b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs @@ -0,0 +1,297 @@ +// Controller for API v0 endpoints used by git-bridge +// These endpoints provide git-bridge with access to project data, versions, and snapshots + +import { callbackify } from 'node:util' +import { expressify } from '@overleaf/promise-utils' +import logger from '@overleaf/logger' +import { fetchJson } from '@overleaf/fetch-utils' +import settings from '@overleaf/settings' +import ProjectGetter from '../Project/ProjectGetter.mjs' +import HistoryManager from '../History/HistoryManager.mjs' +import UserGetter from '../User/UserGetter.js' +import { Snapshot } from 'overleaf-editor-core' +import Errors from '../Errors/Errors.js' + +/** + * GET /api/v0/docs/:project_id + * Returns the latest version info for a project + */ +async function getDoc(req, res, next) { + const projectId = req.params.project_id + + try { + // Get project + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + owner_ref: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // Get latest history version + const historyId = await HistoryManager.promises.getHistoryId(projectId) + const latestHistory = await HistoryManager.promises.getLatestHistory( + projectId + ) + + if (!latestHistory || !latestHistory.updates) { + // No history yet, return minimal response + return res.json({ + latestVerId: 0, + latestVerAt: new Date().toISOString(), + latestVerBy: null, + }) + } + + // Get the most recent update + const updates = latestHistory.updates + const latestUpdate = updates[0] // updates are sorted newest first + + let latestVerBy = null + if (latestUpdate.meta && latestUpdate.meta.users) { + const userId = latestUpdate.meta.users[0] + if (userId) { + const user = await UserGetter.promises.getUser(userId, { + email: 1, + first_name: 1, + last_name: 1, + }) + if (user) { + const name = [user.first_name, user.last_name] + .filter(Boolean) + .join(' ') + latestVerBy = { + email: user.email, + name: name || user.email, + } + } + } + } + + const response = { + latestVerId: latestUpdate.toV || 0, + latestVerAt: latestUpdate.meta.end_ts + ? new Date(latestUpdate.meta.end_ts).toISOString() + : new Date().toISOString(), + latestVerBy, + } + + res.json(response) + } catch (err) { + logger.error({ err, projectId }, 'Error getting doc info') + next(err) + } +} + +/** + * GET /api/v0/docs/:project_id/saved_vers + * Returns the list of saved versions (labels) for a project + */ +async function getSavedVers(req, res, next) { + const projectId = req.params.project_id + + try { + // Get project to verify it exists + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // Get labels from project-history service + let labels = await fetchJson( + `${settings.apis.project_history.url}/project/${projectId}/labels` + ) + + // Enrich labels with user information + labels = await enrichLabels(labels) + + // Transform to git-bridge format + const savedVers = labels.map(label => ({ + versionId: label.version, + comment: label.comment, + user: { + email: label.user_display_name || label.user?.email || 'unknown', + name: label.user_display_name || label.user?.name || 'unknown', + }, + createdAt: label.created_at, + })) + + res.json(savedVers) + } catch (err) { + logger.error({ err, projectId }, 'Error getting saved versions') + next(err) + } +} + +/** + * GET /api/v0/docs/:project_id/snapshots/:version + * Returns the snapshot (file contents) for a specific version + */ +async function getSnapshot(req, res, next) { + const projectId = req.params.project_id + const version = parseInt(req.params.version, 10) + + try { + // Get project to verify it exists + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // Get snapshot content from history service + const snapshotRaw = await HistoryManager.promises.getContentAtVersion( + projectId, + version + ) + + const snapshot = Snapshot.fromRaw(snapshotRaw) + + // Build response in git-bridge format + const srcs = [] + const atts = [] + + // Process all files in the snapshot + const files = snapshot.getFileMap() + for (const [pathname, file] of files) { + if (file.isEditable()) { + // Text file - include content directly + srcs.push({ + content: file.getContent(), + path: pathname, + }) + } else { + // Binary file - provide URL to download + const hash = file.getHash() + const historyId = await HistoryManager.promises.getHistoryId(projectId) + + // Build URL to blob endpoint + const blobUrl = `${settings.siteUrl}/project/${projectId}/blob/${hash}` + + atts.push({ + url: blobUrl, + path: pathname, + }) + } + } + + const response = { + srcs, + atts, + } + + res.json(response) + } catch (err) { + if (err instanceof Errors.NotFoundError) { + return res.status(404).json({ message: 'Version not found' }) + } + logger.error({ err, projectId, version }, 'Error getting snapshot') + next(err) + } +} + +/** + * POST /api/v0/docs/:project_id/snapshots + * Receives a push from git-bridge with file changes + */ +async function postSnapshot(req, res, next) { + const projectId = req.params.project_id + const { latestVerId, files, postbackUrl } = req.body + + try { + // Get project to verify it exists + const project = await ProjectGetter.promises.getProject(projectId, { + name: 1, + }) + + if (!project) { + return res.status(404).json({ message: 'Project not found' }) + } + + // TODO: Implement snapshot push logic + // This would involve: + // 1. Validating the latestVerId matches current version + // 2. Processing the files array (downloading from URLs if modified) + // 3. Updating the project with new content + // 4. Posting back results to postbackUrl + + // For now, return "not implemented" response + logger.warn( + { projectId, latestVerId }, + 'Snapshot push not yet implemented' + ) + + res.status(501).json({ + status: 501, + code: 'notImplemented', + message: 'Snapshot push not yet implemented', + }) + } catch (err) { + logger.error({ err, projectId }, 'Error posting snapshot') + next(err) + } +} + +/** + * Enrich labels with user information + */ +async function enrichLabels(labels) { + if (!labels || !labels.length) { + return [] + } + + // Get unique user IDs + const uniqueUsers = new Set(labels.map(label => label.user_id)) + uniqueUsers.delete(null) + uniqueUsers.delete(undefined) + + // Fetch user details + const userDetailsMap = new Map() + for (const userId of uniqueUsers) { + try { + const user = await UserGetter.promises.getUser(userId, { + email: 1, + first_name: 1, + last_name: 1, + }) + if (user) { + const name = [user.first_name, user.last_name] + .filter(Boolean) + .join(' ') + userDetailsMap.set(userId.toString(), { + email: user.email, + name: name || user.email, + }) + } + } catch (err) { + logger.warn({ err, userId }, 'Failed to get user details for label') + } + } + + // Enrich labels + return labels.map(label => { + const enrichedLabel = { ...label } + if (label.user_id) { + const userDetails = userDetailsMap.get(label.user_id.toString()) + if (userDetails) { + enrichedLabel.user = userDetails + enrichedLabel.user_display_name = userDetails.name + } + } + return enrichedLabel + }) +} + +export default { + getDoc: expressify(getDoc), + getSavedVers: expressify(getSavedVers), + getSnapshot: expressify(getSnapshot), + postSnapshot: expressify(postSnapshot), +} diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index e64f018d2f6..dfa5ac7b532 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -55,6 +55,7 @@ import LinkedFilesRouter from './Features/LinkedFiles/LinkedFilesRouter.mjs' import TemplatesRouter from './Features/Templates/TemplatesRouter.mjs' import UserMembershipRouter from './Features/UserMembership/UserMembershipRouter.mjs' import SystemMessageController from './Features/SystemMessages/SystemMessageController.mjs' +import GitBridgeApiController from './Features/GitBridge/GitBridgeApiController.mjs' import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.mjs' import AnalyticsUTMTrackingMiddleware from './Features/Analytics/AnalyticsUTMTrackingMiddleware.mjs' import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.mjs' @@ -1135,6 +1136,28 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { publicApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) privateApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) + // Git Bridge API v0 endpoints + publicApiRouter.get( + '/v0/docs/:project_id', + AuthorizationMiddleware.ensureUserCanReadProject, + GitBridgeApiController.getDoc + ) + publicApiRouter.get( + '/v0/docs/:project_id/saved_vers', + AuthorizationMiddleware.ensureUserCanReadProject, + GitBridgeApiController.getSavedVers + ) + publicApiRouter.get( + '/v0/docs/:project_id/snapshots/:version', + AuthorizationMiddleware.ensureUserCanReadProject, + GitBridgeApiController.getSnapshot + ) + publicApiRouter.post( + '/v0/docs/:project_id/snapshots', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + GitBridgeApiController.postSnapshot + ) + webRouter.get( '/status/compiler/:Project_id', RateLimiterMiddleware.rateLimit(rateLimiters.statusCompiler), From b77a75ae9123230e3c66fa2517f47370097a3a46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:32:20 +0000 Subject: [PATCH 3/6] Fix snapshot response format to match git-bridge expectations Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- .../Features/GitBridge/GitBridgeApiController.mjs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs index 2d18eef42e8..8086c7c542f 100644 --- a/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs +++ b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs @@ -155,6 +155,7 @@ async function getSnapshot(req, res, next) { const snapshot = Snapshot.fromRaw(snapshotRaw) // Build response in git-bridge format + // Note: srcs and atts are arrays of arrays: [[content, path], [content, path], ...] const srcs = [] const atts = [] @@ -162,23 +163,17 @@ async function getSnapshot(req, res, next) { const files = snapshot.getFileMap() for (const [pathname, file] of files) { if (file.isEditable()) { - // Text file - include content directly - srcs.push({ - content: file.getContent(), - path: pathname, - }) + // Text file - include content directly as [content, path] array + srcs.push([file.getContent(), pathname]) } else { - // Binary file - provide URL to download + // Binary file - provide URL to download as [url, path] array const hash = file.getHash() const historyId = await HistoryManager.promises.getHistoryId(projectId) // Build URL to blob endpoint const blobUrl = `${settings.siteUrl}/project/${projectId}/blob/${hash}` - atts.push({ - url: blobUrl, - path: pathname, - }) + atts.push([blobUrl, pathname]) } } From 51dac29c568bf40facdc7324dafb98a9a854f167 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:33:00 +0000 Subject: [PATCH 4/6] Address code review feedback: remove unused variable and add error handling Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- .../GitBridge/GitBridgeApiController.mjs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs index 8086c7c542f..a6efafc2d03 100644 --- a/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs +++ b/services/web/app/src/Features/GitBridge/GitBridgeApiController.mjs @@ -103,9 +103,19 @@ async function getSavedVers(req, res, next) { } // Get labels from project-history service - let labels = await fetchJson( - `${settings.apis.project_history.url}/project/${projectId}/labels` - ) + let labels + try { + labels = await fetchJson( + `${settings.apis.project_history.url}/project/${projectId}/labels` + ) + } catch (err) { + // If no labels exist, return empty array + if (err.response?.status === 404) { + labels = [] + } else { + throw err + } + } // Enrich labels with user information labels = await enrichLabels(labels) @@ -168,9 +178,8 @@ async function getSnapshot(req, res, next) { } else { // Binary file - provide URL to download as [url, path] array const hash = file.getHash() - const historyId = await HistoryManager.promises.getHistoryId(projectId) - // Build URL to blob endpoint + // Build URL to blob endpoint (already exists in web service) const blobUrl = `${settings.siteUrl}/project/${projectId}/blob/${hash}` atts.push([blobUrl, pathname]) From 653e321d19eeaba1ad2571e68ad37271e4ce5d82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:34:23 +0000 Subject: [PATCH 5/6] Add documentation for Git Bridge API v0 implementation Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- .../web/app/src/Features/GitBridge/README.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 services/web/app/src/Features/GitBridge/README.md diff --git a/services/web/app/src/Features/GitBridge/README.md b/services/web/app/src/Features/GitBridge/README.md new file mode 100644 index 00000000000..debe7b4c7d6 --- /dev/null +++ b/services/web/app/src/Features/GitBridge/README.md @@ -0,0 +1,137 @@ +# Git Bridge API v0 Implementation + +## Overview + +This implementation provides the API v0 endpoints required by the Git Bridge service to synchronize Overleaf projects with Git repositories. + +## Implemented Endpoints + +### 1. GET /api/v0/docs/:project_id + +Returns the latest version information for a project. + +**Response Format:** +```json +{ + "latestVerId": 243, + "latestVerAt": "2014-11-30T18:40:58.123Z", + "latestVerBy": { + "email": "user@example.com", + "name": "User Name" + } +} +``` + +**Implementation Details:** +- Retrieves project information from ProjectGetter +- Gets latest history update from HistoryManager +- Enriches with user information from UserGetter +- Returns null for `latestVerBy` if no user information available + +### 2. GET /api/v0/docs/:project_id/saved_vers + +Returns the list of saved versions (labels) for a project. + +**Response Format:** +```json +[ + { + "versionId": 243, + "comment": "added more info on doc GET", + "user": { + "email": "user@example.com", + "name": "User Name" + }, + "createdAt": "2014-11-30T18:47:01.456Z" + } +] +``` + +**Implementation Details:** +- Fetches labels from project-history service +- Enriches labels with user information +- Handles 404 errors by returning empty array +- Transforms to git-bridge expected format + +### 3. GET /api/v0/docs/:project_id/snapshots/:version + +Returns the snapshot (file contents) for a specific version. + +**Response Format:** +```json +{ + "srcs": [ + ["file content here", "path/to/file.tex"], + ["another file", "main.tex"] + ], + "atts": [ + ["https://example.com/blob/hash", "image.png"] + ] +} +``` + +**Implementation Details:** +- Gets snapshot content from HistoryManager +- Uses overleaf-editor-core Snapshot class to parse +- Separates editable files (srcs) from binary files (atts) +- Provides blob URLs for binary files +- **Note:** Arrays of arrays format is required by git-bridge + +### 4. POST /api/v0/docs/:project_id/snapshots + +Receives push requests from git-bridge with file changes. + +**Status:** Not yet fully implemented (returns 501) + +**Expected Request Format:** +```json +{ + "latestVerId": 123, + "files": [ + { + "name": "path/to/file.tex", + "url": "http://example.com/download/file" + } + ], + "postbackUrl": "http://git-bridge/postback" +} +``` + +**TODO:** +- Implement version validation +- Process file downloads from URLs +- Update project with new content +- Implement postback mechanism + +## Security + +All endpoints are protected with authorization middleware: +- Read endpoints: `AuthorizationMiddleware.ensureUserCanReadProject` +- Write endpoints: `AuthorizationMiddleware.ensureUserCanWriteProjectContent` + +## Error Handling + +- 404: Project not found or version not found +- 403: User does not have permission +- 500: Internal server error (logged with context) + +## Testing + +Manual testing can be done by: +1. Starting the web service +2. Using git-bridge to clone a project +3. Verifying the API endpoints return correct data + +## Future Improvements + +1. **Complete POST implementation**: Implement the full push mechanism +2. **Add unit tests**: Create comprehensive unit tests for all endpoints +3. **Performance optimization**: Consider caching for frequently accessed snapshots +4. **Rate limiting**: Add specific rate limiters for git-bridge endpoints +5. **Metrics**: Add prometheus metrics for API usage + +## References + +- Git Bridge source: `/services/git-bridge/` +- Test data: `/services/git-bridge/src/test/resources/.../state.json` +- Git Bridge API documentation in test files From 3b2d5ebc8a4247d197dcbf1983c97bfc8ee4f457 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:35:32 +0000 Subject: [PATCH 6/6] Add usage guide for Git Bridge API v0 Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- .../web/app/src/Features/GitBridge/USAGE.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 services/web/app/src/Features/GitBridge/USAGE.md diff --git a/services/web/app/src/Features/GitBridge/USAGE.md b/services/web/app/src/Features/GitBridge/USAGE.md new file mode 100644 index 00000000000..9036604f550 --- /dev/null +++ b/services/web/app/src/Features/GitBridge/USAGE.md @@ -0,0 +1,237 @@ +# Git Bridge API v0 - Usage Guide + +## Overview + +This guide explains how to use the newly implemented API v0 endpoints for Git Bridge integration. + +## Prerequisites + +1. Overleaf web service running +2. Git Bridge service configured to point to the web service +3. Valid project ID and user authentication + +## API Endpoints + +### Authentication + +All API v0 endpoints use the same authentication mechanism as other Overleaf API endpoints: +- OAuth2 authentication (if configured) +- Session-based authentication via cookies +- HTTP Basic Auth (if configured) + +### 1. Get Project Latest Version + +**Endpoint:** `GET /api/v0/docs/:project_id` + +**Description:** Retrieves the latest version information for a project. + +**Example:** +```bash +curl -X GET "http://localhost:3000/api/v0/docs/507f1f77bcf86cd799439011" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** +```json +{ + "latestVerId": 243, + "latestVerAt": "2014-11-30T18:40:58.123Z", + "latestVerBy": { + "email": "user@example.com", + "name": "John Doe" + } +} +``` + +### 2. Get Saved Versions (Labels) + +**Endpoint:** `GET /api/v0/docs/:project_id/saved_vers` + +**Description:** Retrieves all saved versions (labels) for a project. + +**Example:** +```bash +curl -X GET "http://localhost:3000/api/v0/docs/507f1f77bcf86cd799439011/saved_vers" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** +```json +[ + { + "versionId": 243, + "comment": "Final version before submission", + "user": { + "email": "user@example.com", + "name": "John Doe" + }, + "createdAt": "2014-11-30T18:47:01.456Z" + }, + { + "versionId": 185, + "comment": "Draft version", + "user": { + "email": "user@example.com", + "name": "John Doe" + }, + "createdAt": "2014-11-11T17:18:40.789Z" + } +] +``` + +### 3. Get Snapshot for Version + +**Endpoint:** `GET /api/v0/docs/:project_id/snapshots/:version` + +**Description:** Retrieves the complete file content for a specific version. + +**Example:** +```bash +curl -X GET "http://localhost:3000/api/v0/docs/507f1f77bcf86cd799439011/snapshots/243" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** +```json +{ + "srcs": [ + [ + "\\documentclass{article}\n\\begin{document}\nHello World\n\\end{document}", + "main.tex" + ], + [ + "This is chapter 1", + "chapters/chapter1.tex" + ] + ], + "atts": [ + [ + "http://localhost:3000/project/507f1f77bcf86cd799439011/blob/abc123def456", + "images/figure1.png" + ] + ] +} +``` + +**Note:** +- `srcs` contains text files as `[content, path]` arrays +- `atts` contains binary files as `[url, path]` arrays where the URL can be used to download the file + +### 4. Push Snapshot (Not Yet Implemented) + +**Endpoint:** `POST /api/v0/docs/:project_id/snapshots` + +**Status:** Returns 501 Not Implemented + +**Expected Request:** +```json +{ + "latestVerId": 243, + "files": [ + { + "name": "main.tex", + "url": "http://git-bridge/files/abc123" + } + ], + "postbackUrl": "http://git-bridge/postback/xyz" +} +``` + +## Error Responses + +### 404 Not Found +```json +{ + "message": "Project not found" +} +``` +or +```json +{ + "message": "Version not found" +} +``` + +### 403 Forbidden +```json +{ + "message": "Forbidden" +} +``` + +### 501 Not Implemented (POST endpoint) +```json +{ + "status": 501, + "code": "notImplemented", + "message": "Snapshot push not yet implemented" +} +``` + +## Testing with Git Bridge + +1. **Configure Git Bridge:** + Update your git-bridge configuration to point to the web service: + ```json + { + "apiBaseUrl": "http://localhost:3000/api/v0/" + } + ``` + +2. **Clone a Project:** + ```bash + git clone http://git-bridge-host:8000/project_id + ``` + +3. **Verify API Calls:** + Monitor the web service logs to verify API calls are being made correctly: + ```bash + tail -f logs/web.log | grep "api/v0" + ``` + +## Troubleshooting + +### "Project not found" error +- Verify the project ID is correct +- Ensure the user has read access to the project +- Check that the project exists in the database + +### "Forbidden" error +- Verify authentication credentials +- Ensure the user has appropriate permissions for the project +- Check OAuth2 configuration if using token-based auth + +### Empty response for saved versions +- This is normal if the project has no saved versions/labels +- Users need to manually create labels through the Overleaf UI + +### Binary file URLs not working +- Ensure the blob endpoint is accessible: `GET /project/:id/blob/:hash` +- Verify the history service is running +- Check file storage backend is accessible + +## Development + +### Adding Debug Logging + +To enable detailed logging for API v0 endpoints: + +```javascript +// In GitBridgeApiController.mjs +logger.debug({ projectId, version }, 'Getting snapshot') +``` + +### Testing Locally + +1. Start the web service in development mode +2. Create a test project with some history +3. Use curl or Postman to test endpoints manually +4. Check response formats match expected structure + +## Next Steps + +- Complete the POST endpoint implementation +- Add comprehensive unit tests +- Add integration tests with actual git-bridge +- Implement rate limiting for git-bridge endpoints +- Add metrics and monitoring