From fc73d629ed438ac7d49b1d4727eae79aebf6a1d6 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Tue, 27 Oct 2026 16:08:35 +0000 Subject: [PATCH 01/15] feat: update git workflow - Changed plugin ID from com.gorlix.gitlab to com.paca.gitlab. - Updated version to 0.6.0 and minimum core version to v0.20.0 in plugin.json. - Removed unnecessary backend, frontend, MCP, and skills configurations. - Deleted obsolete files: tsconfig.json, vite.config.ts, and SKILL.md. - Updated install script to reflect new plugin ID and version. - Adjusted Docker volume checks and asset staging for the new plugin structure. --- .github/workflows/frontend-pr-ci.yml | 53 - .github/workflows/mcp-pr-ci.yml | 53 - .github/workflows/release.yml | 40 +- README.md | 487 ++----- backend/automation.go | 181 --- backend/automation_test.go | 105 -- backend/branches.go | 253 ---- backend/client.go | 444 ------ backend/client_url_test.go | 40 - backend/gitlab.go | 85 ++ backend/go.mod | 2 +- backend/go.sum | 4 +- backend/integration.go | 546 -------- .../migrations/0001_create_gitlab_tables.sql | 2 +- backend/migrations/0002_git_provider.sql | 46 + backend/plugin.go | 135 +- backend/plugin_test.go | 604 -------- backend/pull_requests.go | 683 --------- backend/scanner.go | 4 - backend/spi_api.go | 232 +++ backend/spi_assert.go | 21 + backend/spi_branches.go | 92 ++ backend/spi_conformance_test.go | 319 +++++ backend/spi_dbstore.go | 135 ++ backend/spi_dbstore_test.go | 105 ++ backend/spi_fakegitlab_test.go | 601 ++++++++ backend/spi_hooks.go | 90 ++ backend/spi_ops_test.go | 410 ++++++ backend/spi_provider.go | 425 ++++++ backend/spi_reads.go | 703 ++++++++++ backend/spi_setup.go | 211 +++ backend/spi_store.go | 52 + backend/spi_test.go | 524 +++++++ backend/spi_webhook.go | 549 ++++++++ backend/spi_writes.go | 578 ++++++++ backend/webhook.go | 402 ------ backend/webhook_branch_test.go | 213 --- frontend/bun.lock | 275 ---- frontend/index.html | 1 - frontend/package.json | 27 - frontend/src/GitLabSettingsTab.tsx | 987 ------------- frontend/src/GitLabTaskSection.tsx | 1246 ----------------- frontend/src/gitlab-api.ts | 261 ---- frontend/src/i18n/en.ts | 160 --- frontend/src/i18n/index.ts | 34 - frontend/src/i18n/it.ts | 164 --- frontend/src/index.ts | 2 - frontend/tsconfig.json | 16 - frontend/vite.config.ts | 27 - mcp/bun.lock | 329 ----- mcp/package.json | 18 - mcp/src/index.ts | 820 ----------- mcp/tsconfig.json | 10 - mcp/vite.config.ts | 18 - plugin.json | 370 +---- scripts/install-from-release.sh | 79 +- skills/paca-gitlab-workflow/SKILL.md | 71 - 57 files changed, 5303 insertions(+), 9041 deletions(-) delete mode 100644 .github/workflows/frontend-pr-ci.yml delete mode 100644 .github/workflows/mcp-pr-ci.yml delete mode 100644 backend/automation.go delete mode 100644 backend/automation_test.go delete mode 100644 backend/branches.go delete mode 100644 backend/client.go delete mode 100644 backend/client_url_test.go create mode 100644 backend/gitlab.go delete mode 100644 backend/integration.go create mode 100644 backend/migrations/0002_git_provider.sql delete mode 100644 backend/plugin_test.go delete mode 100644 backend/pull_requests.go create mode 100644 backend/spi_api.go create mode 100644 backend/spi_assert.go create mode 100644 backend/spi_branches.go create mode 100644 backend/spi_conformance_test.go create mode 100644 backend/spi_dbstore.go create mode 100644 backend/spi_dbstore_test.go create mode 100644 backend/spi_fakegitlab_test.go create mode 100644 backend/spi_hooks.go create mode 100644 backend/spi_ops_test.go create mode 100644 backend/spi_provider.go create mode 100644 backend/spi_reads.go create mode 100644 backend/spi_setup.go create mode 100644 backend/spi_store.go create mode 100644 backend/spi_test.go create mode 100644 backend/spi_webhook.go create mode 100644 backend/spi_writes.go delete mode 100644 backend/webhook.go delete mode 100644 backend/webhook_branch_test.go delete mode 100644 frontend/bun.lock delete mode 100644 frontend/index.html delete mode 100644 frontend/package.json delete mode 100644 frontend/src/GitLabSettingsTab.tsx delete mode 100644 frontend/src/GitLabTaskSection.tsx delete mode 100644 frontend/src/gitlab-api.ts delete mode 100644 frontend/src/i18n/en.ts delete mode 100644 frontend/src/i18n/index.ts delete mode 100644 frontend/src/i18n/it.ts delete mode 100644 frontend/src/index.ts delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/vite.config.ts delete mode 100644 mcp/bun.lock delete mode 100644 mcp/package.json delete mode 100644 mcp/src/index.ts delete mode 100644 mcp/tsconfig.json delete mode 100644 mcp/vite.config.ts delete mode 100644 skills/paca-gitlab-workflow/SKILL.md diff --git a/.github/workflows/frontend-pr-ci.yml b/.github/workflows/frontend-pr-ci.yml deleted file mode 100644 index d378789..0000000 --- a/.github/workflows/frontend-pr-ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: frontend-pr-ci - -on: - pull_request: - paths: - - "frontend/**" - - ".github/workflows/frontend-pr-ci.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: frontend-pr-ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - web-quality: - name: Typecheck and build frontend - runs-on: ubuntu-latest - timeout-minutes: 20 - - defaults: - run: - working-directory: frontend - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.2.23" - - - name: Cache Bun packages - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Typecheck - run: bun run typecheck - - - name: Build production bundle - run: bun run build \ No newline at end of file diff --git a/.github/workflows/mcp-pr-ci.yml b/.github/workflows/mcp-pr-ci.yml deleted file mode 100644 index 43c8ad9..0000000 --- a/.github/workflows/mcp-pr-ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: mcp-pr-ci - -on: - pull_request: - paths: - - "mcp/**" - - ".github/workflows/mcp-pr-ci.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: mcp-pr-ci-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - mcp-quality: - name: Typecheck and build MCP server - runs-on: ubuntu-latest - timeout-minutes: 10 - - defaults: - run: - working-directory: mcp - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.2.23" - - - name: Cache Bun packages - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-mcp-${{ hashFiles('mcp/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-mcp- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Typecheck - run: bun run typecheck - - - name: Build - run: bun run build \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18ceffe..41f2e47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,11 +28,6 @@ jobs: curl -sL -o /tmp/tinygo.deb https://github.com/tinygo-org/tinygo/releases/download/v0.41.1/tinygo_0.41.1_amd64.deb sudo dpkg -i /tmp/tinygo.deb - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Build backend WASM working-directory: backend run: | @@ -40,66 +35,35 @@ jobs: mkdir -p ../release/backend tinygo build -target=wasip1 -buildmode=c-shared -o ../release/backend/gitlab.wasm . - - name: Build frontend - working-directory: frontend - run: | - set -euo pipefail - bun install --frozen-lockfile - bun run build - - - name: Build MCP - working-directory: mcp - run: | - set -euo pipefail - bun install --frozen-lockfile - bun run build - + # The plugin is provider-only: a backend, its migrations and the manifest. - name: Collect release files run: | set -euo pipefail - mkdir -p release/frontend - cp -R frontend/dist release/frontend/dist - - mkdir -p release/mcp - cp -R mcp/dist release/mcp/dist - mkdir -p release/migrations cp -R backend/migrations/. release/migrations/ - mkdir -p release/skills - cp -R skills/. release/skills/ - cp plugin.json release/plugin.json - name: Create archives run: | set -euo pipefail 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 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 + - name: Publish GitLab Release assets uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: | 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 8174b7b..5946721 100644 --- a/README.md +++ b/README.md @@ -1,452 +1,129 @@ -# com.gorlix.gitlab +# com.paca.gitlab -First-party Paca plugin that integrates **GitLab projects**, **merge requests**, and **branches** with Paca projects and tasks. +GitLab provider for Paca's platform-neutral Git integration (`gitprovider.v1`, see `docs/plugins/git-provider-spi.md` in the Paca repository). + +Paca core owns repositories, project links, identities, merge requests, triggers, agent runs and audit. This plugin only adapts GitLab to the provider SPI: + +- Identity setup through a token form: an access token (and an optional read-only one) pasted by an administrator. +- Repository listing and resolution on gitlab.com or a self-managed instance. +- Credentials for git and the API (GitLab cannot mint narrower tokens, so these are the stored tokens). +- Webhook verification and event normalisation; webhook registration per project. > [!NOTE] -> **Lineage & Upstream Base:** This plugin is based on and ported from the official GitHub integration plugin ([`Paca-AI/paca-plugin-github`](https://github.com/Paca-AI/paca-plugin-github)). It retains the same architectural patterns, database isolation, and lifecycle handling while adapting authentication, webhook signatures, and data models to GitLab.com and Self-Managed GitLab instances. +> **Lineage:** this plugin was ported from [`Paca-AI/paca-plugin-github`](https://github.com/Paca-AI/paca-plugin-github) and follows the same layout and SPI conventions. Everything platform-specific (token identities, project hooks, discussions, commit statuses, pipelines) is GitLab's own. | | | |---|---| -| **Plugin ID** | `com.gorlix.gitlab` | -| **Release Version** | [`v0.5.2`](https://github.com/gorlix/paca-plugin-gitlab/releases/tag/v0.5.2) | -| **Minimum Paca Version** | `minCoreVersion` **v0.13.3** (declared in `plugin.json`) | -| **Minimum GitLab Version** | **19+** (Standard Webhooks with HMAC `signing_token`) | -| **UI Localization** | **en** + **it** | -| **Upstream Base** | [`com.paca.github`](https://github.com/Paca-AI/paca-plugin-github) | +| **Plugin ID** | `com.paca.gitlab` | +| **Version** | `0.6.0` | +| **Minimum Paca version** | `minCoreVersion` **v0.20.0** (declared in `plugin.json`) | +| **Minimum GitLab version** | **15.7** (merge request diffs endpoint) | +| **SPI** | `gitprovider.v1` | -Reference GitLab documentation: [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/) · [Official MCP server](https://docs.gitlab.com/user/model_context_protocol/mcp_server/) +Reference GitLab documentation: [REST API](https://docs.gitlab.com/api/rest/) · [Project webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/) · [Access tokens](https://docs.gitlab.com/security/tokens/) · [Token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/) --- -## Architecture - -The plugin follows the standard Paca plugin structure: +## Structure ```text gitlab/ -├── backend/ - Go WASM plugin (runs inside the API host) -├── frontend/ - React micro-frontend (Module Federation remote) -├── mcp/ - MCP tools bundle for AI/tooling integrations -├── skills/ - Agent skills bundle (paca-gitlab-workflow) -└── scripts/ - Production install automation helper -``` - -### Backend (`backend/`) - -- Written in Go, compiled to `wasip1/wasm` for production. -- Registered as `com.gorlix.gitlab` in the plugin registry. -- Owns its database schema (`plugin_data_com_gorlix_gitlab`) and runs its own migrations on startup (`0001_create_gitlab_tables.sql`). -- Stores GitLab access tokens and webhook signing tokens encrypted with AES-256-GCM. -- Creates and verifies GitLab project webhooks for linked repositories using HMAC Standard Webhooks signatures. -- Handles task/project cleanup on `task.deleted` and `project.deleted` events. - -### Frontend (`frontend/`) - -- Built with Vite + React 19 + TanStack Query + Lucide icons. -- Exposed as a Module Federation remote (`com_gorlix_gitlab`). -- Mounted at: - - `project.settings.tab` (`GitLabSettingsTab`, required permission: `gitlab.manage`) - - `task.detail.section` (`GitLabTaskSection`) -- Internationalization support with complete English (`en`) and Italian (`it`) catalogs. -- Communicates with the backend via plugin API routes under `/api/v1/plugins/com.gorlix.gitlab/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, MR linking, reviews/comments, and branch operations. - -### Skills (`skills/`) - -- Bundles the `paca-gitlab-workflow` skill (`skills/paca-gitlab-workflow/SKILL.md`), enabling AI agents (e.g. Claude Code, AGY) to interact with GitLab within Paca workflows. - ---- - -## Required Configuration - -The backend uses the following host configuration keys: - -- `ENCRYPTION_KEY`: 64 hex characters (32-byte key) used for AES-256-GCM encryption of tokens and secrets. -- `PUBLIC_URL`: Public base URL of the Paca server without trailing slash (e.g. `https://paca.example.com`), used to construct the webhook callback URL. - -Without `PUBLIC_URL`, linking repositories will fail because GitLab webhook creation requires a reachable callback endpoint. - ---- - -## API Endpoints - -All routes are prefixed with `/projects/:projectId` by the host. -The caller must be an authenticated member of the project with appropriate permissions. - -| Method | Path | Description | Required Permission | -|--------|------|-------------|---------------------| -| GET | `/integration` | Get GitLab integration status for the project | `gitlab.manage` | -| POST | `/integration/token` | Set or replace GitLab access token | `gitlab.manage` | -| DELETE | `/integration/token` | Disconnect GitLab integration and delete token | `gitlab.manage` | -| GET | `/integration/accessible-repos` | List repositories accessible by the token | `gitlab.manage` | -| GET | `/repositories` | List repositories linked to the project | `gitlab.manage` | -| POST | `/repositories` | Link a repository and create project webhook | `gitlab.manage` | -| DELETE | `/repositories/:repoId` | Unlink a repository and delete webhook | `gitlab.manage` | -| GET | `/repositories/:repoId/clone-info` | Get repository clone URLs (HTTPS/SSH) | `gitlab.manage` | -| GET | `/tasks/:taskId/pull-requests` | List merge requests linked to a task | `tasks.read` | -| POST | `/tasks/:taskId/pull-requests` | Create a new merge request and link to task | `tasks.write` | -| POST | `/tasks/:taskId/pull-requests/link` | Link an existing merge request to a task | `tasks.write` | -| DELETE | `/tasks/:taskId/pull-requests/:prId` | Unlink a merge request from a task | `tasks.write` | -| GET | `/tasks/:taskId/pull-requests/:prId` | Get merge request details and diff | `tasks.read` | -| GET | `/tasks/:taskId/pull-requests/:prId/ci-status` | Get MR pipeline CI status | `tasks.read` | -| POST | `/tasks/:taskId/pull-requests/:prId/comments` | Post a comment / note to the MR | `tasks.write` | -| POST | `/tasks/:taskId/pull-requests/:prId/reviews` | Submit an MR review / approval | `tasks.write` | -| GET | `/tasks/:taskId/branches` | List branches linked to a task | `tasks.read` | -| POST | `/tasks/:taskId/branches` | Create a new branch and link it to task | `tasks.write` | -| POST | `/tasks/:taskId/branches/link` | Link an existing branch to a task | `tasks.write` | -| POST | `/webhook` (public) | Inbound GitLab webhook receiver | None (HMAC signature) | - -### Example Requests - -**Set token:** -```json -{ - "token": "glpat-xxxxxxxxxxxxxxxxxxxx", - "instance_url": "https://gitlab.com", - "token_kind": "personal" -} -``` -*(Token kinds supported: `"personal"`, `"project"`, `"group"`)* - -**Link repository:** -```json -{ - "owner": "my-org", - "repo_name": "my-service" -} -``` - -**Link MR 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-gitlab-integration", - "source_branch": "main" -} -``` - -**Post comment to MR:** -```json -{ - "body": "Automated review feedback from Paca." -} +└── backend/ - Go WASM plugin (runs inside the API host) ``` -**Submit review / approval:** -```json -{ - "event": "APPROVE", - "body": "Looks good to me!" -} -``` -*(Review events supported: `"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`)* +The plugin has no HTTP routes of its own: core calls the internal-only `POST /_git/:op` operations. It has no frontend, no MCP tools and no skills; the Git UI, the agent tools and the workflow skills live in core and `apps/mcp`. ---- - -## Webhook Behavior +### Backend -On linked repositories, GitLab project webhooks are configured for: +- `spi_*.go` implement the provider operations; the SDK conformance suite (`spi_conformance_test.go`) runs them against a fake GitLab (`spi_fakegitlab_test.go`), and `spi_test.go` / `spi_ops_test.go` cover the GitLab-specific behaviour. +- Owns the schema `plugin_data_com_paca_gitlab` (`gitlab_identities`, `gitlab_setup_sessions`). Access tokens and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value. +- Personal access tokens work, but the recommended identity is a **project or group access token**: GitLab creates a bot user for it, which can be mentioned and is limited to that project or group. -- `push_events` -- `merge_requests_events` -- `pipeline_events` +### Configuration -Current behavior includes: +| Key | Purpose | +|---|---| +| `ENCRYPTION_KEY` | 64 hex characters (a 32-byte key) used to encrypt tokens and webhook secrets. | -- **Verification**: Validates HMAC signatures using Standard Webhooks headers (`webhook-signature`, `webhook-timestamp`, `webhook-id`, `X-Gitlab-Event`) against the stored `signing_token` (`whsec_...`). -- **State Caching**: Caches and updates merge request state and metadata in the plugin database. -- **Auto-linking MRs**: Automatically links merge requests when opened or reopened from a branch already associated with a task. -- **Auto-linking Branches**: Automatically associates pushed branches matching task keys (e.g. `PROJ-42`, `feat/PROJ-42`, `feat/PROJ/42`, or plain number `42`). -- **Pipeline CI Status**: Real-time pipeline status tracking for linked merge requests. -- **Emitting Plugin Events**: - - `gitlab.mr_linked` - - `gitlab.mr_state_changed` - - `gitlab.branch_linked` +`PUBLIC_URL` is no longer needed by the plugin: core gives the webhook URL to the setup flow. --- -## Database Schema +## Setting it up -Tables live in `plugin_data_com_gorlix_gitlab` and are created by `backend/migrations/0001_create_gitlab_tables.sql`: +1. **Create a token** on GitLab, on the project or group the agents should work in: + - *Settings → Access tokens*, role **Developer** (Maintainer if Paca should register the webhooks), scope **`api`**. + - Optionally a second token with scope **`read_repository`**, which Paca hands to `git clone` and `git fetch` instead of the full token. +2. In Paca open **Admin → Git → GitLab → Set up** and paste the token(s). Choose a **webhook secret** (at least 16 random characters, e.g. `openssl rand -hex 32`). The form shows the webhook URL to use. +3. On every GitLab project you link, add a **webhook** (*Settings → Webhooks*) with that URL, the same secret as *Secret token*, and the triggers **Push events, Comments, Issues events, Merge request events, Pipeline events**. +4. Link the projects from each Paca project's **Settings → Git**. -```text -gitlab_integrations - id UUID PK - project_id UUID UNIQUE -> projects(id) ON DELETE CASCADE - access_token_enc TEXT - instance_url TEXT NOT NULL DEFAULT 'https://gitlab.com' - token_kind TEXT NOT NULL DEFAULT 'personal' -- personal | project | group - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - -gitlab_repositories - id UUID PK - project_id UUID -> projects(id) ON DELETE CASCADE - integration_id UUID -> gitlab_integrations(id) ON DELETE CASCADE - owner TEXT - repo_name TEXT - full_name TEXT -- path_with_namespace - gitlab_project_id BIGINT - webhook_id BIGINT - webhook_secret_enc TEXT -- signing_token (whsec_...) - default_branch TEXT NOT NULL DEFAULT 'main' - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - UNIQUE (project_id, full_name) - -gitlab_merge_requests - id UUID PK - project_id UUID -> projects(id) ON DELETE CASCADE - repo_id UUID -> gitlab_repositories(id) ON DELETE CASCADE - pr_number INT -- GitLab MR iid (project-scoped) - gitlab_mr_id BIGINT - title TEXT - state TEXT -- open | merged | closed - html_url TEXT - head_branch TEXT - base_branch TEXT - author TEXT - merged_at TIMESTAMPTZ - created_at TIMESTAMPTZ - updated_at TIMESTAMPTZ - UNIQUE (repo_id, pr_number) - -gitlab_task_mr_links - id UUID PK - task_id UUID -> tasks(id) ON DELETE CASCADE - merge_request_id UUID -> gitlab_merge_requests(id) ON DELETE CASCADE - created_at TIMESTAMPTZ - UNIQUE (task_id, merge_request_id) - -gitlab_task_branches - id UUID PK - task_id UUID -> tasks(id) ON DELETE CASCADE - repo_id UUID -> gitlab_repositories(id) ON DELETE CASCADE - branch_name TEXT - created_at TIMESTAMPTZ - UNIQUE (task_id, repo_id, branch_name) -``` +An agent can have its own identity (its own bot user) from the agent's Git tab, set up the same way. ---- +> [!IMPORTANT] +> Core does not call the provider's `EnsureWebhook` yet, so step 3 is manual for now. The provider implements it (`webhook_management`): once core registers hooks, step 3 disappears. A hook registered that way is created with the same secret and subscriptions as above. -## Differences Compared to the GitHub Plugin +### Self-managed GitLab -| Feature | GitHub (`com.paca.github`) | GitLab (`com.gorlix.gitlab`) | -|---|---|---| -| **Primary Object** | Pull Request | Merge Request (`iid`) | -| **Authentication** | Personal Access Token (PAT) | Personal, Project, or Group Access Token | -| **API Host** | Fixed `api.github.com` | `instance_url` (defaults to `https://gitlab.com`) | -| **Webhook Verification** | `X-Hub-Signature-256` | Standard Webhooks HMAC `signing_token` (GitLab **19+**) | -| **CI Integration** | Checks / Commit statuses | Merge Request Pipelines | -| **Reviews & Approvals** | Pull Request Reviews | Notes + Approvals API (`APPROVE`, `REQUEST_CHANGES`, `COMMENT`) | -| **UI Localization** | English | **en** + **it** | +Enter the instance's host name (no scheme or path, a port is allowed) as the host. Plugin outbound calls are limited to the hosts in `allowedOutboundDomains` (`plugin.json`), HTTPS only, and never to private or internal addresses. Add your host to that list (or use `"*"`) before setting up the identity. --- -## Production Installation +## Behaviour -### Method 1: Marketplace UI (Recommended once listed) - -Once approved in the official Paca marketplace catalog (see [Paca-AI/paca-plugins #34](https://github.com/Paca-AI/paca-plugins/pull/34)), administrators can install `com.gorlix.gitlab` with one click from the Paca Web Admin under **Admin → Plugins**. +| Area | Behaviour | +|---|---| +| Identity | A pasted access token, validated against the instance (`GET /user`, and `GET /personal_access_tokens/self` for scopes and expiry). A token without the `api` scope is refused. A project or group token is reported as a bot user, a personal token as a plain token. The earliest token expiry is reported so Paca can warn before it passes. | +| Credentials | Static: the stored token, with `oauth2` as the user name. Read access returns the read-only token when one was given. A write credential is refused when GitLab says the identity is below Developer on the project. Core never hands these to an agent. | +| Repositories | The projects the token is a member of (archived ones excluded). A project is addressed by its numeric id when core has it (so renames do not break links), else by its URL-encoded path; subgroups work. | +| Webhooks | Verified by the secret token header, or by a Standard Webhooks signature if the hook has a signing token. Normalised: merge request (opened, reopened, new commits, ready/draft, edited, closed, merged, approved), notes on merge requests and issues (including inline notes with their thread and line), issues, pipelines and branch pushes. Event ids come from GitLab's event UUID, so retries are de-duplicated. | +| Addressing | A mention is `@` of the identity. A review request or an assignment is reported only when it names the identity itself (also when set at creation). Labels are reported for every label added; core decides which one names an agent. | +| Status | A commit status named `Paca / ` on the head commit and the merge request's branch, updated in place. | +| Reviews | **Emulated** (`batch_review` is false): every inline comment becomes a discussion on its line, then the summary is a note, then an approval is recorded. Paths are checked against the diff before anything is posted; a line GitLab refuses reports how many comments were already posted. | +| CI | Checks are the jobs of the newest pipeline plus commit statuses other systems posted; logs are job traces with terminal escape codes removed. A pipeline event becomes a check event when it finishes. | +| Triggers declared | `mention`, `label`, `review_request`, `assignment`, `ci_failure`, `own_cr_feedback`, `auto_review`. | +| CI config globs | `.gitlab-ci.yml`, `.gitlab/ci/**`. | + +### Limits to know about + +- **Request changes** has no REST equivalent on GitLab: it is posted as a note starting with *Changes requested*, and no approval is recorded. +- **Merge method** (merge commit, fast-forward) is a project setting in GitLab, so `rebase` is refused; `merge` and `squash` work. The head the reviewer saw is always enforced. +- **Draft** is the `Draft:` prefix of the title, which is how GitLab defines it. +- **Pipelines of source `external`** are statuses other systems post, Paca's own among them. They are not read as CI failures, otherwise a failed agent run would start another one. +- A pipeline that runs for a branch without a merge-request pipeline has no merge request in its event; core can only match it by commit. +- Inline-note threads are not marked *outdated* (GitLab's webhook and list payloads do not say). +- Reactions and access grants are not implemented (`reactions`, `access_grants` are false). A token already carries every grant it will have. +- Code search uses GitLab's project blob search; where an instance has it disabled the call fails. +- Deleting an identity forgets the tokens in Paca; revoke them on GitLab as well. --- -### Method 2: Self-Hosted Release Install - -For self-hosted Docker Compose deployments installing directly from GitHub Releases: - -#### 1. Host Prerequisites - -- Paca **≥ v0.13.3** (`minCoreVersion`). -- `ENCRYPTION_KEY` configured in `.env` (64 hex characters). -- `PUBLIC_URL` reachable via HTTPS from GitLab. -- Admin API Key (`X-API-Key`). - -The production Docker Compose stack (`name: paca`) uses the following volumes: - -| Docker Volume | Container Mount | Contents | -|---------------|-----------------|----------| -| `paca_backend_plugins` | `/plugins` | `backend.wasm`, `plugin.json`, `migrations/*.sql` | -| `paca_frontend_plugins` | `/plugins-frontend` | Module Federation bundle (`assets/remoteEntry.js`) | -| `paca_mcp_plugins` | `/plugins-mcp` | MCP bundle (`mcp.js`) | -| `paca_skills_plugins` | `/plugins-skills` | Agent skill definitions (`paca-gitlab-workflow/SKILL.md`) | - -#### 2. Automated Install via Script - -```bash -export PACA_URL='https://paca.example.com' -export PACA_API_KEY='your-admin-api-key' - -./scripts/install-from-release.sh \ - --api-url "$PACA_URL" \ - --api-key "$PACA_API_KEY" \ - --version v0.5.2 -``` +## Upgrading from 0.5.x -The script verifies SHA256 checksums, populates the `paca_*_plugins` volumes, and registers/updates the plugin via `POST/PATCH /api/v1/admin/plugins`. +Migration `0002_git_provider.sql` **drops** the legacy tables (they only exist if the plugin's schema held them: see the ID note below) (`gitlab_integrations`, `gitlab_repositories`, `gitlab_merge_requests`, `gitlab_task_mr_links`, `gitlab_task_branches`). Nothing is migrated, because a project-bound token cannot back a provider identity, and repositories, merge-request links and branch links now live in Paca core. -#### 3. Manual Step-by-Step Install +The plugin ID changed from `com.gorlix.gitlab` to `com.paca.gitlab`, so this installs as a new plugin with its own schema `plugin_data_com_paca_gitlab`. The old plugin's data stays in `plugin_data_com_gorlix_gitlab` untouched (and is not read); export anything you still need from there, then remove the old plugin. Afterwards set up a GitLab identity (above) and relink the projects in Paca. -```bash -PLUGIN_ID="com.gorlix.gitlab" -VERSION="v0.5.2" -BASE="https://github.com/gorlix/paca-plugin-gitlab/releases/download/${VERSION}" -WORKDIR=$(mktemp -d) -cd "$WORKDIR" - -# 1. Download and verify release assets -curl -fsSL -O "$BASE/checksums.txt" -for f in \ - 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; do - curl -fsSL -O "$BASE/$f" -done -sha256sum -c checksums.txt - -# 2. Extract into staging layout -STAGE="$WORKDIR/stage" -mkdir -p \ - "$STAGE/backend/$PLUGIN_ID/migrations" \ - "$STAGE/frontend/$PLUGIN_ID" \ - "$STAGE/mcp/$PLUGIN_ID" \ - "$STAGE/skills/$PLUGIN_ID" - -tar -xzf gitlab-backend-wasm.tar.gz -C "$STAGE/backend/$PLUGIN_ID" -mv "$STAGE/backend/$PLUGIN_ID/"*.wasm "$STAGE/backend/$PLUGIN_ID/backend.wasm" -tar -xzf gitlab-plugin-manifest.tar.gz -C "$STAGE/backend/$PLUGIN_ID" -tar -xzf gitlab-migrations.tar.gz -C "$STAGE" -cp "$STAGE/migrations/"*.sql "$STAGE/backend/$PLUGIN_ID/migrations/" - -tar -xzf gitlab-frontend-dist.tar.gz -C "$STAGE" -cp -a "$STAGE/dist/." "$STAGE/frontend/$PLUGIN_ID/" -rm -rf "$STAGE/dist" - -tar -xzf gitlab-mcp-dist.tar.gz -C "$STAGE" -cp -a "$STAGE/dist/." "$STAGE/mcp/$PLUGIN_ID/" -rm -rf "$STAGE/dist" - -tar -xzf gitlab-skills-bundle.tar.gz -C "$STAGE" -cp -a "$STAGE/skills/." "$STAGE/skills/$PLUGIN_ID/" - -# 3. Copy to Docker volumes -docker run --rm \ - -v paca_backend_plugins:/plugins \ - -v paca_frontend_plugins:/plugins-frontend \ - -v paca_mcp_plugins:/plugins-mcp \ - -v paca_skills_plugins:/plugins-skills \ - -v "$STAGE:/stage:ro" \ - alpine:3.20 sh -c " - set -e - mkdir -p /plugins /plugins-frontend /plugins-mcp /plugins-skills - rm -rf /plugins/$PLUGIN_ID /plugins-frontend/$PLUGIN_ID /plugins-mcp/$PLUGIN_ID /plugins-skills/$PLUGIN_ID - cp -a /stage/backend/$PLUGIN_ID /plugins/ - cp -a /stage/frontend/$PLUGIN_ID /plugins-frontend/ - cp -a /stage/mcp/$PLUGIN_ID /plugins-mcp/ - cp -a /stage/skills/$PLUGIN_ID /plugins-skills/ - " - -# 4. Register or update via Admin API -PACA_URL='https://paca.example.com' -PACA_API_KEY='your-admin-api-key' -MANIFEST=$(cat "$STAGE/backend/$PLUGIN_ID/plugin.json") -VERSION_NUM=$(echo "$MANIFEST" | jq -r .version) - -LIST=$(curl -fsS -H "X-API-Key: $PACA_API_KEY" "$PACA_URL/api/v1/plugins") -EXISTING_ID=$(echo "$LIST" | jq -r --arg n "$PLUGIN_ID" '.data.plugins[]? | select(.name==$n) | .id' | head -n1) - -if [[ -n "$EXISTING_ID" && "$EXISTING_ID" != "null" ]]; then - curl -fsS -X PATCH "$PACA_URL/api/v1/admin/plugins/$EXISTING_ID" \ - -H "X-API-Key: $PACA_API_KEY" \ - -H "Content-Type: application/json" \ - -d "{\"version\":\"$VERSION_NUM\",\"manifest\":$MANIFEST,\"enabled\":true}" -else - curl -fsS -X POST "$PACA_URL/api/v1/admin/plugins" \ - -H "X-API-Key: $PACA_API_KEY" \ - -H "Content-Type: application/json" \ - -d "{\"name\":\"$PLUGIN_ID\",\"version\":\"$VERSION_NUM\",\"manifest\":$MANIFEST,\"enabled\":true}" -fi - -rm -rf "$WORKDIR" -``` - -If UI assets do not render immediately, run `docker compose restart api gateway`. +The plugin's own screens, MCP tools, automation nodes (`gitlab.mr_linked`, `gitlab.merge_mr`, …), custom permission `gitlab.manage` and the `paca-gitlab-workflow` skill are gone: core's Git integration (neutral `change_request_*` / `review_*` tools, `git:*` permissions, Git triggers on agents) replaces them. Flows that used the old automation nodes need rebuilding on core's Git triggers. --- -## Troubleshooting +## Acknowledgements -| Symptom | Cause & Solution | -|---------|------------------| -| `PLUGIN_INCOMPATIBLE_HOST_VERSION` | Paca server version is below `minCoreVersion`. Upgrade Paca to ≥ `v0.13.3`. | -| Admin API returns 401/403 | Verify that the API key has administrative permissions and is passed in the `X-API-Key` header. | -| Admin API 500 on migrations/load | Ensure `backend.wasm` and `migrations/*.sql` exist in `/plugins/com.gorlix.gitlab/`. Check `api` container logs. | -| Repository link fails / webhook error | Verify `PUBLIC_URL` is reachable from GitLab via HTTPS. Verify the token has `api` scope and Maintainer/Owner permissions. | -| Tokens fail after database restore | `ENCRYPTION_KEY` changed after the secrets were encrypted. Reconnect the tokens in project settings. | -| Empty / short list of accessible projects | Project/Group tokens have restricted scope and only see their designated project/group. | -| Plugin UI does not render | Check that `remoteEntry.js` exists in `/plugins-frontend/com.gorlix.gitlab/assets/`. Run `docker compose restart gateway` and hard-refresh your browser. | -| Outbound requests fail on Self-Managed | For non-gitlab.com hosts, add the instance hostname to `allowedOutboundDomains` in `plugin.json`. | -| Webhook signature verification failed | GitLab version is older than 19 or hook was manually created without a signing token. Relink the repository via the plugin UI. | +Thank you to [Gorlix](https://github.com/gorlix), who created this plugin ([`gorlix/paca-plugin-gitlab`](https://github.com/gorlix/paca-plugin-gitlab)) and whose work we forked. The GitLab integration, its API coverage and the original design are theirs; this version adapts it to Paca's Git provider SPI. --- ## Development -### Backend - ```bash cd backend - -# Run tests with race detector -go test -race -v ./... - -# Build WASM binary (requires TinyGo 0.41.1+) -tinygo build -target=wasip1 -buildmode=c-shared -o gitlab.wasm . +go vet ./... && go test -race ./... +tinygo build -target=wasip1 -buildmode=c-shared -o backend.wasm . ``` -### Frontend - -```bash -cd frontend - -# Install dependencies -bun install - -# Typecheck & build -bun run typecheck -bun run build -``` - -### MCP - -```bash -cd mcp - -# Install dependencies -bun install - -# Typecheck & build -bun run typecheck -bun run build -``` +To install into a local development Paca instance, use Paca's development script: -To install directly into a local development Paca instance, use Paca's development script: ```bash ./scripts/install-local-plugin.sh /path/to/paca-plugin-gitlab \ --paca-dir /path/to/paca \ @@ -454,14 +131,16 @@ To install directly into a local development Paca instance, use Paca's developme --api-key "$API_KEY" ``` ---- - ## Release (CD) -The release workflow is located at [`.github/workflows/release.yml`](.github/workflows/release.yml). +The release workflow is [`.github/workflows/release.yml`](.github/workflows/release.yml). It runs on tag pushes matching `v*` (e.g. `v0.6.0`) and on manual `workflow_dispatch`: it builds the backend WASM, packages the migrations and `plugin.json`, generates `checksums.txt` and publishes the `.tar.gz` assets to a GitHub Release. + +`scripts/install-from-release.sh` installs a published release into a production Docker Compose stack (backend, migrations and manifest only): + +```bash +./scripts/install-from-release.sh --api-url https://paca.example.com --api-key "$PACA_API_KEY" --version v0.6.0 +``` -It triggers on: -- Tag pushes matching `v*` (e.g. `v0.5.2`). -- Manual `workflow_dispatch`. +## License -The workflow builds backend WASM, frontend, and MCP bundles, packages migrations, skills, and `plugin.json`, generates `checksums.txt`, and publishes all `.tar.gz` assets to a GitHub Release. +Apache License 2.0, see [LICENSE](LICENSE). diff --git a/backend/automation.go b/backend/automation.go deleted file mode 100644 index 7914927..0000000 --- a/backend/automation.go +++ /dev/null @@ -1,181 +0,0 @@ -package main - -import ( - "strings" - "context" - "encoding/json" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// This file implements the automation-graph Condition and Action node -// types this plugin contributes, registered in Init via ctx.Condition and -// 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, -// "gitlab" (the last dot-separated segment of the plugin ID "com.gorlix.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 GitLab -// repository/PR it's linked to; that's resolved through -// 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 = "gitlab.mr_state" - - // automationActionMergePR merges the linked pull request. - automationActionMergePR = "gitlab.merge_mr" - // automationActionCommentPR posts a comment on the linked pull request. - automationActionCommentPR = "gitlab.comment_mr" -) - -// registerAutomationNodes wires this plugin's Condition/Action handlers -// into ctx. Called once from Init. -func (p *gitlabPlugin) registerAutomationNodes(ctx *plugin.Context) { - ctx.Condition(automationConditionPRState, p.conditionPRState) - ctx.Action(automationActionMergePR, p.actionMergePR) - ctx.Action(automationActionCommentPR, p.actionCommentPR) -} - -// ─── shared: resolve the most recently linked PR for a task ────────────────── - -// pluginLinkedPR is what resolveLinkedPRForAutomation returns: enough to -// call the GitLab API plus the plugin's own repo_id/pr row id for logging. -type pluginLinkedPR struct { - Owner string - RepoName string - PRNumber int -} - -// resolveLinkedPRForAutomation finds the most recently linked PR for a -// 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 *gitlabPlugin) resolveLinkedPRForAutomation(projectID, taskID string) (*pluginLinkedPR, error) { - result, err := p.db.Query(` - SELECT r.owner, r.repo_name, pr.pr_number - 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 - `, taskID, projectID) - if err != nil { - return nil, err - } - if len(result.Rows) == 0 { - 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{ - Owner: sc.str("owner"), - RepoName: sc.str("repo_name"), - PRNumber: sc.intVal("pr_number"), - }, nil -} - -// ─── Condition: github.pr_state ─────────────────────────────────────────────── - -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("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("gitlab: pr_state condition: " + err.Error()) - return plugin.ConditionResult{Matched: false} - } - - ghc, err := p.clientForProject(req.ProjectID) - if err != nil { - p.log.Error("gitlab: pr_state condition: decrypt token: " + err.Error()) - return plugin.ConditionResult{Matched: false} - } - ghPR, err := ghc.getPullRequest(context.Background(), linked.Owner, linked.RepoName, linked.PRNumber) - if err != nil { - p.log.Error("gitlab: pr_state condition: fetch PR: " + err.Error()) - return plugin.ConditionResult{Matched: false} - } - - state := ghPR.NormalizedState() - return plugin.ConditionResult{Matched: state == cfg.ExpectedState} -} - -// ─── Action: github.merge_pr ────────────────────────────────────────────────── - -func (p *gitlabPlugin) actionMergePR(req *plugin.ActionRequest) plugin.ActionResult { - var cfg struct { - MergeMethod string `json:"merge_method"` // "merge" | "squash" | "rebase"; defaults to "merge" - } - if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" { - return plugin.ActionResult{Applied: false, Error: "invalid config"} - } - if cfg.MergeMethod == "" { - cfg.MergeMethod = "merge" - } - - linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: err.Error()} - } - - ghc, err := p.clientForProject(req.ProjectID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: err.Error()} - } - ctx := context.Background() - - // Idempotency: a plugin action can be retried by the automation - // engine, so check current state first — merging an already-merged PR - // is a no-op success, not an error, mirroring how built-in actions - // treat "already at the desired state". - ghPR, err := ghc.getPullRequest(ctx, linked.Owner, linked.RepoName, linked.PRNumber) - if err != nil { - return plugin.ActionResult{Applied: false, Error: "fetch PR: " + err.Error()} - } - if strings.EqualFold(ghPR.State, "merged") || ghPR.MergedAt != nil { - return plugin.ActionResult{Applied: false} - } - - if err := ghc.mergePullRequest(ctx, linked.Owner, linked.RepoName, linked.PRNumber, cfg.MergeMethod); err != nil { - return plugin.ActionResult{Applied: false, Error: "merge PR: " + err.Error()} - } - return plugin.ActionResult{Applied: true} -} - -// ─── Action: github.comment_pr ──────────────────────────────────────────────── - -func (p *gitlabPlugin) actionCommentPR(req *plugin.ActionRequest) plugin.ActionResult { - var cfg struct { - Body string `json:"body"` - } - if err := json.Unmarshal(req.Config, &cfg); err != nil || req.ProjectID == "" || cfg.Body == "" { - return plugin.ActionResult{Applied: false, Error: "invalid config: body is required"} - } - - linked, err := p.resolveLinkedPRForAutomation(req.ProjectID, req.Task.ID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: err.Error()} - } - - ghc, err := p.clientForProject(req.ProjectID) - if err != nil { - return plugin.ActionResult{Applied: false, Error: err.Error()} - } - - 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()} - } - return plugin.ActionResult{Applied: true} -} diff --git a/backend/automation_test.go b/backend/automation_test.go deleted file mode 100644 index e7ed644..0000000 --- a/backend/automation_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package main - -import ( - "testing" - - plugin "github.com/Paca-AI/plugin-sdk-go" - "github.com/Paca-AI/plugin-sdk-go/plugintest" -) - -// 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 -// top-level request field, not folded into Config — mirroring how -// pluginNodePayload builds a real automation run's request in the core. - -func conditionReqWithConfig(cfg any) plugintest.ConditionRequest { - return plugintest.ConditionRequest{ - Task: plugin.TaskSnapshot{ID: testTaskID}, - ProjectID: testProjectID, - }.WithJSONConfig(cfg) -} - -func actionReqWithConfig(cfg any) plugintest.ActionRequest { - return plugintest.ActionRequest{ - Task: plugin.TaskSnapshot{ID: testTaskID}, - ProjectID: testProjectID, - }.WithJSONConfig(cfg) -} - -func TestConditionPRState_MissingConfig(t *testing.T) { - tc := setupPlugin(t) - result := tc.EvaluateCondition(automationConditionPRState, conditionReqWithConfig(map[string]string{})) - if result.Matched { - t.Fatal("expected Matched=false for missing config") - } -} - -func TestConditionPRState_MissingProjectID(t *testing.T) { - tc := setupPlugin(t) - req := plugintest.ConditionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}. - WithJSONConfig(map[string]string{"expected_state": "merged"}) - result := tc.EvaluateCondition(automationConditionPRState, req) - if result.Matched { - t.Fatal("expected Matched=false when the host supplies no project_id") - } -} - -func TestConditionPRState_NoLinkedPR(t *testing.T) { - tc := setupPlugin(t) - cfg := map[string]string{"expected_state": "merged"} - result := tc.EvaluateCondition(automationConditionPRState, conditionReqWithConfig(cfg)) - if result.Matched { - t.Fatal("expected Matched=false when no PR is linked to the task") - } -} - -func TestActionMergePR_MissingProjectID(t *testing.T) { - tc := setupPlugin(t) - req := plugintest.ActionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}. - WithJSONConfig(map[string]string{}) - result := tc.RunAction(automationActionMergePR, req) - if result.Applied { - t.Fatal("expected Applied=false when the host supplies no project_id") - } - if result.Error == "" { - t.Fatal("expected an error message for missing project_id") - } -} - -func TestActionMergePR_NoLinkedPR(t *testing.T) { - tc := setupPlugin(t) - result := tc.RunAction(automationActionMergePR, actionReqWithConfig(map[string]string{})) - if result.Applied { - t.Fatal("expected Applied=false when no PR is linked to the task") - } - if result.Error == "" { - t.Fatal("expected an error message when no PR is linked") - } -} - -func TestActionCommentPR_MissingBody(t *testing.T) { - tc := setupPlugin(t) - result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(map[string]string{})) - if result.Applied { - t.Fatal("expected Applied=false for missing body") - } - if result.Error == "" { - t.Fatal("expected an error message for missing body") - } -} - -func TestActionCommentPR_NoLinkedPR(t *testing.T) { - tc := setupPlugin(t) - cfg := map[string]string{"body": "looks good"} - result := tc.RunAction(automationActionCommentPR, actionReqWithConfig(cfg)) - if result.Applied { - t.Fatal("expected Applied=false when no PR is linked to the task") - } - if result.Error == "" { - t.Fatal("expected an error message when no PR is linked") - } -} diff --git a/backend/branches.go b/backend/branches.go deleted file mode 100644 index 7314b75..0000000 --- a/backend/branches.go +++ /dev/null @@ -1,253 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// ─── DTOs ──────────────────────────────────────────────────────────────────── - -type taskBranchResponse struct { - ID string `json:"id"` - TaskID string `json:"task_id"` - RepoID string `json:"repo_id"` - BranchName string `json:"branch_name"` - CreatedAt string `json:"created_at"` -} - -type createBranchResponse struct { - BranchName string `json:"branch_name"` -} - -// ─── POST /tasks/:taskId/github/branches ────────────────────────────────────── - -func (p *gitlabPlugin) createBranch(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type createBranchBody struct { - RepoID string `json:"repo_id"` - BranchName string `json:"branch_name"` - SourceBranch string `json:"source_branch"` - } - b, err := plugin.JSONBody[createBranchBody](req) - if err != nil || b.RepoID == "" || b.BranchName == "" { - apiError(res, 400, "BAD_REQUEST", "repo_id and branch_name are required") - return - } - - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - - // Get repository details. - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name, default_branch FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - 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") - defaultBranch := rSc.str("default_branch") - - sourceBranch := b.SourceBranch - if sourceBranch == "" { - sourceBranch = defaultBranch - } - - if err := ghc.createBranch(context.Background(), owner, repoName, b.BranchName, sourceBranch); err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) { - if apiErr.StatusCode == 403 { - apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create branches") - return - } - 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)) - return - } - - // Link the branch to the task. - now := nowStr() - rowsAffected, dbErr := p.db.Exec(` - 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, "GITLAB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") - return - } - - plugin.EmitEvent("gitlab.branch_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "branch_name": b.BranchName, - }) - - created(res, createBranchResponse{BranchName: b.BranchName}) -} - -// ─── POST /tasks/:taskId/branches/link ──────────────────────────────────────── - -func (p *gitlabPlugin) linkBranchToTask(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type linkBranchToTaskBody struct { - RepoID string `json:"repo_id"` - BranchName string `json:"branch_name"` - } - b, err := plugin.JSONBody[linkBranchToTaskBody](req) - if err != nil || b.RepoID == "" || b.BranchName == "" { - apiError(res, 400, "BAD_REQUEST", "repo_id and branch_name are required") - return - } - - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - 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") - - if err := ghc.branchExists(context.Background(), owner, repoName, b.BranchName); err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) { - if apiErr.StatusCode == 404 { - 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, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read branches") - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to verify branch: %s", err)) - return - } - - now := nowStr() - inserted, dbErr := p.db.Query(` - 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 - `, taskID, b.RepoID, b.BranchName, now) - if dbErr != nil { - apiError(res, 500, "INTERNAL_ERROR", dbErr.Error()) - return - } - if len(inserted.Rows) == 0 { - apiError(res, 409, "GITLAB_BRANCH_ALREADY_LINKED", "Branch is already linked to this task") - return - } - - plugin.EmitEvent("gitlab.branch_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "branch_name": b.BranchName, - }) - - sc := newRowScanner(inserted.Columns, inserted.Rows[0]) - created(res, taskBranchResponse{ - ID: sc.str("id"), - TaskID: sc.str("task_id"), - RepoID: sc.str("repo_id"), - BranchName: sc.str("branch_name"), - CreatedAt: sc.str("created_at"), - }) -} - -// ─── GET /tasks/:taskId/github/branches ─────────────────────────────────────── - -func (p *gitlabPlugin) listTaskBranches(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - result, err := p.db.Query( - `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 { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - - // 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 - // might have already linked across projects. No SQL JOIN here (kept - // consistent with resolvePRForTask's style elsewhere in this plugin, - // which also resolves through separate single-table queries). - items := make([]taskBranchResponse, 0, len(result.Rows)) - for _, row := range result.Rows { - sc := newRowScanner(result.Columns, row) - repoID := sc.str("repo_id") - repoResult, rErr := p.db.Query( - `SELECT id FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, - repoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - continue - } - items = append(items, taskBranchResponse{ - ID: sc.str("id"), - TaskID: sc.str("task_id"), - RepoID: repoID, - BranchName: sc.str("branch_name"), - CreatedAt: sc.str("created_at"), - }) - } - ok(res, items) -} diff --git a/backend/client.go b/backend/client.go deleted file mode 100644 index 931c63a..0000000 --- a/backend/client.go +++ /dev/null @@ -1,444 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "net/url" - "strings" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -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"` -} - -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 *glAPIError) Error() string { - if e.Details != "" { - return fmt.Sprintf("gitlab: API error %d: %s (%s)", e.StatusCode, e.Message, e.Details) - } - 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 -} - -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 newGLClient(token, instanceURL string) *glClient { - host := normalizeInstanceURL(instanceURL) - return &glClient{ - token: token, - hostURL: host, - baseURL: host + "/api/v4", - } -} - -func (c *glClient) headers() map[string]string { - return map[string]string{ - "PRIVATE-TOKEN": c.token, - "Accept": "application/json", - } -} - -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("gitlabclient: execute request: %w", err) - } - if resp.Status >= 400 { - return glParseAPIError(resp.Status, resp.Body) - } - if out != nil { - if err := json.Unmarshal([]byte(resp.Body), out); err != nil { - return fmt.Errorf("gitlabclient: decode response: %w", err) - } - } - return nil -} - -func (c *glClient) post(_ 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("POST", 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) 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("gitlabclient: execute request: %w", err) - } - if resp.Status == 404 || resp.Status == 204 { - return nil - } - if resp.Status >= 400 { - return glParseAPIError(resp.Status, resp.Body) - } - return nil -} - -func glParseAPIError(statusCode int, body string) error { - var errBody struct { - Message any `json:"message"` - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - _ = json.Unmarshal([]byte(body), &errBody) - 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 - } - } - } - if msg == "" { - msg = fmt.Sprintf("HTTP %d", statusCode) - } - return &glAPIError{StatusCode: statusCode, Message: msg} -} - -// ─── API methods ───────────────────────────────────────────────────────────── - -func (c *glClient) validateToken(ctx context.Context) error { - var user struct { - ID int64 `json:"id"` - Username string `json:"username"` - } - return c.get(ctx, c.baseURL+"/user", &user) -} - -func (c *glClient) listRepositories(ctx context.Context) ([]glProject, error) { - seen := make(map[int64]struct{}) - var all []glProject - for page := 1; ; page++ { - 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 _, p := range batch { - if _, ok := seen[p.ID]; ok { - continue - } - seen[p.ID] = struct{}{} - all = append(all, p) - } - if len(batch) < 100 { - break - } - } - return all, 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 &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 *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{ - "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, u, body, &resp); err != nil { - return 0, err - } - return resp.ID, nil -} - -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 *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 &mr, nil -} - -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["description"] = body - } - var mr glMergeRequest - if err := c.post(ctx, u, reqBody, &mr); err != nil { - return nil, err - } - return &mr, 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) -} - -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 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 b.String(), nil -} - -// 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) - } -} - -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) -} - -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 pipelines, 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) -} - -func (c *glClient) cloneURL(pathWithNamespace string) string { - return c.hostURL + "/" + strings.Trim(pathWithNamespace, "/") + ".git" -} diff --git a/backend/client_url_test.go b/backend/client_url_test.go deleted file mode 100644 index 802bd01..0000000 --- a/backend/client_url_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "net/url" - "testing" -) - -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("projectPath = %q, want %q", got, want) - } -} - -func TestNormalizeInstanceURL(t *testing.T) { - cases := []struct { - in, want string - }{ - {"", 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) - } - } -} - -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/gitlab.go b/backend/gitlab.go new file mode 100644 index 0000000..89d3519 --- /dev/null +++ b/backend/gitlab.go @@ -0,0 +1,85 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "regexp" + "strings" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +const ( + gitlabPluginID = "com.paca.gitlab" + defaultHost = "gitlab.com" + apiPrefix = "/api/v4" + + // credUsername is the user name git is given next to a token: GitLab accepts + // any non-empty name with an access token as the password. + credUsername = "oauth2" + + // setupSessionLife is how long a user has to submit the token form. + setupSessionLife = time.Hour +) + +// fetch is the outbound HTTP call; a variable so tests can stub GitLab. +var fetch = plugin.Fetch + +// hostRe accepts a bare host name with an optional port: no scheme, no path. +var hostRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$`) + +// accessTokenBotRe matches the user names GitLab gives project and group +// access tokens, e.g. project_123_bot_ab12cd34 or group_9_bot. +var accessTokenBotRe = regexp.MustCompile(`^(?:project|group)_[0-9]+_bot(?:_[0-9a-f]+)?$`) + +// nowStr returns the current UTC time as an RFC3339Nano string. +func nowStr() string { return time.Now().UTC().Format(time.RFC3339Nano) } + +func randomHex(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// normalizeHost lower-cases and trims a host; ok is false for anything that is +// not a plain host name (a URL, a path, spaces). +func normalizeHost(h string) (string, bool) { + h = strings.ToLower(strings.TrimSpace(h)) + return h, hostRe.MatchString(h) +} + +// baseURL is the REST API root of a GitLab host. +func baseURL(host string) string { return "https://" + host + apiPrefix } + +func secretEqual(a, b string) bool { + return a != "" && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +// verifyStandardWebhook checks a Standard Webhooks signature, which GitLab sends +// when a hook has a signing token (whsec_ + base64 key). +func verifyStandardWebhook(payload []byte, signingToken, msgID, timestamp, signatureHeader string) bool { + if signingToken == "" || msgID == "" || timestamp == "" || signatureHeader == "" { + return false + } + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(signingToken, "whsec_")) + if err != nil || len(key) == 0 { + return false + } + mac := hmac.New(sha256.New, key) + mac.Write([]byte(msgID + "." + timestamp + ".")) + mac.Write(payload) + want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + for _, part := range strings.Fields(signatureHeader) { + if hmac.Equal([]byte(part), []byte(want)) { + return true + } + } + return false +} diff --git a/backend/go.mod b/backend/go.mod index fce816c..4e31206 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/gitlab go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.3.3 +require github.com/Paca-AI/plugin-sdk-go v0.4.0 diff --git a/backend/go.sum b/backend/go.sum index 970bd41..78f3884 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.3.3 h1:nW2DwJatraYfxo4MpwNkfQEp+9jmj7QHaU16bOXzBpM= -github.com/Paca-AI/plugin-sdk-go v0.3.3/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/Paca-AI/plugin-sdk-go v0.4.0 h1:u3xDC+VGfHrgjI2e+XCgVMdnnKo5cedX36KPg6X0+/4= +github.com/Paca-AI/plugin-sdk-go v0.4.0/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= diff --git a/backend/integration.go b/backend/integration.go deleted file mode 100644 index 7932977..0000000 --- a/backend/integration.go +++ /dev/null @@ -1,546 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "strings" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// ─── DTOs ──────────────────────────────────────────────────────────────────── - -type integrationResponse struct { - 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 GitLab API. -type accessibleRepoResponse struct { - FullName string `json:"full_name"` - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - DefaultBranch string `json:"default_branch"` - Private bool `json:"private"` -} - -// repositoryResponse is the canonical DTO for a project-linked repository. -// Returned by GET /repositories and POST /repositories. -type repositoryResponse struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - FullName string `json:"full_name"` - DefaultBranch string `json:"default_branch"` - CloneURL string `json:"clone_url"` - WebhookActive bool `json:"webhook_active"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// repoCloneInfo is returned by GET /repositories/:repoId/clone-info. -// It includes a short-lived token for cloning. -type repoCloneInfo struct { - ID string `json:"id"` - FullName string `json:"full_name"` - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - CloneURL string `json:"clone_url"` - Token string `json:"token"` - ExpiresAt float64 `json:"expires_at"` -} - -const gitlabPluginID = "com.gorlix.gitlab" - -func webhookURLFromPublicURL(cfg *plugin.Config, projectID string) (string, error) { - publicURL, ok := cfg.Get("PUBLIC_URL") - if !ok || strings.TrimSpace(publicURL) == "" { - 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, gitlabPluginID, projectID), nil -} - -// ─── Helper: decrypt credentials for a project ─────────────────────────────── - -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, instance_url, token_kind FROM gitlab_integrations WHERE project_id = $1`, - projectID, - ) - if err != nil { - return nil, err - } - if len(result.Rows) == 0 { - 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 - } - 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) 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 *gitlabPlugin) getIntegration(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - result, err := p.db.Query( - `SELECT project_id, instance_url, token_kind, created_at, updated_at FROM gitlab_integrations WHERE project_id = $1`, - projectID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(result.Rows) == 0 { - ok(res, integrationResponse{ProjectID: projectID, Connected: false}) - 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, - InstanceURL: sc.str("instance_url"), - TokenKind: sc.str("token_kind"), - CreatedAt: &ca, - UpdatedAt: &ua, - }) -} - -// ─── POST /integration/token ───────────────────────────────────────────────── - -func (p *gitlabPlugin) setToken(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - type setTokenBody struct { - 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 == "" { - apiError(res, 400, "BAD_REQUEST", "token is required") - return - } - - 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 *glAPIError - if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { - 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)) - return - } - - enc, err := p.encrypt(b.Token) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to encrypt token") - return - } - - now := nowStr() - _, err = p.db.Exec(` - 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 - } - - result, qErr := p.db.Query( - `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, 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, - InstanceURL: sc.str("instance_url"), - TokenKind: sc.str("token_kind"), - CreatedAt: &ca, - UpdatedAt: &ua, - }) -} - -// ─── DELETE /integration/token ─────────────────────────────────────────────── - -func (p *gitlabPlugin) deleteToken(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - // Best-effort: delete webhooks for all linked repositories first. - if ghc, err := p.clientForProject(projectID); err == nil { - rows, qErr := p.db.Query( - `SELECT owner, repo_name, webhook_id FROM gitlab_repositories WHERE project_id = $1`, - projectID, - ) - if qErr == nil { - for _, row := range rows.Rows { - sc := newRowScanner(rows.Columns, row) - whID := sc.int64Val("webhook_id") - if whID > 0 { - _ = ghc.deleteWebhook(context.Background(), sc.str("owner"), sc.str("repo_name"), whID) - } - } - } - } - - _, execErr := p.db.Exec(`DELETE FROM gitlab_integrations WHERE project_id = $1`, projectID) - if execErr != nil { - apiError(res, 500, "INTERNAL_ERROR", execErr.Error()) - return - } - noContent(res) -} - -// ─── GET /integration/accessible-repos ────────────────────────────────────── - -func (p *gitlabPlugin) listAccessibleRepos(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - - repos, err := ghc.listRepositories(context.Background()) - if err != nil { - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to list repositories: %s", err)) - return - } - - items := make([]accessibleRepoResponse, len(repos)) - for i, r := range repos { - items[i] = accessibleRepoResponse{ - FullName: r.PathWithNamespace, - Owner: r.Owner(), - RepoName: r.Path, - DefaultBranch: r.DefaultBranch, - Private: r.Private(), - } - } - ok(res, items) -} - -// ─── GET /repositories ─────────────────────────────────────────────────────── - -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 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) - fullName := sc.str("full_name") - items = append(items, repositoryResponse{ - ID: sc.str("id"), - ProjectID: sc.str("project_id"), - Owner: sc.str("owner"), - RepoName: sc.str("repo_name"), - FullName: fullName, - DefaultBranch: sc.str("default_branch"), - CloneURL: client.cloneURL(fullName), - WebhookActive: sc.int64Val("webhook_id") > 0, - CreatedAt: sc.str("created_at"), - UpdatedAt: sc.str("updated_at"), - }) - } - ok(res, items) -} - -// ─── POST /repositories ─────────────────────────────────────────────────────── - -func (p *gitlabPlugin) linkRepository(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - - type linkRepositoryBody struct { - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - } - b, err := plugin.JSONBody[linkRepositoryBody](req) - if err != nil || b.Owner == "" || b.RepoName == "" { - apiError(res, 400, "BAD_REQUEST", "owner and repo_name are required") - return - } - - webhookURL, err := webhookURLFromPublicURL(p.cfg, projectID) - if err != nil { - apiError(res, 422, "GITLAB_WEBHOOK_URL_REQUIRED", "PUBLIC_URL is not configured") - return - } - - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - ghRepo, err := ghc.getRepository(context.Background(), b.Owner, b.RepoName) - if err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) && (apiErr.StatusCode == 403 || apiErr.StatusCode == 404) { - 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 project: %s", err)) - return - } - - existResult, _ := p.db.Query( - `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, "GITLAB_REPO_ALREADY_LINKED", "Repository is already linked to this project") - return - } - - 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, "GITLAB_INTEGRATION_NOT_FOUND", "GitLab integration not found") - return - } - integrationID := newRowScanner(integResult.Columns, integResult.Rows[0]).str("id") - - signingToken, randErr := generateSigningToken() - if randErr != nil { - apiError(res, 500, "INTERNAL_ERROR", "failed to generate webhook signing token") - return - } - - webhookID, err := ghc.createWebhook(context.Background(), ghRepo.Owner(), ghRepo.Path, webhookURL, signingToken) - if err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) && isWebhookURLNotPublic(apiErr) { - apiError(res, 422, "GITLAB_WEBHOOK_URL_NOT_PUBLIC", "Webhook URL is not publicly accessible") - return - } - apiError(res, 502, "GITLAB_WEBHOOK_CREATION_FAILED", fmt.Sprintf("failed to create webhook: %s", err)) - return - } - - encSecret, err := p.encrypt(signingToken) - if err != nil { - _ = 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 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, owner, ghRepo.Path, ghRepo.PathWithNamespace, ghRepo.ID, webhookID, encSecret, ghRepo.DefaultBranch, now) - if dbErr != nil || len(inserted.Rows) == 0 { - _ = ghc.deleteWebhook(context.Background(), owner, ghRepo.Path, webhookID) - if dbErr != nil { - apiError(res, 500, "INTERNAL_ERROR", dbErr.Error()) - } else { - apiError(res, 500, "INTERNAL_ERROR", "insert returned no rows") - } - return - } - repoID := newRowScanner(inserted.Columns, inserted.Rows[0]).str("id") - - created(res, repositoryResponse{ - ID: repoID, - ProjectID: projectID, - Owner: owner, - RepoName: ghRepo.Path, - FullName: ghRepo.PathWithNamespace, - DefaultBranch: ghRepo.DefaultBranch, - CloneURL: ghc.cloneURL(ghRepo.PathWithNamespace), - WebhookActive: webhookID > 0, - CreatedAt: now, - UpdatedAt: now, - }) -} - -// ─── DELETE /repositories/:repoId ──────────────────────────────────────────── - -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 gitlab_repositories WHERE id = $1 AND project_id = $2`, - repoID, projectID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(result.Rows) == 0 { - apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "Repository not found") - return - } - sc := newRowScanner(result.Columns, result.Rows[0]) - owner := sc.str("owner") - repoName := sc.str("repo_name") - webhookID := sc.int64Val("webhook_id") - - // Best-effort: delete the webhook. - if webhookID > 0 { - if ghc, tErr := p.clientForProject(projectID); tErr == nil { - _ = ghc.deleteWebhook(context.Background(), owner, repoName, webhookID) - } - } - - _, err = p.db.Exec(`DELETE FROM gitlab_repositories WHERE id = $1`, repoID) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - noContent(res) -} - -// ─── GET /repositories/:repoId/clone-info ──────────────────────────────────── - -func (p *gitlabPlugin) getRepoCloneInfo(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - repoID := req.PathParam("repoId") - if repoID == "" { - apiError(res, 400, "BAD_REQUEST", "repoId path parameter is required") - return - } - result, err := p.db.Query( - `SELECT id, full_name, owner, repo_name FROM gitlab_repositories WHERE project_id = $1 AND id = $2`, - projectID, repoID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(result.Rows) == 0 { - apiError(res, 404, "GITLAB_REPOSITORY_NOT_FOUND", "repository not found") - return - } - creds, err := p.loadCreds(projectID) - if err != nil { - writeAppError(res, err) - return - } - sc := newRowScanner(result.Columns, result.Rows[0]) - fullName := sc.str("full_name") - ok(res, repoCloneInfo{ - ID: sc.str("id"), - FullName: fullName, - Owner: sc.str("owner"), - RepoName: sc.str("repo_name"), - CloneURL: newGLClient("", creds.InstanceURL).cloneURL(fullName), - Token: creds.Token, - ExpiresAt: 0, - }) -} - -// ─── GET /github/repo-info (REMOVED) ───────────────────────────────────────── -// Replaced by GET /repositories (list all) and GET /repositories/:repoId/clone-info - -// ─── Error helpers ──────────────────────────────────────────────────────────── - -type appError struct { - code string - status int - msg string -} - -func (e *appError) Error() string { return e.msg } - -func writeAppError(res *plugin.Response, err error) { - var ae *appError - if errors.As(err, &ae) { - apiError(res, ae.status, ae.code, ae.msg) - return - } - apiError(res, 500, "INTERNAL_ERROR", err.Error()) -} - -func isWebhookURLNotPublic(apiErr *glAPIError) bool { - if apiErr == nil || apiErr.StatusCode != 422 { - return false - } - msg := strings.ToLower(apiErr.Message + " " + apiErr.Details) - return strings.Contains(msg, "isn't reachable over the public internet") || - strings.Contains(msg, "not publicly reachable") || - strings.Contains(msg, "localhost") -} diff --git a/backend/migrations/0001_create_gitlab_tables.sql b/backend/migrations/0001_create_gitlab_tables.sql index 44a4935..f34c342 100644 --- a/backend/migrations/0001_create_gitlab_tables.sql +++ b/backend/migrations/0001_create_gitlab_tables.sql @@ -1,6 +1,6 @@ -- 0001_create_gitlab_tables.sql -- Creates the GitLab integration tables in the plugin schema. --- Run with search_path = plugin_data_com_gorlix_gitlab, public. +-- 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(), diff --git a/backend/migrations/0002_git_provider.sql b/backend/migrations/0002_git_provider.sql new file mode 100644 index 0000000..46a48c5 --- /dev/null +++ b/backend/migrations/0002_git_provider.sql @@ -0,0 +1,46 @@ +-- 0002_git_provider.sql +-- The GitLab provider's own state: the access-token identities Paca connects +-- with (tokens and webhook secrets encrypted) and the setup sessions that tie a +-- token-form round trip together. Drops the previous release's project-bound +-- tables (see the end). +-- Run with search_path = plugin_data_com_paca_gitlab, public. + +CREATE TABLE IF NOT EXISTS gitlab_identities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host TEXT NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + username TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + bot BOOLEAN NOT NULL DEFAULT FALSE, + token_enc TEXT NOT NULL, + read_token_enc TEXT NOT NULL DEFAULT '', + webhook_secret_enc TEXT NOT NULL DEFAULT '', + -- The earliest expiry of the stored tokens; NULL when GitLab reports none. + expires_at TIMESTAMPTZ, + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- One row per identity-setup attempt. state is the single-use nonce core echoes +-- back when the token form is submitted. +CREATE TABLE IF NOT EXISTS gitlab_setup_sessions ( + state TEXT PRIMARY KEY, + -- Whom the identity is for: the shared one, or one agent's own. + scope TEXT NOT NULL DEFAULT 'shared', + agent_id TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL, + completed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- The plugin is a Git provider: repositories, merge requests, task links and +-- the per-project connection now live in Paca core (git_* tables). The +-- project-bound tables of the previous release are dropped; a project-scoped +-- token cannot back a provider identity, so no data is migrated. +DROP TABLE IF EXISTS gitlab_task_mr_links; +DROP TABLE IF EXISTS gitlab_task_branches; +DROP TABLE IF EXISTS gitlab_merge_requests; +DROP TABLE IF EXISTS gitlab_repositories; +DROP TABLE IF EXISTS gitlab_integrations; diff --git a/backend/plugin.go b/backend/plugin.go index 3a57f67..0c2b1d4 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -1,22 +1,15 @@ -// Package main implements the com.gorlix.gitlab backend WASM plugin. -// -// 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 implements the com.paca.gitlab backend WASM plugin: the GitLab +// provider of Paca's Git provider SPI (spi_*.go). Core drives it through the +// internal-only POST /_git/:op routes; the plugin has no routes of its own, no +// per-project data and no frontend. Connections are GitLab access tokens +// (project, group or a bot user's personal token) kept encrypted here. package main import ( - "encoding/json" - "time" - plugin "github.com/Paca-AI/plugin-sdk-go" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/serve" ) -// nowStr returns the current UTC time as an RFC3339Nano string. -func nowStr() string { - return time.Now().UTC().Format(time.RFC3339Nano) -} - // gitlabPlugin implements plugin.Plugin. type gitlabPlugin struct { db *plugin.DB @@ -24,126 +17,14 @@ type gitlabPlugin struct { cfg *plugin.Config } -// Init registers all routes and event handlers on the provided context. +// Init mounts the provider operations. func (p *gitlabPlugin) Init(ctx *plugin.Context) error { p.db = ctx.DB() p.log = ctx.Log() p.cfg = ctx.Config() - - // Event handlers - ctx.On("task.deleted", p.handleTaskDeleted) - ctx.On("project.deleted", p.handleProjectDeleted) - - // ── Integration (GitLab token / connection) ─────────────────────────────── - ctx.Route("GET", "/integration", p.getIntegration) - ctx.Route("POST", "/integration/token", p.setToken) - ctx.Route("DELETE", "/integration/token", p.deleteToken) - ctx.Route("GET", "/integration/accessible-repos", p.listAccessibleRepos) - - // ── Repositories (repos linked to the project) ──────────────────────────── - ctx.Route("GET", "/repositories", p.listRepositories) - ctx.Route("POST", "/repositories", p.linkRepository) - ctx.Route("DELETE", "/repositories/:repoId", p.unlinkRepository) - ctx.Route("GET", "/repositories/:repoId/clone-info", p.getRepoCloneInfo) - - // ── Task resources ──────────────────────────────────────────────────────── - ctx.Route("GET", "/tasks/:taskId/pull-requests", p.listTaskPRs) - ctx.Route("POST", "/tasks/:taskId/pull-requests", p.createPullRequest) - ctx.Route("POST", "/tasks/:taskId/pull-requests/link", p.linkPRToTask) - ctx.Route("DELETE", "/tasks/:taskId/pull-requests/:prId", p.unlinkPRFromTask) - ctx.Route("GET", "/tasks/:taskId/pull-requests/:prId", p.getPullRequestDetails) - ctx.Route("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", p.getPullRequestCIStatus) - ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/comments", p.addPullRequestComment) - ctx.Route("POST", "/tasks/:taskId/pull-requests/:prId/reviews", p.createReview) - ctx.Route("GET", "/tasks/:taskId/branches", p.listTaskBranches) - ctx.Route("POST", "/tasks/:taskId/branches", p.createBranch) - ctx.Route("POST", "/tasks/:taskId/branches/link", p.linkBranchToTask) - - // ── Webhook ─────────────────────────────────────────────────────────────── - ctx.Route("POST", "/webhook", p.receiveWebhook) - - // ── Automation graph nodes (Condition/Action) ───────────────────────────── - p.registerAutomationNodes(ctx) - + serve.Register(ctx, newGLProvider(newDBStore(p))) return nil } // Shutdown is a no-op for this plugin. func (p *gitlabPlugin) Shutdown() {} - -// ─── envelope helpers ──────────────────────────────────────────────────────── - -type envelope struct { - Success bool `json:"success"` - Data any `json:"data"` -} - -func ok(res *plugin.Response, data any) { - res.JSON(200, envelope{Success: true, Data: data}) -} - -func created(res *plugin.Response, data any) { - res.JSON(201, envelope{Success: true, Data: data}) -} - -func noContent(res *plugin.Response) { - res.NoContent() -} - -func apiError(res *plugin.Response, code int, errCode, message string) { - res.JSON(code, map[string]any{ - "success": false, - "error": message, - "error_code": errCode, - }) -} - -// 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 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 *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, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return false - } - if len(result.Rows) == 0 { - apiError(res, 404, "TASK_NOT_FOUND", "Task not found") - return false - } - return true -} - -// ─── event handlers ────────────────────────────────────────────────────────── - -// handleTaskDeleted cleans up branches and PR links when a task is deleted. -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 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 *gitlabPlugin) handleProjectDeleted(evt *plugin.Event) { - var payload struct { - ProjectID string `json:"project_id"` - } - if err := json.Unmarshal(evt.Payload, &payload); err != nil || payload.ProjectID == "" { - return - } - // Cascade via FK, but also be explicit. - _, _ = 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 deleted file mode 100644 index 0fd66b2..0000000 --- a/backend/plugin_test.go +++ /dev/null @@ -1,604 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "strings" - "testing" - - plugin "github.com/Paca-AI/plugin-sdk-go" - "github.com/Paca-AI/plugin-sdk-go/plugintest" -) - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -const ( - testProjectID = "project-1" - testTaskID = "task-1" -) - -func setupPlugin(t *testing.T) *plugintest.Context { - t.Helper() - tc := plugintest.NewContext(t) - - // Seed the shared tables referenced by FK. - tc.DB.SeedRows("tasks", []string{"id", "project_id", "deleted_at"}, [][]any{ - {testTaskID, testProjectID, nil}, - }) - - // Seed empty plugin tables so queries return empty result sets instead of errors. - tc.DB.SeedRows("gitlab_integrations", - []string{"id", "project_id", "access_token_enc", "created_at", "updated_at"}, - nil) - 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("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("gitlab_task_mr_links", - []string{"id", "task_id", "merge_request_id", "created_at"}, - nil) - tc.DB.SeedRows("gitlab_task_branches", - []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, - nil) - - var p gitlabPlugin - if err := p.Init(tc.PluginContext()); err != nil { - t.Fatal("Init failed:", err) - } - return tc -} - -func callerReq() plugintest.Request { - return plugintest.Request{ - Caller: plugin.CallerIdentity{ - ProjectID: testProjectID, - CallerID: "member-1", - CallerRole: "PROJECT_MEMBER", - }, - PathParams: map[string]string{}, - } -} - -// ── Integration tests ───────────────────────────────────────────────────────── - -func TestGetIntegration_NotConnected(t *testing.T) { - tc := setupPlugin(t) - res := tc.Call("GET", "/integration", callerReq()) - - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Success bool `json:"success"` - Data integrationResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if !env.Success { - t.Fatal("expected success=true") - } - if env.Data.Connected { - t.Fatal("expected Connected=false when no token is set") - } - if env.Data.ProjectID != testProjectID { - t.Fatalf("expected project_id=%s, got %s", testProjectID, env.Data.ProjectID) - } -} - -// ── PR review/comment tests ──────────────────────────────────────────────────── -// -// 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. - -func reqWithPathParams(params map[string]string) plugintest.Request { - r := callerReq() - for k, v := range params { - r.PathParams[k] = v - } - return r -} - -func TestGetPullRequestDetails_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}) - res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestAddPullRequestComment_MissingBody(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestAddPullRequestComment_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{"body": "looks good"}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/comments", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateReview_InvalidEvent(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{"event": "NOT_A_REAL_EVENT"}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateReview_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}). - WithJSONBody(map[string]string{"event": "APPROVE"}) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/:prId/reviews", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestGetPullRequestCIStatus_NotLinkedToTask(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID, "prId": "pr-1"}) - res := tc.Call("GET", "/tasks/:taskId/pull-requests/:prId/ci-status", req) - - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_MissingBody(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{}) - res := tc.Call("POST", "/tasks/:taskId/branches/link", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_MissingRepoID(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"branch_name": "dev"}) - res := tc.Call("POST", "/tasks/:taskId/branches/link", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_MissingBranchName(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"repo_id": "00000000-0000-0000-0000-000000000001"}) - res := tc.Call("POST", "/tasks/:taskId/branches/link", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateBranch_MissingBranchName(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"repo_id": "00000000-0000-0000-0000-000000000001"}) - res := tc.Call("POST", "/tasks/:taskId/branches", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreateBranch_MissingRepoID(t *testing.T) { - tc := setupPlugin(t) - req := reqWithPathParams(map[string]string{"taskId": testTaskID}). - WithJSONBody(map[string]string{"branch_name": "dev"}) - res := tc.Call("POST", "/tasks/:taskId/branches", req) - - if res.StatusCode != 400 { - t.Fatalf("expected 400, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -// ── Cross-project task ownership guards ────────────────────────────────────── -// -// Every handler below previously trusted the URL's :taskId without -// verifying it belongs to the caller's own project (req.Caller.ProjectID) — -// only the request body's repo_id/pr_number were project-checked. A caller -// 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 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). - -const otherTaskID = "task-2" // seeded under a project the default caller is not a member of - -func foreignTaskReq() plugintest.Request { - return plugintest.Request{ - Caller: plugin.CallerIdentity{ - ProjectID: testProjectID, // the default caller's own project - CallerID: "member-1", - CallerRole: "PROJECT_MEMBER", - }, - PathParams: map[string]string{"taskId": otherTaskID}, // but a foreign task - } -} - -// setupWithForeignTask re-seeds tasks with both the default task and a -// second task belonging to a different project. -func setupWithForeignTask(t *testing.T) *plugintest.Context { - t.Helper() - tc := setupPlugin(t) - tc.DB.SeedRows("tasks", []string{"id", "project_id", "deleted_at"}, [][]any{ - {testTaskID, testProjectID, nil}, - {otherTaskID, "other-project", nil}, - }) - return tc -} - -func TestListTaskPRs_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("GET", "/tasks/:taskId/pull-requests", foreignTaskReq()) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestListTaskBranches_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("GET", "/tasks/:taskId/branches", foreignTaskReq()) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -// TestListTaskBranches_ReturnsOwnProjectBranches is the happy-path -// counterpart to the cross-project test above — it exercises the handler's -// main query past taskBelongsToProject, which a rejection test alone -// wouldn't reach. -func TestListTaskBranches_ReturnsOwnProjectBranches(t *testing.T) { - tc := setupPlugin(t) - 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("gitlab_task_branches", - []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, - [][]any{{"branch-1", testTaskID, "repo-1", "feature/x", "now"}}) - - res := tc.Call("GET", "/tasks/:taskId/branches", reqWithPathParams(map[string]string{"taskId": testTaskID})) - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Data []taskBranchResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if len(env.Data) != 1 || env.Data[0].BranchName != "feature/x" { - t.Fatalf("expected the seeded branch to be returned, got %+v", env.Data) - } -} - -// TestListTaskBranches_SkipsRepoFromAnotherProject mirrors -// 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("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("gitlab_task_branches", - []string{"id", "task_id", "repo_id", "branch_name", "created_at"}, - [][]any{{"branch-1", testTaskID, "repo-foreign", "feature/x", "now"}}) - - res := tc.Call("GET", "/tasks/:taskId/branches", reqWithPathParams(map[string]string{"taskId": testTaskID})) - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Data []taskBranchResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if len(env.Data) != 0 { - t.Fatalf("expected the cross-project repo's branch to be filtered out, got %+v", env.Data) - } -} - -func TestCreateBranch_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/branches", - foreignTaskReq().WithJSONBody(map[string]string{ - "repo_id": "00000000-0000-0000-0000-000000000001", "branch_name": "dev", - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkBranchToTask_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/branches/link", - foreignTaskReq().WithJSONBody(map[string]string{ - "repo_id": "00000000-0000-0000-0000-000000000001", "branch_name": "dev", - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestCreatePullRequest_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/pull-requests", - foreignTaskReq().WithJSONBody(map[string]string{ - "repo_id": "00000000-0000-0000-0000-000000000001", "title": "x", "head_branch": "a", "base_branch": "main", - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestLinkPRToTask_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - res := tc.Call("POST", "/tasks/:taskId/pull-requests/link", - foreignTaskReq().WithJSONBody(map[string]any{ - "repo_id": "00000000-0000-0000-0000-000000000001", "pr_number": 1, - })) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -func TestUnlinkPRFromTask_CrossProjectTaskRejected(t *testing.T) { - tc := setupWithForeignTask(t) - req := foreignTaskReq() - req.PathParams["prId"] = "pr-1" - res := tc.Call("DELETE", "/tasks/:taskId/pull-requests/:prId", req) - if res.StatusCode != 404 { - t.Fatalf("expected 404, got %d: %s", res.StatusCode, res.BodyString()) - } -} - -// TestListTaskPRs_SkipsLinkFromAnotherProject covers the defense-in-depth -// layer on top of taskBelongsToProject: even for the caller's own, -// 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("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("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})) - if res.StatusCode != 200 { - t.Fatalf("expected 200, got %d: %s", res.StatusCode, res.BodyString()) - } - var env struct { - Data []pullRequestResponse `json:"data"` - } - if err := json.Unmarshal(res.Body, &env); err != nil { - t.Fatal(err) - } - if len(env.Data) != 0 { - t.Fatalf("expected the cross-project-linked PR to be filtered out, got %+v", env.Data) - } -} - -// ── Webhook: project scoping + signature verification ──────────────────────── -// -// 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 -// assert on tc.Log (plugintest.CapturingLogger), using the distinct log -// line each outcome takes — an event that reaches handleWebhookEvent's -// `default:` case only gets there after signature verification succeeds, -// which is what makes "unhandled event type" a reliable positive signal. - -const testEncryptionKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 hex chars = 32 bytes - -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-Gitlab-Event": "Pipeline Hook", // unhandled type — proves we passed signature check - "webhook-id": msgID, - "webhook-timestamp": ts, - "webhook-signature": sig, - }, - Body: payload, - } -} - -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("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("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: 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 -// project's secret to verify a delivery meant for this one, which then -// fails signature verification and silently drops a legitimate delivery. -// This seeds the same full_name under two different projects with two -// different secrets, and confirms a delivery signed with project-1's own -// secret, addressed to project-1's URL, still verifies successfully even -// though project-2's row for the same repo full_name exists and was seeded -// first (so a naive full_name-only lookup would find it before project-1's). -func TestReceiveWebhook_ScopesRepositoryLookupToURLProject(t *testing.T) { - tc := setupPlugin(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(`{"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()) - } - if tc.Log.HasMessage("invalid webhook signature") { - t.Fatal("signature verified against the wrong project's secret — repository lookup is not scoped to the URL's project") - } - if !tc.Log.HasMessage("unhandled event type") { - t.Fatalf("expected signature verification to succeed and reach event dispatch; log entries: %+v", tc.Log.Entries()) - } -} - -// TestReceiveWebhook_RejectsMissingSecret pins the fix for a fail-open path: -// a repository row with no webhook secret configured previously skipped -// HMAC verification entirely and trusted the payload; it must now be -// refused instead, without ever reaching event dispatch. -func TestReceiveWebhook_RejectsMissingSecret(t *testing.T) { - tc := setupPlugin(t) - 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(`{"project":{"path_with_namespace":"octocat/hello-world"}}`) - req := plugintest.Request{ - PathParams: map[string]string{"projectId": testProjectID}, - 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 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") { - t.Fatal("delivery with no configured secret reached event dispatch — HMAC verification was skipped instead of failing closed") - } -} - -// ── overallCIState ───────────────────────────────────────────────────────────── - -func TestOverallCIState_NoChecks(t *testing.T) { - if got := overallCIState(nil); got != "unknown" { - t.Fatalf("expected unknown, got %s", got) - } -} - -func TestOverallCIState_AllSuccess(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "completed", Conclusion: "success"}, - {Status: "completed", Conclusion: "neutral"}, - } - if got := overallCIState(checks); got != "success" { - t.Fatalf("expected success, got %s", got) - } -} - -func TestOverallCIState_OneFailureWins(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "completed", Conclusion: "success"}, - {Status: "completed", Conclusion: "failure"}, - } - if got := overallCIState(checks); got != "failure" { - t.Fatalf("expected failure, got %s", got) - } -} - -func TestOverallCIState_StillRunningIsPending(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "completed", Conclusion: "success"}, - {Status: "in_progress", Conclusion: ""}, - } - if got := overallCIState(checks); got != "pending" { - t.Fatalf("expected pending, got %s", got) - } -} - -func TestOverallCIState_FailureBeatsPending(t *testing.T) { - checks := []ciCheckResponse{ - {Status: "in_progress", Conclusion: ""}, - {Status: "completed", Conclusion: "failure"}, - } - if got := overallCIState(checks); got != "failure" { - t.Fatalf("expected failure, got %s", got) - } -} diff --git a/backend/pull_requests.go b/backend/pull_requests.go deleted file mode 100644 index 7b614e5..0000000 --- a/backend/pull_requests.go +++ /dev/null @@ -1,683 +0,0 @@ -package main - -import ( - "strings" - "context" - "errors" - "fmt" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// ─── DTOs ──────────────────────────────────────────────────────────────────── - -type pullRequestResponse struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - RepoID string `json:"repo_id"` - PRNumber int `json:"pr_number"` - GitLabMRID int64 `json:"gitlab_mr_id"` - Title string `json:"title"` - State string `json:"state"` - HTMLURL string `json:"html_url"` - HeadBranch string `json:"head_branch"` - BaseBranch string `json:"base_branch"` - Author string `json:"author"` - MergedAt *string `json:"merged_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// ─── GET /tasks/:taskId/github/pull-requests ────────────────────────────────── - -func (p *gitlabPlugin) listTaskPRs(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - linkResult, err := p.db.Query( - `SELECT merge_request_id FROM gitlab_task_mr_links WHERE task_id = $1 ORDER BY created_at ASC`, - taskID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - - // No SQL JOIN here (resolved via a separate query per link instead) — - // consistent with resolvePRForTask's style elsewhere in this plugin. - // taskBelongsToProject above already closes the main vector (a foreign - // taskId); re-verifying each linked PR's own project_id here is - // defense-in-depth against any row a pre-fix caller might have already - // linked across projects. - items := make([]pullRequestResponse, 0, len(linkResult.Rows)) - for _, linkRow := range linkResult.Rows { - prID := newRowScanner(linkResult.Columns, linkRow).str("merge_request_id") - prResult, pErr := p.db.Query( - `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 { - apiError(res, 500, "INTERNAL_ERROR", pErr.Error()) - return - } - if len(prResult.Rows) == 0 { - continue - } - sc := newRowScanner(prResult.Columns, prResult.Rows[0]) - items = append(items, pullRequestResponse{ - ID: sc.str("id"), - ProjectID: sc.str("project_id"), - RepoID: sc.str("repo_id"), - PRNumber: sc.intVal("pr_number"), - GitLabMRID: sc.int64Val("gitlab_mr_id"), - Title: sc.str("title"), - State: sc.str("state"), - HTMLURL: sc.str("html_url"), - HeadBranch: sc.str("head_branch"), - BaseBranch: sc.str("base_branch"), - Author: sc.str("author"), - MergedAt: sc.strPtr("merged_at"), - CreatedAt: sc.str("created_at"), - UpdatedAt: sc.str("updated_at"), - }) - } - ok(res, items) -} - -// ─── POST /tasks/:taskId/github/pull-requests/link ─────────────────────────── - -func (p *gitlabPlugin) linkPRToTask(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type linkPRToTaskBody struct { - RepoID string `json:"repo_id"` - PRNumber int `json:"pr_number"` - } - b, err := plugin.JSONBody[linkPRToTaskBody](req) - if err != nil || b.RepoID == "" || b.PRNumber == 0 { - apiError(res, 400, "BAD_REQUEST", "repo_id and pr_number are required") - return - } - - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - - // Get repository details. - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - 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") - - ghPR, err := ghc.getPullRequest(context.Background(), owner, repoName, b.PRNumber) - if err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) { - switch apiErr.StatusCode { - case 404: - 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, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to read pull requests") - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request: %s", err)) - return - } - - state := ghPR.NormalizedState() - - now := nowStr() - - var mergedAtStr *string - if ghPR.MergedAt != nil { - s := ghPR.MergedAt.UTC().Format("2006-01-02T15:04:05.999999999Z") - mergedAtStr = &s - } - - // Upsert the PR cache; let PostgreSQL generate id on insert, RETURNING gives us id+created_at. - upserted, err := p.db.Query(` - 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.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()) - } else { - apiError(res, 500, "INTERNAL_ERROR", "upsert returned no rows") - } - return - } - prSc := newRowScanner(upserted.Columns, upserted.Rows[0]) - prID := prSc.str("id") - prCreatedAt := prSc.str("created_at") - - // Link the PR to the task. - rowsAffected, lErr := p.db.Exec(` - INSERT INTO gitlab_task_mr_links (task_id, merge_request_id, created_at) - VALUES ($1,$2,$3) - 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, "GITLAB_PR_ALREADY_LINKED", "Pull request is already linked to this task") - return - } - - plugin.EmitEvent("gitlab.mr_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "pr_number": b.PRNumber, - }) - - // Return the PR details. - ok(res, pullRequestResponse{ - ID: prID, - ProjectID: projectID, - RepoID: b.RepoID, - PRNumber: b.PRNumber, - GitLabMRID: ghPR.ID, - Title: ghPR.Title, - State: state, - HTMLURL: ghPR.WebURL, - HeadBranch: ghPR.SourceBranch, - BaseBranch: ghPR.TargetBranch, - Author: ghPR.Author.Username, - MergedAt: mergedAtStr, - CreatedAt: prCreatedAt, - UpdatedAt: now, - }) -} - -// ─── POST /tasks/:taskId/github/pull-requests ───────────────────────────────── - -func (p *gitlabPlugin) createPullRequest(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - type createPullRequestBody struct { - RepoID string `json:"repo_id"` - Title string `json:"title"` - HeadBranch string `json:"head_branch"` - BaseBranch string `json:"base_branch"` - Body string `json:"body"` - } - b, err := plugin.JSONBody[createPullRequestBody](req) - if err != nil || b.RepoID == "" || b.Title == "" || b.HeadBranch == "" || b.BaseBranch == "" { - apiError(res, 400, "BAD_REQUEST", "repo_id, title, head_branch, and base_branch are required") - return - } - - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - - repoResult, rErr := p.db.Query( - `SELECT owner, repo_name FROM gitlab_repositories WHERE id = $1 AND project_id = $2`, - b.RepoID, projectID, - ) - if rErr != nil { - apiError(res, 500, "INTERNAL_ERROR", rErr.Error()) - return - } - if len(repoResult.Rows) == 0 { - 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") - - ghPR, err := ghc.createPullRequest(context.Background(), owner, repoName, b.Title, b.HeadBranch, b.BaseBranch, b.Body) - if err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) { - switch apiErr.StatusCode { - case 401, 403: - apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to create pull requests") - return - case 422: - apiError(res, 422, "GITLAB_PR_VALIDATION_ERROR", fmt.Sprintf("GitLab validation error: %s", apiErr.Message)) - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to create pull request: %s", err)) - return - } - - state := ghPR.NormalizedState() - - now := nowStr() - - var mergedAtStr *string - if ghPR.MergedAt != nil { - s := ghPR.MergedAt.UTC().Format("2006-01-02T15:04:05.999999999Z") - mergedAtStr = &s - } - - upserted, err := p.db.Query(` - 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.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()) - } else { - apiError(res, 500, "INTERNAL_ERROR", "upsert returned no rows") - } - return - } - prID := newRowScanner(upserted.Columns, upserted.Rows[0]).str("id") - - _, lErr := p.db.Exec(` - INSERT INTO gitlab_task_mr_links (task_id, merge_request_id, created_at) - VALUES ($1,$2,$3) - 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()) - } - - plugin.EmitEvent("github.pr_created", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": b.RepoID, - "pr_number": ghPR.IID, - "pr_url": ghPR.WebURL, - }) - - created(res, pullRequestResponse{ - ID: prID, - ProjectID: projectID, - RepoID: b.RepoID, - PRNumber: ghPR.IID, - GitLabMRID: ghPR.ID, - Title: ghPR.Title, - State: state, - HTMLURL: ghPR.WebURL, - HeadBranch: ghPR.SourceBranch, - BaseBranch: ghPR.TargetBranch, - Author: ghPR.Author.Username, - MergedAt: mergedAtStr, - CreatedAt: now, - UpdatedAt: now, - }) -} - -// ─── DELETE /tasks/:taskId/github/pull-requests/:prId ──────────────────────── - -func (p *gitlabPlugin) unlinkPRFromTask(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - if !p.taskBelongsToProject(taskID, projectID, res) { - return - } - - // Re-verify the PR itself belongs to the caller's project (not just the - // 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 gitlab_merge_requests WHERE id = $1 AND project_id = $2`, - prID, projectID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if len(prResult.Rows) == 0 { - apiError(res, 404, "GITLAB_PR_LINK_NOT_FOUND", "Pull request link not found") - return - } - - rowsAffected, err := p.db.Exec( - `DELETE FROM gitlab_task_mr_links WHERE task_id = $1 AND merge_request_id = $2`, - taskID, prID, - ) - if err != nil { - apiError(res, 500, "INTERNAL_ERROR", err.Error()) - return - } - if rowsAffected == 0 { - apiError(res, 404, "GITLAB_PR_LINK_NOT_FOUND", "Pull request link not found") - return - } - noContent(res) -} - -// ─── Shared: resolve a PR's GitLab coordinates for a task ──────────────────── - -// 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 *gitlabPlugin) resolvePRForTask(projectID, taskID, prID string) (owner, repoName string, prNumber int, err error) { - linkResult, lErr := p.db.Query( - `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: "GITLAB_PR_LINK_NOT_FOUND", status: 404, msg: "Pull request link not found"} - } - - prResult, pErr := p.db.Query( - `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: "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 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: "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 -} - -// ─── GET /tasks/:taskId/github/pull-requests/:prId ──────────────────────────── - -type pullRequestDetailsResponse struct { - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - PRNumber int `json:"pr_number"` - Title string `json:"title"` - State string `json:"state"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` - Diff string `json:"diff"` -} - -func (p *gitlabPlugin) getPullRequestDetails(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - 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)) - return - } - diff, err := ghc.getPullRequestDiff(ctx, owner, repoName, prNumber) - if err != nil { - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to fetch pull request diff: %s", err)) - return - } - - ok(res, pullRequestDetailsResponse{ - Owner: owner, - RepoName: repoName, - PRNumber: prNumber, - Title: ghPR.Title, - State: ghPR.NormalizedState(), - HTMLURL: ghPR.WebURL, - Diff: diff, - }) -} - -// ─── GET /tasks/:taskId/github/pull-requests/:prId/ci-status ───────────────── - -type ciCheckResponse struct { - Name string `json:"name"` - Status string `json:"status"` // "queued" | "in_progress" | "completed" | "pending" - Conclusion string `json:"conclusion"` // "success" | "failure" | ... | "" while not completed - URL string `json:"url"` -} - -type ciStatusResponse struct { - State string `json:"state"` // "success" | "failure" | "pending" | "unknown" - Checks []ciCheckResponse `json:"checks"` -} - -// 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 -// than "success" so the agent doesn't mistake "no CI configured" for "CI -// passed". -func overallCIState(checks []ciCheckResponse) string { - if len(checks) == 0 { - return "unknown" - } - pending := false - for _, c := range checks { - switch c.Conclusion { - case "failure", "timed_out", "cancelled", "action_required", "error": - return "failure" - case "success", "neutral", "skipped": - continue - } - if c.Status != "completed" { - pending = true - } - } - if pending { - return "pending" - } - return "success" -} - -func (p *gitlabPlugin) getPullRequestCIStatus(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - ctx := context.Background() - 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 - } - 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 - } - - 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) - } - checks = append(checks, ciCheckResponse{ - Name: name, - Status: status, - Conclusion: conclusion, - URL: pipe.WebURL, - }) - } - - ok(res, ciStatusResponse{ - State: overallCIState(checks), - Checks: checks, - }) -} - -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 *gitlabPlugin) addPullRequestComment(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - type addPullRequestCommentBody struct { - Body string `json:"body"` - } - b, err := plugin.JSONBody[addPullRequestCommentBody](req) - if err != nil || b.Body == "" { - apiError(res, 400, "BAD_REQUEST", "body is required") - return - } - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - if err := ghc.createIssueComment(context.Background(), owner, repoName, prNumber, b.Body); err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { - 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)) - return - } - created(res, map[string]bool{"success": true}) -} - -// ─── POST /tasks/:taskId/github/pull-requests/:prId/reviews ────────────────── - -func (p *gitlabPlugin) createReview(req *plugin.Request, res *plugin.Response) { - projectID := req.Caller.ProjectID - taskID := req.PathParam("taskId") - prID := req.PathParam("prId") - - type createReviewBody struct { - Event string `json:"event"` - Body string `json:"body"` - } - b, err := plugin.JSONBody[createReviewBody](req) - if err != nil { - apiError(res, 400, "BAD_REQUEST", "event is required") - return - } - switch b.Event { - case "APPROVE", "REQUEST_CHANGES", "COMMENT": - default: - apiError(res, 400, "BAD_REQUEST", "event must be one of: APPROVE, REQUEST_CHANGES, COMMENT") - return - } - - owner, repoName, prNumber, err := p.resolvePRForTask(projectID, taskID, prID) - if err != nil { - writeAppError(res, err) - return - } - ghc, err := p.clientForProject(projectID) - if err != nil { - writeAppError(res, err) - return - } - if err := ghc.createPullRequestReview(context.Background(), owner, repoName, prNumber, b.Event, b.Body); err != nil { - var apiErr *glAPIError - if errors.As(err, &apiErr) { - switch apiErr.StatusCode { - case 401, 403: - apiError(res, 403, "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", "Token does not have permission to review pull requests") - return - case 422: - apiError(res, 422, "GITLAB_PR_VALIDATION_ERROR", fmt.Sprintf("GitLab validation error: %s", apiErr.Message)) - return - } - } - apiError(res, 502, "INTERNAL_ERROR", fmt.Sprintf("failed to submit review: %s", err)) - return - } - created(res, map[string]bool{"success": true}) -} diff --git a/backend/scanner.go b/backend/scanner.go index 389565c..e5c841f 100644 --- a/backend/scanner.go +++ b/backend/scanner.go @@ -51,7 +51,3 @@ func (s *scanner) int64Val(col string) int64 { } return 0 } - -func (s *scanner) intVal(col string) int { - return int(s.int64Val(col)) -} diff --git a/backend/spi_api.go b/backend/spi_api.go new file mode 100644 index 0000000..c0e3167 --- /dev/null +++ b/backend/spi_api.go @@ -0,0 +1,232 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// glAPI is one authenticated view of a GitLab instance's REST API v4, used by +// the Git provider. Every failure comes back as a *gp.Error so core handles one +// error type; GitLab's wording is kept short and never carries credentials. +type glAPI struct { + base string // https://host/api/v4 + token string +} + +const maxErrorMessage = 200 + +func (a *glAPI) headers() map[string]string { + h := map[string]string{"Accept": "application/json"} + if a.token != "" { + h["PRIVATE-TOKEN"] = a.token + } + return h +} + +// do performs a request and returns the body of a 2xx answer. +func (a *glAPI) do(method, path string, body any) (string, error) { + _, text, _, err := a.doRaw(method, path, body) + return text, err +} + +func (a *glAPI) doRaw(method, path string, body any) (int, string, map[string]string, error) { + h := a.headers() + payload := "" + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, "", nil, gp.Errorf(gp.CodeInternal, "could not encode request") + } + payload = string(b) + h["Content-Type"] = "application/json" + } + resp, err := fetch(method, a.base+path, h, payload) + if err != nil { + return 0, "", nil, gp.Errorf(gp.CodeTransient, "GitLab is unreachable") + } + if resp.Status >= 400 { + return resp.Status, resp.Body, resp.Headers, mapGitLabError(resp.Status, resp.Body, resp.Headers) + } + return resp.Status, resp.Body, resp.Headers, nil +} + +func (a *glAPI) get(path string, out any) error { + text, err := a.do("GET", path, nil) + if err != nil { + return err + } + return decodeInto(text, out) +} + +func (a *glAPI) send(method, path string, body, out any) error { + text, err := a.do(method, path, body) + if err != nil { + return err + } + if out == nil || strings.TrimSpace(text) == "" { + return nil + } + return decodeInto(text, out) +} + +func decodeInto(text string, out any) error { + if out == nil { + return nil + } + if err := json.Unmarshal([]byte(text), out); err != nil { + return gp.Errorf(gp.CodeInternal, "GitLab returned an unreadable response") + } + return nil +} + +func clip(s string) string { + s = strings.TrimSpace(s) + if len(s) > maxErrorMessage { + cut, _ := cutHead(s, maxErrorMessage) + return cut + "…" + } + return s +} + +// mapGitLabError classifies a failed REST call onto the SPI's error set. +func mapGitLabError(status int, body string, headers map[string]string) error { + msg := clip(errorMessage(body)) + if msg == "" { + msg = fmt.Sprintf("GitLab answered HTTP %d", status) + } + lower := strings.ToLower(msg) + switch { + case status == 429: + e := gp.Errorf(gp.CodeRateLimited, "GitLab is rate limiting this identity") + e.RetryAfterSeconds = retryAfter(headers) + return e + case status == 401: + return gp.Errorf(gp.CodeForbidden, "GitLab rejected the identity's token") + case status == 403: + return gp.Errorf(gp.CodeForbidden, "%s", msg) + case status == 404: + return gp.Errorf(gp.CodeNotFound, "%s", msg) + case status == 409 || status == 405 || status == 406 || status == 412: + // 405/406: "not mergeable"; 409: the head moved or the branch exists. + return gp.Errorf(gp.CodeConflict, "%s", msg) + case status == 400 || status == 422: + if strings.Contains(lower, "already exists") || strings.Contains(lower, "already been taken") { + return gp.Errorf(gp.CodeConflict, "%s", msg) + } + return gp.Errorf(gp.CodeValidation, "%s", msg) + case status >= 500: + return gp.Errorf(gp.CodeTransient, "GitLab is temporarily unavailable") + } + return gp.Errorf(gp.CodeInternal, "%s", msg) +} + +// errorMessage pulls the human message out of GitLab's several error shapes: +// {"message":"…"}, {"message":{"field":["…"]}}, {"error":"…"}. +func errorMessage(body string) string { + var eb struct { + Message json.RawMessage `json:"message"` + Error string `json:"error"` + } + if json.Unmarshal([]byte(body), &eb) != nil { + return "" + } + if eb.Error != "" { + return eb.Error + } + var s string + if json.Unmarshal(eb.Message, &s) == nil { + return s + } + if m := strings.TrimSpace(string(eb.Message)); m != "null" { + return m + } + return "" +} + +func headerOf(h map[string]string, name string) string { + for k, v := range h { + if strings.EqualFold(k, name) { + return v + } + } + return "" +} + +func retryAfter(h map[string]string) int { + if v := headerOf(h, "retry-after"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + if v := headerOf(h, "ratelimit-reset"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + if d := int(time.Until(time.Unix(n, 0)).Seconds()); d > 0 { + return d + } + } + } + return 0 +} + +// segmentOK reports whether s is a valid GitLab namespace or project path segment. +func segmentOK(s string) bool { + if s == "" || s == "." || s == ".." { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-': + default: + return false + } + } + return true +} + +// splitFullPath validates "group[/subgroup…]/project": at least two segments. +func splitFullPath(p string) (namespace, name string, ok bool) { + parts := strings.Split(strings.Trim(p, "/"), "/") + if len(parts) < 2 { + return "", "", false + } + for _, s := range parts { + if !segmentOK(s) { + return "", "", false + } + } + return strings.Join(parts[:len(parts)-1], "/"), parts[len(parts)-1], true +} + +func allDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// projectPath builds "/projects/{id}" for a repository. The numeric project id +// is preferred (it survives renames and transfers); otherwise the path is +// URL-encoded, which GitLab requires ("/" becomes %2F). +func projectPath(r gp.RepoRef) (string, error) { + if id := strings.TrimSpace(r.ExternalID); allDigits(id) { + return "/projects/" + id, nil + } + if _, _, ok := splitFullPath(r.FullPath); !ok { + return "", gp.Errorf(gp.CodeValidation, "repository must be group/project") + } + return "/projects/" + url.PathEscape(strings.Trim(r.FullPath, "/")), nil +} + +// pathPart escapes one value for use inside a URL path ("/" included). +func pathPart(s string) string { return url.PathEscape(s) } diff --git a/backend/spi_assert.go b/backend/spi_assert.go new file mode 100644 index 0000000..1c0de0d --- /dev/null +++ b/backend/spi_assert.go @@ -0,0 +1,21 @@ +package main + +import gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + +// Compile-time proof that the provider implements the SPI and exactly the +// optional interfaces Describe declares (the conformance suite checks the +// declarations match). +var ( + _ gp.Provider = (*glProvider)(nil) + _ gp.ThreadResolver = (*glProvider)(nil) + _ gp.FileReader = (*glProvider)(nil) + _ gp.CodeSearcher = (*glProvider)(nil) + _ gp.LabelManager = (*glProvider)(nil) + _ gp.ReviewerRequester = (*glProvider)(nil) + _ gp.AssigneeSetter = (*glProvider)(nil) + _ gp.Merger = (*glProvider)(nil) + _ gp.CheckRerunner = (*glProvider)(nil) + _ gp.WebhookManager = (*glProvider)(nil) + _ gp.IdentityDeleter = (*glProvider)(nil) + _ gp.BranchManager = (*glProvider)(nil) +) diff --git a/backend/spi_branches.go b/backend/spi_branches.go new file mode 100644 index 0000000..9111f26 --- /dev/null +++ b/backend/spi_branches.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "strings" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// validBranchName applies git's core rules for a branch name, so a bad name is +// refused here as validation rather than as a GitLab 400. +func validBranchName(name string) bool { + if name == "" || len(name) > 250 || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || + strings.HasPrefix(name, "-") || strings.Contains(name, "..") || strings.Contains(name, "//") || + strings.HasSuffix(name, ".lock") || strings.HasSuffix(name, ".") || strings.Contains(name, "@{") { + return false + } + for _, r := range name { + if r < 0x20 || r == 0x7f || strings.ContainsRune(" ~^:?*[\\", r) { + return false + } + } + return true +} + +type glBranch struct { + Name string `json:"name"` + WebURL string `json:"web_url"` + Commit struct { + ID string `json:"id"` + } `json:"commit"` +} + +func branchInfo(t gp.Target, b glBranch) gp.BranchInfo { + u := b.WebURL + if u == "" { + u = webURL(hostOf(t), t.Repo.FullPath, "/-/tree/"+b.Name) + } + return gp.BranchInfo{Name: b.Name, SHA: b.Commit.ID, URL: u} +} + +// GetBranch implements gp.BranchManager. +func (p *glProvider) GetBranch(_ context.Context, req gp.GetBranchRequest) (gp.BranchInfo, error) { + if !validBranchName(req.Name) { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid branch name") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.BranchInfo{}, err + } + var b glBranch + if err := api.get(proj+"/repository/branches/"+pathPart(req.Name), &b); err != nil { + return gp.BranchInfo{}, err + } + return branchInfo(req.Target, b), nil +} + +// CreateBranch implements gp.BranchManager: a new branch at the head of From +// (the default branch when empty). The source is looked up first so a missing +// one is not_found; GitLab answers 400 "Branch already exists" for a taken name, +// which is classified as a conflict. +func (p *glProvider) CreateBranch(_ context.Context, req gp.CreateBranchRequest) (gp.BranchInfo, error) { + if !validBranchName(req.Name) { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid branch name") + } + from := strings.TrimSpace(req.From) + if from == "" { + from = req.Target.Repo.DefaultBranch + } + if from == "" { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "the source branch is required: the repository has no known default branch") + } + if !validBranchName(from) { + return gp.BranchInfo{}, gp.Errorf(gp.CodeValidation, "invalid source branch name") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.BranchInfo{}, err + } + var src glBranch + if err := api.get(proj+"/repository/branches/"+pathPart(from), &src); err != nil { + return gp.BranchInfo{}, err + } + var made glBranch + if err := api.send("POST", proj+"/repository/branches", map[string]any{"branch": req.Name, "ref": src.Commit.ID}, &made); err != nil { + return gp.BranchInfo{}, err + } + if made.Name == "" { + made.Name, made.Commit = req.Name, src.Commit + } + return branchInfo(req.Target, made), nil +} diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go new file mode 100644 index 0000000..3058230 --- /dev/null +++ b/backend/spi_conformance_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "encoding/json" + "strconv" + "sync" + "testing" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" + "github.com/Paca-AI/plugin-sdk-go/gitprovider/conformance" +) + +// memStore is an in-memory spiStore. +type memStore struct { + mu sync.Mutex + identities map[string]identityRecord + setups map[string]setupSession + seq int +} + +func newMemStore() *memStore { + return &memStore{identities: map[string]identityRecord{}, setups: map[string]setupSession{}} +} + +func (m *memStore) loadIdentity(id string) (identityRecord, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.identities[id] + if !ok { + return identityRecord{}, errNoSuchIdentity + } + return r, nil +} + +func (m *memStore) createIdentity(r identityRecord) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.seq++ + r.ID = "created-" + strconv.Itoa(m.seq) + m.identities[r.ID] = r + return r.ID, nil +} + +func (m *memStore) deleteIdentity(id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.identities, id) + return nil +} + +func (m *memStore) saveSetup(s setupSession) error { + m.mu.Lock() + defer m.mu.Unlock() + m.setups[s.State] = s + return nil +} + +func (m *memStore) loadSetup(state string) (setupSession, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.setups[state] + if !ok { + return setupSession{}, errNoSuchSetup + } + return s, nil +} + +func (m *memStore) claimSetup(state string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.setups[state] + if !ok || s.Completed { + return false, nil + } + s.Completed = true + m.setups[state] = s + return true, nil +} + +func seededIdentity() identityRecord { + return identityRecord{ + ID: fakeIdentityID, Host: fakeHost, UserID: 77, Username: fakeUsername, Name: "Paca Bot", Bot: true, + Token: fakeToken, ReadToken: fakeReadToken, WebhookSecret: fakeWebhookSecret, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour), + } +} + +// seededProvider returns a provider holding the fixture identity. +func seededProvider(t *testing.T) (*glProvider, *fakeGitLab, *memStore) { + t.Helper() + g := newFakeGitLab(t) + st := newMemStore() + st.identities[fakeIdentityID] = seededIdentity() + return newGLProvider(st), g, st +} + +var ( + fixtureIdentity = gp.IdentityRef{ID: fakeIdentityID, Handle: fakeUsername} + fixtureRepo = gp.RepoRef{Host: fakeHost, FullPath: "acme/web", ExternalID: "1", DefaultBranch: "main"} + fixtureTarget = gp.Target{Identity: fixtureIdentity, Repo: fixtureRepo} +) + +// hookDelivery builds a GitLab webhook delivery carrying the secret token. +func hookDelivery(id string, payload map[string]any) gp.WebhookRequest { + body, _ := json.Marshal(payload) + return gp.WebhookRequest{ + Identity: fixtureIdentity, Method: "POST", Body: body, + Headers: map[string]string{"x-gitlab-token": fakeWebhookSecret, "x-gitlab-event-uuid": id, "content-type": "application/json"}, + } +} + +var ( + hookRepo = map[string]any{"id": 1, "path_with_namespace": "acme/web", "default_branch": "main", "git_http_url": "https://gitlab.com/acme/web.git"} + hookActor = map[string]any{"id": 1, "username": "octocat"} + hookMRAt = func(extra map[string]any) map[string]any { + m := map[string]any{ + "id": 31, "iid": 1, "title": "Add login", "description": "", "state": "opened", "url": "https://gitlab.com/acme/web/-/merge_requests/1", + "source_branch": "feat/PACA-12-login", "target_branch": "main", "author_id": 1, "last_commit": map[string]any{"id": fakeHeadSHA}, + "source": map[string]any{"path_with_namespace": "acme/web"}, "target": map[string]any{"path_with_namespace": "acme/web"}, + "source_project_id": 1, "target_project_id": 1, + } + for k, v := range extra { + m[k] = v + } + return m + } +) + +func mrHook(id string, oa map[string]any, extra map[string]any) gp.WebhookRequest { + p := map[string]any{"object_kind": "merge_request", "user": hookActor, "project": hookRepo, "object_attributes": hookMRAt(oa)} + for k, v := range extra { + p[k] = v + } + return hookDelivery(id, p) +} + +func noteHook(id, body string, user map[string]any, oa map[string]any) gp.WebhookRequest { + attrs := map[string]any{"id": 11, "note": body, "noteable_type": "MergeRequest", "discussion_id": "disc-1", "action": "create"} + for k, v := range oa { + attrs[k] = v + } + return hookDelivery(id, map[string]any{"object_kind": "note", "user": user, "project": hookRepo, "object_attributes": attrs, "merge_request": hookMRAt(nil)}) +} + +func issueNoteHook(id, body string, user map[string]any) gp.WebhookRequest { + return hookDelivery(id, map[string]any{"object_kind": "note", "user": user, "project": hookRepo, + "object_attributes": map[string]any{"id": 12, "note": body, "noteable_type": "Issue", "action": "create"}, + "issue": map[string]any{"iid": 7, "title": "Bug", "author_id": 2}}) +} + +func issueHook(id string, oa, extra map[string]any) gp.WebhookRequest { + attrs := map[string]any{"iid": 7, "title": "Bug", "description": "It breaks", "action": "open", "url": "https://gitlab.com/acme/web/-/issues/7", "author_id": 1} + for k, v := range oa { + attrs[k] = v + } + p := map[string]any{"object_kind": "issue", "user": hookActor, "project": hookRepo, "object_attributes": attrs} + for k, v := range extra { + p[k] = v + } + return hookDelivery(id, p) +} + +var ( + me = map[string]any{"id": 77, "username": fakeUsername} + person = map[string]any{"id": 5, "username": "bob"} +) + +func users(us ...map[string]any) []map[string]any { return us } + +func conformanceFixtures() conformance.Fixtures { + bad := noteHook("d-bad", "@paca-bot hi", hookActor, nil) + bad.Headers["x-gitlab-token"] = "not-the-secret" + + mention := []gp.AddressVia{gp.ViaMention} + return conformance.Fixtures{ + Identity: fixtureIdentity, Repo: fixtureRepo, + InvisibleRepo: gp.RepoRef{Host: fakeHost, FullPath: "secret/hidden"}, + RepoURL: "https://gitlab.com/acme/web.git", Candidates: []gp.IdentityRef{fixtureIdentity}, + ExpectedRepoPaths: []string{"acme/web", "acme/api", "acme/platform/infra"}, + + ChangeRequest: "1", MissingCR: "999", + ChangedFiles: []string{"src/main.go", "src/util.go", "docs/guide.md"}, + HeadSHA: fakeHeadSHA, FailingCheckID: "job:5001", + IssueNumber: "7", ThreadID: "disc-1", + Actors: map[string]gp.Permission{"octocat": gp.PermissionWrite, "reader": gp.PermissionRead, "owner": gp.PermissionAdmin, "lead": gp.PermissionMaintain}, + + BadSignature: bad, + Secrets: []string{fakeWebhookSecret, fakeToken, fakeReadToken, fakeNewToken}, + Webhooks: []conformance.WebhookCase{ + {Name: "mention-in-mr-note", Request: noteHook("d1", "@paca-bot please fix the test", hookActor, nil), + Want: []conformance.Want{{Kind: gp.EventComment, Action: "created", RepoPath: "acme/web", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "1", ActorLogin: "octocat", ActorKind: gp.ActorUser, Addressed: mention}}}, + {Name: "mention-is-case-insensitive", Request: noteHook("d1b", "hey @Paca-Bot, look", hookActor, nil), + Want: []conformance.Want{{Kind: gp.EventComment, Addressed: mention}}}, + {Name: "mention-ending-a-sentence", Request: noteHook("d1c", "ask @paca-bot.", hookActor, nil), + Want: []conformance.Want{{Kind: gp.EventComment, Addressed: mention}}}, + {Name: "inline-note-keeps-its-thread-and-line", Request: noteHook("d1d", "nit", hookActor, map[string]any{"type": "DiffNote", "position": map[string]any{"new_path": "src/main.go", "new_line": 3}}), + Want: []conformance.Want{{Kind: gp.EventComment, SubjectNumber: "1"}}}, + {Name: "comment-without-mention-on-an-issue", Request: issueNoteHook("d2", "thanks!", map[string]any{"id": 2, "username": "reader"}), + Want: []conformance.Want{{Kind: gp.EventComment, SubjectKind: gp.SubjectIssue, SubjectNumber: "7", ActorLogin: "reader", ActorKind: gp.ActorUser}}}, + {Name: "edited-note-is-not-addressed", Request: noteHook("d2b", "@paca-bot now", hookActor, map[string]any{"action": "update"}), + Want: []conformance.Want{{Kind: gp.EventComment, Action: "edited"}}}, + {Name: "email-address-is-not-a-mention", Request: noteHook("d3", "mail me at dev@paca-bot.example", hookActor, nil), + Want: []conformance.Want{{Kind: gp.EventComment, ActorLogin: "octocat"}}}, + {Name: "longer-handle-is-not-a-mention", Request: noteHook("d3b", "@paca-bot-2 is another bot", hookActor, nil), + Want: []conformance.Want{{Kind: gp.EventComment}}}, + {Name: "dotted-handle-is-another-user", Request: noteHook("d3c", "@paca-bot.other check", hookActor, nil), + Want: []conformance.Want{{Kind: gp.EventComment}}}, + {Name: "access-token-bot-actor", Request: noteHook("d4", "bump", map[string]any{"id": 9, "username": "project_9_bot_ab12cd"}, nil), + Want: []conformance.Want{{Kind: gp.EventComment, ActorLogin: "project_9_bot_ab12cd", ActorKind: gp.ActorBot}}}, + {Name: "own-comments-are-bot-actions", Request: noteHook("d4b", "done", me, nil), + Want: []conformance.Want{{Kind: gp.EventComment, ActorLogin: fakeUsername, ActorKind: gp.ActorBot}}}, + {Name: "system-note-is-dropped", Request: noteHook("d4c", "added 1 commit", hookActor, map[string]any{"system": true}), Want: nil}, + {Name: "commit-note-is-dropped", Request: noteHook("d4d", "nice commit", hookActor, map[string]any{"noteable_type": "Commit"}), Want: nil}, + + {Name: "ci-failed-on-a-merge-request", + Request: hookDelivery("d7", map[string]any{"object_kind": "pipeline", "user": map[string]any{"id": 9, "username": "project_9_bot_ab12cd"}, "project": hookRepo, + "object_attributes": map[string]any{"id": 900, "sha": fakeHeadSHA, "status": "failed", "source": "merge_request_event"}, + "merge_request": map[string]any{"iid": 1, "source_branch": "feat/PACA-12-login", "target_branch": "main"}}), + Want: []conformance.Want{{Kind: gp.EventCheck, Action: "completed", CheckConclusion: "failure", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "1"}}}, + {Name: "ci-passed-without-a-merge-request", + Request: hookDelivery("d7b", map[string]any{"object_kind": "pipeline", "user": hookActor, "project": hookRepo, + "object_attributes": map[string]any{"id": 901, "sha": fakeHeadSHA, "status": "success", "source": "push"}}), + Want: []conformance.Want{{Kind: gp.EventCheck, CheckConclusion: "success"}}}, + {Name: "ci-running-is-dropped", + Request: hookDelivery("d7c", map[string]any{"object_kind": "pipeline", "user": hookActor, "project": hookRepo, + "object_attributes": map[string]any{"id": 902, "sha": fakeHeadSHA, "status": "running", "source": "push"}}), + Want: nil}, + {Name: "external-pipelines-are-dropped", + Request: hookDelivery("d7d", map[string]any{"object_kind": "pipeline", "user": me, "project": hookRepo, + "object_attributes": map[string]any{"id": 903, "sha": fakeHeadSHA, "status": "failed", "source": "external"}}), + Want: nil}, + + {Name: "branch-created-by-push", + Request: hookDelivery("d10", map[string]any{"object_kind": "push", "ref": "refs/heads/feat/PACA-12-x", "before": zeroSHA, "after": "def456", "user_username": "octocat", "project": hookRepo}), + Want: []conformance.Want{{Kind: gp.EventPush, Action: "created", ActorLogin: "octocat"}}}, + {Name: "branch-deleted-by-push", + Request: hookDelivery("d10c", map[string]any{"object_kind": "push", "ref": "refs/heads/old", "before": "abc", "after": zeroSHA, "user_username": "octocat", "project": hookRepo}), + Want: []conformance.Want{{Kind: gp.EventPush, Action: "deleted"}}}, + {Name: "tag-push-is-dropped", + Request: hookDelivery("d10b", map[string]any{"object_kind": "tag_push", "ref": "refs/tags/v1", "after": "def456", "user_username": "octocat", "project": hookRepo}), + Want: nil}, + + {Name: "mr-opened-with-mention-in-description", + Request: mrHook("d11", map[string]any{"action": "open", "iid": 3, "description": "@paca-bot can you review?"}, nil), + Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "opened", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "3", Addressed: mention}}}, + {Name: "mr-opened-with-labels-and-the-bot-as-assignee-and-reviewer", + Request: mrHook("d11b", map[string]any{"action": "open"}, map[string]any{ + "labels": []map[string]any{{"title": "paca"}}, "assignees": users(me), "reviewers": users(me, person)}), + Want: []conformance.Want{ + {Kind: gp.EventChangeRequest, Action: "opened"}, + {Kind: gp.EventLabel, Action: "labeled"}, + {Kind: gp.EventAssignment, Action: "assigned", Addressed: []gp.AddressVia{gp.ViaAssignment}}, + {Kind: gp.EventReviewRequest, Action: "requested", Addressed: []gp.AddressVia{gp.ViaReviewRequest}}, + }}, + {Name: "mr-new-commits", Request: mrHook("d11c", map[string]any{"action": "update", "oldrev": "old1"}, nil), + Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "synchronized"}}}, + {Name: "mr-marked-ready", Request: mrHook("d11d", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"draft": map[string]any{"previous": true, "current": false}}}), + Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "ready"}}}, + {Name: "mr-without-relevant-changes-is-dropped", Request: mrHook("d11e", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"milestone_id": map[string]any{}}}), Want: nil}, + {Name: "mr-merged", Request: mrHook("d11f", map[string]any{"action": "merge", "state": "merged"}, nil), + Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "merged"}}}, + {Name: "mr-approved", Request: mrHook("d12", map[string]any{"action": "approved"}, nil), + Want: []conformance.Want{{Kind: gp.EventReview, Action: "submitted", SubjectNumber: "1"}}}, + {Name: "review-requested-from-the-bot", + Request: mrHook("d13", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"reviewers": map[string]any{"previous": users(), "current": users(me)}}}), + Want: []conformance.Want{{Kind: gp.EventReviewRequest, Action: "requested", SubjectKind: gp.SubjectChangeRequest, SubjectNumber: "1", Addressed: []gp.AddressVia{gp.ViaReviewRequest}}}}, + {Name: "review-requested-again-is-not-new", + Request: mrHook("d13b", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"reviewers": map[string]any{"previous": users(me), "current": users(me, person)}}}), + Want: nil}, + {Name: "review-requested-from-a-person", + Request: mrHook("d15", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"reviewers": map[string]any{"previous": users(), "current": users(person)}}}), + Want: nil}, + {Name: "mr-assigned-to-the-bot", + Request: mrHook("d15b", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"assignees": map[string]any{"previous": users(), "current": users(me)}}}), + Want: []conformance.Want{{Kind: gp.EventAssignment, Action: "assigned", SubjectKind: gp.SubjectChangeRequest, Addressed: []gp.AddressVia{gp.ViaAssignment}}}}, + {Name: "mr-label-added", + Request: mrHook("d15c", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"labels": map[string]any{ + "previous": []map[string]any{{"title": "old"}}, "current": []map[string]any{{"title": "old"}, {"title": "paca"}}}}}), + Want: []conformance.Want{{Kind: gp.EventLabel, Action: "labeled", SubjectKind: gp.SubjectChangeRequest}}}, + + {Name: "issue-opened-with-mention", + Request: issueHook("d16", map[string]any{"description": "@paca-bot please look"}, nil), + Want: []conformance.Want{{Kind: gp.EventComment, Action: "created", SubjectKind: gp.SubjectIssue, SubjectNumber: "7", Addressed: mention}}}, + {Name: "issue-assigned-to-the-bot", + Request: issueHook("d16b", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"assignees": map[string]any{"previous": users(), "current": users(me)}}}), + Want: []conformance.Want{{Kind: gp.EventAssignment, Action: "assigned", SubjectKind: gp.SubjectIssue, SubjectNumber: "7", Addressed: []gp.AddressVia{gp.ViaAssignment}}}}, + {Name: "issue-assigned-to-a-person", + Request: issueHook("d17", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"assignees": map[string]any{"previous": users(), "current": users(person)}}}), + Want: nil}, + {Name: "issue-label-added", + Request: issueHook("d18", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"labels": map[string]any{"previous": []map[string]any{}, "current": []map[string]any{{"title": "paca"}}}}}), + Want: []conformance.Want{{Kind: gp.EventLabel, Action: "labeled", SubjectKind: gp.SubjectIssue, SubjectNumber: "7"}}}, + {Name: "issue-closed-is-dropped", Request: issueHook("d18b", map[string]any{"action": "close"}, nil), Want: nil}, + {Name: "unmapped-event-is-dropped", Request: hookDelivery("d8", map[string]any{"object_kind": "wiki_page", "project": hookRepo, "user": hookActor}), Want: nil}, + }, + + SetupHost: fakeHost, + SetupCompleteParams: map[string]string{fieldToken: fakeNewToken, fieldWebhookSecret: fakeWebhookSecret}, + + MutationsAllowed: true, + NewBranchHead: "feat/new", BaseBranch: "main", + File: struct{ Ref, Path string }{Ref: "main", Path: "README.md"}, + SearchQuery: "package main", + Reviewer: "reader", + } +} + +// TestGitLabProviderConformance runs the SDK's provider conformance suite +// against the GitLab adapter wired to a fake GitLab. +func TestGitLabProviderConformance(t *testing.T) { + conformance.Run(t, conformance.Harness{ + New: func(t *testing.T) gp.Provider { + p, _, _ := seededProvider(t) + return p + }, + Fixtures: conformanceFixtures(), + }) +} diff --git a/backend/spi_dbstore.go b/backend/spi_dbstore.go new file mode 100644 index 0000000..7bed653 --- /dev/null +++ b/backend/spi_dbstore.go @@ -0,0 +1,135 @@ +package main + +import ( + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// dbStore is the production spiStore: the plugin's own tables, with every +// secret encrypted under the instance key. +type dbStore struct { + p *gitlabPlugin + db sqlExecutor +} + +// sqlExecutor is the part of *plugin.DB the store uses; a test can stand in for it. +type sqlExecutor interface { + Query(sql string, params ...any) (*plugin.DBQueryResult, error) + Exec(sql string, params ...any) (int64, error) +} + +func newDBStore(p *gitlabPlugin) dbStore { return dbStore{p: p, db: p.db} } + +func (s dbStore) loadIdentity(id string) (identityRecord, error) { + r, err := s.db.Query(`SELECT id, host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at FROM gitlab_identities WHERE id = $1`, id) + if err != nil { + return identityRecord{}, err + } + if len(r.Rows) == 0 { + return identityRecord{}, errNoSuchIdentity + } + sc := newRowScanner(r.Columns, r.Rows[0]) + open := func(col string) (string, error) { + enc := sc.str(col) + if enc == "" { + return "", nil + } + return s.p.decrypt(enc) + } + token, err := open("token_enc") + if err != nil { + return identityRecord{}, err + } + readToken, err := open("read_token_enc") + if err != nil { + return identityRecord{}, err + } + secret, err := open("webhook_secret_enc") + if err != nil { + return identityRecord{}, err + } + rec := identityRecord{ + ID: sc.str("id"), Host: sc.str("host"), UserID: sc.int64Val("user_id"), Username: sc.str("username"), + Name: sc.str("display_name"), Bot: sc.str("bot") == "true", Token: token, ReadToken: readToken, WebhookSecret: secret, + } + if exp := sc.str("expires_at"); exp != "" { + if t, perr := time.Parse(time.RFC3339Nano, exp); perr == nil { + rec.ExpiresAt = t + } + } + return rec, nil +} + +func (s dbStore) createIdentity(x identityRecord) (string, error) { + seal := func(v string) (string, error) { + if v == "" { + return "", nil + } + return s.p.encrypt(v) + } + token, err := seal(x.Token) + if err != nil { + return "", err + } + readToken, err := seal(x.ReadToken) + if err != nil { + return "", err + } + secret, err := seal(x.WebhookSecret) + if err != nil { + return "", err + } + var expires any + if !x.ExpiresAt.IsZero() { + expires = x.ExpiresAt.UTC().Format(time.RFC3339Nano) + } + now := nowStr() + r, err := s.db.Query(` + INSERT INTO gitlab_identities (host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'git-provider',$10,$10) RETURNING id`, + x.Host, x.UserID, x.Username, x.Name, x.Bot, token, readToken, secret, expires, now) + if err != nil { + return "", err + } + if len(r.Rows) == 0 { + return "", errNoSuchIdentity + } + return newRowScanner(r.Columns, r.Rows[0]).str("id"), nil +} + +func (s dbStore) deleteIdentity(id string) error { + _, err := s.db.Exec(`DELETE FROM gitlab_identities WHERE id = $1`, id) + return err +} + +func (s dbStore) saveSetup(x setupSession) error { + _, err := s.db.Exec(` + INSERT INTO gitlab_setup_sessions (state, scope, agent_id, host, expires_at, created_at) + VALUES ($1,$2,$3,$4,$5,$6)`, + x.State, x.Scope, x.AgentID, x.Host, x.ExpiresAt.UTC().Format(time.RFC3339Nano), nowStr()) + return err +} + +func (s dbStore) loadSetup(state string) (setupSession, error) { + r, err := s.db.Query(`SELECT state, scope, agent_id, host, completed_at, expires_at FROM gitlab_setup_sessions WHERE state = $1`, state) + if err != nil { + return setupSession{}, err + } + if len(r.Rows) == 0 { + return setupSession{}, errNoSuchSetup + } + sc := newRowScanner(r.Columns, r.Rows[0]) + exp, _ := time.Parse(time.RFC3339Nano, sc.str("expires_at")) + return setupSession{ + State: sc.str("state"), Scope: sc.str("scope"), AgentID: sc.str("agent_id"), Host: sc.str("host"), + Completed: sc.strPtr("completed_at") != nil, ExpiresAt: exp, + }, nil +} + +func (s dbStore) claimSetup(state string) (bool, error) { + n, err := s.db.Exec(`UPDATE gitlab_setup_sessions SET completed_at = $2 WHERE state = $1 AND completed_at IS NULL`, state, nowStr()) + return n == 1, err +} + +var _ spiStore = dbStore{} diff --git a/backend/spi_dbstore_test.go b/backend/spi_dbstore_test.go new file mode 100644 index 0000000..eb13b1d --- /dev/null +++ b/backend/spi_dbstore_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "testing" + "time" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// fakeSQL is just enough SQL for the setup-session statements: single-row +// INSERT with a column list, and SELECT ... WHERE col = $1. A column an INSERT +// does not name reads back as NULL, so a field the store forgets to write is +// lost exactly as it would be in the real table. (The in-memory spiStore the +// provider tests use keeps every field and cannot notice that.) +type fakeSQL struct { + tables map[string][]map[string]any +} + +var ( + insertRe = regexp.MustCompile(`(?is)INSERT INTO (\w+)\s*\(([^)]*)\)\s*VALUES\s*\((.*)\)\s*$`) + selectRe = regexp.MustCompile(`(?is)SELECT (.*?) FROM (\w+) WHERE (\w+) = \$1`) +) + +func (f *fakeSQL) Exec(sql string, params ...any) (int64, error) { + m := insertRe.FindStringSubmatch(sql) + if m == nil { + return 0, fmt.Errorf("fakeSQL: unsupported statement %q", strings.TrimSpace(sql)) + } + cols := strings.Split(m[2], ",") + vals := strings.Split(m[3], ",") + if len(cols) != len(vals) { + return 0, fmt.Errorf("fakeSQL: %d columns but %d values", len(cols), len(vals)) + } + row := map[string]any{} + for i, c := range cols { + v := strings.TrimSpace(vals[i]) + switch { + case strings.HasPrefix(v, "$"): + n, err := strconv.Atoi(v[1:]) + if err != nil || n < 1 || n > len(params) { + return 0, fmt.Errorf("fakeSQL: bad parameter %s", v) + } + row[strings.TrimSpace(c)] = params[n-1] + case strings.HasPrefix(v, "'"): + row[strings.TrimSpace(c)] = strings.Trim(v, "'") + default: + return 0, fmt.Errorf("fakeSQL: unsupported value %q", v) + } + } + if f.tables == nil { + f.tables = map[string][]map[string]any{} + } + f.tables[m[1]] = append(f.tables[m[1]], row) + return 1, nil +} + +func (f *fakeSQL) Query(sql string, params ...any) (*plugin.DBQueryResult, error) { + m := selectRe.FindStringSubmatch(sql) + if m == nil { + return nil, fmt.Errorf("fakeSQL: unsupported query %q", strings.TrimSpace(sql)) + } + var cols []string + for _, c := range strings.Split(m[1], ",") { + cols = append(cols, strings.TrimSpace(c)) + } + out := &plugin.DBQueryResult{Columns: cols} + for _, row := range f.tables[m[2]] { + if fmt.Sprint(row[m[3]]) != fmt.Sprint(params[0]) { + continue + } + r := make([]any, len(cols)) + for i, c := range cols { + r[i] = row[c] + } + out.Rows = append(out.Rows, r) + } + return out, nil +} + +func newTestDBStore() dbStore { return dbStore{p: &gitlabPlugin{}, db: &fakeSQL{}} } + +// The setup session has to survive a round trip through the real store: the +// host decides where the token is validated, so losing it would validate the +// token against the wrong instance. +func TestDBStore_SetupSessionKeepsItsHostAndScope(t *testing.T) { + s := newTestDBStore() + in := setupSession{State: "st-1", Scope: "agent", AgentID: "agent-7", Host: "gitlab.example.com", ExpiresAt: time.Now().Add(time.Hour).Truncate(time.Millisecond)} + if err := s.saveSetup(in); err != nil { + t.Fatal(err) + } + got, err := s.loadSetup("st-1") + if err != nil { + t.Fatal(err) + } + if got.Scope != "agent" || got.AgentID != "agent-7" || got.Host != "gitlab.example.com" || got.Completed || !got.ExpiresAt.Equal(in.ExpiresAt) { + t.Errorf("round trip lost data: %+v", got) + } + if _, err := s.loadSetup("unknown"); err != errNoSuchSetup { + t.Errorf("unknown state: %v", err) + } +} diff --git a/backend/spi_fakegitlab_test.go b/backend/spi_fakegitlab_test.go new file mode 100644 index 0000000..e94c119 --- /dev/null +++ b/backend/spi_fakegitlab_test.go @@ -0,0 +1,601 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +// fakeGitLab answers the subset of the GitLab REST API the provider uses, from +// seeded in-memory data. It enforces what GitLab enforces and what bugs in the +// provider would violate: every call needs a token this fake knows, projects a +// token cannot see answer 404, and state-changing calls change state the reads +// then show. +type fakeGitLab struct { + t *testing.T + mu sync.Mutex + + calls []string + forceError map[string]fakeResponse // "METHOD /path" → canned answer + + mrs map[int]*fakeMR + nextIID int + notes []fakeNote + nextID int64 + threads map[string]*fakeThread + branches map[string]string // branch → commit sha + hooks map[int64]string // hook id → url + nextHook int64 + statuses []map[string]any + approved []int + retried []string +} + +type fakeMR struct { + IID int + Title string + Body string + State string + SourceRef string + SHA string + Labels []string + Reviewers []int64 +} + +type fakeNote struct { + ID int64 + IID int + Kind string // merge_requests | issues + Body string +} + +type fakeThread struct { + ID string + Resolved bool + Replies int +} + +type fakeResponse struct { + status int + body string + headers map[string]string +} + +const ( + fakeHost = "gitlab.com" + fakeToken = "glpat-fake-token" + fakeReadToken = "glpat-read-token" + fakeNewToken = "glpat-new-token" + fakeWebhookSecret = "hook-secret-value-123456" + fakeUsername = "paca-bot" + fakeIdentityID = "ident-1" + fakeHeadSHA = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" +) + +var fakeUsers = map[string]int64{"octocat": 1, "reader": 2, "owner": 3, "lead": 4, "bob": 5, fakeUsername: 77} + +// fakeAccess is each user's role on acme/web. +var fakeAccess = map[int64]int{1: 30, 2: 10, 3: 50, 4: 40} + +type fakeProject struct { + ID int64 + Path string + Public bool + Level int // the token's role on it +} + +var fakeProjects = []fakeProject{ + {1, "acme/web", false, 40}, + {2, "acme/api", true, 20}, // Reporter: may read, not push + {3, "acme/platform/infra", false, 30}, +} + +func newFakeGitLab(t *testing.T) *fakeGitLab { + t.Helper() + g := &fakeGitLab{ + t: t, forceError: map[string]fakeResponse{}, mrs: map[int]*fakeMR{}, nextIID: 2, nextID: 9000, + threads: map[string]*fakeThread{"disc-1": {ID: "disc-1"}}, + branches: map[string]string{"main": "sha-main", "feat/PACA-12-login": fakeHeadSHA, "feat/new": "sha-new", "draft-branch": "sha-draft"}, + hooks: map[int64]string{}, nextHook: 500, + } + g.mrs[1] = &fakeMR{IID: 1, Title: "Add login", Body: "body", State: "opened", SourceRef: "feat/PACA-12-login", SHA: fakeHeadSHA} + g.notes = []fakeNote{{ID: 8001, IID: 1, Kind: "merge_requests", Body: "first"}} + old := fetch + fetch = g.fetch + t.Cleanup(func() { fetch = old }) + return g +} + +func resp(status int, v any) (*plugin.FetchResponse, error) { + b, _ := json.Marshal(v) + return &plugin.FetchResponse{Status: status, Body: string(b), Headers: map[string]string{}}, nil +} + +func text(status int, s string) (*plugin.FetchResponse, error) { + return &plugin.FetchResponse{Status: status, Body: s, Headers: map[string]string{}}, nil +} + +func notFound() (*plugin.FetchResponse, error) { + return resp(404, map[string]string{"message": "404 Not Found"}) +} + +func (g *fakeGitLab) called(prefix string) int { + g.mu.Lock() + defer g.mu.Unlock() + n := 0 + for _, c := range g.calls { + if strings.HasPrefix(c, prefix) { + n++ + } + } + return n +} + +func (g *fakeGitLab) project(ref string) *fakeProject { + for i := range fakeProjects { + if ref == strconv.FormatInt(fakeProjects[i].ID, 10) || ref == fakeProjects[i].Path { + return &fakeProjects[i] + } + } + return nil +} + +func projectJSON(p fakeProject) map[string]any { + vis := "private" + if p.Public { + vis = "public" + } + return map[string]any{ + "id": p.ID, "path_with_namespace": p.Path, "default_branch": "main", "visibility": vis, "description": "d-" + p.Path, + "permissions": map[string]any{"project_access": map[string]any{"access_level": p.Level}}, + } +} + +func (m *fakeMR) json(g *fakeGitLab) map[string]any { + reviewers := []map[string]any{} + for _, id := range m.Reviewers { + reviewers = append(reviewers, map[string]any{"id": id, "username": usernameOf(id)}) + } + labels := m.Labels + if labels == nil { + labels = []string{} + } + return map[string]any{ + "iid": m.IID, "title": m.Title, "description": m.Body, "state": m.State, "draft": strings.HasPrefix(m.Title, "Draft:"), + "source_branch": m.SourceRef, "target_branch": "main", "sha": m.SHA, + "web_url": fmt.Sprintf("https://gitlab.com/acme/web/-/merge_requests/%d", m.IID), + "author": map[string]any{"id": 1, "username": "octocat"}, "labels": labels, "reviewers": reviewers, + "source_project_id": 1, "target_project_id": 1, + "diff_refs": map[string]string{"base_sha": "base1", "start_sha": "start1", "head_sha": m.SHA}, + } +} + +func usernameOf(id int64) string { + for n, i := range fakeUsers { + if i == id { + return n + } + } + return "" +} + +var fakeDiffFiles = []map[string]any{ + {"old_path": "docs/guide.md", "new_path": "docs/guide.md", "diff": "@@ -1 +1,2 @@\n # Guide\n+more\n"}, + {"old_path": "src/main.go", "new_path": "src/main.go", "diff": "@@ -1,3 +1,4 @@\n package main\n+// hi\n"}, + {"old_path": "src/util.go", "new_path": "src/util.go", "diff": "@@ -1 +1,2 @@\n package main\n+func f() {}\n"}, +} + +func (g *fakeGitLab) fetch(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + g.mu.Lock() + defer g.mu.Unlock() + u, err := url.Parse(rawURL) + if err != nil || u.Host != fakeHost || !strings.HasPrefix(u.EscapedPath(), apiPrefix+"/") { + g.t.Errorf("request outside the allow-list or the API: %s", rawURL) + return nil, fmt.Errorf("blocked") + } + // Segments stay escaped until after the split: "acme%2Fweb" is one segment. + var segs []string + for _, s := range strings.Split(strings.TrimPrefix(u.EscapedPath(), apiPrefix+"/"), "/") { + d, _ := url.PathUnescape(s) + segs = append(segs, d) + } + g.calls = append(g.calls, method+" /"+strings.Join(segs, "/")) + if f, ok := g.forceError[method+" /"+strings.Join(segs, "/")]; ok { + return &plugin.FetchResponse{Status: f.status, Body: f.body, Headers: f.headers}, nil + } + tok := headers["PRIVATE-TOKEN"] + if tok != fakeToken && tok != fakeNewToken && tok != "glpat-readonly-scope" && tok != fakeReadToken { + return resp(401, map[string]string{"message": "401 Unauthorized"}) + } + var payload map[string]any + if body != "" { + _ = json.Unmarshal([]byte(body), &payload) + } + return g.route(method, segs, u.Query(), tok, payload) +} + +func (g *fakeGitLab) route(method string, segs []string, q url.Values, tok string, payload map[string]any) (*plugin.FetchResponse, error) { + switch { + case method == "GET" && len(segs) == 1 && segs[0] == "user": + name := map[string]string{fakeToken: fakeUsername, fakeNewToken: "project_1_bot_ab12", "glpat-readonly-scope": "someone", fakeReadToken: fakeUsername}[tok] + return resp(200, map[string]any{"id": 77, "username": name, "name": "Paca Bot", "bot": strings.Contains(name, "_bot_")}) + case method == "GET" && len(segs) == 2 && segs[0] == "personal_access_tokens" && segs[1] == "self": + if tok == fakeReadToken { + return resp(200, map[string]any{"scopes": []string{"read_repository"}, "expires_at": "2029-01-02", "active": true}) + } + scopes := []string{"api"} + if tok == "glpat-readonly-scope" { + scopes = []string{"read_api"} + } + return resp(200, map[string]any{"scopes": scopes, "expires_at": "2030-06-01", "active": true}) + case method == "GET" && len(segs) == 1 && segs[0] == "users": + name := q.Get("username") + if id, ok := fakeUsers[name]; ok { + return resp(200, []map[string]any{{"id": id, "username": name}}) + } + return resp(200, []any{}) + case method == "GET" && len(segs) == 1 && segs[0] == "projects": + return g.listProjects(q) + case len(segs) >= 2 && segs[0] == "projects": + return g.projectAPI(method, segs[1], segs[2:], q, payload) + } + return notFound() +} + +func (g *fakeGitLab) listProjects(q url.Values) (*plugin.FetchResponse, error) { + var all []map[string]any + paths := []string{} + for _, p := range fakeProjects { + paths = append(paths, p.Path) + } + sort.Strings(paths) + for _, path := range paths { + p := g.project(path) + if s := q.Get("search"); s != "" && !strings.Contains(p.Path, s) { + continue + } + all = append(all, projectJSON(*p)) + } + per, _ := strconv.Atoi(q.Get("per_page")) + pg, _ := strconv.Atoi(q.Get("page")) + if per == 0 { + per = 20 + } + start := min((pg-1)*per, len(all)) + end := min(start+per, len(all)) + return resp(200, all[start:end]) +} + +func pageOf[T any](all []T, q url.Values) []T { + per, _ := strconv.Atoi(q.Get("per_page")) + pg, _ := strconv.Atoi(q.Get("page")) + if per == 0 { + per = 20 + } + if pg == 0 { + pg = 1 + } + start := min((pg-1)*per, len(all)) + end := min(start+per, len(all)) + return all[start:end] +} + +func (g *fakeGitLab) projectAPI(method, ref string, rest []string, q url.Values, payload map[string]any) (*plugin.FetchResponse, error) { + p := g.project(ref) + if p == nil { + return notFound() // a project the token cannot see + } + if p.ID != 1 { + // Only acme/web is seeded in depth; the others answer the project itself. + if len(rest) == 0 && method == "GET" { + return resp(200, projectJSON(*p)) + } + return notFound() + } + if len(rest) == 0 { + return resp(200, projectJSON(*p)) + } + switch rest[0] { + case "members": + if len(rest) == 3 && rest[1] == "all" { + id, _ := strconv.ParseInt(rest[2], 10, 64) + if lvl, ok := fakeAccess[id]; ok { + return resp(200, map[string]any{"id": id, "access_level": lvl}) + } + } + return notFound() + case "repository": + return g.repositoryAPI(method, rest[1:], q, payload) + case "merge_requests": + return g.mrAPI(method, rest[1:], q, payload) + case "issues": + return g.issueAPI(method, rest[1:], payload) + case "pipelines": + return g.pipelineAPI(method, rest[1:], q) + case "jobs": + return g.jobAPI(method, rest[1:]) + case "statuses": + if method == "POST" && len(rest) == 2 { + s := map[string]any{"sha": rest[1]} + for k, v := range payload { + s[k] = v + } + g.statuses = append(g.statuses, s) + return resp(201, s) + } + case "hooks": + return g.hookAPI(method, rest[1:], payload) + case "search": + if q.Get("scope") == "blobs" && strings.Contains("package main", q.Get("search")) { + return resp(200, []map[string]any{{"path": "src/main.go", "startline": 1, "data": "package main"}}) + } + return resp(200, []any{}) + } + return notFound() +} + +func (g *fakeGitLab) repositoryAPI(method string, rest []string, q url.Values, payload map[string]any) (*plugin.FetchResponse, error) { + switch { + case rest[0] == "branches" && method == "GET" && len(rest) == 2: + sha, ok := g.branches[rest[1]] + if !ok { + return resp(404, map[string]string{"message": "404 Branch Not Found"}) + } + return resp(200, map[string]any{"name": rest[1], "protected": rest[1] == "main", "commit": map[string]string{"id": sha}, "web_url": "https://gitlab.com/acme/web/-/tree/" + rest[1]}) + case rest[0] == "branches" && method == "POST": + name, _ := payload["branch"].(string) + ref, _ := payload["ref"].(string) + if _, taken := g.branches[name]; taken { + return resp(400, map[string]string{"message": "Branch already exists"}) + } + var sha string + for _, s := range g.branches { + if s == ref { + sha = s + } + } + if sha == "" { + return resp(400, map[string]string{"message": "Invalid reference name: " + ref}) + } + g.branches[name] = sha + return resp(201, map[string]any{"name": name, "commit": map[string]string{"id": sha}}) + case rest[0] == "files" && method == "GET" && len(rest) == 2: + if rest[1] != "README.md" || q.Get("ref") != "main" { + return resp(404, map[string]string{"message": "404 File Not Found"}) + } + return resp(200, map[string]any{"blob_id": "blob1", "encoding": "base64", "content": "IyBSZWFkbWUK"}) // "# Readme\n" + case rest[0] == "commits" && len(rest) == 3 && rest[2] == "statuses": + return resp(200, []map[string]any{ + {"name": "ci/external", "status": "success", "target_url": "https://ci.example/1", "description": "ok"}, + {"name": "test", "status": "failed", "target_url": "https://gitlab.com/acme/web/-/jobs/5001", "description": ""}, + }) + } + return notFound() +} + +func (g *fakeGitLab) mrAPI(method string, rest []string, q url.Values, payload map[string]any) (*plugin.FetchResponse, error) { + if len(rest) == 0 { + if method != "POST" { + return notFound() + } + src, _ := payload["source_branch"].(string) + if _, ok := g.branches[src]; !ok { + return resp(400, map[string]string{"message": "Source branch does not exist"}) + } + for _, m := range g.mrs { + if m.SourceRef == src && m.State == "opened" { + return resp(409, map[string]string{"message": "Another open merge request already exists for this source branch: !" + strconv.Itoa(m.IID)}) + } + } + m := &fakeMR{IID: g.nextIID, State: "opened", SourceRef: src, SHA: g.branches[src]} + g.nextIID++ + m.Title, _ = payload["title"].(string) + m.Body, _ = payload["description"].(string) + if l, ok := payload["labels"].(string); ok && l != "" { + m.Labels = strings.Split(l, ",") + } + g.mrs[m.IID] = m + return resp(201, m.json(g)) + } + iid, _ := strconv.Atoi(rest[0]) + m, ok := g.mrs[iid] + if !ok { + return resp(404, map[string]string{"message": "404 Merge Request Not Found"}) + } + sub := "" + if len(rest) > 1 { + sub = rest[1] + } + switch { + case sub == "" && method == "GET": + return resp(200, m.json(g)) + case sub == "" && method == "PUT": + if v, ok := payload["title"].(string); ok { + m.Title = v + } + if v, ok := payload["description"].(string); ok { + m.Body = v + } + switch payload["state_event"] { + case "close": + m.State = "closed" + case "reopen": + m.State = "opened" + } + if v, ok := payload["add_labels"].(string); ok { + m.Labels = append(m.Labels, strings.Split(v, ",")...) + } + if v, ok := payload["reviewer_ids"].([]any); ok { + m.Reviewers = nil + for _, id := range v { + m.Reviewers = append(m.Reviewers, int64(id.(float64))) + } + } + return resp(200, m.json(g)) + case sub == "diffs": + return resp(200, pageOf(fakeDiffFiles, q)) + case sub == "merge" && method == "PUT": + if sha, _ := payload["sha"].(string); sha != m.SHA { + return resp(409, map[string]string{"message": "SHA does not match HEAD of source branch"}) + } + m.State = "merged" + out := m.json(g) + out["merge_commit_sha"] = "merge-sha" + return resp(200, out) + case sub == "approve" && method == "POST": + g.approved = append(g.approved, iid) + return resp(201, map[string]any{"id": iid}) + case sub == "discussions" && method == "GET": + return resp(200, g.discussions()) + case sub == "discussions" && method == "POST" && len(rest) == 2: + if _, ok := payload["position"].(map[string]any); !ok { + return resp(400, map[string]string{"message": "position is missing"}) + } + g.nextID++ + return resp(201, map[string]any{"id": "disc-new", "notes": []map[string]any{{"id": g.nextID, "body": payload["body"]}}}) + case sub == "discussions" && len(rest) >= 3: + th, ok := g.threads[rest[2]] + if !ok { + return resp(404, map[string]string{"message": "404 Discussion Not Found"}) + } + if method == "PUT" { + th.Resolved, _ = payload["resolved"].(bool) + return resp(200, map[string]any{"id": th.ID}) + } + th.Replies++ + g.nextID++ + return resp(201, map[string]any{"id": g.nextID, "body": payload["body"]}) + case sub == "notes": + return g.noteAPI("merge_requests", iid, method, q, payload) + } + return notFound() +} + +func (g *fakeGitLab) discussions() []map[string]any { + th := g.threads["disc-1"] + return []map[string]any{ + {"id": "ind-1", "individual_note": true, "notes": []map[string]any{{"id": 8001, "body": "first", "author": map[string]any{"username": "octocat"}}}}, + {"id": th.ID, "individual_note": false, "notes": []map[string]any{{ + "id": 8101, "body": "please rename", "author": map[string]any{"username": "octocat"}, "type": "DiffNote", + "resolvable": true, "resolved": th.Resolved, "position": map[string]any{"new_path": "src/main.go", "new_line": 3}, + }, { + "id": 8102, "body": "added a commit", "system": true, "author": map[string]any{"username": "octocat"}, + }}}, + } +} + +func (g *fakeGitLab) noteAPI(kind string, iid int, method string, q url.Values, payload map[string]any) (*plugin.FetchResponse, error) { + if method == "POST" { + g.nextID++ + g.notes = append(g.notes, fakeNote{ID: g.nextID, IID: iid, Kind: kind, Body: fmt.Sprint(payload["body"])}) + return resp(201, map[string]any{"id": g.nextID, "body": payload["body"]}) + } + var out []map[string]any + for _, n := range g.notes { + if n.IID == iid && n.Kind == kind { + out = append(out, map[string]any{"id": n.ID, "body": n.Body, "author": map[string]any{"username": "octocat"}}) + } + } + // Activity and review notes are returned too; the provider must drop them. + out = append(out, + map[string]any{"id": 9998, "body": "added 1 commit", "system": true, "author": map[string]any{"username": "octocat"}}, + map[string]any{"id": 9999, "body": "inline", "type": "DiffNote", "position": map[string]any{"new_path": "a"}, "author": map[string]any{"username": "octocat"}}) + return resp(200, pageOf(out, q)) +} + +func (g *fakeGitLab) issueAPI(method string, rest []string, payload map[string]any) (*plugin.FetchResponse, error) { + if len(rest) == 0 { + return notFound() + } + iid, _ := strconv.Atoi(rest[0]) + if iid != 7 { + return resp(404, map[string]string{"message": "404 Issue Not Found"}) + } + switch { + case len(rest) == 1 && method == "GET": + return resp(200, map[string]any{"iid": 7, "title": "Bug", "description": "It breaks", "state": "opened", + "web_url": "https://gitlab.com/acme/web/-/issues/7", "author": map[string]any{"username": "reader"}, + "labels": []string{"bug"}, "assignees": []map[string]any{{"username": "bob"}}}) + case len(rest) == 1 && method == "PUT": + return resp(200, map[string]any{"iid": 7}) + case len(rest) == 2 && rest[1] == "notes": + return g.noteAPI("issues", iid, method, nil, payload) + } + return notFound() +} + +func (g *fakeGitLab) pipelineAPI(method string, rest []string, q url.Values) (*plugin.FetchResponse, error) { + switch { + case len(rest) == 0 && method == "GET": + if q.Get("sha") != fakeHeadSHA && q.Get("ref") != "feat/PACA-12-login" { + return resp(200, []any{}) + } + return resp(200, []map[string]any{{"id": 950, "source": "external"}, {"id": 900, "source": "push"}}) + case len(rest) == 2 && rest[1] == "jobs" && rest[0] == "900": + return resp(200, []map[string]any{ + {"id": 5001, "name": "test", "status": "failed", "failure_reason": "script_failure", "web_url": "https://gitlab.com/acme/web/-/jobs/5001"}, + {"id": 5002, "name": "lint", "status": "success", "allow_failure": true, "web_url": "https://gitlab.com/acme/web/-/jobs/5002"}, + }) + case len(rest) == 2 && rest[1] == "retry" && method == "POST": + g.retried = append(g.retried, "pipeline:"+rest[0]) + return resp(201, map[string]any{"id": 900}) + } + return notFound() +} + +func (g *fakeGitLab) jobAPI(method string, rest []string) (*plugin.FetchResponse, error) { + switch { + case len(rest) == 2 && rest[0] == "5001" && rest[1] == "trace": + return text(200, "\x1b[0Ksection_start:1:step\r\x1b[0K\x1b[31;1mFAIL\x1b[0m TestLogin\r\nexpected 1, got 2\n") + case len(rest) == 2 && rest[1] == "retry" && method == "POST": + g.retried = append(g.retried, "job:"+rest[0]) + return resp(201, map[string]any{"id": 5003}) + } + return notFound() +} + +func (g *fakeGitLab) hookAPI(method string, rest []string, payload map[string]any) (*plugin.FetchResponse, error) { + switch { + case len(rest) == 0 && method == "GET": + var out []map[string]any + for id, u := range g.hooks { + out = append(out, map[string]any{"id": id, "url": u}) + } + return resp(200, out) + case len(rest) == 0 && method == "POST": + g.nextHook++ + g.hooks[g.nextHook] = fmt.Sprint(payload["url"]) + g.checkHookBody(payload) + return resp(201, map[string]any{"id": g.nextHook}) + case len(rest) == 1: + id, _ := strconv.ParseInt(rest[0], 10, 64) + if _, ok := g.hooks[id]; !ok { + return notFound() + } + if method == "DELETE" { + delete(g.hooks, id) + return text(204, "") + } + g.checkHookBody(payload) + return resp(200, map[string]any{"id": id}) + } + return notFound() +} + +func (g *fakeGitLab) checkHookBody(p map[string]any) { + if p["token"] != fakeWebhookSecret { + g.t.Errorf("hook registered with the wrong secret token: %v", p["token"]) + } + for _, ev := range []string{"push_events", "merge_requests_events", "note_events", "issues_events", "pipeline_events"} { + if p[ev] != true { + g.t.Errorf("hook does not subscribe to %s", ev) + } + } +} diff --git a/backend/spi_hooks.go b/backend/spi_hooks.go new file mode 100644 index 0000000..1892bde --- /dev/null +++ b/backend/spi_hooks.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "net/url" + "strconv" + "strings" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +const maxHookPages = 5 + +type glHook struct { + ID int64 `json:"id"` + URL string `json:"url"` +} + +// hookBody is the project hook Paca needs: every event class the webhook parser +// understands, signed with the identity's secret as the hook's secret token. +func hookBody(hookURL, secret string) map[string]any { + return map[string]any{ + "url": hookURL, "token": secret, + "push_events": true, "merge_requests_events": true, + "note_events": true, "confidential_note_events": true, + "issues_events": true, "confidential_issues_events": true, + "pipeline_events": true, "enable_ssl_verification": true, + } +} + +// EnsureWebhook implements gp.WebhookManager. It is idempotent: a hook that +// already points at the URL is updated (so its secret and events are right) +// instead of duplicated. Registering a hook needs the Maintainer role; without +// it GitLab answers 403 and the URL and secret have to be pasted by hand. +func (p *glProvider) EnsureWebhook(_ context.Context, req gp.EnsureWebhookRequest) (gp.WebhookRef, error) { + u, err := url.Parse(strings.TrimSpace(req.URL)) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return gp.WebhookRef{}, gp.Errorf(gp.CodeValidation, "url must be an absolute http(s) URL") + } + rec, proj, err := p.identityAndProject(req.Target) + if err != nil { + return gp.WebhookRef{}, err + } + if rec.WebhookSecret == "" { + return gp.WebhookRef{}, gp.Errorf(gp.CodeConflict, "the identity has no webhook secret") + } + api := rec.api() + body := hookBody(u.String(), rec.WebhookSecret) + for pg := 1; pg <= maxHookPages; pg++ { + var hooks []glHook + if err := api.get(paged(proj+"/hooks", pg), &hooks); err != nil { + return gp.WebhookRef{}, err + } + for _, h := range hooks { + if h.URL == u.String() { + if err := api.send("PUT", proj+"/hooks/"+strconv.FormatInt(h.ID, 10), body, nil); err != nil { + return gp.WebhookRef{}, err + } + return gp.WebhookRef{ID: strconv.FormatInt(h.ID, 10)}, nil + } + } + if len(hooks) < listPerPage { + break + } + } + var made glHook + if err := api.send("POST", proj+"/hooks", body, &made); err != nil { + return gp.WebhookRef{}, err + } + if made.ID == 0 { + return gp.WebhookRef{}, gp.Errorf(gp.CodeInternal, "GitLab returned no webhook id") + } + return gp.WebhookRef{ID: strconv.FormatInt(made.ID, 10)}, nil +} + +// RemoveWebhook implements gp.WebhookManager. A hook that is already gone is not +// an error. +func (p *glProvider) RemoveWebhook(_ context.Context, req gp.RemoveWebhookRequest) (gp.Empty, error) { + if !allDigits(req.Webhook.ID) { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "webhook id is required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + if err := api.send("DELETE", proj+"/hooks/"+req.Webhook.ID, nil, nil); err != nil && gp.AsError(err).Code != gp.CodeNotFound { + return gp.Empty{}, err + } + return gp.Empty{}, nil +} diff --git a/backend/spi_ops_test.go b/backend/spi_ops_test.go new file mode 100644 index 0000000..0a77c29 --- /dev/null +++ b/backend/spi_ops_test.go @@ -0,0 +1,410 @@ +package main + +import ( + "strings" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +func TestSetStatus_StatesAndInPlaceUpdates(t *testing.T) { + p, g, _ := seededProvider(t) + subj := gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: "1"} + var ref string + for state, want := range map[gp.RunState]string{ + gp.RunQueued: "pending", gp.RunInProgress: "running", gp.RunSuccess: "success", gp.RunFailure: "failed", gp.RunCancelled: "canceled", + } { + res, err := p.SetStatus(bg, gp.SetStatusRequest{Target: fixtureTarget, Subject: subj, State: state, Title: "Paca / dev", Summary: "s", URL: "https://paca.test/run/1", StatusRef: ref}) + if err != nil { + t.Fatalf("%s: %v", state, err) + } + ref = res.StatusRef + last := g.statuses[len(g.statuses)-1] + if last["state"] != want || last["name"] != "Paca / dev" || last["target_url"] != "https://paca.test/run/1" { + t.Errorf("%s posted %v, want state %s", state, last, want) + } + // Same name, commit and branch: GitLab updates the status in place. + if last["sha"] != fakeHeadSHA || last["ref"] != "feat/PACA-12-login" { + t.Errorf("status not tied to the merge request's head and branch: %v", last) + } + } + // A status needs somewhere to go. + _, err := p.SetStatus(bg, gp.SetStatusRequest{Target: fixtureTarget, State: gp.RunSuccess, Title: "x"}) + code(t, err, gp.CodeValidation) + _, err = p.SetStatus(bg, gp.SetStatusRequest{Target: fixtureTarget, Subject: subj, State: gp.RunSuccess, Title: "x", StatusRef: "garbage"}) + code(t, err, gp.CodeValidation) + _, err = p.SetStatus(bg, gp.SetStatusRequest{Target: fixtureTarget, Subject: subj, State: "exploded", Title: "x"}) + code(t, err, gp.CodeValidation) + long := strings.Repeat("x", 600) + if _, err := p.SetStatus(bg, gp.SetStatusRequest{Target: fixtureTarget, Subject: subj, State: gp.RunSuccess, Title: "t", Summary: long}); err != nil { + t.Fatal(err) + } + if d := g.statuses[len(g.statuses)-1]["description"].(string); len([]rune(d)) > maxStatusDescription { + t.Errorf("description is %d runes; GitLab accepts %d", len([]rune(d)), maxStatusDescription) + } +} + +func TestSubmitReview_Verdicts(t *testing.T) { + p, g, _ := seededProvider(t) + review := func(v gp.Verdict, summary, head string) (gp.ReviewResult, error) { + return p.SubmitReview(bg, gp.SubmitReviewRequest{Target: fixtureTarget, Number: "1", Verdict: v, Summary: summary, HeadSHA: head}) + } + last := func() string { return g.notes[len(g.notes)-1].Body } + + if r, err := review(gp.VerdictApprove, "LGTM", fakeHeadSHA); err != nil || !r.Emulated || r.ID == "" || !strings.Contains(r.URL, "/-/merge_requests/1#note_") { + t.Fatalf("approve: %+v %v", r, err) + } + if len(g.approved) != 1 || last() != "LGTM" { + t.Errorf("approved %v, last note %q: want the summary and one approval", g.approved, last()) + } + // An approval without a summary posts no empty note. + before := len(g.notes) + if _, err := review(gp.VerdictApprove, "", ""); err != nil || len(g.notes) != before || len(g.approved) != 2 { + t.Errorf("silent approval: notes %d→%d approvals %v err %v", before, len(g.notes), g.approved, err) + } + if _, err := review(gp.VerdictRequestChanges, "fix the nil check", ""); err != nil || last() != "**Changes requested**\n\nfix the nil check" || len(g.approved) != 2 { + t.Errorf("request changes: note %q approvals %v err %v", last(), g.approved, err) + } + if _, err := review(gp.VerdictRequestChanges, "", ""); err != nil || last() != "Changes requested." { + t.Errorf("request changes without a summary: note %q err %v", last(), err) + } + // Nothing to say is not a review. + _, err := review(gp.VerdictComment, " ", "") + code(t, err, gp.CodeValidation) + // The reviewer looked at another commit than the merge request now has. + _, err = review(gp.VerdictApprove, "LGTM", "0ld") + code(t, err, gp.CodeConflict) + if len(g.approved) != 2 { + t.Error("a stale approval must not be recorded") + } +} + +func TestSubmitReview_InlineCommentsPinToTheDiff(t *testing.T) { + p, g, _ := seededProvider(t) + res, err := p.SubmitReview(bg, gp.SubmitReviewRequest{Target: fixtureTarget, Number: "1", Verdict: gp.VerdictComment, Summary: "see inline", Comments: []gp.InlineComment{ + {Path: "src/main.go", Line: 2, Body: "new side"}, + {Path: "src/util.go", Line: 1, Side: "old", Body: "old side"}, + }}) + if err != nil || res.Posted != 2 || !res.Emulated { + t.Fatalf("%+v %v", res, err) + } + posts := 0 + for _, c := range g.calls { + if c == "POST /projects/1/merge_requests/1/discussions" { + posts++ + } + } + if posts != 2 { + t.Errorf("%d discussions created, want one per inline comment", posts) + } + // A path outside the diff fails before anything is posted. + n := len(g.calls) + _, err = p.SubmitReview(bg, gp.SubmitReviewRequest{Target: fixtureTarget, Number: "1", Verdict: gp.VerdictComment, Comments: []gp.InlineComment{ + {Path: "src/main.go", Line: 1, Body: "ok"}, {Path: "nope.go", Line: 1, Body: "bad"}, + }}) + code(t, err, gp.CodeValidation) + for _, c := range g.calls[n:] { + if strings.HasPrefix(c, "POST") { + t.Errorf("posted %q although a comment was invalid", c) + } + } + // A line GitLab refuses reports how far it got, with GitLab's code. + g.forceError["POST /projects/1/merge_requests/1/discussions"] = fakeResponse{status: 400, body: `{"message":"line_code can't be blank"}`} + _, err = p.SubmitReview(bg, gp.SubmitReviewRequest{Target: fixtureTarget, Number: "1", Verdict: gp.VerdictComment, Comments: []gp.InlineComment{{Path: "src/main.go", Line: 99, Body: "x"}}}) + code(t, err, gp.CodeValidation) + if !strings.Contains(err.Error(), "posted 0 of 1") { + t.Errorf("error %q should say how many comments were posted", err) + } +} + +func TestOpenAndUpdateChangeRequest(t *testing.T) { + p, g, _ := seededProvider(t) + cr, err := p.OpenChangeRequest(bg, gp.OpenCRRequest{Target: fixtureTarget, Title: "New", Head: "feat/new", Base: "main", Draft: true, Labels: []string{"paca", "bug"}, Reviewers: []string{"reader", "ghost-user"}}) + if err != nil { + t.Fatal(err) + } + if cr.Title != "Draft: New" || !cr.Draft || len(cr.Labels) != 2 || cr.Head.Ref != "feat/new" || cr.Base.Ref != "main" || cr.Head.FromFork { + t.Errorf("%+v", cr) + } + if len(cr.Reviewers) != 0 { + t.Errorf("reviewers %v: an unknown reviewer must not fail the open, and nobody is applied then", cr.Reviewers) + } + cr2, err := p.OpenChangeRequest(bg, gp.OpenCRRequest{Target: fixtureTarget, Title: "Other", Head: "draft-branch", Base: "main", Reviewers: []string{"reader"}}) + if err != nil || len(cr2.Reviewers) != 1 || cr2.Reviewers[0] != "reader" { + t.Errorf("%+v %v", cr2, err) + } + + // Toggling draft rewrites the title prefix and keeps the rest. + off := false + up, err := p.UpdateChangeRequest(bg, gp.UpdateCRRequest{Target: fixtureTarget, Number: cr.Number, Draft: &off}) + if err != nil || up.Draft || up.Title != "New" { + t.Errorf("mark ready: %+v %v", up, err) + } + title := "Renamed" + on := true + up, err = p.UpdateChangeRequest(bg, gp.UpdateCRRequest{Target: fixtureTarget, Number: cr.Number, Title: &title, Draft: &on}) + if err != nil || up.Title != "Draft: Renamed" { + t.Errorf("rename and draft: %+v %v", up, err) + } + closed := gp.CRClosed + up, err = p.UpdateChangeRequest(bg, gp.UpdateCRRequest{Target: fixtureTarget, Number: cr.Number, State: &closed}) + if err != nil || up.State != gp.CRClosed { + t.Errorf("close: %+v %v", up, err) + } + reopened := gp.CROpen + if up, err = p.UpdateChangeRequest(bg, gp.UpdateCRRequest{Target: fixtureTarget, Number: cr.Number, State: &reopened}); err != nil || up.State != gp.CROpen { + t.Errorf("reopen: %+v %v", up, err) + } + empty := " " + _, err = p.UpdateChangeRequest(bg, gp.UpdateCRRequest{Target: fixtureTarget, Number: cr.Number, Title: &empty}) + code(t, err, gp.CodeValidation) + // No changes is a read, not a write. + n := g.called("PUT") + if _, err := p.UpdateChangeRequest(bg, gp.UpdateCRRequest{Target: fixtureTarget, Number: "1"}); err != nil || g.called("PUT") != n { + t.Errorf("an empty update must not write: %v", err) + } +} + +func TestRequestReviewers_KeepsExistingReviewers(t *testing.T) { + p, g, _ := seededProvider(t) + subj := gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: "1"} + for _, who := range []string{"reader", "octocat", "reader"} { + if _, err := p.RequestReviewers(bg, gp.PeopleRequest{Target: fixtureTarget, Subject: subj, Logins: []string{who}}); err != nil { + t.Fatal(err) + } + } + cr, _ := p.GetChangeRequest(bg, gp.CRRequest{Target: fixtureTarget, Number: "1"}) + if strings.Join(cr.Reviewers, ",") != "reader,octocat" { + t.Errorf("reviewers %v: GitLab replaces the list, so earlier reviewers must be carried over once", cr.Reviewers) + } + _, err := p.RequestReviewers(bg, gp.PeopleRequest{Target: fixtureTarget, Subject: subj, Logins: []string{"ghost-user"}}) + code(t, err, gp.CodeValidation) + _, err = p.RequestReviewers(bg, gp.PeopleRequest{Target: fixtureTarget, Subject: gp.SubjectRef{Kind: gp.SubjectIssue, Number: "7"}, Logins: []string{"reader"}}) + code(t, err, gp.CodeValidation) + _ = g +} + +func TestMerge_Methods(t *testing.T) { + p, g, _ := seededProvider(t) + _, err := p.MergeChangeRequest(bg, gp.MergeRequest{Target: fixtureTarget, Number: "1", ExpectedHeadSHA: fakeHeadSHA, Method: "rebase"}) + code(t, err, gp.CodeValidation) + _, err = p.MergeChangeRequest(bg, gp.MergeRequest{Target: fixtureTarget, Number: "1", ExpectedHeadSHA: fakeHeadSHA, Method: "octopus"}) + code(t, err, gp.CodeValidation) + if g.mrs[1].State != "opened" { + t.Fatal("a refused merge must not merge") + } + res, err := p.MergeChangeRequest(bg, gp.MergeRequest{Target: fixtureTarget, Number: "1", ExpectedHeadSHA: fakeHeadSHA}) + if err != nil || res.SHA != "merge-sha" || g.mrs[1].State != "merged" { + t.Errorf("%+v %v", res, err) + } + // A merged request cannot be merged again. + g.forceError["PUT /projects/1/merge_requests/1/merge"] = fakeResponse{status: 405, body: `{"message":"405 Method Not Allowed"}`} + _, err = p.MergeChangeRequest(bg, gp.MergeRequest{Target: fixtureTarget, Number: "1", ExpectedHeadSHA: fakeHeadSHA}) + code(t, err, gp.CodeConflict) +} + +func TestGetChecks(t *testing.T) { + p, g, _ := seededProvider(t) + checks, err := p.GetChecks(bg, gp.ChecksRequest{Target: fixtureTarget, Ref: fakeHeadSHA}) + if err != nil { + t.Fatal(err) + } + byID := map[string]gp.Check{} + for _, c := range checks { + byID[c.ID] = c + } + if len(checks) != 3 { + t.Fatalf("%+v: want two jobs and one external status (the job's own status is the same check)", checks) + } + if c := byID["job:5001"]; c.State != gp.CheckFailure || !c.Required || c.Summary != "script_failure" || c.Name != "test" { + t.Errorf("failed job %+v", c) + } + if c := byID["job:5002"]; c.State != gp.CheckSuccess || c.Required { + t.Errorf("an allow_failure job is not required: %+v", c) + } + if c := byID["status:ci/external"]; c.State != gp.CheckSuccess || c.URL != "https://ci.example/1" { + t.Errorf("external status %+v", c) + } + // The external pipeline (id 950) holds statuses, not jobs: its jobs are never asked for. + if g.called("GET /projects/1/pipelines/950") != 0 { + t.Error("jobs of an external pipeline were requested") + } + // A branch name works as a ref, via the ref filter rather than sha. + if c, err := p.GetChecks(bg, gp.ChecksRequest{Target: fixtureTarget, Ref: "feat/PACA-12-login"}); err != nil || len(c) == 0 { + t.Errorf("by branch: %v %v", c, err) + } + _, err = p.GetChecks(bg, gp.ChecksRequest{Target: fixtureTarget}) + code(t, err, gp.CodeValidation) + if c, err := p.GetChecks(bg, gp.ChecksRequest{Target: fixtureTarget, Ref: strings.Repeat("0", 40)}); err != nil || len(c) != 1 { + t.Errorf("a commit without a pipeline still has its statuses: %v %v", c, err) + } +} + +func TestGetCheckLogs_JobsOnly(t *testing.T) { + p, _, _ := seededProvider(t) + logs, err := p.GetCheckLogs(bg, gp.CheckLogsRequest{Target: fixtureTarget, CheckID: "job:5001"}) + if err != nil || logs.Text != "FAIL TestLogin\nexpected 1, got 2\n" || logs.Truncated { + t.Errorf("%q %v %v: terminal noise should be stripped", logs.Text, logs.Truncated, err) + } + for _, id := range []string{"status:ci/external", "job:", "job:abc", "job:../x", "5001"} { + _, err := p.GetCheckLogs(bg, gp.CheckLogsRequest{Target: fixtureTarget, CheckID: id}) + code(t, err, gp.CodeNotFound) + } + _, err = p.GetCheckLogs(bg, gp.CheckLogsRequest{Target: fixtureTarget, CheckID: "job:7777"}) + code(t, err, gp.CodeNotFound) +} + +func TestRerunChecks(t *testing.T) { + p, g, _ := seededProvider(t) + if _, err := p.RerunChecks(bg, gp.RerunRequest{Target: fixtureTarget, CheckID: "job:5001"}); err != nil { + t.Fatal(err) + } + if _, err := p.RerunChecks(bg, gp.RerunRequest{Target: fixtureTarget, Ref: fakeHeadSHA}); err != nil { + t.Fatal(err) + } + if strings.Join(g.retried, ",") != "job:5001,pipeline:900" { + t.Errorf("retried %v: want the job, then the newest CI pipeline (not the external one)", g.retried) + } + _, err := p.RerunChecks(bg, gp.RerunRequest{Target: fixtureTarget, CheckID: "status:ci/external"}) + code(t, err, gp.CodeNotFound) + _, err = p.RerunChecks(bg, gp.RerunRequest{Target: fixtureTarget, Ref: strings.Repeat("0", 40)}) + code(t, err, gp.CodeNotFound) + _, err = p.RerunChecks(bg, gp.RerunRequest{Target: fixtureTarget}) + code(t, err, gp.CodeValidation) +} + +func TestReads_FiltersAndShapes(t *testing.T) { + p, _, _ := seededProvider(t) + + threads, err := p.ListThreads(bg, gp.PagedCRRequest{Target: fixtureTarget, Number: "1"}) + if err != nil || len(threads.Items) != 1 { + t.Fatalf("%+v %v: plain conversation comments are not threads", threads, err) + } + th := threads.Items[0] + if th.ID != "disc-1" || th.Path != "src/main.go" || th.Line != 3 || th.Resolved || len(th.Comments) != 1 { + t.Errorf("thread %+v: system notes must be left out", th) + } + if c := th.Comments[0]; c.URL != "https://gitlab.com/acme/web/-/merge_requests/1#note_8101" || c.AuthorKind != gp.ActorUser { + t.Errorf("comment %+v", c) + } + + comments, err := p.ListComments(bg, gp.ListCommentsRequest{Target: fixtureTarget, Subject: gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: "1"}}) + if err != nil || len(comments.Items) != 1 || comments.Items[0].Body != "first" { + t.Errorf("%+v %v: activity notes and review-thread notes belong elsewhere", comments, err) + } + _, err = p.ListComments(bg, gp.ListCommentsRequest{Target: fixtureTarget, Subject: gp.SubjectRef{Kind: "commit", Number: "1"}}) + code(t, err, gp.CodeValidation) + + files, err := p.ListChangedFiles(bg, gp.PagedCRRequest{Target: fixtureTarget, Number: "1"}) + if err != nil { + t.Fatal(err) + } + for _, f := range files.Items { + if f.Status != "modified" || f.Additions != 1 || f.Deletions != 0 { + t.Errorf("file %+v: one added line each", f) + } + } + + diff, err := p.GetDiff(bg, gp.DiffRequest{Target: fixtureTarget, Number: "1", Path: "src/main.go"}) + want := "diff --git a/src/main.go b/src/main.go\n--- a/src/main.go\n+++ b/src/main.go\n@@ -1,3 +1,4 @@\n package main\n+// hi\n" + if err != nil || diff.Text != want || diff.Truncated { + t.Errorf("per-file diff %q %v", diff.Text, err) + } + + is, err := p.GetIssue(bg, gp.IssueRequest{Target: fixtureTarget, Number: "7"}) + if err != nil || is.State != "open" || is.Author != "reader" || len(is.Assignees) != 1 || is.Labels[0] != "bug" { + t.Errorf("%+v %v", is, err) + } + + cr, err := p.GetChangeRequest(bg, gp.CRRequest{Target: fixtureTarget, Number: "1"}) + if err != nil || cr.State != gp.CROpen || cr.Head.SHA != fakeHeadSHA || cr.Head.Repo != "acme/web" || cr.Base.Repo != "acme/web" || cr.Head.FromFork { + t.Errorf("%+v %v", cr, err) + } + for _, bad := range []string{"", "x", "-1", "1/../2"} { + _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: fixtureTarget, Number: bad}) + code(t, err, gp.CodeValidation) + } +} + +func TestOptionalOps_ArgumentChecks(t *testing.T) { + p, g, _ := seededProvider(t) + subj := gp.SubjectRef{Kind: gp.SubjectChangeRequest, Number: "1"} + _, err := p.AddLabels(bg, gp.LabelsRequest{Target: fixtureTarget, Subject: subj}) + code(t, err, gp.CodeValidation) + _, err = p.SetAssignees(bg, gp.PeopleRequest{Target: fixtureTarget, Subject: subj, Logins: []string{"ghost-user"}}) + code(t, err, gp.CodeValidation) + if _, err := p.SetAssignees(bg, gp.PeopleRequest{Target: fixtureTarget, Subject: gp.SubjectRef{Kind: gp.SubjectIssue, Number: "7"}}); err != nil { + t.Errorf("clearing the assignees: %v", err) + } + _, err = p.ResolveThread(bg, gp.ResolveThreadRequest{Target: fixtureTarget, Number: "1"}) + code(t, err, gp.CodeValidation) + if _, err := p.GetFile(bg, gp.FileRequest{Target: fixtureTarget, Path: "../etc/passwd"}); err == nil { + t.Error("a path that climbs out must be refused") + } + // The default branch stands in for an empty ref. + if f, err := p.GetFile(bg, gp.FileRequest{Target: fixtureTarget, Path: "README.md"}); err != nil || string(f.Content) != "# Readme\n" || f.SHA != "blob1" { + t.Errorf("%+v %v", f, err) + } + _, err = p.GetFile(bg, gp.FileRequest{Target: gp.Target{Identity: fixtureIdentity, Repo: gp.RepoRef{Host: fakeHost, FullPath: "acme/web", ExternalID: "1"}}, Path: "README.md"}) + code(t, err, gp.CodeValidation) + _, err = p.SearchCode(bg, gp.CodeSearchRequest{Target: fixtureTarget, Query: " "}) + code(t, err, gp.CodeValidation) + _ = g +} + +func TestWebhookManagement_UpdatesInsteadOfDuplicating(t *testing.T) { + p, g, st := seededProvider(t) + req := gp.EnsureWebhookRequest{Target: fixtureTarget, URL: "https://paca.test/api/v1/git/webhooks/abc"} + a, err := p.EnsureWebhook(bg, req) + if err != nil { + t.Fatal(err) + } + b, _ := p.EnsureWebhook(bg, req) + other, _ := p.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: fixtureTarget, URL: "https://paca.test/api/v1/git/webhooks/def"}) + if a.ID != b.ID || other.ID == a.ID || len(g.hooks) != 2 { + t.Errorf("ids %q %q %q, %d hooks: a URL registers once", a.ID, b.ID, other.ID, len(g.hooks)) + } + if g.called("PUT /projects/1/hooks/") != 1 { + t.Error("an existing hook should be refreshed so its secret and events are right") + } + for _, bad := range []string{"", "ftp://x", "not a url", "https://"} { + _, err := p.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: fixtureTarget, URL: bad}) + code(t, err, gp.CodeValidation) + } + _, err = p.RemoveWebhook(bg, gp.RemoveWebhookRequest{Target: fixtureTarget, Webhook: gp.WebhookRef{ID: "x"}}) + code(t, err, gp.CodeValidation) + + // A Developer cannot register hooks: say so, so the URL and secret can be pasted by hand. + g.forceError["POST /projects/1/hooks"] = fakeResponse{status: 403, body: `{"message":"403 Forbidden"}`} + _, err = p.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: fixtureTarget, URL: "https://paca.test/api/v1/git/webhooks/ghi"}) + code(t, err, gp.CodeForbidden) + + rec := st.identities[fakeIdentityID] + rec.WebhookSecret = "" + st.identities[fakeIdentityID] = rec + _, err = p.EnsureWebhook(bg, req) + code(t, err, gp.CodeConflict) +} + +func TestDeleteIdentity_ForgetsTheTokens(t *testing.T) { + p, _, st := seededProvider(t) + if _, err := p.DeleteIdentity(bg, gp.DeleteIdentityRequest{Identity: fixtureIdentity}); err != nil { + t.Fatal(err) + } + if _, err := st.loadIdentity(fakeIdentityID); err != errNoSuchIdentity { + t.Errorf("identity still stored: %v", err) + } + if _, err := p.DeleteIdentity(bg, gp.DeleteIdentityRequest{Identity: fixtureIdentity}); err != nil { + t.Errorf("deleting twice must be a no-op: %v", err) + } + _, err := p.MintCredential(bg, gp.MintRequest{Target: fixtureTarget, Access: gp.AccessRead}) + code(t, err, gp.CodeNotFound) +} + +func TestWebhook_TagRefInAPushEventIsDropped(t *testing.T) { + p, _, _ := seededProvider(t) + evs, err := p.VerifyAndParseWebhook(bg, hookDelivery("d", map[string]any{"object_kind": "push", "ref": "refs/tags/v1", "before": zeroSHA, "after": "abc", "user_username": "octocat", "project": hookRepo})) + if err != nil || len(evs) != 0 { + t.Errorf("%+v %v", evs, err) + } +} diff --git a/backend/spi_provider.go b/backend/spi_provider.go new file mode 100644 index 0000000..5543a09 --- /dev/null +++ b/backend/spi_provider.go @@ -0,0 +1,425 @@ +package main + +import ( + "context" + "encoding/json" + "net/url" + "strconv" + "strings" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// glProvider is the GitLab implementation of the Git provider SPI +// (gitprovider.v1): identities are access tokens pasted into a token form, +// credentials are those stored tokens (GitLab cannot mint narrower ones), +// webhooks are the project hooks users or EnsureWebhook register against the +// endpoint core terminates. See docs/plugins/git-provider-spi.md in the Paca +// repository for the contract this implements. +type glProvider struct { + store spiStore + now func() time.Time +} + +func newGLProvider(store spiStore) *glProvider { + return &glProvider{store: store, now: time.Now} +} + +// Describe implements gp.Provider. Capabilities are declared honestly: +// - credentials are static: core never hands them to an agent and API calls +// are made here, server-side; +// - webhooks are per project (GitLab's group hooks need a paid tier), so +// webhook_management is implemented and users without the rights can paste +// the URL and secret by hand; +// - review is emulated (one discussion per inline comment, then a summary); +// - reactions and access_grants are not implemented: a reaction names only a +// comment id, which GitLab cannot address without its merge request, and a +// token already carries every grant it will ever have. +func (p *glProvider) Describe() gp.Info { + return gp.Info{ + SPIVersion: gp.SPIVersion, + ID: gitlabPluginID, + DisplayName: "GitLab", + Icon: "gitlab", + Hosts: []string{defaultHost}, + SelfHostedHosts: true, + Terms: gp.Terms{ChangeRequest: "Merge request", ChangeRequestPlural: "Merge requests", Repository: "Project"}, + Capabilities: gp.Capabilities{ + IdentityKinds: []gp.IdentityKind{gp.IdentityBotUser, gp.IdentityToken}, + CredentialModel: gp.CredentialStatic, + WebhookScope: gp.WebhookRepo, + StatusKind: gp.StatusCommitStatus, + Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, + MultipleIdentities: true, + + ResolveThreads: true, + FileRead: true, + CodeSearch: true, + Labels: true, + ReviewerRequests: true, + Assignees: true, + Merge: true, + RerunChecks: true, + WebhookManagement: true, + IdentityDeletion: true, + Branches: true, + + BatchReview: false, + HiddenMarkers: true, + ForkChangeRequests: true, + DraftChangeRequests: true, + NestedPaths: true, + CIConfigGlobs: []string{".gitlab-ci.yml", ".gitlab/ci/**"}, + }, + } +} + +// ── identities and API views ───────────────────────────────────────────────── + +func notFoundIdentity() error { + return gp.Errorf(gp.CodeNotFound, "the GitLab identity does not exist") +} + +func (p *glProvider) loadIdentity(identityID string) (identityRecord, error) { + if strings.TrimSpace(identityID) == "" { + return identityRecord{}, notFoundIdentity() + } + rec, err := p.store.loadIdentity(identityID) + if err != nil { + if err == errNoSuchIdentity { + return identityRecord{}, notFoundIdentity() + } + return identityRecord{}, gp.Errorf(gp.CodeInternal, "could not load the GitLab identity") + } + return rec, nil +} + +func (r identityRecord) api() *glAPI { return &glAPI{base: baseURL(r.Host), token: r.Token} } + +// sameHost reports whether a repository's host is the identity's. An empty host +// is accepted: core always sets it, and a bare path resolves on the identity's own. +func (r identityRecord) sameHost(host string) bool { + return host == "" || strings.EqualFold(host, r.Host) +} + +// apiFor returns an API view acting as the target's identity plus the +// repository's "/projects/…" path. +func (p *glProvider) apiFor(t gp.Target) (*glAPI, string, error) { + rec, proj, err := p.identityAndProject(t) + if err != nil { + return nil, "", err + } + return rec.api(), proj, nil +} + +func (p *glProvider) identityAndProject(t gp.Target) (identityRecord, string, error) { + proj, err := projectPath(t.Repo) + if err != nil { + return identityRecord{}, "", err + } + rec, err := p.loadIdentity(t.Identity.ID) + if err != nil { + return identityRecord{}, "", err + } + if !rec.sameHost(t.Repo.Host) { + return identityRecord{}, "", gp.Errorf(gp.CodeValidation, "the repository is on %s but the identity belongs to %s", t.Repo.Host, rec.Host) + } + return rec, proj, nil +} + +// webURL is the browser address of a path inside a project, e.g. "/-/issues/3". +func webURL(host, fullPath, suffix string) string { + return "https://" + host + "/" + strings.Trim(fullPath, "/") + suffix +} + +// ── repositories ───────────────────────────────────────────────────────────── + +type glProject struct { + ID int64 `json:"id"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + Visibility string `json:"visibility"` + Description string `json:"description"` + Permissions struct { + ProjectAccess *struct { + AccessLevel int `json:"access_level"` + } `json:"project_access"` + GroupAccess *struct { + AccessLevel int `json:"access_level"` + } `json:"group_access"` + } `json:"permissions"` +} + +// accessLevel is the identity's effective role on the project, or 0 when +// GitLab did not say. +func (g glProject) accessLevel() int { + level := 0 + if a := g.Permissions.ProjectAccess; a != nil && a.AccessLevel > level { + level = a.AccessLevel + } + if a := g.Permissions.GroupAccess; a != nil && a.AccessLevel > level { + level = a.AccessLevel + } + return level +} + +func repoRefOf(host string, g glProject) gp.RepoRef { + return gp.RepoRef{ + Host: host, FullPath: g.PathWithNamespace, ExternalID: strconv.FormatInt(g.ID, 10), + DefaultBranch: g.DefaultBranch, CloneURL: webURL(host, g.PathWithNamespace, ".git"), + } +} + +func repositoryOf(host string, g glProject) gp.Repository { + return gp.Repository{Ref: repoRefOf(host, g), Private: g.Visibility != "public", Description: g.Description} +} + +// ListRepositories implements gp.Provider: the projects the token is a member +// of. The cursor is the page number, so paging visits each project once. +func (p *glProvider) ListRepositories(_ context.Context, req gp.ListReposRequest) (gp.RepositoryPage, error) { + rec, err := p.loadIdentity(req.Identity.ID) + if err != nil { + return gp.RepositoryPage{}, err + } + per := req.PageSize + if per <= 0 || per > 100 { + per = 30 + } + pg, err := page(req.Cursor) + if err != nil { + return gp.RepositoryPage{}, err + } + q := url.Values{ + "membership": {"true"}, "archived": {"false"}, "simple": {"false"}, + "order_by": {"path"}, "sort": {"asc"}, + "per_page": {strconv.Itoa(per)}, "page": {strconv.Itoa(pg)}, + } + if s := strings.TrimSpace(req.Query); s != "" { + q.Set("search", s) + q.Set("search_namespaces", "true") + } + var batch []glProject + if err := rec.api().get("/projects?"+q.Encode(), &batch); err != nil { + return gp.RepositoryPage{}, err + } + out := gp.RepositoryPage{Items: []gp.Repository{}} + for _, g := range batch { + out.Items = append(out.Items, repositoryOf(rec.Host, g)) + } + if len(batch) >= per { + out.NextCursor = strconv.Itoa(pg + 1) + } + return out, nil +} + +// parseRepoInput accepts group/project, https and ssh URLs, ".git" suffixes and +// merge-request or issue page URLs ("…/-/merge_requests/3"). host is empty for a +// bare path. +func parseRepoInput(in string) (host, fullPath string, err error) { + s := strings.TrimSpace(in) + if s == "" { + return "", "", gp.Errorf(gp.CodeValidation, "a repository is required") + } + switch { + case strings.Contains(s, "://"): + u, perr := url.Parse(s) + if perr != nil || u.Hostname() == "" { + return "", "", gp.Errorf(gp.CodeValidation, "not a valid repository URL") + } + host, s = strings.ToLower(u.Host), u.Path + case strings.HasPrefix(s, "git@"): + h, path, ok := strings.Cut(strings.TrimPrefix(s, "git@"), ":") + if !ok { + return "", "", gp.Errorf(gp.CodeValidation, "not a valid repository address") + } + host, s = strings.ToLower(h), path + } + // A web page of the project ends at "/-/…". + s, _, _ = strings.Cut(s, "/-/") + s = strings.TrimSuffix(strings.Trim(s, "/"), ".git") + if _, _, ok := splitFullPath(s); !ok { + return "", "", gp.Errorf(gp.CodeValidation, "repository must be group/project or a GitLab URL") + } + return host, s, nil +} + +// ResolveRepository implements gp.Provider: the first candidate identity that +// can see the project wins. +func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequest) (gp.ResolvedRepository, error) { + urlHost, fullPath, err := parseRepoInput(req.PathOrURL) + if err != nil { + return gp.ResolvedRepository{}, err + } + if urlHost != "" && req.Host != "" && !strings.EqualFold(urlHost, req.Host) { + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeValidation, "the URL points at %s, not %s", urlHost, req.Host) + } + want := urlHost + if want == "" { + want = strings.ToLower(req.Host) + } + if len(req.Candidates) == 0 { + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeForbidden, "no GitLab identity is available to look this repository up") + } + var last error + for _, c := range req.Candidates { + rec, err := p.loadIdentity(c.ID) + if err != nil { + last = err + continue + } + if want != "" && !strings.EqualFold(rec.Host, want) { + continue // another host's identity cannot see this project + } + var g glProject + if err := rec.api().get("/projects/"+pathPart(fullPath), &g); err != nil { + if code := gp.AsError(err).Code; code == gp.CodeNotFound || code == gp.CodeForbidden { + last = err + continue + } + return gp.ResolvedRepository{}, err + } + // A public project answers any token, so being able to read it proves + // nothing about being a member; a credential is still minted for it. + return gp.ResolvedRepository{ + Repository: repositoryOf(rec.Host, g), + Identity: gp.IdentityRef{ID: rec.ID, Handle: rec.Username}, + }, nil + } + if last != nil && gp.AsError(last).Code == gp.CodeForbidden { + return gp.ResolvedRepository{}, last + } + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeNotFound, "no identity can see %s", fullPath) +} + +// ── credentials and policy inputs ──────────────────────────────────────────── + +const developerLevel = 30 + +// MintCredential implements gp.Provider. GitLab cannot mint narrower tokens, so +// the credential is the stored token: the read-only one for read access when +// the identity has one. The repository is checked through the API first, so an +// identity that cannot see it, or cannot push to it, gets nothing. +func (p *glProvider) MintCredential(_ context.Context, req gp.MintRequest) (gp.Credential, error) { + if req.Access != gp.AccessRead && req.Access != gp.AccessWrite { + return gp.Credential{}, gp.Errorf(gp.CodeValidation, "access must be read or write") + } + rec, proj, err := p.identityAndProject(req.Target) + if err != nil { + return gp.Credential{}, err + } + var g glProject + if err := rec.api().get(proj, &g); err != nil { + return gp.Credential{}, err + } + if lvl := g.accessLevel(); req.Access == gp.AccessWrite && lvl > 0 && lvl < developerLevel { + return gp.Credential{}, gp.Errorf(gp.CodeForbidden, "the identity may not push to this repository") + } + secret := rec.Token + if req.Access == gp.AccessRead && rec.ReadToken != "" { + secret = rec.ReadToken + } + return gp.Credential{Username: credUsername, Secret: secret, ExpiresAt: rec.ExpiresAt}, nil +} + +// lookupUserID resolves a user name to its GitLab id; ok is false for a user +// GitLab does not know. +func (a *glAPI) lookupUserID(username string) (id int64, ok bool, err error) { + var users []struct { + ID int64 `json:"id"` + Username string `json:"username"` + } + if err := a.get("/users?username="+url.QueryEscape(username), &users); err != nil { + return 0, false, err + } + for _, u := range users { + if strings.EqualFold(u.Username, username) { + return u.ID, true, nil + } + } + return 0, false, nil +} + +// permissionOf maps GitLab's numeric roles onto the SPI's neutral levels. +func permissionOf(accessLevel int) gp.Permission { + switch { + case accessLevel >= 50: + return gp.PermissionAdmin // Owner + case accessLevel >= 40: + return gp.PermissionMaintain + case accessLevel >= 30: + return gp.PermissionWrite // Developer + case accessLevel >= 20: + return gp.PermissionTriage // Reporter + case accessLevel >= 10: + return gp.PermissionRead // Guest + } + return gp.PermissionNone // no access, or minimal access (cannot see the code) +} + +// GetActorPermission implements gp.Provider. A login GitLab does not know, or +// that is not a member, has no access; that is an answer, not an error. +func (p *glProvider) GetActorPermission(_ context.Context, req gp.ActorPermissionRequest) (gp.Permission, error) { + login := strings.TrimSpace(req.Login) + if login == "" { + return gp.PermissionNone, gp.Errorf(gp.CodeValidation, "login is required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.PermissionNone, err + } + id, ok, err := api.lookupUserID(login) + if err != nil { + return gp.PermissionNone, err + } + if !ok { + return gp.PermissionNone, nil + } + var m struct { + AccessLevel int `json:"access_level"` + } + if err := api.get(proj+"/members/all/"+strconv.FormatInt(id, 10), &m); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + return gp.PermissionNone, nil + } + return gp.PermissionNone, err + } + return permissionOf(m.AccessLevel), nil +} + +// GetBranchProtection implements gp.Provider using the branch endpoint, which +// needs only read access (the protected-branches endpoint needs Maintainer). +func (p *glProvider) GetBranchProtection(_ context.Context, req gp.BranchProtectionRequest) (gp.BranchProtection, error) { + if strings.TrimSpace(req.Branch) == "" { + return gp.BranchProtection{}, gp.Errorf(gp.CodeValidation, "branch is required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.BranchProtection{}, err + } + var out struct { + Protected bool `json:"protected"` + } + if err := api.get(proj+"/repository/branches/"+pathPart(req.Branch), &out); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + return gp.BranchProtection{}, nil // a branch that does not exist is not protected + } + return gp.BranchProtection{}, err + } + return gp.BranchProtection{Protected: out.Protected}, nil +} + +// loginKind tells people from bots by user name: GitLab's access-token bots +// have a recognisable one. +func loginKind(username string) gp.ActorKind { + if accessTokenBotRe.MatchString(username) { + return gp.ActorBot + } + return gp.ActorUser +} + +// providerRef encodes a provider-private value for an IdentityRef or RepoRef. +func providerRef(v any) json.RawMessage { + b, _ := json.Marshal(v) + return b +} diff --git a/backend/spi_reads.go b/backend/spi_reads.go new file mode 100644 index 0000000..547cae0 --- /dev/null +++ b/backend/spi_reads.go @@ -0,0 +1,703 @@ +package main + +import ( + "context" + "encoding/base64" + "net/url" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +const ( + listPerPage = 100 + maxCheckPages = 5 + maxDiffPages = 20 + defaultLogBytes = 256 * 1024 +) + +// glRef is the part of a user GitLab embeds in other objects. +type glRef struct { + ID int64 `json:"id"` + Username string `json:"username"` +} + +type glMR struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` // opened | closed | merged | locked + Draft bool `json:"draft"` + WorkInProgress bool `json:"work_in_progress"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + SHA string `json:"sha"` + MergeCommitSHA string `json:"merge_commit_sha"` + SquashCommitSHA string `json:"squash_commit_sha"` + WebURL string `json:"web_url"` + Author glRef `json:"author"` + Labels []string `json:"labels"` + Reviewers []glRef `json:"reviewers"` + UpdatedAt time.Time `json:"updated_at"` + SourceProjectID int64 `json:"source_project_id"` + TargetProjectID int64 `json:"target_project_id"` + DiffRefs struct { + BaseSHA string `json:"base_sha"` + HeadSHA string `json:"head_sha"` + StartSHA string `json:"start_sha"` + } `json:"diff_refs"` +} + +func (m glMR) isDraft() bool { return m.Draft || m.WorkInProgress } + +// headSHA is the commit the merge request currently points at. +func (m glMR) headSHA() string { + if m.DiffRefs.HeadSHA != "" { + return m.DiffRefs.HeadSHA + } + return m.SHA +} + +// toCR maps a merge request onto the neutral shape. sourcePath is the full path +// of the source project (the target's own unless the request comes from a fork). +func (m glMR) toCR(targetPath, sourcePath string) gp.ChangeRequest { + cr := gp.ChangeRequest{ + Number: strconv.Itoa(m.IID), Title: m.Title, Body: m.Description, Draft: m.isDraft(), + Author: m.Author.Username, URL: m.WebURL, UpdatedAt: m.UpdatedAt, Labels: m.Labels, + Head: gp.Branch{Ref: m.SourceBranch, SHA: m.headSHA(), Repo: sourcePath, FromFork: m.SourceProjectID != m.TargetProjectID}, + Base: gp.Branch{Ref: m.TargetBranch, SHA: m.DiffRefs.StartSHA, Repo: targetPath}, + } + switch strings.ToLower(m.State) { + case "merged": + cr.State = gp.CRMerged + case "closed": + cr.State = gp.CRClosed + default: // opened, locked (a merge in flight) + cr.State = gp.CROpen + } + for _, r := range m.Reviewers { + cr.Reviewers = append(cr.Reviewers, r.Username) + } + return cr +} + +// number validates a change-request or issue number before it goes into a URL. +func number(n string) (string, error) { + if _, err := strconv.ParseUint(n, 10, 32); err != nil { + return "", gp.Errorf(gp.CodeValidation, "%q is not a valid number", n) + } + return n, nil +} + +func page(cursor string) (int, error) { + if cursor == "" { + return 1, nil + } + n, err := strconv.Atoi(cursor) + if err != nil || n < 1 { + return 0, gp.Errorf(gp.CodeValidation, "invalid cursor") + } + return n, nil +} + +func nextPage(got, cur int) string { + if got >= listPerPage { + return strconv.Itoa(cur + 1) + } + return "" +} + +func paged(path string, pg int) string { + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + return path + sep + "per_page=" + strconv.Itoa(listPerPage) + "&page=" + strconv.Itoa(pg) +} + +// cutHead keeps at most max bytes from the start, never splitting a rune. +func cutHead(s string, max int) (string, bool) { + if max <= 0 || len(s) <= max { + return s, false + } + for max > 0 && !utf8.RuneStart(s[max]) { + max-- + } + return s[:max], true +} + +// cutTail keeps at most max bytes from the end, never splitting a rune. +func cutTail(s string, max int) (string, bool) { + if max <= 0 || len(s) <= max { + return s, false + } + i := len(s) - max + for i < len(s) && !utf8.RuneStart(s[i]) { + i++ + } + return s[i:], true +} + +func (p *glProvider) getMR(api *glAPI, proj, num string) (glMR, error) { + var m glMR + err := api.get(proj+"/merge_requests/"+num, &m) + return m, err +} + +// sourcePathOf names the project a merge request comes from: the target itself, +// or, for a fork, the fork (looked up; empty when it was deleted). +func sourcePathOf(api *glAPI, m glMR, targetPath string) string { + if m.SourceProjectID == m.TargetProjectID || m.SourceProjectID == 0 { + return targetPath + } + var g glProject + if err := api.get("/projects/"+strconv.FormatInt(m.SourceProjectID, 10), &g); err != nil { + return "" + } + return g.PathWithNamespace +} + +// GetChangeRequest implements gp.Provider. +func (p *glProvider) GetChangeRequest(_ context.Context, req gp.CRRequest) (gp.ChangeRequest, error) { + num, err := number(req.Number) + if err != nil { + return gp.ChangeRequest{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangeRequest{}, err + } + m, err := p.getMR(api, proj, num) + if err != nil { + return gp.ChangeRequest{}, err + } + return m.toCR(req.Target.Repo.FullPath, sourcePathOf(api, m, req.Target.Repo.FullPath)), nil +} + +// glDiffFile is one entry of GET …/merge_requests/:iid/diffs. +type glDiffFile struct { + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` + Diff string `json:"diff"` + NewFile bool `json:"new_file"` + RenamedFile bool `json:"renamed_file"` + DeletedFile bool `json:"deleted_file"` + TooLarge bool `json:"too_large"` +} + +func (f glDiffFile) status() string { + switch { + case f.NewFile: + return "added" + case f.DeletedFile: + return "removed" + case f.RenamedFile: + return "renamed" + } + return "modified" +} + +// counts returns the added and removed lines of the file's hunks. +func (f glDiffFile) counts() (add, del int) { + for _, l := range strings.Split(f.Diff, "\n") { + switch { + case strings.HasPrefix(l, "+"): + add++ + case strings.HasPrefix(l, "-"): + del++ + } + } + return add, del +} + +// unified renders the file as the git-style diff text agents expect: GitLab +// returns only the hunks. +func (f glDiffFile) unified() string { + oldP, newP := "a/"+f.OldPath, "b/"+f.NewPath + var b strings.Builder + b.WriteString("diff --git " + oldP + " " + newP + "\n") + if f.NewFile { + oldP = "/dev/null" + b.WriteString("new file mode 100644\n") + } + if f.DeletedFile { + newP = "/dev/null" + b.WriteString("deleted file mode 100644\n") + } + switch { + case f.TooLarge: + b.WriteString("# diff too large to display\n") + case strings.TrimSpace(f.Diff) == "": + b.WriteString("# no textual changes (binary file, mode change or rename)\n") + default: + b.WriteString("--- " + oldP + "\n+++ " + newP + "\n" + f.Diff) + if !strings.HasSuffix(f.Diff, "\n") { + b.WriteString("\n") + } + } + return b.String() +} + +func (p *glProvider) diffPage(api *glAPI, proj, num string, pg int) ([]glDiffFile, error) { + var files []glDiffFile + err := api.get(paged(proj+"/merge_requests/"+num+"/diffs", pg), &files) + return files, err +} + +// ListChangedFiles implements gp.Provider. +func (p *glProvider) ListChangedFiles(_ context.Context, req gp.PagedCRRequest) (gp.ChangedFilePage, error) { + num, err := number(req.Number) + if err != nil { + return gp.ChangedFilePage{}, err + } + pg, err := page(req.Cursor) + if err != nil { + return gp.ChangedFilePage{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangedFilePage{}, err + } + files, err := p.diffPage(api, proj, num, pg) + if err != nil { + return gp.ChangedFilePage{}, err + } + out := gp.ChangedFilePage{Items: []gp.ChangedFile{}, NextCursor: nextPage(len(files), pg)} + for _, f := range files { + add, del := f.counts() + cf := gp.ChangedFile{Path: f.NewPath, Status: f.status(), Additions: add, Deletions: del} + if f.RenamedFile { + cf.OldPath = f.OldPath + } + if f.DeletedFile { + cf.Path = f.OldPath + } + out.Items = append(out.Items, cf) + } + return out, nil +} + +// GetDiff implements gp.Provider. A per-file diff is cut out of the pages; a +// diff longer than MaxBytes is cut and flagged, never silently. +func (p *glProvider) GetDiff(_ context.Context, req gp.DiffRequest) (gp.Diff, error) { + num, err := number(req.Number) + if err != nil { + return gp.Diff{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Diff{}, err + } + var b strings.Builder + found, more := false, false + for pg := 1; pg <= maxDiffPages; pg++ { + files, err := p.diffPage(api, proj, num, pg) + if err != nil { + return gp.Diff{}, err + } + for _, f := range files { + if req.Path != "" && f.NewPath != req.Path && f.OldPath != req.Path { + continue + } + found = true + b.WriteString(f.unified()) + } + more = len(files) >= listPerPage + if !more || (req.MaxBytes > 0 && b.Len() > req.MaxBytes) { + break + } + } + if req.Path != "" && !found { + return gp.Diff{}, gp.Errorf(gp.CodeNotFound, "%s is not part of this change", req.Path) + } + cut, truncated := cutHead(b.String(), req.MaxBytes) + // Stopping at the page cap with more files behind it is a cut too. + return gp.Diff{Text: cut, Truncated: truncated || (more && req.Path == "")}, nil +} + +type glPosition struct { + NewPath string `json:"new_path"` + OldPath string `json:"old_path"` + NewLine int `json:"new_line"` + OldLine int `json:"old_line"` +} + +type glNote struct { + ID int64 `json:"id"` + Body string `json:"body"` + Author glRef `json:"author"` + CreatedAt time.Time `json:"created_at"` + System bool `json:"system"` + Resolvable bool `json:"resolvable"` + Resolved bool `json:"resolved"` + Type *string `json:"type"` + Position *glPosition `json:"position"` +} + +func (n glNote) isDiffNote() bool { + return n.Position != nil || (n.Type != nil && *n.Type == "DiffNote") +} + +func (n glNote) toComment(webBase string) gp.Comment { + return gp.Comment{ + ID: strconv.FormatInt(n.ID, 10), Author: n.Author.Username, AuthorKind: loginKind(n.Author.Username), + Body: n.Body, URL: webBase + "#note_" + strconv.FormatInt(n.ID, 10), CreatedAt: n.CreatedAt, + } +} + +type glDiscussion struct { + ID string `json:"id"` + IndividualNote bool `json:"individual_note"` + Notes []glNote `json:"notes"` +} + +// ListThreads implements gp.Provider: the review discussions of a merge request. +// Plain conversation comments (individual notes) come from ListComments. +func (p *glProvider) ListThreads(_ context.Context, req gp.PagedCRRequest) (gp.ThreadPage, error) { + num, err := number(req.Number) + if err != nil { + return gp.ThreadPage{}, err + } + pg, err := page(req.Cursor) + if err != nil { + return gp.ThreadPage{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.ThreadPage{}, err + } + var ds []glDiscussion + if err := api.get(paged(proj+"/merge_requests/"+num+"/discussions", pg), &ds); err != nil { + return gp.ThreadPage{}, err + } + webBase := webURL(hostOf(req.Target), req.Target.Repo.FullPath, "/-/merge_requests/"+num) + out := gp.ThreadPage{Items: []gp.Thread{}, NextCursor: nextPage(len(ds), pg)} + for _, d := range ds { + if d.IndividualNote { + continue + } + th := gp.Thread{ID: d.ID} + resolvable, resolved := false, true + for _, n := range d.Notes { + if n.System { + continue + } + if th.Path == "" && n.Position != nil { + th.Path, th.Line = n.Position.NewPath, n.Position.NewLine + if th.Path == "" { + th.Path, th.Line = n.Position.OldPath, n.Position.OldLine + } + } + if n.Resolvable { + resolvable = true + resolved = resolved && n.Resolved + } + th.Comments = append(th.Comments, n.toComment(webBase)) + } + th.Resolved = resolvable && resolved + if len(th.Comments) > 0 { + out.Items = append(out.Items, th) + } + } + return out, nil +} + +// hostOf is the host a target's repository lives on. +func hostOf(t gp.Target) string { + if t.Repo.Host != "" { + return t.Repo.Host + } + return defaultHost +} + +// subjectPath is the REST segment and web segment of an issue or merge request. +func subjectPath(k gp.SubjectKind) (api, web string, err error) { + switch k { + case gp.SubjectChangeRequest: + return "merge_requests", "merge_requests", nil + case gp.SubjectIssue: + return "issues", "issues", nil + } + return "", "", gp.Errorf(gp.CodeValidation, "subject kind must be issue or change_request") +} + +// ListComments implements gp.Provider: the conversation comments of an issue or +// change request. Activity notes ("added 1 commit") and review-thread comments +// are left out; the latter come from ListThreads. +func (p *glProvider) ListComments(_ context.Context, req gp.ListCommentsRequest) (gp.CommentPage, error) { + num, err := number(req.Subject.Number) + if err != nil { + return gp.CommentPage{}, err + } + seg, web, err := subjectPath(req.Subject.Kind) + if err != nil { + return gp.CommentPage{}, err + } + pg, err := page(req.Cursor) + if err != nil { + return gp.CommentPage{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.CommentPage{}, err + } + var ns []glNote + if err := api.get(paged(proj+"/"+seg+"/"+num+"/notes?sort=asc&order_by=created_at", pg), &ns); err != nil { + return gp.CommentPage{}, err + } + webBase := webURL(hostOf(req.Target), req.Target.Repo.FullPath, "/-/"+web+"/"+num) + out := gp.CommentPage{Items: []gp.Comment{}, NextCursor: nextPage(len(ns), pg)} + for _, n := range ns { + if n.System || n.isDiffNote() { + continue + } + out.Items = append(out.Items, n.toComment(webBase)) + } + return out, nil +} + +// GetIssue implements gp.Provider. +func (p *glProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, error) { + num, err := number(req.Number) + if err != nil { + return gp.Issue{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Issue{}, err + } + var is struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + WebURL string `json:"web_url"` + Author glRef `json:"author"` + Labels []string `json:"labels"` + Assignees []glRef `json:"assignees"` + } + if err := api.get(proj+"/issues/"+num, &is); err != nil { + return gp.Issue{}, err + } + state := "open" + if is.State == "closed" { + state = "closed" + } + out := gp.Issue{Number: strconv.Itoa(is.IID), Title: is.Title, Body: is.Description, State: state, Author: is.Author.Username, URL: is.WebURL, Labels: is.Labels} + for _, a := range is.Assignees { + out.Assignees = append(out.Assignees, a.Username) + } + return out, nil +} + +func jobStateOf(status string) gp.CheckState { + switch status { + case "success": + return gp.CheckSuccess + case "failed": + return gp.CheckFailure + case "canceled", "canceling": + return gp.CheckCancelled + case "skipped", "manual": + return gp.CheckSkipped + } + return gp.CheckPending // created, pending, running, scheduled, waiting_for_resource, preparing +} + +var fullSHARe = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// ciPipeline finds the newest CI pipeline of a ref (a commit sha or a branch). +// Pipelines of source "external" are the statuses other systems (and Paca) +// post; they carry no jobs, so they are read as statuses instead. +func ciPipeline(api *glAPI, proj, ref string) (id int64, found bool, err error) { + filter := "ref=" + url.QueryEscape(ref) + if fullSHARe.MatchString(strings.ToLower(ref)) { + filter = "sha=" + url.QueryEscape(ref) + } + var ps []struct { + ID int64 `json:"id"` + Source string `json:"source"` + } + if err := api.get(proj+"/pipelines?order_by=id&sort=desc&per_page=20&"+filter, &ps); err != nil { + return 0, false, err + } + for _, p := range ps { + if p.Source != "external" { + return p.ID, true, nil + } + } + return 0, false, nil +} + +// GetChecks implements gp.Provider: the jobs of the newest pipeline plus commit +// statuses other systems posted. Ids are "job:" and "status:". +func (p *glProvider) GetChecks(_ context.Context, req gp.ChecksRequest) ([]gp.Check, error) { + if strings.TrimSpace(req.Ref) == "" { + return nil, gp.Errorf(gp.CodeValidation, "ref is required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return nil, err + } + out := []gp.Check{} + pid, found, err := ciPipeline(api, proj, req.Ref) + if err != nil { + return nil, err + } + if found { + for pg := 1; pg <= maxCheckPages; pg++ { + var jobs []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + AllowFailure bool `json:"allow_failure"` + WebURL string `json:"web_url"` + FailureReason string `json:"failure_reason"` + } + if err := api.get(paged(proj+"/pipelines/"+strconv.FormatInt(pid, 10)+"/jobs", pg), &jobs); err != nil { + return nil, err + } + for _, j := range jobs { + c := gp.Check{ID: "job:" + strconv.FormatInt(j.ID, 10), Name: j.Name, State: jobStateOf(j.Status), Required: !j.AllowFailure, URL: j.WebURL} + if c.State == gp.CheckFailure { + c.Summary = j.FailureReason + } + out = append(out, c) + } + if len(jobs) < listPerPage { + break + } + } + } + var statuses []struct { + Name string `json:"name"` + Status string `json:"status"` + TargetURL string `json:"target_url"` + Description string `json:"description"` + AllowFailure bool `json:"allow_failure"` + } + if err := api.get(paged(proj+"/repository/commits/"+pathPart(req.Ref)+"/statuses", 1), &statuses); err == nil { + for _, s := range statuses { + if strings.Contains(s.TargetURL, "/-/jobs/") { + continue // a pipeline job, already listed above + } + out = append(out, gp.Check{ID: "status:" + s.Name, Name: s.Name, State: jobStateOf(s.Status), Required: !s.AllowFailure, URL: s.TargetURL, Summary: s.Description}) + } + } else if gp.AsError(err).Code != gp.CodeNotFound { + return nil, err + } + return out, nil +} + +var ( + ansiRe = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]") + sectionRe = regexp.MustCompile(`section_(?:start|end):[0-9]+:[A-Za-z0-9_.\-\[\],=]*`) +) + +// cleanTrace removes the terminal control sequences and section markers GitLab +// leaves in a job log, which are noise to a reader. +func cleanTrace(s string) string { + s = ansiRe.ReplaceAllString(s, "") + s = sectionRe.ReplaceAllString(s, "") + return strings.ReplaceAll(s, "\r", "") +} + +// GetCheckLogs implements gp.Provider: the trace of a pipeline job. +func (p *glProvider) GetCheckLogs(_ context.Context, req gp.CheckLogsRequest) (gp.Logs, error) { + id, ok := strings.CutPrefix(req.CheckID, "job:") + if !ok || !allDigits(id) { + return gp.Logs{}, gp.Errorf(gp.CodeNotFound, "no logs for check %q", clip(req.CheckID)) + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Logs{}, err + } + text, err := api.do("GET", proj+"/jobs/"+id+"/trace", nil) + if err != nil { + return gp.Logs{}, err + } + text = cleanTrace(text) + if strings.TrimSpace(text) == "" { + return gp.Logs{}, gp.Errorf(gp.CodeNotFound, "this job produced no log output") + } + limit := req.TailBytes + if limit <= 0 { + limit = defaultLogBytes + } + cut, truncated := cutTail(text, limit) + return gp.Logs{Text: cut, Truncated: truncated}, nil +} + +// GetFile implements gp.FileReader. +func (p *glProvider) GetFile(_ context.Context, req gp.FileRequest) (gp.File, error) { + path := strings.Trim(req.Path, "/") + if path == "" || strings.Contains(path, "..") { + return gp.File{}, gp.Errorf(gp.CodeValidation, "a file path is required") + } + ref := strings.TrimSpace(req.Ref) + if ref == "" { + ref = req.Target.Repo.DefaultBranch + } + if ref == "" { + return gp.File{}, gp.Errorf(gp.CodeValidation, "a ref is required: the repository has no known default branch") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.File{}, err + } + var f struct { + BlobID string `json:"blob_id"` + Encoding string `json:"encoding"` + Content string `json:"content"` + } + if err := api.get(proj+"/repository/files/"+pathPart(path)+"?ref="+url.QueryEscape(ref), &f); err != nil { + return gp.File{}, err + } + content := []byte(f.Content) + if f.Encoding == "base64" { + b, err := base64.StdEncoding.DecodeString(strings.NewReplacer("\n", "", "\r", "").Replace(f.Content)) + if err != nil { + return gp.File{}, gp.Errorf(gp.CodeInternal, "GitLab returned an unreadable file") + } + content = b + } + out := gp.File{Path: path, SHA: f.BlobID, Content: content} + if req.MaxBytes > 0 && len(content) > req.MaxBytes { + out.Content, out.Truncated = content[:req.MaxBytes], true + } + return out, nil +} + +// SearchCode implements gp.CodeSearcher with GitLab's project blob search. +func (p *glProvider) SearchCode(_ context.Context, req gp.CodeSearchRequest) (gp.CodeHits, error) { + q := strings.TrimSpace(req.Query) + if q == "" { + return gp.CodeHits{}, gp.Errorf(gp.CodeValidation, "a query is required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.CodeHits{}, err + } + var hits []struct { + Path string `json:"path"` + Filename string `json:"filename"` + StartLine int `json:"startline"` + Data string `json:"data"` + } + if err := api.get(proj+"/search?scope=blobs&per_page=20&search="+url.QueryEscape(q), &hits); err != nil { + return gp.CodeHits{}, err + } + out := gp.CodeHits{Items: []gp.CodeHit{}} + for _, h := range hits { + path := h.Path + if path == "" { + path = h.Filename + } + out.Items = append(out.Items, gp.CodeHit{Path: path, Line: h.StartLine, Snippet: clip(h.Data)}) + } + return out, nil +} diff --git a/backend/spi_setup.go b/backend/spi_setup.go new file mode 100644 index 0000000..3f4d407 --- /dev/null +++ b/backend/spi_setup.go @@ -0,0 +1,211 @@ +package main + +import ( + "context" + "strings" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// Identity setup is a token form: Begin hands core the inputs to ask for, the +// user pastes a GitLab access token (and picks a webhook secret), Complete +// validates the token against the instance and stores it. Nothing is created on +// GitLab and no browser round trip is needed. +// +// A project or group access token creates a bot user, which is the recommended +// identity: it appears in GitLab as its own account, can be mentioned, and is +// limited to the project or group it was issued for. + +const ( + fieldToken = "token" + fieldReadToken = "read_token" + fieldWebhookSecret = "webhook_secret" +) + +// minWebhookSecretLen keeps a guessable secret from guarding the webhook +// endpoint: anyone who knows the secret can forge a delivery. +const minWebhookSecretLen = 16 + +// BeginIdentitySetup implements gp.Provider. +func (p *glProvider) BeginIdentitySetup(_ context.Context, req gp.BeginSetupRequest) (gp.SetupStep, error) { + host, ok := normalizeHost(req.Host) + if !ok { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "unknown host %q: expected a GitLab host name such as %s", req.Host, defaultHost) + } + if req.Scope != gp.SetupShared && req.Scope != gp.SetupAgent { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "scope must be shared or agent") + } + if req.WebhookURL == "" { + return gp.SetupStep{}, gp.Errorf(gp.CodeValidation, "webhook_url is required") + } + state, err := randomHex(24) + if err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not generate state") + } + if err := p.store.saveSetup(setupSession{ + State: state, Scope: string(req.Scope), AgentID: req.AgentID, Host: host, ExpiresAt: p.now().Add(setupSessionLife), + }); err != nil { + return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not start setup") + } + + prompt := "Create a project or group access token on " + host + " with the api scope and the Developer role " + + "(a bot user is created for it), and paste it below. " + + "Then add a webhook to each project you link: URL " + req.WebhookURL + ", the secret token you choose below, " + + "and the triggers Push, Comments, Issues, Merge request and Pipeline events." + return gp.SetupStep{ + Kind: gp.StepTokenForm, + Prompt: prompt, + State: state, + Form: []gp.FormField{ + {Name: fieldToken, Label: "Access token", Secret: true, Required: true, + Help: "Scope: api. Role: Developer or higher. Used for every API call and for git push."}, + {Name: fieldReadToken, Label: "Read-only access token", Secret: true, + Help: "Optional. Scope: read_repository. Handed to git for clone and fetch instead of the access token above. It cannot be checked here: a wrong one only shows when git first uses it."}, + {Name: fieldWebhookSecret, Label: "Webhook secret", Secret: true, Required: true, + Help: "A random string of at least 16 characters, for example from `openssl rand -hex 32`. Enter the same value as the webhook's Secret token."}, + }, + }, nil +} + +type glUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Bot bool `json:"bot"` +} + +// tokenInfo is what GitLab says about a token: its scopes and expiry. +type tokenInfo struct { + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at"` // a date, or null + Active bool `json:"active"` +} + +func (t tokenInfo) expiry() time.Time { + if t.ExpiresAt == "" { + return time.Time{} + } + // GitLab reports a date; the token works until the end of that day. + d, err := time.Parse("2006-01-02", t.ExpiresAt) + if err != nil { + return time.Time{} + } + return d.Add(24*time.Hour - time.Second) +} + +func (t tokenInfo) hasScope(s string) bool { + for _, x := range t.Scopes { + if x == s { + return true + } + } + return false +} + +// validateToken proves a token against the instance. A token GitLab rejects is a +// validation error here (the user mistyped it), not a forbidden call. +func validateToken(api *glAPI, requireAPI bool) (glUser, tokenInfo, error) { + var u glUser + if err := api.get("/user", &u); err != nil { + switch gp.AsError(err).Code { + case gp.CodeForbidden, gp.CodeNotFound: + return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeValidation, "GitLab rejected the token: check that it is correct, unexpired and has the api scope") + case gp.CodeTransient: + return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeTransient, "could not reach the GitLab host: self-hosted instances must be listed in the plugin's allowedOutboundDomains") + } + return glUser{}, tokenInfo{}, err + } + if u.Username == "" { + return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeInternal, "GitLab returned an incomplete user") + } + var info tokenInfo + // Older instances do not have this endpoint; the token still works without it. + if err := api.get("/personal_access_tokens/self", &info); err == nil && requireAPI && len(info.Scopes) > 0 && !info.hasScope("api") { + return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeValidation, "the token needs the api scope") + } + return u, info, nil +} + +// CompleteIdentitySetup implements gp.Provider. +func (p *glProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSetupRequest) (gp.Identity, error) { + token := strings.TrimSpace(req.Params[fieldToken]) + secret := strings.TrimSpace(req.Params[fieldWebhookSecret]) + if strings.TrimSpace(req.State) == "" || token == "" { + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "state and token are required") + } + if len(secret) < minWebhookSecretLen { + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "the webhook secret must be at least %d characters", minWebhookSecretLen) + } + s, err := p.store.loadSetup(req.State) + if err != nil || s.Completed || !p.now().Before(s.ExpiresAt) { + return gp.Identity{}, gp.Errorf(gp.CodeForbidden, "unknown or expired setup state") + } + api := &glAPI{base: baseURL(s.Host), token: token} + user, info, err := validateToken(api, true) + if err != nil { + return gp.Identity{}, err + } + rec := identityRecord{ + Host: s.Host, UserID: user.ID, Username: user.Username, Name: user.Name, Bot: user.Bot || accessTokenBotRe.MatchString(user.Username), + Token: token, WebhookSecret: secret, ExpiresAt: info.expiry(), + } + if rt := strings.TrimSpace(req.Params[fieldReadToken]); rt != "" { + // The read token only needs to clone: it may lack API access, so it is + // checked for the owner it belongs to when it can call the API at all. + rec.ReadToken = rt + _, rinfo, rerr := validateToken(&glAPI{base: baseURL(s.Host), token: rt}, false) + if rerr == nil { + if exp := rinfo.expiry(); !exp.IsZero() && (rec.ExpiresAt.IsZero() || exp.Before(rec.ExpiresAt)) { + rec.ExpiresAt = exp + } + } + } + claimed, err := p.store.claimSetup(s.State) + if err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not record setup progress") + } + if !claimed { + return gp.Identity{}, gp.Errorf(gp.CodeForbidden, "this setup was already completed") + } + id, err := p.store.createIdentity(rec) + if err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not store the GitLab identity") + } + rec.ID = id + return identityOf(rec), nil +} + +func identityOf(rec identityRecord) gp.Identity { + kind := gp.IdentityToken + if rec.Bot { + kind = gp.IdentityBotUser + } + name := rec.Name + if name == "" { + name = rec.Username + } + return gp.Identity{ + Ref: gp.IdentityRef{ID: rec.ID, Handle: rec.Username, ProviderRef: providerRef(map[string]any{"user_id": rec.UserID})}, + Host: rec.Host, + Kind: kind, + DisplayName: name, + ExpiresAt: rec.ExpiresAt, + } +} + +// DeleteIdentity implements gp.IdentityDeleter: forget the tokens locally. The +// access token itself stays valid on GitLab (revoking it needs a higher scope +// than the token has); the operator revokes it there. +func (p *glProvider) DeleteIdentity(_ context.Context, req gp.DeleteIdentityRequest) (gp.Empty, error) { + if _, err := p.loadIdentity(req.Identity.ID); err != nil { + if gp.AsError(err).Code == gp.CodeNotFound { + return gp.Empty{}, nil // already gone + } + return gp.Empty{}, err + } + if err := p.store.deleteIdentity(req.Identity.ID); err != nil { + return gp.Empty{}, gp.Errorf(gp.CodeInternal, "could not delete the identity") + } + return gp.Empty{}, nil +} diff --git a/backend/spi_store.go b/backend/spi_store.go new file mode 100644 index 0000000..60fc708 --- /dev/null +++ b/backend/spi_store.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "time" +) + +// spiStore is what the Git provider needs persisted. The production +// implementation (dbStore) is backed by the plugin's tables; tests use an +// in-memory one. Secrets are decrypted by the store, never by callers. +type spiStore interface { + loadIdentity(id string) (identityRecord, error) + createIdentity(r identityRecord) (id string, err error) + deleteIdentity(id string) error + + saveSetup(s setupSession) error + loadSetup(state string) (setupSession, error) + // claimSetup marks the setup as completed and reports whether this call was + // the one that did, so two concurrent completions cannot both create an + // identity from the same state. + claimSetup(state string) (bool, error) +} + +var errNoSuchIdentity = errors.New("gitlab identity not found") +var errNoSuchSetup = errors.New("setup session not found") + +// identityRecord is one GitLab access-token identity. +type identityRecord struct { + ID string // row id; the SPI IdentityRef.ID + Host string // e.g. gitlab.com or gitlab.example.com + UserID int64 // the token owner on GitLab (a bot user for project/group tokens) + Username string // the handle people mention + Name string + Bot bool + // Token is the access token used for every API call and for git writes. + Token string + // ReadToken, when set, is handed out instead of Token for read-only git use. + ReadToken string + WebhookSecret string + // ExpiresAt is the earliest expiry of the stored tokens; zero when unknown. + ExpiresAt time.Time +} + +// setupSession is one identity-setup attempt, keyed by its single-use state. +type setupSession struct { + State string + Scope string // shared | agent + AgentID string + Host string + Completed bool + ExpiresAt time.Time +} diff --git a/backend/spi_test.go b/backend/spi_test.go new file mode 100644 index 0000000..048bf7c --- /dev/null +++ b/backend/spi_test.go @@ -0,0 +1,524 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "strings" + "testing" + "time" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +var bg = context.Background() + +func code(t *testing.T, err error, want gp.ErrorCode) { + t.Helper() + var e *gp.Error + if !errors.As(err, &e) || e.Code != want { + t.Fatalf("error = %v, want code %s", err, want) + } +} + +func completeParams(token string) map[string]string { + return map[string]string{fieldToken: token, fieldWebhookSecret: fakeWebhookSecret} +} + +func beginShared(t *testing.T, p *glProvider) gp.SetupStep { + t.Helper() + step, err := p.BeginIdentitySetup(bg, gp.BeginSetupRequest{Scope: gp.SetupShared, Host: "GitLab.com", CallbackURL: "https://paca.test/cb", WebhookURL: "https://paca.test/hook"}) + if err != nil { + t.Fatal(err) + } + return step +} + +func TestSetup_TokenFormAsksForTheRightInputs(t *testing.T) { + p, _, _ := seededProvider(t) + step := beginShared(t, p) + if step.Kind != gp.StepTokenForm || step.State == "" || !strings.Contains(step.Prompt, "https://paca.test/hook") { + t.Fatalf("step %+v: want a token form whose prompt names the webhook URL", step) + } + required := map[string]bool{} + for _, f := range step.Form { + required[f.Name] = f.Required + if !f.Secret { + t.Errorf("field %s holds a secret but is not marked secret", f.Name) + } + } + if !required[fieldToken] || !required[fieldWebhookSecret] || required[fieldReadToken] { + t.Errorf("required fields %v: want token and webhook_secret required, read_token optional", required) + } +} + +func TestSetup_BeginRejectsBadInput(t *testing.T) { + p, _, _ := seededProvider(t) + base := gp.BeginSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", WebhookURL: "https://paca.test/hook"} + for name, mutate := range map[string]func(*gp.BeginSetupRequest){ + "url as host": func(r *gp.BeginSetupRequest) { r.Host = "https://gitlab.com" }, + "path in host": func(r *gp.BeginSetupRequest) { r.Host = "gitlab.com/evil" }, + "empty host": func(r *gp.BeginSetupRequest) { r.Host = "" }, + "bad scope": func(r *gp.BeginSetupRequest) { r.Scope = "everyone" }, + "no webhook url": func(r *gp.BeginSetupRequest) { r.WebhookURL = "" }, + "credentials url": func(r *gp.BeginSetupRequest) { r.Host = "user:pw@gitlab.com" }, + } { + req := base + mutate(&req) + if _, err := p.BeginIdentitySetup(bg, req); err == nil { + t.Errorf("%s: want an error", name) + } else { + code(t, err, gp.CodeValidation) + } + } + req := base + req.Host = "GitLab.Example.com:8443" + if _, err := p.BeginIdentitySetup(bg, req); err != nil { + t.Errorf("a self-hosted host with a port must be accepted: %v", err) + } +} + +func TestSetup_CompleteStoresAnIdentityWithoutLeakingSecrets(t *testing.T) { + p, g, st := seededProvider(t) + step := beginShared(t, p) + params := completeParams(fakeNewToken) + params[fieldReadToken] = fakeReadToken + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: params}) + if err != nil { + t.Fatal(err) + } + if id.Ref.Handle != "project_1_bot_ab12" || id.Kind != gp.IdentityBotUser || id.Host != "gitlab.com" { + t.Errorf("identity %+v: a project access token is a bot user on the session's host", id) + } + // The earliest of the two token expiries is the one that matters. + if want := time.Date(2029, 1, 2, 23, 59, 59, 0, time.UTC); !id.ExpiresAt.Equal(want) { + t.Errorf("expires at %v, want %v (the read token expires first)", id.ExpiresAt, want) + } + stored, err := st.loadIdentity(id.Ref.ID) + if err != nil || stored.Token != fakeNewToken || stored.ReadToken != fakeReadToken || stored.WebhookSecret != fakeWebhookSecret || stored.UserID != 77 { + t.Errorf("stored identity %+v, %v", stored, err) + } + if g.called("GET /user") != 2 { + t.Errorf("both tokens should be proven against the instance") + } +} + +func TestSetup_CompleteRefusesWhatItCannotTrust(t *testing.T) { + p, _, _ := seededProvider(t) + complete := func(state string, params map[string]string) error { + _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: state, Params: params}) + return err + } + step := beginShared(t, p) + + code(t, complete(step.State, completeParams("glpat-wrong")), gp.CodeValidation) // GitLab rejects it + code(t, complete(step.State, completeParams("glpat-readonly-scope")), gp.CodeValidation) // no api scope + code(t, complete(step.State, map[string]string{fieldToken: fakeNewToken, fieldWebhookSecret: "short"}), gp.CodeValidation) + code(t, complete("never-issued", completeParams(fakeNewToken)), gp.CodeForbidden) + // A rejected attempt does not burn the state: the user can fix the form. + if err := complete(step.State, completeParams(fakeNewToken)); err != nil { + t.Fatalf("retry after a rejected token: %v", err) + } + // …but a completed one is spent. + code(t, complete(step.State, completeParams(fakeNewToken)), gp.CodeForbidden) +} + +func TestSetup_ExpiredStateIsRefused(t *testing.T) { + p, _, _ := seededProvider(t) + step := beginShared(t, p) + p.now = func() time.Time { return time.Now().Add(2 * setupSessionLife) } + _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Host: "gitlab.com", State: step.State, Params: completeParams(fakeNewToken)}) + code(t, err, gp.CodeForbidden) +} + +func TestSetup_UnreachableHostSaysHowToFixIt(t *testing.T) { + p, g, _ := seededProvider(t) + step := beginShared(t, p) + g.forceError["GET /user"] = fakeResponse{status: 503} + _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Host: "gitlab.com", State: step.State, Params: completeParams(fakeNewToken)}) + code(t, err, gp.CodeTransient) +} + +func TestMintCredential_StaticTokens(t *testing.T) { + p, _, st := seededProvider(t) + mint := func(access gp.Access, repo gp.RepoRef) (gp.Credential, error) { + return p.MintCredential(bg, gp.MintRequest{Target: gp.Target{Identity: fixtureIdentity, Repo: repo}, Access: access}) + } + read, err := mint(gp.AccessRead, fixtureRepo) + if err != nil || read.Secret != fakeReadToken || read.Username != credUsername { + t.Errorf("read credential %+v %v: want the read-only token", read, err) + } + write, err := mint(gp.AccessWrite, fixtureRepo) + if err != nil || write.Secret != fakeToken { + t.Errorf("write credential %+v %v: want the full token", write, err) + } + if write.ExpiresAt.IsZero() { + t.Error("the token's expiry should be reported") + } + + // Without a read token, reads use the main token. + rec := st.identities[fakeIdentityID] + rec.ReadToken = "" + st.identities[fakeIdentityID] = rec + if c, err := mint(gp.AccessRead, fixtureRepo); err != nil || c.Secret != fakeToken { + t.Errorf("read without a read token: %+v %v", c, err) + } + + // A Reporter can read a project but may not push to it. + reporter := gp.RepoRef{Host: fakeHost, FullPath: "acme/api", ExternalID: "2"} + if _, err := mint(gp.AccessRead, reporter); err != nil { + t.Errorf("read as a Reporter: %v", err) + } + _, err = mint(gp.AccessWrite, reporter) + code(t, err, gp.CodeForbidden) + + // The repository must be on the identity's host. + _, err = mint(gp.AccessRead, gp.RepoRef{Host: "gitlab.other.com", FullPath: "acme/web"}) + code(t, err, gp.CodeValidation) +} + +func TestProjectAddressing(t *testing.T) { + p, g, _ := seededProvider(t) + // Without a numeric id the path is URL-encoded, subgroups included. + nested := gp.RepoRef{Host: fakeHost, FullPath: "acme/platform/infra"} + if _, err := p.GetBranchProtection(bg, gp.BranchProtectionRequest{Target: gp.Target{Identity: fixtureIdentity, Repo: nested}, Branch: "main"}); err == nil { + t.Log("the fake only seeds acme/web in depth; reaching the project is enough") + } + if g.called("GET /projects/acme/platform/infra/repository/branches/main") != 1 { + t.Errorf("calls: %v", g.calls) + } + for _, bad := range []string{"web", "acme/../etc", "acme//web", "acme/we b", "/"} { + _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: gp.Target{Identity: fixtureIdentity, Repo: gp.RepoRef{Host: fakeHost, FullPath: bad}}, Number: "1"}) + code(t, err, gp.CodeValidation) + } +} + +func TestResolveRepository_Inputs(t *testing.T) { + p, _, _ := seededProvider(t) + cands := []gp.IdentityRef{fixtureIdentity} + for _, in := range []string{ + "acme/web", "acme/web.git", "https://gitlab.com/acme/web", "https://gitlab.com/acme/web.git", + "git@gitlab.com:acme/web.git", "https://gitlab.com/acme/web/-/merge_requests/3", "ssh://git@gitlab.com/acme/web.git", " https://gitlab.com/acme/web/ ", + } { + r, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: in, Candidates: cands}) + if err != nil || r.Repository.Ref.FullPath != "acme/web" || r.Repository.Ref.ExternalID != "1" || r.Identity.ID != fakeIdentityID { + t.Errorf("%q → %+v %v", in, r, err) + } + if got := r.Repository.Ref.CloneURL; got != "https://gitlab.com/acme/web.git" { + t.Errorf("clone url %q must be token-free", got) + } + } + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "https://other.example.com/acme/web", Candidates: cands}) + code(t, err, gp.CodeNotFound) // no candidate lives on that host + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{Host: "gitlab.com", PathOrURL: "https://other.example.com/acme/web", Candidates: cands}) + code(t, err, gp.CodeValidation) + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "justaname", Candidates: cands}) + code(t, err, gp.CodeValidation) + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "secret/hidden", Candidates: cands}) + code(t, err, gp.CodeNotFound) + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web"}) + code(t, err, gp.CodeForbidden) +} + +func TestListRepositories_PagesByNumber(t *testing.T) { + p, _, _ := seededProvider(t) + page, err := p.ListRepositories(bg, gp.ListReposRequest{Identity: fixtureIdentity, PageSize: 2}) + if err != nil || len(page.Items) != 2 || page.NextCursor != "2" { + t.Fatalf("first page %+v %v", page, err) + } + if !page.Items[1].Private && page.Items[1].Ref.FullPath == "acme/platform/infra" { + t.Error("a private project must be reported private") + } + page, err = p.ListRepositories(bg, gp.ListReposRequest{Identity: fixtureIdentity, PageSize: 2, Cursor: "2"}) + if err != nil || len(page.Items) != 1 || page.NextCursor != "" { + t.Fatalf("last page %+v %v", page, err) + } + _, err = p.ListRepositories(bg, gp.ListReposRequest{Identity: fixtureIdentity, Cursor: "x"}) + code(t, err, gp.CodeValidation) + found, err := p.ListRepositories(bg, gp.ListReposRequest{Identity: fixtureIdentity, Query: "infra"}) + if err != nil || len(found.Items) != 1 { + t.Errorf("search: %+v %v", found, err) + } +} + +func TestErrorMapping(t *testing.T) { + cases := []struct { + status int + body string + want gp.ErrorCode + }{ + {401, `{"message":"401 Unauthorized"}`, gp.CodeForbidden}, + {403, `{"message":"403 Forbidden"}`, gp.CodeForbidden}, + {404, `{"message":"404 Project Not Found"}`, gp.CodeNotFound}, + {405, `{"message":"Method Not Allowed"}`, gp.CodeConflict}, + {406, `{"message":"Branch cannot be merged"}`, gp.CodeConflict}, + {409, `{"message":"SHA does not match HEAD of source branch"}`, gp.CodeConflict}, + {400, `{"message":"Branch already exists"}`, gp.CodeConflict}, + {400, `{"message":{"base":["is invalid"]}}`, gp.CodeValidation}, + {400, `{"error":"title is missing"}`, gp.CodeValidation}, + {422, `{"message":"Validation failed"}`, gp.CodeValidation}, + {429, `{"message":"Retry later"}`, gp.CodeRateLimited}, + {502, `Bad gateway`, gp.CodeTransient}, + {418, ``, gp.CodeInternal}, + } + for _, c := range cases { + code(t, mapGitLabError(c.status, c.body, nil), c.want) + } + e := mapGitLabError(429, "", map[string]string{"Retry-After": "12"}).(*gp.Error) + if e.RetryAfterSeconds != 12 { + t.Errorf("retry after %d, want 12", e.RetryAfterSeconds) + } + msg := mapGitLabError(400, `{"message":{"base":["is invalid"]}}`, nil).(*gp.Error).Message + if !strings.Contains(msg, "is invalid") { + t.Errorf("message %q lost GitLab's explanation", msg) + } + long := mapGitLabError(400, `{"message":"`+strings.Repeat("é", 500)+`"}`, nil).(*gp.Error).Message + if len(long) > maxErrorMessage+len("…") || !strings.HasSuffix(long, "…") { + t.Errorf("long message not clipped: %d bytes", len(long)) + } +} + +func TestRateLimitAndTransientFailuresReachCore(t *testing.T) { + p, g, _ := seededProvider(t) + g.forceError["GET /projects/1/merge_requests/1"] = fakeResponse{status: 429, headers: map[string]string{"Retry-After": "30"}} + _, err := p.GetChangeRequest(bg, gp.CRRequest{Target: fixtureTarget, Number: "1"}) + code(t, err, gp.CodeRateLimited) + if e := gp.AsError(err); e.RetryAfterSeconds != 30 || !e.Retryable() { + t.Errorf("%+v", e) + } +} + +func TestVerifyDelivery(t *testing.T) { + secret := "whsec_" + base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + body := []byte(`{"object_kind":"push"}`) + key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + mac := hmac.New(sha256.New, key) + mac.Write([]byte("msg-1.1700000000." + string(body))) + sig := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + req := func(h map[string]string) gp.WebhookRequest { return gp.WebhookRequest{Body: body, Headers: h} } + for name, tc := range map[string]struct { + req gp.WebhookRequest + want bool + }{ + "secret token": {req(map[string]string{"x-gitlab-token": secret}), true}, + "wrong secret token": {req(map[string]string{"x-gitlab-token": secret + "x"}), false}, + "standard webhooks": {req(map[string]string{"webhook-id": "msg-1", "webhook-timestamp": "1700000000", "webhook-signature": sig}), true}, + "signature of another id": {req(map[string]string{"webhook-id": "msg-2", "webhook-timestamp": "1700000000", "webhook-signature": sig}), false}, + "one of several": {req(map[string]string{"webhook-id": "msg-1", "webhook-timestamp": "1700000000", "webhook-signature": "v1,AAAA " + sig}), true}, + "no credentials": {req(map[string]string{}), false}, + // A present-but-wrong token must not fall through to the signature check. + "wrong token with a valid signature": {req(map[string]string{"x-gitlab-token": "nope", "webhook-id": "msg-1", "webhook-timestamp": "1700000000", "webhook-signature": sig}), false}, + } { + if got := verifyDelivery(tc.req, secret); got != tc.want { + t.Errorf("%s: verified = %v, want %v", name, got, tc.want) + } + } + if verifyDelivery(req(map[string]string{"x-gitlab-token": ""}), "") { + t.Error("an identity without a secret must verify nothing") + } +} + +func TestWebhook_StandardWebhooksSignedDeliveryParses(t *testing.T) { + p, _, st := seededProvider(t) + rec := st.identities[fakeIdentityID] + rec.WebhookSecret = "whsec_" + base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + st.identities[fakeIdentityID] = rec + req := noteHook("d-sw", "@paca-bot hi", hookActor, nil) + delete(req.Headers, "x-gitlab-token") + key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(rec.WebhookSecret, "whsec_")) + mac := hmac.New(sha256.New, key) + mac.Write([]byte("msg-9.1700000000." + string(req.Body))) + req.Headers["webhook-id"], req.Headers["webhook-timestamp"] = "msg-9", "1700000000" + req.Headers["webhook-signature"] = "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) + evs, err := p.VerifyAndParseWebhook(bg, req) + if err != nil || len(evs) != 1 || len(evs[0].Addressed) != 1 { + t.Fatalf("%+v %v", evs, err) + } +} + +func TestWebhook_EventIDs(t *testing.T) { + p, _, _ := seededProvider(t) + // One delivery, several events: each id is unique and stable across retries. + req := mrHook("uuid-1", map[string]any{"action": "open"}, map[string]any{ + "labels": []map[string]any{{"title": "a"}, {"title": "b"}}, "assignees": users(me)}) + a, _ := p.VerifyAndParseWebhook(bg, req) + b, _ := p.VerifyAndParseWebhook(bg, req) + seen := map[string]bool{} + for i, ev := range a { + if seen[ev.ID] || ev.ID != b[i].ID || !strings.HasPrefix(ev.ID, "uuid-1:") { + t.Errorf("event ids %q vs %q (duplicate=%v)", ev.ID, b[i].ID, seen[ev.ID]) + } + seen[ev.ID] = true + } + if len(a) != 4 { + t.Fatalf("got %d events, want opened + 2 labels + assignment", len(a)) + } + // Two different deliveries of the same kind never share an id. + other, _ := p.VerifyAndParseWebhook(bg, mrHook("uuid-2", map[string]any{"action": "open"}, nil)) + if other[0].ID == a[0].ID { + t.Error("distinct deliveries share an event id") + } + // An old instance that sends no uuid still gets a body-derived, stable id. + req = mrHook("", map[string]any{"action": "close"}, nil) + delete(req.Headers, "x-gitlab-event-uuid") + x, _ := p.VerifyAndParseWebhook(bg, req) + y, _ := p.VerifyAndParseWebhook(bg, req) + if x[0].ID == "" || x[0].ID != y[0].ID { + t.Errorf("fallback ids %q vs %q", x[0].ID, y[0].ID) + } +} + +func TestWebhook_NoteDetails(t *testing.T) { + p, _, _ := seededProvider(t) + req := noteHook("d", "nit", hookActor, map[string]any{"type": "DiffNote", "position": map[string]any{"old_path": "a.go", "old_line": 9}}) + evs, err := p.VerifyAndParseWebhook(bg, req) + if err != nil || len(evs) != 1 { + t.Fatal(evs, err) + } + ev := evs[0] + if ev.Location.ThreadID != "disc-1" || ev.Location.Path != "a.go" || ev.Location.Line != 9 || ev.Location.CommentID != "11" { + t.Errorf("location %+v", ev.Location) + } + if ev.Subject.Head == nil || ev.Subject.Head.Ref != "feat/PACA-12-login" || ev.Subject.Head.FromFork || ev.Subject.Base != "main" || ev.Subject.Author != "octocat" { + t.Errorf("subject %+v", ev.Subject) + } + if ev.Subject.URL == "" || ev.Repo.Host != fakeHost || ev.Repo.ExternalID != "1" { + t.Errorf("subject url / repo: %+v %+v", ev.Subject, ev.Repo) + } +} + +func TestWebhook_ForkMergeRequest(t *testing.T) { + p, _, _ := seededProvider(t) + evs, _ := p.VerifyAndParseWebhook(bg, mrHook("d", map[string]any{"action": "open", "source_project_id": 2, "source": map[string]any{"path_with_namespace": "someone/web"}}, nil)) + if len(evs) == 0 || !evs[0].Subject.Head.FromFork || evs[0].Subject.Head.Repo != "someone/web" { + t.Errorf("%+v", evs) + } +} + +func TestWebhook_MalformedAndUnknown(t *testing.T) { + p, _, _ := seededProvider(t) + req := hookDelivery("d", map[string]any{"object_kind": "note"}) + req.Body = []byte(`{not json`) + _, err := p.VerifyAndParseWebhook(bg, req) + code(t, err, gp.CodeValidation) + _, err = p.VerifyAndParseWebhook(bg, gp.WebhookRequest{Identity: gp.IdentityRef{ID: "nope"}}) + code(t, err, gp.CodeNotFound) + // A project the delivery does not name is dropped, not guessed at. + evs, err := p.VerifyAndParseWebhook(bg, hookDelivery("d", map[string]any{"object_kind": "push", "ref": "refs/heads/x", "user_username": "u"})) + if err != nil || len(evs) != 0 { + t.Errorf("%+v %v", evs, err) + } +} + +func TestPermissionMapping(t *testing.T) { + for level, want := range map[int]gp.Permission{0: gp.PermissionNone, 5: gp.PermissionNone, 10: gp.PermissionRead, 20: gp.PermissionTriage, 30: gp.PermissionWrite, 40: gp.PermissionMaintain, 50: gp.PermissionAdmin} { + if got := permissionOf(level); got != want { + t.Errorf("level %d → %s, want %s", level, got, want) + } + } + p, _, _ := seededProvider(t) + got, err := p.GetActorPermission(bg, gp.ActorPermissionRequest{Target: fixtureTarget, Login: "bob"}) + if err != nil || got != gp.PermissionNone { + t.Errorf("a user who is not a member: %s %v", got, err) + } +} + +func TestMentionRegexp(t *testing.T) { + for text, want := range map[string]bool{ + "@paca-bot": true, + "hi @paca-bot, please": true, + "(@paca-bot)": true, + "ask @paca-bot.": true, + "ask @paca-bot.\nthanks": true, + "@Paca-Bot": true, + "@paca-bot-2": false, + "@paca-bot_x": false, + "@paca-bot.other": false, + "dev@paca-bot.example": false, + "paca-bot": false, + "@@paca-bot": false, + "text\n@paca-bot do it": true, + } { + if got := mentionsHandle(text, "paca-bot"); got != want { + t.Errorf("mentionsHandle(%q) = %v, want %v", text, got, want) + } + } + if mentionsHandle("@anyone", "") { + t.Error("an empty handle mentions nobody") + } +} + +func TestParseRepoInput(t *testing.T) { + for in, want := range map[string]struct{ host, path string }{ + "group/sub/proj": {"", "group/sub/proj"}, + "https://gitlab.example.com/group/sub/proj.git": {"gitlab.example.com", "group/sub/proj"}, + "git@gitlab.example.com:group/proj.git": {"gitlab.example.com", "group/proj"}, + "https://GitLab.com/g/p/-/issues/4": {"gitlab.com", "g/p"}, + } { + host, path, err := parseRepoInput(in) + if err != nil || host != want.host || path != want.path { + t.Errorf("%q → %q %q %v", in, host, path, err) + } + } + for _, bad := range []string{"", " ", "x", "http://", "git@nohost", "a/b c", "https://gitlab.com/onlyone"} { + if _, _, err := parseRepoInput(bad); err == nil { + t.Errorf("%q should be rejected", bad) + } + } +} + +func TestDraftTitles(t *testing.T) { + for _, tc := range []struct { + in string + draft bool + want string + }{ + {"Fix it", true, "Draft: Fix it"}, + {"Draft: Fix it", true, "Draft: Fix it"}, + {"[Draft] Fix it", true, "Draft: Fix it"}, + {"WIP: Fix it", false, "Fix it"}, + {"Draft: Fix it", false, "Fix it"}, + {"Fix it", false, "Fix it"}, + {"Drafting a plan", false, "Drafting a plan"}, + } { + if got := withDraft(tc.in, tc.draft); got != tc.want { + t.Errorf("withDraft(%q, %v) = %q, want %q", tc.in, tc.draft, got, tc.want) + } + } +} + +func TestCleanTrace(t *testing.T) { + in := "\x1b[0Ksection_start:1700:build\r\x1b[0K\x1b[32;1mRunning\x1b[0;m\nline\r\nsection_end:1700:build\r\x1b[0K" + got := cleanTrace(in) + if got != "Running\nline\n" { + t.Errorf("cleanTrace = %q", got) + } +} + +func TestCuts(t *testing.T) { + if s, cut := cutHead("héllo", 2); s != "h" || !cut { + t.Errorf("cutHead split a rune: %q %v", s, cut) + } + if s, cut := cutTail("héllo", 4); s != "llo" || !cut { + t.Errorf("cutTail split a rune: %q %v", s, cut) + } + if got := cutRunes(strings.Repeat("é", 300), maxStatusDescription); len([]rune(got)) != maxStatusDescription { + t.Errorf("status description has %d runes", len([]rune(got))) + } +} + +func TestStatusRefRoundTrip(t *testing.T) { + in := statusRef{SHA: fakeHeadSHA, Name: "Paca / dev: fix", Ref: "feat/x"} + out, ok := decodeStatusRef(in.encode()) + if !ok || out != in { + t.Errorf("%+v %v", out, ok) + } + for _, bad := range []string{"", "###", base64.RawURLEncoding.EncodeToString([]byte(`{}`))} { + if _, ok := decodeStatusRef(bad); ok { + t.Errorf("%q should not decode", bad) + } + } +} diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go new file mode 100644 index 0000000..54456b6 --- /dev/null +++ b/backend/spi_webhook.go @@ -0,0 +1,549 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "regexp" + "strconv" + "strings" + "sync" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// VerifyAndParseWebhook implements gp.Provider. It is the only place GitLab's +// webhook format lives: it checks the delivery against the identity's webhook +// secret and turns it into zero or more normalized events. Event types Paca has +// no use for are dropped, not guessed at. +func (p *glProvider) VerifyAndParseWebhook(_ context.Context, req gp.WebhookRequest) ([]gp.Event, error) { + rec, err := p.loadIdentity(req.Identity.ID) + if err != nil { + return nil, err + } + if !verifyDelivery(req, rec.WebhookSecret) { + return nil, gp.Errorf(gp.CodeInvalidSignature, "webhook signature does not match") + } + var peek struct { + ObjectKind string `json:"object_kind"` + } + if err := json.Unmarshal(req.Body, &peek); err != nil { + return nil, gp.Errorf(gp.CodeValidation, "webhook payload is not valid JSON") + } + d := &delivery{rec: rec, base: deliveryID(req)} + var evs []gp.Event + switch peek.ObjectKind { + case "merge_request": + evs, err = d.mergeRequest(req.Body) + case "note": + evs, err = d.note(req.Body) + case "issue": + evs, err = d.issue(req.Body) + case "pipeline": + evs, err = d.pipeline(req.Body) + case "push": + evs, err = d.push(req.Body) + default: + return nil, nil // tag pushes, wiki pages, releases, … are not used + } + if err != nil { + return nil, gp.Errorf(gp.CodeValidation, "webhook payload is not a valid %s event", peek.ObjectKind) + } + return evs, nil +} + +// verifyDelivery accepts GitLab's secret token header, or a Standard Webhooks +// signature when the hook has a signing token. +func verifyDelivery(req gp.WebhookRequest, secret string) bool { + if secret == "" { + return false + } + if tok := req.Headers["x-gitlab-token"]; tok != "" { + return secretEqual(tok, secret) + } + return verifyStandardWebhook(req.Body, secret, req.Headers["webhook-id"], req.Headers["webhook-timestamp"], req.Headers["webhook-signature"]) +} + +// deliveryID is the id GitLab gives a delivery, stable across its retries; a +// hash of the body stands in for an old instance that sends none. +func deliveryID(req gp.WebhookRequest) string { + for _, h := range []string{"x-gitlab-event-uuid", "idempotency-key", "webhook-id"} { + if v := strings.TrimSpace(req.Headers[h]); v != "" { + return v + } + } + sum := sha256.Sum256(req.Body) + return hex.EncodeToString(sum[:8]) +} + +// delivery turns the payloads of one identity's webhook into events. +type delivery struct { + rec identityRecord + base string +} + +type hookUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + Bot bool `json:"bot"` +} + +type hookProject struct { + ID int64 `json:"id"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` + GitHTTPURL string `json:"git_http_url"` +} + +type hookMR struct { + ID int64 `json:"id"` + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + Action string `json:"action"` + URL string `json:"url"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Draft bool `json:"draft"` + WorkInProgress bool `json:"work_in_progress"` + AuthorID int64 `json:"author_id"` + OldRev string `json:"oldrev"` + SourceProjectID int64 `json:"source_project_id"` + TargetProjectID int64 `json:"target_project_id"` + LastCommit struct { + ID string `json:"id"` + } `json:"last_commit"` + Source struct { + PathWithNamespace string `json:"path_with_namespace"` + } `json:"source"` + Target struct { + PathWithNamespace string `json:"path_with_namespace"` + } `json:"target"` +} + +type hookLabel struct { + Title string `json:"title"` +} + +// change is how GitLab reports a field that was edited. +type change[T any] struct { + Previous T `json:"previous"` + Current T `json:"current"` +} + +type hookChanges struct { + Labels *change[[]hookLabel] `json:"labels"` + Assignees *change[[]hookUser] `json:"assignees"` + Reviewers *change[[]hookUser] `json:"reviewers"` + Draft *change[bool] `json:"draft"` + Title *change[string] `json:"title"` + Desc *change[string] `json:"description"` +} + +func (d *delivery) repo(p hookProject) gp.RepoRef { + clone := p.GitHTTPURL + if clone == "" { + clone = webURL(d.rec.Host, p.PathWithNamespace, ".git") + } + return gp.RepoRef{Host: d.rec.Host, FullPath: p.PathWithNamespace, ExternalID: strconv.FormatInt(p.ID, 10), DefaultBranch: p.DefaultBranch, CloneURL: clone} +} + +// start builds the part of an event every kind shares; ok is false when the +// delivery names no project or no actor. +func (d *delivery) start(p hookProject, login string, isBot bool) (gp.Event, bool) { + if p.PathWithNamespace == "" || login == "" { + return gp.Event{}, false + } + kind := loginKind(login) + if isBot || strings.EqualFold(login, d.rec.Username) { + kind = gp.ActorBot + } + return gp.Event{Repo: d.repo(p), Actor: gp.Actor{Login: login, Kind: kind}, RawRef: d.base}, true +} + +// finish gives an event its deterministic id: the delivery's id, the event +// kind and whatever tells apart the events one delivery can produce. +func (d *delivery) finish(ev gp.Event, parts ...string) gp.Event { + ev.ID = d.base + ":" + strings.Join(append([]string{string(ev.Kind)}, parts...), ":") + return ev +} + +func (d *delivery) mention(text string) []gp.Addressed { + if mentionsHandle(text, d.rec.Username) { + return []gp.Addressed{{IdentityID: d.rec.ID, Via: gp.ViaMention}} + } + return nil +} + +func (d *delivery) isMe(u hookUser) bool { + return u.Username != "" && strings.EqualFold(u.Username, d.rec.Username) +} + +func (d *delivery) hasMe(us []hookUser) bool { + for _, u := range us { + if d.isMe(u) { + return true + } + } + return false +} + +// added reports whether the identity is among the current users but was not +// among the previous ones: it was just assigned or asked. +func (d *delivery) added(c *change[[]hookUser]) bool { + return c != nil && d.hasMe(c.Current) && !d.hasMe(c.Previous) +} + +func addedLabels(c *change[[]hookLabel]) []string { + if c == nil { + return nil + } + had := map[string]bool{} + for _, l := range c.Previous { + had[l.Title] = true + } + var out []string + for _, l := range c.Current { + if !had[l.Title] { + out = append(out, l.Title) + } + } + return out +} + +func labelTitles(ls []hookLabel) []string { + out := make([]string, 0, len(ls)) + for _, l := range ls { + out = append(out, l.Title) + } + return out +} + +func mrSubject(m hookMR, actor hookUser) *gp.EventSubject { + fork := m.SourceProjectID != 0 && m.SourceProjectID != m.TargetProjectID + if m.Source.PathWithNamespace == "" && m.Target.PathWithNamespace != "" { + fork = true // the source project is gone: it can only have been a fork + } + s := &gp.EventSubject{ + Kind: gp.SubjectChangeRequest, Number: strconv.Itoa(m.IID), Base: m.TargetBranch, + Draft: m.Draft || m.WorkInProgress, URL: m.URL, + Head: &gp.Branch{Ref: m.SourceBranch, SHA: m.LastCommit.ID, Repo: m.Source.PathWithNamespace, FromFork: fork}, + } + // The payload names the author by id only; the actor is known by name. + if m.AuthorID != 0 && m.AuthorID == actor.ID { + s.Author = actor.Username + } + return s +} + +func (d *delivery) mergeRequest(body []byte) ([]gp.Event, error) { + var h struct { + User hookUser `json:"user"` + Project hookProject `json:"project"` + ObjectAttributes hookMR `json:"object_attributes"` + Labels []hookLabel `json:"labels"` + Assignees []hookUser `json:"assignees"` + Reviewers []hookUser `json:"reviewers"` + Changes hookChanges `json:"changes"` + } + if err := json.Unmarshal(body, &h); err != nil { + return nil, err + } + oa := h.ObjectAttributes + base, ok := d.start(h.Project, h.User.Username, h.User.Bot) + if !ok || oa.IID == 0 { + return nil, nil + } + base.Subject = mrSubject(oa, h.User) + var out []gp.Event + emit := func(kind gp.EventKind, action, text string, addressed []gp.Addressed, parts ...string) *gp.Event { + ev := base + ev.Kind, ev.Action, ev.Text, ev.Addressed = kind, action, text, addressed + ev = d.finish(ev, append([]string{action}, parts...)...) + out = append(out, ev) + return &out[len(out)-1] + } + label := func(name string) { + ev := emit(gp.EventLabel, "labeled", "", nil, name) + ev.Label = name + } + + switch oa.Action { + case "open": + emit(gp.EventChangeRequest, "opened", oa.Title, d.mention(oa.Title+"\n"+oa.Description)) + for _, l := range labelTitles(h.Labels) { + label(l) + } + if d.hasMe(h.Assignees) { + emit(gp.EventAssignment, "assigned", oa.Title, []gp.Addressed{{IdentityID: d.rec.ID, Via: gp.ViaAssignment}}) + } + if d.hasMe(h.Reviewers) { + emit(gp.EventReviewRequest, "requested", oa.Title, []gp.Addressed{{IdentityID: d.rec.ID, Via: gp.ViaReviewRequest}}) + } + case "reopen": + emit(gp.EventChangeRequest, "reopened", oa.Title, nil) + case "close": + emit(gp.EventChangeRequest, "closed", oa.Title, nil) + case "merge": + emit(gp.EventChangeRequest, "merged", oa.Title, nil) + case "approved": + emit(gp.EventReview, "submitted", "", nil) + case "update": + c := h.Changes + if oa.OldRev != "" { + emit(gp.EventChangeRequest, "synchronized", oa.Title, nil, oa.LastCommit.ID) + } + if c.Draft != nil && c.Draft.Previous != c.Draft.Current { + action := "draft" + if !c.Draft.Current { + action = "ready" + } + emit(gp.EventChangeRequest, action, oa.Title, nil) + } + if c.Title != nil || c.Desc != nil { + emit(gp.EventChangeRequest, "edited", oa.Title, nil) + } + for _, l := range addedLabels(c.Labels) { + label(l) + } + if d.added(c.Assignees) { + emit(gp.EventAssignment, "assigned", oa.Title, []gp.Addressed{{IdentityID: d.rec.ID, Via: gp.ViaAssignment}}) + } + if d.added(c.Reviewers) { + emit(gp.EventReviewRequest, "requested", oa.Title, []gp.Addressed{{IdentityID: d.rec.ID, Via: gp.ViaReviewRequest}}) + } + } + return out, nil +} + +func (d *delivery) note(body []byte) ([]gp.Event, error) { + var h struct { + User hookUser `json:"user"` + Project hookProject `json:"project"` + ObjectAttributes struct { + ID int64 `json:"id"` + Note string `json:"note"` + NoteableType string `json:"noteable_type"` + DiscussionID string `json:"discussion_id"` + Action string `json:"action"` + System bool `json:"system"` + Position *glPosition `json:"position"` + } `json:"object_attributes"` + MergeRequest *hookMR `json:"merge_request"` + Issue *struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + AuthorID int64 `json:"author_id"` + } `json:"issue"` + } + if err := json.Unmarshal(body, &h); err != nil { + return nil, err + } + oa := h.ObjectAttributes + action := map[string]string{"": "created", "create": "created", "update": "edited"}[oa.Action] + ev, ok := d.start(h.Project, h.User.Username, h.User.Bot) + if !ok || oa.System || action == "" || strings.TrimSpace(oa.Note) == "" { + return nil, nil // activity notes ("added 1 commit") are not conversation + } + switch { + case oa.NoteableType == "MergeRequest" && h.MergeRequest != nil && h.MergeRequest.IID != 0: + ev.Subject = mrSubject(*h.MergeRequest, h.User) + case oa.NoteableType == "Issue" && h.Issue != nil && h.Issue.IID != 0: + ev.Subject = &gp.EventSubject{Kind: gp.SubjectIssue, Number: strconv.Itoa(h.Issue.IID)} + if h.Issue.AuthorID != 0 && h.Issue.AuthorID == h.User.ID { + ev.Subject.Author = h.User.Username + } + default: + return nil, nil // commit and snippet notes + } + ev.Kind, ev.Action, ev.Text = gp.EventComment, action, oa.Note + ev.Location = &gp.Location{CommentID: strconv.FormatInt(oa.ID, 10), ThreadID: oa.DiscussionID} + if p := oa.Position; p != nil { + ev.Location.Path, ev.Location.Line = p.NewPath, p.NewLine + if ev.Location.Path == "" { + ev.Location.Path, ev.Location.Line = p.OldPath, p.OldLine + } + } + if action == "created" { + ev.Addressed = d.mention(oa.Note) + } + return []gp.Event{d.finish(ev, action, strconv.FormatInt(oa.ID, 10))}, nil +} + +func (d *delivery) issue(body []byte) ([]gp.Event, error) { + var h struct { + User hookUser `json:"user"` + Project hookProject `json:"project"` + ObjectAttributes struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + Action string `json:"action"` + URL string `json:"url"` + AuthorID int64 `json:"author_id"` + } `json:"object_attributes"` + Labels []hookLabel `json:"labels"` + Assignees []hookUser `json:"assignees"` + Changes hookChanges `json:"changes"` + } + if err := json.Unmarshal(body, &h); err != nil { + return nil, err + } + oa := h.ObjectAttributes + base, ok := d.start(h.Project, h.User.Username, h.User.Bot) + if !ok || oa.IID == 0 { + return nil, nil + } + base.Subject = &gp.EventSubject{Kind: gp.SubjectIssue, Number: strconv.Itoa(oa.IID), URL: oa.URL} + if oa.AuthorID != 0 && oa.AuthorID == h.User.ID { + base.Subject.Author = h.User.Username + } + var out []gp.Event + emit := func(kind gp.EventKind, action, text string, addressed []gp.Addressed, label string, parts ...string) { + ev := base + ev.Kind, ev.Action, ev.Text, ev.Addressed, ev.Label = kind, action, text, addressed, label + out = append(out, d.finish(ev, append([]string{action}, parts...)...)) + } + text := strings.TrimSpace(oa.Title + "\n\n" + oa.Description) + assigned := []gp.Addressed{{IdentityID: d.rec.ID, Via: gp.ViaAssignment}} + switch oa.Action { + case "open": + // The issue body is the first message of the thread; treat it as a comment. + emit(gp.EventComment, "created", text, d.mention(oa.Title+"\n"+oa.Description), "") + for _, l := range labelTitles(h.Labels) { + emit(gp.EventLabel, "labeled", "", nil, l, l) + } + if d.hasMe(h.Assignees) { + emit(gp.EventAssignment, "assigned", text, assigned, "") + } + case "update": + for _, l := range addedLabels(h.Changes.Labels) { + emit(gp.EventLabel, "labeled", "", nil, l, l) + } + if d.added(h.Changes.Assignees) { + emit(gp.EventAssignment, "assigned", text, assigned, "") + } + } + return out, nil +} + +// checkConclusion maps a finished pipeline status; ok is false for one still +// running or waiting. +func checkConclusion(status string) (string, bool) { + switch status { + case "success": + return "success", true + case "failed": + return "failure", true + case "canceled": + return "cancelled", true + case "skipped": + return "skipped", true + } + return "", false +} + +func (d *delivery) pipeline(body []byte) ([]gp.Event, error) { + var h struct { + User hookUser `json:"user"` + Project hookProject `json:"project"` + ObjectAttributes struct { + ID int64 `json:"id"` + SHA string `json:"sha"` + Status string `json:"status"` + Source string `json:"source"` + Name string `json:"name"` + } `json:"object_attributes"` + MergeRequest *hookMR `json:"merge_request"` + } + if err := json.Unmarshal(body, &h); err != nil { + return nil, err + } + oa := h.ObjectAttributes + conclusion, done := checkConclusion(oa.Status) + // External pipelines are statuses other systems post, Paca's own among them: + // reading them as CI would make a failed agent run trigger another run. + if !done || oa.Source == "external" { + return nil, nil + } + login := h.User.Username + if login == "" { + login = "gitlab-ci" // a scheduled pipeline has no user + } + ev, ok := d.start(h.Project, login, h.User.Username == "") + if !ok { + return nil, nil + } + name := oa.Name + if name == "" { + name = "pipeline" + } + ev.Kind, ev.Action = gp.EventCheck, "completed" + ev.Check = &gp.CheckInfo{Name: name, Conclusion: conclusion, RunID: strconv.FormatInt(oa.ID, 10), SHA: oa.SHA} + if m := h.MergeRequest; m != nil && m.IID != 0 { + ev.Subject = &gp.EventSubject{ + Kind: gp.SubjectChangeRequest, Number: strconv.Itoa(m.IID), Base: m.TargetBranch, URL: m.URL, + Head: &gp.Branch{Ref: m.SourceBranch, SHA: oa.SHA}, + } + } + return []gp.Event{d.finish(ev, strconv.FormatInt(oa.ID, 10), oa.Status)}, nil +} + +const zeroSHA = "0000000000000000000000000000000000000000" + +func (d *delivery) push(body []byte) ([]gp.Event, error) { + var h struct { + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + UserUsername string `json:"user_username"` + Project hookProject `json:"project"` + } + if err := json.Unmarshal(body, &h); err != nil { + return nil, err + } + branch, isBranch := strings.CutPrefix(h.Ref, "refs/heads/") + if !isBranch || branch == "" { + return nil, nil // tags and other refs are not branch activity + } + ev, ok := d.start(h.Project, h.UserUsername, false) + if !ok { + return nil, nil + } + created, deleted := h.Before == zeroSHA, h.After == zeroSHA + ev.Kind, ev.Action = gp.EventPush, "pushed" + switch { + case created: + ev.Action = "created" + case deleted: + ev.Action = "deleted" + } + ev.Push = &gp.PushInfo{Ref: branch, SHA: h.After, Created: created, Deleted: deleted} + return []gp.Event{d.finish(ev, h.After)}, nil +} + +// mentionsHandle reports whether text @-mentions handle. An e-mail address or a +// longer handle sharing the prefix is not a mention; GitLab names may hold dots +// and dashes, so a trailing dot ends a sentence but "@bot.name" is another user. +func mentionsHandle(text, handle string) bool { + if handle == "" { + return false + } + return mentionRegexp(handle).MatchString(text) +} + +// mentionRegexps caches the compiled pattern per handle: every event of a +// delivery is checked against the same few handles. +var mentionRegexps sync.Map // handle → *regexp.Regexp + +func mentionRegexp(handle string) *regexp.Regexp { + if re, ok := mentionRegexps.Load(handle); ok { + return re.(*regexp.Regexp) + } + re := regexp.MustCompile(`(?i)(?:^|[^\w@])@` + regexp.QuoteMeta(handle) + `(?:$|[^\w.-]|\.(?:$|[^\w.-]))`) + mentionRegexps.Store(handle, re) + return re +} diff --git a/backend/spi_writes.go b/backend/spi_writes.go new file mode 100644 index 0000000..6b435a3 --- /dev/null +++ b/backend/spi_writes.go @@ -0,0 +1,578 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/url" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +func blank(s string) bool { return strings.TrimSpace(s) == "" } + +var draftPrefixRe = regexp.MustCompile(`(?i)^\s*(?:\[draft\]|\(draft\)|draft:|wip:)\s*`) + +// withDraft sets or clears a title's draft marker: GitLab's API has no draft +// flag, a merge request is a draft when its title starts with "Draft:". +func withDraft(title string, draft bool) string { + title = draftPrefixRe.ReplaceAllString(title, "") + if draft { + return "Draft: " + title + } + return title +} + +// userIDs resolves user names to GitLab ids; an unknown name is a validation +// error, not something to skip silently. +func (a *glAPI) userIDs(logins []string) ([]int64, error) { + ids := make([]int64, 0, len(logins)) + for _, l := range logins { + id, ok, err := a.lookupUserID(strings.TrimSpace(l)) + if err != nil { + return nil, err + } + if !ok { + return nil, gp.Errorf(gp.CodeValidation, "GitLab has no user %q", clip(l)) + } + ids = append(ids, id) + } + return ids, nil +} + +// OpenChangeRequest implements gp.Provider. Labels go in the request (GitLab +// creates unknown ones); reviewers are applied afterwards and, if that fails, +// the merge request is still returned (retrying the open would only collide +// with the one just created). GitLab has no idempotency keys, so IdempotentWrites +// is not declared and core de-duplicates itself. +func (p *glProvider) OpenChangeRequest(_ context.Context, req gp.OpenCRRequest) (gp.ChangeRequest, error) { + if blank(req.Title) || blank(req.Head) || blank(req.Base) { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "title, head and base are required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangeRequest{}, err + } + body := map[string]any{ + "title": withDraft(req.Title, req.Draft), "description": req.Body, + "source_branch": req.Head, "target_branch": req.Base, + } + if len(req.Labels) > 0 { + body["labels"] = strings.Join(req.Labels, ",") + } + var m glMR + if err := api.send("POST", proj+"/merge_requests", body, &m); err != nil { + return gp.ChangeRequest{}, err + } + if len(req.Reviewers) > 0 { + if ids, err := api.userIDs(req.Reviewers); err == nil { + if err := api.send("PUT", proj+"/merge_requests/"+strconv.Itoa(m.IID), map[string]any{"reviewer_ids": ids}, &m); err != nil { + m.Reviewers = nil + } + } + } + return m.toCR(req.Target.Repo.FullPath, req.Target.Repo.FullPath), nil +} + +// UpdateChangeRequest implements gp.Provider. Only open and closed can be set; +// merging is its own operation. Toggling draft rewrites the title prefix. +func (p *glProvider) UpdateChangeRequest(_ context.Context, req gp.UpdateCRRequest) (gp.ChangeRequest, error) { + num, err := number(req.Number) + if err != nil { + return gp.ChangeRequest{}, err + } + patch := map[string]any{} + if req.Title != nil && blank(*req.Title) { + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "title must not be empty") + } + if req.Body != nil { + patch["description"] = *req.Body + } + if req.State != nil { + switch *req.State { + case gp.CROpen: + patch["state_event"] = "reopen" + case gp.CRClosed: + patch["state_event"] = "close" + default: + return gp.ChangeRequest{}, gp.Errorf(gp.CodeValidation, "state can only be set to open or closed; merging is a separate operation") + } + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.ChangeRequest{}, err + } + path := proj + "/merge_requests/" + num + title := req.Title + if req.Draft != nil { + if title == nil { + cur, err := p.getMR(api, proj, num) + if err != nil { + return gp.ChangeRequest{}, err + } + title = &cur.Title + } + t := withDraft(*title, *req.Draft) + title = &t + } + if title != nil { + patch["title"] = *title + } + var m glMR + if len(patch) > 0 { + err = api.send("PUT", path, patch, &m) + } else { + m, err = p.getMR(api, proj, num) + } + if err != nil { + return gp.ChangeRequest{}, err + } + return m.toCR(req.Target.Repo.FullPath, sourcePathOf(api, m, req.Target.Repo.FullPath)), nil +} + +func noteResult(host, fullPath, web, num string, id int64) gp.CommentResult { + return gp.CommentResult{ID: strconv.FormatInt(id, 10), URL: webURL(host, fullPath, "/-/"+web+"/"+num+"#note_"+strconv.FormatInt(id, 10))} +} + +// Comment implements gp.Provider: a conversation note on an issue or merge request. +func (p *glProvider) Comment(_ context.Context, req gp.CommentRequest) (gp.CommentResult, error) { + if blank(req.Body) { + return gp.CommentResult{}, gp.Errorf(gp.CodeValidation, "the comment is empty") + } + num, err := number(req.Subject.Number) + if err != nil { + return gp.CommentResult{}, err + } + seg, web, err := subjectPath(req.Subject.Kind) + if err != nil { + return gp.CommentResult{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.CommentResult{}, err + } + var n glNote + if err := api.send("POST", proj+"/"+seg+"/"+num+"/notes", map[string]any{"body": req.Body}, &n); err != nil { + return gp.CommentResult{}, err + } + return noteResult(hostOf(req.Target), req.Target.Repo.FullPath, web, num, n.ID), nil +} + +// ReplyThread implements gp.Provider: a note in a merge-request discussion; the +// thread id is the discussion id ListThreads returned. +func (p *glProvider) ReplyThread(_ context.Context, req gp.ReplyThreadRequest) (gp.CommentResult, error) { + if blank(req.Body) { + return gp.CommentResult{}, gp.Errorf(gp.CodeValidation, "the reply is empty") + } + if blank(req.ThreadID) { + return gp.CommentResult{}, gp.Errorf(gp.CodeValidation, "thread_id is required") + } + num, err := number(req.Number) + if err != nil { + return gp.CommentResult{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.CommentResult{}, err + } + var n glNote + if err := api.send("POST", proj+"/merge_requests/"+num+"/discussions/"+pathPart(req.ThreadID)+"/notes", map[string]any{"body": req.Body}, &n); err != nil { + return gp.CommentResult{}, err + } + return noteResult(hostOf(req.Target), req.Target.Repo.FullPath, "merge_requests", num, n.ID), nil +} + +// SubmitReview implements gp.Provider. GitLab has no pending review that is +// submitted at once through the REST API, so it is emulated and reported as such: +// every inline comment becomes a discussion on its line, then the summary is +// posted as a note, then an approval is recorded. "Request changes" has no REST +// equivalent; it is a note that says so. Inline paths are checked against the +// diff up front so a bad one fails before anything is posted. +func (p *glProvider) SubmitReview(_ context.Context, req gp.SubmitReviewRequest) (gp.ReviewResult, error) { + switch req.Verdict { + case gp.VerdictComment, gp.VerdictApprove, gp.VerdictRequestChanges: + default: + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "verdict must be comment, approve or request_changes") + } + num, err := number(req.Number) + if err != nil { + return gp.ReviewResult{}, err + } + for _, c := range req.Comments { + if blank(c.Path) || c.Line < 1 || blank(c.Body) { + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "inline comments need a path, a line and a body") + } + } + summary := strings.TrimSpace(req.Summary) + if req.Verdict == gp.VerdictRequestChanges { + if summary == "" { + summary = "Changes requested." + } else { + summary = "**Changes requested**\n\n" + summary + } + } + if len(req.Comments) == 0 && summary == "" && req.Verdict != gp.VerdictApprove { + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "a review needs a summary or inline comments") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.ReviewResult{}, err + } + mr, err := p.getMR(api, proj, num) + if err != nil { + return gp.ReviewResult{}, err + } + if req.HeadSHA != "" && mr.headSHA() != "" && !strings.EqualFold(req.HeadSHA, mr.headSHA()) { + return gp.ReviewResult{}, gp.Errorf(gp.CodeConflict, "the merge request has new commits since the reviewed head") + } + + res := gp.ReviewResult{Emulated: true} + web := webURL(hostOf(req.Target), req.Target.Repo.FullPath, "/-/merge_requests/"+num) + if len(req.Comments) > 0 { + files, err := p.changedFiles(api, proj, num) + if err != nil { + return gp.ReviewResult{}, err + } + for _, c := range req.Comments { + if _, ok := files[c.Path]; !ok { + return gp.ReviewResult{}, gp.Errorf(gp.CodeValidation, "%s is not part of this change", c.Path) + } + } + for _, c := range req.Comments { + f := files[c.Path] + pos := map[string]any{ + "position_type": "text", "base_sha": mr.DiffRefs.BaseSHA, "start_sha": mr.DiffRefs.StartSHA, "head_sha": mr.headSHA(), + "old_path": f.OldPath, "new_path": f.NewPath, + } + if c.Side == "old" { + pos["old_line"] = c.Line + } else { + pos["new_line"] = c.Line + } + var d glDiscussion + if err := api.send("POST", proj+"/merge_requests/"+num+"/discussions", map[string]any{"body": c.Body, "position": pos}, &d); err != nil { + e := gp.AsError(err) + return gp.ReviewResult{}, gp.Errorf(e.Code, "posted %d of %d inline comments, then GitLab refused one: %s", res.Posted, len(req.Comments), e.Message) + } + res.Posted++ + if res.ID == "" && len(d.Notes) > 0 { + res.ID, res.URL = strconv.FormatInt(d.Notes[0].ID, 10), web+"#note_"+strconv.FormatInt(d.Notes[0].ID, 10) + } + } + } + if summary != "" { + var n glNote + if err := api.send("POST", proj+"/merge_requests/"+num+"/notes", map[string]any{"body": summary}, &n); err != nil { + return gp.ReviewResult{}, err + } + res.ID, res.URL = strconv.FormatInt(n.ID, 10), web+"#note_"+strconv.FormatInt(n.ID, 10) + } + if req.Verdict == gp.VerdictApprove { + body := map[string]any{} + if sha := firstNonEmpty(req.HeadSHA, mr.headSHA()); sha != "" { + body["sha"] = sha // GitLab refuses the approval if the head moved + } + if err := api.send("POST", proj+"/merge_requests/"+num+"/approve", body, nil); err != nil { + return gp.ReviewResult{}, err + } + } + return res, nil +} + +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if v != "" { + return v + } + } + return "" +} + +// changedFiles indexes a merge request's files by new path (and old path for +// renames), reading at most maxDiffPages pages. +func (p *glProvider) changedFiles(api *glAPI, proj, num string) (map[string]glDiffFile, error) { + out := map[string]glDiffFile{} + for pg := 1; pg <= maxDiffPages; pg++ { + files, err := p.diffPage(api, proj, num, pg) + if err != nil { + return nil, err + } + for _, f := range files { + out[f.NewPath] = f + if _, dup := out[f.OldPath]; !dup { + out[f.OldPath] = f + } + } + if len(files) < listPerPage { + break + } + } + return out, nil +} + +// statusRef is what SetStatus hands back so a later call updates the same +// commit status: GitLab identifies one by (sha, name, ref). +type statusRef struct { + SHA string `json:"s"` + Name string `json:"n"` + Ref string `json:"r,omitempty"` +} + +func (r statusRef) encode() string { + b, _ := json.Marshal(r) + return base64.RawURLEncoding.EncodeToString(b) +} + +func decodeStatusRef(s string) (statusRef, bool) { + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return statusRef{}, false + } + var r statusRef + if json.Unmarshal(raw, &r) != nil || r.SHA == "" || r.Name == "" { + return statusRef{}, false + } + return r, true +} + +// maxStatusDescription is GitLab's limit on a commit status description. +const maxStatusDescription = 255 + +func cutRunes(s string, max int) string { + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + return string(r[:max-1]) + "…" +} + +// SetStatus implements gp.Provider as a commit status named after the run (the +// identity's own status line on the commit). Posting the same name on the same +// commit and ref updates it in place, so StatusRef only has to carry them. +func (p *glProvider) SetStatus(_ context.Context, req gp.SetStatusRequest) (gp.StatusResult, error) { + if blank(req.Title) { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "title is required") + } + state := map[gp.RunState]string{ + gp.RunQueued: "pending", gp.RunInProgress: "running", gp.RunSuccess: "success", + gp.RunFailure: "failed", gp.RunCancelled: "canceled", + }[req.State] + if state == "" { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "unknown state %q", req.State) + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.StatusResult{}, err + } + ref := statusRef{SHA: req.SHA, Name: req.Title} + if req.StatusRef != "" { + r, ok := decodeStatusRef(req.StatusRef) + if !ok { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "status_ref is not one this provider issued") + } + ref = r + ref.Name = req.Title + } else if req.Subject.Kind == gp.SubjectChangeRequest && req.Subject.Number != "" { + num, err := number(req.Subject.Number) + if err != nil { + return gp.StatusResult{}, err + } + mr, err := p.getMR(api, proj, num) + if err != nil { + return gp.StatusResult{}, err + } + // The branch ties the status to the merge request's pipeline view. + ref.Ref = mr.SourceBranch + if ref.SHA == "" { + ref.SHA = mr.headSHA() + } + } + if ref.SHA == "" { + return gp.StatusResult{}, gp.Errorf(gp.CodeValidation, "sha or a change request is required") + } + desc := req.Summary + if blank(desc) { + desc = req.Title + } + body := map[string]any{"state": state, "name": ref.Name, "description": cutRunes(desc, maxStatusDescription)} + if ref.Ref != "" { + body["ref"] = ref.Ref + } + if req.URL != "" { + body["target_url"] = req.URL + } + if err := api.send("POST", proj+"/statuses/"+url.PathEscape(ref.SHA), body, nil); err != nil { + return gp.StatusResult{}, err + } + return gp.StatusResult{StatusRef: ref.encode()}, nil +} + +// ── optional operations ────────────────────────────────────────────────────── + +// ResolveThread implements gp.ThreadResolver. +func (p *glProvider) ResolveThread(_ context.Context, req gp.ResolveThreadRequest) (gp.Empty, error) { + if blank(req.ThreadID) { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "thread_id is required") + } + num, err := number(req.Number) + if err != nil { + return gp.Empty{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + return gp.Empty{}, api.send("PUT", proj+"/merge_requests/"+num+"/discussions/"+pathPart(req.ThreadID), map[string]any{"resolved": req.Resolved}, nil) +} + +// AddLabels implements gp.LabelManager. +func (p *glProvider) AddLabels(_ context.Context, req gp.LabelsRequest) (gp.Empty, error) { + num, err := number(req.Subject.Number) + if err != nil { + return gp.Empty{}, err + } + seg, _, err := subjectPath(req.Subject.Kind) + if err != nil { + return gp.Empty{}, err + } + if len(req.Labels) == 0 { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "at least one label is required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + return gp.Empty{}, api.send("PUT", proj+"/"+seg+"/"+num, map[string]any{"add_labels": strings.Join(req.Labels, ",")}, nil) +} + +// RequestReviewers implements gp.ReviewerRequester. GitLab replaces the whole +// reviewer list, so the existing reviewers are kept and the new ones added. +func (p *glProvider) RequestReviewers(_ context.Context, req gp.PeopleRequest) (gp.Empty, error) { + if req.Subject.Kind != gp.SubjectChangeRequest { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "reviewers can only be requested on a merge request") + } + num, err := number(req.Subject.Number) + if err != nil { + return gp.Empty{}, err + } + if len(req.Logins) == 0 { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "at least one login is required") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + add, err := api.userIDs(req.Logins) + if err != nil { + return gp.Empty{}, err + } + mr, err := p.getMR(api, proj, num) + if err != nil { + return gp.Empty{}, err + } + ids := []int64{} + seen := map[int64]bool{} + for _, id := range append(refIDs(mr.Reviewers), add...) { + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + return gp.Empty{}, api.send("PUT", proj+"/merge_requests/"+num, map[string]any{"reviewer_ids": ids}, nil) +} + +func refIDs(rs []glRef) []int64 { + ids := make([]int64, 0, len(rs)) + for _, r := range rs { + ids = append(ids, r.ID) + } + return ids +} + +// SetAssignees implements gp.AssigneeSetter: the assignees become exactly logins. +func (p *glProvider) SetAssignees(_ context.Context, req gp.PeopleRequest) (gp.Empty, error) { + num, err := number(req.Subject.Number) + if err != nil { + return gp.Empty{}, err + } + seg, _, err := subjectPath(req.Subject.Kind) + if err != nil { + return gp.Empty{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + ids, err := api.userIDs(req.Logins) + if err != nil { + return gp.Empty{}, err + } + return gp.Empty{}, api.send("PUT", proj+"/"+seg+"/"+num, map[string]any{"assignee_ids": ids}, nil) +} + +// MergeChangeRequest implements gp.Merger. GitLab itself refuses (409) when the +// head moved since ExpectedHeadSHA, which is the guarantee core relies on. The +// merge strategy (merge commit, fast-forward) is a project setting in GitLab, so +// "rebase" cannot be requested per merge. +func (p *glProvider) MergeChangeRequest(_ context.Context, req gp.MergeRequest) (gp.MergeResult, error) { + num, err := number(req.Number) + if err != nil { + return gp.MergeResult{}, err + } + if blank(req.ExpectedHeadSHA) { + return gp.MergeResult{}, gp.Errorf(gp.CodeValidation, "expected_head_sha is required") + } + body := map[string]any{"sha": req.ExpectedHeadSHA} + switch req.Method { + case "", "merge": + case "squash": + body["squash"] = true + case "rebase": + return gp.MergeResult{}, gp.Errorf(gp.CodeValidation, "GitLab sets rebase and fast-forward merging per project; use merge or squash") + default: + return gp.MergeResult{}, gp.Errorf(gp.CodeValidation, "method must be merge or squash") + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.MergeResult{}, err + } + var m glMR + if err := api.send("PUT", proj+"/merge_requests/"+num+"/merge", body, &m); err != nil { + return gp.MergeResult{}, err + } + if st := strings.ToLower(m.State); st != "merged" && st != "locked" { + return gp.MergeResult{}, gp.Errorf(gp.CodeConflict, "GitLab did not merge the merge request") + } + return gp.MergeResult{SHA: firstNonEmpty(m.MergeCommitSHA, m.SquashCommitSHA, m.SHA)}, nil +} + +// RerunChecks implements gp.CheckRerunner: one job, or the failed jobs of the +// ref's newest pipeline. +func (p *glProvider) RerunChecks(_ context.Context, req gp.RerunRequest) (gp.Empty, error) { + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Empty{}, err + } + if req.CheckID != "" { + id, ok := strings.CutPrefix(req.CheckID, "job:") + if !ok || !allDigits(id) { + return gp.Empty{}, gp.Errorf(gp.CodeNotFound, "only pipeline jobs can be re-run") + } + return gp.Empty{}, api.send("POST", proj+"/jobs/"+id+"/retry", map[string]any{}, nil) + } + if blank(req.Ref) { + return gp.Empty{}, gp.Errorf(gp.CodeValidation, "ref is required") + } + pid, found, err := ciPipeline(api, proj, req.Ref) + if err != nil { + return gp.Empty{}, err + } + if !found { + return gp.Empty{}, gp.Errorf(gp.CodeNotFound, "no pipeline for %s", clip(req.Ref)) + } + return gp.Empty{}, api.send("POST", proj+"/pipelines/"+strconv.FormatInt(pid, 10)+"/retry", map[string]any{}, nil) +} diff --git a/backend/webhook.go b/backend/webhook.go deleted file mode 100644 index 348f852..0000000 --- a/backend/webhook.go +++ /dev/null @@ -1,402 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "regexp" - "strconv" - "strings" - "time" - - plugin "github.com/Paca-AI/plugin-sdk-go" -) - -// Branch task-ref patterns (path-segment aware, matches UI CreateBranch names): -// feat/PROJ-42-slug / PROJ-42 -// feat/PROJ/42-slug / PROJ/42 -// feat/42-slug / feat/42 (number-only when project has no task_id_prefix) -var ( - branchTaskRefHyphenRe = regexp.MustCompile(`(?i)(?:^|/)([A-Z][A-Z0-9]{1,19})-(\d{1,6})(?:-|/|$)`) - branchTaskRefSlashRe = regexp.MustCompile(`(?i)(?:^|/)([A-Z][A-Z0-9]{1,19})/(\d{1,6})(?:-|/|$)`) - branchTaskRefNumberOnlyRe = regexp.MustCompile(`(?i)(?:^|/)(\d{1,6})(?:-|$)`) -) - -// ─── POST /webhook ──────────────────────────────────────────────────────────── - -func (p *gitlabPlugin) receiveWebhook(req *plugin.Request, res *plugin.Response) { - projectID := req.PathParam("projectId") - 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 { - res.NoContent() - return - } - - repoFullName := extractGitLabProjectPath(body) - if repoFullName == "" { - res.NoContent() - return - } - - 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 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) - - result, err := p.db.Query( - `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("gitlab: failed to query repository: " + err.Error() + ", repo=" + repoFullName) - return err - } - if len(result.Rows) == 0 { - p.log.Info("gitlab: repository not found, repo=" + repoFullName) - return nil - } - sc := newRowScanner(result.Columns, result.Rows[0]) - repoID := sc.str("id") - projectID = sc.str("project_id") - webhookSecretEnc := sc.str("webhook_secret_enc") - - if webhookSecretEnc == "" { - p.log.Error("gitlab: repository has no webhook signing token configured, refusing delivery, repo=" + repoFullName) - return nil - } - signingToken, dErr := p.decrypt(webhookSecretEnc) - if dErr != nil { - p.log.Error("gitlab: failed to decrypt webhook signing token: " + dErr.Error() + ", repo=" + repoFullName) - return dErr - } - if !verifyGitLabSigningToken(payload, signingToken, webhookID, timestamp, signature) { - p.log.Info("gitlab: invalid webhook signature, repo=" + repoFullName) - return nil - } - - 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 hook", "push": - return p.handlePushEvent(repoID, projectID, payload) - default: - p.log.Info("gitlab: unhandled event type, event=" + event) - } - return nil -} - -func (p *gitlabPlugin) handlePREvent(repoID, projectID string, payload []byte) error { - var event struct { - 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("gitlab: failed to parse merge_request event: " + err.Error()) - return err - } - oa := event.ObjectAttributes - action := oa.Action - state := normalizeMRState(oa.State, oa.MergedAt != nil) - - p.log.Info("gitlab: processing merge_request, action=" + action + ", mr_iid=" + strconv.Itoa(oa.IID) + ", title=" + oa.Title + ", repo_id=" + repoID) - - var previousState string - 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 oa.MergedAt != nil { - s := oa.MergedAt.UTC().Format(time.RFC3339Nano) - mergedAtStr = &s - } - - upserted, err := p.db.Query(` - 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, 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("gitlab: failed to upsert MR: " + err.Error() + ", repo_id=" + repoID + ", mr_iid=" + strconv.Itoa(oa.IID)) - return err - } - var prID string - if len(upserted.Rows) > 0 { - prID = newRowScanner(upserted.Columns, upserted.Rows[0]).str("id") - } - - if action == "open" || action == "reopen" { - brResult, _ := p.db.Query( - `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 gitlab_task_mr_links (task_id, merge_request_id, created_at) - VALUES ($1,$2,$3) - ON CONFLICT (task_id, merge_request_id) DO NOTHING - `, taskID, prID, now) - - plugin.EmitEvent("gitlab.mr_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "pr_number": oa.IID, - }) - p.log.Info("gitlab: MR auto-linked to task, task_id=" + taskID + ", mr_iid=" + strconv.Itoa(oa.IID)) - } - } - - stateChanged := previousState != "" && previousState != state - 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("gitlab.mr_updated", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "pr_number": oa.IID, - "action": action, - }) - if stateChanged { - plugin.EmitEvent("gitlab.mr_state_changed", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "pr_number": oa.IID, - "from_state": previousState, - "to_state": state, - }) - } - } - } - return nil -} - -func (p *gitlabPlugin) handlePushEvent(repoID, projectID string, payload []byte) error { - var event struct { - Ref string `json:"ref"` - Before string `json:"before"` - After string `json:"after"` - Created bool `json:"created"` // GitHub-style; GitLab usually omits this - } - if err := json.Unmarshal(payload, &event); err != nil { - return nil - } - branchName := strings.TrimPrefix(event.Ref, "refs/heads/") - if branchName == event.Ref { - return nil // not a branch ref (e.g. tag) - } - if !isNewBranchPush(event.Created, event.Before, event.After) { - return nil - } - - refPrefix, taskNumber, ok := extractBranchTaskRef(branchName) - if !ok { - return nil - } - - taskID, found := p.resolveTaskForBranchRef(projectID, refPrefix, taskNumber) - if !found { - return nil - } - - now := time.Now().UTC().Format(time.RFC3339Nano) - _, _ = p.db.Exec(` - 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("gitlab.branch_linked", map[string]any{ - "project_id": projectID, - "task_id": taskID, - "repo_id": repoID, - "branch_name": branchName, - }) - p.log.Info("gitlab: branch auto-linked to task, task_id=" + taskID + ", branch=" + branchName) - return nil -} - -// isNewBranchPush detects a newly created branch on push. -// GitLab sends before = 40 zero hex chars; GitHub may set created=true. -func isNewBranchPush(created bool, before, after string) bool { - zeroSHA := strings.Repeat("0", 40) - isDeleted := after == zeroSHA - isCreated := created || before == "" || before == zeroSHA - return isCreated && !isDeleted -} - -// resolveTaskForBranchRef finds a task in projectID matching the branch ref. -// Prefixed refs (PROJ-1 / PROJ/1) require the project's task_id_prefix to match. -// Number-only refs (feat/1-slug) are allowed only when the project has an empty prefix. -func (p *gitlabPlugin) resolveTaskForBranchRef(projectID, refPrefix string, taskNumber int64) (taskID string, ok bool) { - prefixResult, err := p.db.Query(`SELECT task_id_prefix FROM projects WHERE id = $1`, projectID) - if err != nil || len(prefixResult.Rows) == 0 { - return "", false - } - projectPrefix := strings.TrimSpace(newRowScanner(prefixResult.Columns, prefixResult.Rows[0]).str("task_id_prefix")) - - if refPrefix != "" { - if projectPrefix == "" || !strings.EqualFold(refPrefix, projectPrefix) { - return "", false - } - } else if projectPrefix != "" { - // Number-only branch, but project uses a prefix — ignore (matches UI). - return "", false - } - - taskResult, tErr := p.db.Query(` - SELECT id FROM tasks - WHERE project_id = $1 AND task_number = $2 AND deleted_at IS NULL - LIMIT 1 - `, projectID, taskNumber) - if tErr != nil || len(taskResult.Rows) == 0 { - return "", false - } - return newRowScanner(taskResult.Columns, taskResult.Rows[0]).str("id"), true -} - -// 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 - } - 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 extractGitLabProjectPath(payload []byte) string { - var v struct { - Project struct { - PathWithNamespace string `json:"path_with_namespace"` - } `json:"project"` - Repository struct { - PathWithNamespace string `json:"path_with_namespace"` - } `json:"repository"` - } - if err := json.Unmarshal(payload, &v); err != nil { - return "" - } - if v.Project.PathWithNamespace != "" { - return v.Project.PathWithNamespace - } - return v.Repository.PathWithNamespace -} - -// conventionalBranchTypes are gitflow-style path segments, not Paca task prefixes. -// Needed so "feat/1-slug" is number-only, not prefix=FEAT via the slash form. -var conventionalBranchTypes = map[string]struct{}{ - "feat": {}, "feature": {}, "fix": {}, "bugfix": {}, "chore": {}, - "docs": {}, "doc": {}, "refactor": {}, "style": {}, "test": {}, - "tests": {}, "ci": {}, "build": {}, "perf": {}, "revert": {}, - "hotfix": {}, "release": {}, "wip": {}, -} - -func isConventionalBranchType(s string) bool { - _, ok := conventionalBranchTypes[strings.ToLower(s)] - return ok -} - -// extractBranchTaskRef parses a Paca task reference from a git branch name. -// Returns prefix="" for number-only branches (used when project has no task_id_prefix). -func extractBranchTaskRef(branchName string) (prefix string, taskNumber int64, ok bool) { - if m := branchTaskRefHyphenRe.FindStringSubmatch(branchName); m != nil { - n, err := strconv.ParseInt(m[2], 10, 64) - if err == nil && n > 0 { - return strings.ToUpper(m[1]), n, true - } - } - for _, m := range branchTaskRefSlashRe.FindAllStringSubmatch(branchName, -1) { - if isConventionalBranchType(m[1]) { - continue - } - n, err := strconv.ParseInt(m[2], 10, 64) - if err == nil && n > 0 { - return strings.ToUpper(m[1]), n, true - } - } - if m := branchTaskRefNumberOnlyRe.FindStringSubmatch(branchName); m != nil { - n, err := strconv.ParseInt(m[1], 10, 64) - if err == nil && n > 0 { - return "", n, true - } - } - return "", 0, false -} - -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/backend/webhook_branch_test.go b/backend/webhook_branch_test.go deleted file mode 100644 index 1d2d2e1..0000000 --- a/backend/webhook_branch_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "strings" - "testing" - - "github.com/Paca-AI/plugin-sdk-go/plugintest" -) - -func TestExtractBranchTaskRef(t *testing.T) { - cases := []struct { - branch string - prefix string - number int64 - ok bool - }{ - // Hyphen form (UI default with prefix) - {"feat/SKUI-1-prova", "SKUI", 1, true}, - {"feat/skui-1-prova", "SKUI", 1, true}, - {"SKUI-42", "SKUI", 42, true}, - {"chore/PROJ-99-add-thing", "PROJ", 99, true}, - {"feat/SKUI-1", "SKUI", 1, true}, - {"fix/ABC1-2-hotfix", "ABC1", 2, true}, - - // Slash form - {"feat/SKUI/1-prova", "SKUI", 1, true}, - {"SKUI/7", "SKUI", 7, true}, - {"chore/PROJ/12-docs", "PROJ", 12, true}, - - // Number-only (UI when project has no task_id_prefix) - {"feat/1-prova", "", 1, true}, - {"feat/42", "", 42, true}, - {"1-slug", "", 1, true}, - {"fix/3-hotfix", "", 3, true}, - - // Non-matches - {"main", "", 0, false}, - {"feat/add-login", "", 0, false}, - {"feat/SKUI-", "", 0, false}, - {"feat/-1-x", "", 0, false}, - {"feature/9-x", "", 9, true}, // conventional type + number-only - } - for _, tc := range cases { - prefix, n, ok := extractBranchTaskRef(tc.branch) - if ok != tc.ok || prefix != tc.prefix || n != tc.number { - t.Errorf("extractBranchTaskRef(%q) = (%q, %d, %v); want (%q, %d, %v)", - tc.branch, prefix, n, ok, tc.prefix, tc.number, tc.ok) - } - } -} - -func TestIsNewBranchPush(t *testing.T) { - zeros := strings.Repeat("0", 40) - sha := "abc123def456789012345678901234567890abcd" - - if !isNewBranchPush(false, zeros, sha) { - t.Error("GitLab new branch (before=zeros) should be created") - } - if !isNewBranchPush(true, sha, sha) { - t.Error("created=true should be created") - } - if !isNewBranchPush(false, "", sha) { - t.Error("empty before should be treated as created") - } - if isNewBranchPush(false, sha, sha) { - t.Error("normal push on existing branch should not be created") - } - if isNewBranchPush(false, zeros, zeros) { - t.Error("branch delete (after=zeros) should not be created") - } - if isNewBranchPush(true, zeros, zeros) { - t.Error("delete must win even if created=true") - } -} - -func TestHandlePushEvent_AutoLinksPrefixedBranch(t *testing.T) { - tc := setupPlugin(t) - seedProjectAndTask(t, tc, "SKUI", int64(1)) - seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") - - payload := pushPayload("feat/SKUI-1-auto-link-smoke", true) - res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d: %s", res.StatusCode, res.BodyString()) - } - if !tc.Log.HasMessage("branch auto-linked to task") { - t.Fatalf("expected auto-link log; entries: %+v", tc.Log.Entries()) - } - rows := tc.DB.AllRows("gitlab_task_branches") - if len(rows) != 1 { - t.Fatalf("expected 1 linked branch, got %d", len(rows)) - } -} - -func TestHandlePushEvent_AutoLinksSlashPrefixedBranch(t *testing.T) { - tc := setupPlugin(t) - seedProjectAndTask(t, tc, "SKUI", int64(1)) - seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") - - payload := pushPayload("feat/SKUI/1-slash-form", true) - res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d", res.StatusCode) - } - if !tc.Log.HasMessage("branch auto-linked to task") { - t.Fatalf("expected auto-link for slash form; entries: %+v", tc.Log.Entries()) - } -} - -func TestHandlePushEvent_AutoLinksNumberOnlyWhenNoPrefix(t *testing.T) { - tc := setupPlugin(t) - seedProjectAndTask(t, tc, "", int64(7)) - seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") - - payload := pushPayload("feat/7-no-prefix", true) - res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d", res.StatusCode) - } - if !tc.Log.HasMessage("branch auto-linked to task") { - t.Fatalf("expected number-only auto-link; entries: %+v", tc.Log.Entries()) - } -} - -func TestHandlePushEvent_IgnoresNumberOnlyWhenProjectHasPrefix(t *testing.T) { - tc := setupPlugin(t) - seedProjectAndTask(t, tc, "SKUI", int64(1)) - seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") - - payload := pushPayload("feat/1-should-not-link", true) - res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d", res.StatusCode) - } - if tc.Log.HasMessage("branch auto-linked to task") { - t.Fatal("number-only branch must not auto-link when project has a prefix") - } - if len(tc.DB.AllRows("gitlab_task_branches")) != 0 { - t.Fatal("expected no branch links") - } -} - -func TestHandlePushEvent_IgnoresExistingBranchPush(t *testing.T) { - tc := setupPlugin(t) - seedProjectAndTask(t, tc, "SKUI", int64(1)) - seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") - - payload := pushPayload("feat/SKUI-1-existing", false) - res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d", res.StatusCode) - } - if tc.Log.HasMessage("branch auto-linked to task") { - t.Fatal("push to existing branch must not auto-link") - } -} - -func TestHandlePushEvent_MismatchedPrefixIgnored(t *testing.T) { - tc := setupPlugin(t) - seedProjectAndTask(t, tc, "SKUI", int64(1)) - seedEncryptedRepo(t, tc, "repo-1", testProjectID, "gorlix/documentazione-vault", "push-secret") - - payload := pushPayload("feat/OTHER-1-nope", true) - res := tc.Call("POST", "/webhook", signedPushWebhookRequest(testProjectID, payload, "push-secret")) - if res.StatusCode != 204 { - t.Fatalf("expected 204, got %d", res.StatusCode) - } - if tc.Log.HasMessage("branch auto-linked to task") { - t.Fatal("mismatched prefix must not auto-link") - } -} - -func seedProjectAndTask(t *testing.T, tc *plugintest.Context, taskIDPrefix string, taskNumber int64) { - t.Helper() - tc.DB.SeedRows("projects", - []string{"id", "task_id_prefix"}, - [][]any{{testProjectID, taskIDPrefix}}) - // Replace tasks seed from setupPlugin with columns needed for lookup. - tc.DB.SeedRows("tasks", - []string{"id", "project_id", "task_number", "deleted_at"}, - [][]any{{testTaskID, testProjectID, taskNumber, nil}}) -} - -func pushPayload(branch string, isNew bool) []byte { - before := "abc123def456789012345678901234567890abcd" - if isNew { - before = strings.Repeat("0", 40) - } - body := fmt.Sprintf(`{ - "object_kind":"push", - "ref":"refs/heads/%s", - "before":"%s", - "after":"def456abc789012345678901234567890abcdef0", - "project":{"path_with_namespace":"gorlix/documentazione-vault"} - }`, branch, before) - return []byte(body) -} - -func signedPushWebhookRequest(projectID string, payload []byte, signingToken string) plugintest.Request { - req := signedWebhookRequest(projectID, payload, signingToken) - req.Headers["X-Gitlab-Event"] = "Push Hook" - return req -} - -// Ensure payload is valid JSON (catches typos in test helpers). -func TestPushPayloadJSON(t *testing.T) { - var v map[string]any - if err := json.Unmarshal(pushPayload("feat/SKUI-1-x", true), &v); err != nil { - t.Fatal(err) - } -} diff --git a/frontend/bun.lock b/frontend/bun.lock deleted file mode 100644 index 22b2484..0000000 --- a/frontend/bun.lock +++ /dev/null @@ -1,275 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@paca-ai/github-plugin", - "dependencies": { - "@tanstack/react-query": "^5.99.2", - "lucide-react": "^0.511.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - }, - "devDependencies": { - "@originjs/vite-plugin-federation": "^1.3.6", - "@paca-ai/plugin-sdk-react": "^0.2.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^4.3.0", - "typescript": "^5.8.3", - "vite": "^6.0.0", - }, - }, - }, - "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@originjs/vite-plugin-federation": ["@originjs/vite-plugin-federation@1.4.1", "", { "dependencies": { "estree-walker": "^3.0.2", "magic-string": "^0.27.0" } }, "sha512-Uo08jW5pj1t58OUKuZNkmzcfTN2pqeVuAWCCiKf/75/oll4Efq4cHOqSE1FXMlvwZNGDziNdDyBbQ5IANem3CQ=="], - - "@paca-ai/plugin-sdk-react": ["@paca-ai/plugin-sdk-react@0.2.0", "", { "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-tiSM9DHx9zw+lqtsLyyMd9gww5nvpljTKm08me/Kg8NLjJSH7AH57zYfgL4QoQdpNxfPtZWylqeUh7ioZbbaVQ=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], - - "@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], - - "@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="], - - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.353", "", {}, "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w=="], - - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], - - "magic-string": ["magic-string@0.27.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.13" } }, "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "node-releases": ["node-releases@2.0.44", "", {}, "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], - - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - - "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - } -} diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index cb0ff5c..0000000 --- a/frontend/index.html +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index 5b6a97f..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@paca-ai/gitlab-plugin", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite build --watch", - "typecheck": "tsc --noEmit", - "preview": "vite preview" - }, - "dependencies": { - "@tanstack/react-query": "^5.99.2", - "lucide-react": "^0.511.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@originjs/vite-plugin-federation": "^1.3.6", - "@paca-ai/plugin-sdk-react": "^0.2.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "typescript": "^5.8.3", - "vite": "^6.0.0", - "@vitejs/plugin-react": "^4.3.0" - } -} diff --git a/frontend/src/GitLabSettingsTab.tsx b/frontend/src/GitLabSettingsTab.tsx deleted file mode 100644 index e832e65..0000000 --- a/frontend/src/GitLabSettingsTab.tsx +++ /dev/null @@ -1,987 +0,0 @@ -import { PluginApiClient, PluginQueryClientProvider } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertCircle, - BookOpen, - Check, - GitBranch, - GitPullRequest, - KeyRound, - Loader2, - Plus, - RefreshCw, - Search, - Trash2, - Unlink, - X, -} from "lucide-react"; -import { useMemo, useState } from "react"; -import { - type AccessibleRepo, - accessibleReposKey, - deleteGitLabToken, - ErrorCode, - getPluginErrorCode, - type GitLabIntegration, - type GitLabTokenKind, - getGitLabIntegration, - integrationKey, - type LinkedRepository, - linkedReposKey, - listAccessibleRepos, - listLinkedRepositories, - linkRepository, - setGitLabToken, - unlinkRepository, -} from "./gitlab-api"; -import { t } from "./i18n"; - -// ── Utilities ────────────────────────────────────────────────────────────────── - -function cn(...classes: (string | undefined | null | false)[]): string { - return classes.filter(Boolean).join(" "); -} - -// ── GitLab Icon ──────────────────────────────────────────────────────────────── - -function GitLabIcon(props: React.SVGProps) { - return ( - - GitLab - - - ); -} - -// ── Simple UI Primitives ─────────────────────────────────────────────────────── - -function Btn({ - children, - variant = "default", - size = "default", - className, - ...props -}: React.ButtonHTMLAttributes & { - variant?: "default" | "outline" | "destructive" | "ghost"; - size?: "default" | "sm"; -}) { - const base = - "inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"; - const variants = { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - ghost: "hover:bg-accent hover:text-accent-foreground", - }; - const sizes = { - default: "h-10 px-4 py-2 text-sm", - sm: "h-8 px-3 text-xs", - }; - return ( - - ); -} - -function Inp({ - className, - ...props -}: React.InputHTMLAttributes) { - return ( - - ); -} - -function Skeleton({ className }: { className?: string }) { - return
; -} - -// ── Modal ───────────────────────────────────────────────────────────────────── - -function Modal({ - open, - onClose, - children, -}: { - open: boolean; - onClose: () => void; - children: React.ReactNode; -}) { - if (!open) return null; - return ( -
- {children} -
- ); -} - -function ModalContent({ - className, - children, -}: { - className?: string; - children: React.ReactNode; -}) { - return ( -
e.stopPropagation()} - className={cn( - "relative z-10 mx-auto flex flex-col max-h-[85vh] lg:h-[85vh] overflow-hidden rounded-lg border border-border bg-background shadow-xl w-full", - className, - )} - > - {children} -
- ); -} - -// ── Token card ──────────────────────────────────────────────────────────────── - -function TokenCard({ - api, - projectId, - hasIntegration, - onTokenSet, - canEdit, - locale, - integration, -}: { - api: PluginApiClient; - projectId: string; - hasIntegration: boolean; - onTokenSet: () => 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: () => - setGitLabToken(api, token.trim(), { - instanceUrl: instanceUrl.trim(), - tokenKind, - }), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: integrationKey(projectId), - }); - setToken(""); - setError(null); - onTokenSet(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitLabInvalidToken) { - setError(t("settings.token.invalid", locale)); - return; - } - setError(t("settings.token.saveFailed", locale)); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: () => deleteGitLabToken(api), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: integrationKey(projectId), - }); - await queryClient.removeQueries({ - queryKey: linkedReposKey(projectId), - }); - await queryClient.removeQueries({ - queryKey: accessibleReposKey(projectId), - }); - setConfirmOpen(false); - }, - onError: () => { - setError(t("settings.token.removeFailed", locale)); - }, - }); - - if (hasIntegration) { - return ( - <> -
-
-
- -
-
-

- {t("settings.token.saved", locale)} -

-

- {t("settings.token.encrypted", locale)} -

-
-
- {canEdit && ( - setConfirmOpen(true)} - disabled={deleteMutation.isPending} - > - {deleteMutation.isPending ? ( - - ) : ( - - )} - {t("settings.token.remove", locale)} - - )} -
- - setConfirmOpen(false)}> - -
- -
-

- {t("settings.token.removeTitle", locale)} -

-

- {t("settings.token.removeBody", locale)} -

- {deleteMutation.isError && ( -

- {t("settings.token.removeFailed", locale)} -

- )} -
- setConfirmOpen(false)} - > - {t("settings.token.cancel", locale)} - - deleteMutation.mutate()} - > - {deleteMutation.isPending ? ( - <> - - {t("settings.token.removing", locale)} - - ) : ( - t("settings.token.remove", locale) - )} - -
-
-
- - ); - } - - return ( -
-

- {t("settings.instanceUrl.hint", locale)}.{" "} - {t("settings.token.scopeHintBefore", locale)}{" "} - - api - {" "} - {t("settings.token.scopeHintAfter", locale)} -

- - - - {error ?

{error}

: null} -
- ); -} - -// ── Add repository dialog ───────────────────────────────────────────────────── - -function AddRepoDialog({ - api, - projectId, - open, - onOpenChange, - locale, -}: { - api: PluginApiClient; - projectId: string; - open: boolean; - onOpenChange: (open: boolean) => void; - locale?: string; -}) { - const queryClient = useQueryClient(); - const { - data: repos = [], - isLoading, - isFetching, - } = useQuery({ - queryKey: accessibleReposKey(projectId), - queryFn: () => listAccessibleRepos(api), - enabled: open, - staleTime: 0, - }); - const [search, setSearch] = useState(""); - const [error, setError] = useState(null); - - const linkMutation = useMutation({ - mutationFn: (repo: AccessibleRepo) => - linkRepository(api, repo.owner, repo.repo_name), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: linkedReposKey(projectId), - }); - setError(null); - onOpenChange(false); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitLabWebhookURLNotPublic) { - setError(t("settings.addRepo.error.webhookNotPublic", locale)); - return; - } - if (code === ErrorCode.GitLabWebhookCreationFailed) { - setError(t("settings.addRepo.error.webhookFailed", locale)); - return; - } - if (code === ErrorCode.GitLabRepoAlreadyLinked) { - setError(t("settings.addRepo.error.alreadyLinked", locale)); - return; - } - if (code === ErrorCode.GitLabRepoNotAccessible) { - setError(t("settings.addRepo.error.notAccessible", locale)); - return; - } - setError(t("settings.addRepo.error.generic", locale)); - }, - }); - - const filtered = repos.filter((r) => - r.full_name.toLowerCase().includes(search.toLowerCase()), - ); - - function handleOpenChange(next: boolean) { - if (!next) { - setSearch(""); - setError(null); - } - onOpenChange(next); - } - - return ( - handleOpenChange(false)}> - -
-
- -
-

- {t("settings.addRepo.title", locale)} -

-
-

- {t("settings.addRepo.desc", locale)} -

- - {/* Search + reload */} -
-
- - setSearch(e.target.value)} - className="pl-9 text-sm" - // biome-ignore lint/a11y/noAutofocus: intentional dialog autofocus - autoFocus - /> -
- -
- - {error ? ( -

- {error} -

- ) : null} - - {/* Repo list */} -
-
- {isLoading ? ( - [0, 1, 2, 3].map((i) => ( - - )) - ) : repos.length === 0 ? ( -
- -

{t("settings.addRepo.empty", locale)}

-
- ) : filtered.length === 0 ? ( -

- {t("settings.addRepo.noMatch", locale, { search })} -

- ) : ( - filtered.map((repo) => ( - - )) - )} -
-
- -
- handleOpenChange(false)} - > - {t("settings.addRepo.close", locale)} - -
-
-
- ); -} - -// ── Linked repo item ────────────────────────────────────────────────────────── - -function LinkedRepoItem({ - api, - projectId, - repo, - canEdit, - locale, -}: { - api: PluginApiClient; - projectId: string; - repo: LinkedRepository; - canEdit: boolean; - locale?: string; -}) { - const queryClient = useQueryClient(); - const [confirmOpen, setConfirmOpen] = useState(false); - - const unlinkMutation = useMutation({ - mutationFn: () => unlinkRepository(api, repo.id), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: linkedReposKey(projectId), - }); - setConfirmOpen(false); - }, - }); - - return ( - <> -
-
-
- -
-
- - {repo.full_name} - -

- {t("settings.repos.defaultBranch", locale)}{" "} - {repo.default_branch} -

-
-
- {canEdit && ( - setConfirmOpen(true)} - > - - {t("settings.unlink.action", locale)} - - )} -
- - setConfirmOpen(false)}> - -
- -
-

- {t("settings.unlink.title", locale)} -

-

- {t("settings.unlink.bodyBefore", locale)}{" "} - - {repo.full_name} - {" "} - {t("settings.unlink.bodyAfter", locale)} -

- {unlinkMutation.isError && ( -

- {t("settings.unlink.failed", locale)} -

- )} -
- setConfirmOpen(false)} - > - {t("common.cancel", locale)} - - unlinkMutation.mutate()} - > - {unlinkMutation.isPending ? ( - <> - - {t("settings.unlink.unlinking", locale)} - - ) : ( - t("settings.unlink.confirm", locale) - )} - -
-
-
- - ); -} - -// ── Main Settings ───────────────────────────────────────────────────────────── - -function GitLabSettingsInner({ - api, - projectId, - canEdit, - locale, -}: { - api: PluginApiClient; - projectId: string; - canEdit: boolean; - locale?: string; -}) { - const { data: integration, isLoading: integrationLoading } = useQuery< - GitLabIntegration | undefined - >({ - queryKey: integrationKey(projectId), - queryFn: () => getGitLabIntegration(api), - retry: false, - throwOnError: false, - }); - - const { data: linkedRepos = [], isLoading: reposLoading } = useQuery< - LinkedRepository[] - >({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - enabled: !!integration, - }); - - const queryClient = useQueryClient(); - const hasIntegration = integration?.connected === true; - const hasRepos = linkedRepos.length > 0; - - const [addRepoOpen, setAddRepoOpen] = useState(false); - - const steps = [ - { - num: 1, - label: t("settings.step.connectToken", locale), - done: hasIntegration, - }, - { - num: 2, - label: t("settings.step.linkRepo", locale), - done: hasRepos, - }, - ]; - - return ( -
- {/* Header card */} -
-
- -

- {t("settings.integrationTitle", locale)} -

-
-

- {t("settings.integrationDesc", locale)} -

- - {/* Progress steps */} -
- {steps.map((step, idx) => ( -
-
- {step.done ? : step.num} -
- - {step.label} - - {idx < steps.length - 1 && ( -
- )} -
- ))} -
- - {/* Token section */} -
-
- - -
- {integrationLoading ? ( - - ) : ( - setAddRepoOpen(true)} - canEdit={canEdit} - locale={locale} - integration={integration} - /> - )} -
-
- - {/* Repository section — only after token */} - {hasIntegration && ( -
-
-
- -

- {t("settings.repos.title", locale)} -

- -
- {canEdit && ( - setAddRepoOpen(true)} - > - - {t("settings.repos.link", locale)} - - )} -
-

- {hasRepos - ? t("settings.repos.webhooksAuto", locale) - : t("settings.repos.emptyHint", locale)} -

- - {reposLoading ? ( -
- - -
- ) : ( -
- {linkedRepos.map((repo) => ( - - ))} - - {!hasRepos && ( -
- -

{t("settings.repos.empty", locale)}

- {canEdit && ( - setAddRepoOpen(true)} - > - - {t("settings.repos.linkFirst", locale)} - - )} -
- )} -
- )} - - {canEdit && ( - - )} -
- )} - - {/* Webhook hint */} - {hasRepos && ( -
- -

- {t("settings.webhook.hint", locale)} -

-
- )} - - {/* No integration hint */} - {!hasIntegration && !integrationLoading && ( -
- -

- {t("settings.noIntegration", locale)} -

-
- )} -
- ); -} - -// ── Public export ───────────────────────────────────────────────────────────── - -interface GitLabSettingsTabProps { - projectId: string; - canEdit?: boolean; - /** Host locale hint (e.g. "it", "en-US"). Falls back to navigator.language. */ - locale?: string; -} - -export default function GitLabSettingsTab({ - projectId, - canEdit = true, - locale, -}: GitLabSettingsTabProps) { - const api = useMemo( - () => - new PluginApiClient({ - baseUrl: `${window.location.origin}/api/v1`, - projectId, - fetch: (url, init) => - window.fetch(url, { ...init, credentials: "include" }), - }), - [projectId], - ); - - return ( - - - - ); -} diff --git a/frontend/src/GitLabTaskSection.tsx b/frontend/src/GitLabTaskSection.tsx deleted file mode 100644 index 9783434..0000000 --- a/frontend/src/GitLabTaskSection.tsx +++ /dev/null @@ -1,1246 +0,0 @@ -import { PluginApiClient, PluginQueryClientProvider } from "@paca-ai/plugin-sdk-react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Check, - ChevronDown, - ChevronRight, - ClipboardCopy, - ExternalLink, - GitBranch, - GitMerge, - GitPullRequest, - GitPullRequestClosed, - Link2, - Loader2, - Plus, - Terminal, - Trash2, -} from "lucide-react"; -import { useMemo, useState } from "react"; -import { - type CreateBranchResult, - createBranch, - ErrorCode, - getPluginErrorCode, - type LinkedRepository, - linkBranchToTask, - linkedReposKey, - listLinkedRepositories, - listTaskBranches, - listTaskPRs, - linkPRToTask, - type PullRequest, - type TaskBranch, - taskBranchesKey, - taskPRsKey, - unlinkPRFromTask, -} from "./gitlab-api"; -import { t } from "./i18n"; - -// ── Utilities ────────────────────────────────────────────────────────────────── - -function cn(...classes: (string | undefined | null | false)[]): string { - return classes.filter(Boolean).join(" "); -} - -// ─────────────────────── Merge Requests Section ──────────────────────────────── - -// ── PR state badge ──────────────────────────────────────────────────────────── - -function PRStateBadge({ - state, - locale, -}: { - state: PullRequest["state"]; - locale?: string; -}) { - if (state === "merged") { - return ( - - - {t("task.mr.state.merged", locale)} - - ); - } - if (state === "closed") { - return ( - - - {t("task.mr.state.closed", locale)} - - ); - } - return ( - - - {t("task.mr.state.open", locale)} - - ); -} - -// ── Single PR row ───────────────────────────────────────────────────────────── - -function PRRow({ - api, - pr, - projectId, - taskId, - canEdit, - locale, -}: { - api: PluginApiClient; - pr: PullRequest; - projectId: string; - taskId: string; - canEdit: boolean; - locale?: string; -}) { - const queryClient = useQueryClient(); - - const unlinkMutation = useMutation({ - mutationFn: () => unlinkPRFromTask(api, taskId, pr.id), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: taskPRsKey(projectId, taskId), - }); - }, - }); - - return ( -
-
- -
-
- - {pr.title} - - -
- - #{pr.pr_number} - - · - - {pr.head_branch} - - {pr.author && ( - <> - · - - {t("task.mr.by", locale, { author: pr.author })} - - - )} -
-
- {canEdit && ( - - )} -
- ); -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function parseGitLabPRUrl( - raw: string, -): { fullName: string; prNumber: number } | null { - try { - const url = new URL(raw.trim()); - const parts = url.pathname.replace(/^\//, "").split("/").filter(Boolean); - const dash = parts.indexOf("-"); - if (dash < 1 || parts[dash + 1] !== "merge_requests") return null; - const prNumber = Number(parts[dash + 2]); - if (!Number.isInteger(prNumber) || prNumber <= 0) return null; - const fullName = parts.slice(0, dash).join("/"); - if (!fullName) return null; - return { fullName, prNumber }; - } catch { - return null; - } -} - -// ── Link PR form ────────────────────────────────────────────────────────────── - -function LinkPRForm({ - api, - projectId, - taskId, - repos, - onDone, - locale, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - repos: LinkedRepository[]; - onDone: () => void; - locale?: string; -}) { - const queryClient = useQueryClient(); - const [selectedRepoId, setSelectedRepoId] = useState( - repos.length === 1 ? repos[0].id : "", - ); - const [value, setValue] = useState(""); - const [error, setError] = useState(null); - - const parsed = parseGitLabPRUrl(value); - const urlMatchedRepo = parsed - ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) - : null; - const effectiveRepoId = parsed ? (urlMatchedRepo?.id ?? "") : selectedRepoId; - - const mutation = useMutation({ - mutationFn: () => { - const prNum = parsed ? parsed.prNumber : Number(value); - return linkPRToTask(api, taskId, effectiveRepoId, prNum); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: taskPRsKey(projectId, taskId), - }); - setValue(""); - setError(null); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitLabIntegrationNotFound) { - setError(t("task.mr.error.noToken", locale)); - return; - } - if (code === ErrorCode.GitLabRepositoryNotFound) { - setError(t("task.mr.error.repoNotFound", locale)); - return; - } - if (code === ErrorCode.GitLabPRNotFound) { - const displayNum = parsed ? parsed.prNumber : value; - setError( - t("task.mr.error.prNotFound", locale, { number: displayNum }), - ); - return; - } - if (code === ErrorCode.GitLabPRAlreadyLinked) { - const displayNum = parsed ? parsed.prNumber : value; - setError( - t("task.mr.error.alreadyLinked", locale, { number: displayNum }), - ); - return; - } - if (code === ErrorCode.GitLabTokenInsufficientPermissions) { - setError(t("task.mr.error.permissions", locale)); - return; - } - setError(t("task.mr.error.generic", locale)); - }, - }); - - function submit() { - if (parsed) { - if (!urlMatchedRepo) { - setError( - t("task.mr.error.repoNotLinked", locale, { name: parsed.fullName }), - ); - return; - } - } else { - if (!effectiveRepoId) { - setError(t("task.mr.error.selectRepo", locale)); - return; - } - const num = Number(value); - if (!value.trim() || !Number.isInteger(num) || num <= 0) { - setError(t("task.mr.error.invalidInput", locale)); - return; - } - } - mutation.mutate(); - } - - return ( -
- {/* Repository selector */} -
-

- {t("task.mr.repository", locale)} -

- -
- - {/* PR number or URL */} -
-

- {t("task.mr.inputLabel", locale)} -

- { - setValue(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") submit(); - if (e.key === "Escape") onDone(); - }} - placeholder={t("task.mr.placeholder", locale)} - className={cn( - "w-full rounded-md border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring", - error ? "border-destructive" : "border-border/60", - )} - // biome-ignore lint/a11y/noAutofocus: intentional for inline form - autoFocus - disabled={mutation.isPending} - /> - {parsed && urlMatchedRepo && ( -

- {t("task.mr.willLink", locale, { - number: parsed.prNumber, - repo: urlMatchedRepo.full_name, - })} -

- )} -
- - {error && ( -

- {error} -

- )} - - {/* Action buttons */} -
- - -
-
- ); -} - -// ── Merge Requests section ───────────────────────────────────────────────────── - -function PullRequestsSection({ - api, - projectId, - taskId, - canEdit, - locale, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - canEdit: boolean; - locale?: string; -}) { - const { data: prs = [], isLoading } = useQuery({ - queryKey: taskPRsKey(projectId, taskId), - queryFn: () => listTaskPRs(api, taskId), - retry: false, - throwOnError: false, - }); - const { data: linkedRepos = [] } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - }); - const [expanded, setExpanded] = useState(true); - const [linking, setLinking] = useState(false); - - const count = prs.length; - const canLinkPR = canEdit && linkedRepos.length > 0; - - return ( -
- - - {expanded && ( -
- {isLoading ? ( -
- - {t("common.loading", locale)} -
- ) : ( - <> - {prs.map((pr) => ( - - ))} - - {count === 0 && !linking && ( -

- {t("task.mr.empty", locale)} -

- )} - - {linking ? ( - setLinking(false)} - locale={locale} - /> - ) : ( - canLinkPR && ( - - ) - )} - - )} -
- )} -
- ); -} - -// ─────────────────────── Branches Section ───────────────────────────────────── - -// ── Copy button ─────────────────────────────────────────────────────────────── - -function CopyButton({ text, locale }: { text: string; locale?: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - navigator.clipboard?.writeText(text)?.catch(() => {}); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - - ); -} - -// ── Command block ───────────────────────────────────────────────────────────── - -function CommandBlock({ - command, - locale, -}: { - command: string; - locale?: string; -}) { - return ( -
- - - {command} - - -
- ); -} - -// ── Existing branch row ─────────────────────────────────────────────────────── - -function BranchRow({ - branch, - locale, -}: { - branch: TaskBranch; - locale?: string; -}) { - const cloneCmd = `git fetch origin && git checkout ${branch.branch_name}`; - return ( -
-
- - - {branch.branch_name} - -
- -
- ); -} - -// ── Branch creation form ────────────────────────────────────────────────────── - -const BRANCH_TYPES = [ - "feat", - "fix", - "chore", - "docs", - "test", - "refactor", -] as const; - -function CreateBranchForm({ - api, - projectId, - taskId, - taskIdPrefix, - taskNumber, - taskTitle, - repos, - onDone, - locale, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - taskIdPrefix: string; - taskNumber: number; - taskTitle?: string; - repos: { id: string; full_name: string }[]; - onDone: () => void; - locale?: string; -}) { - const queryClient = useQueryClient(); - - const taskRef = taskIdPrefix - ? `${taskIdPrefix.toUpperCase()}-${taskNumber}` - : `${taskNumber}`; - - const defaultSlug = taskTitle - ? `-${taskTitle - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 30)}` - : ""; - - const [type, setType] = useState<(typeof BRANCH_TYPES)[number]>("feat"); - const [branchName, setBranchName] = useState( - `${type}/${taskRef}${defaultSlug}`, - ); - const [selectedRepoId, setSelectedRepoId] = useState( - repos.length === 1 ? repos[0].id : "", - ); - const [sourceBranch, setSourceBranch] = useState(""); - const [error, setError] = useState(null); - - function handleTypeChange(newType: (typeof BRANCH_TYPES)[number]) { - setType(newType); - setBranchName((prev) => { - const slash = prev.indexOf("/"); - const rest = slash >= 0 ? prev.slice(slash) : `/${taskRef}`; - return `${newType}${rest}`; - }); - } - - const createMutation = useMutation({ - mutationFn: (): Promise => - createBranch( - api, - taskId, - selectedRepoId, - branchName, - sourceBranch || undefined, - ), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: taskBranchesKey(projectId, taskId), - }); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitLabIntegrationNotFound) { - setError(t("task.branch.error.noToken", locale)); - return; - } - if (code === ErrorCode.GitLabRepositoryNotFound) { - setError(t("task.branch.error.repoNotFound", locale)); - return; - } - if (code === ErrorCode.GitLabBranchAlreadyLinked) { - setError(t("task.branch.error.alreadyLinked", locale)); - return; - } - if (code === ErrorCode.GitLabTokenInsufficientPermissions) { - setError(t("task.branch.error.permissionsCreate", locale)); - return; - } - setError(t("task.branch.error.createFailed", locale)); - }, - }); - - const localCmd = `git checkout -b ${branchName} && git push -u origin ${branchName}`; - - function validateForm(): boolean { - if (!branchName.trim()) { - setError(t("task.branch.error.nameRequired", locale)); - return false; - } - if (repos.length > 1 && !selectedRepoId) { - setError(t("task.branch.error.selectRepo", locale)); - return false; - } - return true; - } - - function handleCreate() { - setError(null); - if (!validateForm()) return; - createMutation.mutate(); - } - - return ( -
- {/* Branch type pills */} -
-

- {t("task.branch.type", locale)} -

-
- {BRANCH_TYPES.map((branchType) => ( - - ))} -
-
- - {/* Branch name */} -
-

- {t("task.branch.name", locale)} -

- { - setBranchName(e.target.value); - setError(null); - }} - placeholder={`feat/${taskRef}`} - className="w-full rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring" - spellCheck={false} - /> -
- - {/* Repo selector — only when multiple repos */} - {repos.length > 1 && ( -
-

- {t("task.branch.repository", locale)} -

- -
- )} - - {/* Source branch (optional) */} -
-

- {t("task.branch.source", locale)}{" "} - - {t("task.branch.sourceOptional", locale)} - -

- setSourceBranch(e.target.value)} - placeholder="main" - className="w-full rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring" - spellCheck={false} - /> -
- - {error && ( -

- {error} -

- )} - - {/* Action buttons */} -
- - -
-

- {t("task.branch.orLocal", locale)} -

- -
-
- - -
- ); -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function parseGitLabBranchUrl( - raw: string, -): { fullName: string; branchName: string } | null { - try { - const url = new URL(raw.trim()); - 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; - } -} - -// ── Link branch form ────────────────────────────────────────────────────────── - -function LinkBranchForm({ - api, - projectId, - taskId, - repos, - onDone, - locale, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - repos: LinkedRepository[]; - onDone: () => void; - locale?: string; -}) { - const queryClient = useQueryClient(); - const [selectedRepoId, setSelectedRepoId] = useState( - repos.length === 1 ? repos[0].id : "", - ); - const [value, setValue] = useState(""); - const [error, setError] = useState(null); - - const parsed = parseGitLabBranchUrl(value); - const urlMatchedRepo = parsed - ? (repos.find((r) => r.full_name === parsed.fullName) ?? null) - : null; - const effectiveRepoId = parsed ? (urlMatchedRepo?.id ?? "") : selectedRepoId; - const branchName = parsed ? parsed.branchName : value.trim(); - - const mutation = useMutation({ - mutationFn: () => linkBranchToTask(api, taskId, effectiveRepoId, branchName), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: taskBranchesKey(projectId, taskId), - }); - setValue(""); - setError(null); - onDone(); - }, - onError: (err: unknown) => { - const code = getPluginErrorCode(err); - if (code === ErrorCode.GitLabIntegrationNotFound) { - setError(t("task.branch.error.noToken", locale)); - return; - } - if (code === ErrorCode.GitLabRepositoryNotFound) { - setError(t("task.branch.error.repoNotFound", locale)); - return; - } - if (code === ErrorCode.GitLabBranchNotFound) { - setError( - t("task.branch.error.notFound", locale, { branch: branchName }), - ); - return; - } - if (code === ErrorCode.GitLabBranchAlreadyLinked) { - setError( - t("task.branch.error.alreadyLinkedNamed", locale, { - branch: branchName, - }), - ); - return; - } - if (code === ErrorCode.GitLabTokenInsufficientPermissions) { - setError(t("task.branch.error.permissionsRead", locale)); - return; - } - setError(t("task.branch.error.linkFailed", locale)); - }, - }); - - function submit() { - if (parsed) { - if (!urlMatchedRepo) { - setError( - t("task.branch.error.repoNotLinked", locale, { - name: parsed.fullName, - }), - ); - return; - } - } else { - if (!effectiveRepoId) { - setError(t("task.branch.error.selectRepo", locale)); - return; - } - if (!branchName) { - setError(t("task.branch.error.invalidInput", locale)); - return; - } - } - mutation.mutate(); - } - - return ( -
- {/* Repository selector */} -
-

- {t("task.branch.repository", locale)} -

- -
- - {/* Branch name or URL */} -
-

- {t("task.branch.nameOrUrl", locale)} -

- { - setValue(e.target.value); - setError(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter") submit(); - if (e.key === "Escape") onDone(); - }} - placeholder={t("task.branch.placeholder", locale)} - className={cn( - "w-full rounded-md border bg-background px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring", - error ? "border-destructive" : "border-border/60", - )} - spellCheck={false} - // biome-ignore lint/a11y/noAutofocus: intentional for inline form - autoFocus - disabled={mutation.isPending} - /> - {parsed && urlMatchedRepo && ( -

- {t("task.branch.willLink", locale, { - branch: parsed.branchName, - repo: urlMatchedRepo.full_name, - })} -

- )} -
- - {error && ( -

- {error} -

- )} - - {/* Action buttons */} -
- - -
-
- ); -} - -// ── Branches section ────────────────────────────────────────────────────────── - -function BranchesSection({ - api, - projectId, - taskId, - taskIdPrefix, - taskNumber, - taskTitle, - canEdit, - locale, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - taskIdPrefix: string; - taskNumber: number; - taskTitle?: string; - canEdit: boolean; - locale?: string; -}) { - const [expanded, setExpanded] = useState(true); - const [mode, setMode] = useState<"create" | "link" | null>(null); - - const { data: branches = [], isLoading } = useQuery({ - queryKey: taskBranchesKey(projectId, taskId), - queryFn: () => listTaskBranches(api, taskId), - retry: false, - throwOnError: false, - }); - - const { data: linkedRepos = [] } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - }); - - const count = branches.length; - const canCreate = canEdit && linkedRepos.length > 0; - - return ( -
- - - {expanded && ( -
- {isLoading ? ( -
- - {t("common.loading", locale)} -
- ) : ( - <> - {branches.map((branch) => ( - - ))} - - {count === 0 && mode === null && ( -

- {t("task.branch.empty", locale)} -

- )} - - {mode === "create" && ( - setMode(null)} - locale={locale} - /> - )} - - {mode === "link" && ( - setMode(null)} - locale={locale} - /> - )} - - {mode === null && canCreate && ( -
- - -
- )} - - )} -
- )} -
- ); -} - -// ─────────────────────── Main Task Section ──────────────────────────────────── - -interface GitLabTaskSectionProps { - projectId: string; - taskId: string; - canEdit?: boolean; - /** Host locale hint (e.g. "it", "en-US"). Falls back to navigator.language. */ - locale?: string; -} - -function GitLabTaskSectionInner({ - api, - projectId, - taskId, - canEdit, - locale, -}: { - api: PluginApiClient; - projectId: string; - taskId: string; - canEdit: boolean; - locale?: string; -}) { - // Fetch task and project metadata needed for branch creation - const { data: task } = useQuery({ - queryKey: ["plugin", "task", taskId], - queryFn: () => api.getTask(taskId), - staleTime: 60_000, - }); - - const { data: project } = useQuery({ - queryKey: ["plugin", "project", projectId], - queryFn: () => api.getProject(), - staleTime: 60_000, - }); - - const { data: linkedRepos, isLoading: reposLoading } = useQuery({ - queryKey: linkedReposKey(projectId), - queryFn: () => listLinkedRepositories(api), - retry: false, - throwOnError: false, - }); - - const taskIdPrefix = project?.task_id_prefix ?? ""; - const taskNumber = task?.task_number ?? 0; - const taskTitle = task?.title; - - // Don't render anything until we know whether repos are linked - if (reposLoading) return null; - if (!linkedRepos || linkedRepos.length === 0) return null; - - return ( -
- - -
- ); -} - -export default function GitLabTaskSection({ - projectId, - taskId, - canEdit = true, - locale, -}: GitLabTaskSectionProps) { - const api = useMemo( - () => - new PluginApiClient({ - baseUrl: `${window.location.origin}/api/v1`, - projectId, - fetch: (url, init) => - window.fetch(url, { ...init, credentials: "include" }), - }), - [projectId], - ); - - return ( - - - - ); -} diff --git a/frontend/src/gitlab-api.ts b/frontend/src/gitlab-api.ts deleted file mode 100644 index 5630cff..0000000 --- a/frontend/src/gitlab-api.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { type PluginApiClient } from "@paca-ai/plugin-sdk-react"; - -const PLUGIN_ID = "com.gorlix.gitlab"; - -// ── Error codes ──────────────────────────────────────────────────────────────── - -export const ErrorCode = { - GitLabIntegrationNotFound: "GITLAB_INTEGRATION_NOT_FOUND", - GitLabRepositoryNotFound: "GITLAB_REPOSITORY_NOT_FOUND", - GitLabPRNotFound: "GITLAB_PR_NOT_FOUND", - GitLabPRAlreadyLinked: "GITLAB_PR_ALREADY_LINKED", - GitLabInvalidToken: "GITLAB_INVALID_TOKEN", - GitLabRepoNotAccessible: "GITLAB_REPO_NOT_ACCESSIBLE", - GitLabRepoAlreadyLinked: "GITLAB_REPO_ALREADY_LINKED", - GitLabWebhookCreationFailed: "GITLAB_WEBHOOK_CREATION_FAILED", - GitLabWebhookURLNotPublic: "GITLAB_WEBHOOK_URL_NOT_PUBLIC", - GitLabBranchAlreadyLinked: "GITLAB_BRANCH_ALREADY_LINKED", - GitLabBranchNotFound: "GITLAB_BRANCH_NOT_FOUND", - GitLabTokenInsufficientPermissions: "GITLAB_TOKEN_INSUFFICIENT_PERMISSIONS", - BadRequest: "BAD_REQUEST", -} as const; - -export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; - -/** - * Extracts the error_code field from a PluginApiClient error. - * The React SDK throws: `[PluginApiClient] METHOD URL → STATUS: BODY` - * where BODY is the JSON from the plugin backend. - */ -export function getPluginErrorCode(err: unknown): ErrorCodeValue | null { - if (!(err instanceof Error)) return null; - const arrowIdx = err.message.lastIndexOf("→ "); - if (arrowIdx === -1) return null; - const rest = err.message.slice(arrowIdx + 2); - const colonIdx = rest.indexOf(": "); - if (colonIdx === -1) return null; - const maybeJson = rest.slice(colonIdx + 2); - try { - const body = JSON.parse(maybeJson) as { error_code?: string }; - const code = body.error_code; - if (!code) return null; - const known = Object.values(ErrorCode) as string[]; - return known.includes(code) ? (code as ErrorCodeValue) : null; - } catch { - return null; - } -} - -// ── Domain types ─────────────────────────────────────────────────────────────── - -export interface GitLabIntegration { - id?: string; - project_id: string; - connected: boolean; - instance_url?: string; - token_kind?: "personal" | "project" | "group"; - created_at?: string; - updated_at?: string; -} - -export type GitLabTokenKind = "personal" | "project" | "group"; - -export interface AccessibleRepo { - full_name: string; - owner: string; - repo_name: string; - default_branch: string; - private: boolean; - description: string; -} - -export interface LinkedRepository { - id: string; - project_id: string; - owner: string; - repo_name: string; - full_name: string; - default_branch: string; - clone_url: string; - webhook_active: boolean; - created_at: string; - updated_at: string; -} - -export interface PullRequest { - id: string; - project_id: string; - repo_id: string; - pr_number: number; - github_pr_id: number; - title: string; - state: "open" | "closed" | "merged"; - html_url: string; - head_branch: string; - base_branch: string; - author: string; - merged_at: string | null; - created_at: string; - updated_at: string; -} - -export interface TaskBranch { - id: string; - task_id: string; - repo_id: string; - branch_name: string; - created_at: string; -} - -export interface CreateBranchResult { - branch_name: string; -} - -// ── Query key factories ──────────────────────────────────────────────────────── - -export const integrationKey = (projectId: string) => - [PLUGIN_ID, "integration", projectId] as const; - -export const linkedReposKey = (projectId: string) => - [PLUGIN_ID, "linked-repos", projectId] as const; - -export const accessibleReposKey = (projectId: string) => - [PLUGIN_ID, "accessible-repos", projectId] as const; - -export const taskPRsKey = (projectId: string, taskId: string) => - [PLUGIN_ID, "prs", projectId, taskId] as const; - -export const taskBranchesKey = (projectId: string, taskId: string) => - [PLUGIN_ID, "branches", projectId, taskId] as const; - -// ── API functions ────────────────────────────────────────────────────────────── - -export async function getGitLabIntegration( - api: PluginApiClient, -): Promise { - return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration`); -} - -export async function setGitLabToken( - api: PluginApiClient, - token: string, - 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 deleteGitLabToken(api: PluginApiClient): Promise { - return api.pluginDelete(PLUGIN_ID, `/projects/${api.projectId}/integration/token`); -} - -export async function listAccessibleRepos( - api: PluginApiClient, -): Promise { - return api.pluginGet(PLUGIN_ID, `/projects/${api.projectId}/integration/accessible-repos`); -} - -export async function linkRepository( - api: PluginApiClient, - owner: string, - repoName: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/repositories`, - { owner, repo_name: repoName }, - ); -} - -export async function listLinkedRepositories( - api: PluginApiClient, -): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/repositories`, - ); -} - -export async function unlinkRepository( - api: PluginApiClient, - repoId: string, -): Promise { - return api.pluginDelete( - PLUGIN_ID, - `/projects/${api.projectId}/repositories/${repoId}`, - ); -} - -export async function listTaskPRs( - api: PluginApiClient, - taskId: string, -): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/pull-requests`, - ); -} - -export async function linkPRToTask( - api: PluginApiClient, - taskId: string, - repoId: string, - prNumber: number, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/pull-requests/link`, - { repo_id: repoId, pr_number: prNumber }, - ); -} - -export async function unlinkPRFromTask( - api: PluginApiClient, - taskId: string, - prId: string, -): Promise { - return api.pluginDelete( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/pull-requests/${prId}`, - ); -} - -export async function listTaskBranches( - api: PluginApiClient, - taskId: string, -): Promise { - return api.pluginGet( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/branches`, - ); -} - -export async function createBranch( - api: PluginApiClient, - taskId: string, - repoId: string, - branchName: string, - sourceBranch?: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/branches`, - { repo_id: repoId, branch_name: branchName, source_branch: sourceBranch }, - ); -} - -export async function linkBranchToTask( - api: PluginApiClient, - taskId: string, - repoId: string, - branchName: string, -): Promise { - return api.pluginPost( - PLUGIN_ID, - `/projects/${api.projectId}/tasks/${taskId}/branches/link`, - { repo_id: repoId, branch_name: branchName }, - ); -} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts deleted file mode 100644 index a76b998..0000000 --- a/frontend/src/i18n/en.ts +++ /dev/null @@ -1,160 +0,0 @@ -const en = { - "settings.title": "GitLab", - "settings.integrationTitle": "GitLab Integration", - "settings.integrationDesc": - "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.", - "settings.step.connectToken": "Connect a GitLab token", - "settings.step.linkRepo": "Link a repository", - - "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.removing": "Removing…", - "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.token.scopeHintBefore": - "Use a Personal, Project, or Group Access Token with scope", - "settings.token.scopeHintAfter": - "(Maintainer/Owner required to create project webhooks).", - "settings.token.show": "show", - "settings.token.hide": "hide", - "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 Repositories", - "settings.repos.link": "Add repository", - "settings.repos.linkFirst": "Add your first repository", - "settings.repos.empty": "No repositories linked yet.", - "settings.repos.emptyHint": - "No repositories linked yet. Link a repository to track merge requests and branches.", - "settings.repos.webhooksAuto": - "Webhooks are registered automatically for each linked repository.", - "settings.repos.reload": "Reload linked repositories", - "settings.repos.defaultBranch": "Default branch:", - - "settings.addRepo.title": "Add repository", - "settings.addRepo.desc": - "Select a repository from your GitLab account to link to this project. A webhook will be registered automatically.", - "settings.addRepo.search": "Search repositories…", - "settings.addRepo.reload": "Reload repositories", - "settings.addRepo.empty": "No accessible repositories found.", - "settings.addRepo.noMatch": 'No repositories match "{search}"', - "settings.addRepo.private": "Private", - "settings.addRepo.close": "Close", - "settings.addRepo.error.webhookNotPublic": - "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.", - "settings.addRepo.error.webhookFailed": - "Could not create the webhook. Ensure the token has the api scope, you have Maintainer/Owner on the project, and PUBLIC_URL is a public HTTPS URL GitLab can reach.", - "settings.addRepo.error.alreadyLinked": - "This repository is already linked to the project.", - "settings.addRepo.error.notAccessible": - "Repository not found or not accessible. Check that your token has the api scope.", - "settings.addRepo.error.generic": "Failed to link repository. Please try again.", - - "settings.unlink.action": "Unlink", - "settings.unlink.title": "Unlink repository", - "settings.unlink.bodyBefore": "This will remove the link to", - "settings.unlink.bodyAfter": - "and attempt to delete the webhook from GitLab.", - "settings.unlink.confirm": "Unlink repository", - "settings.unlink.unlinking": "Unlinking…", - "settings.unlink.failed": "Failed to unlink. Please try again.", - - "settings.webhook.hint": - "Webhooks are registered on all linked repositories. GitLab will push merge_request events to keep MR status in sync automatically.", - "settings.noIntegration": - "No GitLab integration configured. Add a personal access token to get started.", - - "task.section.title": "GitLab", - "task.mr.title": "Merge Requests", - "task.mr.link": "Link merge request", - "task.mr.create": "Create merge request", - "task.mr.empty": "No merge requests linked yet.", - "task.mr.unlink": "Unlink merge request", - "task.mr.state.open": "Open", - "task.mr.state.merged": "Merged", - "task.mr.state.closed": "Closed", - "task.mr.by": "by {author}", - "task.mr.repository": "Repository", - "task.mr.selectRepo": "Select repository…", - "task.mr.inputLabel": "PR number or GitLab URL", - "task.mr.placeholder": - "42 or https://gitlab.com/owner/repo/-/merge_requests/42", - "task.mr.willLink": "Will link PR #{number} from {repo}", - "task.mr.error.noToken": "No GitLab token configured for this project.", - "task.mr.error.repoNotFound": - "Repository not found. It may have been unlinked.", - "task.mr.error.prNotFound": - "PR #{number} was not found in the selected repository.", - "task.mr.error.alreadyLinked": "PR #{number} is already linked to this task.", - "task.mr.error.permissions": - "Your GitLab token does not have permission to read merge requests. Update it in Project Settings > GitLab.", - "task.mr.error.generic": "Failed to link merge request. Please try again.", - "task.mr.error.repoNotLinked": - 'Repository "{name}" is not linked to this project.', - "task.mr.error.selectRepo": "Select a repository.", - "task.mr.error.invalidInput": - "Enter a valid PR number or paste a GitLab PR URL.", - - "task.branch.title": "Branches", - "task.branch.create": "Create branch on GitLab", - "task.branch.createShort": "Create branch", - "task.branch.link": "Link existing branch", - "task.branch.linkAction": "Link branch", - "task.branch.empty": "No branches linked yet.", - "task.branch.type": "Type", - "task.branch.name": "Branch name", - "task.branch.repository": "Repository", - "task.branch.selectRepo": "Select repository…", - "task.branch.source": "Source branch", - "task.branch.sourceOptional": "(optional, defaults to repo default)", - "task.branch.orLocal": "Or create locally:", - "task.branch.copy": "Copy to clipboard", - "task.branch.nameOrUrl": "Branch name or GitLab URL", - "task.branch.placeholder": - "feature/foo or https://gitlab.com/owner/repo/tree/feature/foo", - "task.branch.willLink": "Will link branch {branch} from {repo}", - "task.branch.error.noToken": "No GitLab token configured for this project.", - "task.branch.error.repoNotFound": - "Repository not found. It may have been unlinked.", - "task.branch.error.alreadyLinked": - "This branch is already linked to the task.", - "task.branch.error.alreadyLinkedNamed": - 'Branch "{branch}" is already linked to this task.', - "task.branch.error.notFound": - 'Branch "{branch}" was not found in the selected repository.', - "task.branch.error.permissionsCreate": - "Your GitLab token does not have permission to create branches. Update it in Project Settings > GitLab with a token that has the api scope.", - "task.branch.error.permissionsRead": - "Your GitLab token does not have permission to read branches. Update it in Project Settings > GitLab.", - "task.branch.error.createFailed": "Failed to create branch. Please try again.", - "task.branch.error.linkFailed": "Failed to link branch. Please try again.", - "task.branch.error.nameRequired": "Branch name is required.", - "task.branch.error.selectRepo": "Select a repository.", - "task.branch.error.invalidInput": - "Enter a branch name or paste a GitLab branch URL.", - "task.branch.error.repoNotLinked": - 'Repository "{name}" is not linked to this project.', - - "common.loading": "Loading…", - "common.error": "Something went wrong", - "common.cancel": "Cancel", -} 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 deleted file mode 100644 index 2e5d437..0000000 --- a/frontend/src/i18n/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -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"; -} - -/** Simple `{name}` interpolation; missing vars are left as-is. */ -export function t( - key: MessageKey, - locale?: string | null, - vars?: Record, -): string { - const loc = resolveLocale(locale); - let msg = catalogs[loc][key] ?? catalogs.en[key] ?? key; - if (vars) { - for (const [name, value] of Object.entries(vars)) { - msg = msg.split(`{${name}}`).join(String(value)); - } - } - return msg; -} - -export type { MessageKey }; diff --git a/frontend/src/i18n/it.ts b/frontend/src/i18n/it.ts deleted file mode 100644 index 174e42d..0000000 --- a/frontend/src/i18n/it.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { MessageKey } from "./en"; - -const it: Record = { - "settings.title": "GitLab", - "settings.integrationTitle": "Integrazione GitLab", - "settings.integrationDesc": - "Collega i repository GitLab per tracciare le merge request, creare branch dalle task e ricevere automaticamente gli eventi webhook. Puoi collegare più repository a un unico progetto.", - "settings.step.connectToken": "Collega un token GitLab", - "settings.step.linkRepo": "Collega un repository", - - "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 repository e disabilitati i webhook. L’azione non è reversibile.", - "settings.token.cancel": "Annulla", - "settings.token.removing": "Rimozione…", - "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.token.scopeHintBefore": - "Usa un Personal, Project o Group Access Token con scope", - "settings.token.scopeHintAfter": - "(ruolo Maintainer/Owner richiesto per creare i webhook di progetto).", - "settings.token.show": "mostra", - "settings.token.hide": "nascondi", - "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": "Repository collegati", - "settings.repos.link": "Aggiungi repository", - "settings.repos.linkFirst": "Aggiungi il primo repository", - "settings.repos.empty": "Nessun repository collegato.", - "settings.repos.emptyHint": - "Nessun repository collegato. Collega un repository per tracciare merge request e branch.", - "settings.repos.webhooksAuto": - "I webhook vengono registrati automaticamente per ogni repository collegato.", - "settings.repos.reload": "Ricarica repository collegati", - "settings.repos.defaultBranch": "Branch predefinito:", - - "settings.addRepo.title": "Aggiungi repository", - "settings.addRepo.desc": - "Seleziona un repository dal tuo account GitLab da collegare a questo progetto. Verrà registrato automaticamente un webhook.", - "settings.addRepo.search": "Cerca repository…", - "settings.addRepo.reload": "Ricarica repository", - "settings.addRepo.empty": "Nessun repository accessibile trovato.", - "settings.addRepo.noMatch": 'Nessun repository corrisponde a "{search}"', - "settings.addRepo.private": "Privato", - "settings.addRepo.close": "Chiudi", - "settings.addRepo.error.webhookNotPublic": - "Impossibile registrare il webhook perché questo URL API non è raggiungibile pubblicamente (ad esempio localhost). Configura PUBLIC_URL con un URL HTTPS pubblico e riprova.", - "settings.addRepo.error.webhookFailed": - "Impossibile creare il webhook. Assicurati che il token abbia lo scope api, che tu sia Maintainer/Owner sul progetto e che PUBLIC_URL sia un URL HTTPS pubblico raggiungibile da GitLab.", - "settings.addRepo.error.alreadyLinked": - "Questo repository è già collegato al progetto.", - "settings.addRepo.error.notAccessible": - "Repository non trovato o non accessibile. Verifica che il token abbia lo scope api.", - "settings.addRepo.error.generic": - "Collegamento del repository non riuscito. Riprova.", - - "settings.unlink.action": "Scollega", - "settings.unlink.title": "Scollega repository", - "settings.unlink.bodyBefore": "Questo rimuoverà il collegamento a", - "settings.unlink.bodyAfter": - "e tenterà di eliminare il webhook da GitLab.", - "settings.unlink.confirm": "Scollega repository", - "settings.unlink.unlinking": "Scollegamento…", - "settings.unlink.failed": "Scollegamento non riuscito. Riprova.", - - "settings.webhook.hint": - "I webhook sono registrati su tutti i repository collegati. GitLab invierà eventi merge_request per mantenere lo stato delle MR sincronizzato automaticamente.", - "settings.noIntegration": - "Nessuna integrazione GitLab configurata. Aggiungi un token di accesso per iniziare.", - - "task.section.title": "GitLab", - "task.mr.title": "Merge Request", - "task.mr.link": "Collega merge request", - "task.mr.create": "Crea merge request", - "task.mr.empty": "Nessuna merge request collegata.", - "task.mr.unlink": "Scollega merge request", - "task.mr.state.open": "Aperta", - "task.mr.state.merged": "Merged", - "task.mr.state.closed": "Chiusa", - "task.mr.by": "di {author}", - "task.mr.repository": "Repository", - "task.mr.selectRepo": "Seleziona repository…", - "task.mr.inputLabel": "Numero PR o URL GitLab", - "task.mr.placeholder": - "42 oppure https://gitlab.com/owner/repo/-/merge_requests/42", - "task.mr.willLink": "Collegherà la PR #{number} da {repo}", - "task.mr.error.noToken": "Nessun token GitLab configurato per questo progetto.", - "task.mr.error.repoNotFound": - "Repository non trovato. Potrebbe essere stato scollegato.", - "task.mr.error.prNotFound": - "La PR #{number} non è stata trovata nel repository selezionato.", - "task.mr.error.alreadyLinked": "La PR #{number} è già collegata a questa task.", - "task.mr.error.permissions": - "Il token GitLab non ha i permessi per leggere le merge request. Aggiornalo in Impostazioni progetto > GitLab.", - "task.mr.error.generic": "Collegamento merge request non riuscito. Riprova.", - "task.mr.error.repoNotLinked": - 'Il repository "{name}" non è collegato a questo progetto.', - "task.mr.error.selectRepo": "Seleziona un repository.", - "task.mr.error.invalidInput": - "Inserisci un numero PR valido o incolla un URL di PR GitLab.", - - "task.branch.title": "Branch", - "task.branch.create": "Crea branch su GitLab", - "task.branch.createShort": "Crea branch", - "task.branch.link": "Collega branch esistente", - "task.branch.linkAction": "Collega branch", - "task.branch.empty": "Nessun branch collegato.", - "task.branch.type": "Tipo", - "task.branch.name": "Nome branch", - "task.branch.repository": "Repository", - "task.branch.selectRepo": "Seleziona repository…", - "task.branch.source": "Branch di origine", - "task.branch.sourceOptional": - "(opzionale, predefinito: branch di default del repo)", - "task.branch.orLocal": "Oppure crea in locale:", - "task.branch.copy": "Copia negli appunti", - "task.branch.nameOrUrl": "Nome branch o URL GitLab", - "task.branch.placeholder": - "feature/foo oppure https://gitlab.com/owner/repo/tree/feature/foo", - "task.branch.willLink": "Collegherà il branch {branch} da {repo}", - "task.branch.error.noToken": - "Nessun token GitLab configurato per questo progetto.", - "task.branch.error.repoNotFound": - "Repository non trovato. Potrebbe essere stato scollegato.", - "task.branch.error.alreadyLinked": - "Questo branch è già collegato alla task.", - "task.branch.error.alreadyLinkedNamed": - 'Il branch "{branch}" è già collegato a questa task.', - "task.branch.error.notFound": - 'Il branch "{branch}" non è stato trovato nel repository selezionato.', - "task.branch.error.permissionsCreate": - "Il token GitLab non ha i permessi per creare branch. Aggiornalo in Impostazioni progetto > GitLab con un token che abbia lo scope api.", - "task.branch.error.permissionsRead": - "Il token GitLab non ha i permessi per leggere i branch. Aggiornalo in Impostazioni progetto > GitLab.", - "task.branch.error.createFailed": "Creazione branch non riuscita. Riprova.", - "task.branch.error.linkFailed": "Collegamento branch non riuscito. Riprova.", - "task.branch.error.nameRequired": "Il nome del branch è obbligatorio.", - "task.branch.error.selectRepo": "Seleziona un repository.", - "task.branch.error.invalidInput": - "Inserisci un nome di branch o incolla un URL di branch GitLab.", - "task.branch.error.repoNotLinked": - 'Il repository "{name}" non è collegato a questo progetto.', - - "common.loading": "Caricamento…", - "common.error": "Qualcosa è andato storto", - "common.cancel": "Annulla", -}; - -export default it; diff --git a/frontend/src/index.ts b/frontend/src/index.ts deleted file mode 100644 index 4614dcb..0000000 --- a/frontend/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as GitLabSettingsTab } from "./GitLabSettingsTab"; -export { default as GitLabTaskSection } from "./GitLabTaskSection"; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json deleted file mode 100644 index 1d14c6f..0000000 --- a/frontend/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "moduleResolution": "bundler", - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "paths": { - "@paca-ai/plugin-sdk-react": ["../../plugin-sdk-react/src/index.ts"] - } - }, - "include": ["src"] -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts deleted file mode 100644 index cdb9506..0000000 --- a/frontend/vite.config.ts +++ /dev/null @@ -1,27 +0,0 @@ -import react from "@vitejs/plugin-react"; -import federation from "@originjs/vite-plugin-federation"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [ - react(), - federation({ - name: "com_gorlix_gitlab", - filename: "remoteEntry.js", - exposes: { - "./GitLabSettingsTab": "./src/GitLabSettingsTab.tsx", - "./GitLabTaskSection": "./src/GitLabTaskSection.tsx", - }, - shared: { - react: { requiredVersion: "^19.0.0" }, - "react-dom": { requiredVersion: "^19.0.0" }, - "@tanstack/react-query": { requiredVersion: "^5.0.0" }, - }, - }), - ], - build: { - target: "esnext", - minify: false, - cssCodeSplit: false, - }, -}); diff --git a/mcp/bun.lock b/mcp/bun.lock deleted file mode 100644 index 59a72f0..0000000 --- a/mcp/bun.lock +++ /dev/null @@ -1,329 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@paca-ai/github-plugin-mcp", - "dependencies": { - "@paca-ai/plugin-sdk-mcp": "^0.2.0", - }, - "devDependencies": { - "typescript": "^5.8.3", - "vite": "^6.0.0", - }, - }, - }, - "packages": { - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - - "@paca-ai/plugin-sdk-mcp": ["@paca-ai/plugin-sdk-mcp@0.2.0", "", { "peerDependencies": { "@modelcontextprotocol/sdk": ">=0.6.0" } }, "sha512-wpZqKLmfoO98OO2xRfCtrXzfRzJX06yr48LCfu+dE2p1kpNUyWHvp1XkTPOLHTBBOuebMwSDcuZT94u+egv4uA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], - - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - - "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - - "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - } -} diff --git a/mcp/package.json b/mcp/package.json deleted file mode 100644 index adba738..0000000 --- a/mcp/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@paca-ai/gitlab-plugin-mcp", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "dev": "vite build --watch", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@paca-ai/plugin-sdk-mcp": "^0.2.0" - }, - "devDependencies": { - "typescript": "^5.8.3", - "vite": "^6.0.0" - } -} diff --git a/mcp/src/index.ts b/mcp/src/index.ts deleted file mode 100644 index 0e1022a..0000000 --- a/mcp/src/index.ts +++ /dev/null @@ -1,820 +0,0 @@ -import { - PluginAPIClient, - type PluginMCPContext, - type PluginMCPEntry, - type Tool, - errorResult, - textResult, -} from "@paca-ai/plugin-sdk-mcp"; - -// ── Domain types ────────────────────────────────────────────────────────────── - -interface GitLabIntegration { - id: string; - project_id: string; - created_at: string; - updated_at: string; -} - -interface LinkedRepository { - id: string; - project_id: string; - owner: string; - repo_name: string; - full_name: string; - default_branch: string; - clone_url: string; - webhook_active: boolean; - created_at: string; - updated_at: string; -} - - -interface PullRequest { - id: string; - task_id: string; - repo_id: string; - pr_number: number; - title: string; - state: "open" | "closed" | "merged"; - html_url: string; - head_branch: string; - author: string | null; - merged_at: string | null; - created_at: string; -} - -interface PullRequestDetails { - owner: string; - repo_name: string; - pr_number: number; - title: string; - state: string; - body: string; - html_url: string; - diff: string; -} - -interface CICheck { - name: string; - status: string; - conclusion: string; - url: string; -} - -interface PullRequestCIStatus { - state: string; - checks: CICheck[]; -} - -interface TaskBranch { - id: string; - task_id: string; - repo_id: string; - branch_name: string; - created_at: string; -} - -interface CreateBranchResult { - branch_name: string; - repo_full_name: string; - html_url: string; -} - -interface CreatePullRequestResult { - id: string; - project_id: string; - repo_id: string; - pr_number: number; - gitlab_pr_id: number; - title: string; - state: string; - html_url: string; - head_branch: string; - base_branch: string; - author: string; - merged_at: string | null; - created_at: string; - updated_at: string; -} - -// ── Formatting helpers ──────────────────────────────────────────────────────── - -function formatIntegration(integration: GitLabIntegration): string { - return `GitLab Integration: -ID: ${integration.id} -Project ID: ${integration.project_id} -Created: ${integration.created_at} -Updated: ${integration.updated_at}`; -} - -function formatLinkedRepo(repo: LinkedRepository): string { - return `Repository: ${repo.full_name} -ID: ${repo.id} -Owner: ${repo.owner} -Repo Name: ${repo.repo_name} -Default Branch: ${repo.default_branch} -Clone URL: ${repo.clone_url} -Webhook Active: ${repo.webhook_active ? "Yes" : "No"} -Created: ${repo.created_at}`; -} - -function formatPullRequest(pr: PullRequest): string { - return `Merge Request: #${pr.pr_number} - ${pr.title} -ID: ${pr.id} -State: ${pr.state} -Author: ${pr.author ?? "Unknown"} -URL: ${pr.html_url} -Head Branch: ${pr.head_branch} -Created: ${pr.created_at} -Merged: ${pr.merged_at ? `Yes (${pr.merged_at})` : "No"}`; -} - -function formatPullRequestDetails(pr: PullRequestDetails): string { - return `Merge Request: #${pr.pr_number} - ${pr.title} (${pr.owner}/${pr.repo_name}) -State: ${pr.state} -URL: ${pr.html_url} -Description: ${pr.body || "(none)"} - -Diff: -${pr.diff || "(no changes)"}`; -} - -function formatCIStatus(ci: PullRequestCIStatus): string { - if (ci.checks.length === 0) { - return "CI status: unknown (no checks found for this commit)."; - } - const checkLines = ci.checks - .map((c) => `- ${c.name}: ${c.status}${c.conclusion ? ` (${c.conclusion})` : ""}`) - .join("\n"); - return `CI status: ${ci.state}\n\n${checkLines}`; -} - -function formatBranch(branch: TaskBranch): string { - return `Branch: ${branch.branch_name} -ID: ${branch.id} -Task ID: ${branch.task_id} -Repo ID: ${branch.repo_id} -Created: ${branch.created_at}`; -} - -function formatList(items: T[], formatter: (item: T) => string): string { - if (items.length === 0) return "(none)"; - return items.map(formatter).join("\n\n---\n\n"); -} - -// ── Tool definitions ────────────────────────────────────────────────────────── - -const UUID_DESC = - "The technical UUID of the %s (e.g., '550e8400-e29b-41d4-a716-446655440000')."; - -const projectIdProp = { - type: "string", - description: - UUID_DESC.replace("%s", "project") + - " Use list_projects to get the project ID. Do NOT use the project name.", -}; - -const taskIdProp = { - type: "string", - description: - UUID_DESC.replace("%s", "task") + " Use list_tasks to get the task ID.", -}; - -const tools: Tool[] = [ - // ── Integration ────────────────────────────────────────────────────────── - { - name: "gitlab_get_integration", - description: "Get GitLab integration status for a project.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - }, - required: ["projectId"], - }, - }, - { - name: "gitlab_set_token", - description: - "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 GitLab personal access token (e.g., 'ghp_xxxx').", - }, - }, - required: ["projectId", "token"], - }, - }, - { - name: "gitlab_delete_token", - description: "Delete the GitLab token for a project, removing GitLab integration.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - }, - required: ["projectId"], - }, - }, - // ── Repositories ───────────────────────────────────────────────────────── - { - name: "gitlab_list_linked_repos", - description: "List GitLab repositories linked to a project.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - }, - required: ["projectId"], - }, - }, - { - name: "gitlab_link_repository", - description: - "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 (GitLab username or org).", - }, - repo_name: { - type: "string", - description: "The repository name (not including the owner).", - }, - }, - required: ["projectId", "owner", "repo_name"], - }, - }, - { - name: "gitlab_unlink_repository", - description: "Unlink a GitLab repository from a project.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use gitlab_list_linked_repos to get the repo ID.", - }, - }, - required: ["projectId", "repoId"], - }, - }, - // ── Merge Requests ───────────────────────────────────────────────────────── - { - name: "gitlab_list_task_prs", - description: "List merge requests linked to a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - }, - required: ["projectId", "taskId"], - }, - }, - { - name: "gitlab_link_pr_to_task", - description: "Link a GitLab merge request to a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use gitlab_list_linked_repos to get the repo ID.", - }, - pr_number: { - type: "number", - description: "The GitLab merge request number (e.g., 42).", - }, - }, - required: ["projectId", "taskId", "repoId", "pr_number"], - }, - }, - { - name: "gitlab_unlink_pr_from_task", - description: "Unlink a merge request from a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked merge request") + - " Use gitlab_list_task_prs to get the PR ID.", - }, - }, - required: ["projectId", "taskId", "prId"], - }, - }, - { - name: "gitlab_create_pull_request", - description: - "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: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use gitlab_list_linked_repos to get the repo ID.", - }, - title: { - type: "string", - description: - "The title for the merge request (e.g., 'feat: add user authentication').", - }, - head_branch: { - type: "string", - description: - "The name of the branch that contains the changes (the source branch, e.g., 'feat/PROJ-42-add-feature').", - }, - base_branch: { - type: "string", - description: - "The name of the branch to merge into (the target branch, e.g., 'main' or 'develop').", - }, - body: { - type: "string", - description: - "The description/body for the merge request in Markdown format (optional).", - }, - }, - required: ["projectId", "taskId", "repoId", "title", "head_branch", "base_branch"], - }, - }, - { - name: "gitlab_get_pull_request", - description: - "Fetch a merge request's title, description, state, and diff so it can be reviewed.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked merge request") + - " Use gitlab_list_task_prs to get the PR ID.", - }, - }, - required: ["projectId", "taskId", "prId"], - }, - }, - { - name: "gitlab_get_pull_request_ci_status", - description: - "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: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked merge request") + - " Use gitlab_list_task_prs to get the PR ID.", - }, - }, - required: ["projectId", "taskId", "prId"], - }, - }, - { - name: "gitlab_comment_pull_request", - description: "Add a general (non-review) comment to a merge request.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked merge request") + - " Use gitlab_list_task_prs to get the PR ID.", - }, - body: { - type: "string", - description: "The comment text.", - }, - }, - required: ["projectId", "taskId", "prId", "body"], - }, - }, - { - name: "gitlab_review_pull_request", - description: - "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: { - projectId: projectIdProp, - taskId: taskIdProp, - prId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked merge request") + - " Use gitlab_list_task_prs to get the PR ID.", - }, - event: { - type: "string", - enum: ["APPROVE", "REQUEST_CHANGES", "COMMENT"], - description: "The review verdict.", - }, - body: { - type: "string", - description: - "Review summary (required for REQUEST_CHANGES and COMMENT).", - }, - }, - required: ["projectId", "taskId", "prId", "event"], - }, - }, - // ── Branches ───────────────────────────────────────────────────────────── - { - name: "gitlab_create_branch", - description: - "Create a new branch on GitLab for a task and link it to the task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use gitlab_list_linked_repos to get the repo ID.", - }, - branch_name: { - type: "string", - description: - "The name for the new branch (e.g., 'feat/PROJ-42-add-feature').", - }, - source_branch: { - type: "string", - description: - "The source branch to branch from. Defaults to the repository's default branch (optional).", - }, - }, - required: ["projectId", "taskId", "repoId", "branch_name"], - }, - }, - { - name: "gitlab_link_branch_to_task", - description: - "Link an existing GitLab branch to a task (does not create the branch on GitLab).", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - repoId: { - type: "string", - description: - UUID_DESC.replace("%s", "linked repository") + - " Use gitlab_list_linked_repos to get the repo ID.", - }, - branch_name: { - type: "string", - description: - "The existing branch name (e.g., 'feature/my-branch').", - }, - }, - required: ["projectId", "taskId", "repoId", "branch_name"], - }, - }, - { - name: "gitlab_list_task_branches", - description: "List branches linked to a task.", - inputSchema: { - type: "object", - properties: { - projectId: projectIdProp, - taskId: taskIdProp, - }, - required: ["projectId", "taskId"], - }, - }, -]; - -// ── Entry ───────────────────────────────────────────────────────────────────── - -const entry: PluginMCPEntry = { - tools, - - async handleToolCall( - name: string, - args: Record, - context: PluginMCPContext, - ) { - const api = new PluginAPIClient(context); - - try { - switch (name) { - // ── Integration ──────────────────────────────────────────────── - case "gitlab_get_integration": { - const { projectId } = args as { projectId: string }; - const integration = await api.pluginGet( - `projects/${projectId}/integration`, - ); - return textResult(formatIntegration(integration)); - } - - case "gitlab_set_token": { - const { projectId, token } = args as { - projectId: string; - token: string; - }; - const integration = await api.pluginPost( - `projects/${projectId}/integration/token`, - { token }, - ); - return textResult( - `GitLab token set successfully:\n\n${formatIntegration(integration)}`, - ); - } - - case "gitlab_delete_token": { - const { projectId } = args as { projectId: string }; - await api.pluginDelete(`projects/${projectId}/integration/token`); - return textResult("GitLab token deleted successfully."); - } - - // ── Repositories ─────────────────────────────────────────────── - case "gitlab_list_linked_repos": { - const { projectId } = args as { projectId: string }; - const repos = await api.pluginGet( - `projects/${projectId}/repositories`, - ); - return textResult( - `Linked GitLab Repositories:\n\n${formatList(repos, formatLinkedRepo)}`, - ); - } - - case "gitlab_link_repository": { - const { projectId, owner, repo_name } = args as { - projectId: string; - owner: string; - repo_name: string; - }; - const repo = await api.pluginPost( - `projects/${projectId}/repositories`, - { owner, repo_name }, - ); - return textResult( - `Repository linked successfully:\n\n${formatLinkedRepo(repo)}`, - ); - } - - case "gitlab_unlink_repository": { - const { projectId, repoId } = args as { - projectId: string; - repoId: string; - }; - await api.pluginDelete( - `projects/${projectId}/repositories/${repoId}`, - ); - return textResult(`Repository ${repoId} unlinked successfully.`); - } - - // ── Merge Requests ────────────────────────────────────────────── - case "gitlab_list_task_prs": { - const { projectId, taskId } = args as { - projectId: string; - taskId: string; - }; - const prs = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests`, - ); - return textResult( - `Merge Requests:\n\n${formatList(prs, formatPullRequest)}`, - ); - } - - case "gitlab_link_pr_to_task": { - const { projectId, taskId, repoId, pr_number } = args as { - projectId: string; - taskId: string; - repoId: string; - pr_number: number; - }; - const pr = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests/link`, - { repo_id: repoId, pr_number }, - ); - return textResult( - `Merge request linked successfully:\n\n${formatPullRequest(pr)}`, - ); - } - - case "gitlab_unlink_pr_from_task": { - const { projectId, taskId, prId } = args as { - projectId: string; - taskId: string; - prId: string; - }; - await api.pluginDelete( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}`, - ); - return textResult(`Merge request ${prId} unlinked successfully.`); - } - - case "gitlab_create_pull_request": { - const { projectId, taskId, repoId, title, head_branch, base_branch, body } = - args as { - projectId: string; - taskId: string; - repoId: string; - title: string; - head_branch: string; - base_branch: string; - body?: string; - }; - const pr = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests`, - { repo_id: repoId, title, head_branch, base_branch, body: body ?? "" }, - ); - return textResult( - `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 "gitlab_get_pull_request": { - const { projectId, taskId, prId } = args as { - projectId: string; - taskId: string; - prId: string; - }; - const pr = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}`, - ); - return textResult(formatPullRequestDetails(pr)); - } - - case "gitlab_get_pull_request_ci_status": { - const { projectId, taskId, prId } = args as { - projectId: string; - taskId: string; - prId: string; - }; - const ci = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/ci-status`, - ); - return textResult(formatCIStatus(ci)); - } - - case "gitlab_comment_pull_request": { - const { projectId, taskId, prId, body } = args as { - projectId: string; - taskId: string; - prId: string; - body: string; - }; - await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/comments`, - { body }, - ); - return textResult("Comment posted successfully."); - } - - case "gitlab_review_pull_request": { - const { projectId, taskId, prId, event, body } = args as { - projectId: string; - taskId: string; - prId: string; - event: string; - body?: string; - }; - await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/pull-requests/${prId}/reviews`, - { event, body: body ?? "" }, - ); - return textResult("Review submitted successfully."); - } - - // ── Branches ─────────────────────────────────────────────────── - case "gitlab_create_branch": { - const { projectId, taskId, repoId, branch_name, source_branch } = - args as { - projectId: string; - taskId: string; - repoId: string; - branch_name: string; - source_branch?: string; - }; - const result = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/branches`, - { repo_id: repoId, branch_name, source_branch }, - ); - return textResult( - `Branch created successfully:\n\nBranch: ${result.branch_name}\nRepository: ${result.repo_full_name}\nURL: ${result.html_url}`, - ); - } - - case "gitlab_link_branch_to_task": { - const { projectId, taskId, repoId, branch_name } = args as { - projectId: string; - taskId: string; - repoId: string; - branch_name: string; - }; - const branch = await api.pluginPost( - `projects/${projectId}/tasks/${taskId}/branches/link`, - { repo_id: repoId, branch_name }, - ); - return textResult( - `Branch linked successfully:\n\n${formatBranch(branch)}`, - ); - } - - case "gitlab_list_task_branches": { - const { projectId, taskId } = args as { - projectId: string; - taskId: string; - }; - const branches = await api.pluginGet( - `projects/${projectId}/tasks/${taskId}/branches`, - ); - return textResult( - `Branches:\n\n${formatList(branches, formatBranch)}`, - ); - } - - default: - return errorResult(`Unknown tool: ${name}`); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - return errorResult(`GitLab plugin error: ${msg}`); - } - }, - - async getToolContext( - toolId: string, - args: Record, - context: PluginMCPContext, - ) { - if (toolId !== "get_task") return null; - const { projectId, taskId } = args as { projectId: string; taskId: string }; - - const api = new PluginAPIClient(context); - try { - const [branches, prs] = await Promise.all([ - api.pluginGet( - `projects/${projectId}/tasks/${taskId}/branches`, - ), - api.pluginGet( - `projects/${projectId}/tasks/${taskId}/pull-requests`, - ), - ]); - if (branches.length === 0 && prs.length === 0) return null; - - const lines = ["## GitLab"]; - if (branches.length > 0) { - lines.push( - "", - "**Branches:**", - ...branches.map((b) => `- ${b.branch_name}`), - ); - } - if (prs.length > 0) { - lines.push( - "", - "**Merge Requests:**", - ...prs.map( - (pr) => `- #${pr.pr_number} [${pr.state}] ${pr.title} — ${pr.html_url}`, - ), - ); - } - return lines.join("\n"); - } catch { - // Best-effort enrichment — a transient failure here should not - // break the get_task response for the AI client. - return null; - } - }, -}; - -export default entry; diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json deleted file mode 100644 index fb6f94d..0000000 --- a/mcp/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/mcp/vite.config.ts b/mcp/vite.config.ts deleted file mode 100644 index 9f94da5..0000000 --- a/mcp/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig } from "vite"; - -export default defineConfig({ - build: { - lib: { - entry: "./src/index.ts", - formats: ["es"], - fileName: () => "mcp.js", - }, - target: "es2022", - minify: false, - rollupOptions: { - // No externals — the output is a self-contained ESM bundle loaded - // dynamically by the Paca MCP server at runtime. - external: [], - }, - }, -}); diff --git a/plugin.json b/plugin.json index 7cf65fc..9c983d2 100644 --- a/plugin.json +++ b/plugin.json @@ -1,368 +1,16 @@ { - "id": "com.gorlix.gitlab", - "displayName": "GitLab Integration", - "description": "Integrates GitLab projects, merge requests, and branches with Paca projects and tasks.", - "version": "0.5.2", - "minCoreVersion": "v0.13.3", - "capabilities": ["repository"], + "id": "com.paca.gitlab", + "displayName": "GitLab", + "description": "GitLab provider for Paca's Git integration: access-token identities, project access, credentials and webhooks.", + "version": "0.6.0", + "minCoreVersion": "v0.20.0", + "capabilities": ["git-provider"], "permissions": ["db.read", "db.write"], - "customPermissions": [ - { - "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"], + "allowedConfigKeys": ["ENCRYPTION_KEY"], "allowedOutboundDomains": ["gitlab.com"], "sensitiveFields": { - "gitlab_integrations": ["access_token_enc"], - "gitlab_repositories": ["webhook_secret_enc"] - }, - "routes": [ - { - "method": "GET", - "path": "/projects/:projectId/integration", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/integration/token", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "DELETE", - "path": "/projects/:projectId/integration/token", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/integration/accessible-repos", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/repositories", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/repositories", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "DELETE", - "path": "/projects/:projectId/repositories/:repoId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/repositories/:repoId/clone-info", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["gitlab.manage"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/pull-requests", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.read"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/link", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.write"] - } - ] - }, - { - "method": "DELETE", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.write"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.read"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/ci-status", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.read"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/comments", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/pull-requests/:prId/reviews", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.write"] - } - ] - }, - { - "method": "GET", - "path": "/projects/:projectId/tasks/:taskId/branches", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.read"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/branches", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/tasks/:taskId/branches/link", - "middlewares": [ - { "name": "optionalAuthn" }, - { "name": "requireFreshPassword" }, - { - "name": "requirePermissions", - "scope": "project", - "permissions": ["tasks.write"] - } - ] - }, - { - "method": "POST", - "path": "/projects/:projectId/webhook", - "middlewares": [{ "name": "optionalAuthn" }] - } - ] - }, - "frontend": { - "remoteEntryUrl": "/plugins/com.gorlix.gitlab/assets/remoteEntry.js", - "extensionPoints": [ - { - "point": "project.settings.tab", - "component": "GitLabSettingsTab", - "label": "GitLab", - "order": 100, - "requiredPermission": "gitlab.manage" - }, - { - "point": "task.detail.section", - "component": "GitLabTaskSection", - "order": 5 - } - ] - }, - "automation": { - "triggers": [ - { - "type": "gitlab.mr_linked", - "label": "GitLab: Merge Request Created", - "eventTopic": "gitlab.mr_linked" - }, - { - "type": "gitlab.mr_state_changed", - "label": "GitLab: Merge Request State Changed", - "eventTopic": "gitlab.mr_state_changed" - }, - { - "type": "gitlab.branch_linked", - "label": "GitLab: Branch Created", - "eventTopic": "gitlab.branch_linked" - } - ], - "conditions": [ - { - "type": "gitlab.mr_state", - "label": "GitLab: Merge Request State", - "configSchema": { - "type": "object", - "required": ["expected_state"], - "properties": { - "expected_state": { - "type": "string", - "title": "Expected State", - "enum": ["open", "closed", "merged"] - } - } - } - } - ], - "actions": [ - { - "type": "gitlab.merge_mr", - "label": "GitLab: Merge MR", - "configSchema": { - "type": "object", - "properties": { - "merge_method": { - "type": "string", - "title": "Merge Method", - "enum": ["merge", "squash", "rebase"], - "default": "merge" - } - } - } - }, - { - "type": "gitlab.comment_mr", - "label": "GitLab: Comment on Merge Request", - "configSchema": { - "type": "object", - "required": ["body"], - "properties": { - "body": { "type": "string", "title": "Comment Body", "format": "textarea" } - } - } - } - ] - }, - "mcp": { - "remoteEntryUrl": "/plugins-mcp/com.gorlix.gitlab/mcp.js", - "toolContextHooks": ["get_task"] - }, - "skills": { - "baseUrl": "/plugins-skills/com.gorlix.gitlab", - "names": ["paca-gitlab-workflow"] + "gitlab_identities": ["token_enc", "read_token_enc", "webhook_secret_enc"] + } } } diff --git a/scripts/install-from-release.sh b/scripts/install-from-release.sh index ccec30e..59a4578 100755 --- a/scripts/install-from-release.sh +++ b/scripts/install-from-release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Install com.gorlix.gitlab from a GitHub Release into a production Paca stack. +# Install com.paca.gitlab from a GitHub Release into a production Paca stack. # # Populates Docker named volumes (compose project "paca") and registers the # plugin via POST/PATCH /api/v1/admin/plugins. The Paca API then runs @@ -11,14 +11,14 @@ # ./scripts/install-from-release.sh \ # --api-url https://paca.example.com \ # --api-key "$PACA_API_KEY" \ -# [--version v0.5.2] \ +# [--version v0.6.0] \ # [--compose-project paca] # set -euo pipefail -PLUGIN_ID="com.gorlix.gitlab" -REPO="gorlix/paca-plugin-gitlab" -VERSION="v0.5.2" +PLUGIN_ID="com.paca.gitlab" +REPO="Paca-AI/paca-plugin-gitlab" +VERSION="v0.6.0" COMPOSE_PROJECT="paca" API_URL="${PACA_URL:-}" API_KEY="${PACA_API_KEY:-}" @@ -54,18 +54,13 @@ for cmd in curl tar sha256sum jq docker; do done VOL_BACKEND="${COMPOSE_PROJECT}_backend_plugins" -VOL_FRONTEND="${COMPOSE_PROJECT}_frontend_plugins" -VOL_MCP="${COMPOSE_PROJECT}_mcp_plugins" -VOL_SKILLS="${COMPOSE_PROJECT}_skills_plugins" - -for vol in "$VOL_BACKEND" "$VOL_FRONTEND" "$VOL_MCP" "$VOL_SKILLS"; do - if ! docker volume inspect "$vol" >/dev/null 2>&1; then - echo "error: docker volume '$vol' not found." >&2 - echo " Expected compose project name '${COMPOSE_PROJECT}' (see docker-compose.yml 'name: paca')." >&2 - echo " List volumes: docker volume ls | grep plugins" >&2 - exit 1 - fi -done + +if ! docker volume inspect "$VOL_BACKEND" >/dev/null 2>&1; then + echo "error: docker volume '$VOL_BACKEND' not found." >&2 + echo " Expected compose project name '${COMPOSE_PROJECT}' (see docker-compose.yml 'name: paca')." >&2 + echo " List volumes: docker volume ls | grep plugins" >&2 + exit 1 +fi BASE="https://github.com/${REPO}/releases/download/${VERSION}" WORKDIR=$(mktemp -d) @@ -74,12 +69,10 @@ cd "$WORKDIR" echo "==> Downloading ${VERSION} assets from ${REPO}" curl -fsSL -O "${BASE}/checksums.txt" +# The plugin is provider-only: a backend, its migrations and the manifest. ASSETS=( 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 ) for f in "${ASSETS[@]}"; do @@ -88,11 +81,7 @@ done sha256sum -c checksums.txt STAGE="$WORKDIR/stage" -mkdir -p \ - "$STAGE/backend/$PLUGIN_ID/migrations" \ - "$STAGE/frontend/$PLUGIN_ID" \ - "$STAGE/mcp/$PLUGIN_ID" \ - "$STAGE/skills/$PLUGIN_ID" +mkdir -p "$STAGE/backend/$PLUGIN_ID/migrations" echo "==> Staging plugin layout for ${PLUGIN_ID}" tar -xzf gitlab-backend-wasm.tar.gz -C "$STAGE/backend/$PLUGIN_ID" @@ -110,46 +99,16 @@ tar -xzf gitlab-plugin-manifest.tar.gz -C "$STAGE/backend/$PLUGIN_ID" tar -xzf gitlab-migrations.tar.gz -C "$STAGE" cp "$STAGE/migrations/"*.sql "$STAGE/backend/$PLUGIN_ID/migrations/" -tar -xzf gitlab-frontend-dist.tar.gz -C "$STAGE" -cp -a "$STAGE/dist/." "$STAGE/frontend/$PLUGIN_ID/" -rm -rf "$STAGE/dist" - -tar -xzf gitlab-mcp-dist.tar.gz -C "$STAGE" -cp -a "$STAGE/dist/." "$STAGE/mcp/$PLUGIN_ID/" -rm -rf "$STAGE/dist" - -tar -xzf gitlab-skills-bundle.tar.gz -C "$STAGE" -cp -a "$STAGE/skills/." "$STAGE/skills/$PLUGIN_ID/" - -if [[ ! -f "$STAGE/frontend/$PLUGIN_ID/assets/remoteEntry.js" ]]; then - echo "error: missing frontend assets/remoteEntry.js after extract" >&2 - exit 1 -fi -if [[ ! -f "$STAGE/mcp/$PLUGIN_ID/mcp.js" ]]; then - echo "error: missing mcp.js after extract" >&2 - exit 1 -fi - -echo "==> Copying into Docker volumes (${COMPOSE_PROJECT}_*_plugins)" +echo "==> Copying into Docker volume ${VOL_BACKEND}" docker run --rm \ -e PLUGIN_ID="$PLUGIN_ID" \ -v "${VOL_BACKEND}:/plugins" \ - -v "${VOL_FRONTEND}:/plugins-frontend" \ - -v "${VOL_MCP}:/plugins-mcp" \ - -v "${VOL_SKILLS}:/plugins-skills" \ -v "${STAGE}:/stage:ro" \ alpine:3.20 sh -c ' set -e - mkdir -p /plugins /plugins-frontend /plugins-mcp /plugins-skills - rm -rf \ - "/plugins/${PLUGIN_ID}" \ - "/plugins-frontend/${PLUGIN_ID}" \ - "/plugins-mcp/${PLUGIN_ID}" \ - "/plugins-skills/${PLUGIN_ID}" + mkdir -p /plugins + rm -rf "/plugins/${PLUGIN_ID}" cp -a "/stage/backend/${PLUGIN_ID}" /plugins/ - cp -a "/stage/frontend/${PLUGIN_ID}" /plugins-frontend/ - cp -a "/stage/mcp/${PLUGIN_ID}" /plugins-mcp/ - cp -a "/stage/skills/${PLUGIN_ID}" /plugins-skills/ ' MANIFEST=$(cat "$STAGE/backend/$PLUGIN_ID/plugin.json") @@ -190,5 +149,5 @@ if [[ "$RESP_HTTP" != "200" && "$RESP_HTTP" != "201" ]]; then fi echo "==> OK: ${PLUGIN_ID} v${VERSION_NUM} installed and enabled" -echo " Next: project Settings → GitLab → token + link repositories" -echo " If the UI bundle looks stale: docker compose restart gateway" +echo " Next: Admin → Git → GitLab → Set up (paste an access token), then link" +echo " projects from each Paca project's Settings → Git." diff --git a/skills/paca-gitlab-workflow/SKILL.md b/skills/paca-gitlab-workflow/SKILL.md deleted file mode 100644 index 7414880..0000000 --- a/skills/paca-gitlab-workflow/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -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.gorlix.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.gorlix.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 (`GITLAB_INTEGRATION_NOT_FOUND` / `GITLAB_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 bcfa5a0c954afb78675119b515cf33fa5805ca9c Mon Sep 17 00:00:00 2001 From: pikann22 Date: Wed, 28 Oct 2026 08:15:10 +0000 Subject: [PATCH 02/15] feat: update GitLab identity setup and improve webhook management --- README.md | 16 ++--- backend/go.mod | 2 +- backend/go.sum | 4 +- backend/migrations/0002_git_provider.sql | 9 +-- backend/spi_fakegitlab_test.go | 4 +- backend/spi_provider.go | 17 +++-- backend/spi_setup.go | 90 +++++++++++++++++------- backend/spi_store.go | 5 ++ backend/spi_test.go | 90 ++++++++++++++++++++++-- backend/spi_webhook.go | 2 +- 10 files changed, 185 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 5946721..dcf452b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ GitLab provider for Paca's platform-neutral Git integration (`gitprovider.v1`, s Paca core owns repositories, project links, identities, merge requests, triggers, agent runs and audit. This plugin only adapts GitLab to the provider SPI: -- Identity setup through a token form: an access token (and an optional read-only one) pasted by an administrator. +- Identity setup through a token form: an access token with the Maintainer role and the api scope, pasted by a user; the webhook secret is generated. - Repository listing and resolution on gitlab.com or a self-managed instance. - Credentials for git and the API (GitLab cannot mint narrower tokens, so these are the stored tokens). - Webhook verification and event normalisation; webhook registration per project. @@ -51,18 +51,12 @@ The plugin has no HTTP routes of its own: core calls the internal-only `POST /_g ## Setting it up -1. **Create a token** on GitLab, on the project or group the agents should work in: - - *Settings → Access tokens*, role **Developer** (Maintainer if Paca should register the webhooks), scope **`api`**. - - Optionally a second token with scope **`read_repository`**, which Paca hands to `git clone` and `git fetch` instead of the full token. -2. In Paca open **Admin → Git → GitLab → Set up** and paste the token(s). Choose a **webhook secret** (at least 16 random characters, e.g. `openssl rand -hex 32`). The form shows the webhook URL to use. -3. On every GitLab project you link, add a **webhook** (*Settings → Webhooks*) with that URL, the same secret as *Secret token*, and the triggers **Push events, Comments, Issues events, Merge request events, Pipeline events**. -4. Link the projects from each Paca project's **Settings → Git**. +1. **Create a token** on GitLab, on the project or group the agents should work in: *Settings → Access tokens*, role **Maintainer**, scope **`api`**. The Maintainer role is what lets Paca register webhooks; with a lower role GitLab refuses (403) and no events reach Paca. +2. In Paca open a project's **Settings → Git → Connections → Add GitLab** and paste the token. Choose who may use the connection: *This project only* or *All projects*. A project can have several connections (different groups, different hosts). +3. Link the projects from the project's **Settings → Git → Repositories**. Paca registers the webhook on each GitLab project it links, with a secret it generated, and removes it when the last link goes. If registration fails, the repository says so: fix the token's role (create a new Maintainer token) and press *Try again*. An agent can have its own identity (its own bot user) from the agent's Git tab, set up the same way. -> [!IMPORTANT] -> Core does not call the provider's `EnsureWebhook` yet, so step 3 is manual for now. The provider implements it (`webhook_management`): once core registers hooks, step 3 disappears. A hook registered that way is created with the same secret and subscriptions as above. - ### Self-managed GitLab Enter the instance's host name (no scheme or path, a port is allowed) as the host. Plugin outbound calls are limited to the hosts in `allowedOutboundDomains` (`plugin.json`), HTTPS only, and never to private or internal addresses. Add your host to that list (or use `"*"`) before setting up the identity. @@ -100,7 +94,7 @@ Enter the instance's host name (no scheme or path, a port is allowed) as the hos ## Upgrading from 0.5.x -Migration `0002_git_provider.sql` **drops** the legacy tables (they only exist if the plugin's schema held them: see the ID note below) (`gitlab_integrations`, `gitlab_repositories`, `gitlab_merge_requests`, `gitlab_task_mr_links`, `gitlab_task_branches`). Nothing is migrated, because a project-bound token cannot back a provider identity, and repositories, merge-request links and branch links now live in Paca core. +Migration `0002_git_provider.sql` **drops** the legacy tables (they only exist if the plugin's schema held them: see the ID note below): `gitlab_integrations`, `gitlab_repositories`, `gitlab_merge_requests`, `gitlab_task_mr_links`, `gitlab_task_branches`. Nothing is migrated, because a project-bound token has no webhook secret and cannot back a provider identity on its own, and repositories, merge-request links and branch links now live in Paca core. Add the token again from a project's Git settings. The plugin ID changed from `com.gorlix.gitlab` to `com.paca.gitlab`, so this installs as a new plugin with its own schema `plugin_data_com_paca_gitlab`. The old plugin's data stays in `plugin_data_com_gorlix_gitlab` untouched (and is not read); export anything you still need from there, then remove the old plugin. Afterwards set up a GitLab identity (above) and relink the projects in Paca. diff --git a/backend/go.mod b/backend/go.mod index 4e31206..5447ca9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/gitlab go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.4.0 +require github.com/Paca-AI/plugin-sdk-go v0.4.1 diff --git a/backend/go.sum b/backend/go.sum index 78f3884..9925b1c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.4.0 h1:u3xDC+VGfHrgjI2e+XCgVMdnnKo5cedX36KPg6X0+/4= -github.com/Paca-AI/plugin-sdk-go v0.4.0/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/Paca-AI/plugin-sdk-go v0.4.1 h1:Fid4T5OryhJca+9dBtM09iTipli9KouY/3G5ju2Sz5Y= +github.com/Paca-AI/plugin-sdk-go v0.4.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= diff --git a/backend/migrations/0002_git_provider.sql b/backend/migrations/0002_git_provider.sql index 46a48c5..31dea88 100644 --- a/backend/migrations/0002_git_provider.sql +++ b/backend/migrations/0002_git_provider.sql @@ -35,10 +35,11 @@ CREATE TABLE IF NOT EXISTS gitlab_setup_sessions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- The plugin is a Git provider: repositories, merge requests, task links and --- the per-project connection now live in Paca core (git_* tables). The --- project-bound tables of the previous release are dropped; a project-scoped --- token cannot back a provider identity, so no data is migrated. +-- The plugin is a Git provider: repositories, merge requests, task links and the +-- per-project connection now live in Paca core (git_* tables). The previous +-- release's project-bound tables are dropped; a project-scoped token cannot back +-- a provider identity on its own (it has no webhook secret), so nothing is +-- migrated. DROP TABLE IF EXISTS gitlab_task_mr_links; DROP TABLE IF EXISTS gitlab_task_branches; DROP TABLE IF EXISTS gitlab_merge_requests; diff --git a/backend/spi_fakegitlab_test.go b/backend/spi_fakegitlab_test.go index e94c119..28b5f89 100644 --- a/backend/spi_fakegitlab_test.go +++ b/backend/spi_fakegitlab_test.go @@ -234,7 +234,9 @@ func (g *fakeGitLab) route(method string, segs []string, q url.Values, tok strin if tok == "glpat-readonly-scope" { scopes = []string{"read_api"} } - return resp(200, map[string]any{"scopes": scopes, "expires_at": "2030-06-01", "active": true}) + return resp(200, map[string]any{"user_id": 77, "scopes": scopes, "expires_at": "2030-06-01", "active": true}) + case method == "GET" && len(segs) == 2 && segs[0] == "users" && segs[1] == "77": + return resp(200, map[string]any{"id": 77, "username": "project_1_bot_ab12", "name": "Paca Bot", "bot": true}) case method == "GET" && len(segs) == 1 && segs[0] == "users": name := q.Get("username") if id, ok := fakeUsers[name]; ok { diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 5543a09..818623a 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -30,8 +30,11 @@ func newGLProvider(store spiStore) *glProvider { // - credentials are static: core never hands them to an agent and API calls // are made here, server-side; // - webhooks are per project (GitLab's group hooks need a paid tier), so -// webhook_management is implemented and users without the rights can paste -// the URL and secret by hand; +// webhook_management is implemented: core registers one when a project is +// linked, and users without the rights can paste the URL and secret by hand; +// - there is one way to connect (a pasted access token; whether it makes a +// bot user depends on the token), so Kinds is left empty and the UI offers +// no choice of kind; // - review is emulated (one discussion per inline comment, then a summary); // - reactions and access_grants are not implemented: a reaction names only a // comment id, which GitLab cannot address without its merge request, and a @@ -265,8 +268,12 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ for _, c := range req.Candidates { rec, err := p.loadIdentity(c.ID) if err != nil { - last = err - continue + if gp.AsError(err).Code == gp.CodeNotFound { + last = err + continue + } + // A broken store is not "no identity can see it": say so. + return gp.ResolvedRepository{}, err } if want != "" && !strings.EqualFold(rec.Host, want) { continue // another host's identity cannot see this project @@ -283,7 +290,7 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ // nothing about being a member; a credential is still minted for it. return gp.ResolvedRepository{ Repository: repositoryOf(rec.Host, g), - Identity: gp.IdentityRef{ID: rec.ID, Handle: rec.Username}, + Identity: gp.IdentityRef{ID: rec.ID, Handle: rec.mentionHandle()}, }, nil } if last != nil && gp.AsError(last).Code == gp.CodeForbidden { diff --git a/backend/spi_setup.go b/backend/spi_setup.go index 3f4d407..f5f12ba 100644 --- a/backend/spi_setup.go +++ b/backend/spi_setup.go @@ -2,6 +2,7 @@ package main import ( "context" + "strconv" "strings" "time" @@ -49,21 +50,16 @@ func (p *glProvider) BeginIdentitySetup(_ context.Context, req gp.BeginSetupRequ return gp.SetupStep{}, gp.Errorf(gp.CodeInternal, "could not start setup") } - prompt := "Create a project or group access token on " + host + " with the api scope and the Developer role " + - "(a bot user is created for it), and paste it below. " + - "Then add a webhook to each project you link: URL " + req.WebhookURL + ", the secret token you choose below, " + - "and the triggers Push, Comments, Issues, Merge request and Pipeline events." + prompt := "Create a project or group access token on " + host + " with the Maintainer role and the api scope " + + "(a bot user is created for it), and paste it below. Paca uses it for the API and for git, " + + "and registers the webhook on each project you link, which is why it needs the Maintainer role." return gp.SetupStep{ Kind: gp.StepTokenForm, Prompt: prompt, State: state, Form: []gp.FormField{ {Name: fieldToken, Label: "Access token", Secret: true, Required: true, - Help: "Scope: api. Role: Developer or higher. Used for every API call and for git push."}, - {Name: fieldReadToken, Label: "Read-only access token", Secret: true, - Help: "Optional. Scope: read_repository. Handed to git for clone and fetch instead of the access token above. It cannot be checked here: a wrong one only shows when git first uses it."}, - {Name: fieldWebhookSecret, Label: "Webhook secret", Secret: true, Required: true, - Help: "A random string of at least 16 characters, for example from `openssl rand -hex 32`. Enter the same value as the webhook's Secret token."}, + Help: "Role: Maintainer. Scope: api. Used for every API call, for git and to register webhooks."}, }, }, nil } @@ -77,6 +73,7 @@ type glUser struct { // tokenInfo is what GitLab says about a token: its scopes and expiry. type tokenInfo struct { + UserID int64 `json:"user_id"` Scopes []string `json:"scopes"` ExpiresAt string `json:"expires_at"` // a date, or null Active bool `json:"active"` @@ -104,29 +101,70 @@ func (t tokenInfo) hasScope(s string) bool { } // validateToken proves a token against the instance. A token GitLab rejects is a -// validation error here (the user mistyped it), not a forbidden call. +// validation error here (the user mistyped it), not a forbidden call, and the +// message says what GitLab answered so the cause (a wrong host, an expired +// token, a missing scope) can be told apart. +// +// GET /user is what names the token's user, but GitLab refuses it to tokens +// without the read_user or api scope (some bot tokens among them). Such a token +// can still ask who it is through /personal_access_tokens/self, and the user is +// then read from /users/:id. func validateToken(api *glAPI, requireAPI bool) (glUser, tokenInfo, error) { var u glUser - if err := api.get("/user", &u); err != nil { + var info tokenInfo + haveInfo := false + status, body, _, err := api.doRaw("GET", "/user", nil) + if err != nil { switch gp.AsError(err).Code { - case gp.CodeForbidden, gp.CodeNotFound: - return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeValidation, "GitLab rejected the token: check that it is correct, unexpired and has the api scope") case gp.CodeTransient: return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeTransient, "could not reach the GitLab host: self-hosted instances must be listed in the plugin's allowedOutboundDomains") + case gp.CodeForbidden, gp.CodeNotFound: + if status == 401 { + return glUser{}, tokenInfo{}, rejectedToken(status, body) + } + // Not allowed to read /user: ask the token about itself instead. + if ierr := api.get("/personal_access_tokens/self", &info); ierr != nil || info.UserID == 0 { + return glUser{}, tokenInfo{}, rejectedToken(status, body) + } + haveInfo = true + if uerr := api.get("/users/"+strconv.FormatInt(info.UserID, 10), &u); uerr != nil { + return glUser{}, tokenInfo{}, rejectedToken(status, body) + } + default: + return glUser{}, tokenInfo{}, err } - return glUser{}, tokenInfo{}, err + } else if derr := decodeInto(body, &u); derr != nil { + return glUser{}, tokenInfo{}, derr } if u.Username == "" { return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeInternal, "GitLab returned an incomplete user") } - var info tokenInfo // Older instances do not have this endpoint; the token still works without it. - if err := api.get("/personal_access_tokens/self", &info); err == nil && requireAPI && len(info.Scopes) > 0 && !info.hasScope("api") { - return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeValidation, "the token needs the api scope") + if !haveInfo { + _ = api.get("/personal_access_tokens/self", &info) + } + if requireAPI && len(info.Scopes) > 0 && !info.hasScope("api") { + return glUser{}, tokenInfo{}, gp.Errorf(gp.CodeValidation, "the token needs the api scope (it has %s)", strings.Join(info.Scopes, ", ")) } return u, info, nil } +// rejectedToken is the validation error for a token GitLab would not accept, +// quoting GitLab's own words and status. +func rejectedToken(status int, body string) error { + detail := clip(errorMessage(body)) + if detail == "" { + detail = "no details" + } + if status == 403 && strings.Contains(strings.ToLower(detail), "insufficient_scope") { + return gp.Errorf(gp.CodeValidation, + "GitLab says the token's scopes are not enough (HTTP 403: insufficient_scope). Create the access token with the api scope (read_repository or write_repository alone are not enough) and paste the new one") + } + return gp.Errorf(gp.CodeValidation, + "GitLab rejected the token (HTTP %d: %s). Check that the token is correct and unexpired, has the api scope, and that the host is the GitLab instance it was created on", + status, detail) +} + // CompleteIdentitySetup implements gp.Provider. func (p *glProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSetupRequest) (gp.Identity, error) { token := strings.TrimSpace(req.Params[fieldToken]) @@ -134,7 +172,13 @@ func (p *glProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSet if strings.TrimSpace(req.State) == "" || token == "" { return gp.Identity{}, gp.Errorf(gp.CodeValidation, "state and token are required") } - if len(secret) < minWebhookSecretLen { + switch { + case secret == "": + var err error + if secret, err = randomHex(32); err != nil { + return gp.Identity{}, gp.Errorf(gp.CodeInternal, "could not generate a webhook secret") + } + case len(secret) < minWebhookSecretLen: return gp.Identity{}, gp.Errorf(gp.CodeValidation, "the webhook secret must be at least %d characters", minWebhookSecretLen) } s, err := p.store.loadSetup(req.State) @@ -186,7 +230,7 @@ func identityOf(rec identityRecord) gp.Identity { name = rec.Username } return gp.Identity{ - Ref: gp.IdentityRef{ID: rec.ID, Handle: rec.Username, ProviderRef: providerRef(map[string]any{"user_id": rec.UserID})}, + Ref: gp.IdentityRef{ID: rec.ID, Handle: rec.mentionHandle(), ProviderRef: providerRef(map[string]any{"user_id": rec.UserID})}, Host: rec.Host, Kind: kind, DisplayName: name, @@ -198,12 +242,8 @@ func identityOf(rec identityRecord) gp.Identity { // access token itself stays valid on GitLab (revoking it needs a higher scope // than the token has); the operator revokes it there. func (p *glProvider) DeleteIdentity(_ context.Context, req gp.DeleteIdentityRequest) (gp.Empty, error) { - if _, err := p.loadIdentity(req.Identity.ID); err != nil { - if gp.AsError(err).Code == gp.CodeNotFound { - return gp.Empty{}, nil // already gone - } - return gp.Empty{}, err - } + // Forgetting an identity must not depend on being able to read it: a row + // that cannot be loaded is exactly one an operator wants gone. if err := p.store.deleteIdentity(req.Identity.ID); err != nil { return gp.Empty{}, gp.Errorf(gp.CodeInternal, "could not delete the identity") } diff --git a/backend/spi_store.go b/backend/spi_store.go index 60fc708..f5d521b 100644 --- a/backend/spi_store.go +++ b/backend/spi_store.go @@ -50,3 +50,8 @@ type setupSession struct { Completed bool ExpiresAt time.Time } + +// mentionHandle is what people mention: the identity's GitLab username. For a +// project or group access token that is the bot user GitLab generates +// ("project__bot_"), which GitLab cannot rename. +func (r identityRecord) mentionHandle() string { return r.Username } diff --git a/backend/spi_test.go b/backend/spi_test.go index 048bf7c..a03250f 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -39,8 +39,8 @@ func beginShared(t *testing.T, p *glProvider) gp.SetupStep { func TestSetup_TokenFormAsksForTheRightInputs(t *testing.T) { p, _, _ := seededProvider(t) step := beginShared(t, p) - if step.Kind != gp.StepTokenForm || step.State == "" || !strings.Contains(step.Prompt, "https://paca.test/hook") { - t.Fatalf("step %+v: want a token form whose prompt names the webhook URL", step) + if step.Kind != gp.StepTokenForm || step.State == "" || !strings.Contains(step.Prompt, "Maintainer") || strings.Contains(step.Prompt, "https://paca.test/hook") { + t.Fatalf("step %+v: want a token form whose prompt asks for the Maintainer role and does not send the user to register a webhook by hand", step) } required := map[string]bool{} for _, f := range step.Form { @@ -49,8 +49,8 @@ func TestSetup_TokenFormAsksForTheRightInputs(t *testing.T) { t.Errorf("field %s holds a secret but is not marked secret", f.Name) } } - if !required[fieldToken] || !required[fieldWebhookSecret] || required[fieldReadToken] { - t.Errorf("required fields %v: want token and webhook_secret required, read_token optional", required) + if len(required) != 1 || !required[fieldToken] { + t.Errorf("form fields %v: want only the access token (the webhook secret is generated)", required) } } @@ -522,3 +522,85 @@ func TestStatusRefRoundTrip(t *testing.T) { } } } + +// Leaving the webhook secret empty is fine: the provider generates one. +func TestSetup_GeneratesTheWebhookSecretWhenLeftEmpty(t *testing.T) { + p, _, st := seededProvider(t) + step := beginShared(t, p) + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, + Params: map[string]string{fieldToken: fakeNewToken}}) + if err != nil { + t.Fatal(err) + } + stored, err := st.loadIdentity(id.Ref.ID) + if err != nil || len(stored.WebhookSecret) < minWebhookSecretLen { + t.Fatalf("stored secret = %q, %v", stored.WebhookSecret, err) + } +} + +// GitLab refuses GET /user to some tokens (no read_user/api scope on a bot +// token); they are accepted when /personal_access_tokens/self names the user. +func TestSetup_TokenThatCannotReadUserIsIdentifiedByItself(t *testing.T) { + p, g, _ := seededProvider(t) + g.forceError["GET /user"] = fakeResponse{status: 403, body: `{"message":"403 Forbidden"}`} + step := beginShared(t, p) + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams(fakeNewToken)}) + if err != nil { + t.Fatal(err) + } + if id.Ref.Handle != "project_1_bot_ab12" { + t.Errorf("identity %+v", id) + } +} + +// A rejection says what GitLab answered, so the cause can be told apart. +func TestSetup_RejectionQuotesGitLab(t *testing.T) { + p, g, _ := seededProvider(t) + step := beginShared(t, p) + _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams("glpat-wrong")}) + code(t, err, gp.CodeValidation) + if !strings.Contains(err.Error(), "HTTP 401") || !strings.Contains(err.Error(), "401 Unauthorized") { + t.Errorf("error %q should quote GitLab's answer", err) + } + g.forceError["GET /user"] = fakeResponse{status: 404, body: `{"message":"404 Not Found"}`} + g.forceError["GET /personal_access_tokens/self"] = fakeResponse{status: 404, body: `{"message":"404 Not Found"}`} + _, err = p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams(fakeNewToken)}) + code(t, err, gp.CodeValidation) + if !strings.Contains(err.Error(), "HTTP 404") || !strings.Contains(err.Error(), "host is the GitLab instance") { + t.Errorf("error %q should name the status and hint at the host", err) + } +} + +func TestSetup_InsufficientScopeSaysWhichScopeToUse(t *testing.T) { + p, g, _ := seededProvider(t) + g.forceError["GET /user"] = fakeResponse{status: 403, body: `{"error":"insufficient_scope","error_description":"scope"}`} + g.forceError["GET /personal_access_tokens/self"] = fakeResponse{status: 403, body: `{"error":"insufficient_scope"}`} + step := beginShared(t, p) + _, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams(fakeNewToken)}) + code(t, err, gp.CodeValidation) + if !strings.Contains(err.Error(), "api scope") { + t.Errorf("error %q should name the scope to use", err) + } +} + +// brokenLoadStore cannot read identities (a schema out of step with the code) +// but can still delete them. +type brokenLoadStore struct{ *memStore } + +func (brokenLoadStore) loadIdentity(string) (identityRecord, error) { + return identityRecord{}, errors.New("column does not exist") +} + +func TestBrokenStore_DeleteStillWorksAndResolveSaysSo(t *testing.T) { + _, g, st := seededProvider(t) + _ = g + p := newGLProvider(brokenLoadStore{st}) + if _, err := p.DeleteIdentity(bg, gp.DeleteIdentityRequest{Identity: gp.IdentityRef{ID: fakeIdentityID}}); err != nil { + t.Fatalf("delete must not need to read the identity: %v", err) + } + if _, ok := st.identities[fakeIdentityID]; ok { + t.Fatal("the identity should be gone") + } + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/web", Candidates: []gp.IdentityRef{{ID: "x"}}}) + code(t, err, gp.CodeInternal) // not a misleading "no identity can see it" +} diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go index 54456b6..c1b7a68 100644 --- a/backend/spi_webhook.go +++ b/backend/spi_webhook.go @@ -171,7 +171,7 @@ func (d *delivery) finish(ev gp.Event, parts ...string) gp.Event { } func (d *delivery) mention(text string) []gp.Addressed { - if mentionsHandle(text, d.rec.Username) { + if mentionsHandle(text, d.rec.mentionHandle()) { return []gp.Addressed{{IdentityID: d.rec.ID, Via: gp.ViaMention}} } return nil From 59ef5d840dda6a0c50b353090b08c70d40c19233 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Wed, 28 Oct 2026 12:06:45 +0000 Subject: [PATCH 03/15] feat: enhance webhook verification and delivery ID handling --- backend/gitlab.go | 13 +++++++++++++ backend/spi_test.go | 25 ++++++++++++++++++------- backend/spi_webhook.go | 2 +- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/backend/gitlab.go b/backend/gitlab.go index 89d3519..f1991f6 100644 --- a/backend/gitlab.go +++ b/backend/gitlab.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/hex" "regexp" + "strconv" "strings" "time" @@ -58,6 +59,9 @@ func normalizeHost(h string) (string, bool) { // baseURL is the REST API root of a GitLab host. func baseURL(host string) string { return "https://" + host + apiPrefix } +// webhookTolerance is how far a Standard Webhooks timestamp may be from now. +const webhookTolerance = 5 * time.Minute + func secretEqual(a, b string) bool { return a != "" && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } @@ -68,6 +72,15 @@ func verifyStandardWebhook(payload []byte, signingToken, msgID, timestamp, signa if signingToken == "" || msgID == "" || timestamp == "" || signatureHeader == "" { return false } + // A signed delivery stays valid only for a short window, so a captured one + // cannot be replayed later. + sec, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return false + } + if d := time.Since(time.Unix(sec, 0)); d > webhookTolerance || d < -webhookTolerance { + return false + } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(signingToken, "whsec_")) if err != nil || len(key) == 0 { return false diff --git a/backend/spi_test.go b/backend/spi_test.go index a03250f..ee13f06 100644 --- a/backend/spi_test.go +++ b/backend/spi_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "errors" + "strconv" "strings" "testing" "time" @@ -292,10 +293,11 @@ func TestRateLimitAndTransientFailuresReachCore(t *testing.T) { func TestVerifyDelivery(t *testing.T) { secret := "whsec_" + base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) + nowTS := strconv.FormatInt(time.Now().Unix(), 10) body := []byte(`{"object_kind":"push"}`) key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) mac := hmac.New(sha256.New, key) - mac.Write([]byte("msg-1.1700000000." + string(body))) + mac.Write([]byte("msg-1." + nowTS + "." + string(body))) sig := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) req := func(h map[string]string) gp.WebhookRequest { return gp.WebhookRequest{Body: body, Headers: h} } @@ -305,17 +307,25 @@ func TestVerifyDelivery(t *testing.T) { }{ "secret token": {req(map[string]string{"x-gitlab-token": secret}), true}, "wrong secret token": {req(map[string]string{"x-gitlab-token": secret + "x"}), false}, - "standard webhooks": {req(map[string]string{"webhook-id": "msg-1", "webhook-timestamp": "1700000000", "webhook-signature": sig}), true}, - "signature of another id": {req(map[string]string{"webhook-id": "msg-2", "webhook-timestamp": "1700000000", "webhook-signature": sig}), false}, - "one of several": {req(map[string]string{"webhook-id": "msg-1", "webhook-timestamp": "1700000000", "webhook-signature": "v1,AAAA " + sig}), true}, + "standard webhooks": {req(map[string]string{"webhook-id": "msg-1", "webhook-timestamp": nowTS, "webhook-signature": sig}), true}, + "signature of another id": {req(map[string]string{"webhook-id": "msg-2", "webhook-timestamp": nowTS, "webhook-signature": sig}), false}, + "one of several": {req(map[string]string{"webhook-id": "msg-1", "webhook-timestamp": nowTS, "webhook-signature": "v1,AAAA " + sig}), true}, "no credentials": {req(map[string]string{}), false}, // A present-but-wrong token must not fall through to the signature check. - "wrong token with a valid signature": {req(map[string]string{"x-gitlab-token": "nope", "webhook-id": "msg-1", "webhook-timestamp": "1700000000", "webhook-signature": sig}), false}, + "wrong token with a valid signature": {req(map[string]string{"x-gitlab-token": "nope", "webhook-id": "msg-1", "webhook-timestamp": nowTS, "webhook-signature": sig}), false}, } { if got := verifyDelivery(tc.req, secret); got != tc.want { t.Errorf("%s: verified = %v, want %v", name, got, tc.want) } } + // A correctly signed but old delivery is a replay. + oldTS := strconv.FormatInt(time.Now().Add(-time.Hour).Unix(), 10) + omac := hmac.New(sha256.New, key) + omac.Write([]byte("msg-1." + oldTS + "." + string(body))) + osig := "v1," + base64.StdEncoding.EncodeToString(omac.Sum(nil)) + if verifyDelivery(req(map[string]string{"webhook-id": "msg-1", "webhook-timestamp": oldTS, "webhook-signature": osig}), secret) { + t.Error("a delivery signed an hour ago must be rejected as a replay") + } if verifyDelivery(req(map[string]string{"x-gitlab-token": ""}), "") { t.Error("an identity without a secret must verify nothing") } @@ -323,6 +333,7 @@ func TestVerifyDelivery(t *testing.T) { func TestWebhook_StandardWebhooksSignedDeliveryParses(t *testing.T) { p, _, st := seededProvider(t) + nowTS := strconv.FormatInt(time.Now().Unix(), 10) rec := st.identities[fakeIdentityID] rec.WebhookSecret = "whsec_" + base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")) st.identities[fakeIdentityID] = rec @@ -330,8 +341,8 @@ func TestWebhook_StandardWebhooksSignedDeliveryParses(t *testing.T) { delete(req.Headers, "x-gitlab-token") key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(rec.WebhookSecret, "whsec_")) mac := hmac.New(sha256.New, key) - mac.Write([]byte("msg-9.1700000000." + string(req.Body))) - req.Headers["webhook-id"], req.Headers["webhook-timestamp"] = "msg-9", "1700000000" + mac.Write([]byte("msg-9." + nowTS + "." + string(req.Body))) + req.Headers["webhook-id"], req.Headers["webhook-timestamp"] = "msg-9", nowTS req.Headers["webhook-signature"] = "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)) evs, err := p.VerifyAndParseWebhook(bg, req) if err != nil || len(evs) != 1 || len(evs[0].Addressed) != 1 { diff --git a/backend/spi_webhook.go b/backend/spi_webhook.go index c1b7a68..6736ed8 100644 --- a/backend/spi_webhook.go +++ b/backend/spi_webhook.go @@ -74,7 +74,7 @@ func deliveryID(req gp.WebhookRequest) string { } } sum := sha256.Sum256(req.Body) - return hex.EncodeToString(sum[:8]) + return hex.EncodeToString(sum[:16]) } // delivery turns the payloads of one identity's webhook into events. From 7ec178a4140346a04a194676f5b6237ad0726238 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Wed, 28 Oct 2026 16:20:11 +0100 Subject: [PATCH 04/15] feat: recognize token types and their reach when resolving repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify each access token at setup (project, group, service account or personal) and store the type, the project or group id and its path on the identity, with a new migration for the columns. Project and group tokens are told by the bot user name GitLab gives them; any other bot user is a service account, whose name its creator may choose, and a human user holds a personal token. Identities stored before get classified lazily on first load. ResolveRepository skips connections whose token cannot cover the repository, requires at least the Developer role so a public project is never bound to a non-member token, and explains what each connection covers (or that its role is too low) when none qualifies. The hint no longer names only group access tokens, which GitLab.com offers on Premium and above. MintCredential refuses writes to non-members too. The display name now says what a connection covers, e.g. "Project token · acme/ui" or "Service account · ". --- backend/gitlab.go | 3 + .../migrations/0003_identity_token_reach.sql | 9 +++ backend/spi_dbstore.go | 15 +++- backend/spi_provider.go | 80 ++++++++++++++++++- backend/spi_setup.go | 77 ++++++++++++++++-- backend/spi_store.go | 23 ++++++ 6 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/0003_identity_token_reach.sql diff --git a/backend/gitlab.go b/backend/gitlab.go index f1991f6..c8b2632 100644 --- a/backend/gitlab.go +++ b/backend/gitlab.go @@ -38,6 +38,9 @@ var hostRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5} // access tokens, e.g. project_123_bot_ab12cd34 or group_9_bot. var accessTokenBotRe = regexp.MustCompile(`^(?:project|group)_[0-9]+_bot(?:_[0-9a-f]+)?$`) +// accessTokenBotKindRe splits such a user name into its kind and resource id. +var accessTokenBotKindRe = regexp.MustCompile(`^(project|group)_([0-9]+)_bot(?:_[0-9a-f]+)?$`) + // nowStr returns the current UTC time as an RFC3339Nano string. func nowStr() string { return time.Now().UTC().Format(time.RFC3339Nano) } diff --git a/backend/migrations/0003_identity_token_reach.sql b/backend/migrations/0003_identity_token_reach.sql new file mode 100644 index 0000000..a2b922a --- /dev/null +++ b/backend/migrations/0003_identity_token_reach.sql @@ -0,0 +1,9 @@ +-- What each access token covers: its kind (personal, project or group) and, for +-- a project or group token, the project or group it was issued for. Existing +-- rows keep the columns empty; the provider classifies them lazily from the +-- stored username the first time it loads them, and saves the result. +-- Run with search_path = plugin_data_com_paca_gitlab, public. + +ALTER TABLE gitlab_identities ADD COLUMN IF NOT EXISTS token_type TEXT NOT NULL DEFAULT ''; +ALTER TABLE gitlab_identities ADD COLUMN IF NOT EXISTS resource_id BIGINT NOT NULL DEFAULT 0; +ALTER TABLE gitlab_identities ADD COLUMN IF NOT EXISTS resource_path TEXT NOT NULL DEFAULT ''; diff --git a/backend/spi_dbstore.go b/backend/spi_dbstore.go index 7bed653..d9d34a0 100644 --- a/backend/spi_dbstore.go +++ b/backend/spi_dbstore.go @@ -22,7 +22,7 @@ type sqlExecutor interface { func newDBStore(p *gitlabPlugin) dbStore { return dbStore{p: p, db: p.db} } func (s dbStore) loadIdentity(id string) (identityRecord, error) { - r, err := s.db.Query(`SELECT id, host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at FROM gitlab_identities WHERE id = $1`, id) + r, err := s.db.Query(`SELECT id, host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at, token_type, resource_id, resource_path FROM gitlab_identities WHERE id = $1`, id) if err != nil { return identityRecord{}, err } @@ -52,6 +52,7 @@ func (s dbStore) loadIdentity(id string) (identityRecord, error) { rec := identityRecord{ ID: sc.str("id"), Host: sc.str("host"), UserID: sc.int64Val("user_id"), Username: sc.str("username"), Name: sc.str("display_name"), Bot: sc.str("bot") == "true", Token: token, ReadToken: readToken, WebhookSecret: secret, + Reach: tokenReach{Type: sc.str("token_type"), ResourceID: sc.int64Val("resource_id"), Path: sc.str("resource_path")}, } if exp := sc.str("expires_at"); exp != "" { if t, perr := time.Parse(time.RFC3339Nano, exp); perr == nil { @@ -86,9 +87,9 @@ func (s dbStore) createIdentity(x identityRecord) (string, error) { } now := nowStr() r, err := s.db.Query(` - INSERT INTO gitlab_identities (host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'git-provider',$10,$10) RETURNING id`, - x.Host, x.UserID, x.Username, x.Name, x.Bot, token, readToken, secret, expires, now) + INSERT INTO gitlab_identities (host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at, token_type, resource_id, resource_path, created_by, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'git-provider',$13,$13) RETURNING id`, + x.Host, x.UserID, x.Username, x.Name, x.Bot, token, readToken, secret, expires, x.Reach.Type, x.Reach.ResourceID, x.Reach.Path, now) if err != nil { return "", err } @@ -103,6 +104,12 @@ func (s dbStore) deleteIdentity(id string) error { return err } +func (s dbStore) saveReach(id string, r tokenReach) error { + _, err := s.db.Exec(`UPDATE gitlab_identities SET token_type = $2, resource_id = $3, resource_path = $4, updated_at = $5 WHERE id = $1`, + id, r.Type, r.ResourceID, r.Path, nowStr()) + return err +} + func (s dbStore) saveSetup(x setupSession) error { _, err := s.db.Exec(` INSERT INTO gitlab_setup_sessions (state, scope, agent_id, host, expires_at, created_at) diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 818623a..ada8901 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -95,6 +95,14 @@ func (p *glProvider) loadIdentity(identityID string) (identityRecord, error) { } return identityRecord{}, gp.Errorf(gp.CodeInternal, "could not load the GitLab identity") } + if rec.Reach.Type == "" { + // Stored before token types were recorded: classify from the username + // once and save it. A failed save only means doing this again next time. + rec.Reach = classifyToken(rec.Username, rec.Bot) + if lookupReach(rec.api(), &rec.Reach) { + _ = p.store.saveReach(rec.ID, rec.Reach) + } + } return rec, nil } @@ -247,8 +255,57 @@ func parseRepoInput(in string) (host, fullPath string, err error) { return host, s, nil } +// covers reports whether the identity's token can reach the repository at the +// given path, judged from what the token was issued for alone. A token whose +// path is unknown is given the benefit of the doubt; the membership check after +// the API call still applies. +func (r identityRecord) covers(fullPath string) bool { + switch r.Reach.Type { + case tokenProject: + return r.Reach.Path == "" || strings.EqualFold(r.Reach.Path, fullPath) + case tokenGroup: + return r.Reach.Path == "" || strings.EqualFold(r.Reach.Path, fullPath) || + strings.HasPrefix(strings.ToLower(fullPath), strings.ToLower(r.Reach.Path)+"/") + } + return true +} + +// coverage says what the connection reaches, for the error shown when no +// connection covers a repository. +func (r identityRecord) coverage() string { + switch r.Reach.Type { + case tokenProject: + if r.Reach.Path != "" { + return "The connected project token covers only " + r.Reach.Path + } + return "The connected project token (" + r.Username + ") covers a single other project" + case tokenGroup: + if r.Reach.Path != "" { + return "The connected group token covers only " + r.Reach.Path + " and its subgroups" + } + return "The connected group token (" + r.Username + ") covers a single other group" + } + if r.Reach.Type == tokenServiceAccount { + return "The connected service account " + r.Username + " is not a member of it with at least the Developer role" + } + return "The connected personal token of " + r.Username + " is not a member of it with at least the Developer role" +} + +// tooLowRole says why a connection that does reach a repository still cannot be +// used for it: its role there is below Developer. +func (r identityRecord) tooLowRole() string { + switch r.Reach.Type { + case tokenProject: + return "The connected project token has less than the Developer role on it" + case tokenGroup: + return "The connected group token has less than the Developer role on it" + } + return r.coverage() +} + // ResolveRepository implements gp.Provider: the first candidate identity that -// can see the project wins. +// covers the project, can see it and is a member of it (Developer or above) +// wins. func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequest) (gp.ResolvedRepository, error) { urlHost, fullPath, err := parseRepoInput(req.PathOrURL) if err != nil { @@ -265,6 +322,7 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ return gp.ResolvedRepository{}, gp.Errorf(gp.CodeForbidden, "no GitLab identity is available to look this repository up") } var last error + var skipped []string // what each connection that did not qualify covers for _, c := range req.Candidates { rec, err := p.loadIdentity(c.ID) if err != nil { @@ -278,6 +336,10 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ if want != "" && !strings.EqualFold(rec.Host, want) { continue // another host's identity cannot see this project } + if !rec.covers(fullPath) { + skipped = append(skipped, rec.coverage()) + continue + } var g glProject if err := rec.api().get("/projects/"+pathPart(fullPath), &g); err != nil { if code := gp.AsError(err).Code; code == gp.CodeNotFound || code == gp.CodeForbidden { @@ -287,12 +349,24 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ return gp.ResolvedRepository{}, err } // A public project answers any token, so being able to read it proves - // nothing about being a member; a credential is still minted for it. + // nothing about being a member: require a role that can push, which a + // project token for another project (or a user outside the project) lacks. + if rec.Reach.Type == tokenProject && rec.Reach.ResourceID != g.ID { + skipped = append(skipped, rec.coverage()) + continue + } + if g.accessLevel() < developerLevel { + skipped = append(skipped, rec.tooLowRole()) + continue + } return gp.ResolvedRepository{ Repository: repositoryOf(rec.Host, g), Identity: gp.IdentityRef{ID: rec.ID, Handle: rec.mentionHandle()}, }, nil } + if len(skipped) > 0 { + return gp.ResolvedRepository{}, gp.Errorf(gp.CodeForbidden, "%s. Add a connection for %s, or use a token that is a member of it (a project or group access token, or a service account).", strings.Join(skipped, "; "), fullPath) + } if last != nil && gp.AsError(last).Code == gp.CodeForbidden { return gp.ResolvedRepository{}, last } @@ -319,7 +393,7 @@ func (p *glProvider) MintCredential(_ context.Context, req gp.MintRequest) (gp.C if err := rec.api().get(proj, &g); err != nil { return gp.Credential{}, err } - if lvl := g.accessLevel(); req.Access == gp.AccessWrite && lvl > 0 && lvl < developerLevel { + if lvl := g.accessLevel(); req.Access == gp.AccessWrite && lvl < developerLevel { return gp.Credential{}, gp.Errorf(gp.CodeForbidden, "the identity may not push to this repository") } secret := rec.Token diff --git a/backend/spi_setup.go b/backend/spi_setup.go index f5f12ba..31e7d7c 100644 --- a/backend/spi_setup.go +++ b/backend/spi_setup.go @@ -190,8 +190,11 @@ func (p *glProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSet if err != nil { return gp.Identity{}, err } + reach := classifyToken(user.Username, user.Bot) + lookupReach(api, &reach) rec := identityRecord{ - Host: s.Host, UserID: user.ID, Username: user.Username, Name: user.Name, Bot: user.Bot || accessTokenBotRe.MatchString(user.Username), + Reach: reach, + Host: s.Host, UserID: user.ID, Username: user.Username, Name: user.Name, Bot: user.Bot || accessTokenBotRe.MatchString(user.Username), Token: token, WebhookSecret: secret, ExpiresAt: info.expiry(), } if rt := strings.TrimSpace(req.Params[fieldReadToken]); rt != "" { @@ -220,15 +223,79 @@ func (p *glProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSet return identityOf(rec), nil } +// classifyToken tells the kind of a token from its user name: GitLab names the +// bot user of a project or group access token "project__bot_…" or +// "group__bot_…". Any other bot user is a service account, whose name its +// creator may choose, so it is told by GitLab's bot flag; it reaches what its +// memberships allow, like a personal token. A human user holds a personal token. +// Path is not set. +func classifyToken(username string, bot bool) tokenReach { + m := accessTokenBotKindRe.FindStringSubmatch(username) + if m == nil { + if bot { + return tokenReach{Type: tokenServiceAccount} + } + return tokenReach{Type: tokenPersonal} + } + id, _ := strconv.ParseInt(m[2], 10, 64) + if m[1] == "group" { + return tokenReach{Type: tokenGroup, ResourceID: id} + } + return tokenReach{Type: tokenProject, ResourceID: id} +} + +// lookupReach fills in the path of the project or group a token was issued for. +// It reports false when the lookup was a transient failure worth retrying; any +// other failure (a token without read_api, say) keeps the type with no path. +func lookupReach(api *glAPI, r *tokenReach) bool { + var out struct { + Path string `json:"path_with_namespace"` + Full string `json:"full_path"` + } + switch r.Type { + case tokenProject: + if err := api.get("/projects/"+strconv.FormatInt(r.ResourceID, 10), &out); err != nil { + return gp.AsError(err).Code != gp.CodeTransient + } + r.Path = out.Path + case tokenGroup: + if err := api.get("/groups/"+strconv.FormatInt(r.ResourceID, 10), &out); err != nil { + return gp.AsError(err).Code != gp.CodeTransient + } + r.Path = out.Full + } + return true +} + +// label names what the token covers, e.g. "Project token · acme/ui". +func (r identityRecord) reachLabel() string { + switch r.Reach.Type { + case tokenProject, tokenGroup: + kind := "Project token" + if r.Reach.Type == tokenGroup { + kind = "Group token" + } + if r.Reach.Path != "" { + return kind + " · " + r.Reach.Path + } + return kind + " · " + r.Username + case tokenServiceAccount: + // The user name is long and generated; the name given on GitLab is the one + // people recognise. + if r.Name != "" { + return "Service account · " + r.Name + } + return "Service account · " + r.Username + } + return "Personal token · " + r.Username +} + func identityOf(rec identityRecord) gp.Identity { kind := gp.IdentityToken if rec.Bot { kind = gp.IdentityBotUser } - name := rec.Name - if name == "" { - name = rec.Username - } + name := rec.reachLabel() return gp.Identity{ Ref: gp.IdentityRef{ID: rec.ID, Handle: rec.mentionHandle(), ProviderRef: providerRef(map[string]any{"user_id": rec.UserID})}, Host: rec.Host, diff --git a/backend/spi_store.go b/backend/spi_store.go index f5d521b..0d23518 100644 --- a/backend/spi_store.go +++ b/backend/spi_store.go @@ -12,6 +12,8 @@ type spiStore interface { loadIdentity(id string) (identityRecord, error) createIdentity(r identityRecord) (id string, err error) deleteIdentity(id string) error + // saveReach records what an identity's token covers (see identityRecord). + saveReach(id string, r tokenReach) error saveSetup(s setupSession) error loadSetup(state string) (setupSession, error) @@ -39,6 +41,27 @@ type identityRecord struct { WebhookSecret string // ExpiresAt is the earliest expiry of the stored tokens; zero when unknown. ExpiresAt time.Time + // Reach is what the token covers; its Type is empty until classified. + Reach tokenReach +} + +// Token types: what kind of access token an identity holds. +const ( + tokenPersonal = "personal" + tokenProject = "project" + tokenGroup = "group" + tokenServiceAccount = "service_account" +) + +// tokenReach is what an access token can reach: a personal token the user's +// memberships, a project token one project, a group token a group and its +// subgroups. +type tokenReach struct { + Type string // personal | project | group; empty when not yet classified + // ResourceID and Path name the project or group of a project or group token. + // Path stays empty when the lookup failed (a token without read_api, say). + ResourceID int64 + Path string } // setupSession is one identity-setup attempt, keyed by its single-use state. From a7e0b21b514b14408833639ccecbf88cc23659c0 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Wed, 28 Oct 2026 16:20:11 +0100 Subject: [PATCH 05/15] test: cover token types, reach filtering and the Developer requirement Extend the fake GitLab with project and group bot users, /groups/:id and per-token roles on /projects/:id, and test classification (including service accounts with a chosen name), display names, candidate filtering and ordering, lazy classification of existing rows, a token whose role on its own repository is too low, the error text for a service account, and the refusal of writes at access level 0. Co-Authored-By: Claude Sonnet 5.5 --- backend/spi_conformance_test.go | 12 ++ backend/spi_fakegitlab_test.go | 81 +++++++++-- backend/spi_reach_test.go | 239 ++++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 backend/spi_reach_test.go diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go index 3058230..a883df9 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -49,6 +49,18 @@ func (m *memStore) deleteIdentity(id string) error { return nil } +func (m *memStore) saveReach(id string, r tokenReach) error { + m.mu.Lock() + defer m.mu.Unlock() + rec, ok := m.identities[id] + if !ok { + return errNoSuchIdentity + } + rec.Reach = r + m.identities[id] = rec + return nil +} + func (m *memStore) saveSetup(s setupSession) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/backend/spi_fakegitlab_test.go b/backend/spi_fakegitlab_test.go index 28b5f89..8a4149e 100644 --- a/backend/spi_fakegitlab_test.go +++ b/backend/spi_fakegitlab_test.go @@ -74,9 +74,18 @@ const ( fakeReadToken = "glpat-read-token" fakeNewToken = "glpat-new-token" fakeWebhookSecret = "hook-secret-value-123456" - fakeUsername = "paca-bot" - fakeIdentityID = "ident-1" - fakeHeadSHA = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" + + // Tokens of the three kinds. The project token was issued for acme/ui, the + // group token for acme/frontend; the dev token is a personal one with the + // Developer role on acme/ui and the outsider belongs to nothing. + fakeProjectToken = "glpat-project-ui" + fakeGroupToken = "glpat-group-frontend" + fakeDevToken = "glpat-dev" + fakeReporterProjectToken = "glpat-project-reporter" + fakeOutsiderToken = "glpat-outsider" + fakeUsername = "paca-bot" + fakeIdentityID = "ident-1" + fakeHeadSHA = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" ) var fakeUsers = map[string]int64{"octocat": 1, "reader": 2, "owner": 3, "lead": 4, "bob": 5, fakeUsername: 77} @@ -97,6 +106,28 @@ var fakeProjects = []fakeProject{ {3, "acme/platform/infra", false, 30}, } +// extraProjects exist for the token-reach tests only; the default tokens do not +// see them, and ListRepositories does not list them. +var extraProjects = []fakeProject{ + {4, "acme/ui", false, 0}, + {5, "acme/frontend/app", false, 0}, + {6, "acme/frontend/sub/lib", false, 0}, +} + +// reachTokens maps each reach-test token to its user name and its role per +// project id. A project missing from the map is invisible unless public, and a +// public one is then read with no role at all. +var reachTokens = map[string]struct { + user string + levels map[int64]int +}{ + fakeProjectToken: {"project_4_bot_ab12", map[int64]int{4: 40}}, + fakeGroupToken: {"group_9_bot_cd34", map[int64]int{5: 30, 6: 30}}, + fakeDevToken: {"dev", map[int64]int{4: 30}}, + fakeReporterProjectToken: {"project_4_bot_ee56", map[int64]int{4: 20}}, + fakeOutsiderToken: {"stranger", map[int64]int{}}, +} + func newFakeGitLab(t *testing.T) *fakeGitLab { t.Helper() g := &fakeGitLab{ @@ -139,9 +170,11 @@ func (g *fakeGitLab) called(prefix string) int { } func (g *fakeGitLab) project(ref string) *fakeProject { - for i := range fakeProjects { - if ref == strconv.FormatInt(fakeProjects[i].ID, 10) || ref == fakeProjects[i].Path { - return &fakeProjects[i] + for _, list := range [][]fakeProject{fakeProjects, extraProjects} { + for i := range list { + if ref == strconv.FormatInt(list[i].ID, 10) || ref == list[i].Path { + return &list[i] + } } } return nil @@ -152,9 +185,14 @@ func projectJSON(p fakeProject) map[string]any { if p.Public { vis = "public" } + // A user with no role on a public project gets no project_access, as on GitLab. + var access any + if p.Level > 0 { + access = map[string]any{"access_level": p.Level} + } return map[string]any{ "id": p.ID, "path_with_namespace": p.Path, "default_branch": "main", "visibility": vis, "description": "d-" + p.Path, - "permissions": map[string]any{"project_access": map[string]any{"access_level": p.Level}}, + "permissions": map[string]any{"project_access": access, "group_access": nil}, } } @@ -211,7 +249,8 @@ func (g *fakeGitLab) fetch(method, rawURL string, headers map[string]string, bod return &plugin.FetchResponse{Status: f.status, Body: f.body, Headers: f.headers}, nil } tok := headers["PRIVATE-TOKEN"] - if tok != fakeToken && tok != fakeNewToken && tok != "glpat-readonly-scope" && tok != fakeReadToken { + _, reach := reachTokens[tok] + if tok != fakeToken && tok != fakeNewToken && tok != "glpat-readonly-scope" && tok != fakeReadToken && !reach { return resp(401, map[string]string{"message": "401 Unauthorized"}) } var payload map[string]any @@ -225,6 +264,9 @@ func (g *fakeGitLab) route(method string, segs []string, q url.Values, tok strin switch { case method == "GET" && len(segs) == 1 && segs[0] == "user": name := map[string]string{fakeToken: fakeUsername, fakeNewToken: "project_1_bot_ab12", "glpat-readonly-scope": "someone", fakeReadToken: fakeUsername}[tok] + if r, ok := reachTokens[tok]; ok { + name = r.user + } return resp(200, map[string]any{"id": 77, "username": name, "name": "Paca Bot", "bot": strings.Contains(name, "_bot_")}) case method == "GET" && len(segs) == 2 && segs[0] == "personal_access_tokens" && segs[1] == "self": if tok == fakeReadToken { @@ -245,7 +287,30 @@ func (g *fakeGitLab) route(method string, segs []string, q url.Values, tok strin return resp(200, []any{}) case method == "GET" && len(segs) == 1 && segs[0] == "projects": return g.listProjects(q) + case method == "GET" && len(segs) == 2 && segs[0] == "groups": + if tok == fakeGroupToken && segs[1] == "9" { + return resp(200, map[string]any{"id": 9, "full_path": "acme/frontend", "path": "frontend"}) + } + return notFound() + case method == "GET" && len(segs) == 2 && segs[0] == "projects" && reachTokens[tok].user != "": + // The reach-test tokens see projects through their own roles. + p := g.project(segs[1]) + if p == nil { + return notFound() + } + lvl, member := reachTokens[tok].levels[p.ID] + if !member && !p.Public { + return notFound() + } + q := *p + q.Level = lvl + return resp(200, projectJSON(q)) case len(segs) >= 2 && segs[0] == "projects": + for _, x := range extraProjects { + if (segs[1] == x.Path || segs[1] == strconv.FormatInt(x.ID, 10)) && reachTokens[tok].user == "" { + return notFound() // the default tokens do not see these projects + } + } return g.projectAPI(method, segs[1], segs[2:], q, payload) } return notFound() diff --git a/backend/spi_reach_test.go b/backend/spi_reach_test.go new file mode 100644 index 0000000..1e40d85 --- /dev/null +++ b/backend/spi_reach_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "strings" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// reachIdentity seeds an identity holding a token of the given kind. +func reachIdentity(st *memStore, id, token, username string, reach tokenReach) gp.IdentityRef { + st.identities[id] = identityRecord{ID: id, Host: fakeHost, UserID: 77, Username: username, Token: token, Bot: reach.Type != tokenPersonal, Reach: reach} + return gp.IdentityRef{ID: id, Handle: username} +} + +var ( + projectReach = tokenReach{Type: tokenProject, ResourceID: 4, Path: "acme/ui"} + groupReach = tokenReach{Type: tokenGroup, ResourceID: 9, Path: "acme/frontend"} +) + +func TestClassifyToken(t *testing.T) { + for _, tc := range []struct { + name string + bot bool + want tokenReach + }{ + {"project_4_bot_ab12", true, tokenReach{Type: tokenProject, ResourceID: 4}}, + {"project_12_bot", true, tokenReach{Type: tokenProject, ResourceID: 12}}, + {"group_9_bot_cd34", true, tokenReach{Type: tokenGroup, ResourceID: 9}}, + {"alice", false, tokenReach{Type: tokenPersonal}}, + {"project_x_bot", false, tokenReach{Type: tokenPersonal}}, + {"my_project_4_bot", false, tokenReach{Type: tokenPersonal}}, + // A service account's name may be chosen by its creator: the bot flag tells it. + {"service_account_group_143230090_44d957ff", true, tokenReach{Type: tokenServiceAccount}}, + {"reach-sa", true, tokenReach{Type: tokenServiceAccount}}, + } { + if got := classifyToken(tc.name, tc.bot); got != tc.want { + t.Errorf("%s (bot %v) → %+v, want %+v", tc.name, tc.bot, got, tc.want) + } + } +} + +func TestSetup_RecordsTheTokenTypeAndReach(t *testing.T) { + for name, tc := range map[string]struct { + token, display string + want tokenReach + }{ + "personal": {fakeToken, "Personal token · paca-bot", tokenReach{Type: tokenPersonal}}, + "project": {fakeProjectToken, "Project token · acme/ui", projectReach}, + "group": {fakeGroupToken, "Group token · acme/frontend", groupReach}, + } { + p, _, st := seededProvider(t) + step := beginShared(t, p) + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams(tc.token)}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if id.DisplayName != tc.display { + t.Errorf("%s: display name %q, want %q", name, id.DisplayName, tc.display) + } + if stored, _ := st.loadIdentity(id.Ref.ID); stored.Reach != tc.want { + t.Errorf("%s: stored reach %+v, want %+v", name, stored.Reach, tc.want) + } + } +} + +func TestSetup_FailedLookupKeepsTheTypeWithoutAPath(t *testing.T) { + p, g, st := seededProvider(t) + g.forceError["GET /projects/4"] = fakeResponse{status: 403, body: `{"message":"403 Forbidden"}`} + step := beginShared(t, p) + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams(fakeProjectToken)}) + if err != nil { + t.Fatal(err) + } + if stored, _ := st.loadIdentity(id.Ref.ID); stored.Reach != (tokenReach{Type: tokenProject, ResourceID: 4}) { + t.Errorf("reach %+v: want the type and id, no path", stored.Reach) + } + if !strings.HasPrefix(id.DisplayName, "Project token") { + t.Errorf("display name %q", id.DisplayName) + } +} + +func TestResolveRepository_ProjectTokenIsSkippedForAnotherRepo(t *testing.T) { + p, g, st := seededProvider(t) + proj := reachIdentity(st, "proj", fakeProjectToken, "project_4_bot_ab12", projectReach) + r, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{proj}}) + if err != nil || r.Repository.Ref.ExternalID != "4" || r.Identity.ID != "proj" { + t.Fatalf("own project: %+v %v", r, err) + } + // acme/api is public: the API would answer, but the token was issued elsewhere. + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{proj}}) + code(t, err, gp.CodeForbidden) + if !strings.Contains(err.Error(), "covers only acme/ui") || !strings.Contains(err.Error(), "Add a connection for acme/api") { + t.Errorf("error %q should say what the connection covers and what to do", err) + } + if g.called("GET /projects/acme/api") != 0 { + t.Error("a token that cannot cover the repository must not be asked about it") + } +} + +func TestResolveRepository_GroupTokenCoversSubgroups(t *testing.T) { + p, _, st := seededProvider(t) + grp := reachIdentity(st, "grp", fakeGroupToken, "group_9_bot_cd34", groupReach) + for _, path := range []string{"acme/frontend/app", "acme/frontend/sub/lib"} { + r, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: path, Candidates: []gp.IdentityRef{grp}}) + if err != nil || r.Repository.Ref.FullPath != path { + t.Errorf("%s: %+v %v", path, r, err) + } + } + // A sibling that merely shares the name prefix is not below the group. + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/frontend-old/app", Candidates: []gp.IdentityRef{grp}}) + code(t, err, gp.CodeForbidden) + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{grp}}) + code(t, err, gp.CodeForbidden) + if !strings.Contains(err.Error(), "group token covers only acme/frontend") { + t.Errorf("error %q", err) + } +} + +func TestResolveRepository_PublicRepoIsNotBoundToANonMember(t *testing.T) { + p, g, st := seededProvider(t) + out := reachIdentity(st, "out", fakeOutsiderToken, "stranger", tokenReach{Type: tokenPersonal}) + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{out}}) + code(t, err, gp.CodeForbidden) + if g.called("GET /projects/acme/api") != 1 { + t.Error("a personal token is asked: only the answer's role can tell") + } + // A Reporter (level 20) can read but not push, so it does not win either. + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{fixtureIdentity}}) + code(t, err, gp.CodeForbidden) +} + +// A project token for the very repository, but below Developer, must not be +// told it "covers only" that repository: it does, and the role is the problem. +func TestResolveRepository_LowRoleTokenIsToldItsRoleIsTooLow(t *testing.T) { + p, _, st := seededProvider(t) + rep := reachIdentity(st, "rep", fakeReporterProjectToken, "project_4_bot_ee56", projectReach) + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{rep}}) + code(t, err, gp.CodeForbidden) + if !strings.Contains(err.Error(), "less than the Developer role") || strings.Contains(err.Error(), "covers only") { + t.Errorf("error %q: want the role named, not the coverage", err) + } +} + +func TestResolveRepository_FirstQualifyingCandidateWins(t *testing.T) { + p, _, st := seededProvider(t) + proj := reachIdentity(st, "proj", fakeProjectToken, "project_4_bot_ab12", projectReach) + dev := reachIdentity(st, "dev", fakeDevToken, "dev", tokenReach{Type: tokenPersonal}) + out := reachIdentity(st, "out", fakeOutsiderToken, "stranger", tokenReach{Type: tokenPersonal}) + resolve := func(cands ...gp.IdentityRef) string { + t.Helper() + r, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: cands}) + if err != nil { + t.Fatal(err) + } + return r.Identity.ID + } + if got := resolve(dev, proj); got != "dev" { + t.Errorf("winner %s, want dev (listed first)", got) + } + if got := resolve(proj, dev); got != "proj" { + t.Errorf("winner %s, want proj (listed first)", got) + } + if got := resolve(out, dev, proj); got != "dev" { + t.Errorf("winner %s, want dev (the outsider cannot see it)", got) + } +} + +func TestIdentity_LegacyRowsAreClassifiedOnFirstLoad(t *testing.T) { + p, _, st := seededProvider(t) + ref := reachIdentity(st, "old", fakeProjectToken, "project_4_bot_ab12", tokenReach{}) + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{ref}}); err != nil { + t.Fatal(err) + } + if got := st.identities["old"].Reach; got != projectReach { + t.Errorf("saved reach %+v, want %+v", got, projectReach) + } + // The seeded identity predates the columns too: it is a bot that is neither a + // project nor a group token, so a service account. + if _, err := p.MintCredential(bg, gp.MintRequest{Target: fixtureTarget, Access: gp.AccessRead}); err != nil { + t.Fatal(err) + } + if got := st.identities[fakeIdentityID].Reach.Type; got != tokenServiceAccount { + t.Errorf("type %q, want service_account", got) + } + // A human user's token is personal. + st.identities["human"] = identityRecord{ID: "human", Host: fakeHost, UserID: 78, Username: "alice", Token: fakeDevToken} + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{{ID: "human"}}}); err != nil { + t.Fatal(err) + } + if got := st.identities["human"].Reach.Type; got != tokenPersonal { + t.Errorf("type %q, want personal", got) + } +} + +func TestMintCredential_WriteIsRefusedToANonMember(t *testing.T) { + p, _, st := seededProvider(t) + out := reachIdentity(st, "out", fakeOutsiderToken, "stranger", tokenReach{Type: tokenPersonal}) + api := gp.RepoRef{Host: fakeHost, FullPath: "acme/api", ExternalID: "2"} + target := gp.Target{Identity: out, Repo: api} + if _, err := p.MintCredential(bg, gp.MintRequest{Target: target, Access: gp.AccessRead}); err != nil { + t.Errorf("a public repository can be cloned by anyone: %v", err) + } + _, err := p.MintCredential(bg, gp.MintRequest{Target: target, Access: gp.AccessWrite}) + code(t, err, gp.CodeForbidden) +} + +func TestIdentityOf_DisplayNames(t *testing.T) { + for want, rec := range map[string]identityRecord{ + "Project token · acme/ui": {Username: "project_4_bot_x", Reach: projectReach}, + "Group token · acme/frontend": {Username: "group_9_bot_x", Reach: groupReach}, + "Personal token · alice": {Username: "alice", Name: "Alice A", Reach: tokenReach{Type: tokenPersonal}}, + "Project token · project_4_bot_x": {Username: "project_4_bot_x", Reach: tokenReach{Type: tokenProject, ResourceID: 4}}, + "Service account · paca-reach": {Username: "service_account_group_9_ab12", Name: "paca-reach", Reach: tokenReach{Type: tokenServiceAccount}}, + "Service account · service_account_group_9_ab12": {Username: "service_account_group_9_ab12", Reach: tokenReach{Type: tokenServiceAccount}}, + } { + if got := identityOf(rec).DisplayName; got != want { + t.Errorf("display name %q, want %q", got, want) + } + } +} + +// A service account is judged by its memberships, like a personal token, but is +// named for what it is, and the hint to add a token does not name only group +// access tokens (GitLab.com offers those on Premium and above). +func TestResolveRepository_ServiceAccountOutsideTheRepositoryIsExplained(t *testing.T) { + p, _, st := seededProvider(t) + sa := reachIdentity(st, "sa", fakeOutsiderToken, "service_account_group_9_ab12", tokenReach{Type: tokenServiceAccount}) + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{sa}}) + code(t, err, gp.CodeForbidden) + for _, want := range []string{"service account service_account_group_9_ab12", "Add a connection for acme/api", "a service account"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q lacks %q", err, want) + } + } + if strings.Contains(err.Error(), "use a group access token") { + t.Errorf("error %q still points only at group access tokens", err) + } +} From 6c8c9c69b436e09c3405c6db6fe08f524c3e109b Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Wed, 28 Oct 2026 16:39:49 +0100 Subject: [PATCH 06/15] test: cover change-request edited events for title and description changes Co-Authored-By: Claude Sonnet 5.5 --- backend/spi_conformance_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go index 3058230..f8eeb64 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -257,6 +257,10 @@ func conformanceFixtures() conformance.Fixtures { Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "synchronized"}}}, {Name: "mr-marked-ready", Request: mrHook("d11d", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"draft": map[string]any{"previous": true, "current": false}}}), Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "ready"}}}, + {Name: "mr-title-edited", Request: mrHook("d11g", map[string]any{"action": "update", "title": "TST-2 fix"}, map[string]any{"changes": map[string]any{"title": map[string]any{"previous": "fix", "current": "TST-2 fix"}}}), + Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "edited", SubjectKind: gp.SubjectChangeRequest}}}, + {Name: "mr-description-edited", Request: mrHook("d11h", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"description": map[string]any{"previous": "", "current": "TST-2"}}}), + Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "edited"}}}, {Name: "mr-without-relevant-changes-is-dropped", Request: mrHook("d11e", map[string]any{"action": "update"}, map[string]any{"changes": map[string]any{"milestone_id": map[string]any{}}}), Want: nil}, {Name: "mr-merged", Request: mrHook("d11f", map[string]any{"action": "merge", "state": "merged"}, nil), Want: []conformance.Want{{Kind: gp.EventChangeRequest, Action: "merged"}}}, From 5a97385b1e4bf019a06c35f3c6031716aa045126 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Wed, 28 Oct 2026 16:57:18 +0100 Subject: [PATCH 07/15] docs: expand self-managed GitLab setup Cover both traffic directions for a self-managed instance: adding the host to allowedOutboundDomains before installing, the core limits (public address, trusted certificate), GitLab's default block on local-network webhooks, and why a hook must not be edited by hand. Also note that project and group access tokens need a paid tier on GitLab.com. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dcf452b..0f14771 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The plugin has no HTTP routes of its own: core calls the internal-only `POST /_g ## Setting it up -1. **Create a token** on GitLab, on the project or group the agents should work in: *Settings → Access tokens*, role **Maintainer**, scope **`api`**. The Maintainer role is what lets Paca register webhooks; with a lower role GitLab refuses (403) and no events reach Paca. +1. **Create a token** on GitLab, on the project or group the agents should work in: *Settings → Access tokens*, role **Maintainer**, scope **`api`**. The Maintainer role is what lets Paca register webhooks; with a lower role GitLab refuses (403) and no events reach Paca. On GitLab.com, project and group access tokens need the Premium or Ultimate tier; on Free, use a personal access token (ideally of a dedicated user that is a Maintainer of the projects). 2. In Paca open a project's **Settings → Git → Connections → Add GitLab** and paste the token. Choose who may use the connection: *This project only* or *All projects*. A project can have several connections (different groups, different hosts). 3. Link the projects from the project's **Settings → Git → Repositories**. Paca registers the webhook on each GitLab project it links, with a secret it generated, and removes it when the last link goes. If registration fails, the repository says so: fix the token's role (create a new Maintainer token) and press *Try again*. @@ -59,7 +59,24 @@ An agent can have its own identity (its own bot user) from the agent's Git tab, ### Self-managed GitLab -Enter the instance's host name (no scheme or path, a port is allowed) as the host. Plugin outbound calls are limited to the hosts in `allowedOutboundDomains` (`plugin.json`), HTTPS only, and never to private or internal addresses. Add your host to that list (or use `"*"`) before setting up the identity. +Enter the instance's host name (no scheme or path, a port is allowed) as the host. Then check both directions of traffic: + +**Paca → GitLab (API calls).** Plugin outbound calls are limited to the hosts in `allowedOutboundDomains` (`plugin.json`), HTTPS only, and never to private or internal addresses. + +1. Add your host to `allowedOutboundDomains` in `plugin.json` (or use `"*"`), for example `["gitlab.com", "gitlab.example.com"]`. +2. Install or upgrade the plugin with that `plugin.json`: the list is read from the installed manifest, so editing it afterwards has no effect until the plugin is installed again. +3. Set up the identity. + +Two limits come from core, not from this plugin: + +- The host must resolve to a public address. A GitLab reachable only on a private network or VPN (`10.x`, `192.168.x`, …) is refused even when listed; see Paca-AI/paca#656 for the proposal to allow it. +- The certificate must be trusted by the Paca API container. With an internal CA, add it to the container's trust store. + +**GitLab → Paca (webhooks).** The webhook URL is built from Paca's `PUBLIC_URL`, so it must be reachable from the GitLab instance. + +- A GitLab instance refuses hooks to local-network addresses by default. If Paca is on your network, enable *Admin → Settings → Network → Outbound requests → Allow requests to the local network from webhooks and integrations* (or add Paca's host to the allowlist there). +- If registration fails, the repository shows a notice: fix the cause and press *Try again*. +- Don't edit Paca's hook by hand in GitLab: changing its URL clears the secret token, and events then fail verification. To fix a hook, press *Try again* in Paca, which updates the hook (or creates a new one) with the secret. --- From 6fd371d4eb9a72b3d5861a9f1d94ca6459d265ba Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Thu, 29 Oct 2026 11:10:40 +0100 Subject: [PATCH 08/15] feat: migrate the community GitLab plugin's data (0.5.x) Migration 0004 carries over what com.gorlix.gitlab 0.5.x kept in its own schema: each project's access token becomes a project-scoped token connection in core, with its repositories, merge requests, task links and task branches. It mirrors the GitHub provider's migration and is a no-op when the old schema or core's git tables are absent; rerunning it creates nothing new. The old schema is left untouched. A migrated identity is completed on first use: its user is asked of GitLab, a webhook secret is generated, then its reach is classified. A token GitLab rejects asks to connect again, and a token the instance key cannot decrypt names ENCRYPTION_KEY. The old plugin's webhooks are recorded and each is deleted once the repository's new hook is registered, only if it still points at the old plugin; failures are best effort and only retryable ones are tried again. Co-Authored-By: Claude Opus 5.5 --- .../0004_migrate_community_plugin.sql | 119 ++++++++ backend/plugin.go | 4 +- backend/spi_conformance_test.go | 47 ++- backend/spi_dbstore.go | 56 +++- backend/spi_fakegitlab_test.go | 5 +- backend/spi_hooks.go | 16 +- backend/spi_legacy_hooks.go | 70 +++++ backend/spi_migrated_test.go | 288 ++++++++++++++++++ backend/spi_ops_test.go | 8 +- backend/spi_provider.go | 58 +++- backend/spi_store.go | 13 + 11 files changed, 668 insertions(+), 16 deletions(-) create mode 100644 backend/migrations/0004_migrate_community_plugin.sql create mode 100644 backend/spi_legacy_hooks.go create mode 100644 backend/spi_migrated_test.go diff --git a/backend/migrations/0004_migrate_community_plugin.sql b/backend/migrations/0004_migrate_community_plugin.sql new file mode 100644 index 0000000..d494b14 --- /dev/null +++ b/backend/migrations/0004_migrate_community_plugin.sql @@ -0,0 +1,119 @@ +-- 0004_migrate_community_plugin.sql +-- Carries over the data of the community GitLab plugin (com.gorlix.gitlab, +-- 0.5.x), which this provider replaces. Run with +-- search_path = plugin_data_com_paca_gitlab, public. +-- +-- The community plugin had another plugin id, so its tables are in its own +-- schema (plugin_data_com_gorlix_gitlab), not in this one. It kept one access +-- token per project (gitlab_integrations) and the repositories linked with it. +-- Each becomes a project-scoped token connection in core with its +-- repositories, branch links and merge request links. The old schema is left as +-- it is: it belongs to the old plugin, and uninstalling that plugin removes it. +-- +-- What cannot be known here is completed on the token's first use (see +-- completeMigratedIdentity in spi_provider.go): the token's user is asked of +-- GitLab and a webhook secret is generated. The webhooks the old plugin +-- registered on GitLab point at the old plugin and use per-repository secrets. +-- They are kept in gitlab_legacy_webhooks and each is deleted from GitLab once +-- the repository's new hook is registered ("Try again" on the repository), if +-- it still points at the old plugin (see removeLegacyHooks in +-- spi_legacy_hooks.go). +-- Nothing happens when the old plugin was never installed. Core's tables are +-- created before any plugin migration runs; if they are not there (a +-- plugin-only database) only the tokens are copied. + +-- The community plugin's hooks still to remove, per migrated identity. A row +-- goes once its hook is dealt with. +CREATE TABLE IF NOT EXISTS gitlab_legacy_webhooks ( + identity_id UUID NOT NULL REFERENCES gitlab_identities (id) ON DELETE CASCADE, + gitlab_project_id BIGINT NOT NULL DEFAULT 0, + full_path TEXT NOT NULL, + webhook_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (identity_id, webhook_id) +); + +DO $$ +BEGIN + IF to_regclass('plugin_data_com_gorlix_gitlab.gitlab_integrations') IS NULL THEN + RETURN; + END IF; + + -- The ciphertexts are copied as they are: both plugins encrypt with their + -- ENCRYPTION_KEY the same way, so this one needs the key the old one used. + -- host is the host (and port) of the instance URL; the API is always https. + INSERT INTO gitlab_identities (id, host, username, token_enc, created_by) + SELECT i.id, + COALESCE(NULLIF(lower(substring(i.instance_url FROM '^(?:[A-Za-z][A-Za-z0-9+.-]*://)?([^/?#]+)')), ''), 'gitlab.com'), + '', i.access_token_enc, 'com.gorlix.gitlab' + FROM plugin_data_com_gorlix_gitlab.gitlab_integrations i + ON CONFLICT (id) DO NOTHING; + + INSERT INTO gitlab_legacy_webhooks (identity_id, gitlab_project_id, full_path, webhook_id) + SELECT r.integration_id, r.gitlab_project_id, r.full_name, r.webhook_id + FROM plugin_data_com_gorlix_gitlab.gitlab_repositories r + JOIN gitlab_identities g ON g.id = r.integration_id + WHERE r.webhook_id > 0 + ON CONFLICT DO NOTHING; + + IF to_regclass('public.git_identities') IS NULL THEN + RETURN; + END IF; + + INSERT INTO public.git_identities (provider_id, host, provider_identity_id, kind, scope, project_id, handle, display_name) + SELECT 'com.paca.gitlab', g.host, g.id::text, 'token', 'project', i.project_id, '', 'GitLab access token' + FROM plugin_data_com_gorlix_gitlab.gitlab_integrations i + JOIN gitlab_identities g ON g.id = i.id + WHERE NOT EXISTS ( + SELECT 1 FROM public.git_identities c + WHERE c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = i.id::text + ); + + -- Webhook deliveries for a connection arrive at its own endpoint. + INSERT INTO public.git_webhook_endpoints (key, provider_id, identity_id) + SELECT replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''), c.provider_id, c.id + FROM public.git_identities c + JOIN plugin_data_com_gorlix_gitlab.gitlab_integrations i ON c.provider_identity_id = i.id::text + WHERE c.provider_id = 'com.paca.gitlab' AND c.kind = 'token' AND c.scope = 'project' + AND NOT EXISTS (SELECT 1 FROM public.git_webhook_endpoints e WHERE e.identity_id = c.id); + + -- external_id is GitLab's numeric project id, which survives renames. + INSERT INTO public.git_repositories (project_id, provider_id, host, external_id, full_path, default_branch, clone_url, identity_id) + SELECT r.project_id, 'com.paca.gitlab', c.host, + CASE WHEN r.gitlab_project_id > 0 THEN r.gitlab_project_id::text ELSE '' END, + r.full_name, r.default_branch, 'https://' || c.host || '/' || r.full_name || '.git', c.id + FROM plugin_data_com_gorlix_gitlab.gitlab_repositories r + JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text + ON CONFLICT DO NOTHING; + + INSERT INTO public.git_change_requests (repository_id, number, title, state, head_ref, base_ref, author, html_url, merged_at) + SELECT cr.id, m.pr_number::text, m.title, + CASE WHEN m.state IN ('open', 'closed', 'merged') THEN m.state ELSE 'open' END, + m.head_branch, m.base_branch, m.author, m.html_url, m.merged_at + FROM plugin_data_com_gorlix_gitlab.gitlab_merge_requests m + JOIN plugin_data_com_gorlix_gitlab.gitlab_repositories r ON r.id = m.repo_id + JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text + JOIN public.git_repositories cr ON cr.project_id = r.project_id AND cr.provider_id = 'com.paca.gitlab' + AND cr.host = c.host AND lower(cr.full_path) = lower(r.full_name) + ON CONFLICT DO NOTHING; + + INSERT INTO public.git_task_links (task_id, change_request_id) + SELECT l.task_id, cr.id + FROM plugin_data_com_gorlix_gitlab.gitlab_task_mr_links l + JOIN plugin_data_com_gorlix_gitlab.gitlab_merge_requests m ON m.id = l.merge_request_id + JOIN plugin_data_com_gorlix_gitlab.gitlab_repositories r ON r.id = m.repo_id + JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text + JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.gitlab' + AND gr.host = c.host AND lower(gr.full_path) = lower(r.full_name) + JOIN public.git_change_requests cr ON cr.repository_id = gr.id AND cr.number = m.pr_number::text + ON CONFLICT DO NOTHING; + + INSERT INTO public.git_task_branches (task_id, repository_id, branch_name) + SELECT b.task_id, gr.id, b.branch_name + FROM plugin_data_com_gorlix_gitlab.gitlab_task_branches b + JOIN plugin_data_com_gorlix_gitlab.gitlab_repositories r ON r.id = b.repo_id + JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text + JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.gitlab' + AND gr.host = c.host AND lower(gr.full_path) = lower(r.full_name) + ON CONFLICT DO NOTHING; +END $$; diff --git a/backend/plugin.go b/backend/plugin.go index 0c2b1d4..e817535 100644 --- a/backend/plugin.go +++ b/backend/plugin.go @@ -22,7 +22,9 @@ func (p *gitlabPlugin) Init(ctx *plugin.Context) error { p.db = ctx.DB() p.log = ctx.Log() p.cfg = ctx.Config() - serve.Register(ctx, newGLProvider(newDBStore(p))) + prov := newGLProvider(newDBStore(p)) + prov.warn = p.log.Warn + serve.Register(ctx, prov) return nil } diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go index 0a1f990..a4cda04 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "strconv" + "strings" "sync" "testing" "time" @@ -17,10 +18,12 @@ type memStore struct { identities map[string]identityRecord setups map[string]setupSession seq int + // legacyHooks are each identity's community-plugin hooks still to remove. + legacyHooks map[string][]legacyWebhook } func newMemStore() *memStore { - return &memStore{identities: map[string]identityRecord{}, setups: map[string]setupSession{}} + return &memStore{identities: map[string]identityRecord{}, setups: map[string]setupSession{}, legacyHooks: map[string][]legacyWebhook{}} } func (m *memStore) loadIdentity(id string) (identityRecord, error) { @@ -61,6 +64,48 @@ func (m *memStore) saveReach(id string, r tokenReach) error { return nil } +func (m *memStore) completeIdentity(r identityRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + rec, ok := m.identities[r.ID] + if !ok { + return errNoSuchIdentity + } + if rec.Username == "" { + rec.UserID, rec.Username, rec.Name, rec.Bot, rec.ExpiresAt = r.UserID, r.Username, r.Name, r.Bot, r.ExpiresAt + } + if rec.WebhookSecret == "" { + rec.WebhookSecret = r.WebhookSecret + } + m.identities[r.ID] = rec + return nil +} + +func (m *memStore) legacyWebhooks(identityID string, gitlabProjectID int64, fullPath string) ([]legacyWebhook, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []legacyWebhook + for _, h := range m.legacyHooks[identityID] { + if (gitlabProjectID > 0 && h.GitLabProjectID == gitlabProjectID) || strings.EqualFold(h.FullPath, fullPath) { + out = append(out, h) + } + } + return out, nil +} + +func (m *memStore) forgetLegacyWebhook(identityID string, webhookID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + kept := m.legacyHooks[identityID][:0] + for _, h := range m.legacyHooks[identityID] { + if h.WebhookID != webhookID { + kept = append(kept, h) + } + } + m.legacyHooks[identityID] = kept + return nil +} + func (m *memStore) saveSetup(s setupSession) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/backend/spi_dbstore.go b/backend/spi_dbstore.go index d9d34a0..200b4f4 100644 --- a/backend/spi_dbstore.go +++ b/backend/spi_dbstore.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "time" plugin "github.com/Paca-AI/plugin-sdk-go" @@ -35,7 +36,11 @@ func (s dbStore) loadIdentity(id string) (identityRecord, error) { if enc == "" { return "", nil } - return s.p.decrypt(enc) + v, err := s.p.decrypt(enc) + if err != nil { + return "", fmt.Errorf("%w: %s: %v", errUndecryptable, col, err) + } + return v, nil } token, err := open("token_enc") if err != nil { @@ -110,6 +115,55 @@ func (s dbStore) saveReach(id string, r tokenReach) error { return err } +func (s dbStore) completeIdentity(x identityRecord) error { + secret := "" + if x.WebhookSecret != "" { + var err error + if secret, err = s.p.encrypt(x.WebhookSecret); err != nil { + return err + } + } + var expires any + if !x.ExpiresAt.IsZero() { + expires = x.ExpiresAt.UTC().Format(time.RFC3339Nano) + } + // Every CASE reads the row as it was before this statement, so the user + // fields are written together, and only while the username is still blank. + _, err := s.db.Exec(` + UPDATE gitlab_identities SET + user_id = CASE WHEN username = '' THEN $2 ELSE user_id END, + username = CASE WHEN username = '' THEN $3 ELSE username END, + display_name = CASE WHEN username = '' THEN $4 ELSE display_name END, + bot = CASE WHEN username = '' THEN $5 ELSE bot END, + expires_at = CASE WHEN username = '' THEN $6::timestamptz ELSE expires_at END, + webhook_secret_enc = CASE WHEN webhook_secret_enc = '' THEN $7 ELSE webhook_secret_enc END, + updated_at = $8 + WHERE id = $1`, + x.ID, x.UserID, x.Username, x.Name, x.Bot, expires, secret, nowStr()) + return err +} + +func (s dbStore) legacyWebhooks(identityID string, gitlabProjectID int64, fullPath string) ([]legacyWebhook, error) { + r, err := s.db.Query(` + SELECT gitlab_project_id, full_path, webhook_id FROM gitlab_legacy_webhooks + WHERE identity_id = $1 AND (($2 > 0 AND gitlab_project_id = $2) OR lower(full_path) = lower($3))`, + identityID, gitlabProjectID, fullPath) + if err != nil { + return nil, err + } + var out []legacyWebhook + for _, row := range r.Rows { + sc := newRowScanner(r.Columns, row) + out = append(out, legacyWebhook{GitLabProjectID: sc.int64Val("gitlab_project_id"), FullPath: sc.str("full_path"), WebhookID: sc.int64Val("webhook_id")}) + } + return out, nil +} + +func (s dbStore) forgetLegacyWebhook(identityID string, webhookID int64) error { + _, err := s.db.Exec(`DELETE FROM gitlab_legacy_webhooks WHERE identity_id = $1 AND webhook_id = $2`, identityID, webhookID) + return err +} + func (s dbStore) saveSetup(x setupSession) error { _, err := s.db.Exec(` INSERT INTO gitlab_setup_sessions (state, scope, agent_id, host, expires_at, created_at) diff --git a/backend/spi_fakegitlab_test.go b/backend/spi_fakegitlab_test.go index 8a4149e..0612460 100644 --- a/backend/spi_fakegitlab_test.go +++ b/backend/spi_fakegitlab_test.go @@ -646,7 +646,10 @@ func (g *fakeGitLab) hookAPI(method string, rest []string, payload map[string]an if _, ok := g.hooks[id]; !ok { return notFound() } - if method == "DELETE" { + switch method { + case "GET": + return resp(200, map[string]any{"id": id, "url": g.hooks[id]}) + case "DELETE": delete(g.hooks, id) return text(204, "") } diff --git a/backend/spi_hooks.go b/backend/spi_hooks.go index 1892bde..c146e66 100644 --- a/backend/spi_hooks.go +++ b/backend/spi_hooks.go @@ -44,15 +44,25 @@ func (p *glProvider) EnsureWebhook(_ context.Context, req gp.EnsureWebhookReques if rec.WebhookSecret == "" { return gp.WebhookRef{}, gp.Errorf(gp.CodeConflict, "the identity has no webhook secret") } - api := rec.api() - body := hookBody(u.String(), rec.WebhookSecret) + ref, err := ensureHook(rec.api(), proj, u.String(), hookBody(u.String(), rec.WebhookSecret)) + if err != nil { + return gp.WebhookRef{}, err + } + // Only now that the new hook is in place: a repository keeps its old hook + // until it has a working one. + p.removeLegacyHooks(rec, req.Target.Repo, proj) + return ref, nil +} + +// ensureHook updates the project's hook that points at hookURL, or creates one. +func ensureHook(api *glAPI, proj, hookURL string, body map[string]any) (gp.WebhookRef, error) { for pg := 1; pg <= maxHookPages; pg++ { var hooks []glHook if err := api.get(paged(proj+"/hooks", pg), &hooks); err != nil { return gp.WebhookRef{}, err } for _, h := range hooks { - if h.URL == u.String() { + if h.URL == hookURL { if err := api.send("PUT", proj+"/hooks/"+strconv.FormatInt(h.ID, 10), body, nil); err != nil { return gp.WebhookRef{}, err } diff --git a/backend/spi_legacy_hooks.go b/backend/spi_legacy_hooks.go new file mode 100644 index 0000000..aecf01c --- /dev/null +++ b/backend/spi_legacy_hooks.go @@ -0,0 +1,70 @@ +package main + +import ( + "net/url" + "strconv" + "strings" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// legacyHookPath is in the URL of every hook the community plugin (0.5.x) +// registered: /api/v1/plugins/com.gorlix.gitlab/projects//webhook. +const legacyHookPath = "/api/v1/plugins/com.gorlix.gitlab/projects/" + +// legacyWebhook is a hook the community plugin registered on a repository with +// the token an identity was migrated from (see 0004_migrate_community_plugin.sql). +type legacyWebhook struct { + GitLabProjectID int64 + FullPath string + WebhookID int64 +} + +func isLegacyHookURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && strings.Contains(u.Path, legacyHookPath) && strings.HasSuffix(u.Path, "/webhook") +} + +// removeLegacyHooks deletes the hooks the community plugin registered on the +// repository, once its new hook is in place: they point at the old plugin, +// whose route is gone once it is removed, so GitLab would keep failing them +// until it disables them. Only a hook whose URL is still the old plugin's is +// deleted; one that was pointed elsewhere is left alone. +// +// It is best effort and never fails EnsureWebhook. Every outcome but a +// retryable failure is recorded, so a hook is tried once, not on every call. +func (p *glProvider) removeLegacyHooks(rec identityRecord, repo gp.RepoRef, proj string) { + var projectID int64 + if allDigits(repo.ExternalID) { + projectID, _ = strconv.ParseInt(repo.ExternalID, 10, 64) + } + hooks, err := p.store.legacyWebhooks(rec.ID, projectID, repo.FullPath) + if err != nil || len(hooks) == 0 { + return + } + api := rec.api() + for _, h := range hooks { + path := proj + "/hooks/" + strconv.FormatInt(h.WebhookID, 10) + var cur glHook + err := api.get(path, &cur) + switch { + case err == nil && isLegacyHookURL(cur.URL): + err = api.send("DELETE", path, nil, nil) + case err == nil: + p.warn("GitLab hook " + strconv.FormatInt(h.WebhookID, 10) + " on " + h.FullPath + + " no longer points at the previous GitLab plugin; it was left in place") + } + if err != nil { + e := gp.AsError(err) + if e.Retryable() { + continue + } + // Already gone, or the token may not manage hooks: nothing to retry. + if e.Code != gp.CodeNotFound && e.Code != gp.CodeForbidden { + p.warn("could not remove the previous GitLab plugin's hook " + strconv.FormatInt(h.WebhookID, 10) + + " on " + h.FullPath + ": " + e.Message) + } + } + _ = p.store.forgetLegacyWebhook(rec.ID, h.WebhookID) + } +} diff --git a/backend/spi_migrated_test.go b/backend/spi_migrated_test.go new file mode 100644 index 0000000..8396830 --- /dev/null +++ b/backend/spi_migrated_test.go @@ -0,0 +1,288 @@ +package main + +import ( + "errors" + "strings" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// migratedIdentity seeds what 0004_migrate_community_plugin.sql leaves behind: +// the host and the token, nothing else. +func migratedIdentity(st *memStore, id, token string) gp.IdentityRef { + st.identities[id] = identityRecord{ID: id, Host: fakeHost, Token: token} + return gp.IdentityRef{ID: id} +} + +func (g *fakeGitLab) count(call string) int { + g.mu.Lock() + defer g.mu.Unlock() + n := 0 + for _, c := range g.calls { + if c == call { + n++ + } + } + return n +} + +// A migrated token is completed on first use: its user is asked of GitLab, a +// webhook secret is generated and its reach is classified, and all of it is +// stored so the next use asks GitLab nothing. +func TestMigratedIdentity_CompletedOnFirstUse(t *testing.T) { + g := newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(st) + ref := migratedIdentity(st, "migrated-1", fakeProjectToken) + + got, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{Candidates: []gp.IdentityRef{ref}, PathOrURL: "acme/ui"}) + if err != nil { + t.Fatal(err) + } + if got.Repository.Ref.FullPath != "acme/ui" { + t.Errorf("resolved %+v", got.Repository) + } + rec := st.identities["migrated-1"] + if rec.Username != "project_4_bot_ab12" || rec.UserID != 77 || !rec.Bot { + t.Errorf("user not stored: %+v", rec) + } + if len(rec.WebhookSecret) != 64 { + t.Errorf("webhook secret %q, want 32 random bytes in hex", rec.WebhookSecret) + } + if rec.ExpiresAt.IsZero() { + t.Error("the token's expiry was not stored") + } + if rec.Reach != projectReach { + t.Errorf("reach %+v, want %+v", rec.Reach, projectReach) + } + + secret := rec.WebhookSecret + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{Candidates: []gp.IdentityRef{ref}, PathOrURL: "acme/ui"}); err != nil { + t.Fatal(err) + } + if n := g.count("GET /user"); n != 1 { + t.Errorf("GET /user called %d times, want once", n) + } + if st.identities["migrated-1"].WebhookSecret != secret { + t.Error("the webhook secret changed on the second use") + } +} + +// The completed identity registers hooks with its new secret and verifies the +// deliveries signed with it. +func TestMigratedIdentity_WebhookUsesTheGeneratedSecret(t *testing.T) { + newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(st) + ref := migratedIdentity(st, "migrated-1", fakeToken) + + body := []byte(`{"object_kind":"push","project":{"id":1,"path_with_namespace":"acme/web"},"ref":"refs/heads/main"}`) + _, err := p.VerifyAndParseWebhook(bg, gp.WebhookRequest{Identity: ref, Headers: map[string]string{"x-gitlab-token": "guess"}, Body: body}) + code(t, err, gp.CodeInvalidSignature) + + secret := st.identities["migrated-1"].WebhookSecret + if secret == "" { + t.Fatal("no webhook secret was generated") + } + if _, err := p.VerifyAndParseWebhook(bg, gp.WebhookRequest{Identity: ref, Headers: map[string]string{"x-gitlab-token": secret}, Body: body}); err != nil { + t.Fatalf("delivery signed with the generated secret: %v", err) + } +} + +// A token GitLab no longer accepts cannot be completed: the error says to +// connect again, and nothing is stored. +func TestMigratedIdentity_RevokedToken(t *testing.T) { + newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(st) + ref := migratedIdentity(st, "migrated-1", "glpat-revoked") + + _, err := p.ListRepositories(bg, gp.ListReposRequest{Identity: ref}) + code(t, err, gp.CodeForbidden) + if !strings.Contains(err.Error(), "connect GitLab again") { + t.Errorf("error does not say what to do: %v", err) + } + if rec := st.identities["migrated-1"]; rec.Username != "" || rec.WebhookSecret != "" { + t.Errorf("a failed completion stored %+v", rec) + } +} + +// racingStore stores another secret just before the provider's completion, as a +// concurrent first use on another replica would. +type racingStore struct { + *memStore + other string +} + +func (s racingStore) completeIdentity(r identityRecord) error { + first := r + first.WebhookSecret = s.other + if err := s.memStore.completeIdentity(first); err != nil { + return err + } + return s.memStore.completeIdentity(r) +} + +// Two first uses at once must agree on the secret, or the hook one registers +// rejects the deliveries the other verifies: the stored one wins. +func TestMigratedIdentity_ConcurrentCompletionKeepsTheStoredSecret(t *testing.T) { + newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(racingStore{memStore: st, other: "secret-stored-by-the-other-replica"}) + ref := migratedIdentity(st, "migrated-1", fakeToken) + + rec, err := p.loadIdentity(ref.ID) + if err != nil { + t.Fatal(err) + } + if rec.WebhookSecret != "secret-stored-by-the-other-replica" { + t.Errorf("provider uses %q, not the stored secret", rec.WebhookSecret) + } +} + +// undecryptableStore is a store whose instance key does not open the token. +type undecryptableStore struct{ *memStore } + +func (undecryptableStore) loadIdentity(string) (identityRecord, error) { + return identityRecord{}, errors.Join(errUndecryptable, errors.New("cipher: message authentication failed")) +} + +// A token stored under another ENCRYPTION_KEY (the old plugin's, say) gets an +// error naming the key, not a generic failure. +func TestMigratedIdentity_WrongEncryptionKey(t *testing.T) { + p := newGLProvider(undecryptableStore{newMemStore()}) + _, err := p.loadIdentity("migrated-1") + code(t, err, gp.CodeInternal) + if !strings.Contains(err.Error(), "ENCRYPTION_KEY") { + t.Errorf("error does not name the key: %v", err) + } +} + +const ( + newHookURL = "https://paca.test/api/v1/git/webhooks/abc" + legacyHookURL = "https://paca.test/api/v1/plugins/com.gorlix.gitlab/projects/0b6f1c1e-3c1a-4e0e-9a51-7f1f0b3c2d4e/webhook" +) + +// legacyHooksProvider is the fixture identity with community-plugin hooks on +// acme/web: hook 10 (registered by project id) and hook 11 (by path only). +func legacyHooksProvider(t *testing.T, urls map[int64]string) (*glProvider, *fakeGitLab, *memStore, *[]string) { + t.Helper() + p, g, st := seededProvider(t) + var warned []string + p.warn = func(m string) { warned = append(warned, m) } + for id, u := range urls { + g.hooks[id] = u + h := legacyWebhook{GitLabProjectID: 1, FullPath: "acme/web", WebhookID: id} + if id == 11 { + h = legacyWebhook{FullPath: "Acme/Web", WebhookID: id} + } + st.legacyHooks[fakeIdentityID] = append(st.legacyHooks[fakeIdentityID], h) + } + return p, g, st, &warned +} + +func ensureFixtureHook(p *glProvider) error { + _, err := p.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: fixtureTarget, URL: newHookURL}) + return err +} + +// Once the new hook is registered, the old plugin's hooks on the repository go, +// whether they were recorded by project id or by path. +func TestLegacyHooks_RemovedAfterTheNewHook(t *testing.T) { + p, g, st, warned := legacyHooksProvider(t, map[int64]string{10: legacyHookURL, 11: legacyHookURL}) + if err := ensureFixtureHook(p); err != nil { + t.Fatal(err) + } + if _, ok := g.hooks[10]; ok { + t.Error("legacy hook 10 is still on GitLab") + } + if _, ok := g.hooks[11]; ok { + t.Error("legacy hook 11 (recorded by path) is still on GitLab") + } + if len(g.hooks) != 1 { + t.Errorf("hooks left: %v, want only the new one", g.hooks) + } + if n := len(st.legacyHooks[fakeIdentityID]); n != 0 { + t.Errorf("%d legacy hooks still recorded", n) + } + if len(*warned) != 0 { + t.Errorf("unexpected warnings: %v", *warned) + } +} + +// A hook someone has since pointed elsewhere is not the old plugin's any more: +// it stays, with a warning, and is not looked at again. +func TestLegacyHooks_RepointedHookIsLeft(t *testing.T) { + p, g, st, warned := legacyHooksProvider(t, map[int64]string{10: "https://ci.example.com/hook"}) + if err := ensureFixtureHook(p); err != nil { + t.Fatal(err) + } + if g.hooks[10] != "https://ci.example.com/hook" { + t.Error("a hook that does not point at the old plugin was deleted") + } + if len(*warned) != 1 || !strings.Contains((*warned)[0], "left in place") { + t.Errorf("warnings: %v", *warned) + } + if n := len(st.legacyHooks[fakeIdentityID]); n != 0 { + t.Errorf("%d legacy hooks still recorded", n) + } +} + +// Without a working new hook the repository keeps its old one. +func TestLegacyHooks_KeptWhenRegistrationFails(t *testing.T) { + p, g, st, _ := legacyHooksProvider(t, map[int64]string{10: legacyHookURL}) + g.forceError["POST /projects/1/hooks"] = fakeResponse{status: 403, body: `{"message":"403 Forbidden"}`} + if err := ensureFixtureHook(p); err == nil { + t.Fatal("registration was expected to fail") + } + if _, ok := g.hooks[10]; !ok { + t.Error("legacy hook deleted although the new one was not registered") + } + if n := len(st.legacyHooks[fakeIdentityID]); n != 1 { + t.Errorf("%d legacy hooks recorded, want 1 still to do", n) + } +} + +// A hook already deleted on GitLab is done with; a transient failure is retried +// on the next call. Neither fails the registration. +func TestLegacyHooks_GoneOrTransient(t *testing.T) { + p, g, st, warned := legacyHooksProvider(t, map[int64]string{10: legacyHookURL, 11: legacyHookURL}) + delete(g.hooks, 10) + g.forceError["GET /projects/1/hooks/11"] = fakeResponse{status: 502, body: `{"message":"502 Bad Gateway"}`} + if err := ensureFixtureHook(p); err != nil { + t.Fatal(err) + } + left := st.legacyHooks[fakeIdentityID] + if len(left) != 1 || left[0].WebhookID != 11 { + t.Fatalf("recorded after the first call: %+v, want only hook 11", left) + } + delete(g.forceError, "GET /projects/1/hooks/11") + if err := ensureFixtureHook(p); err != nil { + t.Fatal(err) + } + if _, ok := g.hooks[11]; ok { + t.Error("legacy hook 11 was not removed on the retry") + } + if n := len(st.legacyHooks[fakeIdentityID]); n != 0 { + t.Errorf("%d legacy hooks still recorded", n) + } + if len(*warned) != 0 { + t.Errorf("unexpected warnings: %v", *warned) + } +} + +func TestIsLegacyHookURL(t *testing.T) { + for u, want := range map[string]bool{ + legacyHookURL: true, + "https://paca.example.com/sub/api/v1/plugins/com.gorlix.gitlab/projects/x/webhook": true, + newHookURL: false, + "https://paca.test/api/v1/plugins/com.paca.gitlab/projects/x/webhook": false, + "https://evil.test/?u=/api/v1/plugins/com.gorlix.gitlab/projects/x/webhook": false, + "::": false, + } { + if got := isLegacyHookURL(u); got != want { + t.Errorf("isLegacyHookURL(%q) = %v, want %v", u, got, want) + } + } +} diff --git a/backend/spi_ops_test.go b/backend/spi_ops_test.go index 0a77c29..fe49ef1 100644 --- a/backend/spi_ops_test.go +++ b/backend/spi_ops_test.go @@ -353,7 +353,7 @@ func TestOptionalOps_ArgumentChecks(t *testing.T) { } func TestWebhookManagement_UpdatesInsteadOfDuplicating(t *testing.T) { - p, g, st := seededProvider(t) + p, g, _ := seededProvider(t) req := gp.EnsureWebhookRequest{Target: fixtureTarget, URL: "https://paca.test/api/v1/git/webhooks/abc"} a, err := p.EnsureWebhook(bg, req) if err != nil { @@ -378,12 +378,6 @@ func TestWebhookManagement_UpdatesInsteadOfDuplicating(t *testing.T) { g.forceError["POST /projects/1/hooks"] = fakeResponse{status: 403, body: `{"message":"403 Forbidden"}`} _, err = p.EnsureWebhook(bg, gp.EnsureWebhookRequest{Target: fixtureTarget, URL: "https://paca.test/api/v1/git/webhooks/ghi"}) code(t, err, gp.CodeForbidden) - - rec := st.identities[fakeIdentityID] - rec.WebhookSecret = "" - st.identities[fakeIdentityID] = rec - _, err = p.EnsureWebhook(bg, req) - code(t, err, gp.CodeConflict) } func TestDeleteIdentity_ForgetsTheTokens(t *testing.T) { diff --git a/backend/spi_provider.go b/backend/spi_provider.go index ada8901..954f9c3 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "net/url" "strconv" "strings" @@ -20,10 +21,12 @@ import ( type glProvider struct { store spiStore now func() time.Time + // warn reports what an operator may want to know but no caller can act on. + warn func(msg string) } func newGLProvider(store spiStore) *glProvider { - return &glProvider{store: store, now: time.Now} + return &glProvider{store: store, now: time.Now, warn: func(string) {}} } // Describe implements gp.Provider. Capabilities are declared honestly: @@ -90,11 +93,21 @@ func (p *glProvider) loadIdentity(identityID string) (identityRecord, error) { } rec, err := p.store.loadIdentity(identityID) if err != nil { - if err == errNoSuchIdentity { + switch { + case errors.Is(err, errNoSuchIdentity): return identityRecord{}, notFoundIdentity() + case errors.Is(err, errUndecryptable): + return identityRecord{}, gp.Errorf(gp.CodeInternal, + "the stored GitLab token cannot be decrypted: the plugin's ENCRYPTION_KEY is not the one it was stored with "+ + "(after an upgrade from the 0.5.x plugin, set the key that plugin used), or connect GitLab again") } return identityRecord{}, gp.Errorf(gp.CodeInternal, "could not load the GitLab identity") } + if rec.Username == "" || rec.WebhookSecret == "" { + if err := p.completeMigratedIdentity(&rec); err != nil { + return identityRecord{}, err + } + } if rec.Reach.Type == "" { // Stored before token types were recorded: classify from the username // once and save it. A failed save only means doing this again next time. @@ -106,6 +119,47 @@ func (p *glProvider) loadIdentity(identityID string) (identityRecord, error) { return rec, nil } +// completeMigratedIdentity fills in what an identity carried over from the +// community plugin (0.5.x) lacks: the token's user (asked of GitLab) and a +// webhook secret (generated; the old per-repository secrets belonged to hooks +// that pointed at the old plugin). It runs on first use and stores the result; +// the token's reach is classified afterwards like any unclassified identity. +func (p *glProvider) completeMigratedIdentity(rec *identityRecord) error { + if rec.Username == "" { + // The token was checked when it was saved, possibly long ago: a token + // GitLab rejects now has to be replaced, not retried. + user, info, err := validateToken(rec.api(), false) + if err != nil { + if e := gp.AsError(err); e.Code == gp.CodeValidation { + return gp.Errorf(gp.CodeForbidden, "the access token carried over from the previous GitLab plugin no longer works (%s): connect GitLab again with a new token", e.Message) + } + return err + } + rec.UserID, rec.Username, rec.Name = user.ID, user.Username, user.Name + rec.Bot = user.Bot || accessTokenBotRe.MatchString(user.Username) + rec.ExpiresAt = info.expiry() + } + if rec.WebhookSecret == "" { + secret, err := randomHex(32) + if err != nil { + return gp.Errorf(gp.CodeInternal, "could not generate a webhook secret") + } + rec.WebhookSecret = secret + } + if err := p.store.completeIdentity(*rec); err != nil { + return gp.Errorf(gp.CodeInternal, "could not store the GitLab identity") + } + // completeIdentity only fills blanks, so a concurrent first use may have + // stored a different secret first: read back what won, or hooks signed with + // ours fail. + won, err := p.store.loadIdentity(rec.ID) + if err != nil { + return gp.Errorf(gp.CodeInternal, "could not load the GitLab identity") + } + *rec = won + return nil +} + func (r identityRecord) api() *glAPI { return &glAPI{base: baseURL(r.Host), token: r.Token} } // sameHost reports whether a repository's host is the identity's. An empty host diff --git a/backend/spi_store.go b/backend/spi_store.go index 0d23518..a4b977f 100644 --- a/backend/spi_store.go +++ b/backend/spi_store.go @@ -14,6 +14,15 @@ type spiStore interface { deleteIdentity(id string) error // saveReach records what an identity's token covers (see identityRecord). saveReach(id string, r tokenReach) error + // completeIdentity stores the user and webhook secret of an identity carried + // over from the community plugin (0.5.x). It only fills fields that are still + // blank, so a concurrent completion keeps whichever was stored first. + completeIdentity(r identityRecord) error + // legacyWebhooks lists the community plugin's hooks on a repository, by + // GitLab project id or path, still to be removed for the identity. + legacyWebhooks(identityID string, gitlabProjectID int64, fullPath string) ([]legacyWebhook, error) + // forgetLegacyWebhook records that a legacy hook was dealt with. + forgetLegacyWebhook(identityID string, webhookID int64) error saveSetup(s setupSession) error loadSetup(state string) (setupSession, error) @@ -26,6 +35,10 @@ type spiStore interface { var errNoSuchIdentity = errors.New("gitlab identity not found") var errNoSuchSetup = errors.New("setup session not found") +// errUndecryptable is a stored secret the instance key does not open: the key +// changed, or the row came from a plugin configured with another one. +var errUndecryptable = errors.New("gitlab identity secret cannot be decrypted") + // identityRecord is one GitLab access-token identity. type identityRecord struct { ID string // row id; the SPI IdentityRef.ID From 4ce2786187645f5391736dc7080d9ea9da091ba0 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Thu, 29 Oct 2026 11:14:14 +0100 Subject: [PATCH 09/15] fix: carry over 0.5.0/0.5.1 data kept in this plugin's own schema Community releases 0.5.0 and 0.5.1 already used the id com.paca.gitlab, so their tables are in this plugin's schema, and 0002 dropped them before anything could carry them over. 0002 now keeps them; 0004 gathers the rows of both releases (0.5.2's schema wins for a project found in both), carries them over and then drops the tables in this schema, as the GitHub provider does with its previous release's. Co-Authored-By: Claude Opus 5.5 --- backend/migrations/0002_git_provider.sql | 14 +-- .../0004_migrate_community_plugin.sql | 103 +++++++++++++----- 2 files changed, 82 insertions(+), 35 deletions(-) diff --git a/backend/migrations/0002_git_provider.sql b/backend/migrations/0002_git_provider.sql index 31dea88..dd7785b 100644 --- a/backend/migrations/0002_git_provider.sql +++ b/backend/migrations/0002_git_provider.sql @@ -1,8 +1,7 @@ -- 0002_git_provider.sql -- The GitLab provider's own state: the access-token identities Paca connects -- with (tokens and webhook secrets encrypted) and the setup sessions that tie a --- token-form round trip together. Drops the previous release's project-bound --- tables (see the end). +-- token-form round trip together. -- Run with search_path = plugin_data_com_paca_gitlab, public. CREATE TABLE IF NOT EXISTS gitlab_identities ( @@ -37,11 +36,6 @@ CREATE TABLE IF NOT EXISTS gitlab_setup_sessions ( -- The plugin is a Git provider: repositories, merge requests, task links and the -- per-project connection now live in Paca core (git_* tables). The previous --- release's project-bound tables are dropped; a project-scoped token cannot back --- a provider identity on its own (it has no webhook secret), so nothing is --- migrated. -DROP TABLE IF EXISTS gitlab_task_mr_links; -DROP TABLE IF EXISTS gitlab_task_branches; -DROP TABLE IF EXISTS gitlab_merge_requests; -DROP TABLE IF EXISTS gitlab_repositories; -DROP TABLE IF EXISTS gitlab_integrations; +-- release's project-bound tables (0001) are kept here until +-- 0004_migrate_community_plugin.sql has carried their rows over to core, and +-- dropped there. diff --git a/backend/migrations/0004_migrate_community_plugin.sql b/backend/migrations/0004_migrate_community_plugin.sql index d494b14..cfd3b23 100644 --- a/backend/migrations/0004_migrate_community_plugin.sql +++ b/backend/migrations/0004_migrate_community_plugin.sql @@ -1,14 +1,18 @@ -- 0004_migrate_community_plugin.sql --- Carries over the data of the community GitLab plugin (com.gorlix.gitlab, --- 0.5.x), which this provider replaces. Run with --- search_path = plugin_data_com_paca_gitlab, public. +-- Carries over the data of the community GitLab plugin (0.5.x), which this +-- provider replaces. Run with search_path = plugin_data_com_paca_gitlab, public. -- --- The community plugin had another plugin id, so its tables are in its own --- schema (plugin_data_com_gorlix_gitlab), not in this one. It kept one access --- token per project (gitlab_integrations) and the repositories linked with it. --- Each becomes a project-scoped token connection in core with its --- repositories, branch links and merge request links. The old schema is left as --- it is: it belongs to the old plugin, and uninstalling that plugin removes it. +-- The community plugin kept one access token per project (gitlab_integrations) +-- and the repositories linked with it. Each becomes a project-scoped token +-- connection in core with its repositories, branch links and merge request +-- links. Its tables are in one of two places: +-- - 0.5.0 and 0.5.1 had this plugin's id (com.paca.gitlab), so their tables +-- are in this schema: 0001 created them and 0002 kept them. They are +-- dropped at the end, as the GitHub provider drops its previous release's. +-- - 0.5.2 had its own id (com.gorlix.gitlab), so its tables are in +-- plugin_data_com_gorlix_gitlab. That schema belongs to the old plugin and +-- is left as it is. +-- A project found in both is taken from 0.5.2, the newer. -- -- What cannot be known here is completed on the token's first use (see -- completeMigratedIdentity in spi_provider.go): the token's user is asked of @@ -34,24 +38,66 @@ CREATE TABLE IF NOT EXISTS gitlab_legacy_webhooks ( ); DO $$ +DECLARE + sources TEXT[] := '{}'; + src TEXT; BEGIN - IF to_regclass('plugin_data_com_gorlix_gitlab.gitlab_integrations') IS NULL THEN + IF to_regclass('plugin_data_com_gorlix_gitlab.gitlab_integrations') IS NOT NULL THEN + sources := sources || 'plugin_data_com_gorlix_gitlab'::text; + END IF; + IF to_regclass(format('%I.gitlab_integrations', current_schema())) IS NOT NULL THEN + sources := sources || current_schema()::text; + END IF; + IF cardinality(sources) = 0 THEN RETURN; END IF; - -- The ciphertexts are copied as they are: both plugins encrypt with their - -- ENCRYPTION_KEY the same way, so this one needs the key the old one used. + -- Both releases' rows, gathered in one place; src says which schema each + -- came from, so a row only ever joins rows of its own release. + CREATE TEMP TABLE legacy_integrations (src TEXT, id UUID, project_id UUID, access_token_enc TEXT, instance_url TEXT) ON COMMIT DROP; + CREATE TEMP TABLE legacy_repositories (src TEXT, id UUID, project_id UUID, integration_id UUID, full_name TEXT, + gitlab_project_id BIGINT, webhook_id BIGINT, default_branch TEXT) ON COMMIT DROP; + CREATE TEMP TABLE legacy_merge_requests (src TEXT, id UUID, repo_id UUID, pr_number INT, title TEXT, state TEXT, html_url TEXT, + head_branch TEXT, base_branch TEXT, author TEXT, merged_at TIMESTAMPTZ) ON COMMIT DROP; + CREATE TEMP TABLE legacy_task_mr_links (task_id UUID, merge_request_id UUID) ON COMMIT DROP; + CREATE TEMP TABLE legacy_task_branches (task_id UUID, repo_id UUID, branch_name TEXT) ON COMMIT DROP; + + FOREACH src IN ARRAY sources LOOP + EXECUTE format($q$ + INSERT INTO pg_temp.legacy_integrations + SELECT %1$L, i.id, i.project_id, i.access_token_enc, i.instance_url FROM %1$I.gitlab_integrations i + WHERE NOT EXISTS (SELECT 1 FROM pg_temp.legacy_integrations l WHERE l.project_id = i.project_id OR l.id = i.id)$q$, src); + EXECUTE format($q$ + INSERT INTO pg_temp.legacy_repositories + SELECT %1$L, r.id, r.project_id, r.integration_id, r.full_name, r.gitlab_project_id, r.webhook_id, r.default_branch + FROM %1$I.gitlab_repositories r JOIN pg_temp.legacy_integrations l ON l.src = %1$L AND l.id = r.integration_id$q$, src); + EXECUTE format($q$ + INSERT INTO pg_temp.legacy_merge_requests + SELECT %1$L, m.id, m.repo_id, m.pr_number, m.title, m.state, m.html_url, m.head_branch, m.base_branch, m.author, m.merged_at + FROM %1$I.gitlab_merge_requests m JOIN pg_temp.legacy_repositories r ON r.src = %1$L AND r.id = m.repo_id$q$, src); + EXECUTE format($q$ + INSERT INTO pg_temp.legacy_task_mr_links + SELECT l.task_id, l.merge_request_id + FROM %1$I.gitlab_task_mr_links l JOIN pg_temp.legacy_merge_requests m ON m.src = %1$L AND m.id = l.merge_request_id$q$, src); + EXECUTE format($q$ + INSERT INTO pg_temp.legacy_task_branches + SELECT b.task_id, b.repo_id, b.branch_name + FROM %1$I.gitlab_task_branches b JOIN pg_temp.legacy_repositories r ON r.src = %1$L AND r.id = b.repo_id$q$, src); + END LOOP; + + -- The ciphertexts are copied as they are: the community plugin encrypted + -- with its ENCRYPTION_KEY the same way, so this one needs the key it used. -- host is the host (and port) of the instance URL; the API is always https. INSERT INTO gitlab_identities (id, host, username, token_enc, created_by) SELECT i.id, COALESCE(NULLIF(lower(substring(i.instance_url FROM '^(?:[A-Za-z][A-Za-z0-9+.-]*://)?([^/?#]+)')), ''), 'gitlab.com'), - '', i.access_token_enc, 'com.gorlix.gitlab' - FROM plugin_data_com_gorlix_gitlab.gitlab_integrations i + '', i.access_token_enc, 'community-plugin' + FROM pg_temp.legacy_integrations i ON CONFLICT (id) DO NOTHING; INSERT INTO gitlab_legacy_webhooks (identity_id, gitlab_project_id, full_path, webhook_id) SELECT r.integration_id, r.gitlab_project_id, r.full_name, r.webhook_id - FROM plugin_data_com_gorlix_gitlab.gitlab_repositories r + FROM pg_temp.legacy_repositories r JOIN gitlab_identities g ON g.id = r.integration_id WHERE r.webhook_id > 0 ON CONFLICT DO NOTHING; @@ -62,7 +108,7 @@ BEGIN INSERT INTO public.git_identities (provider_id, host, provider_identity_id, kind, scope, project_id, handle, display_name) SELECT 'com.paca.gitlab', g.host, g.id::text, 'token', 'project', i.project_id, '', 'GitLab access token' - FROM plugin_data_com_gorlix_gitlab.gitlab_integrations i + FROM pg_temp.legacy_integrations i JOIN gitlab_identities g ON g.id = i.id WHERE NOT EXISTS ( SELECT 1 FROM public.git_identities c @@ -73,7 +119,7 @@ BEGIN INSERT INTO public.git_webhook_endpoints (key, provider_id, identity_id) SELECT replace(gen_random_uuid()::text || gen_random_uuid()::text, '-', ''), c.provider_id, c.id FROM public.git_identities c - JOIN plugin_data_com_gorlix_gitlab.gitlab_integrations i ON c.provider_identity_id = i.id::text + JOIN pg_temp.legacy_integrations i ON c.provider_identity_id = i.id::text WHERE c.provider_id = 'com.paca.gitlab' AND c.kind = 'token' AND c.scope = 'project' AND NOT EXISTS (SELECT 1 FROM public.git_webhook_endpoints e WHERE e.identity_id = c.id); @@ -82,7 +128,7 @@ BEGIN SELECT r.project_id, 'com.paca.gitlab', c.host, CASE WHEN r.gitlab_project_id > 0 THEN r.gitlab_project_id::text ELSE '' END, r.full_name, r.default_branch, 'https://' || c.host || '/' || r.full_name || '.git', c.id - FROM plugin_data_com_gorlix_gitlab.gitlab_repositories r + FROM pg_temp.legacy_repositories r JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text ON CONFLICT DO NOTHING; @@ -90,8 +136,8 @@ BEGIN SELECT cr.id, m.pr_number::text, m.title, CASE WHEN m.state IN ('open', 'closed', 'merged') THEN m.state ELSE 'open' END, m.head_branch, m.base_branch, m.author, m.html_url, m.merged_at - FROM plugin_data_com_gorlix_gitlab.gitlab_merge_requests m - JOIN plugin_data_com_gorlix_gitlab.gitlab_repositories r ON r.id = m.repo_id + FROM pg_temp.legacy_merge_requests m + JOIN pg_temp.legacy_repositories r ON r.id = m.repo_id JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text JOIN public.git_repositories cr ON cr.project_id = r.project_id AND cr.provider_id = 'com.paca.gitlab' AND cr.host = c.host AND lower(cr.full_path) = lower(r.full_name) @@ -99,9 +145,9 @@ BEGIN INSERT INTO public.git_task_links (task_id, change_request_id) SELECT l.task_id, cr.id - FROM plugin_data_com_gorlix_gitlab.gitlab_task_mr_links l - JOIN plugin_data_com_gorlix_gitlab.gitlab_merge_requests m ON m.id = l.merge_request_id - JOIN plugin_data_com_gorlix_gitlab.gitlab_repositories r ON r.id = m.repo_id + FROM pg_temp.legacy_task_mr_links l + JOIN pg_temp.legacy_merge_requests m ON m.id = l.merge_request_id + JOIN pg_temp.legacy_repositories r ON r.id = m.repo_id JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.gitlab' AND gr.host = c.host AND lower(gr.full_path) = lower(r.full_name) @@ -110,10 +156,17 @@ BEGIN INSERT INTO public.git_task_branches (task_id, repository_id, branch_name) SELECT b.task_id, gr.id, b.branch_name - FROM plugin_data_com_gorlix_gitlab.gitlab_task_branches b - JOIN plugin_data_com_gorlix_gitlab.gitlab_repositories r ON r.id = b.repo_id + FROM pg_temp.legacy_task_branches b + JOIN pg_temp.legacy_repositories r ON r.id = b.repo_id JOIN public.git_identities c ON c.provider_id = 'com.paca.gitlab' AND c.provider_identity_id = r.integration_id::text JOIN public.git_repositories gr ON gr.project_id = r.project_id AND gr.provider_id = 'com.paca.gitlab' AND gr.host = c.host AND lower(gr.full_path) = lower(r.full_name) ON CONFLICT DO NOTHING; END $$; + +-- 0.5.0 and 0.5.1's tables in this schema, now carried over. +DROP TABLE IF EXISTS gitlab_task_mr_links; +DROP TABLE IF EXISTS gitlab_task_branches; +DROP TABLE IF EXISTS gitlab_merge_requests; +DROP TABLE IF EXISTS gitlab_repositories; +DROP TABLE IF EXISTS gitlab_integrations; From 01b120bbc1b2be02c18f7fd3374b1fc42e7e4213 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Thu, 29 Oct 2026 11:34:45 +0100 Subject: [PATCH 10/15] docs: explain the upgrade from 0.5.x, with backup and rollback Rewrites "Upgrading from 0.5.x" for the migration: what is carried over and what is not, the order of the steps for 0.5.0/0.5.1 (same id) and 0.5.2 (com.gorlix.gitlab), what "Try again" does to the old webhooks, and how to back up the database first and roll back. The decryption error now names the instance's ENCRYPTION_KEY, which core hands every plugin. Co-Authored-By: Claude Opus 5.5 --- README.md | 60 +++++++++++++++++-- .../0004_migrate_community_plugin.sql | 4 +- backend/spi_migrated_test.go | 4 +- backend/spi_provider.go | 4 +- backend/spi_store.go | 2 +- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0f14771..5cd7911 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ The plugin has no HTTP routes of its own: core calls the internal-only `POST /_g ### Backend - `spi_*.go` implement the provider operations; the SDK conformance suite (`spi_conformance_test.go`) runs them against a fake GitLab (`spi_fakegitlab_test.go`), and `spi_test.go` / `spi_ops_test.go` cover the GitLab-specific behaviour. -- Owns the schema `plugin_data_com_paca_gitlab` (`gitlab_identities`, `gitlab_setup_sessions`). Access tokens and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value. +- Owns the schema `plugin_data_com_paca_gitlab` (`gitlab_identities`, `gitlab_setup_sessions`, and `gitlab_legacy_webhooks` for an upgrade from 0.5.x). Access tokens and webhook secrets are stored encrypted with AES-256-GCM using the `ENCRYPTION_KEY` config value. - Personal access tokens work, but the recommended identity is a **project or group access token**: GitLab creates a bot user for it, which can be mentioned and is limited to that project or group. ### Configuration @@ -111,11 +111,63 @@ Two limits come from core, not from this plugin: ## Upgrading from 0.5.x -Migration `0002_git_provider.sql` **drops** the legacy tables (they only exist if the plugin's schema held them: see the ID note below): `gitlab_integrations`, `gitlab_repositories`, `gitlab_merge_requests`, `gitlab_task_mr_links`, `gitlab_task_branches`. Nothing is migrated, because a project-bound token has no webhook secret and cannot back a provider identity on its own, and repositories, merge-request links and branch links now live in Paca core. Add the token again from a project's Git settings. +The community plugin (0.5.x) is migrated: migration `0004_migrate_community_plugin.sql` carries its data over to Paca core when the plugin is installed or upgraded. -The plugin ID changed from `com.gorlix.gitlab` to `com.paca.gitlab`, so this installs as a new plugin with its own schema `plugin_data_com_paca_gitlab`. The old plugin's data stays in `plugin_data_com_gorlix_gitlab` untouched (and is not read); export anything you still need from there, then remove the old plugin. Afterwards set up a GitLab identity (above) and relink the projects in Paca. +### What is carried over -The plugin's own screens, MCP tools, automation nodes (`gitlab.mr_linked`, `gitlab.merge_mr`, …), custom permission `gitlab.manage` and the `paca-gitlab-workflow` skill are gone: core's Git integration (neutral `change_request_*` / `review_*` tools, `git:*` permissions, Git triggers on agents) replaces them. Flows that used the old automation nodes need rebuilding on core's Git triggers. +| 0.5.x | After the upgrade | +|---|---| +| A project's access token | A connection of that project (*This project only*) in **Settings → Git → Connections**, with the same token. | +| Linked repositories | Linked repositories of the project, through that connection. A repository already linked by hand is kept as it is. | +| Merge requests and their links to tasks | Change requests in core, linked to the same tasks. | +| Branches linked to tasks | The same branch links. | + +What is not known at migration time is filled in the first time the connection is used: the token's user is asked of GitLab, a webhook secret is generated and the token's kind (personal, project, group) is read from GitLab. A token that GitLab no longer accepts (revoked, expired) reports *connect GitLab again*: add a new connection with a new token. + +Not carried over: the plugin's own screens, MCP tools, automation nodes (`gitlab.mr_linked`, `gitlab.merge_mr`, …), the `gitlab.manage` permission and the `paca-gitlab-workflow` skill. Core's Git integration replaces them (neutral `change_request_*` / `review_*` tools, `git:*` permissions, Git triggers on agents); flows that used the old automation nodes need rebuilding on core's Git triggers. + +The tokens are copied encrypted, as they are: they stay readable as long as the instance's `ENCRYPTION_KEY` is the one they were stored with. If the key changed, the connection says so; restore the key or add the token again. + +### Webhooks + +The webhooks 0.5.x registered on GitLab point at the old plugin, and they stop working with it. For each linked repository, open **Settings → Git → Repositories** and press *Try again*: Paca registers its new hook, and once that has worked it deletes the old plugin's hook from the GitLab project. Only a hook whose URL is still the old plugin's (`…/api/v1/plugins/com.gorlix.gitlab/projects/…/webhook`) is deleted; one that was pointed somewhere else is left alone. If the new hook cannot be registered, the old one stays. + +### Order of the steps + +0.5.0 and 0.5.1 already had the id `com.paca.gitlab`, 0.5.2 had `com.gorlix.gitlab`. Check which one you run in Paca's plugin list. + +1. **Back up the database** (below). The migration cannot be undone without it. +2. Install this plugin: + - from **0.5.0 or 0.5.1**: upgrade the plugin in place. The old tables in `plugin_data_com_paca_gitlab` are carried over and then dropped. + - from **0.5.2**: install `com.paca.gitlab` **before** removing `com.gorlix.gitlab`. The data is read from `plugin_data_com_gorlix_gitlab`, which is left untouched. +3. Check a project that used GitLab: its connection, its repositories, and the merge requests and branches on its tasks. +4. Press *Try again* on each repository (see Webhooks). +5. Only then remove `com.gorlix.gitlab` (0.5.2). Paca keeps its schema `plugin_data_com_gorlix_gitlab` after the plugin is removed; drop it by hand once you no longer need it. + +### Backup and rollback + +Take a backup right before the upgrade, and check that it can be read. With the bundled Postgres of `deploy/docker-compose.prod.yml`: + +```sh +docker compose -f deploy/docker-compose.prod.yml exec -T postgres \ + pg_dump -U paca -d paca -Fc > paca-before-gitlab-0.6.dump +docker run --rm -v "$PWD":/d:ro postgres:16 pg_restore -l /d/paca-before-gitlab-0.6.dump | head +``` + +With a managed database, run `pg_dump "$DATABASE_URL" -Fc` instead. The dump holds the encrypted tokens: keep it as private as the database. + +To roll back, stop the API, restore the dump and reinstall the plugin version you had: + +```sh +docker compose -f deploy/docker-compose.prod.yml stop api +docker compose -f deploy/docker-compose.prod.yml exec -T postgres dropdb -U paca --force paca +docker compose -f deploy/docker-compose.prod.yml exec -T postgres createdb -U paca paca +docker compose -f deploy/docker-compose.prod.yml exec -T postgres \ + pg_restore -U paca -d paca --no-owner < paca-before-gitlab-0.6.dump +docker compose -f deploy/docker-compose.prod.yml start api +``` + +Replace `paca` with your `POSTGRES_USER` and `POSTGRES_DB` if you changed them. A restore also brings back everything else as it was at the time of the dump, so roll back soon after the upgrade. Hooks that *Try again* deleted on GitLab are not restored with the database; register them again from the old plugin if you go back to it. --- diff --git a/backend/migrations/0004_migrate_community_plugin.sql b/backend/migrations/0004_migrate_community_plugin.sql index cfd3b23..2799a2b 100644 --- a/backend/migrations/0004_migrate_community_plugin.sql +++ b/backend/migrations/0004_migrate_community_plugin.sql @@ -85,8 +85,8 @@ BEGIN FROM %1$I.gitlab_task_branches b JOIN pg_temp.legacy_repositories r ON r.src = %1$L AND r.id = b.repo_id$q$, src); END LOOP; - -- The ciphertexts are copied as they are: the community plugin encrypted - -- with its ENCRYPTION_KEY the same way, so this one needs the key it used. + -- The ciphertexts are copied as they are: the same ENCRYPTION_KEY (the + -- instance's, which core hands every plugin) protects both. -- host is the host (and port) of the instance URL; the API is always https. INSERT INTO gitlab_identities (id, host, username, token_enc, created_by) SELECT i.id, diff --git a/backend/spi_migrated_test.go b/backend/spi_migrated_test.go index 8396830..a51cd50 100644 --- a/backend/spi_migrated_test.go +++ b/backend/spi_migrated_test.go @@ -148,8 +148,8 @@ func (undecryptableStore) loadIdentity(string) (identityRecord, error) { return identityRecord{}, errors.Join(errUndecryptable, errors.New("cipher: message authentication failed")) } -// A token stored under another ENCRYPTION_KEY (the old plugin's, say) gets an -// error naming the key, not a generic failure. +// A token stored under another ENCRYPTION_KEY gets an error naming the key, +// not a generic failure. func TestMigratedIdentity_WrongEncryptionKey(t *testing.T) { p := newGLProvider(undecryptableStore{newMemStore()}) _, err := p.loadIdentity("migrated-1") diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 954f9c3..dc3ffc1 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -98,8 +98,8 @@ func (p *glProvider) loadIdentity(identityID string) (identityRecord, error) { return identityRecord{}, notFoundIdentity() case errors.Is(err, errUndecryptable): return identityRecord{}, gp.Errorf(gp.CodeInternal, - "the stored GitLab token cannot be decrypted: the plugin's ENCRYPTION_KEY is not the one it was stored with "+ - "(after an upgrade from the 0.5.x plugin, set the key that plugin used), or connect GitLab again") + "the stored GitLab token cannot be decrypted: the instance's ENCRYPTION_KEY is not the one it was stored with; "+ + "restore that key, or connect GitLab again") } return identityRecord{}, gp.Errorf(gp.CodeInternal, "could not load the GitLab identity") } diff --git a/backend/spi_store.go b/backend/spi_store.go index a4b977f..30bb198 100644 --- a/backend/spi_store.go +++ b/backend/spi_store.go @@ -36,7 +36,7 @@ var errNoSuchIdentity = errors.New("gitlab identity not found") var errNoSuchSetup = errors.New("setup session not found") // errUndecryptable is a stored secret the instance key does not open: the key -// changed, or the row came from a plugin configured with another one. +// changed since the row was stored. var errUndecryptable = errors.New("gitlab identity secret cannot be decrypted") // identityRecord is one GitLab access-token identity. From f896fdde433f41ed6a6f618d46f04981cb41dbd8 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Thu, 29 Oct 2026 11:56:58 +0100 Subject: [PATCH 11/15] test: run the 0.5.x migration against a real Postgres scripts/test-legacy-migration runs this plugin's migrations the way core does (one transaction each, SET LOCAL search_path) on Postgres 16 with core's git tables from a Paca checkout, and checks the upgrade from 0.5.2, the in-place upgrade from 0.5.0/0.5.1, both together, a rerun, a fresh install and a plugin-only database, plus the store's own SQL for completing an identity and finding its legacy hooks. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 + scripts/test-legacy-migration/run.sh | 178 +++++++++++++++++++++++++ scripts/test-legacy-migration/seed.sql | 45 +++++++ 3 files changed, 229 insertions(+) create mode 100755 scripts/test-legacy-migration/run.sh create mode 100644 scripts/test-legacy-migration/seed.sql diff --git a/README.md b/README.md index 5cd7911..0cf9faf 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,12 @@ go vet ./... && go test -race ./... tinygo build -target=wasip1 -buildmode=c-shared -o backend.wasm . ``` +The migration from 0.5.x is tested against a real Postgres 16 (in docker) with core's Git tables, taken from a Paca checkout: + +```bash +PACA_CORE=/path/to/paca scripts/test-legacy-migration/run.sh +``` + To install into a local development Paca instance, use Paca's development script: ```bash diff --git a/scripts/test-legacy-migration/run.sh b/scripts/test-legacy-migration/run.sh new file mode 100755 index 0000000..446f2ea --- /dev/null +++ b/scripts/test-legacy-migration/run.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Tests backend/migrations/0004_migrate_community_plugin.sql against Postgres 16 +# with core's real git tables (000073 + 000076) and the community plugin's +# schema (0.5.x's 0001 is this plugin's 0001). Each migration runs the way core +# runs it: one transaction with SET LOCAL search_path TO ,public. +# +# Usage: PACA_CORE=/path/to/paca [PACA_CORE_REF=origin/master] scripts/test-legacy-migration/run.sh +# Needs docker. Starts and removes a throwaway container, paca-migtest-pg. +set -euo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +PLUGIN=$(cd "$HERE/../.." && pwd) +CORE=${PACA_CORE:?set PACA_CORE to a checkout of Paca-AI/paca} +CORE_REF=${PACA_CORE_REF:-HEAD} +PASS=0; FAIL=0 +NAME=paca-migtest-pg +PASS=0; FAIL=0 + +docker rm -f "$NAME" >/dev/null 2>&1 || true +docker run -d --name "$NAME" -e POSTGRES_PASSWORD=t -e POSTGRES_DB=paca postgres:16 >/dev/null +trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT +until docker exec "$NAME" pg_isready -U postgres -d paca >/dev/null 2>&1; do sleep 0.5; done +sleep 1 + +psql() { docker exec -i "$NAME" psql -U postgres -v ON_ERROR_STOP=1 -q -X "$@"; } +val() { psql -d "$1" -At -c "$2"; } +check() { # db description sql expected + local got; got=$(val "$1" "$3") + if [[ "$got" == "$4" ]]; then PASS=$((PASS+1)); echo " ok $2" + else FAIL=$((FAIL+1)); echo " FAIL $2: got [$got] want [$4]"; fi +} + +# migrate : core's runner, one transaction per file. +migrate() { + local db=$1 schema=$2; shift 2 + psql -d "$db" -c "CREATE SCHEMA IF NOT EXISTS $schema" + for f in "$@"; do + { echo "BEGIN; SET LOCAL search_path TO $schema,public;"; cat "$f"; echo "; COMMIT;"; } | psql -d "$db" + done +} + +core_schema() { # the parts of core the git tables reference, then the git tables + psql -d "$1" <<'SQL' +CREATE TABLE users (id UUID PRIMARY KEY DEFAULT gen_random_uuid()); +CREATE TABLE projects (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL); +CREATE TABLE agents (id UUID PRIMARY KEY DEFAULT gen_random_uuid()); +CREATE TABLE tasks (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, deleted_at TIMESTAMPTZ); +SQL + git -C "$CORE" show "$CORE_REF":services/api/migrations/000073_add_git_integration.sql | psql -d "$1" + git -C "$CORE" show "$CORE_REF":services/api/migrations/000076_add_git_repository_description.sql | psql -d "$1" +} + +M=$PLUGIN/backend/migrations +LEGACY=$M/0001_create_gitlab_tables.sql # the tables 0.5.x created +NEW=("$M/0001_create_gitlab_tables.sql" "$M/0002_git_provider.sql" "$M/0003_identity_token_reach.sql" "$M/0004_migrate_community_plugin.sql") + +S=plugin_data_com_paca_gitlab +full_checks() { # db + local D=$1 +check $D "one plugin identity per old integration" "SELECT count(*) FROM $S.gitlab_identities" 3 +check $D "ids kept, ciphertext copied unchanged" "SELECT string_agg(id || '=' || token_enc, ',' ORDER BY id) FROM $S.gitlab_identities" "30000000-0000-0000-0000-000000000001=aa01,30000000-0000-0000-0000-000000000002=aa02,30000000-0000-0000-0000-000000000003=aa03" +check $D "username, secret and reach left for first use" "SELECT count(*) FROM $S.gitlab_identities WHERE username = '' AND webhook_secret_enc = '' AND token_type = '' AND read_token_enc = ''" 3 +check $D "hosts from instance_url (case, port, path, empty)" "SELECT string_agg(host, ',' ORDER BY host) FROM $S.gitlab_identities" "gitlab.com,gitlab.com,gitlab.example.com:8443" +check $D "core identities: project-scoped tokens" "SELECT count(*) FROM git_identities WHERE provider_id = 'com.paca.gitlab' AND kind = 'token' AND scope = 'project'" 3 +check $D "core identity per old project" "SELECT string_agg(provider_identity_id || '@' || project_id, ',' ORDER BY provider_identity_id) FROM git_identities WHERE provider_id = 'com.paca.gitlab'" "30000000-0000-0000-0000-000000000001@10000000-0000-0000-0000-000000000001,30000000-0000-0000-0000-000000000002@10000000-0000-0000-0000-000000000002,30000000-0000-0000-0000-000000000003@10000000-0000-0000-0000-000000000003" +check $D "core identity host = plugin identity host" "SELECT count(*) FROM git_identities c JOIN $S.gitlab_identities g ON g.id::text = c.provider_identity_id AND g.host = c.host" 3 +check $D "GitHub identity untouched" "SELECT count(*) FROM git_identities WHERE provider_id = 'com.paca.github'" 1 +check $D "one webhook endpoint per migrated identity" "SELECT count(*) FROM git_webhook_endpoints e JOIN git_identities c ON c.id = e.identity_id WHERE c.provider_id = 'com.paca.gitlab'" 3 +check $D "endpoint keys are 64 hex chars" "SELECT count(*) FROM git_webhook_endpoints WHERE provider_id = 'com.paca.gitlab' AND key ~ '^[0-9a-f]{64}$'" 3 +check $D "repositories (one already linked by hand)" "SELECT count(*) FROM git_repositories WHERE provider_id = 'com.paca.gitlab'" 4 +check $D "the hand-linked one is not duplicated" "SELECT count(*) FROM git_repositories WHERE lower(full_path) = 'acme/web'" 1 +check $D "the hand-linked one keeps its identity" "SELECT identity_id IS NULL FROM git_repositories WHERE lower(full_path) = 'acme/web'" t +check $D "external_id = GitLab project id, '' when unknown" "SELECT string_agg(full_path || '=' || external_id, ',' ORDER BY full_path COLLATE ucs_basic) FROM git_repositories WHERE provider_id = 'com.paca.gitlab'" "Acme/Web=,acme/empty=12,group/sub/infra=,ops/tools=33" +check $D "clone URLs on the identity's host" "SELECT string_agg(clone_url, ',' ORDER BY full_path COLLATE ucs_basic) FROM git_repositories WHERE provider_id = 'com.paca.gitlab' AND identity_id IS NOT NULL" "https://gitlab.com/acme/empty.git,https://gitlab.example.com:8443/group/sub/infra.git,https://gitlab.com/ops/tools.git" +check $D "default branch kept" "SELECT default_branch FROM git_repositories WHERE full_path = 'ops/tools'" develop +check $D "merge requests" "SELECT count(*) FROM git_change_requests" 5 +check $D "states (unknown → open)" "SELECT string_agg(number || ':' || state, ',' ORDER BY number::int) FROM git_change_requests" "1:open,2:merged,3:closed,4:open,7:merged" +check $D "merged_at kept" "SELECT count(*) FROM git_change_requests WHERE merged_at IS NOT NULL" 2 +check $D "MRs of the hand-linked repo attach to it" "SELECT count(*) FROM git_change_requests cr JOIN git_repositories r ON r.id = cr.repository_id WHERE r.full_path = 'Acme/Web'" 2 +check $D "MR fields kept" "SELECT title || '|' || head_ref || '|' || base_ref || '|' || author || '|' || html_url FROM git_change_requests WHERE number = '2'" "Add login|feat/login|main|alice|https://gitlab.com/acme/web/-/merge_requests/2" +check $D "task ↔ MR links (soft-deleted task included)" "SELECT count(*) FROM git_task_links" 4 +check $D "task branches" "SELECT count(*) FROM git_task_branches" 3 +check $D "legacy hooks recorded per identity" "SELECT string_agg(webhook_id || '@' || full_path || '#' || gitlab_project_id, ',' ORDER BY webhook_id) FROM $S.gitlab_legacy_webhooks" "101@acme/web#11,103@group/sub/infra#0,104@ops/tools#33" +check $D "this schema's old tables dropped" "SELECT count(*) FROM information_schema.tables WHERE table_schema = '$S' AND table_name IN ('gitlab_integrations','gitlab_repositories')" 0 + +} + +echo "== upgrade from 0.5.2 (com.gorlix.gitlab, its own schema)" +psql -d paca -c "CREATE DATABASE from_052" +core_schema from_052 +migrate from_052 plugin_data_com_gorlix_gitlab "$LEGACY" +psql -d from_052 -v legacy=plugin_data_com_gorlix_gitlab -f - < "$HERE/seed.sql" +migrate from_052 $S "${NEW[@]}" +full_checks from_052 +check from_052 "0.5.2's schema left as it was" "SELECT (SELECT count(*) FROM plugin_data_com_gorlix_gitlab.gitlab_integrations) || '/' || (SELECT count(*) FROM plugin_data_com_gorlix_gitlab.gitlab_merge_requests)" "3/5" + +echo "== upgrade in place from 0.5.0/0.5.1 (com.paca.gitlab, this schema)" +psql -d paca -c "CREATE DATABASE from_050" +core_schema from_050 +migrate from_050 $S "${NEW[0]}" +psql -d from_050 -v legacy=$S -f - < "$HERE/seed.sql" +migrate from_050 $S "${NEW[@]:1}" +full_checks from_050 + +D=from_052 +echo "== running 0004 again creates nothing new" +before=$(val $D "SELECT (SELECT count(*) FROM $S.gitlab_identities),(SELECT count(*) FROM git_identities),(SELECT count(*) FROM git_webhook_endpoints),(SELECT count(*) FROM git_repositories),(SELECT count(*) FROM git_change_requests),(SELECT count(*) FROM git_task_links),(SELECT count(*) FROM git_task_branches)") +migrate $D $S "$M/0004_migrate_community_plugin.sql" +check $D "same row counts" "SELECT (SELECT count(*) FROM $S.gitlab_identities),(SELECT count(*) FROM git_identities),(SELECT count(*) FROM git_webhook_endpoints),(SELECT count(*) FROM git_repositories),(SELECT count(*) FROM git_change_requests),(SELECT count(*) FROM git_task_links),(SELECT count(*) FROM git_task_branches)" "$before" + +echo "== completeIdentity's statement (spi_dbstore.go) against the real table" +ID=$(val $D "SELECT id FROM $S.gitlab_identities ORDER BY id LIMIT 1") +UPD=$(sed -n '/^\s*UPDATE gitlab_identities SET$/,/WHERE id = \$1/p' "$PLUGIN/backend/spi_dbstore.go" | sed 's/`,$//') +complete() { # secret expires + psql -d $D < Date: Thu, 29 Oct 2026 12:48:45 +0100 Subject: [PATCH 12/15] feat(spi): describe identities so core learns a migrated handle Implement gp.IdentityDescriber and declare identity_description. Describing an identity loads it, which completes one carried over from the community plugin (asks GitLab once, stores the user and webhook secret); a normal identity is answered from the stored record with no API call. Unknown identities report not_found, tokens GitLab rejects report forbidden. Needs the SDK's identity_description support (see the following commit). Refs Paca-AI/paca#661 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ backend/spi_assert.go | 1 + backend/spi_migrated_test.go | 67 ++++++++++++++++++++++++++++++++++++ backend/spi_provider.go | 25 ++++++++------ backend/spi_setup.go | 13 +++++++ 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0cf9faf..ee3ae35 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ The community plugin (0.5.x) is migrated: migration `0004_migrate_community_plug What is not known at migration time is filled in the first time the connection is used: the token's user is asked of GitLab, a webhook secret is generated and the token's kind (personal, project, group) is read from GitLab. A token that GitLab no longer accepts (revoked, expired) reports *connect GitLab again*: add a new connection with a new token. +The connection's name (the token's GitLab user) does not wait for that first use: the first time Paca lists the connections it asks the plugin to describe each one (`identity_description`), which completes a carried-over connection and tells core its handle. This needs a Paca core that supports `identity_description`; with an older core the name appears at the first use instead. + Not carried over: the plugin's own screens, MCP tools, automation nodes (`gitlab.mr_linked`, `gitlab.merge_mr`, …), the `gitlab.manage` permission and the `paca-gitlab-workflow` skill. Core's Git integration replaces them (neutral `change_request_*` / `review_*` tools, `git:*` permissions, Git triggers on agents); flows that used the old automation nodes need rebuilding on core's Git triggers. The tokens are copied encrypted, as they are: they stay readable as long as the instance's `ENCRYPTION_KEY` is the one they were stored with. If the key changed, the connection says so; restore the key or add the token again. diff --git a/backend/spi_assert.go b/backend/spi_assert.go index 1c0de0d..bd4a1f9 100644 --- a/backend/spi_assert.go +++ b/backend/spi_assert.go @@ -18,4 +18,5 @@ var ( _ gp.WebhookManager = (*glProvider)(nil) _ gp.IdentityDeleter = (*glProvider)(nil) _ gp.BranchManager = (*glProvider)(nil) + _ gp.IdentityDescriber = (*glProvider)(nil) ) diff --git a/backend/spi_migrated_test.go b/backend/spi_migrated_test.go index a51cd50..37ac39e 100644 --- a/backend/spi_migrated_test.go +++ b/backend/spi_migrated_test.go @@ -286,3 +286,70 @@ func TestIsLegacyHookURL(t *testing.T) { } } } + +// DescribeIdentity gives core the handle of a migrated identity: GitLab is +// asked once, the answer is stored, and the next call is answered from it. +func TestDescribeIdentity_MigratedFillsAndPersists(t *testing.T) { + g := newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(st) + ref := migratedIdentity(st, "migrated-1", fakeProjectToken) + + got, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: ref}) + if err != nil { + t.Fatal(err) + } + if got.Ref.ID != "migrated-1" || got.Ref.Handle != "project_4_bot_ab12" { + t.Errorf("ref %+v", got.Ref) + } + if got.Kind != gp.IdentityBotUser || got.Host != fakeHost || got.DisplayName != "Project token · acme/ui" || got.ExpiresAt.IsZero() { + t.Errorf("identity %+v", got) + } + if rec := st.identities["migrated-1"]; rec.Username != got.Ref.Handle || rec.WebhookSecret == "" { + t.Errorf("not persisted: %+v", rec) + } + + again, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: gp.IdentityRef{ID: "migrated-1"}}) + if err != nil || again.Ref.Handle != got.Ref.Handle || again.DisplayName != got.DisplayName { + t.Errorf("second call: %+v %v", again, err) + } + if n := g.count("GET /user"); n != 1 { + t.Errorf("GET /user called %d times, want once", n) + } +} + +// A normal identity is described from the stored record, without asking GitLab, +// exactly as setup returned it. +func TestDescribeIdentity_NormalUsesTheStoredRecord(t *testing.T) { + p, g, st := seededProvider(t) + got, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: gp.IdentityRef{ID: fakeIdentityID}}) + if err != nil { + t.Fatal(err) + } + if want := identityOf(st.identities[fakeIdentityID]); got.Ref.Handle != fakeUsername || got.DisplayName != want.DisplayName || got.Kind != gp.IdentityBotUser { + t.Errorf("got %+v, want %+v", got, want) + } + if len(g.calls) != 0 { + t.Errorf("GitLab was called: %v", g.calls) + } +} + +func TestDescribeIdentity_Unknown(t *testing.T) { + p, _, _ := seededProvider(t) + _, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: gp.IdentityRef{ID: "nope"}}) + code(t, err, gp.CodeNotFound) + _, err = p.DescribeIdentity(bg, gp.DescribeIdentityRequest{}) + code(t, err, gp.CodeNotFound) +} + +func TestDescribeIdentity_RevokedToken(t *testing.T) { + newFakeGitLab(t) + st := newMemStore() + p := newGLProvider(st) + ref := migratedIdentity(st, "migrated-1", "glpat-revoked") + _, err := p.DescribeIdentity(bg, gp.DescribeIdentityRequest{Identity: ref}) + code(t, err, gp.CodeForbidden) + if rec := st.identities["migrated-1"]; rec.Username != "" { + t.Errorf("a failed description stored %+v", rec) + } +} diff --git a/backend/spi_provider.go b/backend/spi_provider.go index dc3ffc1..3ed0efa 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -38,6 +38,8 @@ func newGLProvider(store spiStore) *glProvider { // - there is one way to connect (a pasted access token; whether it makes a // bot user depends on the token), so Kinds is left empty and the UI offers // no choice of kind; +// - identity_description is implemented: identities carried over from the +// community plugin have no handle until first use, and core asks for it; // - review is emulated (one discussion per inline comment, then a summary); // - reactions and access_grants are not implemented: a reaction names only a // comment id, which GitLab cannot address without its merge request, and a @@ -59,17 +61,18 @@ func (p *glProvider) Describe() gp.Info { Triggers: []gp.TriggerKind{gp.TriggerMention, gp.TriggerLabel, gp.TriggerReviewRequest, gp.TriggerAssignment, gp.TriggerCIFailure, gp.TriggerOwnCRFeedback, gp.TriggerAutoReview}, MultipleIdentities: true, - ResolveThreads: true, - FileRead: true, - CodeSearch: true, - Labels: true, - ReviewerRequests: true, - Assignees: true, - Merge: true, - RerunChecks: true, - WebhookManagement: true, - IdentityDeletion: true, - Branches: true, + ResolveThreads: true, + FileRead: true, + CodeSearch: true, + Labels: true, + ReviewerRequests: true, + Assignees: true, + Merge: true, + RerunChecks: true, + WebhookManagement: true, + IdentityDeletion: true, + IdentityDescription: true, + Branches: true, BatchReview: false, HiddenMarkers: true, diff --git a/backend/spi_setup.go b/backend/spi_setup.go index 31e7d7c..9e785a3 100644 --- a/backend/spi_setup.go +++ b/backend/spi_setup.go @@ -305,6 +305,19 @@ func identityOf(rec identityRecord) gp.Identity { } } +// DescribeIdentity implements gp.IdentityDescriber: the identity as +// CompleteIdentitySetup returned it. An identity carried over from the community +// plugin has no user yet; loading it asks GitLab once and stores the answer +// (see completeMigratedIdentity), so core learns the handle and every later +// call answers from the stored record. A token GitLab rejects reports forbidden. +func (p *glProvider) DescribeIdentity(_ context.Context, req gp.DescribeIdentityRequest) (gp.Identity, error) { + rec, err := p.loadIdentity(req.Identity.ID) + if err != nil { + return gp.Identity{}, err + } + return identityOf(rec), nil +} + // DeleteIdentity implements gp.IdentityDeleter: forget the tokens locally. The // access token itself stays valid on GitLab (revoking it needs a higher scope // than the token has); the operator revokes it there. From a3ddc469a3bf0ee9984f1569db22d7262168ede6 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Mon, 2 Nov 2026 10:26:07 +0100 Subject: [PATCH 13/15] docs: say what happens when a connection cannot be named Refs Paca-AI/paca#661 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee3ae35..a738355 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The community plugin (0.5.x) is migrated: migration `0004_migrate_community_plug What is not known at migration time is filled in the first time the connection is used: the token's user is asked of GitLab, a webhook secret is generated and the token's kind (personal, project, group) is read from GitLab. A token that GitLab no longer accepts (revoked, expired) reports *connect GitLab again*: add a new connection with a new token. -The connection's name (the token's GitLab user) does not wait for that first use: the first time Paca lists the connections it asks the plugin to describe each one (`identity_description`), which completes a carried-over connection and tells core its handle. This needs a Paca core that supports `identity_description`; with an older core the name appears at the first use instead. +The connection's name (the token's GitLab user) does not wait for that first use: the first time Paca lists the connections it asks the plugin to describe each one (`identity_description`), which completes a carried-over connection and tells core its handle. This needs a Paca core that supports `identity_description`; with an older core the name appears at the first use instead. If GitLab rejects the token or cannot be reached, the connection keeps the generic name *GitLab access token* with no `@handle`, nothing else changes, and Paca asks again 15 minutes later (or after a restart). A rejected token stays nameless until you connect GitLab again with a new one. Not carried over: the plugin's own screens, MCP tools, automation nodes (`gitlab.mr_linked`, `gitlab.merge_mr`, …), the `gitlab.manage` permission and the `paca-gitlab-workflow` skill. Core's Git integration replaces them (neutral `change_request_*` / `review_*` tools, `git:*` permissions, Git triggers on agents); flows that used the old automation nodes need rebuilding on core's Git triggers. From 141f020b84906867df2a337e6030cae0425c602e Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Tue, 3 Nov 2026 11:50:26 +0100 Subject: [PATCH 14/15] build: require plugin-sdk-go v0.4.2 v0.4.2 ships the identity_description capability and DescribeIdentity. Co-Authored-By: Claude Sonnet 5.5 --- backend/go.mod | 2 +- backend/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 5447ca9..b81edf7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/gitlab go 1.24 -require github.com/Paca-AI/plugin-sdk-go v0.4.1 +require github.com/Paca-AI/plugin-sdk-go v0.4.2 diff --git a/backend/go.sum b/backend/go.sum index 9925b1c..481df2d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,2 @@ -github.com/Paca-AI/plugin-sdk-go v0.4.1 h1:Fid4T5OryhJca+9dBtM09iTipli9KouY/3G5ju2Sz5Y= -github.com/Paca-AI/plugin-sdk-go v0.4.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= +github.com/Paca-AI/plugin-sdk-go v0.4.2 h1:J9Jz7HorscHip2pRHNPepKjtYfKkSosJ2TmGtWWJyC8= +github.com/Paca-AI/plugin-sdk-go v0.4.2/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw= From 813dfc17eb070345620e45b24e3d4dd3d97bfb1a Mon Sep 17 00:00:00 2001 From: pikann22 Date: Tue, 3 Nov 2026 15:18:41 +0000 Subject: [PATCH 15/15] feat: implement issue listing functionality and related tests --- backend/spi_assert.go | 1 + backend/spi_fakegitlab_test.go | 6 +++ backend/spi_issues_test.go | 91 ++++++++++++++++++++++++++++++++++ backend/spi_provider.go | 1 + backend/spi_reads.go | 69 ++++++++++++++++++++------ 5 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 backend/spi_issues_test.go diff --git a/backend/spi_assert.go b/backend/spi_assert.go index bd4a1f9..25d026a 100644 --- a/backend/spi_assert.go +++ b/backend/spi_assert.go @@ -19,4 +19,5 @@ var ( _ gp.IdentityDeleter = (*glProvider)(nil) _ gp.BranchManager = (*glProvider)(nil) _ gp.IdentityDescriber = (*glProvider)(nil) + _ gp.IssueLister = (*glProvider)(nil) ) diff --git a/backend/spi_fakegitlab_test.go b/backend/spi_fakegitlab_test.go index 0612460..27bf2d7 100644 --- a/backend/spi_fakegitlab_test.go +++ b/backend/spi_fakegitlab_test.go @@ -578,6 +578,12 @@ func (g *fakeGitLab) noteAPI(kind string, iid int, method string, q url.Values, } func (g *fakeGitLab) issueAPI(method string, rest []string, payload map[string]any) (*plugin.FetchResponse, error) { + if len(rest) == 0 && method == "GET" { + // The listing: the one issue the fake knows. + return resp(200, []map[string]any{{"iid": 7, "title": "Bug", "description": "It breaks", "state": "opened", + "web_url": "https://gitlab.com/acme/web/-/issues/7", "author": map[string]any{"username": "reader"}, + "labels": []string{"bug"}, "assignees": []map[string]any{{"username": "bob"}}}}) + } if len(rest) == 0 { return notFound() } diff --git a/backend/spi_issues_test.go b/backend/spi_issues_test.go new file mode 100644 index 0000000..ec13bbd --- /dev/null +++ b/backend/spi_issues_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "net/url" + "strconv" + "strings" + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +func glIssueRow(n int, state string) map[string]any { + return map[string]any{"iid": n, "title": "Issue " + strconv.Itoa(n), "description": "Body", "state": state, + "web_url": "https://gitlab.com/acme/web/-/issues/" + strconv.Itoa(n), "author": map[string]any{"username": "reader"}, + "labels": []string{"bug"}, "assignees": []map[string]any{{"username": "bob"}}} +} + +func TestListIssuesPagesAndMapsStates(t *testing.T) { + p, g, _ := seededProvider(t) + var seen []url.Values + fetch = func(method, rawURL string, headers map[string]string, body string) (*plugin.FetchResponse, error) { + u, _ := url.Parse(rawURL) + if !strings.HasSuffix(u.EscapedPath(), "/issues") { + return g.fetch(method, rawURL, headers, body) + } + if method != "GET" { + t.Fatalf("unexpected request: %s %s", method, rawURL) + } + seen = append(seen, u.Query()) + switch u.Query().Get("page") { + case "1": // a full page: there may be more + rows := make([]map[string]any, listPerPage) + for i := range rows { + rows[i] = glIssueRow(i+1, "closed") + } + rows[0] = glIssueRow(500, "opened") + return resp(200, rows) + case "2": + return resp(200, []map[string]any{glIssueRow(501, "opened")}) + } + t.Fatalf("unexpected page: %s", rawURL) + return nil, nil + } + + first, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: fixtureTarget, State: "closed"}) + if err != nil || len(first.Items) != listPerPage || first.NextCursor != "2" { + t.Fatalf("first page: %d items, cursor %q, %v", len(first.Items), first.NextCursor, err) + } + if q := seen[0]; q.Get("state") != "closed" || q.Get("per_page") != "100" { + t.Fatalf("query = %v", q) + } + if first.Items[0].State != "open" || first.Items[1].State != "closed" { + t.Fatalf("states: %+v %+v", first.Items[0], first.Items[1]) + } + second, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: fixtureTarget, Cursor: first.NextCursor}) + if err != nil || len(second.Items) != 1 || second.NextCursor != "" { + t.Fatalf("last page: %+v %v", second, err) + } + i := second.Items[0] + if i.Number != "501" || i.Title != "Issue 501" || i.Body != "Body" || i.Author != "reader" || i.URL != "https://gitlab.com/acme/web/-/issues/501" || + len(i.Labels) != 1 || len(i.Assignees) != 1 || i.Assignees[0] != "bob" { + t.Fatalf("issue fields: %+v", i) + } + // The default state is open, which GitLab calls "opened". + if q := seen[1]; q.Get("state") != "opened" || q.Get("page") != "2" { + t.Fatalf("default query = %v", q) + } +} + +func TestListIssuesValidatesAndPropagatesErrors(t *testing.T) { + p, g, _ := seededProvider(t) + for _, req := range []gp.ListIssuesRequest{ + {Target: fixtureTarget, State: "merged"}, + {Target: fixtureTarget, Cursor: "0"}, + {Target: fixtureTarget, Cursor: "x"}, + } { + _, err := p.ListIssues(bg, req) + code(t, err, gp.CodeValidation) + } + g.forceError["GET /projects/1/issues"] = fakeResponse{403, `{"message":"403 Forbidden"}`, nil} + _, err := p.ListIssues(bg, gp.ListIssuesRequest{Target: fixtureTarget}) + code(t, err, gp.CodeForbidden) +} + +func TestIssueListIsDeclared(t *testing.T) { + p, _, _ := seededProvider(t) + if !p.Describe().Capabilities.IssueList { + t.Fatal("issue listing must be declared") + } +} diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 3ed0efa..e26533f 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -73,6 +73,7 @@ func (p *glProvider) Describe() gp.Info { IdentityDeletion: true, IdentityDescription: true, Branches: true, + IssueList: true, BatchReview: false, HiddenMarkers: true, diff --git a/backend/spi_reads.go b/backend/spi_reads.go index 547cae0..2f59187 100644 --- a/backend/spi_reads.go +++ b/backend/spi_reads.go @@ -460,6 +460,29 @@ func (p *glProvider) ListComments(_ context.Context, req gp.ListCommentsRequest) return out, nil } +type glIssue struct { + IID int `json:"iid"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` // opened | closed + WebURL string `json:"web_url"` + Author glRef `json:"author"` + Labels []string `json:"labels"` + Assignees []glRef `json:"assignees"` +} + +func (is glIssue) toIssue() gp.Issue { + state := "open" + if is.State == "closed" { + state = "closed" + } + out := gp.Issue{Number: strconv.Itoa(is.IID), Title: is.Title, Body: is.Description, State: state, Author: is.Author.Username, URL: is.WebURL, Labels: is.Labels} + for _, a := range is.Assignees { + out.Assignees = append(out.Assignees, a.Username) + } + return out +} + // GetIssue implements gp.Provider. func (p *glProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, error) { num, err := number(req.Number) @@ -470,26 +493,40 @@ func (p *glProvider) GetIssue(_ context.Context, req gp.IssueRequest) (gp.Issue, if err != nil { return gp.Issue{}, err } - var is struct { - IID int `json:"iid"` - Title string `json:"title"` - Description string `json:"description"` - State string `json:"state"` - WebURL string `json:"web_url"` - Author glRef `json:"author"` - Labels []string `json:"labels"` - Assignees []glRef `json:"assignees"` - } + var is glIssue if err := api.get(proj+"/issues/"+num, &is); err != nil { return gp.Issue{}, err } - state := "open" - if is.State == "closed" { - state = "closed" + return is.toIssue(), nil +} + +// ListIssues implements gp.IssueLister. GitLab's issues endpoint returns issues +// only (merge requests have their own), newest first, so a page needs no +// filtering and the cursor follows the raw page size. +func (p *glProvider) ListIssues(_ context.Context, req gp.ListIssuesRequest) (gp.IssuePage, error) { + state := "opened" + switch req.State { + case "", "open": + case "closed", "all": + state = req.State + default: + return gp.IssuePage{}, gp.Errorf(gp.CodeValidation, "invalid issue state") } - out := gp.Issue{Number: strconv.Itoa(is.IID), Title: is.Title, Body: is.Description, State: state, Author: is.Author.Username, URL: is.WebURL, Labels: is.Labels} - for _, a := range is.Assignees { - out.Assignees = append(out.Assignees, a.Username) + pg, err := page(req.Cursor) + if err != nil { + return gp.IssuePage{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.IssuePage{}, err + } + var issues []glIssue + if err := api.get(paged(proj+"/issues?scope=all&order_by=created_at&sort=desc&state="+state, pg), &issues); err != nil { + return gp.IssuePage{}, err + } + out := gp.IssuePage{Items: make([]gp.Issue, 0, len(issues)), NextCursor: nextPage(len(issues), pg)} + for _, is := range issues { + out.Items = append(out.Items, is.toIssue()) } return out, nil }