From db6ff2db45a0b98f7e6986269180fb9427a77e84 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 17 Apr 2026 00:28:39 -0400 Subject: [PATCH 01/13] docs: add spec-kit artifacts for Gerrit integration Spec, plan, and tasks extracted from PR #1078's design, targeting reimplementation with current harness conventions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .specify/specs/gerrit-integration/plan.md | 145 +++++++++++++++++++ .specify/specs/gerrit-integration/spec.md | 155 +++++++++++++++++++++ .specify/specs/gerrit-integration/tasks.md | 69 +++++++++ 3 files changed, 369 insertions(+) create mode 100644 .specify/specs/gerrit-integration/plan.md create mode 100644 .specify/specs/gerrit-integration/spec.md create mode 100644 .specify/specs/gerrit-integration/tasks.md diff --git a/.specify/specs/gerrit-integration/plan.md b/.specify/specs/gerrit-integration/plan.md new file mode 100644 index 000000000..5859a113c --- /dev/null +++ b/.specify/specs/gerrit-integration/plan.md @@ -0,0 +1,145 @@ +# Implementation Plan: Gerrit Integration Connector + +**Branch**: `001-gerrit-integration` | **Date**: 2026-04-17 | **Spec**: [spec.md](spec.md) + +## Summary + +Add Gerrit as a native integration following the established Jira/CodeRabbit pattern. Multi-instance support with HTTP Basic and gitcookies auth methods, SSRF protection, and MCP server config generation for agentic sessions. Gated behind a feature flag. + +## Technical Context + +**Language/Version**: Go 1.23 (backend), TypeScript/Next.js (frontend), Python 3.12 (runner) +**Primary Dependencies**: Gin (HTTP), client-go (K8s), React Query, Shadcn/ui +**Storage**: Kubernetes Secrets (per-user credential storage) +**Testing**: Ginkgo v2 (backend), Vitest (frontend), pytest (runner) +**Target Platform**: Kubernetes cluster +**Constraints**: Follow existing integration patterns (Jira as canonical reference) + +## Project Structure + +### Files to Create + +```text +components/backend/handlers/gerrit_auth.go # Handlers: Connect, Status, Disconnect, List +components/backend/handlers/gerrit_auth_test.go # Ginkgo v2 test suite +components/frontend/src/components/gerrit-connection-card.tsx +components/frontend/src/services/api/gerrit-auth.ts +components/frontend/src/services/queries/use-gerrit.ts +components/frontend/src/app/api/auth/gerrit/connect/route.ts +components/frontend/src/app/api/auth/gerrit/test/route.ts +components/frontend/src/app/api/auth/gerrit/instances/route.ts +components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts +components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts +docs/gerrit-integration.md +``` + +### Files to Modify + +```text +components/backend/handlers/integration_validation.go # Add ValidateGerritToken, parseGitcookies, TestGerritConnection +components/backend/handlers/integrations_status.go # Add getGerritStatusForUser to GetIntegrationsStatus +components/backend/handlers/runtime_credentials.go # Add GetGerritCredentialsForSession +components/backend/routes.go # Register Gerrit routes +components/frontend/src/app/integrations/IntegrationsClient.tsx # Add GerritConnectionCard +components/frontend/src/services/api/integrations.ts # Add gerrit to IntegrationsStatus type +components/runners/ambient-runner/ambient_runner/platform/auth.py # Add fetch_gerrit_credentials +components/runners/ambient-runner/ambient_runner/platform/__init__.py # Export fetch_gerrit_credentials +components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py # Add generate_gerrit_config +components/runners/ambient-runner/.mcp.json # Add gerrit MCP server entry +components/manifests/base/core/flags.json # Add gerrit.enabled feature flag +``` + +## Implementation Phases + +### Phase 1: Feature Flag + Backend Handlers + +**Reference**: `jira_auth.go` (269 lines), `integration_validation.go` (229 lines) + +1. Add `gerrit.enabled` flag to `flags.json` with `scope:workspace` tag +2. Create `gerrit_auth.go` with: + - `GerritCredentials` struct (UserID, InstanceName, URL, AuthMethod, Username, HTTPToken, GitcookiesContent, UpdatedAt) + - `ConnectGerrit` handler — validates instance name (regex), validates URL (SSRF), validates auth method exclusivity, validates credentials, stores in Secret + - `GetGerritStatus` handler — single instance status lookup + - `DisconnectGerrit` handler — remove instance from Secret with conflict retry + - `ListGerritInstances` handler — all instances sorted by name + - `storeGerritCredentials` — K8s Secret CRUD with 3x conflict retry (follows Jira pattern) + - `getGerritCredentials`, `listGerritCredentials`, `deleteGerritCredentials` — Secret data access + - SSRF: `validateGerritURL` (scheme + DNS resolution + IP range check), `isPrivateOrBlocked` (all RFC ranges), `ssrfSafeTransport` (custom dialer re-validates at connection time) +3. Add to `integration_validation.go`: + - `ValidateGerritToken(ctx, url, authMethod, username, httpToken, gitcookiesContent) (bool, error)` — validates against `/a/accounts/self` with 15s timeout and SSRF-safe transport + - `parseGitcookies(gerritURL, content) (string, error)` — parses tab-delimited format, subdomain flag logic + - `TestGerritConnection` handler — validates without storing +4. Add to `integrations_status.go`: `getGerritStatusForUser` returning instances array +5. Add to `runtime_credentials.go`: `GetGerritCredentialsForSession` with RBAC via `enforceCredentialRBAC` +6. Register routes in `routes.go` + +**K8s client usage**: `K8sClient` (service account) for Secret CRUD. `GetK8sClientsForRequest(c)` for user auth validation. Follows K8S_CLIENT_PATTERNS.md. + +### Phase 2: Backend Tests + +**Reference**: Existing Ginkgo test suites in handlers/ + +1. Create `gerrit_auth_test.go` with Ginkgo v2: + - Auth token required checks + - Instance name validation (valid/invalid patterns) + - URL validation (HTTPS enforcement, private IP rejection) + - Mixed credential rejection + - Valid HTTP Basic credential flow + - Valid gitcookies credential flow + - Per-user Secret isolation + - Disconnect and list operations + - SSRF edge cases (loopback, metadata, CGNAT, DNS rebinding) +2. Use `test_utils.HTTPTestUtils` and `test_utils.K8sTestUtils` +3. Mock validation via package-level var: `var validateGerritTokenFn = ValidateGerritToken` + +### Phase 3: Frontend + +**Reference**: `jira-connection-card.tsx` (263 lines), `jira-auth.ts` (35 lines), `use-jira.ts` (31 lines) + +1. Create `gerrit-auth.ts` — types (GerritAuthMethod, GerritConnectRequest as discriminated union, GerritInstanceStatus, etc.) and API functions +2. Create `use-gerrit.ts` — React Query hooks: `useGerritInstances`, `useConnectGerrit`, `useDisconnectGerrit`, `useTestGerritConnection` with cache invalidation on `['integrations', 'status']` and `['gerrit', 'instances']` +3. Create Next.js proxy routes (5 files) — follow Jira route pattern with `buildForwardHeadersAsync`; test route gets 15s `AbortSignal.timeout()` +4. Create `gerrit-connection-card.tsx` — multi-instance card: + - Instance list with green status indicators + - Add form with: instance name, URL, auth method radio (http_basic/git_cookies), conditional fields + - Clear other auth method fields when switching radio buttons + - Test and Save buttons, show/hide token toggle + - Client-side validation: instance name min 2 chars, URL required, auth fields required + - Gate with `useWorkspaceFlag(projectName, 'gerrit.enabled')` +5. Add to `IntegrationsClient.tsx` — import and render GerritConnectionCard +6. Update `IntegrationsStatus` type to include gerrit + +### Phase 4: Runner Integration + +**Reference**: `auth.py` (fetch_jira_credentials pattern), `mcp.py` + +1. Add `fetch_gerrit_credentials(context)` to `auth.py`: + - Calls `_fetch_credential(context, "gerrit")` + - Returns list of instance dicts + - Handles PermissionError (auth failure) vs other errors (network) +2. Update `populate_runtime_credentials`: + - Add gerrit to `asyncio.gather` alongside google/jira/gitlab/github + - On success: call `generate_gerrit_config(instances)` + - On PermissionError: add to `auth_failures`, clear config + - On other error: log warning, preserve stale config +3. Add `generate_gerrit_config(instances)` to `mcp.py`: + - Creates `/tmp/gerrit-mcp/` directory + - Writes `gerrit_config.json` with `gerrit_hosts` array + - For git_cookies instances: writes combined `.gitcookies` file (0o600) + - Sets `GERRIT_CONFIG_PATH` env var + - Cleans up stale config on each call +4. Export `fetch_gerrit_credentials` from `__init__.py` +5. Add gerrit entry to `.mcp.json` + +### Phase 5: Documentation + +1. Create `docs/gerrit-integration.md` covering setup, auth methods, multi-instance usage, API reference, security, troubleshooting + +## Verification + +```bash +cd components/backend && make test # Backend tests pass +cd components/frontend && npm run build # Zero errors, zero warnings +cd components/runners/ambient-runner && python -m pytest tests/ # Runner tests pass +make lint # Pre-commit hooks pass +``` diff --git a/.specify/specs/gerrit-integration/spec.md b/.specify/specs/gerrit-integration/spec.md new file mode 100644 index 000000000..3d683207e --- /dev/null +++ b/.specify/specs/gerrit-integration/spec.md @@ -0,0 +1,155 @@ +# Feature Specification: Gerrit Integration Connector + +**Feature Branch**: `001-gerrit-integration` +**Created**: 2026-04-17 +**Status**: Draft +**Input**: Add Gerrit as a native integration in the Ambient Code Platform, enabling users to connect one or more Gerrit instances for code review workflows in agentic sessions. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Connect a Gerrit Instance via HTTP Basic Auth (Priority: P1) + +A platform user navigates to the Integrations page and connects a Gerrit instance by providing an instance name, the Gerrit server URL, and their HTTP Basic credentials (username + HTTP password). The system validates credentials against the Gerrit server before storing them. The user sees a success indicator and the instance appears in their list of connected Gerrit instances. + +**Why this priority**: Core functionality — without connecting, nothing else works. HTTP Basic is the most common Gerrit auth method. + +**Independent Test**: Can be fully tested by navigating to Integrations, filling out the Gerrit form with valid credentials, and verifying the instance appears as connected. + +**Acceptance Scenarios**: + +1. **Given** a user on the Integrations page with no Gerrit instances configured, **When** they fill in instance name "openstack", URL "https://review.opendev.org", username and HTTP token, and click Save, **Then** credentials are validated against the Gerrit server and the instance appears as connected with a green status indicator. +2. **Given** a user providing invalid credentials, **When** they click Save, **Then** the system shows an error that credentials are invalid and does not store them. +3. **Given** a user providing an HTTP (not HTTPS) URL, **When** they click Save, **Then** the system rejects the URL with an SSRF protection error. +4. **Given** a user providing a URL that resolves to a private/loopback IP, **When** they click Save, **Then** the system rejects it. + +--- + +### User Story 2 - Connect a Gerrit Instance via Gitcookies (Priority: P1) + +A platform user connects a Gerrit instance using their .gitcookies file content instead of HTTP Basic credentials. The system parses the gitcookies format, extracts the matching cookie for the target hostname (respecting subdomain flags), validates against the Gerrit server, and stores the credentials. + +**Why this priority**: Gitcookies is the required auth method for many enterprise Gerrit deployments (e.g., Android, Chromium). + +**Independent Test**: Can be tested by pasting gitcookies content and verifying connection succeeds. + +**Acceptance Scenarios**: + +1. **Given** a user with valid gitcookies content for a Gerrit host, **When** they select "Gitcookies" auth method and paste the content, **Then** the system extracts the matching cookie and validates successfully. +2. **Given** gitcookies content with subdomain flag TRUE, **When** connecting to a subdomain of the cookie's host, **Then** the cookie matches correctly. +3. **Given** a user providing both HTTP Basic fields AND gitcookies content, **When** they submit, **Then** the system rejects the request as mixed credentials. + +--- + +### User Story 3 - Manage Multiple Gerrit Instances (Priority: P1) + +A user connects multiple Gerrit instances (e.g., "openstack" and "android") and manages them independently. Each instance has its own credentials and can be connected/disconnected without affecting others. All instances are listed on the Integrations page. + +**Why this priority**: Multi-instance is a core design requirement — many users work across multiple Gerrit servers. + +**Independent Test**: Connect two instances, verify both appear, disconnect one, verify the other remains. + +**Acceptance Scenarios**: + +1. **Given** a user with one connected instance "openstack", **When** they add a second instance "android", **Then** both appear in the instances list, sorted alphabetically. +2. **Given** a user with two connected instances, **When** they disconnect "openstack", **Then** "android" remains connected and functional. +3. **Given** a user trying to add an instance with a duplicate name, **When** they submit, **Then** the existing instance credentials are updated (upsert behavior). + +--- + +### User Story 4 - Test Credentials Without Saving (Priority: P2) + +A user can test their Gerrit credentials before saving them. This validates the connection without persisting anything. + +**Why this priority**: Reduces failed connection attempts and gives users confidence before committing credentials. + +**Independent Test**: Click "Test" with valid/invalid credentials and verify the response without any state change. + +**Acceptance Scenarios**: + +1. **Given** valid credentials, **When** the user clicks Test, **Then** the system reports "Valid" without storing anything. +2. **Given** invalid credentials, **When** the user clicks Test, **Then** the system reports the credentials are invalid. +3. **Given** an unreachable Gerrit server, **When** the user clicks Test, **Then** the system reports a connection error within 15 seconds. + +--- + +### User Story 5 - Gerrit Credentials Available in Agentic Sessions (Priority: P1) + +When an agentic session starts, the runner automatically fetches the user's Gerrit credentials and generates the MCP server configuration. The Gerrit MCP server can then interact with all connected Gerrit instances during the session. + +**Why this priority**: This is the purpose of the integration — making Gerrit available to agents. + +**Independent Test**: Create a session with Gerrit configured, verify the MCP config file is generated with correct instance data. + +**Acceptance Scenarios**: + +1. **Given** a user with two connected Gerrit instances, **When** an agentic session starts, **Then** the runner generates a config file containing both instances with their auth details. +2. **Given** a user with gitcookies-based instances, **When** the session starts, **Then** a combined .gitcookies file is generated with entries from all gitcookies instances (file permissions 0o600). +3. **Given** the backend is temporarily unavailable, **When** the runner tries to fetch credentials, **Then** it preserves any existing stale config rather than clearing it. +4. **Given** a credential auth failure (PermissionError), **When** the runner handles it, **Then** existing config IS cleared and the failure is recorded. + +--- + +### User Story 6 - View Gerrit Status in Unified Integrations Endpoint (Priority: P2) + +The platform's unified integrations status endpoint includes Gerrit instance information, enabling the frontend to show connection status alongside other integrations. + +**Why this priority**: Consistency with existing integration status pattern. + +**Independent Test**: Call the integrations status endpoint and verify Gerrit instances are included. + +**Acceptance Scenarios**: + +1. **Given** a user with connected Gerrit instances, **When** the integrations status endpoint is called, **Then** the response includes a "gerrit" key with instance details. +2. **Given** a user with no Gerrit instances, **When** the status endpoint is called, **Then** the response includes "gerrit" with an empty instances array. + +--- + +### Edge Cases + +- What happens when the Gerrit server's DNS changes to a private IP after initial validation? (DNS rebinding — blocked by custom transport that re-validates at dial time) +- What happens when two users connect the same Gerrit instance? (Each user has their own Secret — no conflict) +- What happens when the K8s Secret update conflicts due to concurrent writes? (Retry up to 3 times) +- What happens when instance name is 1 character? (Rejected — minimum 2 characters) +- What happens when gitcookies content has no matching entry for the Gerrit host? (Validation fails — no cookie found) + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST support connecting multiple named Gerrit instances per user +- **FR-002**: System MUST support HTTP Basic Auth (username + HTTP token) as an authentication method +- **FR-003**: System MUST support gitcookies (tab-delimited format with subdomain flag logic) as an authentication method +- **FR-004**: System MUST reject credentials that mix fields from both auth methods (discriminated union) +- **FR-005**: System MUST validate credentials against Gerrit's `/a/accounts/self` endpoint before storing +- **FR-006**: System MUST provide a test endpoint that validates credentials without persisting them +- **FR-007**: System MUST enforce HTTPS-only URLs with SSRF protection (private IP blocking, DNS rebinding prevention) +- **FR-008**: System MUST enforce instance naming rules: lowercase alphanumeric + hyphens, 2-63 chars, regex `^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$` +- **FR-009**: System MUST store credentials in per-user Kubernetes Secrets with conflict retry handling +- **FR-010**: System MUST return instances sorted by name for deterministic API responses +- **FR-011**: System MUST enforce RBAC for session-level credential access (owner or active run user only) +- **FR-012**: System MUST include Gerrit status in the unified integrations status endpoint +- **FR-013**: System MUST generate MCP server configuration at session startup with all connected instances +- **FR-014**: System MUST handle backend failures gracefully in the runner (auth errors clear config, network errors preserve stale config) +- **FR-015**: System MUST never log credential values; use len(token) for presence checks +- **FR-016**: System MUST strip URLs and methods from error messages to prevent information leakage +- **FR-017**: System MUST gate the integration behind a feature flag +- **FR-018**: System MUST provide a frontend card with multi-instance management UI on the Integrations page +- **FR-019**: System MUST generate combined .gitcookies file from all gitcookies-based instances with 0o600 permissions + +### Key Entities + +- **GerritInstance**: A named connection to a Gerrit server — instanceName, URL, authMethod, credentials, updatedAt timestamp +- **GerritCredentials**: Per-user collection of GerritInstances stored in a Kubernetes Secret +- **GerritMCPConfig**: Generated configuration file consumed by the Gerrit MCP server at session runtime + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can connect a Gerrit instance and have it available in an agentic session within 30 seconds of saving credentials +- **SC-002**: All SSRF attack vectors (private IPs, DNS rebinding, HTTP downgrade) are blocked with appropriate error messages +- **SC-003**: Credential validation completes or times out within 15 seconds +- **SC-004**: Multiple Gerrit instances can be managed independently without cross-instance side effects +- **SC-005**: The integration follows the same patterns as existing integrations (Jira, CodeRabbit) so future integrations can use it as a reference +- **SC-006**: All backend handler paths are covered by Ginkgo v2 tests +- **SC-007**: Frontend build passes with zero errors and zero warnings diff --git a/.specify/specs/gerrit-integration/tasks.md b/.specify/specs/gerrit-integration/tasks.md new file mode 100644 index 000000000..97965eda6 --- /dev/null +++ b/.specify/specs/gerrit-integration/tasks.md @@ -0,0 +1,69 @@ +# Tasks: Gerrit Integration Connector + +**Input**: Design documents from `/specs/gerrit-integration/` +**Prerequisites**: plan.md, spec.md + +## Phase 1: Feature Flag + +- [ ] T001 [US6] Add `gerrit.enabled` feature flag to `components/manifests/base/core/flags.json` with `scope:workspace` tag + +--- + +## Phase 2: Backend Core (Blocking) + +- [ ] T002 [US1] Create `components/backend/handlers/gerrit_auth.go` — GerritCredentials struct, SSRF URL validation (validateGerritURL, isPrivateOrBlocked, ssrfSafeTransport), K8s Secret CRUD (store/get/list/delete with 3x conflict retry), ConnectGerrit handler, GetGerritStatus handler, DisconnectGerrit handler, ListGerritInstances handler. Follow jira_auth.go pattern. Use K8sClient for Secret ops, GetK8sClientsForRequest for auth. +- [ ] T003 [US1] Add Gerrit validation to `components/backend/handlers/integration_validation.go` — ValidateGerritToken (GET /a/accounts/self, 15s timeout, SSRF-safe transport, HTTP Basic and gitcookies support), parseGitcookies (tab-delimited format, subdomain flag logic), TestGerritConnection handler. Add `var validateGerritTokenFn = ValidateGerritToken` for test mockability. +- [ ] T004 [US6] Add `getGerritStatusForUser` to `components/backend/handlers/integrations_status.go` — return instances array, add to GetIntegrationsStatus response under "gerrit" key +- [ ] T005 [US5] Add `GetGerritCredentialsForSession` to `components/backend/handlers/runtime_credentials.go` — RBAC via enforceCredentialRBAC, returns all instances with auth details +- [ ] T006 Register Gerrit routes in `components/backend/routes.go` — POST connect, POST test, GET instances, GET :instanceName/status, DELETE :instanceName/disconnect, GET session credentials + +**Checkpoint**: Backend API functional + +--- + +## Phase 3: Backend Tests + +- [ ] T007 [US1] Create `components/backend/handlers/gerrit_auth_test.go` — Ginkgo v2 suite with test_constants labels. Cover: auth token required, user context validation, instance name validation (valid/invalid), HTTPS enforcement, private IP rejection (loopback, metadata, CGNAT, RFC ranges), mixed credential rejection, valid HTTP Basic flow, valid gitcookies flow, per-user Secret isolation, disconnect, list sorted, DNS rebinding edge cases. Use HTTPTestUtils and K8sTestUtils, mock validateGerritTokenFn. + +**Checkpoint**: `cd components/backend && make test` passes + +--- + +## Phase 4: Frontend + +- [ ] T008 [P] [US1] Create `components/frontend/src/services/api/gerrit-auth.ts` — GerritAuthMethod type, GerritConnectRequest (discriminated union), GerritTestRequest, GerritTestResponse, GerritInstanceStatus, GerritInstancesResponse types. API functions: connectGerrit, testGerritConnection, getGerritInstances, getGerritInstanceStatus, disconnectGerrit +- [ ] T009 [P] [US1] Create `components/frontend/src/services/queries/use-gerrit.ts` — useGerritInstances (queryKey ['gerrit','instances']), useConnectGerrit (invalidates ['integrations','status'] + ['gerrit','instances']), useDisconnectGerrit (same invalidation), useTestGerritConnection (no invalidation) +- [ ] T010 [P] [US1] Create Next.js proxy routes: `components/frontend/src/app/api/auth/gerrit/connect/route.ts`, `test/route.ts` (15s AbortSignal.timeout), `instances/route.ts`, `[instanceName]/status/route.ts`, `[instanceName]/disconnect/route.ts`. Follow jira route pattern with buildForwardHeadersAsync. +- [ ] T011 [US1] Create `components/frontend/src/components/gerrit-connection-card.tsx` — multi-instance card. Instance list with green status indicators. Add form: instance name input (auto-lowercase), URL input, auth method radio (http_basic/git_cookies), conditional fields (username+token with show/hide toggle OR gitcookies textarea). Clear other auth fields on radio switch. Test button, Save button. Client-side validation (name min 2 chars, required fields). Gate with useWorkspaceFlag(projectName, 'gerrit.enabled'). +- [ ] T012 [US6] Add GerritConnectionCard to `components/frontend/src/app/integrations/IntegrationsClient.tsx` and add gerrit to IntegrationsStatus type in `components/frontend/src/services/api/integrations.ts` + +**Checkpoint**: `cd components/frontend && npm run build` passes with 0 errors, 0 warnings + +--- + +## Phase 5: Runner Integration + +- [ ] T013 [US5] Add `fetch_gerrit_credentials(context)` to `components/runners/ambient-runner/ambient_runner/platform/auth.py` — calls _fetch_credential(context, "gerrit"), returns list of instance dicts. Export from `__init__.py`. +- [ ] T014 [US5] Update `populate_runtime_credentials` in auth.py — add gerrit to asyncio.gather. On success: call generate_gerrit_config. On PermissionError: add to auth_failures. On other error: log warning, preserve stale config. +- [ ] T015 [US5] Add `generate_gerrit_config(instances)` to `components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py` — creates /tmp/gerrit-mcp/, writes gerrit_config.json (gerrit_hosts array with name, external_url, authentication per instance), writes combined .gitcookies for git_cookies instances (0o600), sets GERRIT_CONFIG_PATH env var. Clean up stale config on each call. Handle empty list (clear env var). +- [ ] T016 [P] [US5] Add gerrit MCP server entry to `components/runners/ambient-runner/.mcp.json` + +**Checkpoint**: `cd components/runners/ambient-runner && python -m pytest tests/` passes + +--- + +## Phase 6: Documentation + Polish + +- [ ] T017 [P] Create `docs/gerrit-integration.md` — overview, auth methods, multi-instance usage, instance naming rules, API reference, security (SSRF, credential storage, rotation), troubleshooting +- [ ] T018 Run `make lint` — all pre-commit hooks pass + +--- + +## Dependencies & Execution Order + +- **Phase 1** (flag): No deps, start immediately +- **Phase 2** (backend): Depends on Phase 1 (flag exists) +- **Phase 3** (tests): Depends on Phase 2 +- **Phase 4** (frontend): Depends on Phase 2 (backend API exists); T008/T009/T010 can run in parallel; T011 depends on T008+T009; T012 depends on T011 +- **Phase 5** (runner): Depends on Phase 2 (credential endpoint exists); T013-T016 are sequential except T016 +- **Phase 6** (docs): Depends on all prior phases From 409b11aaedd86b8e2b7bad07b91d796dbc3e5d96 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Fri, 17 Apr 2026 08:52:09 -0400 Subject: [PATCH 02/13] feat: add Gerrit integration connector for code review workflows Multi-instance Gerrit support with HTTP Basic and gitcookies auth, SSRF protection, per-user K8s Secret storage, and dynamic MCP server injection when credentials are configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/backend/handlers/gerrit_auth.go | 609 ++++++++++++++++++ .../handlers/integration_validation.go | 93 +++ .../backend/handlers/integrations_status.go | 23 + .../backend/handlers/runtime_credentials.go | 50 ++ components/backend/routes.go | 8 + .../gerrit/[instanceName]/disconnect/route.ts | 18 + .../gerrit/[instanceName]/status/route.ts | 18 + .../src/app/api/auth/gerrit/connect/route.ts | 16 + .../app/api/auth/gerrit/instances/route.ts | 14 + .../src/app/api/auth/gerrit/test/route.ts | 17 + .../app/integrations/IntegrationsClient.tsx | 4 + .../src/components/gerrit-connection-card.tsx | 387 +++++++++++ .../frontend/src/services/api/gerrit-auth.ts | 73 +++ .../frontend/src/services/api/integrations.ts | 9 + .../src/services/queries/use-gerrit.ts | 39 ++ components/manifests/base/core/flags.json | 10 + .../ambient_runner/bridges/claude/mcp.py | 85 +++ .../ambient_runner/platform/__init__.py | 2 + .../ambient_runner/platform/auth.py | 35 + 19 files changed, 1510 insertions(+) create mode 100644 components/backend/handlers/gerrit_auth.go create mode 100644 components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/connect/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/instances/route.ts create mode 100644 components/frontend/src/app/api/auth/gerrit/test/route.ts create mode 100644 components/frontend/src/components/gerrit-connection-card.tsx create mode 100644 components/frontend/src/services/api/gerrit-auth.ts create mode 100644 components/frontend/src/services/queries/use-gerrit.ts diff --git a/components/backend/handlers/gerrit_auth.go b/components/backend/handlers/gerrit_auth.go new file mode 100644 index 000000000..d39d4d1fd --- /dev/null +++ b/components/backend/handlers/gerrit_auth.go @@ -0,0 +1,609 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "net/url" + "regexp" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GerritCredentials represents cluster-level Gerrit credentials for a single instance +type GerritCredentials struct { + UserID string `json:"userId"` + InstanceName string `json:"instanceName"` + URL string `json:"url"` + AuthMethod string `json:"authMethod"` // "http_basic" or "git_cookies" + Username string `json:"username,omitempty"` + HTTPToken string `json:"httpToken,omitempty"` + GitcookiesContent string `json:"gitcookiesContent,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// instanceNameRegex validates Gerrit instance names: 2-63 chars, lowercase alphanumeric + hyphens +var instanceNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`) + +// validateGerritURL validates and resolves a Gerrit URL, enforcing HTTPS and blocking +// private/reserved IP addresses to prevent SSRF attacks. +func validateGerritURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL format") + } + + if parsed.Scheme != "https" { + return fmt.Errorf("URL must use HTTPS scheme") + } + + hostname := parsed.Hostname() + if hostname == "" { + return fmt.Errorf("URL must include a hostname") + } + + ips, err := net.LookupHost(hostname) + if err != nil { + return fmt.Errorf("failed to resolve hostname") + } + + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + if isPrivateOrBlocked(ip) { + return fmt.Errorf("URL resolves to a blocked IP address") + } + } + + return nil +} + +// isPrivateOrBlocked returns true if the IP is loopback, private (RFC1918), +// link-local, CGNAT, documentation, benchmarking, multicast, reserved, +// or the cloud metadata address 169.254.169.254. +func isPrivateOrBlocked(ip net.IP) bool { + // Loopback + if ip.IsLoopback() { + return true + } + // Link-local + if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + // Private (RFC1918 for v4, RFC4193 for v6) + if ip.IsPrivate() { + return true + } + // Multicast + if ip.IsMulticast() { + return true + } + // Unspecified (0.0.0.0 / ::) + if ip.IsUnspecified() { + return true + } + + // Additional blocked ranges for IPv4 + ip4 := ip.To4() + if ip4 != nil { + // CGNAT 100.64.0.0/10 + cgnat := net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)} + if cgnat.Contains(ip4) { + return true + } + // Documentation: 192.0.2.0/24 + doc1 := net.IPNet{IP: net.IP{192, 0, 2, 0}, Mask: net.CIDRMask(24, 32)} + if doc1.Contains(ip4) { + return true + } + // Documentation: 198.51.100.0/24 + doc2 := net.IPNet{IP: net.IP{198, 51, 100, 0}, Mask: net.CIDRMask(24, 32)} + if doc2.Contains(ip4) { + return true + } + // Documentation: 203.0.113.0/24 + doc3 := net.IPNet{IP: net.IP{203, 0, 113, 0}, Mask: net.CIDRMask(24, 32)} + if doc3.Contains(ip4) { + return true + } + // Benchmarking: 198.18.0.0/15 + bench := net.IPNet{IP: net.IP{198, 18, 0, 0}, Mask: net.CIDRMask(15, 32)} + if bench.Contains(ip4) { + return true + } + // Reserved: 240.0.0.0/4 + reserved := net.IPNet{IP: net.IP{240, 0, 0, 0}, Mask: net.CIDRMask(4, 32)} + if reserved.Contains(ip4) { + return true + } + // Cloud metadata: 169.254.169.254/32 + if ip4.Equal(net.IP{169, 254, 169, 254}) { + return true + } + } + + return false +} + +// ssrfSafeTransport returns an http.Transport with a custom DialContext that +// resolves the hostname and checks each resolved IP against isPrivateOrBlocked +// before establishing the connection. This prevents DNS rebinding attacks. +func ssrfSafeTransport() *http.Transport { + return &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid address: %w", err) + } + + ips, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, fmt.Errorf("failed to resolve host: %w", err) + } + + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + if ip != nil && isPrivateOrBlocked(ip) { + return nil, fmt.Errorf("connection to blocked IP address denied") + } + } + + // Dial the first allowed IP + dialer := &net.Dialer{Timeout: 10 * time.Second} + return dialer.DialContext(ctx, network, addr) + }, + } +} + +// ConnectGerrit handles POST /api/auth/gerrit/connect +// Saves user's Gerrit credentials for a named instance +func ConnectGerrit(c *gin.Context) { + // Verify user has valid K8s token (follows RBAC pattern) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + if !isValidUserID(userID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"}) + return + } + + var req struct { + InstanceName string `json:"instanceName" binding:"required"` + URL string `json:"url" binding:"required"` + AuthMethod string `json:"authMethod" binding:"required"` + Username string `json:"username"` + HTTPToken string `json:"httpToken"` + GitcookiesContent string `json:"gitcookiesContent"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate instance name + if !instanceNameRegex.MatchString(req.InstanceName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid instance name: must be 2-63 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric"}) + return + } + + // Validate URL (SSRF protection) + if err := validateGerritURL(req.URL); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid Gerrit URL: %s", err.Error())}) + return + } + + // Validate auth method + if req.AuthMethod != "http_basic" && req.AuthMethod != "git_cookies" { + c.JSON(http.StatusBadRequest, gin.H{"error": "authMethod must be 'http_basic' or 'git_cookies'"}) + return + } + + // Reject mixed credentials + if req.HTTPToken != "" && req.GitcookiesContent != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot provide both httpToken and gitcookiesContent"}) + return + } + + // Validate required fields per auth method + switch req.AuthMethod { + case "http_basic": + if req.Username == "" || req.HTTPToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username and httpToken are required for http_basic auth"}) + return + } + case "git_cookies": + if req.GitcookiesContent == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "gitcookiesContent is required for git_cookies auth"}) + return + } + } + + // Validate credentials against Gerrit + valid, err := validateGerritTokenFn(c.Request.Context(), req.URL, req.AuthMethod, req.Username, req.HTTPToken, req.GitcookiesContent) + if err != nil { + log.Printf("Gerrit credential validation failed for user %s instance %s: %v", userID, req.InstanceName, err) + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Failed to validate Gerrit credentials: %s", err.Error())}) + return + } + if !valid { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Gerrit credentials"}) + return + } + + // Store credentials + creds := &GerritCredentials{ + UserID: userID, + InstanceName: req.InstanceName, + URL: req.URL, + AuthMethod: req.AuthMethod, + Username: req.Username, + HTTPToken: req.HTTPToken, + GitcookiesContent: req.GitcookiesContent, + UpdatedAt: time.Now(), + } + + if err := storeGerritCredentials(c.Request.Context(), creds); err != nil { + log.Printf("Failed to store Gerrit credentials for user %s instance %s: %v", userID, req.InstanceName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save Gerrit credentials"}) + return + } + + log.Printf("Stored Gerrit credentials for user %s instance %s (authMethod=%s, hasToken=%t)", + userID, req.InstanceName, req.AuthMethod, len(req.HTTPToken) > 0 || len(req.GitcookiesContent) > 0) + c.JSON(http.StatusOK, gin.H{ + "message": "Gerrit instance connected successfully", + "instanceName": req.InstanceName, + "url": req.URL, + "authMethod": req.AuthMethod, + }) +} + +// GetGerritStatus handles GET /api/auth/gerrit/:instanceName/status +// Returns connection status for a single Gerrit instance +func GetGerritStatus(c *gin.Context) { + // Verify user has valid K8s token + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + + instanceName := c.Param("instanceName") + if !instanceNameRegex.MatchString(instanceName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid instance name"}) + return + } + + creds, err := getGerritCredentials(c.Request.Context(), userID, instanceName) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusOK, gin.H{"connected": false, "instanceName": instanceName}) + return + } + log.Printf("Failed to get Gerrit credentials for user %s instance %s: %v", userID, instanceName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check Gerrit status"}) + return + } + + if creds == nil { + c.JSON(http.StatusOK, gin.H{"connected": false, "instanceName": instanceName}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "connected": true, + "instanceName": creds.InstanceName, + "url": creds.URL, + "authMethod": creds.AuthMethod, + "updatedAt": creds.UpdatedAt.Format(time.RFC3339), + }) +} + +// DisconnectGerrit handles DELETE /api/auth/gerrit/:instanceName/disconnect +// Removes a Gerrit instance's credentials for the user +func DisconnectGerrit(c *gin.Context) { + // Verify user has valid K8s token + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + + instanceName := c.Param("instanceName") + if !instanceNameRegex.MatchString(instanceName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid instance name"}) + return + } + + if err := deleteGerritCredentials(c.Request.Context(), userID, instanceName); err != nil { + log.Printf("Failed to delete Gerrit credentials for user %s instance %s: %v", userID, instanceName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect Gerrit instance"}) + return + } + + log.Printf("Deleted Gerrit credentials for user %s instance %s", userID, instanceName) + c.JSON(http.StatusOK, gin.H{"message": "Gerrit instance disconnected successfully"}) +} + +// ListGerritInstances handles GET /api/auth/gerrit/instances +// Lists all Gerrit instances configured for the authenticated user +func ListGerritInstances(c *gin.Context) { + // Verify user has valid K8s token + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + userID := c.GetString("userID") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"}) + return + } + + instances, err := listGerritCredentials(c.Request.Context(), userID) + if err != nil { + log.Printf("Failed to list Gerrit instances for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Gerrit instances"}) + return + } + + // Sort by instance name for consistent ordering + sort.Slice(instances, func(i, j int) bool { + return instances[i].InstanceName < instances[j].InstanceName + }) + + result := make([]gin.H, 0, len(instances)) + for _, inst := range instances { + entry := gin.H{ + "instanceName": inst.InstanceName, + "url": inst.URL, + "authMethod": inst.AuthMethod, + "updatedAt": inst.UpdatedAt.Format(time.RFC3339), + } + result = append(result, entry) + } + + c.JSON(http.StatusOK, gin.H{"instances": result}) +} + +// gerritSecretName returns the K8s Secret name for a user's Gerrit credentials +func gerritSecretName(userID string) string { + return fmt.Sprintf("gerrit-credentials-%s", userID) +} + +// storeGerritCredentials stores Gerrit credentials for a single instance in the user's Secret +func storeGerritCredentials(ctx context.Context, creds *GerritCredentials) error { + if creds == nil || creds.UserID == "" || creds.InstanceName == "" { + return fmt.Errorf("invalid credentials payload") + } + + secretName := gerritSecretName(creds.UserID) + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + // Create Secret + secret = &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: secretName, + Namespace: Namespace, + Labels: map[string]string{ + "app": "ambient-code", + "ambient-code.io/provider": "gerrit", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{}, + } + if _, cerr := K8sClient.CoreV1().Secrets(Namespace).Create(ctx, secret, v1.CreateOptions{}); cerr != nil && !errors.IsAlreadyExists(cerr) { + return fmt.Errorf("failed to create Secret: %w", cerr) + } + // Fetch again to get resourceVersion + secret, err = K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to fetch Secret after create: %w", err) + } + } else { + return fmt.Errorf("failed to get Secret: %w", err) + } + } + + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + + b, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + secret.Data[creds.InstanceName] = b + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue // retry + } + return fmt.Errorf("failed to update Secret: %w", uerr) + } + return nil + } + return fmt.Errorf("failed to update Secret after retries") +} + +// getGerritCredentials retrieves credentials for a single Gerrit instance +func getGerritCredentials(ctx context.Context, userID, instanceName string) (*GerritCredentials, error) { + if userID == "" || instanceName == "" { + return nil, fmt.Errorf("userID and instanceName are required") + } + + secretName := gerritSecretName(userID) + + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + return nil, err + } + + if secret.Data == nil || len(secret.Data[instanceName]) == 0 { + return nil, nil // Instance not configured + } + + var creds GerritCredentials + if err := json.Unmarshal(secret.Data[instanceName], &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + + return &creds, nil +} + +// listGerritCredentials retrieves all Gerrit instances for a user +func listGerritCredentials(ctx context.Context, userID string) ([]GerritCredentials, error) { + if userID == "" { + return nil, fmt.Errorf("userID is required") + } + + secretName := gerritSecretName(userID) + + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil // No instances configured + } + return nil, fmt.Errorf("failed to get Secret: %w", err) + } + + if secret.Data == nil { + return nil, nil + } + + var instances []GerritCredentials + for key, data := range secret.Data { + var creds GerritCredentials + if err := json.Unmarshal(data, &creds); err != nil { + log.Printf("Failed to parse Gerrit credentials for instance %s: %v", key, err) + continue + } + instances = append(instances, creds) + } + + return instances, nil +} + +// deleteGerritCredentials removes a single Gerrit instance's credentials +func deleteGerritCredentials(ctx context.Context, userID, instanceName string) error { + if userID == "" || instanceName == "" { + return fmt.Errorf("userID and instanceName are required") + } + + secretName := gerritSecretName(userID) + + for i := 0; i < 3; i++ { // retry on conflict + secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil // Secret doesn't exist, nothing to delete + } + return fmt.Errorf("failed to get Secret: %w", err) + } + + if secret.Data == nil || len(secret.Data[instanceName]) == 0 { + return nil // Instance credentials don't exist + } + + delete(secret.Data, instanceName) + + // If no more instances, delete the entire Secret + if len(secret.Data) == 0 { + if derr := K8sClient.CoreV1().Secrets(Namespace).Delete(ctx, secretName, v1.DeleteOptions{}); derr != nil && !errors.IsNotFound(derr) { + return fmt.Errorf("failed to delete empty Secret: %w", derr) + } + return nil + } + + if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil { + if errors.IsConflict(uerr) { + continue // retry + } + return fmt.Errorf("failed to update Secret: %w", uerr) + } + return nil + } + return fmt.Errorf("failed to update Secret after retries") +} + +// parseGitcookies parses gitcookies content and extracts the cookie for the given Gerrit URL. +// Each line follows the Netscape cookie format: host\tsubdomain_flag\tpath\tsecure\texpiry\tname\tvalue +// Subdomain flag "TRUE" means wildcard match (URL host is a subdomain of cookie host), +// "FALSE" means exact match. +func parseGitcookies(gerritURL, content string) (string, error) { + parsed, err := url.Parse(gerritURL) + if err != nil { + return "", fmt.Errorf("invalid Gerrit URL: %w", err) + } + targetHost := parsed.Hostname() + + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Split(line, "\t") + if len(fields) < 7 { + continue + } + + cookieHost := fields[0] + subdomainFlag := strings.ToUpper(fields[1]) + cookieName := fields[5] + cookieValue := fields[6] + + var matched bool + if subdomainFlag == "TRUE" { + // Wildcard: target host must be the cookie host or a subdomain of it + matched = targetHost == cookieHost || strings.HasSuffix(targetHost, "."+cookieHost) + } else { + // Exact match + matched = targetHost == cookieHost + } + + if matched { + return fmt.Sprintf("%s=%s", cookieName, cookieValue), nil + } + } + + return "", fmt.Errorf("no matching cookie found for host %s", targetHost) +} diff --git a/components/backend/handlers/integration_validation.go b/components/backend/handlers/integration_validation.go index e137aebbb..becce2435 100755 --- a/components/backend/handlers/integration_validation.go +++ b/components/backend/handlers/integration_validation.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "net/url" + "strings" "time" "github.com/gin-gonic/gin" @@ -198,6 +199,98 @@ func TestJiraConnection(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"valid": true, "message": "Jira connection successful"}) } +// validateGerritTokenFn is a package-level var for test mockability +var validateGerritTokenFn = ValidateGerritToken + +// ValidateGerritToken checks if Gerrit credentials are valid by calling /a/accounts/self +func ValidateGerritToken(ctx context.Context, gerritURL, authMethod, username, httpToken, gitcookiesContent string) (bool, error) { + if gerritURL == "" { + return false, fmt.Errorf("missing Gerrit URL") + } + + apiURL := fmt.Sprintf("%s/a/accounts/self", strings.TrimSuffix(gerritURL, "/")) + + client := &http.Client{ + Timeout: 15 * time.Second, + Transport: ssrfSafeTransport(), + } + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return false, fmt.Errorf("failed to create request") + } + + switch authMethod { + case "http_basic": + if username == "" || httpToken == "" { + return false, fmt.Errorf("missing username or HTTP token") + } + req.SetBasicAuth(username, httpToken) + case "git_cookies": + if gitcookiesContent == "" { + return false, fmt.Errorf("missing gitcookies content") + } + cookie, err := parseGitcookies(gerritURL, gitcookiesContent) + if err != nil { + return false, fmt.Errorf("failed to parse gitcookies: %w", err) + } + req.Header.Set("Cookie", cookie) + default: + return false, fmt.Errorf("unsupported auth method: %s", authMethod) + } + + resp, err := client.Do(req) + if err != nil { + return false, fmt.Errorf("request failed: %w", networkError(err)) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return true, nil + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return false, nil + } + + return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) +} + +// TestGerritConnection handles POST /api/auth/gerrit/test +// Tests Gerrit credentials without saving them +func TestGerritConnection(c *gin.Context) { + var req struct { + URL string `json:"url" binding:"required"` + AuthMethod string `json:"authMethod" binding:"required"` + Username string `json:"username"` + HTTPToken string `json:"httpToken"` + GitcookiesContent string `json:"gitcookiesContent"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate URL (SSRF protection) + if err := validateGerritURL(req.URL); err != nil { + c.JSON(http.StatusOK, gin.H{"valid": false, "error": fmt.Sprintf("Invalid URL: %s", err.Error())}) + return + } + + valid, err := validateGerritTokenFn(c.Request.Context(), req.URL, req.AuthMethod, req.Username, req.HTTPToken, req.GitcookiesContent) + if err != nil { + c.JSON(http.StatusOK, gin.H{"valid": false, "error": err.Error()}) + return + } + + if !valid { + c.JSON(http.StatusOK, gin.H{"valid": false, "error": "Invalid credentials"}) + return + } + + c.JSON(http.StatusOK, gin.H{"valid": true, "message": "Gerrit connection successful"}) +} + // TestGitLabConnection handles POST /api/auth/gitlab/test // Tests GitLab credentials without saving them func TestGitLabConnection(c *gin.Context) { diff --git a/components/backend/handlers/integrations_status.go b/components/backend/handlers/integrations_status.go index 327f021d6..1ed263967 100644 --- a/components/backend/handlers/integrations_status.go +++ b/components/backend/handlers/integrations_status.go @@ -42,6 +42,9 @@ func GetIntegrationsStatus(c *gin.Context) { // CodeRabbit status response["coderabbit"] = getCodeRabbitStatusForUser(ctx, userID) + // Gerrit status + response["gerrit"] = getGerritStatusForUser(ctx, userID) + // MCP server credentials status response["mcpServers"] = getMCPServerStatusForUser(ctx, userID) @@ -131,6 +134,26 @@ func getJiraStatusForUser(ctx context.Context, userID string) gin.H { } } +func getGerritStatusForUser(ctx context.Context, userID string) gin.H { + instances, err := listGerritCredentials(ctx, userID) + if err != nil || len(instances) == 0 { + return gin.H{"instances": []gin.H{}} + } + + result := make([]gin.H, 0, len(instances)) + for _, inst := range instances { + result = append(result, gin.H{ + "connected": true, + "instanceName": inst.InstanceName, + "url": inst.URL, + "authMethod": inst.AuthMethod, + "updatedAt": inst.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + + return gin.H{"instances": result} +} + func getGitLabStatusForUser(ctx context.Context, userID string) gin.H { creds, err := GetGitLabCredentials(ctx, userID) if err != nil || creds == nil { diff --git a/components/backend/handlers/runtime_credentials.go b/components/backend/handlers/runtime_credentials.go index 475cab477..1d2abdaf0 100755 --- a/components/backend/handlers/runtime_credentials.go +++ b/components/backend/handlers/runtime_credentials.go @@ -380,6 +380,56 @@ func GetCodeRabbitCredentialsForSession(c *gin.Context) { }) } +// GetGerritCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/gerrit +// Returns all Gerrit instance credentials for the session's user +func GetGerritCredentialsForSession(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + return + } + + effectiveUserID, ok := enforceCredentialRBAC(c, reqK8s, reqDyn, project, session) + if !ok { + return + } + + // Get all Gerrit instances for the user + instances, err := listGerritCredentials(c.Request.Context(), effectiveUserID) + if err != nil { + log.Printf("Failed to get Gerrit credentials for user %s: %v", effectiveUserID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get Gerrit credentials"}) + return + } + + if len(instances) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Gerrit credentials not configured"}) + return + } + + result := make([]gin.H, 0, len(instances)) + for _, inst := range instances { + entry := gin.H{ + "instanceName": inst.InstanceName, + "url": inst.URL, + "authMethod": inst.AuthMethod, + } + switch inst.AuthMethod { + case gerritAuthHTTPBasic: + entry["username"] = inst.Username + entry["httpToken"] = inst.HTTPToken + case gerritAuthGitCookies: + entry["gitcookiesContent"] = inst.GitcookiesContent + } + result = append(result, entry) + } + + c.JSON(http.StatusOK, gin.H{"instances": result}) +} + // refreshGoogleAccessToken refreshes a Google OAuth access token using the refresh token func refreshGoogleAccessToken(ctx context.Context, oldCreds *GoogleOAuthCredentials) (*GoogleOAuthCredentials, error) { if oldCreds.RefreshToken == "" { diff --git a/components/backend/routes.go b/components/backend/routes.go index be84d589c..d920d27c4 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -100,6 +100,7 @@ func registerRoutes(r *gin.Engine) { projectGroup.GET("/agentic-sessions/:sessionName/credentials/jira", handlers.GetJiraCredentialsForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/gitlab", handlers.GetGitLabTokenForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/coderabbit", handlers.GetCodeRabbitCredentialsForSession) + projectGroup.GET("/agentic-sessions/:sessionName/credentials/gerrit", handlers.GetGerritCredentialsForSession) projectGroup.GET("/agentic-sessions/:sessionName/credentials/mcp/:serverName", handlers.GetMCPCredentialsForSession) // Session export @@ -174,6 +175,13 @@ func registerRoutes(r *gin.Engine) { api.DELETE("/auth/jira/disconnect", handlers.DisconnectJira) api.POST("/auth/jira/test", handlers.TestJiraConnection) + // Cluster-level Gerrit (user-scoped, multi-instance) + api.POST("/auth/gerrit/connect", handlers.ConnectGerrit) + api.POST("/auth/gerrit/test", handlers.TestGerritConnection) + api.GET("/auth/gerrit/instances", handlers.ListGerritInstances) + api.GET("/auth/gerrit/:instanceName/status", handlers.GetGerritStatus) + api.DELETE("/auth/gerrit/:instanceName/disconnect", handlers.DisconnectGerrit) + // Cluster-level GitLab (user-scoped) api.POST("/auth/gitlab/connect", handlers.ConnectGitLabGlobal) api.GET("/auth/gitlab/status", handlers.GetGitLabStatusGlobal) diff --git a/components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts b/components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts new file mode 100644 index 000000000..8451d46a2 --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/[instanceName]/disconnect/route.ts @@ -0,0 +1,18 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ instanceName: string }> } +) { + const { instanceName } = await params + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/${encodeURIComponent(instanceName)}/disconnect`, { + method: 'DELETE', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts b/components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts new file mode 100644 index 000000000..9d98b8389 --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/[instanceName]/status/route.ts @@ -0,0 +1,18 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function GET( + request: Request, + { params }: { params: Promise<{ instanceName: string }> } +) { + const { instanceName } = await params + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/${encodeURIComponent(instanceName)}/status`, { + method: 'GET', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/connect/route.ts b/components/frontend/src/app/api/auth/gerrit/connect/route.ts new file mode 100644 index 000000000..83b8957d7 --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/connect/route.ts @@ -0,0 +1,16 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function POST(request: Request) { + const headers = await buildForwardHeadersAsync(request) + const body = await request.text() + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/connect`, { + method: 'POST', + headers, + body, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/instances/route.ts b/components/frontend/src/app/api/auth/gerrit/instances/route.ts new file mode 100644 index 000000000..928db061d --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/instances/route.ts @@ -0,0 +1,14 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function GET(request: Request) { + const headers = await buildForwardHeadersAsync(request) + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/instances`, { + method: 'GET', + headers, + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/api/auth/gerrit/test/route.ts b/components/frontend/src/app/api/auth/gerrit/test/route.ts new file mode 100644 index 000000000..60e1c679a --- /dev/null +++ b/components/frontend/src/app/api/auth/gerrit/test/route.ts @@ -0,0 +1,17 @@ +import { BACKEND_URL } from '@/lib/config' +import { buildForwardHeadersAsync } from '@/lib/auth' + +export async function POST(request: Request) { + const headers = await buildForwardHeadersAsync(request) + const body = await request.text() + + const resp = await fetch(`${BACKEND_URL}/auth/gerrit/test`, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(15000), + }) + + const data = await resp.text() + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) +} diff --git a/components/frontend/src/app/integrations/IntegrationsClient.tsx b/components/frontend/src/app/integrations/IntegrationsClient.tsx index ca195b32f..15ff44e2f 100644 --- a/components/frontend/src/app/integrations/IntegrationsClient.tsx +++ b/components/frontend/src/app/integrations/IntegrationsClient.tsx @@ -5,6 +5,7 @@ import { GoogleDriveConnectionCard } from '@/components/google-drive-connection- import { GitLabConnectionCard } from '@/components/gitlab-connection-card' import { JiraConnectionCard } from '@/components/jira-connection-card' import { CodeRabbitConnectionCard } from '@/components/coderabbit-connection-card' +import { GerritConnectionCard } from '@/components/gerrit-connection-card' import { PageHeader } from '@/components/page-header' import { useIntegrationsStatus } from '@/services/queries/use-integrations' import { Loader2 } from 'lucide-react' @@ -58,6 +59,9 @@ export default function IntegrationsClient({ appSlug }: Props) { status={integrations?.coderabbit} onRefresh={refetch} /> + )} diff --git a/components/frontend/src/components/gerrit-connection-card.tsx b/components/frontend/src/components/gerrit-connection-card.tsx new file mode 100644 index 000000000..fdb8466b7 --- /dev/null +++ b/components/frontend/src/components/gerrit-connection-card.tsx @@ -0,0 +1,387 @@ +'use client' + +import React, { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Badge } from '@/components/ui/badge' +import { Loader2, Eye, EyeOff, Plus, Trash2, CheckCircle2, XCircle, Server } from 'lucide-react' +import { toast } from 'sonner' +import { + useGerritInstances, + useConnectGerrit, + useDisconnectGerrit, + useTestGerritConnection, +} from '@/services/queries/use-gerrit' +import type { GerritAuthMethod, GerritInstanceStatus } from '@/services/api/gerrit-auth' + +type Props = { + status?: { + connected: boolean + instances?: Array<{ + instanceName: string + url: string + authMethod: string + connected: boolean + }> + } + onRefresh?: () => void +} + +export function GerritConnectionCard({ onRefresh }: Props) { + const { data: instancesData, refetch: refetchInstances } = useGerritInstances() + const connectMutation = useConnectGerrit() + const disconnectMutation = useDisconnectGerrit() + const testMutation = useTestGerritConnection() + + const [showForm, setShowForm] = useState(false) + const [instanceName, setInstanceName] = useState('') + const [url, setUrl] = useState('') + const [authMethod, setAuthMethod] = useState('http_basic') + const [username, setUsername] = useState('') + const [httpToken, setHttpToken] = useState('') + const [showToken, setShowToken] = useState(false) + const [gitcookiesContent, setGitcookiesContent] = useState('') + + const instances = instancesData?.instances ?? [] + const hasInstances = instances.length > 0 + + const resetForm = () => { + setInstanceName('') + setUrl('') + setAuthMethod('http_basic') + setUsername('') + setHttpToken('') + setShowToken(false) + setGitcookiesContent('') + } + + const handleAuthMethodChange = (value: string) => { + const method = value as GerritAuthMethod + setAuthMethod(method) + if (method === 'http_basic') { + setGitcookiesContent('') + } else { + setUsername('') + setHttpToken('') + } + } + + const normalizedInstanceName = instanceName.toLowerCase().replace(/[^a-z0-9-]/g, '-') + + const isFormValid = () => { + if (!normalizedInstanceName || !url) return false + if (authMethod === 'http_basic') return !!username && !!httpToken + return !!gitcookiesContent + } + + const buildTestPayload = () => { + if (authMethod === 'http_basic') { + return { url, authMethod: 'http_basic' as const, username, httpToken } + } + return { url, authMethod: 'git_cookies' as const, gitcookiesContent } + } + + const buildConnectPayload = () => { + if (authMethod === 'http_basic') { + return { instanceName: normalizedInstanceName, url, authMethod: 'http_basic' as const, username, httpToken } + } + return { instanceName: normalizedInstanceName, url, authMethod: 'git_cookies' as const, gitcookiesContent } + } + + const handleTest = () => { + if (!url) { + toast.error('Please enter a Gerrit URL') + return + } + + testMutation.mutate(buildTestPayload(), { + onSuccess: (result) => { + if (result.valid) { + toast.success(result.message ?? 'Connection test successful') + } else { + toast.error(result.error ?? 'Connection test failed') + } + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Connection test failed') + }, + }) + } + + const handleConnect = () => { + if (!isFormValid()) { + toast.error('Please fill in all required fields') + return + } + + connectMutation.mutate(buildConnectPayload(), { + onSuccess: () => { + toast.success(`Gerrit instance "${normalizedInstanceName}" connected`) + setShowForm(false) + resetForm() + onRefresh?.() + refetchInstances() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to connect Gerrit instance') + }, + }) + } + + const handleDisconnect = (name: string) => { + disconnectMutation.mutate(name, { + onSuccess: () => { + toast.success(`Gerrit instance "${name}" disconnected`) + onRefresh?.() + refetchInstances() + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Failed to disconnect Gerrit instance') + }, + }) + } + + return ( + +
+ {/* Header */} +
+
+
+
+

Gerrit

+

Connect to Gerrit for code review

+
+
+ + {/* Instance list */} + {hasInstances && ( +
+ {instances.map((instance: GerritInstanceStatus) => ( +
+
+ {instance.connected ? ( + + ) : ( + + )} +
+ + {instance.instanceName} + + + {instance.url} + +
+ + {instance.authMethod === 'http_basic' ? 'HTTP' : 'Cookies'} + +
+ +
+ ))} +
+ )} + + {/* Status when no instances */} + {!hasInstances && !showForm && ( +
+
+ + Not Connected +
+

+ Connect to Gerrit instances for code review across all sessions +

+
+ )} + + {/* Add instance form */} + {showForm && ( +
+
+ + setInstanceName(e.target.value)} + disabled={connectMutation.isPending} + className="mt-1" + /> + {instanceName && instanceName !== normalizedInstanceName && ( +

+ Will be saved as: {normalizedInstanceName} +

+ )} +
+
+ + setUrl(e.target.value)} + disabled={connectMutation.isPending} + className="mt-1" + /> +
+
+ + +
+ + +
+
+ + +
+
+
+ + {authMethod === 'http_basic' && ( + <> +
+ + setUsername(e.target.value)} + disabled={connectMutation.isPending} + className="mt-1" + /> +
+
+ +
+ setHttpToken(e.target.value)} + disabled={connectMutation.isPending} + /> + +
+

+ Generate at Settings → HTTP Credentials in your Gerrit instance +

+
+ + )} + + {authMethod === 'git_cookies' && ( +
+ +