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..a738355 100644 --- a/README.md +++ b/README.md @@ -1,452 +1,200 @@ -# 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 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. > [!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/ - Go WASM plugin (runs inside the API host) ``` -### 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/...`. +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`. -### 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. - ---- +### Backend -## Required Configuration +- `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`, 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. -The backend uses the following host configuration keys: +### Configuration -- `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. +| Key | Purpose | +|---|---| +| `ENCRYPTION_KEY` | 64 hex characters (a 32-byte key) used to encrypt tokens and webhook secrets. | -Without `PUBLIC_URL`, linking repositories will fail because GitLab webhook creation requires a reachable callback endpoint. +`PUBLIC_URL` is no longer needed by the plugin: core gives the webhook URL to the setup flow. --- -## 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" -} -``` +## Setting it up -**Link MR to task:** -```json -{ - "repo_id": "550e8400-e29b-41d4-a716-446655440000", - "pr_number": 42 -} -``` +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*. -**Create branch for task:** -```json -{ - "repo_id": "550e8400-e29b-41d4-a716-446655440000", - "branch_name": "feat/PROJ-42-gitlab-integration", - "source_branch": "main" -} -``` +An agent can have its own identity (its own bot user) from the agent's Git tab, set up the same way. -**Post comment to MR:** -```json -{ - "body": "Automated review feedback from Paca." -} -``` +### Self-managed GitLab -**Submit review / approval:** -```json -{ - "event": "APPROVE", - "body": "Looks good to me!" -} -``` -*(Review events supported: `"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`)* +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. -## Webhook Behavior +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. -On linked repositories, GitLab project webhooks are configured for: +Two limits come from core, not from this plugin: -- `push_events` -- `merge_requests_events` -- `pipeline_events` +- 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. -Current behavior includes: +**GitLab → Paca (webhooks).** The webhook URL is built from Paca's `PUBLIC_URL`, so it must be reachable from the GitLab instance. -- **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` +- 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. --- -## Database Schema - -Tables live in `plugin_data_com_gorlix_gitlab` and are created by `backend/migrations/0001_create_gitlab_tables.sql`: +## Behaviour -```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) -``` +| 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. --- -## Differences Compared to the GitHub Plugin +## Upgrading from 0.5.x -| 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** | +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. ---- +### What is carried over -## Production Installation +| 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. | -### Method 1: Marketplace UI (Recommended once listed) +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. -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**. +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. -### Method 2: Self-Hosted Release Install +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. -For self-hosted Docker Compose deployments installing directly from GitHub Releases: +### Webhooks -#### 1. Host Prerequisites +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. -- 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`). +### Order of the steps -The production Docker Compose stack (`name: paca`) uses the following volumes: +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. -| 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`) | +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. -#### 2. Automated Install via Script +### Backup and rollback -```bash -export PACA_URL='https://paca.example.com' -export PACA_API_KEY='your-admin-api-key' +Take a backup right before the upgrade, and check that it can be read. With the bundled Postgres of `deploy/docker-compose.prod.yml`: -./scripts/install-from-release.sh \ - --api-url "$PACA_URL" \ - --api-key "$PACA_API_KEY" \ - --version v0.5.2 +```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 ``` -The script verifies SHA256 checksums, populates the `paca_*_plugins` volumes, and registers/updates the plugin via `POST/PATCH /api/v1/admin/plugins`. +With a managed database, run `pg_dump "$DATABASE_URL" -Fc` instead. The dump holds the encrypted tokens: keep it as private as the database. -#### 3. Manual Step-by-Step Install +To roll back, stop the API, restore the dump and reinstall the plugin version you had: -```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" +```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 ``` -If UI assets do not render immediately, run `docker compose restart api gateway`. +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. --- -## 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 +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 -cd frontend - -# Install dependencies -bun install - -# Typecheck & build -bun run typecheck -bun run build +PACA_CORE=/path/to/paca scripts/test-legacy-migration/run.sh ``` -### MCP +To install into a local development Paca instance, use Paca's development script: -```bash -cd mcp - -# Install dependencies -bun install - -# Typecheck & build -bun run typecheck -bun run build -``` - -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 +202,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..c8b2632 --- /dev/null +++ b/backend/gitlab.go @@ -0,0 +1,101 @@ +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "regexp" + "strconv" + "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]+)?$`) + +// 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) } + +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 } + +// 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 +} + +// 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 + } + // 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 + } + 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..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.3.3 +require github.com/Paca-AI/plugin-sdk-go v0.4.2 diff --git a/backend/go.sum b/backend/go.sum index 970bd41..481df2d 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.2 h1:J9Jz7HorscHip2pRHNPepKjtYfKkSosJ2TmGtWWJyC8= +github.com/Paca-AI/plugin-sdk-go v0.4.2/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..dd7785b --- /dev/null +++ b/backend/migrations/0002_git_provider.sql @@ -0,0 +1,41 @@ +-- 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. +-- 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 previous +-- 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/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/migrations/0004_migrate_community_plugin.sql b/backend/migrations/0004_migrate_community_plugin.sql new file mode 100644 index 0000000..2799a2b --- /dev/null +++ b/backend/migrations/0004_migrate_community_plugin.sql @@ -0,0 +1,172 @@ +-- 0004_migrate_community_plugin.sql +-- 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 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 +-- 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 $$ +DECLARE + sources TEXT[] := '{}'; + src TEXT; +BEGIN + 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; + + -- 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 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, + COALESCE(NULLIF(lower(substring(i.instance_url FROM '^(?:[A-Za-z][A-Za-z0-9+.-]*://)?([^/?#]+)')), ''), 'gitlab.com'), + '', 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 pg_temp.legacy_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 pg_temp.legacy_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 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); + + -- 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 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; + + 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 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) + ON CONFLICT DO NOTHING; + + INSERT INTO public.git_task_links (task_id, change_request_id) + SELECT l.task_id, cr.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) + 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 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; diff --git a/backend/plugin.go b/backend/plugin.go index 3a57f67..e817535 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,16 @@ 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) - + prov := newGLProvider(newDBStore(p)) + prov.warn = p.log.Warn + serve.Register(ctx, prov) 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..25d026a --- /dev/null +++ b/backend/spi_assert.go @@ -0,0 +1,23 @@ +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) + _ gp.IdentityDescriber = (*glProvider)(nil) + _ gp.IssueLister = (*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..a4cda04 --- /dev/null +++ b/backend/spi_conformance_test.go @@ -0,0 +1,380 @@ +package main + +import ( + "encoding/json" + "strconv" + "strings" + "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 + // 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{}, legacyHooks: map[string][]legacyWebhook{}} +} + +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) 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) 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() + 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-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"}}}, + {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..200b4f4 --- /dev/null +++ b/backend/spi_dbstore.go @@ -0,0 +1,196 @@ +package main + +import ( + "fmt" + "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, token_type, resource_id, resource_path 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 + } + 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 { + 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, + 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 { + 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, 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 + } + 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) 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) 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) + 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..27bf2d7 --- /dev/null +++ b/backend/spi_fakegitlab_test.go @@ -0,0 +1,677 @@ +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" + + // 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} + +// 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}, +} + +// 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{ + 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 _, 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 +} + +func projectJSON(p fakeProject) map[string]any { + vis := "private" + 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": access, "group_access": nil}, + } +} + +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"] + _, 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 + 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] + 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 { + 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{"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 { + 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 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() +} + +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 && 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() + } + 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() + } + 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, "") + } + 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..c146e66 --- /dev/null +++ b/backend/spi_hooks.go @@ -0,0 +1,100 @@ +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") + } + 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 == hookURL { + 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_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_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..37ac39e --- /dev/null +++ b/backend/spi_migrated_test.go @@ -0,0 +1,355 @@ +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 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) + } + } +} + +// 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_ops_test.go b/backend/spi_ops_test.go new file mode 100644 index 0000000..fe49ef1 --- /dev/null +++ b/backend/spi_ops_test.go @@ -0,0 +1,404 @@ +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, _ := 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) +} + +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..e26533f --- /dev/null +++ b/backend/spi_provider.go @@ -0,0 +1,564 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "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 + // 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, warn: func(string) {}} +} + +// 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: 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; +// - 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 +// 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, + IdentityDescription: true, + Branches: true, + IssueList: 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 { + 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 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") + } + 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. + rec.Reach = classifyToken(rec.Username, rec.Bot) + if lookupReach(rec.api(), &rec.Reach) { + _ = p.store.saveReach(rec.ID, rec.Reach) + } + } + 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 +// 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 +} + +// 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 +// 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 { + 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 + 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 { + 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 + } + 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 { + 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: 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 + } + 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 < 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_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) + } +} diff --git a/backend/spi_reads.go b/backend/spi_reads.go new file mode 100644 index 0000000..2f59187 --- /dev/null +++ b/backend/spi_reads.go @@ -0,0 +1,740 @@ +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 +} + +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) + if err != nil { + return gp.Issue{}, err + } + api, proj, err := p.apiFor(req.Target) + if err != nil { + return gp.Issue{}, err + } + var is glIssue + if err := api.get(proj+"/issues/"+num, &is); err != nil { + return gp.Issue{}, err + } + 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") + } + 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 +} + +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..9e785a3 --- /dev/null +++ b/backend/spi_setup.go @@ -0,0 +1,331 @@ +package main + +import ( + "context" + "strconv" + "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 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: "Role: Maintainer. Scope: api. Used for every API call, for git and to register webhooks."}, + }, + }, 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 { + UserID int64 `json:"user_id"` + 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, 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 + var info tokenInfo + haveInfo := false + status, body, _, err := api.doRaw("GET", "/user", nil) + if err != nil { + switch gp.AsError(err).Code { + 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 + } + } 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") + } + // Older instances do not have this endpoint; the token still works without it. + 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]) + secret := strings.TrimSpace(req.Params[fieldWebhookSecret]) + if strings.TrimSpace(req.State) == "" || token == "" { + return gp.Identity{}, gp.Errorf(gp.CodeValidation, "state and token are required") + } + 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) + 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 + } + reach := classifyToken(user.Username, user.Bot) + lookupReach(api, &reach) + rec := identityRecord{ + 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 != "" { + // 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 +} + +// 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.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, + Kind: kind, + DisplayName: name, + ExpiresAt: rec.ExpiresAt, + } +} + +// 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. +func (p *glProvider) DeleteIdentity(_ context.Context, req gp.DeleteIdentityRequest) (gp.Empty, error) { + // 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") + } + return gp.Empty{}, nil +} diff --git a/backend/spi_store.go b/backend/spi_store.go new file mode 100644 index 0000000..30bb198 --- /dev/null +++ b/backend/spi_store.go @@ -0,0 +1,93 @@ +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 + // 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) + // 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") + +// errUndecryptable is a stored secret the instance key does not open: the key +// changed since the row was stored. +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 + 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 + // 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. +type setupSession struct { + State string + Scope string // shared | agent + AgentID string + Host string + 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 new file mode 100644 index 0000000..ee13f06 --- /dev/null +++ b/backend/spi_test.go @@ -0,0 +1,617 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "strconv" + "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, "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 { + required[f.Name] = f.Required + if !f.Secret { + t.Errorf("field %s holds a secret but is not marked secret", f.Name) + } + } + if len(required) != 1 || !required[fieldToken] { + t.Errorf("form fields %v: want only the access token (the webhook secret is generated)", 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")) + 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." + 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} } + 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": 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": 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") + } +} + +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 + 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." + 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 { + 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) + } + } +} + +// 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 new file mode 100644 index 0000000..6736ed8 --- /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[:16]) +} + +// 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.mentionHandle()) { + 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/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 </-[-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.