From 382007ffc85e9460b4d176e0ebfc351bd6736080 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 15:11:32 +0000 Subject: [PATCH 1/3] feat: port Paca plugin from GitHub to GitLab Rename com.paca.github to com.paca.gitlab across backend, frontend, MCP, and skills. Add GitLab REST client with instance URL, Personal/Project/Group tokens, signing_token webhooks (19+), MR/pipeline mappings, and en/it i18n. Co-authored-by: Alessandro Gorla --- .github/workflows/backend-pr-ci.yml | 2 +- .github/workflows/release.yml | 38 +- README.md | 235 +------ backend/automation.go | 62 +- backend/automation_test.go | 6 +- backend/branches.go | 52 +- backend/client.go | 623 +++++++++--------- backend/client_url_test.go | 83 +-- backend/crypto.go | 6 +- backend/go.mod | 2 +- backend/integration.go | 264 +++++--- backend/main.go | 2 +- .../migrations/0001_create_github_tables.sql | 82 --- .../migrations/0001_create_gitlab_tables.sql | 81 +++ backend/plugin.go | 28 +- backend/plugin_test.go | 103 +-- backend/pull_requests.go | 243 ++++--- backend/webhook.go | 252 +++---- frontend/package.json | 2 +- ...bSettingsTab.tsx => GitLabSettingsTab.tsx} | 226 ++++--- ...bTaskSection.tsx => GitLabTaskSection.tsx} | 115 ++-- frontend/src/{github-api.ts => gitlab-api.ts} | 49 +- frontend/src/i18n/en.ts | 34 + frontend/src/i18n/index.ts | 23 + frontend/src/i18n/it.ts | 35 + frontend/src/index.ts | 4 +- frontend/vite.config.ts | 6 +- mcp/package.json | 2 +- mcp/src/index.ts | 180 ++--- plugin.json | 80 +-- skills/paca-github-workflow/SKILL.md | 71 -- skills/paca-gitlab-workflow/SKILL.md | 71 ++ 32 files changed, 1551 insertions(+), 1511 deletions(-) delete mode 100644 backend/migrations/0001_create_github_tables.sql create mode 100644 backend/migrations/0001_create_gitlab_tables.sql rename frontend/src/{GitHubSettingsTab.tsx => GitLabSettingsTab.tsx} (84%) rename frontend/src/{GitHubTaskSection.tsx => GitLabTaskSection.tsx} (91%) rename frontend/src/{github-api.ts => gitlab-api.ts} (82%) create mode 100644 frontend/src/i18n/en.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/it.ts delete mode 100644 skills/paca-github-workflow/SKILL.md create mode 100644 skills/paca-gitlab-workflow/SKILL.md diff --git a/.github/workflows/backend-pr-ci.yml b/.github/workflows/backend-pr-ci.yml index b3f6e64..72cbf59 100644 --- a/.github/workflows/backend-pr-ci.yml +++ b/.github/workflows/backend-pr-ci.yml @@ -73,7 +73,7 @@ jobs: sudo dpkg -i /tmp/tinygo.deb - name: Build WASM - run: tinygo build -target=wasip1 -buildmode=c-shared -o github.wasm . + run: tinygo build -target=wasip1 -buildmode=c-shared -o gitlab.wasm . test: name: Tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32a2b54..18ceffe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: run: | set -euo pipefail mkdir -p ../release/backend - tinygo build -target=wasip1 -buildmode=c-shared -o ../release/backend/github.wasm . + tinygo build -target=wasip1 -buildmode=c-shared -o ../release/backend/gitlab.wasm . - name: Build frontend working-directory: frontend @@ -74,32 +74,32 @@ jobs: - name: Create archives run: | set -euo pipefail - tar -czf github-backend-wasm.tar.gz -C release/backend github.wasm - tar -czf github-frontend-dist.tar.gz -C release/frontend dist - tar -czf github-mcp-dist.tar.gz -C release/mcp dist - tar -czf github-migrations.tar.gz -C release migrations - tar -czf github-skills-bundle.tar.gz -C release skills - tar -czf github-plugin-manifest.tar.gz -C release plugin.json + tar -czf gitlab-backend-wasm.tar.gz -C release/backend gitlab.wasm + tar -czf gitlab-frontend-dist.tar.gz -C release/frontend dist + tar -czf gitlab-mcp-dist.tar.gz -C release/mcp dist + tar -czf gitlab-migrations.tar.gz -C release migrations + tar -czf gitlab-skills-bundle.tar.gz -C release skills + tar -czf gitlab-plugin-manifest.tar.gz -C release plugin.json - name: Generate checksums run: | set -euo pipefail - sha256sum github-backend-wasm.tar.gz \ - github-frontend-dist.tar.gz \ - github-mcp-dist.tar.gz \ - github-migrations.tar.gz \ - github-skills-bundle.tar.gz \ - github-plugin-manifest.tar.gz > checksums.txt + sha256sum gitlab-backend-wasm.tar.gz \ + gitlab-frontend-dist.tar.gz \ + gitlab-mcp-dist.tar.gz \ + gitlab-migrations.tar.gz \ + gitlab-skills-bundle.tar.gz \ + gitlab-plugin-manifest.tar.gz > checksums.txt - name: Publish GitHub Release assets uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: | - github-backend-wasm.tar.gz - github-frontend-dist.tar.gz - github-mcp-dist.tar.gz - github-migrations.tar.gz - github-skills-bundle.tar.gz - github-plugin-manifest.tar.gz + gitlab-backend-wasm.tar.gz + gitlab-frontend-dist.tar.gz + gitlab-mcp-dist.tar.gz + gitlab-migrations.tar.gz + gitlab-skills-bundle.tar.gz + gitlab-plugin-manifest.tar.gz checksums.txt diff --git a/README.md b/README.md index 2b925c6..78af844 100644 --- a/README.md +++ b/README.md @@ -1,185 +1,38 @@ -# com.paca.github +# com.paca.gitlab -First-party Paca plugin that integrates GitHub repositories, pull requests, and branches with Paca projects and tasks. +First-party Paca plugin that integrates GitLab projects, merge requests, and branches with Paca projects and tasks. + +Forked from `com.paca.github` and ported to GitLab. **Source/CI/release stay on GitHub**; the product integrates GitLab.com and Self-Managed (19+). --- ## Architecture -The plugin follows the standard three-part plugin structure: - ```text -github/ ├── backend/ - Go WASM plugin (runs inside the API host) -├── frontend/ - React micro-frontend (Module Federation remote) -└── mcp/ - MCP tools bundle for AI/tooling integrations -``` - -### Backend (backend/) - -- Written in Go, compiled to wasip1/wasm for production. -- Registered as com.paca.github in the plugin registry. -- Owns its database schema (plugin_data_com_paca_github) and runs its own migration on startup. -- Stores GitHub PATs and webhook secrets encrypted with AES-256-GCM. -- Creates and verifies GitHub webhooks for linked repositories. -- Handles task/project cleanup on task.deleted and project.deleted events. - -### Frontend (frontend/) - -- Vite + React + TanStack Query. -- Exposed as a Module Federation remote (com_paca_github). -- Mounted at: - - project.settings.tab (GitHubSettingsTab) - - task.detail.section (GitHubTaskSection) -- Communicates with backend via plugin API routes under /api/v1/plugins/com.paca.github/projects/:projectId/... - -### MCP (mcp/) - -- Built with @paca-ai/plugin-sdk-mcp. -- Published as an ESM bundle (mcp.js). -- Exposes tools for integration management, repository linking, PR linking, and branch operations. - ---- - -## Required Configuration - -The backend uses the following config keys: - -- ENCRYPTION_KEY: 64 hex chars (32-byte key) used for AES-256-GCM encryption of tokens/secrets. -- PUBLIC_URL: public base URL of the Paca server, used to build the webhook callback URL. - -Without PUBLIC_URL, linking repositories will fail because webhook creation requires a reachable callback URL. - ---- - -## API Endpoints - -All routes are prefixed with /projects/:projectId by the host. -The caller must be an authenticated member of the project unless noted otherwise. - -| Method | Path | Description | -|--------|------|-------------| -| GET | /github | Get integration status for the current project | -| POST | /github/token | Set or replace GitHub PAT | -| DELETE | /github/token | Delete GitHub PAT and integration | -| GET | /github/repositories | List repositories accessible by the token | -| GET | /github/linked-repositories | List repositories linked to the project | -| POST | /github/linked-repositories | Link a repository and create webhook | -| DELETE | /github/linked-repositories/:repoId | Unlink a repository | -| GET | /tasks/:taskId/github/pull-requests | List pull requests linked to a task | -| POST | /tasks/:taskId/github/pull-requests | Link a pull request to a task | -| DELETE | /tasks/:taskId/github/pull-requests/:prId | Unlink a pull request from a task | -| POST | /tasks/:taskId/github/branches | Create branch and link it to a task | -| GET | /tasks/:taskId/github/branches | List branches linked to a task | -| POST | /webhook (public) | Receive GitHub webhook events | - -### Example requests - -Set token: - -```json -{ "token": "ghp_xxx" } -``` - -Link repository: - -```json -{ "owner": "Paca-AI", "repo_name": "paca" } -``` - -Link PR to task: - -```json -{ "repo_id": "550e8400-e29b-41d4-a716-446655440000", "pr_number": 42 } -``` - -Create branch for task: - -```json -{ - "repo_id": "550e8400-e29b-41d4-a716-446655440000", - "branch_name": "feat/PROJ-42-github-integration", - "source_branch": "main" -} +├── frontend/ - React micro-frontend (Module Federation remote) + i18n (en/it) +└── mcp/ - MCP tools bundle for AI/tooling integrations (Paca task-centric) ``` ---- - -## Webhook Behavior +GitLab also ships an [official MCP server](https://docs.gitlab.com/user/model_context_protocol/mcp_server/) at `/api/v4/mcp`. The plugin MCP remains required for linking MRs/branches to Paca tasks; use the official MCP as a semantic/API reference. -On linked repositories, GitHub webhooks are configured for: +### Auth -- push -- pull_request -- check_run +- **Personal**, **Project**, and **Group** Access Tokens (all supported). +- Scope: `api`. Creating project webhooks requires **Maintainer** or **Owner**. +- Per-integration `instance_url` (default `https://gitlab.com`). +- Tokens encrypted with AES-256-GCM (`ENCRYPTION_KEY`). -Current behavior includes: +### Webhooks (GitLab 19+) -- Verifying X-Hub-Signature-256 with the stored webhook secret. -- Caching/updating pull request state in plugin tables. -- Auto-linking PRs when opened/reopened from a branch already linked to a task. -- Auto-linking created branches when branch names include task references like PROJ-42. -- Emitting plugin events: - - github.pr_linked - - github.pr_updated - - github.branch_linked +- Events: push, merge request, pipeline. +- Verification: `signing_token` (Standard Webhooks HMAC) — primary path. ---- +### Config -## Database Schema - -Tables live in plugin_data_com_paca_github and are created by backend/migrations/0001_create_github_tables.sql. - -```text -github_integrations - id UUID PK - project_id UUID UNIQUE -> projects(id) ON DELETE CASCADE - access_token_enc TEXT - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - -github_repositories - id UUID PK - project_id UUID -> projects(id) ON DELETE CASCADE - integration_id UUID -> github_integrations(id) ON DELETE CASCADE - owner TEXT - repo_name TEXT - full_name TEXT - webhook_id BIGINT - webhook_secret_enc TEXT - default_branch TEXT - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - -github_pull_requests - id UUID PK - project_id UUID -> projects(id) ON DELETE CASCADE - repo_id UUID -> github_repositories(id) ON DELETE CASCADE - pr_number INT - github_pr_id BIGINT - title TEXT - state TEXT - html_url TEXT - head_branch TEXT - base_branch TEXT - author TEXT - merged_at TIMESTAMPTZ - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - -github_task_pr_links - id UUID PK - task_id UUID -> tasks(id) ON DELETE CASCADE - pull_request_id UUID -> github_pull_requests(id) ON DELETE CASCADE - created_at TIMESTAMPTZ - -github_task_branches - id UUID PK - task_id UUID -> tasks(id) ON DELETE CASCADE - repo_id UUID -> github_repositories(id) ON DELETE CASCADE - branch_name TEXT - created_at TIMESTAMPTZ -``` +- `ENCRYPTION_KEY`: 64 hex chars. +- `PUBLIC_URL`: public Paca base URL for webhook callbacks. +- Outbound allowlist in `plugin.json`: `gitlab.com`. For Self-Managed, add your hostname to `allowedOutboundDomains` before building. --- @@ -189,53 +42,15 @@ github_task_branches ```bash cd backend - -# Run tests -go test -v ./... - -# Build WASM binary (requires TinyGo — see https://tinygo.org/getting-started/install/) -tinygo build -target=wasip1 -buildmode=c-shared -o github.wasm . -``` - -### Frontend - -```bash -cd frontend - -# Install dependencies -bun install - -# Development build (watch) -bun run dev - -# Production build -bun run build +go test ./... +tinygo build -target=wasip1 -buildmode=c-shared -o gitlab.wasm . ``` -### MCP +### Frontend / MCP ```bash -cd mcp - -# Install dependencies -bun install - -# Typecheck -bun run typecheck - -# Production build (outputs mcp.js) -bun run build +cd frontend && bun install && bun run build +cd mcp && bun install && bun run build ``` ---- - -## Release (CD) - -The release workflow is at .github/workflows/release.yml. - -It runs on: - -- tag push matching v* -- manual workflow_dispatch - -The workflow builds backend/frontend/mcp assets, packages migrations and plugin.json, generates SHA256 checksums, and uploads all artifacts to a GitHub Release. +Release: `.github/workflows/release.yml` on tag `v*`. diff --git a/backend/automation.go b/backend/automation.go index eed9ba6..caf9c17 100644 --- a/backend/automation.go +++ b/backend/automation.go @@ -1,6 +1,7 @@ package main import ( + "strings" "context" "encoding/json" @@ -12,30 +13,30 @@ import ( // ctx.Action. Node types must match exactly what's declared in plugin.json // under "automation" (see AutomationManifest in the core's // domain/plugin/entity.go) — namespaced under the plugin's short name, -// "github" (the last dot-separated segment of the plugin ID "com.paca.github"), +// "github" (the last dot-separated segment of the plugin ID "com.paca.gitlab"), // not the full reverse-DNS ID. // // Both handlers resolve the calling project from req.ProjectID — supplied // directly by the host (the automation graph's own project), not read from -// the node's config — since a task by itself doesn't carry which GitHub +// the node's config — since a task by itself doesn't carry which GitLab // repository/PR it's linked to; that's resolved through -// github_task_pr_links the same way the HTTP handlers in pull_requests.go +// gitlab_task_mr_links the same way the HTTP handlers in pull_requests.go // do it, keyed by (task_id, project_id). const ( // automationConditionPRState checks the linked pull request's state // (open/closed/merged) against a configured expected value. - automationConditionPRState = "github.pr_state" + automationConditionPRState = "gitlab.mr_state" // automationActionMergePR merges the linked pull request. - automationActionMergePR = "github.merge_pr" + automationActionMergePR = "gitlab.merge_mr" // automationActionCommentPR posts a comment on the linked pull request. - automationActionCommentPR = "github.comment_pr" + automationActionCommentPR = "gitlab.comment_mr" ) // registerAutomationNodes wires this plugin's Condition/Action handlers // into ctx. Called once from Init. -func (p *githubPlugin) registerAutomationNodes(ctx *plugin.Context) { +func (p *gitlabPlugin) registerAutomationNodes(ctx *plugin.Context) { ctx.Condition(automationConditionPRState, p.conditionPRState) ctx.Action(automationActionMergePR, p.actionMergePR) ctx.Action(automationActionCommentPR, p.actionCommentPR) @@ -44,7 +45,7 @@ func (p *githubPlugin) registerAutomationNodes(ctx *plugin.Context) { // ─── shared: resolve the most recently linked PR for a task ────────────────── // pluginLinkedPR is what resolveLinkedPRForAutomation returns: enough to -// call the GitHub API plus the plugin's own repo_id/pr row id for logging. +// call the GitLab API plus the plugin's own repo_id/pr row id for logging. type pluginLinkedPR struct { Owner string RepoName string @@ -55,12 +56,12 @@ type pluginLinkedPR struct { // task, the same many-PRs-per-task relationship listTaskPRs exposes over // HTTP — automation nodes act on the newest link since that's virtually // always the PR the automation graph author means ("the PR for this task"). -func (p *githubPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (*pluginLinkedPR, error) { +func (p *gitlabPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (*pluginLinkedPR, error) { result, err := p.db.Query(` SELECT r.owner, r.repo_name, pr.pr_number - FROM github_pull_requests pr - JOIN github_task_pr_links l ON l.pull_request_id = pr.id - JOIN github_repositories r ON r.id = pr.repo_id + FROM gitlab_merge_requests pr + JOIN gitlab_task_mr_links l ON l.merge_request_id = pr.id + JOIN gitlab_repositories r ON r.id = pr.repo_id WHERE l.task_id = $1 AND pr.project_id = $2 ORDER BY l.created_at DESC LIMIT 1 @@ -69,7 +70,7 @@ func (p *githubPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (* return nil, err } if len(result.Rows) == 0 { - return nil, &appError{code: "GITHUB_PR_LINK_NOT_FOUND", status: 404, msg: "No pull request linked to this task"} + return nil, &appError{code: "GITLAB_PR_LINK_NOT_FOUND", status: 404, msg: "No pull request linked to this task"} } sc := newRowScanner(result.Columns, result.Rows[0]) return &pluginLinkedPR{ @@ -81,44 +82,39 @@ func (p *githubPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (* // ─── Condition: github.pr_state ─────────────────────────────────────────────── -func (p *githubPlugin) conditionPRState(req *plugin.ConditionRequest) plugin.ConditionResult { +func (p *gitlabPlugin) conditionPRState(req *plugin.ConditionRequest) plugin.ConditionResult { var cfg struct { ExpectedState string `json:"expected_state"` // "open" | "closed" | "merged" } if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" || cfg.ExpectedState == "" { - p.log.Error("github: pr_state condition: invalid config") + p.log.Error("gitlab: pr_state condition: invalid config") return plugin.ConditionResult{Matched: false} } linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) if err != nil { - p.log.Info("github: pr_state condition: " + err.Error()) + p.log.Info("gitlab: pr_state condition: " + err.Error()) return plugin.ConditionResult{Matched: false} } - token, err := p.decryptToken(req.ProjectID) + ghc, err := p.clientForProject(req.ProjectID) if err != nil { - p.log.Error("github: pr_state condition: decrypt token: " + err.Error()) + p.log.Error("gitlab: pr_state condition: decrypt token: " + err.Error()) return plugin.ConditionResult{Matched: false} } - - ghc := newGHClient(token) ghPR, err := ghc.getPullRequest(context.Background(), linked.Owner, linked.RepoName, linked.PRNumber) if err != nil { - p.log.Error("github: pr_state condition: fetch PR: " + err.Error()) + p.log.Error("gitlab: pr_state condition: fetch PR: " + err.Error()) return plugin.ConditionResult{Matched: false} } - state := ghPR.State - if ghPR.Merged { - state = "merged" - } + state := ghPR.NormalizedState() return plugin.ConditionResult{Matched: state == cfg.ExpectedState} } // ─── Action: github.merge_pr ────────────────────────────────────────────────── -func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionResult { +func (p *gitlabPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionResult { var cfg struct { MergeMethod string `json:"merge_method"` // "merge" | "squash" | "rebase"; defaults to "merge" } @@ -134,11 +130,10 @@ func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionRes return plugin.ActionResult{Applied: false, Error: err.Error()} } - token, err := p.decryptToken(req.ProjectID) + ghc, err := p.clientForProject(req.ProjectID) if err != nil { - return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} + return plugin.ActionResult{Applied: false, Error: err.Error()} } - ghc := newGHClient(token) ctx := context.Background() // Idempotency: a plugin action can be retried by the automation @@ -149,7 +144,7 @@ func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionRes if err != nil { return plugin.ActionResult{Applied: false, Error: "fetch PR: " + err.Error()} } - if ghPR.Merged { + if strings.EqualFold(ghPR.State, "merged") || ghPR.MergedAt != nil { return plugin.ActionResult{Applied: false} } @@ -161,7 +156,7 @@ func (p *githubPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionRes // ─── Action: github.comment_pr ──────────────────────────────────────────────── -func (p *githubPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionResult { +func (p *gitlabPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionResult { var cfg struct { Body string `json:"body"` } @@ -174,11 +169,10 @@ func (p *githubPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionR return plugin.ActionResult{Applied: false, Error: err.Error()} } - token, err := p.decryptToken(req.ProjectID) + ghc, err := p.clientForProject(req.ProjectID) if err != nil { - return plugin.ActionResult{Applied: false, Error: "decrypt token: " + err.Error()} + return plugin.ActionResult{Applied: false, Error: err.Error()} } - ghc := newGHClient(token) if err := ghc.createIssueComment(context.Background(), linked.Owner, linked.RepoName, linked.PRNumber, cfg.Body); err != nil { return plugin.ActionResult{Applied: false, Error: "comment PR: " + err.Error()} diff --git a/backend/automation_test.go b/backend/automation_test.go index 88bb01d..e7ed644 100644 --- a/backend/automation_test.go +++ b/backend/automation_test.go @@ -7,9 +7,9 @@ import ( "github.com/Paca-AI/plugin-sdk-go/plugintest" ) -// These cover the paths reachable without an outbound GitHub API call: -// config validation and "no PR linked to this task". The GitHub-API-backed -// happy path (like other ghClient-dependent handlers in this plugin) isn't +// These cover the paths reachable without an outbound GitLab API call: +// config validation and "no PR linked to this task". The GitLab-API-backed +// happy path (like other glClient-dependent handlers in this plugin) isn't // unit-testable outside a WASM build — see plugin_test.go's note on this. // // ProjectID is supplied the same way the host always supplies it — as a diff --git a/backend/branches.go b/backend/branches.go index 4f591de..7314b75 100644 --- a/backend/branches.go +++ b/backend/branches.go @@ -24,7 +24,7 @@ type createBranchResponse struct { // ─── POST /tasks/:taskId/github/branches ────────────────────────────────────── -func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) createBranch(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") if !p.taskBelongsToProject(taskID, projectID, res) { @@ -42,7 +42,7 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return @@ -50,7 +50,7 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { // Get repository details. repoResult, rErr := p.db.Query( - `SELECT owner, repo_name, default_branch FROM github_repositories WHERE id = $1 AND project_id = $2`, + `SELECT owner, repo_name, default_branch FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, b.RepoID, projectID, ) if rErr != nil { @@ -58,7 +58,7 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { return } if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") + apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "Repository not found") return } rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) @@ -71,15 +71,14 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { sourceBranch = defaultBranch } - ghc := newGHClient(token) if err := ghc.createBranch(context.Background(), owner, repoName, b.BranchName, sourceBranch); err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) { if apiErr.StatusCode == 403 { - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create branches") + apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create branches") return } - apiError(res, 400, "BAD_REQUEST", fmt.Sprintf("GitHub API error: %s", apiErr.Message)) + apiError(res, 400, "BAD_REQUEST", fmt.Sprintf("GitLab API error: %s", apiErr.Message)) return } apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to create branch: %s", err)) @@ -89,18 +88,18 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { // Link the branch to the task. now := nowStr() rowsAffected, dbErr := p.db.Exec(` - INSERT INTO github_task_branches (task_id, repo_id, branch_name, created_at) + INSERT INTO gitlab_task_branches (task_id, repo_id, branch_name, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (task_id, repo_id, branch_name) DO NOTHING `, taskID, b.RepoID, b.BranchName, now) if dbErr != nil { p.log.Error("failed to link branch to task: " + dbErr.Error()) } else if rowsAffected == 0 { - apiError(res, 409, "GITHUB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") + apiError(res, 409, "GITLAB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") return } - plugin.EmitEvent("github.branch_linked", map[string]any{ + plugin.EmitEvent("gitlab.branch_linked", map[string]any{ "project_id": projectID, "task_id": taskID, "repo_id": b.RepoID, @@ -112,7 +111,7 @@ func (p *githubPlugin) createBranch(req *plugin.Request, res *plugin.Response) { // ─── POST /tasks/:taskId/branches/link ──────────────────────────────────────── -func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") if !p.taskBelongsToProject(taskID, projectID, res) { @@ -129,14 +128,14 @@ func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Respons return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, + `SELECT owner, repo_name FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, b.RepoID, projectID, ) if rErr != nil { @@ -144,24 +143,23 @@ func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Respons return } if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") + apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "Repository not found") return } rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) owner := rSc.str("owner") repoName := rSc.str("repo_name") - ghc := newGHClient(token) if err := ghc.branchExists(context.Background(), owner, repoName, b.BranchName); err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) { if apiErr.StatusCode == 404 { - apiError(res, 404, "GITHUB_BRANCH_NOT_FOUND", + apiError(res, 404, "GITLAB_BRANCH_NOT_FOUND", fmt.Sprintf("Branch %q not found in %s/%s", b.BranchName, owner, repoName)) return } if apiErr.StatusCode == 403 { - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read branches") + apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read branches") return } } @@ -171,7 +169,7 @@ func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Respons now := nowStr() inserted, dbErr := p.db.Query(` - INSERT INTO github_task_branches (task_id, repo_id, branch_name, created_at) + INSERT INTO gitlab_task_branches (task_id, repo_id, branch_name, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (task_id, repo_id, branch_name) DO NOTHING RETURNING id, task_id, repo_id, branch_name, created_at @@ -181,11 +179,11 @@ func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Respons return } if len(inserted.Rows) == 0 { - apiError(res, 409, "GITHUB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") + apiError(res, 409, "GITLAB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") return } - plugin.EmitEvent("github.branch_linked", map[string]any{ + plugin.EmitEvent("gitlab.branch_linked", map[string]any{ "project_id": projectID, "task_id": taskID, "repo_id": b.RepoID, @@ -204,7 +202,7 @@ func (p *githubPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Respons // ─── GET /tasks/:taskId/github/branches ─────────────────────────────────────── -func (p *githubPlugin) listTaskBranches(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) listTaskBranches(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") if !p.taskBelongsToProject(taskID, projectID, res) { @@ -212,7 +210,7 @@ func (p *githubPlugin) listTaskBranches(req *plugin.Request, res *plugin.Respons } result, err := p.db.Query( - `SELECT id, task_id, repo_id, branch_name, created_at FROM github_task_branches WHERE task_id = $1 ORDER BY created_at ASC`, + `SELECT id, task_id, repo_id, branch_name, created_at FROM gitlab_task_branches WHERE task_id = $1 ORDER BY created_at ASC`, taskID, ) if err != nil { @@ -220,8 +218,8 @@ func (p *githubPlugin) listTaskBranches(req *plugin.Request, res *plugin.Respons return } - // github_task_branches has no project_id column of its own — only - // repo_id, which links to github_repositories (which does). Re-verify + // gitlab_task_branches has no project_id column of its own — only + // repo_id, which links to gitlab_repositories (which does). Re-verify // each branch's repo against the caller's project as defense-in-depth: // taskBelongsToProject above already closes the main vector (a foreign // taskId), but this also protects against any row a pre-fix caller @@ -233,7 +231,7 @@ func (p *githubPlugin) listTaskBranches(req *plugin.Request, res *plugin.Respons sc := newRowScanner(result.Columns, row) repoID := sc.str("repo_id") repoResult, rErr := p.db.Query( - `SELECT id FROM github_repositories WHERE id = $1 AND project_id = $2`, + `SELECT id FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, repoID, projectID, ) if rErr != nil { diff --git a/backend/client.go b/backend/client.go index 0427fb5..b1eef8e 100644 --- a/backend/client.go +++ b/backend/client.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "net/url" @@ -11,433 +13,460 @@ import ( plugin "github.com/Paca-AI/plugin-sdk-go" ) -const ( - ghBaseURL = "https://api.github.com" - ghAPIVersion = "2022-11-28" -) +const defaultGitLabInstance = "https://gitlab.com" + +// glProject is a GitLab project (repository) representation. +type glProject struct { + ID int64 `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + WebURL string `json:"web_url"` + HTTPURLToRepo string `json:"http_url_to_repo"` + Namespace struct { + FullPath string `json:"full_path"` + Path string `json:"path"` + } `json:"namespace"` +} -// ghRepository is a minimal GitHub repository representation. -type ghRepository struct { - ID int64 `json:"id"` - FullName string `json:"full_name"` - Name string `json:"name"` - Owner struct { - Login string `json:"login"` - } `json:"owner"` - DefaultBranch string `json:"default_branch"` - Private bool `json:"private"` -} - -// ghPullRequest is a minimal representation of a GitHub pull request. -type ghPullRequest struct { - ID int64 `json:"id"` - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` // "open" | "closed" - HTMLURL string `json:"html_url"` - Head struct { - Ref string `json:"ref"` - SHA string `json:"sha"` - } `json:"head"` - Base struct { - Ref string `json:"ref"` - } `json:"base"` - User struct { - Login string `json:"login"` - } `json:"user"` - Merged bool `json:"merged"` - MergedAt *time.Time `json:"merged_at"` -} - -// ghCombinedStatus is the response of GET /commits/{ref}/status — the legacy -// Status API used by CI integrations that post plain commit statuses rather -// than check runs (e.g. classic CircleCI/Travis integrations). -type ghCombinedStatus struct { - State string `json:"state"` // "pending" | "success" | "failure" | "error" - Statuses []struct { - Context string `json:"context"` - State string `json:"state"` - Description string `json:"description"` - TargetURL string `json:"target_url"` - } `json:"statuses"` -} - -// ghCheckRunsResponse is the response of GET /commits/{ref}/check-runs — the -// modern Checks API used by GitHub Actions and most GitHub Apps. -type ghCheckRunsResponse struct { - CheckRuns []struct { - Name string `json:"name"` - Status string `json:"status"` // "queued" | "in_progress" | "completed" - Conclusion *string `json:"conclusion"` // "success" | "failure" | ... | null while not completed - HTMLURL string `json:"html_url"` - } `json:"check_runs"` -} - -// ghAPIError carries a non-2xx HTTP status and the GitHub error message. -type ghAPIError struct { +func (p glProject) Private() bool { + return p.Visibility == "private" || p.Visibility == "internal" +} + +func (p glProject) Owner() string { + if p.Namespace.FullPath != "" { + return p.Namespace.FullPath + } + parts := strings.Split(p.PathWithNamespace, "/") + if len(parts) < 2 { + return "" + } + return strings.Join(parts[:len(parts)-1], "/") +} + +// glMergeRequest is a GitLab merge request. Number maps to project-scoped iid. +type glMergeRequest struct { + ID int64 `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + State string `json:"state"` // opened | closed | merged | locked + WebURL string `json:"web_url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Author struct { + Username string `json:"username"` + } `json:"author"` + MergedAt *time.Time `json:"merged_at"` + Description string `json:"description"` + SHA string `json:"sha"` +} + +// Number returns the project-scoped iid (GitLab UI number). +func (m glMergeRequest) Number() int { return m.IID } + +func (m glMergeRequest) NormalizedState() string { + switch strings.ToLower(m.State) { + case "opened", "open", "locked": + return "open" + case "merged": + return "merged" + case "closed": + return "closed" + default: + return strings.ToLower(m.State) + } +} + +// glPipeline is a minimal pipeline summary for CI status. +type glPipeline struct { + ID int64 `json:"id"` + Status string `json:"status"` + WebURL string `json:"web_url"` + Name string `json:"name"` + Ref string `json:"ref"` + SHA string `json:"sha"` +} + +type glAPIError struct { StatusCode int Message string Details string } -func (e *ghAPIError) Error() string { +func (e *glAPIError) Error() string { if e.Details != "" { - return fmt.Sprintf("github: API error %d: %s (%s)", e.StatusCode, e.Message, e.Details) + return fmt.Sprintf("gitlab: API error %d: %s (%s)", e.StatusCode, e.Message, e.Details) } - return fmt.Sprintf("github: API error %d: %s", e.StatusCode, e.Message) + return fmt.Sprintf("gitlab: API error %d: %s", e.StatusCode, e.Message) +} + +// glClient talks to a GitLab REST API v4 instance with a Personal, Project, or +// Group Access Token (all use the same PRIVATE-TOKEN / Bearer auth). +type glClient struct { + token string + baseURL string // e.g. https://gitlab.com/api/v4 + hostURL string // e.g. https://gitlab.com } -// ghClient is a GitHub REST API v3 client authenticated via a PAT. -// All outbound HTTP is proxied through the paca.fetch WASM host function, -// which enforces domain allowlisting configured in plugin.json. -type ghClient struct { - token string +func normalizeInstanceURL(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return defaultGitLabInstance + } + if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") { + s = "https://" + s + } + return strings.TrimRight(s, "/") } -func newGHClient(token string) *ghClient { - return &ghClient{token: token} +func newGLClient(token, instanceURL string) *glClient { + host := normalizeInstanceURL(instanceURL) + return &glClient{ + token: token, + hostURL: host, + baseURL: host + "/api/v4", + } } -func (c *ghClient) headers() map[string]string { +func (c *glClient) headers() map[string]string { return map[string]string{ - "Authorization": "Bearer " + c.token, - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": ghAPIVersion, + "PRIVATE-TOKEN": c.token, + "Accept": "application/json", } } -func (c *ghClient) get(_ context.Context, rawURL string, out any) error { +func (c *glClient) projectPath(owner, repo string) string { + full := strings.Trim(owner, "/") + "/" + strings.Trim(repo, "/") + return url.PathEscape(full) // encodes slash as %2F — required by GitLab +} + +func (c *glClient) get(_ context.Context, rawURL string, out any) error { resp, err := plugin.Fetch("GET", rawURL, c.headers(), "") if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) + return fmt.Errorf("gitlabclient: execute request: %w", err) } if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) + return glParseAPIError(resp.Status, resp.Body) } if out != nil { if err := json.Unmarshal([]byte(resp.Body), out); err != nil { - return fmt.Errorf("githubclient: decode response: %w", err) + return fmt.Errorf("gitlabclient: decode response: %w", err) } } return nil } -func (c *ghClient) post(_ context.Context, rawURL string, body, out any) error { +func (c *glClient) post(_ context.Context, rawURL string, body, out any) error { bodyJSON, err := json.Marshal(body) if err != nil { - return fmt.Errorf("githubclient: encode body: %w", err) + return fmt.Errorf("gitlabclient: encode body: %w", err) } hdrs := c.headers() hdrs["Content-Type"] = "application/json" resp, err := plugin.Fetch("POST", rawURL, hdrs, string(bodyJSON)) if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) + return fmt.Errorf("gitlabclient: execute request: %w", err) } if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) + return glParseAPIError(resp.Status, resp.Body) } - if out != nil { + if out != nil && strings.TrimSpace(resp.Body) != "" { if err := json.Unmarshal([]byte(resp.Body), out); err != nil { - return fmt.Errorf("githubclient: decode response: %w", err) + return fmt.Errorf("gitlabclient: decode response: %w", err) } } return nil } -func (c *ghClient) doDelete(_ context.Context, rawURL string) error { +func (c *glClient) put(_ context.Context, rawURL string, body, out any) error { + bodyJSON, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("gitlabclient: encode body: %w", err) + } + hdrs := c.headers() + hdrs["Content-Type"] = "application/json" + resp, err := plugin.Fetch("PUT", rawURL, hdrs, string(bodyJSON)) + if err != nil { + return fmt.Errorf("gitlabclient: execute request: %w", err) + } + if resp.Status >= 400 { + return glParseAPIError(resp.Status, resp.Body) + } + if out != nil && strings.TrimSpace(resp.Body) != "" { + if err := json.Unmarshal([]byte(resp.Body), out); err != nil { + return fmt.Errorf("gitlabclient: decode response: %w", err) + } + } + return nil +} + +func (c *glClient) doDelete(_ context.Context, rawURL string) error { resp, err := plugin.Fetch("DELETE", rawURL, c.headers(), "") if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) + return fmt.Errorf("gitlabclient: execute request: %w", err) } if resp.Status == 404 || resp.Status == 204 { return nil } if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) + return glParseAPIError(resp.Status, resp.Body) } return nil } -func ghParseAPIError(statusCode int, body string) error { +func glParseAPIError(statusCode int, body string) error { var errBody struct { - Message string `json:"message"` - Errors []struct { - Field string `json:"field"` - Message string `json:"message"` - } `json:"errors"` + Message any `json:"message"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` } _ = json.Unmarshal([]byte(body), &errBody) - if errBody.Message == "" { - errBody.Message = fmt.Sprintf("HTTP %d", statusCode) - } - details := make([]string, 0, len(errBody.Errors)) - for _, e := range errBody.Errors { - msg := strings.TrimSpace(e.Message) - if msg == "" { - continue - } - if e.Field != "" { - details = append(details, fmt.Sprintf("%s: %s", e.Field, msg)) - } else { - details = append(details, msg) + msg := errBody.Error + if msg == "" { + switch v := errBody.Message.(type) { + case string: + msg = v + case map[string]any: + b, _ := json.Marshal(v) + msg = string(b) + default: + if errBody.ErrorDescription != "" { + msg = errBody.ErrorDescription + } } } - return &ghAPIError{ - StatusCode: statusCode, - Message: errBody.Message, - Details: strings.Join(details, "; "), + if msg == "" { + msg = fmt.Sprintf("HTTP %d", statusCode) } + return &glAPIError{StatusCode: statusCode, Message: msg} } // ─── API methods ───────────────────────────────────────────────────────────── -func (c *ghClient) validateToken(ctx context.Context) error { +func (c *glClient) validateToken(ctx context.Context) error { var user struct { - Login string `json:"login"` + ID int64 `json:"id"` + Username string `json:"username"` } - return c.get(ctx, ghBaseURL+"/user", &user) + return c.get(ctx, c.baseURL+"/user", &user) } -func (c *ghClient) listRepositories(ctx context.Context) ([]ghRepository, error) { - seen := make(map[string]struct{}) - var all []ghRepository - - addRepo := func(r ghRepository) { - if _, ok := seen[r.FullName]; ok { - return - } - seen[r.FullName] = struct{}{} - all = append(all, r) - } - - // 1. Repos directly accessible to the user (owned + collaborator + org member). +func (c *glClient) listRepositories(ctx context.Context) ([]glProject, error) { + seen := make(map[int64]struct{}) + var all []glProject for page := 1; ; page++ { - url := fmt.Sprintf("%s/user/repos?visibility=all&affiliation=owner,collaborator,organization_member&per_page=100&page=%d", ghBaseURL, page) - var batch []ghRepository - if err := c.get(ctx, url, &batch); err != nil { + u := fmt.Sprintf("%s/projects?membership=true&simple=false&per_page=100&page=%d&order_by=updated_at&sort=desc", c.baseURL, page) + var batch []glProject + if err := c.get(ctx, u, &batch); err != nil { return nil, err } - for _, r := range batch { - addRepo(r) - } - if len(batch) < 100 { - break - } - } - - // 2. List all orgs the user belongs to, then fetch each org's repos - // explicitly. This catches orgs where the membership is private or - // where the token has "read:org" but not full org repo visibility. - var orgs []struct { - Login string `json:"login"` - } - for page := 1; ; page++ { - url := fmt.Sprintf("%s/user/orgs?per_page=100&page=%d", ghBaseURL, page) - var batch []struct { - Login string `json:"login"` - } - if err := c.get(ctx, url, &batch); err != nil { - // Non-fatal: proceed with what we have from /user/repos. - break + for _, p := range batch { + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + all = append(all, p) } - orgs = append(orgs, batch...) if len(batch) < 100 { break } } - - for _, org := range orgs { - for page := 1; ; page++ { - url := fmt.Sprintf("%s/orgs/%s/repos?type=all&per_page=100&page=%d", ghBaseURL, org.Login, page) - var batch []ghRepository - if err := c.get(ctx, url, &batch); err != nil { - // Skip orgs where the token lacks access. - break - } - for _, r := range batch { - addRepo(r) - } - if len(batch) < 100 { - break - } - } - } - return all, nil } -func (c *ghClient) getRepository(ctx context.Context, owner, repo string) (*ghRepository, error) { - url := fmt.Sprintf("%s/repos/%s/%s", ghBaseURL, owner, repo) - var r ghRepository - if err := c.get(ctx, url, &r); err != nil { +func (c *glClient) getRepository(ctx context.Context, owner, repo string) (*glProject, error) { + u := fmt.Sprintf("%s/projects/%s", c.baseURL, c.projectPath(owner, repo)) + var p glProject + if err := c.get(ctx, u, &p); err != nil { return nil, err } - return &r, nil + return &p, nil +} + +// generateSigningToken creates a Standard Webhooks-style signing token +// (whsec_ + base64 of 32 random bytes) for GitLab 19+ project hooks. +func generateSigningToken() (string, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return "", err + } + return "whsec_" + base64.StdEncoding.EncodeToString(key), nil } -func (c *ghClient) createWebhook(ctx context.Context, owner, repo, webhookURL, secret string, events []string) (int64, error) { - url := fmt.Sprintf("%s/repos/%s/%s/hooks", ghBaseURL, owner, repo) +func (c *glClient) createWebhook(ctx context.Context, owner, repo, webhookURL, signingToken string) (int64, error) { + u := fmt.Sprintf("%s/projects/%s/hooks", c.baseURL, c.projectPath(owner, repo)) body := map[string]any{ - "name": "web", - "active": true, - "events": events, - "config": map[string]string{ - "url": webhookURL, - "content_type": "json", - "secret": secret, - }, + "url": webhookURL, + "push_events": true, + "merge_requests_events": true, + "pipeline_events": true, + "enable_ssl_verification": true, + "signing_token": signingToken, } var resp struct { ID int64 `json:"id"` } - if err := c.post(ctx, url, body, &resp); err != nil { + if err := c.post(ctx, u, body, &resp); err != nil { return 0, err } return resp.ID, nil } -func (c *ghClient) deleteWebhook(ctx context.Context, owner, repo string, hookID int64) error { - url := fmt.Sprintf("%s/repos/%s/%s/hooks/%d", ghBaseURL, owner, repo, hookID) - return c.doDelete(ctx, url) +func (c *glClient) deleteWebhook(ctx context.Context, owner, repo string, hookID int64) error { + u := fmt.Sprintf("%s/projects/%s/hooks/%d", c.baseURL, c.projectPath(owner, repo), hookID) + return c.doDelete(ctx, u) } -func (c *ghClient) getPullRequest(ctx context.Context, owner, repo string, prNumber int) (*ghPullRequest, error) { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", ghBaseURL, owner, repo, prNumber) - var pr ghPullRequest - if err := c.get(ctx, url, &pr); err != nil { +func (c *glClient) getPullRequest(ctx context.Context, owner, repo string, mrIID int) (*glMergeRequest, error) { + u := fmt.Sprintf("%s/projects/%s/merge_requests/%d", c.baseURL, c.projectPath(owner, repo), mrIID) + var mr glMergeRequest + if err := c.get(ctx, u, &mr); err != nil { return nil, err } - return &pr, nil + return &mr, nil } -func (c *ghClient) createPullRequest(ctx context.Context, owner, repo, title, head, base, body string) (*ghPullRequest, error) { - url := fmt.Sprintf("%s/repos/%s/%s/pulls", ghBaseURL, owner, repo) - reqBody := map[string]string{ - "title": title, - "head": head, - "base": base, +func (c *glClient) createPullRequest(ctx context.Context, owner, repo, title, sourceBranch, targetBranch, body string) (*glMergeRequest, error) { + u := fmt.Sprintf("%s/projects/%s/merge_requests", c.baseURL, c.projectPath(owner, repo)) + reqBody := map[string]any{ + "title": title, + "source_branch": sourceBranch, + "target_branch": targetBranch, } if body != "" { - reqBody["body"] = body + reqBody["description"] = body } - var pr ghPullRequest - if err := c.post(ctx, url, reqBody, &pr); err != nil { + var mr glMergeRequest + if err := c.post(ctx, u, reqBody, &mr); err != nil { return nil, err } - return &pr, nil + return &mr, nil } -// mergePullRequest merges a pull request via PUT /pulls/{number}/merge. -// mergeMethod is one of "merge" | "squash" | "rebase" (GitHub defaults to -// "merge" if empty, but callers should always pass an explicit value). -func (c *ghClient) mergePullRequest(ctx context.Context, owner, repo string, prNumber int, mergeMethod string) error { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/merge", ghBaseURL, owner, repo, prNumber) - body := map[string]string{"merge_method": mergeMethod} - bodyJSON, err := json.Marshal(body) - if err != nil { - return fmt.Errorf("githubclient: encode body: %w", err) - } - hdrs := c.headers() - hdrs["Content-Type"] = "application/json" - resp, err := plugin.Fetch("PUT", url, hdrs, string(bodyJSON)) - if err != nil { - return fmt.Errorf("githubclient: execute request: %w", err) - } - if resp.Status >= 400 { - return ghParseAPIError(resp.Status, resp.Body) - } - return nil +func (c *glClient) mergePullRequest(ctx context.Context, owner, repo string, mrIID int, mergeMethod string) error { + u := fmt.Sprintf("%s/projects/%s/merge_requests/%d/merge", c.baseURL, c.projectPath(owner, repo), mrIID) + body := map[string]any{} + switch mergeMethod { + case "squash": + body["squash"] = true + case "rebase": + // GitLab merges after rebase when merge_when_pipeline_succeeds / FF settings allow; + // request squash=false and rely on project merge method when possible. + body["squash"] = false + default: + body["squash"] = false + } + return c.put(ctx, u, body, nil) } -// getPullRequestDiff fetches the unified diff for a pull request via GitHub's -// diff media type. Unlike get(), the response body is raw diff text, not -// JSON, so it bypasses get()'s json.Unmarshal step. -func (c *ghClient) getPullRequestDiff(_ context.Context, owner, repo string, prNumber int) (string, error) { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", ghBaseURL, owner, repo, prNumber) - hdrs := c.headers() - hdrs["Accept"] = "application/vnd.github.v3.diff" - resp, err := plugin.Fetch("GET", url, hdrs, "") - if err != nil { - return "", fmt.Errorf("githubclient: execute request: %w", err) +func (c *glClient) getPullRequestDiff(ctx context.Context, owner, repo string, mrIID int) (string, error) { + u := fmt.Sprintf("%s/projects/%s/merge_requests/%d/changes", c.baseURL, c.projectPath(owner, repo), mrIID) + var resp struct { + Changes []struct { + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` + Diff string `json:"diff"` + } `json:"changes"` } - if resp.Status >= 400 { - return "", ghParseAPIError(resp.Status, resp.Body) + if err := c.get(ctx, u, &resp); err != nil { + return "", err + } + var b strings.Builder + for _, ch := range resp.Changes { + fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n%s\n", ch.OldPath, ch.NewPath, ch.Diff) } - return resp.Body, nil + return b.String(), nil } -// createPullRequestReview submits a review on a pull request. event must be -// one of "APPROVE", "REQUEST_CHANGES", or "COMMENT" (GitHub requires a -// non-empty body for the latter two). -func (c *ghClient) createPullRequestReview(ctx context.Context, owner, repo string, prNumber int, event, body string) error { - url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews", ghBaseURL, owner, repo, prNumber) - reqBody := map[string]string{"event": event} - if body != "" { - reqBody["body"] = body +// createPullRequestReview maps GitHub-style review events onto GitLab: +// COMMENT → MR note; APPROVE → approvals API; REQUEST_CHANGES → blocking-style note + unapprove. +func (c *glClient) createPullRequestReview(ctx context.Context, owner, repo string, mrIID int, event, body string) error { + switch strings.ToUpper(event) { + case "APPROVE": + if body != "" { + _ = c.createIssueComment(ctx, owner, repo, mrIID, body) + } + u := fmt.Sprintf("%s/projects/%s/merge_requests/%d/approve", c.baseURL, c.projectPath(owner, repo), mrIID) + return c.post(ctx, u, map[string]any{}, nil) + case "REQUEST_CHANGES": + note := body + if note == "" { + note = "Changes requested." + } else { + note = "**Changes requested**\n\n" + note + } + if err := c.createIssueComment(ctx, owner, repo, mrIID, note); err != nil { + return err + } + // Best-effort: remove prior approval so the MR is no longer approved. + u := fmt.Sprintf("%s/projects/%s/merge_requests/%d/unapprove", c.baseURL, c.projectPath(owner, repo), mrIID) + _ = c.post(ctx, u, map[string]any{}, nil) + return nil + case "COMMENT": + return c.createIssueComment(ctx, owner, repo, mrIID, body) + default: + return fmt.Errorf("gitlabclient: unsupported review event %q", event) } - return c.post(ctx, url, reqBody, nil) } -// createIssueComment adds a general (non-review) comment to a pull request. -// GitHub represents PR comments as issue comments under /issues/{number}/comments. -func (c *ghClient) createIssueComment(ctx context.Context, owner, repo string, prNumber int, body string) error { - url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", ghBaseURL, owner, repo, prNumber) - return c.post(ctx, url, map[string]string{"body": body}, nil) +func (c *glClient) createIssueComment(ctx context.Context, owner, repo string, mrIID int, body string) error { + u := fmt.Sprintf("%s/projects/%s/merge_requests/%d/notes", c.baseURL, c.projectPath(owner, repo), mrIID) + return c.post(ctx, u, map[string]string{"body": body}, nil) } -// getCombinedStatus fetches legacy commit statuses posted for ref (a SHA, -// branch, or tag name). -func (c *ghClient) getCombinedStatus(ctx context.Context, owner, repo, ref string) (*ghCombinedStatus, error) { - url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/status", ghBaseURL, owner, repo, ref) - var status ghCombinedStatus - if err := c.get(ctx, url, &status); err != nil { +func (c *glClient) listMergeRequestPipelines(ctx context.Context, owner, repo string, mrIID int) ([]glPipeline, error) { + u := fmt.Sprintf("%s/projects/%s/merge_requests/%d/pipelines", c.baseURL, c.projectPath(owner, repo), mrIID) + var pipelines []glPipeline + if err := c.get(ctx, u, &pipelines); err != nil { return nil, err } - return &status, nil + return pipelines, nil } -// getCheckRuns fetches check runs (GitHub Actions and other Checks-API-based -// CI) for ref (a SHA, branch, or tag name). -func (c *ghClient) getCheckRuns(ctx context.Context, owner, repo, ref string) (*ghCheckRunsResponse, error) { - url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-runs", ghBaseURL, owner, repo, ref) - var result ghCheckRunsResponse - if err := c.get(ctx, url, &result); err != nil { - return nil, err - } - return &result, nil +func (c *glClient) branchExists(ctx context.Context, owner, repo, branch string) error { + u := fmt.Sprintf("%s/projects/%s/repository/branches/%s", + c.baseURL, c.projectPath(owner, repo), url.PathEscape(branch)) + return c.get(ctx, u, &struct{}{}) +} + +func (c *glClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error { + u := fmt.Sprintf("%s/projects/%s/repository/branches", c.baseURL, c.projectPath(owner, repo)) + return c.post(ctx, u, map[string]string{ + "branch": newBranch, + "ref": sourceBranch, + }, nil) } -// ghBranchHeadRefURL builds GET /repos/{owner}/{repo}/git/ref/heads/{branch}. -// Branch names may contain slashes (e.g. fix/foo); encode the ref segment so -// the path is not split into extra URL segments. -func ghBranchHeadRefURL(owner, repo, branch string) string { - return fmt.Sprintf( - "%s/repos/%s/%s/git/ref/heads/%s", - ghBaseURL, - url.PathEscape(owner), - url.PathEscape(repo), - url.PathEscape(branch), - ) +func (c *glClient) cloneURL(pathWithNamespace string) string { + return c.hostURL + "/" + strings.Trim(pathWithNamespace, "/") + ".git" } -func (c *ghClient) branchExists(ctx context.Context, owner, repo, branch string) error { - return c.get(ctx, ghBranchHeadRefURL(owner, repo, branch), &struct{}{}) +// Compatibility aliases used by handlers still naming GitHub types in local vars. +type ghPullRequest = glMergeRequest +type ghRepository struct { + ID int64 + FullName string + Name string + DefaultBranch string + Private bool + Owner struct{ Login string } + HTTPURLToRepo string } -func (c *ghClient) createBranch(ctx context.Context, owner, repo, newBranch, sourceBranch string) error { - refURL := ghBranchHeadRefURL(owner, repo, sourceBranch) - var refResp struct { - Object struct { - SHA string `json:"sha"` - } `json:"object"` +func glProjectToGHCompat(p *glProject) *ghRepository { + if p == nil { + return nil } - if err := c.get(ctx, refURL, &refResp); err != nil { - return fmt.Errorf("resolve source branch %q: %w", sourceBranch, err) + r := &ghRepository{ + ID: p.ID, + FullName: p.PathWithNamespace, + Name: p.Path, + DefaultBranch: p.DefaultBranch, + Private: p.Private(), + HTTPURLToRepo: p.HTTPURLToRepo, } - - createURL := fmt.Sprintf("%s/repos/%s/%s/git/refs", ghBaseURL, url.PathEscape(owner), url.PathEscape(repo)) - return c.post(ctx, createURL, map[string]string{ - "ref": "refs/heads/" + newBranch, - "sha": refResp.Object.SHA, - }, nil) + r.Owner.Login = p.Owner() + return r } diff --git a/backend/client_url_test.go b/backend/client_url_test.go index d750f89..802bd01 100644 --- a/backend/client_url_test.go +++ b/backend/client_url_test.go @@ -1,67 +1,40 @@ package main -import "testing" +import ( + "net/url" + "testing" +) -func TestGhBranchHeadRefURL_EncodesSlashes(t *testing.T) { - got := ghBranchHeadRefURL("torvalds", "linux", "fix/sched-fair") - want := "https://api.github.com/repos/torvalds/linux/git/ref/heads/fix%2Fsched-fair" +func TestProjectPathEncoding(t *testing.T) { + c := newGLClient("tok", "https://gitlab.com") + got := c.projectPath("group/sub", "proj") + want := url.PathEscape("group/sub/proj") if got != want { - t.Fatalf("got %q, want %q", got, want) + t.Fatalf("projectPath = %q, want %q", got, want) } } -func TestGhBranchHeadRefURL_PlainBranch(t *testing.T) { - got := ghBranchHeadRefURL("golang", "go", "master") - want := "https://api.github.com/repos/golang/go/git/ref/heads/master" - if got != want { - t.Fatalf("got %q, want %q", got, want) - } -} - -func TestGhBranchHeadRefURL_TableDriven(t *testing.T) { - tests := []struct { - name string - owner string - repo string - branch string - want string +func TestNormalizeInstanceURL(t *testing.T) { + cases := []struct { + in, want string }{ - { - name: "release branch", - owner: "nodejs", - repo: "node", - branch: "v20.x", - want: "https://api.github.com/repos/nodejs/node/git/ref/heads/v20.x", - }, - { - name: "nested feature branch", - owner: "rust-lang", - repo: "rust", - branch: "feature/const-traits", - want: "https://api.github.com/repos/rust-lang/rust/git/ref/heads/feature%2Fconst-traits", - }, - { - name: "dependabot style branch", - owner: "django", - repo: "django", - branch: "dependabot/pip/wheel-0.43.0", - want: "https://api.github.com/repos/django/django/git/ref/heads/dependabot%2Fpip%2Fwheel-0.43.0", - }, - { - name: "owner repo with hyphen", - owner: "home-assistant", - repo: "core", - branch: "dev", - want: "https://api.github.com/repos/home-assistant/core/git/ref/heads/dev", - }, + {"", defaultGitLabInstance}, + {"https://gitlab.example.com/", "https://gitlab.example.com"}, + {"gitlab.example.com", "https://gitlab.example.com"}, + {"http://gitlab.local", "http://gitlab.local"}, } + for _, tc := range cases { + if got := normalizeInstanceURL(tc.in); got != tc.want { + t.Fatalf("normalizeInstanceURL(%q)=%q want %q", tc.in, got, tc.want) + } + } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ghBranchHeadRefURL(tt.owner, tt.repo, tt.branch) - if got != tt.want { - t.Fatalf("got %q, want %q", got, tt.want) - } - }) +func TestCloneURL(t *testing.T) { + c := newGLClient("", "https://gitlab.example.com") + got := c.cloneURL("acme/app") + want := "https://gitlab.example.com/acme/app.git" + if got != want { + t.Fatalf("cloneURL = %q, want %q", got, want) } } diff --git a/backend/crypto.go b/backend/crypto.go index b722b7c..4492879 100644 --- a/backend/crypto.go +++ b/backend/crypto.go @@ -79,7 +79,7 @@ func decryptAES(cipherHex, hexKey string) (string, error) { } // encryptionKey reads the encryption key from the plugin config. -func (p *githubPlugin) encryptionKey() (string, error) { +func (p *gitlabPlugin) encryptionKey() (string, error) { key, ok := p.cfg.Get("ENCRYPTION_KEY") if !ok || key == "" { return "", errors.New("ENCRYPTION_KEY not configured") @@ -88,7 +88,7 @@ func (p *githubPlugin) encryptionKey() (string, error) { } // encrypt encrypts plaintext with the configured AES key. -func (p *githubPlugin) encrypt(plaintext string) (string, error) { +func (p *gitlabPlugin) encrypt(plaintext string) (string, error) { key, err := p.encryptionKey() if err != nil { return "", err @@ -97,7 +97,7 @@ func (p *githubPlugin) encrypt(plaintext string) (string, error) { } // decrypt decrypts ciphertext with the configured AES key. -func (p *githubPlugin) decrypt(ciphertext string) (string, error) { +func (p *gitlabPlugin) decrypt(ciphertext string) (string, error) { key, err := p.encryptionKey() if err != nil { return "", err diff --git a/backend/go.mod b/backend/go.mod index c0bba7e..fce816c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,4 +1,4 @@ -module github.com/Paca-AI/first-party/github +module github.com/Paca-AI/first-party/gitlab go 1.24 diff --git a/backend/integration.go b/backend/integration.go index 1507d36..0402261 100644 --- a/backend/integration.go +++ b/backend/integration.go @@ -2,8 +2,6 @@ package main import ( "context" - "crypto/rand" - "encoding/hex" "errors" "fmt" "strings" @@ -14,14 +12,16 @@ import ( // ─── DTOs ──────────────────────────────────────────────────────────────────── type integrationResponse struct { - ProjectID string `json:"project_id"` - Connected bool `json:"connected"` - CreatedAt *string `json:"created_at,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` + ProjectID string `json:"project_id"` + Connected bool `json:"connected"` + InstanceURL string `json:"instance_url,omitempty"` + TokenKind string `json:"token_kind,omitempty"` + CreatedAt *string `json:"created_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` } // accessibleRepoResponse is returned by GET /integration/accessible-repos. -// It reflects data fetched live from the GitHub API. +// It reflects data fetched live from the GitLab API. type accessibleRepoResponse struct { FullName string `json:"full_name"` Owner string `json:"owner"` @@ -57,7 +57,7 @@ type repoCloneInfo struct { ExpiresAt float64 `json:"expires_at"` } -const githubPluginID = "com.paca.github" +const gitlabPluginID = "com.paca.gitlab" func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, error) { publicURL, ok := cfg.Get("PUBLIC_URL") @@ -65,32 +65,66 @@ func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, erro return "", errors.New("PUBLIC_URL is not configured") } base := strings.TrimRight(strings.TrimSpace(publicURL), "/") - return fmt.Sprintf("%s/api/v1/plugins/%s/projects/%s/webhook", base, githubPluginID, projectID), nil + return fmt.Sprintf("%s/api/v1/plugins/%s/projects/%s/webhook", base, gitlabPluginID, projectID), nil } -// ─── Helper: decrypt PAT for a project ─────────────────────────────────────── +// ─── Helper: decrypt credentials for a project ─────────────────────────────── -func (p *githubPlugin) decryptToken(projectID string) (string, error) { +type gitlabCreds struct { + Token string + InstanceURL string + TokenKind string +} + +func (p *gitlabPlugin) loadCreds(projectID string) (*gitlabCreds, error) { result, err := p.db.Query( - `SELECT access_token_enc FROM github_integrations WHERE project_id = $1`, + `SELECT access_token_enc, instance_url, token_kind FROM gitlab_integrations WHERE project_id = $1`, projectID, ) if err != nil { - return "", err + return nil, err } if len(result.Rows) == 0 { - return "", &appError{code: "GITHUB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitHub integration not found"} + return nil, &appError{code: "GITLAB_INTEGRATION_NOT_FOUND", status: 404, msg: "GitLab integration not found"} + } + sc := newRowScanner(result.Columns, result.Rows[0]) + token, err := p.decrypt(sc.str("access_token_enc")) + if err != nil { + return nil, err } - enc := newRowScanner(result.Columns, result.Rows[0]).str("access_token_enc") - return p.decrypt(enc) + instance := sc.str("instance_url") + if instance == "" { + instance = defaultGitLabInstance + } + kind := sc.str("token_kind") + if kind == "" { + kind = "personal" + } + return &gitlabCreds{Token: token, InstanceURL: instance, TokenKind: kind}, nil +} + +func (p *gitlabPlugin) decryptToken(projectID string) (string, error) { + c, err := p.loadCreds(projectID) + if err != nil { + return "", err + } + return c.Token, nil +} + +func (p *gitlabPlugin) clientForProject(projectID string) (*glClient, error) { + c, err := p.loadCreds(projectID) + if err != nil { + return nil, err + } + return newGLClient(c.Token, c.InstanceURL), nil } // ─── GET /integration ──────────────────────────────────────────────────────── -func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) getIntegration(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID result, err := p.db.Query( - `SELECT project_id, created_at, updated_at FROM github_integrations WHERE project_id = $1`, + `SELECT project_id, instance_url, token_kind, created_at, updated_at FROM gitlab_integrations WHERE project_id = $1`, projectID, ) if err != nil { @@ -105,20 +139,24 @@ func (p *githubPlugin) getIntegration(req *plugin.Request, res *plugin.Response) ca := sc.str("created_at") ua := sc.str("updated_at") ok(res, integrationResponse{ - ProjectID: sc.str("project_id"), - Connected: true, - CreatedAt: &ca, - UpdatedAt: &ua, + ProjectID: sc.str("project_id"), + Connected: true, + InstanceURL: sc.str("instance_url"), + TokenKind: sc.str("token_kind"), + CreatedAt: &ca, + UpdatedAt: &ua, }) } // ─── POST /integration/token ───────────────────────────────────────────────── -func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) setToken(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID type setTokenBody struct { - Token string `json:"token"` + Token string `json:"token"` + InstanceURL string `json:"instance_url"` + TokenKind string `json:"token_kind"` // personal | project | group } b, err := plugin.JSONBody[setTokenBody](req) if err != nil || b.Token == "" { @@ -126,12 +164,26 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { return } - // Validate against GitHub API. - ghc := newGHClient(b.Token) + instanceURL := normalizeInstanceURL(b.InstanceURL) + kind := strings.ToLower(strings.TrimSpace(b.TokenKind)) + switch kind { + case "", "personal", "pat": + kind = "personal" + case "project": + kind = "project" + case "group": + kind = "group" + default: + apiError(res, 400, "BAD_REQUEST", "token_kind must be personal, project, or group") + return + } + + // Validate against GitLab API (works for Personal, Project, and Group tokens). + ghc := newGLClient(b.Token, instanceURL) if err := ghc.validateToken(context.Background()); err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { - apiError(res, 422, "GITHUB_INVALID_TOKEN", "GitHub token is invalid or expired") + apiError(res, 422, "GITLAB_INVALID_TOKEN", "GitLab token is invalid or expired for this instance") return } apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to validate token: %s", err)) @@ -146,46 +198,49 @@ func (p *githubPlugin) setToken(req *plugin.Request, res *plugin.Response) { now := nowStr() _, err = p.db.Exec(` - INSERT INTO github_integrations (project_id, access_token_enc, created_at, updated_at) - VALUES ($1, $2, $3, $4) - ON CONFLICT (project_id) DO UPDATE SET access_token_enc = EXCLUDED.access_token_enc, updated_at = EXCLUDED.updated_at - `, projectID, enc, now, now) + INSERT INTO gitlab_integrations (project_id, access_token_enc, instance_url, token_kind, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (project_id) DO UPDATE SET + access_token_enc = EXCLUDED.access_token_enc, + instance_url = EXCLUDED.instance_url, + token_kind = EXCLUDED.token_kind, + updated_at = EXCLUDED.updated_at + `, projectID, enc, instanceURL, kind, now, now) if err != nil { apiError(res, 500, "INTERNAL_ERROR", err.Error()) return } - // Fetch back the record. result, qErr := p.db.Query( - `SELECT project_id, created_at, updated_at FROM github_integrations WHERE project_id = $1`, + `SELECT project_id, instance_url, token_kind, created_at, updated_at FROM gitlab_integrations WHERE project_id = $1`, projectID, ) if qErr != nil || len(result.Rows) == 0 { - ok(res, integrationResponse{ProjectID: projectID, Connected: true}) + ok(res, integrationResponse{ProjectID: projectID, Connected: true, InstanceURL: instanceURL, TokenKind: kind}) return } sc := newRowScanner(result.Columns, result.Rows[0]) ca := sc.str("created_at") ua := sc.str("updated_at") ok(res, integrationResponse{ - ProjectID: sc.str("project_id"), - Connected: true, - CreatedAt: &ca, - UpdatedAt: &ua, + ProjectID: sc.str("project_id"), + Connected: true, + InstanceURL: sc.str("instance_url"), + TokenKind: sc.str("token_kind"), + CreatedAt: &ca, + UpdatedAt: &ua, }) } // ─── DELETE /integration/token ─────────────────────────────────────────────── -func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID // Best-effort: delete webhooks for all linked repositories first. - token, tokenErr := p.decryptToken(projectID) - if tokenErr == nil { - ghc := newGHClient(token) + if ghc, err := p.clientForProject(projectID); err == nil { rows, qErr := p.db.Query( - `SELECT owner, repo_name, webhook_id FROM github_repositories WHERE project_id = $1`, + `SELECT owner, repo_name, webhook_id FROM gitlab_repositories WHERE project_id = $1`, projectID, ) if qErr == nil { @@ -199,7 +254,7 @@ func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { } } - _, execErr := p.db.Exec(`DELETE FROM github_integrations WHERE project_id = $1`, projectID) + _, execErr := p.db.Exec(`DELETE FROM gitlab_integrations WHERE project_id = $1`, projectID) if execErr != nil { apiError(res, 500, "INTERNAL_ERROR", execErr.Error()) return @@ -209,16 +264,15 @@ func (p *githubPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { // ─── GET /integration/accessible-repos ────────────────────────────────────── -func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } - ghc := newGHClient(token) repos, err := ghc.listRepositories(context.Background()) if err != nil { apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to list repositories: %s", err)) @@ -228,11 +282,11 @@ func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Resp items := make([]accessibleRepoResponse, len(repos)) for i, r := range repos { items[i] = accessibleRepoResponse{ - FullName: r.FullName, - Owner: r.Owner.Login, - RepoName: r.Name, + FullName: r.PathWithNamespace, + Owner: r.Owner(), + RepoName: r.Path, DefaultBranch: r.DefaultBranch, - Private: r.Private, + Private: r.Private(), } } ok(res, items) @@ -240,18 +294,25 @@ func (p *githubPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Resp // ─── GET /repositories ─────────────────────────────────────────────────────── -func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) listRepositories(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID result, err := p.db.Query(` SELECT id, project_id, owner, repo_name, full_name, default_branch, webhook_id, created_at, updated_at - FROM github_repositories WHERE project_id = $1 ORDER BY created_at ASC + FROM gitlab_repositories WHERE project_id = $1 ORDER BY created_at ASC `, projectID) if err != nil { apiError(res, 500, "INTERNAL_ERROR", err.Error()) return } + creds, _ := p.loadCreds(projectID) + host := defaultGitLabInstance + if creds != nil { + host = creds.InstanceURL + } + client := newGLClient("", host) + items := make([]repositoryResponse, 0, len(result.Rows)) for _, row := range result.Rows { sc := newRowScanner(result.Columns, row) @@ -263,7 +324,7 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons RepoName: sc.str("repo_name"), FullName: fullName, DefaultBranch: sc.str("default_branch"), - CloneURL: "https://github.com/" + fullName + ".git", + CloneURL: client.cloneURL(fullName), WebhookActive: sc.int64Val("webhook_id") > 0, CreatedAt: sc.str("created_at"), UpdatedAt: sc.str("updated_at"), @@ -274,7 +335,7 @@ func (p *githubPlugin) listRepositories(req *plugin.Request, res *plugin.Respons // ─── POST /repositories ─────────────────────────────────────────────────────── -func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) linkRepository(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID type linkRepositoryBody struct { @@ -289,82 +350,76 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) webhookURL, err := webhookURLFromPublicURL(p.cfg, projectID) if err != nil { - apiError(res, 422, "GITHUB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") + apiError(res, 422, "GITLAB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } - - ghc := newGHClient(token) ghRepo, err := ghc.getRepository(context.Background(), b.Owner, b.RepoName) if err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) && (apiErr.StatusCode == 403 || apiErr.StatusCode == 404) { - apiError(res, 422, "GITHUB_REPO_NOT_ACCESSIBLE", "Repository not accessible with the provided token") + apiError(res, 422, "GITLAB_REPO_NOT_ACCESSIBLE", "Project not accessible with the provided token") return } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to get repository: %s", err)) + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to get project: %s", err)) return } - // Check if already linked. existResult, _ := p.db.Query( - `SELECT id FROM github_repositories WHERE project_id = $1 AND full_name = $2`, - projectID, ghRepo.FullName, + `SELECT id FROM gitlab_repositories WHERE project_id = $1 AND full_name = $2`, + projectID, ghRepo.PathWithNamespace, ) if existResult != nil && len(existResult.Rows) > 0 { - apiError(res, 409, "GITHUB_REPO_ALREADY_LINKED", "Repository is already linked to this project") + apiError(res, 409, "GITLAB_REPO_ALREADY_LINKED", "Repository is already linked to this project") return } - // Fetch integration ID. - integResult, iErr := p.db.Query(`SELECT id FROM github_integrations WHERE project_id = $1`, projectID) + integResult, iErr := p.db.Query(`SELECT id FROM gitlab_integrations WHERE project_id = $1`, projectID) if iErr != nil || len(integResult.Rows) == 0 { - apiError(res, 404, "GITHUB_INTEGRATION_NOT_FOUND", "GitHub integration not found") + apiError(res, 404, "GITLAB_INTEGRATION_NOT_FOUND", "GitLab integration not found") return } integrationID := newRowScanner(integResult.Columns, integResult.Rows[0]).str("id") - // Generate webhook secret. - secretBytes := make([]byte, 32) - if _, randErr := rand.Read(secretBytes); randErr != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to generate webhook secret") + signingToken, randErr := generateSigningToken() + if randErr != nil { + apiError(res, 500, "INTERNAL_ERROR", "failed to generate webhook signing token") return } - webhookSecret := hex.EncodeToString(secretBytes) - webhookID, err := ghc.createWebhook(context.Background(), ghRepo.Owner.Login, ghRepo.Name, webhookURL, webhookSecret, - []string{"push", "pull_request", "check_run"}) + webhookID, err := ghc.createWebhook(context.Background(), ghRepo.Owner(), ghRepo.Path, webhookURL, signingToken) if err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) && isWebhookURLNotPublic(apiErr) { - apiError(res, 422, "GITHUB_WEBHOOK_URL_NOT_PUBLIC", "Webhook URL is not publicly accessible") + apiError(res, 422, "GITLAB_WEBHOOK_URL_NOT_PUBLIC", "Webhook URL is not publicly accessible") return } - apiError(res, 502, "GITHUB_WEBHOOK_CREATION_FAILED", fmt.Sprintf("failed to create webhook: %s", err)) + apiError(res, 502, "GITLAB_WEBHOOK_CREATION_FAILED", fmt.Sprintf("failed to create webhook: %s", err)) return } - encSecret, err := p.encrypt(webhookSecret) + encSecret, err := p.encrypt(signingToken) if err != nil { - _ = ghc.deleteWebhook(context.Background(), ghRepo.Owner.Login, ghRepo.Name, webhookID) - apiError(res, 500, "INTERNAL_ERROR", "failed to encrypt webhook secret") + _ = ghc.deleteWebhook(context.Background(), ghRepo.Owner(), ghRepo.Path, webhookID) + apiError(res, 500, "INTERNAL_ERROR", "failed to encrypt webhook signing token") return } now := nowStr() + owner := ghRepo.Owner() inserted, dbErr := p.db.Query(` - INSERT INTO github_repositories - (project_id, integration_id, owner, repo_name, full_name, webhook_id, webhook_secret_enc, default_branch, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9) + INSERT INTO gitlab_repositories + (project_id, integration_id, owner, repo_name, full_name, gitlab_project_id, webhook_id, webhook_secret_enc, default_branch, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$10) RETURNING id - `, projectID, integrationID, ghRepo.Owner.Login, ghRepo.Name, ghRepo.FullName, webhookID, encSecret, ghRepo.DefaultBranch, now) + `, projectID, integrationID, owner, ghRepo.Path, ghRepo.PathWithNamespace, ghRepo.ID, webhookID, encSecret, ghRepo.DefaultBranch, now) if dbErr != nil || len(inserted.Rows) == 0 { - _ = ghc.deleteWebhook(context.Background(), ghRepo.Owner.Login, ghRepo.Name, webhookID) + _ = ghc.deleteWebhook(context.Background(), owner, ghRepo.Path, webhookID) if dbErr != nil { apiError(res, 500, "INTERNAL_ERROR", dbErr.Error()) } else { @@ -377,11 +432,11 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) created(res, repositoryResponse{ ID: repoID, ProjectID: projectID, - Owner: ghRepo.Owner.Login, - RepoName: ghRepo.Name, - FullName: ghRepo.FullName, + Owner: owner, + RepoName: ghRepo.Path, + FullName: ghRepo.PathWithNamespace, DefaultBranch: ghRepo.DefaultBranch, - CloneURL: "https://github.com/" + ghRepo.FullName + ".git", + CloneURL: ghc.cloneURL(ghRepo.PathWithNamespace), WebhookActive: webhookID > 0, CreatedAt: now, UpdatedAt: now, @@ -390,13 +445,13 @@ func (p *githubPlugin) linkRepository(req *plugin.Request, res *plugin.Response) // ─── DELETE /repositories/:repoId ──────────────────────────────────────────── -func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) unlinkRepository(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID repoID := req.PathParam("repoId") // Fetch repository details to delete webhook. result, err := p.db.Query( - `SELECT owner, repo_name, webhook_id FROM github_repositories WHERE id = $1 AND project_id = $2`, + `SELECT owner, repo_name, webhook_id FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, repoID, projectID, ) if err != nil { @@ -404,7 +459,7 @@ func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Respons return } if len(result.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") + apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "Repository not found") return } sc := newRowScanner(result.Columns, result.Rows[0]) @@ -414,13 +469,12 @@ func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Respons // Best-effort: delete the webhook. if webhookID > 0 { - if token, tErr := p.decryptToken(projectID); tErr == nil { - ghc := newGHClient(token) + if ghc, tErr := p.clientForProject(projectID); tErr == nil { _ = ghc.deleteWebhook(context.Background(), owner, repoName, webhookID) } } - _, err = p.db.Exec(`DELETE FROM github_repositories WHERE id = $1`, repoID) + _, err = p.db.Exec(`DELETE FROM gitlab_repositories WHERE id = $1`, repoID) if err != nil { apiError(res, 500, "INTERNAL_ERROR", err.Error()) return @@ -430,7 +484,7 @@ func (p *githubPlugin) unlinkRepository(req *plugin.Request, res *plugin.Respons // ─── GET /repositories/:repoId/clone-info ──────────────────────────────────── -func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID repoID := req.PathParam("repoId") if repoID == "" { @@ -438,7 +492,7 @@ func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Respons return } result, err := p.db.Query( - `SELECT id, full_name, owner, repo_name FROM github_repositories WHERE project_id = $1 AND id = $2`, + `SELECT id, full_name, owner, repo_name FROM gitlab_repositories WHERE project_id = $1 AND id = $2`, projectID, repoID, ) if err != nil { @@ -446,10 +500,10 @@ func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Respons return } if len(result.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "repository not found") + apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "repository not found") return } - token, err := p.decryptToken(projectID) + creds, err := p.loadCreds(projectID) if err != nil { writeAppError(res, err) return @@ -461,8 +515,8 @@ func (p *githubPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Respons FullName: fullName, Owner: sc.str("owner"), RepoName: sc.str("repo_name"), - CloneURL: "https://github.com/" + fullName + ".git", - Token: token, + CloneURL: newGLClient("", creds.InstanceURL).cloneURL(fullName), + Token: creds.Token, ExpiresAt: 0, }) } @@ -489,7 +543,7 @@ func writeAppError(res *plugin.Response, err error) { apiError(res, 500, "INTERNAL_ERROR", err.Error()) } -func isWebhookURLNotPublic(apiErr *ghAPIError) bool { +func isWebhookURLNotPublic(apiErr *glAPIError) bool { if apiErr == nil || apiErr.StatusCode != 422 { return false } diff --git a/backend/main.go b/backend/main.go index 1875dba..712d9ee 100644 --- a/backend/main.go +++ b/backend/main.go @@ -5,7 +5,7 @@ package main import plugin "github.com/Paca-AI/plugin-sdk-go" func init() { - plugin.Run(&githubPlugin{}) + plugin.Run(&gitlabPlugin{}) } func main() {} diff --git a/backend/migrations/0001_create_github_tables.sql b/backend/migrations/0001_create_github_tables.sql deleted file mode 100644 index 8aa9331..0000000 --- a/backend/migrations/0001_create_github_tables.sql +++ /dev/null @@ -1,82 +0,0 @@ --- 0001_create_github_tables.sql --- Creates the GitHub integration tables in the plugin schema. --- Run with search_path = plugin_data_com_paca_github, public. --- --- If the core migration (000006_migrate_github_to_plugin.sql) has already --- moved tables from the public schema, the IF NOT EXISTS guards make this --- migration a safe no-op. - -CREATE TABLE IF NOT EXISTS github_integrations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - project_id UUID NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, - access_token_enc TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_github_integrations_project_id - ON github_integrations (project_id); - -CREATE TABLE IF NOT EXISTS github_repositories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - integration_id UUID NOT NULL REFERENCES github_integrations(id) ON DELETE CASCADE, - owner TEXT NOT NULL, - repo_name TEXT NOT NULL, - full_name TEXT NOT NULL, - webhook_id BIGINT NOT NULL DEFAULT 0, - webhook_secret_enc TEXT NOT NULL DEFAULT '', - default_branch TEXT NOT NULL DEFAULT 'main', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (project_id, full_name) -); - -CREATE INDEX IF NOT EXISTS idx_github_repositories_project_id - ON github_repositories (project_id); -CREATE INDEX IF NOT EXISTS idx_github_repositories_full_name - ON github_repositories (full_name); - -CREATE TABLE IF NOT EXISTS github_pull_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - repo_id UUID NOT NULL REFERENCES github_repositories(id) ON DELETE CASCADE, - pr_number INT NOT NULL, - github_pr_id BIGINT NOT NULL, - title TEXT NOT NULL DEFAULT '', - state TEXT NOT NULL DEFAULT 'open', - html_url TEXT NOT NULL DEFAULT '', - head_branch TEXT NOT NULL DEFAULT '', - base_branch TEXT NOT NULL DEFAULT '', - author TEXT NOT NULL DEFAULT '', - merged_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (repo_id, pr_number) -); - -CREATE INDEX IF NOT EXISTS idx_github_pull_requests_repo_id - ON github_pull_requests (repo_id); - -CREATE TABLE IF NOT EXISTS github_task_pr_links ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - pull_request_id UUID NOT NULL REFERENCES github_pull_requests(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (task_id, pull_request_id) -); - -CREATE INDEX IF NOT EXISTS idx_github_task_pr_links_task_id - ON github_task_pr_links (task_id); - -CREATE TABLE IF NOT EXISTS github_task_branches ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - repo_id UUID NOT NULL REFERENCES github_repositories(id) ON DELETE CASCADE, - branch_name TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (task_id, repo_id, branch_name) -); - -CREATE INDEX IF NOT EXISTS idx_github_task_branches_task_id - ON github_task_branches (task_id); diff --git a/backend/migrations/0001_create_gitlab_tables.sql b/backend/migrations/0001_create_gitlab_tables.sql new file mode 100644 index 0000000..f34c342 --- /dev/null +++ b/backend/migrations/0001_create_gitlab_tables.sql @@ -0,0 +1,81 @@ +-- 0001_create_gitlab_tables.sql +-- Creates the GitLab integration tables in the plugin schema. +-- Run with search_path = plugin_data_com_paca_gitlab, public. + +CREATE TABLE IF NOT EXISTS gitlab_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, + access_token_enc TEXT NOT NULL, + instance_url TEXT NOT NULL DEFAULT 'https://gitlab.com', + token_kind TEXT NOT NULL DEFAULT 'personal', -- personal | project | group + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_gitlab_integrations_project_id + ON gitlab_integrations (project_id); + +CREATE TABLE IF NOT EXISTS gitlab_repositories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + integration_id UUID NOT NULL REFERENCES gitlab_integrations(id) ON DELETE CASCADE, + owner TEXT NOT NULL, + repo_name TEXT NOT NULL, + full_name TEXT NOT NULL, -- path_with_namespace + gitlab_project_id BIGINT NOT NULL DEFAULT 0, + webhook_id BIGINT NOT NULL DEFAULT 0, + webhook_secret_enc TEXT NOT NULL DEFAULT '', -- signing_token (whsec_...) + default_branch TEXT NOT NULL DEFAULT 'main', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, full_name) +); + +CREATE INDEX IF NOT EXISTS idx_gitlab_repositories_project_id + ON gitlab_repositories (project_id); +CREATE INDEX IF NOT EXISTS idx_gitlab_repositories_full_name + ON gitlab_repositories (full_name); + +CREATE TABLE IF NOT EXISTS gitlab_merge_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + repo_id UUID NOT NULL REFERENCES gitlab_repositories(id) ON DELETE CASCADE, + pr_number INT NOT NULL, -- GitLab MR iid (project-scoped) + gitlab_mr_id BIGINT NOT NULL, + title TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'open', + html_url TEXT NOT NULL DEFAULT '', + head_branch TEXT NOT NULL DEFAULT '', + base_branch TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + merged_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (repo_id, pr_number) +); + +CREATE INDEX IF NOT EXISTS idx_gitlab_merge_requests_repo_id + ON gitlab_merge_requests (repo_id); + +CREATE TABLE IF NOT EXISTS gitlab_task_mr_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + merge_request_id UUID NOT NULL REFERENCES gitlab_merge_requests(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (task_id, merge_request_id) +); + +CREATE INDEX IF NOT EXISTS idx_gitlab_task_mr_links_task_id + ON gitlab_task_mr_links (task_id); + +CREATE TABLE IF NOT EXISTS gitlab_task_branches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + repo_id UUID NOT NULL REFERENCES gitlab_repositories(id) ON DELETE CASCADE, + branch_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (task_id, repo_id, branch_name) +); + +CREATE INDEX IF NOT EXISTS idx_gitlab_task_branches_task_id + ON gitlab_task_branches (task_id); diff --git a/backend/plugin.go b/backend/plugin.go index 0010b64..3e8cafd 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -1,6 +1,6 @@ -// Package main implements the com.paca.github backend WASM plugin. +// Package main implements the com.paca.gitlab backend WASM plugin. // -// It manages GitHub integration for projects: PAT storage, repository linking +// It manages GitLab integration for projects: PAT storage, repository linking // with automatic webhook creation, pull-request caching, branch creation, and // webhook event processing. package main @@ -17,15 +17,15 @@ func nowStr() string { return time.Now().UTC().Format(time.RFC3339Nano) } -// githubPlugin implements plugin.Plugin. -type githubPlugin struct { +// gitlabPlugin implements plugin.Plugin. +type gitlabPlugin struct { db *plugin.DB log *plugin.Logger cfg *plugin.Config } // Init registers all routes and event handlers on the provided context. -func (p *githubPlugin) Init(ctx *plugin.Context) error { +func (p *gitlabPlugin) Init(ctx *plugin.Context) error { p.db = ctx.DB() p.log = ctx.Log() p.cfg = ctx.Config() @@ -34,7 +34,7 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { ctx.On("task.deleted", p.handleTaskDeleted) ctx.On("project.deleted", p.handleProjectDeleted) - // ── Integration (GitHub token / connection) ─────────────────────────────── + // ── Integration (GitLab token / connection) ─────────────────────────────── ctx.Route("GET", "/integration", p.getIntegration) ctx.Route("POST", "/integration/token", p.setToken) ctx.Route("DELETE", "/integration/token", p.deleteToken) @@ -69,7 +69,7 @@ func (p *githubPlugin) Init(ctx *plugin.Context) error { } // Shutdown is a no-op for this plugin. -func (p *githubPlugin) Shutdown() {} +func (p *gitlabPlugin) Shutdown() {} // ─── envelope helpers ──────────────────────────────────────────────────────── @@ -101,12 +101,12 @@ func apiError(res *plugin.Response, code int, errCode, message string) { // taskBelongsToProject verifies taskID exists, is not deleted, and belongs // to projectID — writing a 404 and returning false otherwise. Every handler // that accepts a :taskId path param and uses it to read or write -// project-scoped GitHub data (PRs, branches) must call this first: the +// project-scoped GitLab data (PRs, branches) must call this first: the // host's route-level permission check only verifies the caller belongs to // the project in the URL, it has no way to also verify an arbitrary path // param like :taskId belongs to that same project — that's this plugin's // job, the same role resolvePRForTask plays for PR-specific resources. -func (p *githubPlugin) taskBelongsToProject(taskID, projectID string, res *plugin.Response) bool { +func (p *gitlabPlugin) taskBelongsToProject(taskID, projectID string, res *plugin.Response) bool { result, err := p.db.Query( `SELECT id FROM tasks WHERE id = $1 AND project_id = $2 AND deleted_at IS NULL`, taskID, projectID, @@ -125,19 +125,19 @@ func (p *githubPlugin) taskBelongsToProject(taskID, projectID string, res *plugi // ─── event handlers ────────────────────────────────────────────────────────── // handleTaskDeleted cleans up branches and PR links when a task is deleted. -func (p *githubPlugin) handleTaskDeleted(evt *plugin.Event) { +func (p *gitlabPlugin) handleTaskDeleted(evt *plugin.Event) { var payload struct { TaskID string `json:"task_id"` } if err := json.Unmarshal(evt.Payload, &payload); err != nil || payload.TaskID == "" { return } - _, _ = p.db.Exec(`DELETE FROM github_task_branches WHERE task_id = $1`, payload.TaskID) - _, _ = p.db.Exec(`DELETE FROM github_task_pr_links WHERE task_id = $1`, payload.TaskID) + _, _ = p.db.Exec(`DELETE FROM gitlab_task_branches WHERE task_id = $1`, payload.TaskID) + _, _ = p.db.Exec(`DELETE FROM gitlab_task_mr_links WHERE task_id = $1`, payload.TaskID) } // handleProjectDeleted cleans up the full integration when a project is deleted. -func (p *githubPlugin) handleProjectDeleted(evt *plugin.Event) { +func (p *gitlabPlugin) handleProjectDeleted(evt *plugin.Event) { var payload struct { ProjectID string `json:"project_id"` } @@ -145,5 +145,5 @@ func (p *githubPlugin) handleProjectDeleted(evt *plugin.Event) { return } // Cascade via FK, but also be explicit. - _, _ = p.db.Exec(`DELETE FROM github_integrations WHERE project_id = $1`, payload.ProjectID) + _, _ = p.db.Exec(`DELETE FROM gitlab_integrations WHERE project_id = $1`, payload.ProjectID) } diff --git a/backend/plugin_test.go b/backend/plugin_test.go index cb2a3cd..0fd66b2 100644 --- a/backend/plugin_test.go +++ b/backend/plugin_test.go @@ -3,8 +3,9 @@ package main import ( "crypto/hmac" "crypto/sha256" - "encoding/hex" + "encoding/base64" "encoding/json" + "strings" "testing" plugin "github.com/Paca-AI/plugin-sdk-go" @@ -28,25 +29,25 @@ func setupPlugin(t *testing.T) *plugintest.Context { }) // Seed empty plugin tables so queries return empty result sets instead of errors. - tc.DB.SeedRows("github_integrations", + tc.DB.SeedRows("gitlab_integrations", []string{"id", "project_id", "access_token_enc", "created_at", "updated_at"}, nil) - tc.DB.SeedRows("github_repositories", + tc.DB.SeedRows("gitlab_repositories", []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, nil) - tc.DB.SeedRows("github_pull_requests", - []string{"id", "project_id", "repo_id", "pr_number", "github_pr_id", "title", + tc.DB.SeedRows("gitlab_merge_requests", + []string{"id", "project_id", "repo_id", "pr_number", "gitlab_mr_id", "title", "state", "html_url", "head_branch", "base_branch", "author", "merged_at", "created_at", "updated_at"}, nil) - tc.DB.SeedRows("github_task_pr_links", - []string{"id", "task_id", "pull_request_id", "created_at"}, + tc.DB.SeedRows("gitlab_task_mr_links", + []string{"id", "task_id", "merge_request_id", "created_at"}, nil) - tc.DB.SeedRows("github_task_branches", + tc.DB.SeedRows("gitlab_task_branches", []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, nil) - var p githubPlugin + var p gitlabPlugin if err := p.Init(tc.PluginContext()); err != nil { t.Fatal("Init failed:", err) } @@ -93,9 +94,9 @@ func TestGetIntegration_NotConnected(t *testing.T) { // ── PR review/comment tests ──────────────────────────────────────────────────── // -// These cover the paths reachable without an outbound GitHub API call -// (validation and "PR not linked to this task"). The actual GitHub call -// (ghClient -> plugin.Fetch) always errors outside a WASM build — see +// These cover the paths reachable without an outbound GitLab API call +// (validation and "PR not linked to this task"). The actual GitLab call +// (glClient -> plugin.Fetch) always errors outside a WASM build — see // plugin-sdk-go's native_backends.go — so the happy path for these three // handlers, like the existing createPullRequest handler, isn't unit-testable // here and is covered manually / by integration testing instead. @@ -235,8 +236,8 @@ func TestCreateBranch_MissingRepoID(t *testing.T) { // with tasks.read/write on their own project could substitute a foreign // taskId to read or attach records to a completely different project's // task. taskBelongsToProject now runs before any of that, so these all -// resolve to a 404 before ever reaching a GitHub API call or a DB write — -// which is also what keeps them unit-testable, since the outbound GitHub +// resolve to a 404 before ever reaching a GitLab API call or a DB write — +// which is also what keeps them unit-testable, since the outbound GitLab // call itself always errors outside a real WASM build (see the comment // above the PR review/comment tests). @@ -287,12 +288,12 @@ func TestListTaskBranches_CrossProjectTaskRejected(t *testing.T) { // wouldn't reach. func TestListTaskBranches_ReturnsOwnProjectBranches(t *testing.T) { tc := setupPlugin(t) - tc.DB.SeedRows("github_repositories", + tc.DB.SeedRows("gitlab_repositories", []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, [][]any{{"repo-1", testProjectID, "integration-1", "octocat", "hello-world", "octocat/hello-world", "wh-1", "enc-secret", "main", "now", "now"}}) - tc.DB.SeedRows("github_task_branches", + tc.DB.SeedRows("gitlab_task_branches", []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, [][]any{{"branch-1", testTaskID, "repo-1", "feature/x", "now"}}) @@ -312,18 +313,18 @@ func TestListTaskBranches_ReturnsOwnProjectBranches(t *testing.T) { } // TestListTaskBranches_SkipsRepoFromAnotherProject mirrors -// TestListTaskPRs_SkipsLinkFromAnotherProject: github_task_branches has no +// TestListTaskPRs_SkipsLinkFromAnotherProject: gitlab_task_branches has no // project_id of its own, only repo_id, so a branch row pointing at a repo // that belongs to a different project must be filtered out even when the // task itself is the caller's own. func TestListTaskBranches_SkipsRepoFromAnotherProject(t *testing.T) { tc := setupPlugin(t) - tc.DB.SeedRows("github_repositories", + tc.DB.SeedRows("gitlab_repositories", []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, [][]any{{"repo-foreign", "other-project", "integration-1", "octocat", "other-repo", "octocat/other-repo", "wh-1", "enc-secret", "main", "now", "now"}}) - tc.DB.SeedRows("github_task_branches", + tc.DB.SeedRows("gitlab_task_branches", []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, [][]any{{"branch-1", testTaskID, "repo-foreign", "feature/x", "now"}}) @@ -398,20 +399,20 @@ func TestUnlinkPRFromTask_CrossProjectTaskRejected(t *testing.T) { // TestListTaskPRs_SkipsLinkFromAnotherProject covers the defense-in-depth // layer on top of taskBelongsToProject: even for the caller's own, -// legitimate task, a github_task_pr_links row pointing at a PR that +// legitimate task, a gitlab_task_mr_links row pointing at a PR that // actually belongs to a different project (e.g. one created by a pre-fix // caller exploiting the taskId substitution above) must not be returned. func TestListTaskPRs_SkipsLinkFromAnotherProject(t *testing.T) { tc := setupPlugin(t) - tc.DB.SeedRows("github_pull_requests", - []string{"id", "project_id", "repo_id", "pr_number", "github_pr_id", "title", + tc.DB.SeedRows("gitlab_merge_requests", + []string{"id", "project_id", "repo_id", "pr_number", "gitlab_mr_id", "title", "state", "html_url", "head_branch", "base_branch", "author", "merged_at", "created_at", "updated_at"}, [][]any{ {"pr-foreign", "other-project", "repo-1", 1, int64(1), "Someone else's PR", "open", "https://example.com", "feature", "main", "octocat", nil, "now", "now"}, }) - tc.DB.SeedRows("github_task_pr_links", - []string{"id", "task_id", "pull_request_id", "created_at"}, + tc.DB.SeedRows("gitlab_task_mr_links", + []string{"id", "task_id", "merge_request_id", "created_at"}, [][]any{{"link-1", testTaskID, "pr-foreign", "now"}}) res := tc.Call("GET", "/tasks/:taskId/pull-requests", reqWithPathParams(map[string]string{"taskId": testTaskID})) @@ -431,7 +432,7 @@ func TestListTaskPRs_SkipsLinkFromAnotherProject(t *testing.T) { // ── Webhook: project scoping + signature verification ──────────────────────── // -// receiveWebhook always responds 204 regardless of outcome (so GitHub +// receiveWebhook always responds 204 regardless of outcome (so GitLab // doesn't retry on an application-level rejection), so the status code // can't distinguish "accepted" from "rejected" the way it can for the // authenticated API routes elsewhere in this file. These tests instead @@ -442,15 +443,28 @@ func TestListTaskPRs_SkipsLinkFromAnotherProject(t *testing.T) { const testEncryptionKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 hex chars = 32 bytes -func signedWebhookRequest(projectID string, payload []byte, secret string) plugintest.Request { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(payload) - sig := "sha256=" + hex.EncodeToString(mac.Sum(nil)) +func signedWebhookRequest(projectID string, payload []byte, signingToken string) plugintest.Request { + // GitLab 19+ Standard Webhooks: sign "{id}.{timestamp}.{body}" with whsec_ key. + msgID := "msg_test_1" + ts := "1710000000" + keyPart := strings.TrimPrefix(signingToken, "whsec_") + key, err := base64.StdEncoding.DecodeString(keyPart) + if err != nil { + // Allow raw test secrets: wrap as whsec_ of the utf8 bytes encoded. + signingToken = "whsec_" + base64.StdEncoding.EncodeToString([]byte(signingToken)) + keyPart = strings.TrimPrefix(signingToken, "whsec_") + key, _ = base64.StdEncoding.DecodeString(keyPart) + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(msgID + "." + ts + "." + string(payload))) + sig := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) return plugintest.Request{ PathParams: map[string]string{"projectId": projectID}, Headers: map[string]string{ - "X-Github-Event": "workflow_run", // any type receiveWebhook doesn't specifically handle - "X-Hub-Signature-256": sig, + "X-Gitlab-Event": "Pipeline Hook", // unhandled type — proves we passed signature check + "webhook-id": msgID, + "webhook-timestamp": ts, + "webhook-signature": sig, }, Body: payload, } @@ -459,21 +473,25 @@ func signedWebhookRequest(projectID string, payload []byte, secret string) plugi func seedEncryptedRepo(t *testing.T, tc *plugintest.Context, repoID, projectID, fullName, secret string) { t.Helper() tc.Config.Set("ENCRYPTION_KEY", testEncryptionKey) + // Persist as whsec_ signing token (same form createWebhook stores). + if !strings.HasPrefix(secret, "whsec_") { + secret = "whsec_" + base64.StdEncoding.EncodeToString([]byte(secret)) + } encSecret, err := encryptAES(secret, testEncryptionKey) if err != nil { t.Fatalf("failed to encrypt test secret: %v", err) } - tc.DB.SeedRows("github_repositories", + tc.DB.SeedRows("gitlab_repositories", []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, - append(tc.DB.AllRows("github_repositories"), []any{ + append(tc.DB.AllRows("gitlab_repositories"), []any{ repoID, projectID, "integration-1", "octocat", "hello-world", fullName, "wh-1", encSecret, "main", "now", "now", })) } // TestReceiveWebhook_ScopesRepositoryLookupToURLProject pins the fix for a -// real cross-tenant interference bug: github_repositories.full_name is only +// real cross-tenant interference bug: gitlab_repositories.full_name is only // unique per-project, so two projects can legitimately link the same repo. // The old lookup (`WHERE full_name = $1`, no project_id) could resolve to // whichever row Postgres happened to return first — using a DIFFERENT @@ -489,7 +507,7 @@ func TestReceiveWebhook_ScopesRepositoryLookupToURLProject(t *testing.T) { seedEncryptedRepo(t, tc, "repo-other", "other-project", "octocat/hello-world", "other-projects-secret") seedEncryptedRepo(t, tc, "repo-mine", testProjectID, "octocat/hello-world", "my-projects-secret") - payload := []byte(`{"repository":{"full_name":"octocat/hello-world"}}`) + payload := []byte(`{"project":{"path_with_namespace":"octocat/hello-world"}}`) res := tc.Call("POST", "/webhook", signedWebhookRequest(testProjectID, payload, "my-projects-secret")) if res.StatusCode != 204 { t.Fatalf("expected 204, got %d: %s", res.StatusCode, res.BodyString()) @@ -508,23 +526,28 @@ func TestReceiveWebhook_ScopesRepositoryLookupToURLProject(t *testing.T) { // refused instead, without ever reaching event dispatch. func TestReceiveWebhook_RejectsMissingSecret(t *testing.T) { tc := setupPlugin(t) - tc.DB.SeedRows("github_repositories", + tc.DB.SeedRows("gitlab_repositories", []string{"id", "project_id", "integration_id", "owner", "repo_name", "full_name", "webhook_id", "webhook_secret_enc", "default_branch", "created_at", "updated_at"}, [][]any{{"repo-1", testProjectID, "integration-1", "octocat", "hello-world", "octocat/hello-world", "wh-1", "", "main", "now", "now"}}) - payload := []byte(`{"repository":{"full_name":"octocat/hello-world"}}`) + payload := []byte(`{"project":{"path_with_namespace":"octocat/hello-world"}}`) req := plugintest.Request{ PathParams: map[string]string{"projectId": testProjectID}, - Headers: map[string]string{"X-Github-Event": "workflow_run", "X-Hub-Signature-256": "sha256=deadbeef"}, - Body: payload, + Headers: map[string]string{ + "X-Gitlab-Event": "Pipeline Hook", + "webhook-id": "msg_x", + "webhook-timestamp": "1", + "webhook-signature": "v1,deadbeef", + }, + Body: payload, } res := tc.Call("POST", "/webhook", req) if res.StatusCode != 204 { t.Fatalf("expected 204, got %d: %s", res.StatusCode, res.BodyString()) } - if !tc.Log.HasMessage("no webhook secret configured") { + if !tc.Log.HasMessage("no webhook signing token configured") { t.Fatalf("expected the missing-secret delivery to be refused before dispatch; log entries: %+v", tc.Log.Entries()) } if tc.Log.HasMessage("unhandled event type") { diff --git a/backend/pull_requests.go b/backend/pull_requests.go index 00b4652..7b614e5 100644 --- a/backend/pull_requests.go +++ b/backend/pull_requests.go @@ -1,6 +1,7 @@ package main import ( + "strings" "context" "errors" "fmt" @@ -15,7 +16,7 @@ type pullRequestResponse struct { ProjectID string `json:"project_id"` RepoID string `json:"repo_id"` PRNumber int `json:"pr_number"` - GitHubPRID int64 `json:"github_pr_id"` + GitLabMRID int64 `json:"gitlab_mr_id"` Title string `json:"title"` State string `json:"state"` HTMLURL string `json:"html_url"` @@ -29,7 +30,7 @@ type pullRequestResponse struct { // ─── GET /tasks/:taskId/github/pull-requests ────────────────────────────────── -func (p *githubPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") if !p.taskBelongsToProject(taskID, projectID, res) { @@ -37,7 +38,7 @@ func (p *githubPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { } linkResult, err := p.db.Query( - `SELECT pull_request_id FROM github_task_pr_links WHERE task_id = $1 ORDER BY created_at ASC`, + `SELECT merge_request_id FROM gitlab_task_mr_links WHERE task_id = $1 ORDER BY created_at ASC`, taskID, ) if err != nil { @@ -53,9 +54,9 @@ func (p *githubPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { // linked across projects. items := make([]pullRequestResponse, 0, len(linkResult.Rows)) for _, linkRow := range linkResult.Rows { - prID := newRowScanner(linkResult.Columns, linkRow).str("pull_request_id") + prID := newRowScanner(linkResult.Columns, linkRow).str("merge_request_id") prResult, pErr := p.db.Query( - `SELECT id, project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at FROM github_pull_requests WHERE id = $1 AND project_id = $2`, + `SELECT id, project_id, repo_id, pr_number, gitlab_mr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at FROM gitlab_merge_requests WHERE id = $1 AND project_id = $2`, prID, projectID, ) if pErr != nil { @@ -71,7 +72,7 @@ func (p *githubPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { ProjectID: sc.str("project_id"), RepoID: sc.str("repo_id"), PRNumber: sc.intVal("pr_number"), - GitHubPRID: sc.int64Val("github_pr_id"), + GitLabMRID: sc.int64Val("gitlab_mr_id"), Title: sc.str("title"), State: sc.str("state"), HTMLURL: sc.str("html_url"), @@ -88,7 +89,7 @@ func (p *githubPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { // ─── POST /tasks/:taskId/github/pull-requests/link ─────────────────────────── -func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") if !p.taskBelongsToProject(taskID, projectID, res) { @@ -105,7 +106,7 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return @@ -113,7 +114,7 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { // Get repository details. repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, + `SELECT owner, repo_name FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, b.RepoID, projectID, ) if rErr != nil { @@ -121,24 +122,23 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { return } if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") + apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "Repository not found") return } rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) owner := rSc.str("owner") repoName := rSc.str("repo_name") - ghc := newGHClient(token) ghPR, err := ghc.getPullRequest(context.Background(), owner, repoName, b.PRNumber) if err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) { switch apiErr.StatusCode { case 404: - apiError(res, 404, "GITHUB_PR_NOT_FOUND", fmt.Sprintf("PR #%d not found in %s/%s", b.PRNumber, owner, repoName)) + apiError(res, 404, "GITLAB_PR_NOT_FOUND", fmt.Sprintf("PR #%d not found in %s/%s", b.PRNumber, owner, repoName)) return case 401, 403: - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read pull requests") + apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read pull requests") return } } @@ -146,10 +146,7 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { return } - state := ghPR.State - if ghPR.Merged { - state = "merged" - } + state := ghPR.NormalizedState() now := nowStr() @@ -161,15 +158,15 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { // Upsert the PR cache; let PostgreSQL generate id on insert, RETURNING gives us id+created_at. upserted, err := p.db.Query(` - INSERT INTO github_pull_requests - (project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) + INSERT INTO gitlab_merge_requests + (project_id, repo_id, pr_number, gitlab_mr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (repo_id, pr_number) DO UPDATE SET title=$5, state=$6, html_url=$7, head_branch=$8, base_branch=$9, author=$10, merged_at=$11, updated_at=$13 RETURNING id, created_at `, projectID, b.RepoID, b.PRNumber, ghPR.ID, ghPR.Title, state, - ghPR.HTMLURL, ghPR.Head.Ref, ghPR.Base.Ref, ghPR.User.Login, mergedAtStr, now, now) + ghPR.WebURL, ghPR.SourceBranch, ghPR.TargetBranch, ghPR.Author.Username, mergedAtStr, now, now) if err != nil || len(upserted.Rows) == 0 { if err != nil { apiError(res, 500, "INTERNAL_ERROR", err.Error()) @@ -184,20 +181,20 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { // Link the PR to the task. rowsAffected, lErr := p.db.Exec(` - INSERT INTO github_task_pr_links (task_id, pull_request_id, created_at) + INSERT INTO gitlab_task_mr_links (task_id, merge_request_id, created_at) VALUES ($1,$2,$3) - ON CONFLICT (task_id, pull_request_id) DO NOTHING + ON CONFLICT (task_id, merge_request_id) DO NOTHING `, taskID, prID, now) if lErr != nil { apiError(res, 500, "INTERNAL_ERROR", lErr.Error()) return } if rowsAffected == 0 { - apiError(res, 409, "GITHUB_PR_ALREADY_LINKED", "Pull request is already linked to this task") + apiError(res, 409, "GITLAB_PR_ALREADY_LINKED", "Pull request is already linked to this task") return } - plugin.EmitEvent("github.pr_linked", map[string]any{ + plugin.EmitEvent("gitlab.mr_linked", map[string]any{ "project_id": projectID, "task_id": taskID, "repo_id": b.RepoID, @@ -210,13 +207,13 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { ProjectID: projectID, RepoID: b.RepoID, PRNumber: b.PRNumber, - GitHubPRID: ghPR.ID, + GitLabMRID: ghPR.ID, Title: ghPR.Title, State: state, - HTMLURL: ghPR.HTMLURL, - HeadBranch: ghPR.Head.Ref, - BaseBranch: ghPR.Base.Ref, - Author: ghPR.User.Login, + HTMLURL: ghPR.WebURL, + HeadBranch: ghPR.SourceBranch, + BaseBranch: ghPR.TargetBranch, + Author: ghPR.Author.Username, MergedAt: mergedAtStr, CreatedAt: prCreatedAt, UpdatedAt: now, @@ -225,7 +222,7 @@ func (p *githubPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { // ─── POST /tasks/:taskId/github/pull-requests ───────────────────────────────── -func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) createPullRequest(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") if !p.taskBelongsToProject(taskID, projectID, res) { @@ -245,14 +242,14 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, + `SELECT owner, repo_name FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, b.RepoID, projectID, ) if rErr != nil { @@ -260,24 +257,23 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon return } if len(repoResult.Rows) == 0 { - apiError(res, 404, "GITHUB_REPOSITORY_NOT_FOUND", "Repository not found") + apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "Repository not found") return } rSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) owner := rSc.str("owner") repoName := rSc.str("repo_name") - ghc := newGHClient(token) ghPR, err := ghc.createPullRequest(context.Background(), owner, repoName, b.Title, b.HeadBranch, b.BaseBranch, b.Body) if err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) { switch apiErr.StatusCode { case 401, 403: - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create pull requests") + apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create pull requests") return case 422: - apiError(res, 422, "GITHUB_PR_VALIDATION_ERROR", fmt.Sprintf("GitHub validation error: %s", apiErr.Message)) + apiError(res, 422, "GITLAB_PR_VALIDATION_ERROR", fmt.Sprintf("GitLab validation error: %s", apiErr.Message)) return } } @@ -285,10 +281,7 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon return } - state := ghPR.State - if ghPR.Merged { - state = "merged" - } + state := ghPR.NormalizedState() now := nowStr() @@ -299,15 +292,15 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon } upserted, err := p.db.Query(` - INSERT INTO github_pull_requests - (project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) + INSERT INTO gitlab_merge_requests + (project_id, repo_id, pr_number, gitlab_mr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$12) ON CONFLICT (repo_id, pr_number) DO UPDATE SET title=$5, state=$6, html_url=$7, head_branch=$8, base_branch=$9, author=$10, merged_at=$11, updated_at=$12 RETURNING id - `, projectID, b.RepoID, ghPR.Number, ghPR.ID, ghPR.Title, state, - ghPR.HTMLURL, ghPR.Head.Ref, ghPR.Base.Ref, ghPR.User.Login, mergedAtStr, now) + `, projectID, b.RepoID, ghPR.IID, ghPR.ID, ghPR.Title, state, + ghPR.WebURL, ghPR.SourceBranch, ghPR.TargetBranch, ghPR.Author.Username, mergedAtStr, now) if err != nil || len(upserted.Rows) == 0 { if err != nil { apiError(res, 500, "INTERNAL_ERROR", err.Error()) @@ -319,9 +312,9 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon prID := newRowScanner(upserted.Columns, upserted.Rows[0]).str("id") _, lErr := p.db.Exec(` - INSERT INTO github_task_pr_links (task_id, pull_request_id, created_at) + INSERT INTO gitlab_task_mr_links (task_id, merge_request_id, created_at) VALUES ($1,$2,$3) - ON CONFLICT (task_id, pull_request_id) DO NOTHING + ON CONFLICT (task_id, merge_request_id) DO NOTHING `, taskID, prID, now) if lErr != nil { p.log.Error("failed to link PR to task: " + lErr.Error()) @@ -331,22 +324,22 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon "project_id": projectID, "task_id": taskID, "repo_id": b.RepoID, - "pr_number": ghPR.Number, - "pr_url": ghPR.HTMLURL, + "pr_number": ghPR.IID, + "pr_url": ghPR.WebURL, }) created(res, pullRequestResponse{ ID: prID, ProjectID: projectID, RepoID: b.RepoID, - PRNumber: ghPR.Number, - GitHubPRID: ghPR.ID, + PRNumber: ghPR.IID, + GitLabMRID: ghPR.ID, Title: ghPR.Title, State: state, - HTMLURL: ghPR.HTMLURL, - HeadBranch: ghPR.Head.Ref, - BaseBranch: ghPR.Base.Ref, - Author: ghPR.User.Login, + HTMLURL: ghPR.WebURL, + HeadBranch: ghPR.SourceBranch, + BaseBranch: ghPR.TargetBranch, + Author: ghPR.Author.Username, MergedAt: mergedAtStr, CreatedAt: now, UpdatedAt: now, @@ -355,7 +348,7 @@ func (p *githubPlugin) createPullRequest(req *plugin.Request, res *plugin.Respon // ─── DELETE /tasks/:taskId/github/pull-requests/:prId ──────────────────────── -func (p *githubPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") prID := req.PathParam("prId") @@ -367,7 +360,7 @@ func (p *githubPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Respons // task) before deleting the link — defense-in-depth against any link a // pre-fix caller might have already created across projects. prResult, err := p.db.Query( - `SELECT id FROM github_pull_requests WHERE id = $1 AND project_id = $2`, + `SELECT id FROM gitlab_merge_requests WHERE id = $1 AND project_id = $2`, prID, projectID, ) if err != nil { @@ -375,12 +368,12 @@ func (p *githubPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Respons return } if len(prResult.Rows) == 0 { - apiError(res, 404, "GITHUB_PR_LINK_NOT_FOUND", "Pull request link not found") + apiError(res, 404, "GITLAB_PR_LINK_NOT_FOUND", "Pull request link not found") return } rowsAffected, err := p.db.Exec( - `DELETE FROM github_task_pr_links WHERE task_id = $1 AND pull_request_id = $2`, + `DELETE FROM gitlab_task_mr_links WHERE task_id = $1 AND merge_request_id = $2`, taskID, prID, ) if err != nil { @@ -388,53 +381,53 @@ func (p *githubPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Respons return } if rowsAffected == 0 { - apiError(res, 404, "GITHUB_PR_LINK_NOT_FOUND", "Pull request link not found") + apiError(res, 404, "GITLAB_PR_LINK_NOT_FOUND", "Pull request link not found") return } noContent(res) } -// ─── Shared: resolve a PR's GitHub coordinates for a task ──────────────────── +// ─── Shared: resolve a PR's GitLab coordinates for a task ──────────────────── -// resolvePRForTask looks up the owner/repo/PR-number GitHub needs to act on a +// resolvePRForTask looks up the owner/repo/PR-number GitLab needs to act on a // PR, verifying in the same query that prID is actually linked to taskID // within this project. Used by every handler below so a caller can't act on // a PR outside the task (or project) it claims to be operating in. -func (p *githubPlugin) resolvePRForTask(projectID, taskID, prID string) (owner, repoName string, prNumber int, err error) { +func (p *gitlabPlugin) resolvePRForTask(projectID, taskID, prID string) (owner, repoName string, prNumber int, err error) { linkResult, lErr := p.db.Query( - `SELECT pull_request_id FROM github_task_pr_links WHERE task_id = $1 AND pull_request_id = $2`, + `SELECT merge_request_id FROM gitlab_task_mr_links WHERE task_id = $1 AND merge_request_id = $2`, taskID, prID, ) if lErr != nil { return "", "", 0, lErr } if len(linkResult.Rows) == 0 { - return "", "", 0, &appError{code: "GITHUB_PR_LINK_NOT_FOUND", status: 404, msg: "Pull request link not found"} + return "", "", 0, &appError{code: "GITLAB_PR_LINK_NOT_FOUND", status: 404, msg: "Pull request link not found"} } prResult, pErr := p.db.Query( - `SELECT repo_id, pr_number FROM github_pull_requests WHERE id = $1 AND project_id = $2`, + `SELECT repo_id, pr_number FROM gitlab_merge_requests WHERE id = $1 AND project_id = $2`, prID, projectID, ) if pErr != nil { return "", "", 0, pErr } if len(prResult.Rows) == 0 { - return "", "", 0, &appError{code: "GITHUB_PR_NOT_FOUND", status: 404, msg: "Pull request not found"} + return "", "", 0, &appError{code: "GITLAB_PR_NOT_FOUND", status: 404, msg: "Pull request not found"} } prSc := newRowScanner(prResult.Columns, prResult.Rows[0]) repoID := prSc.str("repo_id") prNumber = prSc.intVal("pr_number") repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM github_repositories WHERE id = $1 AND project_id = $2`, + `SELECT owner, repo_name FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, repoID, projectID, ) if rErr != nil { return "", "", 0, rErr } if len(repoResult.Rows) == 0 { - return "", "", 0, &appError{code: "GITHUB_REPOSITORY_NOT_FOUND", status: 404, msg: "Repository not found"} + return "", "", 0, &appError{code: "GITLAB_REPOSITORY_NOT_FOUND", status: 404, msg: "Repository not found"} } repoSc := newRowScanner(repoResult.Columns, repoResult.Rows[0]) return repoSc.str("owner"), repoSc.str("repo_name"), prNumber, nil @@ -453,7 +446,7 @@ type pullRequestDetailsResponse struct { Diff string `json:"diff"` } -func (p *githubPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") prID := req.PathParam("prId") @@ -463,13 +456,11 @@ func (p *githubPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Re writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } - - ghc := newGHClient(token) ctx := context.Background() ghPR, err := ghc.getPullRequest(ctx, owner, repoName, prNumber) if err != nil { @@ -487,8 +478,8 @@ func (p *githubPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Re RepoName: repoName, PRNumber: prNumber, Title: ghPR.Title, - State: ghPR.State, - HTMLURL: ghPR.HTMLURL, + State: ghPR.NormalizedState(), + HTMLURL: ghPR.WebURL, Diff: diff, }) } @@ -507,7 +498,7 @@ type ciStatusResponse struct { Checks []ciCheckResponse `json:"checks"` } -// overallCIState summarizes a set of checks the same way GitHub's own PR +// overallCIState summarizes a set of checks the same way GitLab's own PR // merge-box does: any failure wins, otherwise any still-running check makes // the whole thing pending, otherwise (and only if there's at least one // check) it's a success. No checks at all is reported as "unknown" rather @@ -535,7 +526,7 @@ func overallCIState(checks []ciCheckResponse) string { return "success" } -func (p *githubPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") prID := req.PathParam("prId") @@ -545,51 +536,36 @@ func (p *githubPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.R writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } - - ghc := newGHClient(token) ctx := context.Background() - ghPR, err := ghc.getPullRequest(ctx, owner, repoName, prNumber) - if err != nil { - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request: %s", err)) + if _, err := ghc.getPullRequest(ctx, owner, repoName, prNumber); err != nil { + apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch merge request: %s", err)) return } - sha := ghPR.Head.SHA - - checks := make([]ciCheckResponse, 0) - - if combined, cErr := ghc.getCombinedStatus(ctx, owner, repoName, sha); cErr == nil { - for _, s := range combined.Statuses { - checks = append(checks, ciCheckResponse{ - Name: s.Context, - Status: "completed", - Conclusion: s.State, - URL: s.TargetURL, - }) - } - } else { - p.log.Error(fmt.Sprintf("failed to fetch combined status for %s/%s@%s: %s", owner, repoName, sha, cErr)) + pipelines, pErr := ghc.listMergeRequestPipelines(ctx, owner, repoName, prNumber) + if pErr != nil { + p.log.Error(fmt.Sprintf("failed to fetch MR pipelines for %s/%s!%d: %s", owner, repoName, prNumber, pErr)) + ok(res, ciStatusResponse{State: "unknown", Checks: []ciCheckResponse{}}) + return } - if runs, rErr := ghc.getCheckRuns(ctx, owner, repoName, sha); rErr == nil { - for _, r := range runs.CheckRuns { - conclusion := "" - if r.Conclusion != nil { - conclusion = *r.Conclusion - } - checks = append(checks, ciCheckResponse{ - Name: r.Name, - Status: r.Status, - Conclusion: conclusion, - URL: r.HTMLURL, - }) + checks := make([]ciCheckResponse, 0, len(pipelines)) + for _, pipe := range pipelines { + status, conclusion := mapPipelineStatus(pipe.Status) + name := pipe.Name + if name == "" { + name = "pipeline #" + fmt.Sprintf("%d", pipe.ID) } - } else { - p.log.Error(fmt.Sprintf("failed to fetch check runs for %s/%s@%s: %s", owner, repoName, sha, rErr)) + checks = append(checks, ciCheckResponse{ + Name: name, + Status: status, + Conclusion: conclusion, + URL: pipe.WebURL, + }) } ok(res, ciStatusResponse{ @@ -598,9 +574,28 @@ func (p *githubPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.R }) } +func mapPipelineStatus(status string) (runStatus, conclusion string) { + switch strings.ToLower(status) { + case "success": + return "completed", "success" + case "failed": + return "completed", "failure" + case "canceled", "cancelled": + return "completed", "cancelled" + case "skipped": + return "completed", "skipped" + case "manual", "scheduled": + return "completed", "action_required" + case "running", "pending", "created", "waiting_for_resource", "preparing": + return "in_progress", "" + default: + return status, "" + } +} + // ─── POST /tasks/:taskId/github/pull-requests/:prId/comments ───────────────── -func (p *githubPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") prID := req.PathParam("prId") @@ -619,17 +614,15 @@ func (p *githubPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Re writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } - - ghc := newGHClient(token) if err := ghc.createIssueComment(context.Background(), owner, repoName, prNumber, b.Body); err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to comment on pull requests") + apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to comment on pull requests") return } apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to add comment: %s", err)) @@ -640,7 +633,7 @@ func (p *githubPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Re // ─── POST /tasks/:taskId/github/pull-requests/:prId/reviews ────────────────── -func (p *githubPlugin) createReview(req *plugin.Request, res *plugin.Response) { +func (p *gitlabPlugin) createReview(req *plugin.Request, res *plugin.Response) { projectID := req.Caller.ProjectID taskID := req.PathParam("taskId") prID := req.PathParam("prId") @@ -666,22 +659,20 @@ func (p *githubPlugin) createReview(req *plugin.Request, res *plugin.Response) { writeAppError(res, err) return } - token, err := p.decryptToken(projectID) + ghc, err := p.clientForProject(projectID) if err != nil { writeAppError(res, err) return } - - ghc := newGHClient(token) if err := ghc.createPullRequestReview(context.Background(), owner, repoName, prNumber, b.Event, b.Body); err != nil { - var apiErr *ghAPIError + var apiErr *glAPIError if errors.As(err, &apiErr) { switch apiErr.StatusCode { case 401, 403: - apiError(res, 403, "GITHUB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to review pull requests") + apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to review pull requests") return case 422: - apiError(res, 422, "GITHUB_PR_VALIDATION_ERROR", fmt.Sprintf("GitHub validation error: %s", apiErr.Message)) + apiError(res, 422, "GITLAB_PR_VALIDATION_ERROR", fmt.Sprintf("GitLab validation error: %s", apiErr.Message)) return } } diff --git a/backend/webhook.go b/backend/webhook.go index 39e7f8f..5686775 100644 --- a/backend/webhook.go +++ b/backend/webhook.go @@ -3,7 +3,7 @@ package main import ( "crypto/hmac" "crypto/sha256" - "encoding/hex" + "encoding/base64" "encoding/json" "regexp" "strconv" @@ -18,15 +18,12 @@ var branchTaskRefRe = regexp.MustCompile(`(?i)\b([A-Z][A-Z0-9]{1,19})-(\d{1,6})\ // ─── POST /webhook ──────────────────────────────────────────────────────────── -func (p *githubPlugin) receiveWebhook(req *plugin.Request, res *plugin.Response) { - // projectId comes from the URL the integration registered with GitHub - // (.../projects/:projectId/webhook) — this route has no requirePermissions - // middleware (GitHub itself calls it, with no Paca auth), so - // req.Caller.ProjectID is never populated here; the path segment is the - // only source of truth for which project this delivery is for. +func (p *gitlabPlugin) receiveWebhook(req *plugin.Request, res *plugin.Response) { projectID := req.PathParam("projectId") - event := req.Headers["X-Github-Event"] - signature := req.Headers["X-Hub-Signature-256"] + event := headerGet(req.Headers, "X-Gitlab-Event") + webhookID := headerGet(req.Headers, "webhook-id") + timestamp := headerGet(req.Headers, "webhook-timestamp") + signature := headerGet(req.Headers, "webhook-signature") body := req.Body if len(body) == 0 { @@ -34,40 +31,47 @@ func (p *githubPlugin) receiveWebhook(req *plugin.Request, res *plugin.Response) return } - repoFullName := extractRepoFullName(body) + repoFullName := extractGitLabProjectPath(body) if repoFullName == "" { res.NoContent() return } - // Always 204 so GitHub does not retry on application errors. - if err := p.handleWebhookEvent(projectID, repoFullName, event, signature, body); err != nil { - p.log.Error("github: webhook handler error: " + err.Error()) + if err := p.handleWebhookEvent(projectID, repoFullName, event, webhookID, timestamp, signature, body); err != nil { + p.log.Error("gitlab: webhook handler error: " + err.Error()) } res.NoContent() } -func (p *githubPlugin) handleWebhookEvent(projectID, repoFullName, event, signature string, payload []byte) error { - p.log.Info("github: webhook received, repo=" + repoFullName + ", event=" + event) +func headerGet(headers map[string]string, key string) string { + if headers == nil { + return "" + } + if v, ok := headers[key]; ok { + return v + } + // Host may normalize header keys. + for k, v := range headers { + if strings.EqualFold(k, key) { + return v + } + } + return "" +} + +func (p *gitlabPlugin) handleWebhookEvent(projectID, repoFullName, event, webhookID, timestamp, signature string, payload []byte) error { + p.log.Info("gitlab: webhook received, repo=" + repoFullName + ", event=" + event) - // Look up the repository by (project_id, full_name) — full_name alone is - // only unique per-project (two projects can legitimately link the same - // repo), so scoping by the URL's own project_id avoids picking an - // arbitrary row when that happens; the previous full_name-only lookup - // could resolve to a different project's row, using its secret to - // verify a delivery meant for this project (which then just fails - // closed on the signature check) or its repo/default_branch for event - // processing. result, err := p.db.Query( - `SELECT id, project_id, integration_id, owner, repo_name, full_name, default_branch, webhook_secret_enc FROM github_repositories WHERE full_name = $1 AND project_id = $2`, + `SELECT id, project_id, integration_id, owner, repo_name, full_name, default_branch, webhook_secret_enc FROM gitlab_repositories WHERE full_name = $1 AND project_id = $2`, repoFullName, projectID, ) if err != nil { - p.log.Error("github: failed to query repository: " + err.Error() + ", repo=" + repoFullName) + p.log.Error("gitlab: failed to query repository: " + err.Error() + ", repo=" + repoFullName) return err } if len(result.Rows) == 0 { - p.log.Info("github: repository not found, repo=" + repoFullName) + p.log.Info("gitlab: repository not found, repo=" + repoFullName) return nil } sc := newRowScanner(result.Columns, result.Rows[0]) @@ -75,88 +79,84 @@ func (p *githubPlugin) handleWebhookEvent(projectID, repoFullName, event, signat projectID = sc.str("project_id") webhookSecretEnc := sc.str("webhook_secret_enc") - // Verify HMAC signature. A missing secret fails closed rather than - // skipping verification — an unsigned/unverifiable delivery must never - // be trusted, even though every repo linked through the normal API - // always has a secret generated for it today. if webhookSecretEnc == "" { - p.log.Error("github: repository has no webhook secret configured, refusing to process delivery, repo=" + repoFullName) + p.log.Error("gitlab: repository has no webhook signing token configured, refusing delivery, repo=" + repoFullName) return nil } - secret, dErr := p.decrypt(webhookSecretEnc) + signingToken, dErr := p.decrypt(webhookSecretEnc) if dErr != nil { - p.log.Error("github: failed to decrypt webhook secret: " + dErr.Error() + ", repo=" + repoFullName) + p.log.Error("gitlab: failed to decrypt webhook signing token: " + dErr.Error() + ", repo=" + repoFullName) return dErr } - if !verifyHMAC(payload, secret, signature) { - p.log.Info("github: invalid webhook signature, repo=" + repoFullName) - return nil // silently drop invalid signatures + if !verifyGitLabSigningToken(payload, signingToken, webhookID, timestamp, signature) { + p.log.Info("gitlab: invalid webhook signature, repo=" + repoFullName) + return nil } - switch event { - case "pull_request": - p.log.Info("github: handling pull_request event, repo=" + repoFullName) + switch strings.ToLower(event) { + case "merge request hook", "merge_request": + p.log.Info("gitlab: handling merge_request event, repo=" + repoFullName) return p.handlePREvent(repoID, projectID, payload) - case "push": + case "push hook", "push": return p.handlePushEvent(repoID, projectID, payload) default: - p.log.Info("github: unhandled event type, event=" + event) + p.log.Info("gitlab: unhandled event type, event=" + event) } return nil } -func (p *githubPlugin) handlePREvent(repoID, projectID string, payload []byte) error { +func (p *gitlabPlugin) handlePREvent(repoID, projectID string, payload []byte) error { var event struct { - Action string `json:"action"` - PullRequest ghPullRequest `json:"pull_request"` + ObjectKind string `json:"object_kind"` + ObjectAttributes struct { + ID int64 `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + State string `json:"state"` + Action string `json:"action"` + URL string `json:"url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + MergedAt *time.Time `json:"merged_at"` + } `json:"object_attributes"` + User struct { + Username string `json:"username"` + } `json:"user"` } if err := json.Unmarshal(payload, &event); err != nil { - p.log.Error("github: failed to parse pull_request event: " + err.Error()) + p.log.Error("gitlab: failed to parse merge_request event: " + err.Error()) return err } - gh := &event.PullRequest - - p.log.Info("github: processing pull_request, action=" + event.Action + ", pr_number=" + strconv.Itoa(gh.Number) + ", title=" + gh.Title + ", repo_id=" + repoID) + oa := event.ObjectAttributes + action := oa.Action + state := normalizeMRState(oa.State, oa.MergedAt != nil) - state := gh.State - if gh.Merged { - state = "merged" - } + p.log.Info("gitlab: processing merge_request, action=" + action + ", mr_iid=" + strconv.Itoa(oa.IID) + ", title=" + oa.Title + ", repo_id=" + repoID) - // Read the PR's previously cached state before the upsert overwrites - // it, so we can tell a genuine open/closed/merged transition (the - // github.pr_state_changed automation trigger's source) apart from a - // re-delivered webhook or a non-state-changing action like - // "synchronize"/"labeled", both of which still update the row above but - // shouldn't re-fire an automation. previousState == "" (no existing - // row) means this is the PR's first webhook delivery — not a - // transition, so it never fires pr_state_changed either. var previousState string - if existing, exErr := p.db.Query(`SELECT state FROM github_pull_requests WHERE repo_id = $1 AND pr_number = $2`, repoID, gh.Number); exErr == nil && len(existing.Rows) > 0 { + if existing, exErr := p.db.Query(`SELECT state FROM gitlab_merge_requests WHERE repo_id = $1 AND pr_number = $2`, repoID, oa.IID); exErr == nil && len(existing.Rows) > 0 { previousState = newRowScanner(existing.Columns, existing.Rows[0]).str("state") } now := time.Now().UTC().Format(time.RFC3339Nano) - var mergedAtStr *string - if gh.MergedAt != nil { - s := gh.MergedAt.UTC().Format(time.RFC3339Nano) + if oa.MergedAt != nil { + s := oa.MergedAt.UTC().Format(time.RFC3339Nano) mergedAtStr = &s } - // Upsert the PR cache; let PostgreSQL generate id on insert, RETURNING gives us the id. upserted, err := p.db.Query(` - INSERT INTO github_pull_requests - (project_id, repo_id, pr_number, github_pr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) + INSERT INTO gitlab_merge_requests + (project_id, repo_id, pr_number, gitlab_mr_id, title, state, html_url, head_branch, base_branch, author, merged_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (repo_id, pr_number) DO UPDATE SET title=$5, state=$6, html_url=$7, head_branch=$8, base_branch=$9, author=$10, merged_at=$11, updated_at=$13 RETURNING id - `, projectID, repoID, gh.Number, gh.ID, gh.Title, state, - gh.HTMLURL, gh.Head.Ref, gh.Base.Ref, gh.User.Login, mergedAtStr, now, now) + `, projectID, repoID, oa.IID, oa.ID, oa.Title, state, + oa.URL, oa.SourceBranch, oa.TargetBranch, event.User.Username, mergedAtStr, now, now) if err != nil { - p.log.Error("github: failed to upsert PR: " + err.Error() + ", repo_id=" + repoID + ", pr_number=" + strconv.Itoa(gh.Number)) + p.log.Error("gitlab: failed to upsert MR: " + err.Error() + ", repo_id=" + repoID + ", mr_iid=" + strconv.Itoa(oa.IID)) return err } var prID string @@ -164,54 +164,47 @@ func (p *githubPlugin) handlePREvent(repoID, projectID string, payload []byte) e prID = newRowScanner(upserted.Columns, upserted.Rows[0]).str("id") } - p.log.Info("github: PR saved successfully, pr_id=" + prID + ", pr_number=" + strconv.Itoa(gh.Number) + ", action=" + event.Action) - - // On "opened"/"reopened": auto-link to task if head branch is already linked. - if event.Action == "opened" || event.Action == "reopened" { + if action == "open" || action == "reopen" { brResult, _ := p.db.Query( - `SELECT task_id FROM github_task_branches WHERE repo_id = $1 AND branch_name = $2`, - repoID, gh.Head.Ref, + `SELECT task_id FROM gitlab_task_branches WHERE repo_id = $1 AND branch_name = $2`, + repoID, oa.SourceBranch, ) if brResult != nil && len(brResult.Rows) > 0 { taskID := newRowScanner(brResult.Columns, brResult.Rows[0]).str("task_id") _, _ = p.db.Exec(` - INSERT INTO github_task_pr_links (task_id, pull_request_id, created_at) + INSERT INTO gitlab_task_mr_links (task_id, merge_request_id, created_at) VALUES ($1,$2,$3) - ON CONFLICT (task_id, pull_request_id) DO NOTHING + ON CONFLICT (task_id, merge_request_id) DO NOTHING `, taskID, prID, now) - plugin.EmitEvent("github.pr_linked", map[string]any{ + plugin.EmitEvent("gitlab.mr_linked", map[string]any{ "project_id": projectID, "task_id": taskID, "repo_id": repoID, - "pr_number": gh.Number, + "pr_number": oa.IID, }) - p.log.Info("github: PR auto-linked to task, task_id=" + taskID + ", pr_number=" + strconv.Itoa(gh.Number)) + p.log.Info("gitlab: MR auto-linked to task, task_id=" + taskID + ", mr_iid=" + strconv.Itoa(oa.IID)) } } - // Emit PR updated for all linked tasks — and, when the derived - // open/closed/merged state actually transitioned since the last - // webhook delivery, the more specific pr_state_changed event too (the - // github.pr_state_changed automation trigger's source). stateChanged := previousState != "" && previousState != state - linkedResult, _ := p.db.Query(`SELECT task_id FROM github_task_pr_links WHERE pull_request_id = $1`, prID) + linkedResult, _ := p.db.Query(`SELECT task_id FROM gitlab_task_mr_links WHERE merge_request_id = $1`, prID) if linkedResult != nil { for _, row := range linkedResult.Rows { taskID := newRowScanner(linkedResult.Columns, row).str("task_id") - plugin.EmitEvent("github.pr_updated", map[string]any{ + plugin.EmitEvent("gitlab.mr_updated", map[string]any{ "project_id": projectID, "task_id": taskID, "repo_id": repoID, - "pr_number": gh.Number, - "action": event.Action, + "pr_number": oa.IID, + "action": action, }) if stateChanged { - plugin.EmitEvent("github.pr_state_changed", map[string]any{ + plugin.EmitEvent("gitlab.mr_state_changed", map[string]any{ "project_id": projectID, "task_id": taskID, "repo_id": repoID, - "pr_number": gh.Number, + "pr_number": oa.IID, "from_state": previousState, "to_state": state, }) @@ -221,28 +214,32 @@ func (p *githubPlugin) handlePREvent(repoID, projectID string, payload []byte) e return nil } -func (p *githubPlugin) handlePushEvent(repoID, projectID string, payload []byte) error { +func (p *gitlabPlugin) handlePushEvent(repoID, projectID string, payload []byte) error { var event struct { Ref string `json:"ref"` - Created bool `json:"created"` - Deleted bool `json:"deleted"` + Before string `json:"before"` + After string `json:"after"` + Created bool `json:"created"` // not always present on GitLab } - if err := json.Unmarshal(payload, &event); err != nil || !event.Created || event.Deleted { + if err := json.Unmarshal(payload, &event); err != nil { return nil } - - // Extract "refs/heads/branch-name" → "branch-name". branchName := strings.TrimPrefix(event.Ref, "refs/heads/") if branchName == event.Ref { return nil } + // GitLab signals new branch with before = 40 zeros (or omitted created). + isCreated := event.Created || event.Before == "" || event.Before == strings.Repeat("0", 40) + isDeleted := event.After == strings.Repeat("0", 40) + if !isCreated || isDeleted { + return nil + } prefix, taskNumber, ok := extractBranchTaskRef(branchName) if !ok { return nil } - // Look up the task by project prefix and task number. taskResult, tErr := p.db.Query(` SELECT t.id FROM tasks t JOIN projects pr ON pr.id = t.project_id @@ -256,12 +253,12 @@ func (p *githubPlugin) handlePushEvent(repoID, projectID string, payload []byte) now := time.Now().UTC().Format(time.RFC3339Nano) _, _ = p.db.Exec(` - INSERT INTO github_task_branches (task_id, repo_id, branch_name, created_at) + INSERT INTO gitlab_task_branches (task_id, repo_id, branch_name, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (task_id, repo_id, branch_name) DO NOTHING `, taskID, repoID, branchName, now) - plugin.EmitEvent("github.branch_linked", map[string]any{ + plugin.EmitEvent("gitlab.branch_linked", map[string]any{ "project_id": projectID, "task_id": taskID, "repo_id": repoID, @@ -270,30 +267,45 @@ func (p *githubPlugin) handlePushEvent(repoID, projectID string, payload []byte) return nil } -// ─── Helpers ────────────────────────────────────────────────────────────────── - -func verifyHMAC(payload []byte, secret, signatureHeader string) bool { - const prefix = "sha256=" - if !strings.HasPrefix(signatureHeader, prefix) { +// verifyGitLabSigningToken validates Standard Webhooks HMAC (GitLab 19+ signing_token). +func verifyGitLabSigningToken(payload []byte, signingToken, msgID, timestamp, signatureHeader string) bool { + if signingToken == "" || msgID == "" || timestamp == "" || signatureHeader == "" { return false } - expected := signatureHeader[len(prefix):] - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(payload) - got := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(got), []byte(expected)) + keyPart := strings.TrimPrefix(signingToken, "whsec_") + key, err := base64.StdEncoding.DecodeString(keyPart) + if err != nil || len(key) == 0 { + return false + } + signed := msgID + "." + timestamp + "." + string(payload) + mac := hmac.New(sha256.New, key) + mac.Write([]byte(signed)) + digest := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + want := "v1," + digest + for _, part := range strings.Fields(signatureHeader) { + if hmac.Equal([]byte(part), []byte(want)) { + return true + } + } + return false } -func extractRepoFullName(payload []byte) string { +func extractGitLabProjectPath(payload []byte) string { var v struct { + Project struct { + PathWithNamespace string `json:"path_with_namespace"` + } `json:"project"` Repository struct { - FullName string `json:"full_name"` + PathWithNamespace string `json:"path_with_namespace"` } `json:"repository"` } if err := json.Unmarshal(payload, &v); err != nil { return "" } - return v.Repository.FullName + if v.Project.PathWithNamespace != "" { + return v.Project.PathWithNamespace + } + return v.Repository.PathWithNamespace } func extractBranchTaskRef(branchName string) (prefix string, taskNumber int64, ok bool) { @@ -307,3 +319,17 @@ func extractBranchTaskRef(branchName string) (prefix string, taskNumber int64, o } return strings.ToUpper(m[1]), n, true } + +func normalizeMRState(state string, merged bool) string { + if merged || strings.EqualFold(state, "merged") { + return "merged" + } + switch strings.ToLower(state) { + case "opened", "open", "locked", "reopened": + return "open" + case "closed": + return "closed" + default: + return strings.ToLower(state) + } +} diff --git a/frontend/package.json b/frontend/package.json index 34db25c..5b6a97f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "@paca-ai/github-plugin", + "name": "@paca-ai/gitlab-plugin", "version": "0.1.0", "private": true, "type": "module", diff --git a/frontend/src/GitHubSettingsTab.tsx b/frontend/src/GitLabSettingsTab.tsx similarity index 84% rename from frontend/src/GitHubSettingsTab.tsx rename to frontend/src/GitLabSettingsTab.tsx index c2b52b1..7b91c94 100644 --- a/frontend/src/GitHubSettingsTab.tsx +++ b/frontend/src/GitLabSettingsTab.tsx @@ -19,20 +19,22 @@ import { useMemo, useState } from "react"; import { type AccessibleRepo, accessibleReposKey, - deleteGitHubToken, + deleteGitLabToken, ErrorCode, getPluginErrorCode, - type GitHubIntegration, - getGitHubIntegration, + type GitLabIntegration, + type GitLabTokenKind, + getGitLabIntegration, integrationKey, type LinkedRepository, linkedReposKey, listAccessibleRepos, listLinkedRepositories, linkRepository, - setGitHubToken, + setGitLabToken, unlinkRepository, -} from "./github-api"; +} from "./gitlab-api"; +import { t } from "./i18n"; // ── Utilities ────────────────────────────────────────────────────────────────── @@ -40,12 +42,12 @@ function cn(...classes: (string | undefined | null | false)[]): string { return classes.filter(Boolean).join(" "); } -// ── GitHub Icon ──────────────────────────────────────────────────────────────── +// ── GitLab Icon ──────────────────────────────────────────────────────────────── -function GitHubIcon(props: React.SVGProps) { +function GitLabIcon(props: React.SVGProps) { return ( - GitHub + GitLab void; canEdit: boolean; + locale?: string; + integration?: GitLabIntegration | null; }) { const queryClient = useQueryClient(); const [token, setToken] = useState(""); + const [instanceUrl, setInstanceUrl] = useState( + integration?.instance_url || "https://gitlab.com", + ); + const [tokenKind, setTokenKind] = useState( + integration?.token_kind || "personal", + ); const [showToken, setShowToken] = useState(false); const [error, setError] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const saveMutation = useMutation({ - mutationFn: () => setGitHubToken(api, token.trim()), + mutationFn: () => + setGitLabToken(api, token.trim(), { + instanceUrl: instanceUrl.trim(), + tokenKind, + }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: integrationKey(projectId), @@ -187,18 +203,16 @@ function TokenCard({ }, onError: (err: unknown) => { const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubInvalidToken) { - setError( - "GitHub rejected the token. Check it has the required scopes (repo, pull request, admin:repo_hook).", - ); + if (code === ErrorCode.GitLabInvalidToken) { + setError(t("settings.token.invalid", locale)); return; } - setError("Failed to save token. Please try again."); + setError(t("settings.token.saveFailed", locale)); }, }); const deleteMutation = useMutation({ - mutationFn: () => deleteGitHubToken(api), + mutationFn: () => deleteGitLabToken(api), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: integrationKey(projectId), @@ -255,7 +269,7 @@ function TokenCard({

- Remove GitHub token + Remove GitLab token

Removing the token will also unlink all repositories and disable @@ -298,67 +312,85 @@ function TokenCard({ } return ( -

+

- Add a GitHub personal access token with{" "} - - repo - {" "} - (includes{" "} - - contents - {" "} - and{" "} - - pull requests - {" "} - permissions) and the{" "} - - admin:repo_hook - {" "} - scope to link repositories, create branches, and track pull requests. + {t("settings.instanceUrl.hint", locale)}. Use a Personal, Project, or + Group Access Token with scope{" "} + api{" "} + (Maintainer/Owner required to create project webhooks).

-
-
- { - setToken(e.target.value); - setError(null); - }} - placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" - disabled={!canEdit || saveMutation.isPending} - className={cn( - "pr-8 font-mono text-sm", - error ? "border-destructive focus-visible:ring-destructive/30" : "", - )} - autoComplete="off" - onKeyDown={(e) => { - if (e.key === "Enter" && token.trim()) saveMutation.mutate(); - }} - /> - +
+ saveMutation.mutate()} + className="shrink-0" > - {showToken ? "hide" : "show"} - + {saveMutation.isPending ? ( + + ) : null} + {t("settings.token.save", locale)} +
- saveMutation.mutate()} - className="shrink-0" - > - {saveMutation.isPending ? ( - - ) : null} - Save token - -
+ {error ?

{error}

: null}
); @@ -403,23 +435,23 @@ function AddRepoDialog({ }, onError: (err: unknown) => { const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubWebhookURLNotPublic) { + if (code === ErrorCode.GitLabWebhookURLNotPublic) { setError( "Cannot register webhook because this API URL is not publicly reachable (for example localhost). Configure PUBLIC_URL to a public HTTPS URL and try again.", ); return; } - if (code === ErrorCode.GitHubWebhookCreationFailed) { + if (code === ErrorCode.GitLabWebhookCreationFailed) { setError( "Could not create the webhook. Make sure your token has the admin:repo_hook scope and you have admin access to the repository.", ); return; } - if (code === ErrorCode.GitHubRepoAlreadyLinked) { + if (code === ErrorCode.GitLabRepoAlreadyLinked) { setError("This repository is already linked to the project."); return; } - if (code === ErrorCode.GitHubRepoNotAccessible) { + if (code === ErrorCode.GitLabRepoNotAccessible) { setError( "Repository not found or not accessible. Check that your token has the repo scope.", ); @@ -451,7 +483,7 @@ function AddRepoDialog({

Add repository

- Select a repository from your GitHub account to link to this project. + Select a repository from your GitLab account to link to this project. A webhook will be registered automatically.

@@ -598,7 +630,7 @@ function LinkedRepoItem({
{repo.full_name} {" "} - and attempt to delete the webhook from GitHub. + and attempt to delete the webhook from GitLab.

{unlinkMutation.isError && (

@@ -677,7 +709,7 @@ function LinkedRepoItem({ // ── Main Settings ───────────────────────────────────────────────────────────── -function GitHubSettingsInner({ +function GitLabSettingsInner({ api, projectId, canEdit, @@ -687,10 +719,10 @@ function GitHubSettingsInner({ canEdit: boolean; }) { const { data: integration, isLoading: integrationLoading } = useQuery< - GitHubIntegration | undefined + GitLabIntegration | undefined >({ queryKey: integrationKey(projectId), - queryFn: () => getGitHubIntegration(api), + queryFn: () => getGitLabIntegration(api), retry: false, throwOnError: false, }); @@ -712,7 +744,7 @@ function GitHubSettingsInner({ const [addRepoOpen, setAddRepoOpen] = useState(false); const steps = [ - { num: 1, label: "Connect a GitHub token", done: hasIntegration }, + { num: 1, label: "Connect a GitLab token", done: hasIntegration }, { num: 2, label: "Link a repository", done: hasRepos }, ]; @@ -721,13 +753,13 @@ function GitHubSettingsInner({ {/* Header card */}

- +

- GitHub Integration + GitLab Integration

- Link GitHub repositories to track pull requests, create branches from + Link GitLab repositories to track merge requests, create branches from tasks, and receive webhook events automatically. You can link multiple repositories to a single project.

@@ -766,7 +798,7 @@ function GitHubSettingsInner({
{integrationLoading ? ( @@ -778,6 +810,7 @@ function GitHubSettingsInner({ hasIntegration={hasIntegration} onTokenSet={() => setAddRepoOpen(true)} canEdit={canEdit} + integration={integration} /> )}
@@ -824,7 +857,7 @@ function GitHubSettingsInner({

{hasRepos ? "Webhooks are registered automatically for each linked repository." - : "No repositories linked yet. Link a repository to track pull requests and branches."} + : "No repositories linked yet. Link a repository to track merge requests and branches."}

{reposLoading ? ( @@ -879,7 +912,7 @@ function GitHubSettingsInner({

- Webhooks are registered on all linked repositories. GitHub will push{" "} + Webhooks are registered on all linked repositories. GitLab will push{" "} pull_request events to keep PR status in sync automatically.

@@ -891,7 +924,7 @@ function GitHubSettingsInner({

- No GitHub integration configured. Add a personal access token to get + No GitLab integration configured. Add a personal access token to get started.

@@ -902,15 +935,18 @@ function GitHubSettingsInner({ // ── Public export ───────────────────────────────────────────────────────────── -interface GitHubSettingsTabProps { +interface GitLabSettingsTabProps { projectId: string; canEdit?: boolean; + /** Host locale hint (e.g. "it", "en-US"). Falls back to navigator.language. */ + locale?: string; } -export default function GitHubSettingsTab({ +export default function GitLabSettingsTab({ projectId, canEdit = true, -}: GitHubSettingsTabProps) { + locale, +}: GitLabSettingsTabProps) { const api = useMemo( () => new PluginApiClient({ @@ -924,7 +960,7 @@ export default function GitHubSettingsTab({ return ( - + ); } diff --git a/frontend/src/GitHubTaskSection.tsx b/frontend/src/GitLabTaskSection.tsx similarity index 91% rename from frontend/src/GitHubTaskSection.tsx rename to frontend/src/GitLabTaskSection.tsx index 3e716f0..32ed23c 100644 --- a/frontend/src/GitHubTaskSection.tsx +++ b/frontend/src/GitLabTaskSection.tsx @@ -34,7 +34,7 @@ import { taskBranchesKey, taskPRsKey, unlinkPRFromTask, -} from "./github-api"; +} from "./gitlab-api"; // ── Utilities ────────────────────────────────────────────────────────────────── @@ -42,7 +42,7 @@ function cn(...classes: (string | undefined | null | false)[]): string { return classes.filter(Boolean).join(" "); } -// ─────────────────────── Pull Requests Section ──────────────────────────────── +// ─────────────────────── Merge Requests Section ──────────────────────────────── // ── PR state badge ──────────────────────────────────────────────────────────── @@ -133,7 +133,7 @@ function PRRow({ {canEdit && ( ) )} @@ -598,21 +600,21 @@ function CreateBranchForm({ }, onError: (err: unknown) => { const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubIntegrationNotFound) { - setError("No GitHub token configured for this project."); + if (code === ErrorCode.GitLabIntegrationNotFound) { + setError("No GitLab token configured for this project."); return; } - if (code === ErrorCode.GitHubRepositoryNotFound) { + if (code === ErrorCode.GitLabRepositoryNotFound) { setError("Repository not found. It may have been unlinked."); return; } - if (code === ErrorCode.GitHubBranchAlreadyLinked) { + if (code === ErrorCode.GitLabBranchAlreadyLinked) { setError("This branch is already linked to the task."); return; } - if (code === ErrorCode.GitHubTokenInsufficientPermissions) { + if (code === ErrorCode.GitLabTokenInsufficientPermissions) { setError( - "Your GitHub token does not have permission to create branches. Please update it in Project Settings > GitHub with a token that has the repo (contents) scope.", + "Your GitLab token does not have permission to create branches. Please update it in Project Settings > GitLab with a token that has the repo (contents) scope.", ); return; } @@ -734,7 +736,7 @@ function CreateBranchForm({ ) : ( )} - Create branch on GitHub + Create branch on GitLab
@@ -758,17 +760,18 @@ function CreateBranchForm({ // ── Helpers ─────────────────────────────────────────────────────────────────── -function parseGitHubBranchUrl( +function parseGitLabBranchUrl( raw: string, ): { fullName: string; branchName: string } | null { try { const url = new URL(raw.trim()); - if (url.hostname !== "github.com") return null; - const parts = url.pathname.replace(/^\//, "").split("/"); - if (parts.length < 4 || parts[2] !== "tree") return null; - const branchName = parts.slice(3).join("/"); - if (!branchName) return null; - return { fullName: `${parts[0]}/${parts[1]}`, branchName }; + const parts = url.pathname.replace(/^\//, "").split("/").filter(Boolean); + const dash = parts.indexOf("-"); + if (dash < 1 || parts[dash + 1] !== "tree") return null; + const branchName = parts.slice(dash + 2).join("/"); + const fullName = parts.slice(0, dash).join("/"); + if (!fullName || !branchName) return null; + return { fullName, branchName }; } catch { return null; } @@ -796,7 +799,7 @@ function LinkBranchForm({ const [value, setValue] = useState(""); const [error, setError] = useState(null); - const parsed = parseGitHubBranchUrl(value); + const parsed = parseGitLabBranchUrl(value); const urlMatchedRepo = parsed ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) : null; @@ -815,27 +818,27 @@ function LinkBranchForm({ }, onError: (err: unknown) => { const code = getPluginErrorCode(err); - if (code === ErrorCode.GitHubIntegrationNotFound) { - setError("No GitHub token configured for this project."); + if (code === ErrorCode.GitLabIntegrationNotFound) { + setError("No GitLab token configured for this project."); return; } - if (code === ErrorCode.GitHubRepositoryNotFound) { + if (code === ErrorCode.GitLabRepositoryNotFound) { setError("Repository not found. It may have been unlinked."); return; } - if (code === ErrorCode.GitHubBranchNotFound) { + if (code === ErrorCode.GitLabBranchNotFound) { setError( `Branch "${branchName}" was not found in the selected repository.`, ); return; } - if (code === ErrorCode.GitHubBranchAlreadyLinked) { + if (code === ErrorCode.GitLabBranchAlreadyLinked) { setError(`Branch "${branchName}" is already linked to this task.`); return; } - if (code === ErrorCode.GitHubTokenInsufficientPermissions) { + if (code === ErrorCode.GitLabTokenInsufficientPermissions) { setError( - "Your GitHub token does not have permission to read branches. Update it in Project Settings > GitHub.", + "Your GitLab token does not have permission to read branches. Update it in Project Settings > GitLab.", ); return; } @@ -857,7 +860,7 @@ function LinkBranchForm({ return; } if (!branchName) { - setError("Enter a branch name or paste a GitHub branch URL."); + setError("Enter a branch name or paste a GitLab branch URL."); return; } } @@ -890,7 +893,7 @@ function LinkBranchForm({ {/* Branch name or URL */}

- Branch name or GitHub URL + Branch name or GitLab URL

new PluginApiClient({ @@ -1174,7 +1177,7 @@ export default function GitHubTaskSection({ return ( - // ── API functions ────────────────────────────────────────────────────────────── -export async function getGitHubIntegration( +export async function getGitLabIntegration( api: PluginApiClient, -): Promise { - return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration`); +): Promise { + return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration`); } -export async function setGitHubToken( +export async function setGitLabToken( api: PluginApiClient, token: string, -): Promise { - return api.pluginPost(PLUGIN_ID, `/projects/${api.projectId}/integration/token`, { + opts?: { instanceUrl?: string; tokenKind?: GitLabTokenKind }, +): Promise { + return api.pluginPost(PLUGIN_ID, `/projects/${api.projectId}/integration/token`, { token, + instance_url: opts?.instanceUrl || "https://gitlab.com", + token_kind: opts?.tokenKind || "personal", }); } -export async function deleteGitHubToken(api: PluginApiClient): Promise { +export async function deleteGitLabToken(api: PluginApiClient): Promise { return api.pluginDelete(PLUGIN_ID, `/projects/${api.projectId}/integration/token`); } diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts new file mode 100644 index 0000000..6186b91 --- /dev/null +++ b/frontend/src/i18n/en.ts @@ -0,0 +1,34 @@ +const en = { + "settings.title": "GitLab", + "settings.token.saved": "Access token saved", + "settings.token.encrypted": "Token is stored encrypted. It is never returned by the API.", + "settings.token.remove": "Remove token", + "settings.token.removeTitle": "Remove GitLab token", + "settings.token.removeBody": + "Removing the token will also unlink all repositories and disable webhook events. This action cannot be undone.", + "settings.token.cancel": "Cancel", + "settings.token.save": "Save token", + "settings.token.label": "Access token", + "settings.token.placeholder": "glpat-… or project/group access token", + "settings.token.kind": "Token type", + "settings.token.kind.personal": "Personal access token", + "settings.token.kind.project": "Project access token", + "settings.token.kind.group": "Group access token", + "settings.instanceUrl": "GitLab instance URL", + "settings.instanceUrl.hint": "Default https://gitlab.com — Self-Managed: your instance base URL", + "settings.token.invalid": + "GitLab rejected the token. Check scope api and Maintainer/Owner role for webhooks.", + "settings.token.saveFailed": "Failed to save token. Please try again.", + "settings.token.removeFailed": "Failed to remove token. Please try again.", + "settings.repos.title": "Linked projects", + "settings.repos.link": "Link project", + "task.section.title": "GitLab", + "task.mr.link": "Link merge request", + "task.mr.create": "Create merge request", + "task.branch.create": "Create branch on GitLab", + "common.loading": "Loading…", + "common.error": "Something went wrong", +} as const; + +export type MessageKey = keyof typeof en; +export default en; diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..5992b99 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,23 @@ +import en, { type MessageKey } from "./en"; +import it from "./it"; + +const catalogs: Record> = { + en, + it, +}; + +export type Locale = "en" | "it"; + +export function resolveLocale(raw?: string | null): Locale { + const v = (raw || (typeof navigator !== "undefined" ? navigator.language : "en") || "en") + .toLowerCase() + .split("-")[0]; + return v === "it" ? "it" : "en"; +} + +export function t(key: MessageKey, locale?: string | null): string { + const loc = resolveLocale(locale); + return catalogs[loc][key] ?? catalogs.en[key] ?? key; +} + +export type { MessageKey }; diff --git a/frontend/src/i18n/it.ts b/frontend/src/i18n/it.ts new file mode 100644 index 0000000..8b2bf40 --- /dev/null +++ b/frontend/src/i18n/it.ts @@ -0,0 +1,35 @@ +import type { MessageKey } from "./en"; + +const it: Record = { + "settings.title": "GitLab", + "settings.token.saved": "Token di accesso salvato", + "settings.token.encrypted": "Il token è cifrato. Non viene mai restituito dall’API.", + "settings.token.remove": "Rimuovi token", + "settings.token.removeTitle": "Rimuovi token GitLab", + "settings.token.removeBody": + "Rimuovendo il token verranno scollegati tutti i progetti e disabilitati i webhook. L’azione non è reversibile.", + "settings.token.cancel": "Annulla", + "settings.token.save": "Salva token", + "settings.token.label": "Token di accesso", + "settings.token.placeholder": "glpat-… o project/group access token", + "settings.token.kind": "Tipo di token", + "settings.token.kind.personal": "Personal access token", + "settings.token.kind.project": "Project access token", + "settings.token.kind.group": "Group access token", + "settings.instanceUrl": "URL istanza GitLab", + "settings.instanceUrl.hint": "Default https://gitlab.com — Self-Managed: URL base della tua istanza", + "settings.token.invalid": + "GitLab ha rifiutato il token. Verifica lo scope api e il ruolo Maintainer/Owner per i webhook.", + "settings.token.saveFailed": "Salvataggio token non riuscito. Riprova.", + "settings.token.removeFailed": "Rimozione token non riuscita. Riprova.", + "settings.repos.title": "Progetti collegati", + "settings.repos.link": "Collega progetto", + "task.section.title": "GitLab", + "task.mr.link": "Collega merge request", + "task.mr.create": "Crea merge request", + "task.branch.create": "Crea branch su GitLab", + "common.loading": "Caricamento…", + "common.error": "Qualcosa è andato storto", +}; + +export default it; diff --git a/frontend/src/index.ts b/frontend/src/index.ts index ce601ee..4614dcb 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -1,2 +1,2 @@ -export { default as GitHubSettingsTab } from "./GitHubSettingsTab"; -export { default as GitHubTaskSection } from "./GitHubTaskSection"; +export { default as GitLabSettingsTab } from "./GitLabSettingsTab"; +export { default as GitLabTaskSection } from "./GitLabTaskSection"; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 242d6bf..fd66e7d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,11 +6,11 @@ export default defineConfig({ plugins: [ react(), federation({ - name: "com_paca_github", + name: "com_paca_gitlab", filename: "remoteEntry.js", exposes: { - "./GitHubSettingsTab": "./src/GitHubSettingsTab.tsx", - "./GitHubTaskSection": "./src/GitHubTaskSection.tsx", + "./GitLabSettingsTab": "./src/GitLabSettingsTab.tsx", + "./GitLabTaskSection": "./src/GitLabTaskSection.tsx", }, shared: { react: { requiredVersion: "^19.0.0" }, diff --git a/mcp/package.json b/mcp/package.json index 91e5ce3..adba738 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,5 +1,5 @@ { - "name": "@paca-ai/github-plugin-mcp", + "name": "@paca-ai/gitlab-plugin-mcp", "version": "0.1.0", "private": true, "type": "module", diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 8f081ec..0e1022a 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -9,7 +9,7 @@ import { // ── Domain types ────────────────────────────────────────────────────────────── -interface GitHubIntegration { +interface GitLabIntegration { id: string; project_id: string; created_at: string; @@ -86,7 +86,7 @@ interface CreatePullRequestResult { project_id: string; repo_id: string; pr_number: number; - github_pr_id: number; + gitlab_pr_id: number; title: string; state: string; html_url: string; @@ -100,8 +100,8 @@ interface CreatePullRequestResult { // ── Formatting helpers ──────────────────────────────────────────────────────── -function formatIntegration(integration: GitHubIntegration): string { - return `GitHub Integration: +function formatIntegration(integration: GitLabIntegration): string { + return `GitLab Integration: ID: ${integration.id} Project ID: ${integration.project_id} Created: ${integration.created_at} @@ -120,7 +120,7 @@ Created: ${repo.created_at}`; } function formatPullRequest(pr: PullRequest): string { - return `Pull Request: #${pr.pr_number} - ${pr.title} + return `Merge Request: #${pr.pr_number} - ${pr.title} ID: ${pr.id} State: ${pr.state} Author: ${pr.author ?? "Unknown"} @@ -131,7 +131,7 @@ Merged: ${pr.merged_at ? `Yes (${pr.merged_at})` : "No"}`; } function formatPullRequestDetails(pr: PullRequestDetails): string { - return `Pull Request: #${pr.pr_number} - ${pr.title} (${pr.owner}/${pr.repo_name}) + return `Merge Request: #${pr.pr_number} - ${pr.title} (${pr.owner}/${pr.repo_name}) State: ${pr.state} URL: ${pr.html_url} Description: ${pr.body || "(none)"} @@ -184,8 +184,8 @@ const taskIdProp = { const tools: Tool[] = [ // ── Integration ────────────────────────────────────────────────────────── { - name: "github_get_integration", - description: "Get GitHub integration status for a project.", + name: "gitlab_get_integration", + description: "Get GitLab integration status for a project.", inputSchema: { type: "object", properties: { @@ -195,24 +195,24 @@ const tools: Tool[] = [ }, }, { - name: "github_set_token", + name: "gitlab_set_token", description: - "Set (or replace) the GitHub personal access token for a project. The token must have at least the 'repo' scope.", + "Set (or replace) the GitLab personal access token for a project. The token must have at least the 'repo' scope.", inputSchema: { type: "object", properties: { projectId: projectIdProp, token: { type: "string", - description: "The GitHub personal access token (e.g., 'ghp_xxxx').", + description: "The GitLab personal access token (e.g., 'ghp_xxxx').", }, }, required: ["projectId", "token"], }, }, { - name: "github_delete_token", - description: "Delete the GitHub token for a project, removing GitHub integration.", + name: "gitlab_delete_token", + description: "Delete the GitLab token for a project, removing GitLab integration.", inputSchema: { type: "object", properties: { @@ -223,8 +223,8 @@ const tools: Tool[] = [ }, // ── Repositories ───────────────────────────────────────────────────────── { - name: "github_list_linked_repos", - description: "List GitHub repositories linked to a project.", + name: "gitlab_list_linked_repos", + description: "List GitLab repositories linked to a project.", inputSchema: { type: "object", properties: { @@ -234,16 +234,16 @@ const tools: Tool[] = [ }, }, { - name: "github_link_repository", + name: "gitlab_link_repository", description: - "Link a GitHub repository to a project. The repository must be accessible with the project's GitHub token.", + "Link a GitLab repository to a project. The repository must be accessible with the project's GitLab token.", inputSchema: { type: "object", properties: { projectId: projectIdProp, owner: { type: "string", - description: "The repository owner (GitHub username or org).", + description: "The repository owner (GitLab username or org).", }, repo_name: { type: "string", @@ -254,8 +254,8 @@ const tools: Tool[] = [ }, }, { - name: "github_unlink_repository", - description: "Unlink a GitHub repository from a project.", + name: "gitlab_unlink_repository", + description: "Unlink a GitLab repository from a project.", inputSchema: { type: "object", properties: { @@ -264,16 +264,16 @@ const tools: Tool[] = [ type: "string", description: UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", + " Use gitlab_list_linked_repos to get the repo ID.", }, }, required: ["projectId", "repoId"], }, }, - // ── Pull Requests ───────────────────────────────────────────────────────── + // ── Merge Requests ───────────────────────────────────────────────────────── { - name: "github_list_task_prs", - description: "List pull requests linked to a task.", + name: "gitlab_list_task_prs", + description: "List merge requests linked to a task.", inputSchema: { type: "object", properties: { @@ -284,8 +284,8 @@ const tools: Tool[] = [ }, }, { - name: "github_link_pr_to_task", - description: "Link a GitHub pull request to a task.", + name: "gitlab_link_pr_to_task", + description: "Link a GitLab merge request to a task.", inputSchema: { type: "object", properties: { @@ -295,19 +295,19 @@ const tools: Tool[] = [ type: "string", description: UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", + " Use gitlab_list_linked_repos to get the repo ID.", }, pr_number: { type: "number", - description: "The GitHub pull request number (e.g., 42).", + description: "The GitLab merge request number (e.g., 42).", }, }, required: ["projectId", "taskId", "repoId", "pr_number"], }, }, { - name: "github_unlink_pr_from_task", - description: "Unlink a pull request from a task.", + name: "gitlab_unlink_pr_from_task", + description: "Unlink a merge request from a task.", inputSchema: { type: "object", properties: { @@ -316,17 +316,17 @@ const tools: Tool[] = [ prId: { type: "string", description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", + UUID_DESC.replace("%s", "linked merge request") + + " Use gitlab_list_task_prs to get the PR ID.", }, }, required: ["projectId", "taskId", "prId"], }, }, { - name: "github_create_pull_request", + name: "gitlab_create_pull_request", description: - "Create a new pull request on GitHub for a task. The pull request will be created on the linked repository and automatically linked to the task. Returns the PR URL.", + "Create a new merge request on GitLab for a task. The merge request will be created on the linked repository and automatically linked to the task. Returns the PR URL.", inputSchema: { type: "object", properties: { @@ -336,12 +336,12 @@ const tools: Tool[] = [ type: "string", description: UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", + " Use gitlab_list_linked_repos to get the repo ID.", }, title: { type: "string", description: - "The title for the pull request (e.g., 'feat: add user authentication').", + "The title for the merge request (e.g., 'feat: add user authentication').", }, head_branch: { type: "string", @@ -356,16 +356,16 @@ const tools: Tool[] = [ body: { type: "string", description: - "The description/body for the pull request in Markdown format (optional).", + "The description/body for the merge request in Markdown format (optional).", }, }, required: ["projectId", "taskId", "repoId", "title", "head_branch", "base_branch"], }, }, { - name: "github_get_pull_request", + name: "gitlab_get_pull_request", description: - "Fetch a pull request's title, description, state, and diff so it can be reviewed.", + "Fetch a merge request's title, description, state, and diff so it can be reviewed.", inputSchema: { type: "object", properties: { @@ -374,17 +374,17 @@ const tools: Tool[] = [ prId: { type: "string", description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", + UUID_DESC.replace("%s", "linked merge request") + + " Use gitlab_list_task_prs to get the PR ID.", }, }, required: ["projectId", "taskId", "prId"], }, }, { - name: "github_get_pull_request_ci_status", + name: "gitlab_get_pull_request_ci_status", description: - "Get the CI/check status for a pull request's latest commit (e.g. GitHub Actions runs, other check runs, and legacy commit statuses). Returns an overall state (success, failure, pending, or unknown) plus the individual checks.", + "Get the CI/check status for a merge request's latest commit (e.g. GitLab Actions runs, other check runs, and legacy commit statuses). Returns an overall state (success, failure, pending, or unknown) plus the individual checks.", inputSchema: { type: "object", properties: { @@ -393,16 +393,16 @@ const tools: Tool[] = [ prId: { type: "string", description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", + UUID_DESC.replace("%s", "linked merge request") + + " Use gitlab_list_task_prs to get the PR ID.", }, }, required: ["projectId", "taskId", "prId"], }, }, { - name: "github_comment_pull_request", - description: "Add a general (non-review) comment to a pull request.", + name: "gitlab_comment_pull_request", + description: "Add a general (non-review) comment to a merge request.", inputSchema: { type: "object", properties: { @@ -411,8 +411,8 @@ const tools: Tool[] = [ prId: { type: "string", description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", + UUID_DESC.replace("%s", "linked merge request") + + " Use gitlab_list_task_prs to get the PR ID.", }, body: { type: "string", @@ -423,9 +423,9 @@ const tools: Tool[] = [ }, }, { - name: "github_review_pull_request", + name: "gitlab_review_pull_request", description: - "Submit a formal review on a pull request (approve, request changes, or comment). Call github_get_pull_request first to see the diff.", + "Submit a formal review on a merge request (approve, request changes, or comment). Call gitlab_get_pull_request first to see the diff.", inputSchema: { type: "object", properties: { @@ -434,8 +434,8 @@ const tools: Tool[] = [ prId: { type: "string", description: - UUID_DESC.replace("%s", "linked pull request") + - " Use github_list_task_prs to get the PR ID.", + UUID_DESC.replace("%s", "linked merge request") + + " Use gitlab_list_task_prs to get the PR ID.", }, event: { type: "string", @@ -453,9 +453,9 @@ const tools: Tool[] = [ }, // ── Branches ───────────────────────────────────────────────────────────── { - name: "github_create_branch", + name: "gitlab_create_branch", description: - "Create a new branch on GitHub for a task and link it to the task.", + "Create a new branch on GitLab for a task and link it to the task.", inputSchema: { type: "object", properties: { @@ -465,7 +465,7 @@ const tools: Tool[] = [ type: "string", description: UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", + " Use gitlab_list_linked_repos to get the repo ID.", }, branch_name: { type: "string", @@ -482,9 +482,9 @@ const tools: Tool[] = [ }, }, { - name: "github_link_branch_to_task", + name: "gitlab_link_branch_to_task", description: - "Link an existing GitHub branch to a task (does not create the branch on GitHub).", + "Link an existing GitLab branch to a task (does not create the branch on GitLab).", inputSchema: { type: "object", properties: { @@ -494,7 +494,7 @@ const tools: Tool[] = [ type: "string", description: UUID_DESC.replace("%s", "linked repository") + - " Use github_list_linked_repos to get the repo ID.", + " Use gitlab_list_linked_repos to get the repo ID.", }, branch_name: { type: "string", @@ -506,7 +506,7 @@ const tools: Tool[] = [ }, }, { - name: "github_list_task_branches", + name: "gitlab_list_task_branches", description: "List branches linked to a task.", inputSchema: { type: "object", @@ -534,46 +534,46 @@ const entry: PluginMCPEntry = { try { switch (name) { // ── Integration ──────────────────────────────────────────────── - case "github_get_integration": { + case "gitlab_get_integration": { const { projectId } = args as { projectId: string }; - const integration = await api.pluginGet( + const integration = await api.pluginGet( `projects/${projectId}/integration`, ); return textResult(formatIntegration(integration)); } - case "github_set_token": { + case "gitlab_set_token": { const { projectId, token } = args as { projectId: string; token: string; }; - const integration = await api.pluginPost( + const integration = await api.pluginPost( `projects/${projectId}/integration/token`, { token }, ); return textResult( - `GitHub token set successfully:\n\n${formatIntegration(integration)}`, + `GitLab token set successfully:\n\n${formatIntegration(integration)}`, ); } - case "github_delete_token": { + case "gitlab_delete_token": { const { projectId } = args as { projectId: string }; await api.pluginDelete(`projects/${projectId}/integration/token`); - return textResult("GitHub token deleted successfully."); + return textResult("GitLab token deleted successfully."); } // ── Repositories ─────────────────────────────────────────────── - case "github_list_linked_repos": { + case "gitlab_list_linked_repos": { const { projectId } = args as { projectId: string }; const repos = await api.pluginGet( `projects/${projectId}/repositories`, ); return textResult( - `Linked GitHub Repositories:\n\n${formatList(repos, formatLinkedRepo)}`, + `Linked GitLab Repositories:\n\n${formatList(repos, formatLinkedRepo)}`, ); } - case "github_link_repository": { + case "gitlab_link_repository": { const { projectId, owner, repo_name } = args as { projectId: string; owner: string; @@ -588,7 +588,7 @@ const entry: PluginMCPEntry = { ); } - case "github_unlink_repository": { + case "gitlab_unlink_repository": { const { projectId, repoId } = args as { projectId: string; repoId: string; @@ -599,8 +599,8 @@ const entry: PluginMCPEntry = { return textResult(`Repository ${repoId} unlinked successfully.`); } - // ── Pull Requests ────────────────────────────────────────────── - case "github_list_task_prs": { + // ── Merge Requests ────────────────────────────────────────────── + case "gitlab_list_task_prs": { const { projectId, taskId } = args as { projectId: string; taskId: string; @@ -609,11 +609,11 @@ const entry: PluginMCPEntry = { `projects/${projectId}/tasks/${taskId}/pull-requests`, ); return textResult( - `Pull Requests:\n\n${formatList(prs, formatPullRequest)}`, + `Merge Requests:\n\n${formatList(prs, formatPullRequest)}`, ); } - case "github_link_pr_to_task": { + case "gitlab_link_pr_to_task": { const { projectId, taskId, repoId, pr_number } = args as { projectId: string; taskId: string; @@ -625,11 +625,11 @@ const entry: PluginMCPEntry = { { repo_id: repoId, pr_number }, ); return textResult( - `Pull request linked successfully:\n\n${formatPullRequest(pr)}`, + `Merge request linked successfully:\n\n${formatPullRequest(pr)}`, ); } - case "github_unlink_pr_from_task": { + case "gitlab_unlink_pr_from_task": { const { projectId, taskId, prId } = args as { projectId: string; taskId: string; @@ -638,10 +638,10 @@ const entry: PluginMCPEntry = { await api.pluginDelete( `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}`, ); - return textResult(`Pull request ${prId} unlinked successfully.`); + return textResult(`Merge request ${prId} unlinked successfully.`); } - case "github_create_pull_request": { + case "gitlab_create_pull_request": { const { projectId, taskId, repoId, title, head_branch, base_branch, body } = args as { projectId: string; @@ -657,11 +657,11 @@ const entry: PluginMCPEntry = { { repo_id: repoId, title, head_branch, base_branch, body: body ?? "" }, ); return textResult( - `Pull request created successfully:\n\n#${pr.pr_number} ${pr.title}\nState: ${pr.state}\nAuthor: ${pr.author}\nHead: ${pr.head_branch} → Base: ${pr.base_branch}\nURL: ${pr.html_url}`, + `Merge request created successfully:\n\n#${pr.pr_number} ${pr.title}\nState: ${pr.state}\nAuthor: ${pr.author}\nHead: ${pr.head_branch} → Base: ${pr.base_branch}\nURL: ${pr.html_url}`, ); } - case "github_get_pull_request": { + case "gitlab_get_pull_request": { const { projectId, taskId, prId } = args as { projectId: string; taskId: string; @@ -673,7 +673,7 @@ const entry: PluginMCPEntry = { return textResult(formatPullRequestDetails(pr)); } - case "github_get_pull_request_ci_status": { + case "gitlab_get_pull_request_ci_status": { const { projectId, taskId, prId } = args as { projectId: string; taskId: string; @@ -685,7 +685,7 @@ const entry: PluginMCPEntry = { return textResult(formatCIStatus(ci)); } - case "github_comment_pull_request": { + case "gitlab_comment_pull_request": { const { projectId, taskId, prId, body } = args as { projectId: string; taskId: string; @@ -699,7 +699,7 @@ const entry: PluginMCPEntry = { return textResult("Comment posted successfully."); } - case "github_review_pull_request": { + case "gitlab_review_pull_request": { const { projectId, taskId, prId, event, body } = args as { projectId: string; taskId: string; @@ -715,7 +715,7 @@ const entry: PluginMCPEntry = { } // ── Branches ─────────────────────────────────────────────────── - case "github_create_branch": { + case "gitlab_create_branch": { const { projectId, taskId, repoId, branch_name, source_branch } = args as { projectId: string; @@ -733,7 +733,7 @@ const entry: PluginMCPEntry = { ); } - case "github_link_branch_to_task": { + case "gitlab_link_branch_to_task": { const { projectId, taskId, repoId, branch_name } = args as { projectId: string; taskId: string; @@ -749,7 +749,7 @@ const entry: PluginMCPEntry = { ); } - case "github_list_task_branches": { + case "gitlab_list_task_branches": { const { projectId, taskId } = args as { projectId: string; taskId: string; @@ -767,7 +767,7 @@ const entry: PluginMCPEntry = { } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - return errorResult(`GitHub plugin error: ${msg}`); + return errorResult(`GitLab plugin error: ${msg}`); } }, @@ -791,7 +791,7 @@ const entry: PluginMCPEntry = { ]); if (branches.length === 0 && prs.length === 0) return null; - const lines = ["## GitHub"]; + const lines = ["## GitLab"]; if (branches.length > 0) { lines.push( "", @@ -802,7 +802,7 @@ const entry: PluginMCPEntry = { if (prs.length > 0) { lines.push( "", - "**Pull Requests:**", + "**Merge Requests:**", ...prs.map( (pr) => `- #${pr.pr_number} [${pr.state}] ${pr.title} — ${pr.html_url}`, ), diff --git a/plugin.json b/plugin.json index 0dc7d6e..47a8894 100644 --- a/plugin.json +++ b/plugin.json @@ -1,25 +1,25 @@ { - "id": "com.paca.github", - "displayName": "GitHub Integration", - "description": "Integrates GitHub repositories, pull requests, and branches with Paca projects and tasks.", + "id": "com.paca.gitlab", + "displayName": "GitLab Integration", + "description": "Integrates GitLab projects, merge requests, and branches with Paca projects and tasks.", "version": "0.4.0", "minCoreVersion": "v0.13.3", "capabilities": ["repository"], "permissions": ["db.read", "db.write"], "customPermissions": [ { - "key": "github.manage", - "label": "Manage GitHub integration", - "description": "View and manage this project's GitHub integration: connect or disconnect the access token, and link or unlink repositories. Does not affect PR/branch info on individual tasks, which any project member with task access can already see.", + "key": "gitlab.manage", + "label": "Manage GitLab integration", + "description": "View and manage this project's GitLab integration: connect or disconnect the access token, and link or unlink repositories. Does not affect MR/branch info on individual tasks, which any project member with task access can already see.", "scope": "project" } ], "backend": { "allowedConfigKeys": ["ENCRYPTION_KEY", "PUBLIC_URL"], - "allowedOutboundDomains": ["api.github.com"], + "allowedOutboundDomains": ["gitlab.com"], "sensitiveFields": { - "github_integrations": ["access_token_enc"], - "github_repositories": ["webhook_secret_enc"] + "gitlab_integrations": ["access_token_enc"], + "gitlab_repositories": ["webhook_secret_enc"] }, "routes": [ { @@ -31,7 +31,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -44,7 +44,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -57,7 +57,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -70,7 +70,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -83,7 +83,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -96,7 +96,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -109,7 +109,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -122,7 +122,7 @@ { "name": "requirePermissions", "scope": "project", - "permissions": ["github.manage"] + "permissions": ["gitlab.manage"] } ] }, @@ -277,18 +277,18 @@ ] }, "frontend": { - "remoteEntryUrl": "/plugins/com.paca.github/assets/remoteEntry.js", + "remoteEntryUrl": "/plugins/com.paca.gitlab/assets/remoteEntry.js", "extensionPoints": [ { "point": "project.settings.tab", - "component": "GitHubSettingsTab", - "label": "GitHub", + "component": "GitLabSettingsTab", + "label": "GitLab", "order": 100, - "requiredPermission": "github.manage" + "requiredPermission": "gitlab.manage" }, { "point": "task.detail.section", - "component": "GitHubTaskSection", + "component": "GitLabTaskSection", "order": 5 } ] @@ -296,25 +296,25 @@ "automation": { "triggers": [ { - "type": "github.pr_linked", - "label": "GitHub: Pull Request Created", - "eventTopic": "github.pr_linked" + "type": "gitlab.mr_linked", + "label": "GitLab: Merge Request Created", + "eventTopic": "gitlab.mr_linked" }, { - "type": "github.pr_state_changed", - "label": "GitHub: Pull Request State Changed", - "eventTopic": "github.pr_state_changed" + "type": "gitlab.mr_state_changed", + "label": "GitLab: Merge Request State Changed", + "eventTopic": "gitlab.mr_state_changed" }, { - "type": "github.branch_linked", - "label": "GitHub: Branch Created", - "eventTopic": "github.branch_linked" + "type": "gitlab.branch_linked", + "label": "GitLab: Branch Created", + "eventTopic": "gitlab.branch_linked" } ], "conditions": [ { - "type": "github.pr_state", - "label": "GitHub: Pull Request State", + "type": "gitlab.mr_state", + "label": "GitLab: Merge Request State", "configSchema": { "type": "object", "required": ["expected_state"], @@ -330,8 +330,8 @@ ], "actions": [ { - "type": "github.merge_pr", - "label": "GitHub: Merge Pull Request", + "type": "gitlab.merge_mr", + "label": "GitLab: Merge MR", "configSchema": { "type": "object", "properties": { @@ -345,8 +345,8 @@ } }, { - "type": "github.comment_pr", - "label": "GitHub: Comment on Pull Request", + "type": "gitlab.comment_mr", + "label": "GitLab: Comment on Merge Request", "configSchema": { "type": "object", "required": ["body"], @@ -358,11 +358,11 @@ ] }, "mcp": { - "remoteEntryUrl": "/plugins-mcp/com.paca.github/mcp.js", + "remoteEntryUrl": "/plugins-mcp/com.paca.gitlab/mcp.js", "toolContextHooks": ["get_task"] }, "skills": { - "baseUrl": "/plugins-skills/com.paca.github", - "names": ["paca-github-workflow"] + "baseUrl": "/plugins-skills/com.paca.gitlab", + "names": ["paca-gitlab-workflow"] } } diff --git a/skills/paca-github-workflow/SKILL.md b/skills/paca-github-workflow/SKILL.md deleted file mode 100644 index 8cfa141..0000000 --- a/skills/paca-github-workflow/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: paca-github-workflow -description: Link a GitHub repository to a Paca project, create branches, open and manage pull requests, review PRs, and check CI status — using the com.paca.github plugin's tools. Use when asked to open a PR, create a branch, link a pull request to a task, review or comment on a PR, or check CI/build status for a task. -triggers: - - /paca-github-workflow - - create a pull request - - open a pr - - create branch - - link pull request - - review this pr - - check ci status ---- - -# GitHub Workflow Skill - -This plugin (`com.paca.github`) connects a Paca project to GitHub via a Personal Access Token (PAT) — not a GitHub App — and links repositories, branches, and pull requests to individual tasks. All GitHub API calls happen server-side; you only ever call this plugin's tools. - -## Prerequisites — check before doing anything else - -1. `github_get_integration({projectId})`. If `connected` is false, a GitHub PAT must be connected first (scopes `repo` + `admin:repo_hook`) — you cannot mint one yourself. Tell the user to add it under Project Settings → GitHub, or, if they give you a token directly in the conversation, call `github_set_token({projectId, token})` yourself. -2. `github_list_linked_repos({projectId})`. If empty and you already know the exact `owner`/`repo_name`, call `github_link_repository({projectId, owner, repo_name})` — this also creates the required webhook automatically. If you don't know the exact owner/repo name, ask the user; there's no tool to browse "which repos can this token see" (that picker is UI-only). - -Every branch/PR tool below 404s (`GITHUB_INTEGRATION_NOT_FOUND` / `GITHUB_REPOSITORY_NOT_FOUND`) until both of these are satisfied. - -## Tools - -**Integration** -- `github_get_integration(projectId)` — connection status. -- `github_set_token(projectId, token)` — only when the user hands you a token directly. -- `github_delete_token(projectId)` — disconnects and removes webhooks. - -**Repositories** -- `github_list_linked_repos(projectId)` -- `github_link_repository(projectId, owner, repo_name)` -- `github_unlink_repository(projectId, repoId)` - -**Branches** -- `github_create_branch(projectId, taskId, repoId, branch_name, source_branch?)` — `source_branch` defaults to the repo's default branch if omitted. -- `github_list_task_branches(projectId, taskId)` - -**Pull requests** -- `github_list_task_prs(projectId, taskId)` -- `github_create_pull_request(projectId, taskId, repoId, title, head_branch, base_branch, body?)` — creates the PR on GitHub **and** links it to the task in the same call. -- `github_link_pr_to_task(projectId, taskId, repoId, pr_number)` — only for a PR that already existed before you touched it (opened by a human, or via `gh pr create` directly). -- `github_unlink_pr_from_task(projectId, taskId, prId)` -- `github_get_pull_request(projectId, taskId, prId)` — title, state, body, and the full diff. -- `github_get_pull_request_ci_status(projectId, taskId, prId)` — combined GitHub Actions / check-run / legacy status state. -- `github_comment_pull_request(projectId, taskId, prId, body)` — a plain issue-style comment. -- `github_review_pull_request(projectId, taskId, prId, event, body?)` — `event` is `APPROVE`, `REQUEST_CHANGES`, or `COMMENT`; `body` is required by GitHub for the latter two. - -`repoId` and `prId` are Paca's own internal ids — get `repoId` from `github_list_linked_repos` and `prId` from `github_list_task_prs`. Neither is a GitHub PR number or `owner/repo` string; don't substitute one for the other. - -## Workflow — finishing a task with a PR - -1. Confirm the prerequisites above. -2. Name the branch `/-[-slug]` — e.g. `feat/PROJ-42-add-auth` — matching the task's own reference. This is load-bearing, not cosmetic: both the manual UI and the webhook auto-linker match on exactly this pattern to associate branches and PRs with a task without an explicit link call. -3. `github_create_branch`, or push a branch with a matching name yourself. -4. Commit and push your changes (plain git — outside this plugin's scope). -5. `github_create_pull_request`. This both opens the PR and links it to the task — don't also call `github_link_pr_to_task` afterward; that's only for PRs that already existed independently of you. -6. Before merging or requesting review, call `github_get_pull_request_ci_status`. Treat `pending` **and** `unknown` as not yet safe to merge — `unknown` means no checks have reported at all, not that everything passed. - -## Reviewing a PR - -1. `github_get_pull_request` first, to read the diff — never review blind. -2. `github_review_pull_request` with `event: APPROVE | REQUEST_CHANGES | COMMENT`. -3. For a lighter note that isn't a formal review verdict, use `github_comment_pull_request` instead. - -## Constraints - -- `github_comment_pull_request` and `github_review_pull_request` post directly to GitHub — they do **not** add anything to the Paca task's own activity or comments. If the user also wants a note left on the Paca task, add that separately with the core `add_task_comment` tool. -- You cannot mint a GitHub PAT and cannot enumerate which repos a token can see — if you don't already know the exact `owner`/`repo_name`, ask rather than guess. diff --git a/skills/paca-gitlab-workflow/SKILL.md b/skills/paca-gitlab-workflow/SKILL.md new file mode 100644 index 0000000..4350b3d --- /dev/null +++ b/skills/paca-gitlab-workflow/SKILL.md @@ -0,0 +1,71 @@ +--- +name: paca-gitlab-workflow +description: Link a GitLab repository to a Paca project, create branches, open and manage merge requests, review PRs, and check CI status — using the com.paca.gitlab plugin's tools. Use when asked to open a merge request, create a branch, link a merge request to a task, review or comment on a PR, or check CI/build status for a task. +triggers: + - /paca-gitlab-workflow + - create a merge request + - open a merge request + - create branch + - link merge request + - review this pr + - check ci status +--- + +# GitLab Workflow Skill + +This plugin (`com.paca.gitlab`) connects a Paca project to GitLab via a Personal Access Token (PAT) — not a GitLab App — and links repositories, branches, and merge requests to individual tasks. All GitLab API calls happen server-side; you only ever call this plugin's tools. + +## Prerequisites — check before doing anything else + +1. `gitlab_get_integration({projectId})`. If `connected` is false, a GitLab PAT must be connected first (scopes `api` (Maintainer/Owner to manage webhooks)) — you cannot mint one yourself. Tell the user to add it under Project Settings → GitLab, or, if they give you a token directly in the conversation, call `gitlab_set_token({projectId, token})` yourself. +2. `gitlab_list_linked_repos({projectId})`. If empty and you already know the exact `owner`/`repo_name`, call `gitlab_link_repository({projectId, owner, repo_name})` — this also creates the required webhook automatically. If you don't know the exact owner/repo name, ask the user; there's no tool to browse "which repos can this token see" (that picker is UI-only). + +Every branch/PR tool below 404s (`GITHUB_INTEGRATION_NOT_FOUND` / `GITHUB_REPOSITORY_NOT_FOUND`) until both of these are satisfied. + +## Tools + +**Integration** +- `gitlab_get_integration(projectId)` — connection status. +- `gitlab_set_token(projectId, token)` — only when the user hands you a token directly. +- `gitlab_delete_token(projectId)` — disconnects and removes webhooks. + +**Repositories** +- `gitlab_list_linked_repos(projectId)` +- `gitlab_link_repository(projectId, owner, repo_name)` +- `gitlab_unlink_repository(projectId, repoId)` + +**Branches** +- `gitlab_create_branch(projectId, taskId, repoId, branch_name, source_branch?)` — `source_branch` defaults to the repo's default branch if omitted. +- `gitlab_list_task_branches(projectId, taskId)` + +**Merge requests** +- `gitlab_list_task_prs(projectId, taskId)` +- `gitlab_create_pull_request(projectId, taskId, repoId, title, head_branch, base_branch, body?)` — creates the PR on GitLab **and** links it to the task in the same call. +- `gitlab_link_pr_to_task(projectId, taskId, repoId, pr_number)` — only for a PR that already existed before you touched it (opened by a human, or via `glab mr create` directly). +- `gitlab_unlink_pr_from_task(projectId, taskId, prId)` +- `gitlab_get_pull_request(projectId, taskId, prId)` — title, state, body, and the full diff. +- `gitlab_get_pull_request_ci_status(projectId, taskId, prId)` — combined GitLab Actions / check-run / legacy status state. +- `gitlab_comment_pull_request(projectId, taskId, prId, body)` — a plain issue-style comment. +- `gitlab_review_pull_request(projectId, taskId, prId, event, body?)` — `event` is `APPROVE`, `REQUEST_CHANGES`, or `COMMENT`; `body` is required by GitLab for the latter two. + +`repoId` and `prId` are Paca's own internal ids — get `repoId` from `gitlab_list_linked_repos` and `prId` from `gitlab_list_task_prs`. Neither is a GitLab PR number or `owner/repo` string; don't substitute one for the other. + +## Workflow — finishing a task with a PR + +1. Confirm the prerequisites above. +2. Name the branch `/-[-slug]` — e.g. `feat/PROJ-42-add-auth` — matching the task's own reference. This is load-bearing, not cosmetic: both the manual UI and the webhook auto-linker match on exactly this pattern to associate branches and PRs with a task without an explicit link call. +3. `gitlab_create_branch`, or push a branch with a matching name yourself. +4. Commit and push your changes (plain git — outside this plugin's scope). +5. `gitlab_create_pull_request`. This both opens the PR and links it to the task — don't also call `gitlab_link_pr_to_task` afterward; that's only for PRs that already existed independently of you. +6. Before merging or requesting review, call `gitlab_get_pull_request_ci_status`. Treat `pending` **and** `unknown` as not yet safe to merge — `unknown` means no checks have reported at all, not that everything passed. + +## Reviewing a PR + +1. `gitlab_get_pull_request` first, to read the diff — never review blind. +2. `gitlab_review_pull_request` with `event: APPROVE | REQUEST_CHANGES | COMMENT`. +3. For a lighter note that isn't a formal review verdict, use `gitlab_comment_pull_request` instead. + +## Constraints + +- `gitlab_comment_pull_request` and `gitlab_review_pull_request` post directly to GitLab — they do **not** add anything to the Paca task's own activity or comments. If the user also wants a note left on the Paca task, add that separately with the core `add_task_comment` tool. +- You cannot mint a GitLab PAT and cannot enumerate which repos a token can see — if you don't already know the exact `owner`/`repo_name`, ask rather than guess. From 9a794829b24e6c17c4c9f227ed8711b4af40068d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 15:17:30 +0000 Subject: [PATCH 2/3] docs: rewrite README for com.paca.gitlab plugin Document GitLab setup, token kinds, optional instance URL, webhook 19+, i18n, and differences from the GitHub plugin. Note Self-Managed tests deferred. Co-authored-by: Alessandro Gorla --- README.md | 129 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 78af844..e202688 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,115 @@ # com.paca.gitlab -First-party Paca plugin that integrates GitLab projects, merge requests, and branches with Paca projects and tasks. +Plugin first-party Paca che collega **progetti GitLab**, **merge request** e **branch** a progetti e task Paca. -Forked from `com.paca.github` and ported to GitLab. **Source/CI/release stay on GitHub**; the product integrates GitLab.com and Self-Managed (19+). +Port del plugin GitHub (`com.paca.github`) verso GitLab. Il **codice e la CI restano su GitHub**; il prodotto parla con GitLab.com (e, nel codice, con Self-Managed via URL opzionale). + +Documentazione GitLab di riferimento: [docs.gitlab.com](https://docs.gitlab.com/) · [REST API](https://docs.gitlab.com/ee/api/rest/) · [Project webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/) · [Access token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/) · [MCP server ufficiale](https://docs.gitlab.com/user/model_context_protocol/mcp_server/) + +--- + +## Cosa fa + +- Salva un access token GitLab (cifrato) per progetto Paca +- Elenca e collega progetti GitLab accessibili dal token +- Crea webhook sul progetto collegato (push, merge request, pipeline) +- Su ogni task Paca: crea/linka branch e merge request, commenti, review, stato CI (pipelines) +- Espone trigger/condizioni/azioni di automazione (`gitlab.mr_*`, `gitlab.branch_linked`, …) +- Bundle MCP + skill `paca-gitlab-workflow` centrati sul *link al task Paca* (non sostituiscono il [MCP ufficiale GitLab](https://docs.gitlab.com/user/model_context_protocol/mcp_server/)) + +--- + +## Differenze rispetto al plugin GitHub + +| | GitHub (`com.paca.github`) | GitLab (`com.paca.gitlab`) | +|--|---------------------------|----------------------------| +| Oggetto | Pull request | Merge request (`iid`) | +| Auth | PAT | Personal **o** Project **o** Group Access Token | +| Host API | `api.github.com` fisso | `instance_url` (default `https://gitlab.com`) | +| Webhook | `X-Hub-Signature-256` | `signing_token` HMAC (GitLab **19+**, Standard Webhooks) | +| CI | Checks / commit status | Pipelines MR | +| Review | PR reviews | Note + Approvals API (`APPROVE` / `REQUEST_CHANGES` / `COMMENT`) | +| UI | inglese | i18n **en** + **it** | --- -## Architecture +## Setup + +### Config host Paca + +| Chiave | Obbligatoria | Descrizione | +|--------|--------------|-------------| +| `ENCRYPTION_KEY` | sì | 64 caratteri hex (chiave AES-256-GCM per token e signing token webhook) | +| `PUBLIC_URL` | sì per i webhook | URL pubblico del server Paca (callback webhook) | + +Senza `PUBLIC_URL` il link di un progetto GitLab fallisce: la creazione webhook richiede un URL raggiungibile. + +Outbound in `plugin.json`: `gitlab.com`. Per un’istanza Self-Managed andrebbe aggiunto l’hostname in `allowedOutboundDomains` prima del build (test Self-Managed non in scope per ora). + +### Token GitLab + +Supportati (obbligatori in prodotto): + +1. **Personal Access Token** +2. **Project Access Token** +3. **Group Access Token** + +Scope consigliato: [`api`](https://docs.gitlab.com/security/tokens/access_token_scopes/). +Per creare project webhook serve ruolo **Maintainer** o **Owner** sul progetto GitLab ([webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/)). + +In Project Settings → **GitLab**: + +- **Instance URL** (opzionale): default `https://gitlab.com` +- **Token type**: personal / project / group +- **Access token**: valore del token + +Project/Group token vedono solo il proprio scope: la lista “accessible projects” può essere corta o un solo progetto — è normale. + +### Webhook (GitLab 19+) + +Alla link di un progetto il plugin crea un hook con: + +- `push_events`, `merge_requests_events`, `pipeline_events` +- **`signing_token`** (`whsec_…`) — path primario di verifica (niente secret token legacy) + +Header attesi in ingresso: `X-Gitlab-Event`, `webhook-id`, `webhook-timestamp`, `webhook-signature`. + +--- + +## Architettura ```text -├── backend/ - Go WASM plugin (runs inside the API host) -├── frontend/ - React micro-frontend (Module Federation remote) + i18n (en/it) -└── mcp/ - MCP tools bundle for AI/tooling integrations (Paca task-centric) +├── backend/ Go → WASM (plugin-sdk-go), schema plugin_data_com_paca_gitlab +├── frontend/ React Module Federation + i18n (en/it) +├── mcp/ Tool MCP Paca-centrici +└── skills/ paca-gitlab-workflow ``` -GitLab also ships an [official MCP server](https://docs.gitlab.com/user/model_context_protocol/mcp_server/) at `/api/v4/mcp`. The plugin MCP remains required for linking MRs/branches to Paca tasks; use the official MCP as a semantic/API reference. +Plugin ID: `com.paca.gitlab` · permesso custom: `gitlab.manage` -### Auth +### Route principali -- **Personal**, **Project**, and **Group** Access Tokens (all supported). -- Scope: `api`. Creating project webhooks requires **Maintainer** or **Owner**. -- Per-integration `instance_url` (default `https://gitlab.com`). -- Tokens encrypted with AES-256-GCM (`ENCRYPTION_KEY`). +Prefisso host: `/api/v1/plugins/com.paca.gitlab/projects/:projectId/…` -### Webhooks (GitLab 19+) +| Metodo | Path | Note | +|--------|------|------| +| GET/POST/DELETE | `/integration`, `/integration/token` | stato + set/remove token (`instance_url`, `token_kind`) | +| GET | `/integration/accessible-repos` | progetti visibili al token | +| GET/POST/DELETE | `/repositories`… | link/unlink + webhook | +| * | `/tasks/:taskId/pull-requests`… | MR (path legacy “pull-requests”, semantica MR) | +| * | `/tasks/:taskId/branches`… | branch | +| POST | `/webhook` | pubblico (firma signing_token) | -- Events: push, merge request, pipeline. -- Verification: `signing_token` (Standard Webhooks HMAC) — primary path. +--- -### Config +## i18n -- `ENCRYPTION_KEY`: 64 hex chars. -- `PUBLIC_URL`: public Paca base URL for webhook callbacks. -- Outbound allowlist in `plugin.json`: `gitlab.com`. For Self-Managed, add your hostname to `allowedOutboundDomains` before building. +Frontend: cataloghi `en` e `it` in `frontend/src/i18n/`. +Locale da prop host `locale` oppure `navigator.language` (fallback `en`). --- -## Development +## Sviluppo ### Backend @@ -49,8 +122,18 @@ tinygo build -target=wasip1 -buildmode=c-shared -o gitlab.wasm . ### Frontend / MCP ```bash -cd frontend && bun install && bun run build -cd mcp && bun install && bun run build +cd frontend && bun install && bun run typecheck && bun run build +cd mcp && bun install && bun run typecheck && bun run build ``` -Release: `.github/workflows/release.yml` on tag `v*`. +### Release + +Workflow GitHub Actions: `.github/workflows/release.yml` su tag `v*` → artifact `gitlab-*.tar.gz`. + +--- + +## Note + +- **Self-Managed:** campo `instance_url` presente; smoke/test su istanze private **deferred** finché non c’è un’istanza da usare. Validazione primaria prevista su GitLab.com. +- Coesistenza GitHub+GitLab sullo stesso progetto Paca: **non** in scope. +- Minimo GitLab: **19+** (firma webhook `signing_token`). From 15e97f38cbae707d9b5fc3af42ba3e0cf4d3c1c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 15:21:50 +0000 Subject: [PATCH 3/3] fix: remove unused GitHub compat helpers for golangci-lint Drop dead ghPullRequest/ghRepository aliases, glProjectToGHCompat, and unused decryptToken wrapper so the Lint CI job passes. Co-authored-by: Alessandro Gorla --- backend/client.go | 28 ---------------------------- backend/integration.go | 8 -------- 2 files changed, 36 deletions(-) diff --git a/backend/client.go b/backend/client.go index b1eef8e..931c63a 100644 --- a/backend/client.go +++ b/backend/client.go @@ -442,31 +442,3 @@ func (c *glClient) createBranch(ctx context.Context, owner, repo, newBranch, sou func (c *glClient) cloneURL(pathWithNamespace string) string { return c.hostURL + "/" + strings.Trim(pathWithNamespace, "/") + ".git" } - -// Compatibility aliases used by handlers still naming GitHub types in local vars. -type ghPullRequest = glMergeRequest -type ghRepository struct { - ID int64 - FullName string - Name string - DefaultBranch string - Private bool - Owner struct{ Login string } - HTTPURLToRepo string -} - -func glProjectToGHCompat(p *glProject) *ghRepository { - if p == nil { - return nil - } - r := &ghRepository{ - ID: p.ID, - FullName: p.PathWithNamespace, - Name: p.Path, - DefaultBranch: p.DefaultBranch, - Private: p.Private(), - HTTPURLToRepo: p.HTTPURLToRepo, - } - r.Owner.Login = p.Owner() - return r -} diff --git a/backend/integration.go b/backend/integration.go index 0402261..2086455 100644 --- a/backend/integration.go +++ b/backend/integration.go @@ -103,14 +103,6 @@ func (p *gitlabPlugin) loadCreds(projectID string) (*gitlabCreds, error) { return &gitlabCreds{Token: token, InstanceURL: instance, TokenKind: kind}, nil } -func (p *gitlabPlugin) decryptToken(projectID string) (string, error) { - c, err := p.loadCreds(projectID) - if err != nil { - return "", err - } - return c.Token, nil -} - func (p *gitlabPlugin) clientForProject(projectID string) (*glClient, error) { c, err := p.loadCreds(projectID) if err != nil {