diff --git a/.gitignore b/.gitignore
index 17ad0f22..6f878790 100644
--- a/.gitignore
+++ b/.gitignore
@@ -163,4 +163,8 @@ dist
.sourcebot
/bin
/config.json
-.DS_Store
\ No newline at end of file
+.DS_Store
+
+# Test files with real credentials (should not be tracked)
+gerrit_auth_test.ts
+**/gerrit_auth_test.ts
\ No newline at end of file
diff --git a/docs/docs/connections/gerrit-troubleshooting.mdx b/docs/docs/connections/gerrit-troubleshooting.mdx
new file mode 100644
index 00000000..63d33fc6
--- /dev/null
+++ b/docs/docs/connections/gerrit-troubleshooting.mdx
@@ -0,0 +1,496 @@
+---
+title: Gerrit Authentication Troubleshooting Guide
+sidebarTitle: Gerrit Troubleshooting
+---
+
+# Gerrit Authentication Troubleshooting Guide
+
+This guide provides detailed troubleshooting steps for Gerrit authentication issues with Sourcebot, based on extensive testing and real-world scenarios.
+
+## Quick Diagnosis
+
+### Authentication Test Checklist
+
+Run through this checklist to quickly identify authentication issues:
+
+1. **✅ API Test**: Can you access Gerrit's API?
+ ```bash
+ curl -u "username:http-password" "https://gerrit.example.com/a/projects/"
+ ```
+
+2. **✅ Git Clone Test**: Can you clone manually?
+ ```bash
+ git clone https://username@gerrit.example.com/a/project-name
+ ```
+
+3. **✅ Project Access**: Do you have permissions for the project?
+ ```bash
+ curl -u "username:http-password" "https://gerrit.example.com/a/projects/project-name"
+ ```
+
+4. **✅ Environment Variable**: Is your password set correctly?
+ ```bash
+ echo $GERRIT_HTTP_PASSWORD
+ ```
+
+## Common Error Scenarios
+
+### 1. Authentication Failed (401 Unauthorized)
+
+**Error Signs:**
+- Sourcebot logs show "401 Unauthorized"
+- API tests fail with authentication errors
+- Git clone fails with "Authentication failed"
+
+**Root Causes & Solutions:**
+
+
+
+ **Problem**: Using your regular Gerrit login password instead of the generated HTTP password.
+
+ **Solution**:
+ 1. Generate a new HTTP password in Gerrit:
+ - Go to Gerrit → Settings → HTTP Credentials
+ - Click "Generate Password"
+ - Copy the generated password (NOT your login password)
+ 2. Test the new password:
+ ```bash
+ curl -u "username:NEW_HTTP_PASSWORD" "https://gerrit.example.com/a/projects/"
+ ```
+
+
+
+ **Problem**: Using display name or email instead of Gerrit username.
+
+ **Solution**:
+ 1. Check your Gerrit username:
+ - Go to Gerrit → Settings → Profile
+ - Note the "Username" field (not display name)
+ 2. Common mistakes:
+ - ❌ `john.doe@company.com` (email)
+ - ❌ `John Doe` (display name)
+ - ✅ `jdoe` (username)
+
+
+
+ **Problem**: Environment variable not set or incorrectly named.
+
+ **Solution**:
+ 1. Check if variable is set:
+ ```bash
+ echo $GERRIT_HTTP_PASSWORD
+ ```
+ 2. Set it correctly:
+ ```bash
+ export GERRIT_HTTP_PASSWORD="your-http-password"
+ ```
+ 3. For Docker:
+ ```bash
+ docker run -e GERRIT_HTTP_PASSWORD="password" ...
+ ```
+
+
+
+### 2. No Projects Found (0 Repos Synced)
+
+**Error Signs:**
+- Sourcebot connects successfully but syncs 0 repositories
+- Logs show "Upserted 0 repos for connection"
+- No error messages, just empty results
+
+**Root Causes & Solutions:**
+
+
+
+ **Problem**: Project names in config don't match actual Gerrit project names.
+
+ **Solution**:
+ 1. List all accessible projects:
+ ```bash
+ curl -u "username:password" \
+ "https://gerrit.example.com/a/projects/" | \
+ jq 'keys[]' # If jq is available
+ ```
+ 2. Use exact project names from the API response
+ 3. For testing, try wildcard pattern:
+ ```json
+ "projects": ["*"]
+ ```
+
+
+
+ **Problem**: User doesn't have clone permissions for specified projects.
+
+ **Solution**:
+ 1. Check project-specific permissions in Gerrit admin
+ 2. Test access to a specific project:
+ ```bash
+ curl -u "username:password" \
+ "https://gerrit.example.com/a/projects/project-name"
+ ```
+ 3. Contact Gerrit admin to grant clone permissions
+
+
+
+ **Problem**: Projects are hidden or read-only and excluded by default.
+
+ **Solution**:
+ 1. Include hidden/read-only projects explicitly:
+ ```json
+ {
+ "type": "gerrit",
+ "url": "https://gerrit.example.com",
+ "projects": ["project-name"],
+ "exclude": {
+ "hidden": false, // Include hidden projects
+ "readOnly": false // Include read-only projects
+ }
+ }
+ ```
+
+
+
+### 3. Git Clone Failures
+
+**Error Signs:**
+- Authentication works for API but fails for git operations
+- "fatal: Authentication failed for" errors
+- "remote: Unauthorized" messages
+
+**Root Causes & Solutions:**
+
+
+
+ **Problem**: Git clone URL doesn't include the `/a/` prefix required for authenticated access.
+
+ **Solution**:
+ 1. Verify correct URL format:
+ - ❌ `https://username@gerrit.example.com/project-name`
+ - ✅ `https://username@gerrit.example.com/a/project-name`
+ 2. Test manually:
+ ```bash
+ git clone https://username@gerrit.example.com/a/project-name
+ ```
+
+
+
+ **Problem**: Git is using cached credentials or wrong credential helper.
+
+ **Solution**:
+ 1. Clear Git credential cache:
+ ```bash
+ git credential-manager-core erase
+ # Or for older systems:
+ git credential-cache exit
+ ```
+ 2. Test with explicit credentials:
+ ```bash
+ git -c credential.helper= clone https://username@gerrit.example.com/a/project
+ ```
+
+
+
+ **Problem**: HTTP passwords containing special characters (`/`, `+`, `=`) cause authentication failures.
+
+ **Solution**:
+ 1. **For Sourcebot**: No action needed - URL encoding is handled automatically
+ 2. **For manual testing**: URL-encode the password:
+ ```bash
+ # Original password: pass/with+special=chars
+ # URL-encoded: pass%2Fwith%2Bspecial%3Dchars
+ git clone https://user:pass%2Fwith%2Bspecial%3Dchars@gerrit.example.com/a/project
+ ```
+ 3. **For curl testing**:
+ ```bash
+ curl -u "username:pass/with+special=chars" "https://gerrit.example.com/a/projects/"
+ ```
+
+
+ Sourcebot v4.5.0+ automatically handles URL encoding for git operations. Earlier versions may require manual password encoding.
+
+
+
+
+### 4. Configuration Schema Errors
+
+**Error Signs:**
+- "Config file is invalid" errors
+- "must NOT have additional properties" messages
+- Schema validation failures
+
+**Root Causes & Solutions:**
+
+
+
+ **Problem**: Authentication configuration doesn't match expected schema.
+
+ **Solution**:
+ 1. Use correct auth structure:
+ ```json
+ "auth": {
+ "username": "your-username",
+ "password": {
+ "env": "GERRIT_HTTP_PASSWORD"
+ }
+ }
+ ```
+ 2. Common mistakes:
+ ```json
+ // ❌ Wrong - password as string
+ "auth": {
+ "username": "user",
+ "password": "direct-password"
+ }
+
+ // ❌ Wrong - missing auth wrapper
+ "username": "user",
+ "password": {"env": "VAR"}
+ ```
+
+
+
+ **Problem**: Configuration includes properties not in the schema.
+
+ **Solution**:
+ 1. Check allowed properties in schema
+ 2. Remove any extra fields not in the official schema
+ 3. Validate configuration:
+ ```bash
+ # Use JSON schema validator if available
+ jsonschema -i config.json schemas/v3/gerrit.json
+ ```
+
+
+
+## Advanced Troubleshooting
+
+### Network and Connectivity Issues
+
+
+
+ **Problem**: SSL certificate validation failures.
+
+ **Solution**:
+ 1. Test with curl to verify SSL:
+ ```bash
+ curl -v "https://gerrit.example.com"
+ ```
+ 2. For self-signed certificates (not recommended for production):
+ ```bash
+ git -c http.sslVerify=false clone https://...
+ ```
+ 3. Install proper certificates on the system
+
+
+
+ **Problem**: Corporate proxy or firewall blocking connections.
+
+ **Solution**:
+ 1. Configure git proxy:
+ ```bash
+ git config --global http.proxy http://proxy.company.com:8080
+ ```
+ 2. Test direct connection vs proxy:
+ ```bash
+ curl --proxy http://proxy:8080 "https://gerrit.example.com/a/projects/"
+ ```
+
+
+
+### Debugging Tools and Scripts
+
+#### Complete Authentication Test Script
+
+Save as `gerrit-debug.ts` and run with `ts-node`:
+
+```typescript
+import * as https from 'https';
+import * as child_process from 'child_process';
+import * as url from 'url';
+
+// Configuration
+const GERRIT_URL = 'https://gerrit.example.com';
+const USERNAME = 'your-username';
+const HTTP_PASSWORD = 'your-http-password';
+const TEST_PROJECT = 'test-project-name';
+
+console.log('🔍 Gerrit Authentication Debug Tool\n');
+
+// Test 1: API Authentication
+console.log('1️⃣ Testing API Authentication...');
+const auth = 'Basic ' + Buffer.from(`${USERNAME}:${HTTP_PASSWORD}`).toString('base64');
+const parsedUrl = new url.URL(GERRIT_URL);
+const options: https.RequestOptions = {
+ hostname: parsedUrl.hostname,
+ port: parsedUrl.port || 443,
+ path: '/a/projects/',
+ method: 'GET',
+ headers: {
+ 'Authorization': auth,
+ 'Accept': 'application/json'
+ }
+};
+
+https.request(options, (res) => {
+ console.log(` Status: ${res.statusCode}`);
+ if (res.statusCode === 200) {
+ console.log(' ✅ API authentication successful');
+
+ let data = '';
+ res.on('data', chunk => data += chunk);
+ res.on('end', () => {
+ // Remove JSON prefix that Gerrit sometimes adds
+ const cleanData = data.replace(/^\)\]\}'\n/, '');
+ try {
+ const projects = JSON.parse(cleanData);
+ const projectCount = Object.keys(projects).length;
+ console.log(` 📊 Found ${projectCount} accessible projects`);
+
+ if (projectCount > 0) {
+ console.log(' 📋 First 5 projects:');
+ Object.keys(projects).slice(0, 5).forEach(project => {
+ console.log(` - ${project}`);
+ });
+ }
+ } catch (e) {
+ console.log(' ⚠️ Could not parse project list');
+ }
+ });
+ } else {
+ console.log(' ❌ API authentication failed');
+ }
+}).on('error', (err) => {
+ console.log(` ❌ API request failed: ${err.message}`);
+}).end();
+
+// Test 2: Git Clone Test
+console.log('\n2️⃣ Testing Git Clone...');
+const cloneUrl = `https://${USERNAME}@${parsedUrl.host}/a/${TEST_PROJECT}`;
+console.log(` URL: ${cloneUrl}`);
+
+// Note: This is a simplified test - in practice you'd need proper credential handling
+console.log(' ℹ️ Manual test: git clone ' + cloneUrl);
+
+// Test 3: Environment Variables
+console.log('\n3️⃣ Checking Environment...');
+console.log(` NODE_ENV: ${process.env.NODE_ENV || 'not set'}`);
+console.log(` GERRIT_HTTP_PASSWORD: ${process.env.GERRIT_HTTP_PASSWORD ? 'set (length: ' + process.env.GERRIT_HTTP_PASSWORD.length + ')' : 'not set'}`);
+
+console.log('\n🔍 Debug complete!');
+```
+
+#### Sourcebot Log Analysis
+
+Look for these specific log patterns:
+
+```bash
+# Authentication success
+grep "API authentication successful" sourcebot.log
+
+# Project discovery
+grep "Found .* accessible projects" sourcebot.log
+
+# Git clone operations
+grep "git clone" sourcebot.log
+
+# Error patterns
+grep -E "(401|unauthorized|authentication)" sourcebot.log -i
+```
+
+## Prevention and Best Practices
+
+### Security Best Practices
+
+1. **Credential Management**:
+ - Never commit HTTP passwords to version control
+ - Use environment variables or secret management systems
+ - Rotate HTTP passwords regularly
+
+2. **Least Privilege**:
+ - Create dedicated service accounts for Sourcebot
+ - Grant minimal necessary permissions
+ - Monitor access logs
+
+3. **Testing**:
+ - Always test authentication manually before configuring Sourcebot
+ - Keep backup authentication methods
+ - Document working configurations
+
+### Configuration Templates
+
+#### Development Environment
+```json
+{
+ "connections": {
+ "dev-gerrit": {
+ "type": "gerrit",
+ "url": "https://gerrit-dev.company.com",
+ "projects": ["dev-*"],
+ "auth": {
+ "username": "sourcebot-dev",
+ "password": {
+ "env": "GERRIT_DEV_PASSWORD"
+ }
+ }
+ }
+ }
+}
+```
+
+#### Production Environment
+```json
+{
+ "connections": {
+ "prod-gerrit": {
+ "type": "gerrit",
+ "url": "https://gerrit.company.com",
+ "projects": [
+ "critical-project",
+ "team-alpha/**"
+ ],
+ "exclude": {
+ "projects": ["**/archived/**"],
+ "hidden": true,
+ "readOnly": true
+ },
+ "auth": {
+ "username": "sourcebot-prod",
+ "password": {
+ "env": "GERRIT_PROD_PASSWORD"
+ }
+ }
+ }
+ }
+}
+```
+
+## Getting Help
+
+If you've followed this troubleshooting guide and still encounter issues:
+
+1. **Gather Debug Information**:
+ - Sourcebot logs with timestamps
+ - Your configuration (with credentials redacted)
+ - Gerrit version and authentication method
+ - Network topology (proxy, firewall, etc.)
+
+2. **Test Manually**:
+ - Run the debug script above
+ - Document exact error messages
+ - Note which step fails
+
+3. **Report Issues**:
+ - [Open a GitHub issue](https://github.com/sourcebot-dev/sourcebot/issues)
+ - Include debug information
+ - Describe expected vs actual behavior
+
+4. **Community Support**:
+ - [Join discussions](https://github.com/sourcebot-dev/sourcebot/discussions)
+ - Search existing issues for similar problems
+ - Share solutions that work for your environment
+
+---
+
+
+This troubleshooting guide is based on real-world testing and user reports. It will be updated as new scenarios are discovered and resolved.
+
\ No newline at end of file
diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx
index b7253259..89dd4e27 100644
--- a/docs/docs/connections/gerrit.mdx
+++ b/docs/docs/connections/gerrit.mdx
@@ -6,72 +6,356 @@ icon: crow
import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx'
-Authenticating with Gerrit is currently not supported. If you need this capability, please raise a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas).
+Sourcebot can sync code from self-hosted Gerrit instances, including both public and authenticated repositories.
-Sourcebot can sync code from self-hosted gerrit instances.
+## Authentication Support
+
+
+**Authentication Status**: Gerrit authentication is supported through HTTP Basic Auth using username and HTTP password credentials. This guide documents the verified authentication methods and implementation details.
+
+
+### Authentication Methods
+
+Gerrit supports multiple authentication methods with Sourcebot:
+
+1. **Public Access**: For publicly accessible projects (no authentication required)
+2. **HTTP Basic Auth**: Using Gerrit username and HTTP password
+3. **Cookie-based Auth**: Using Gerrit session cookies (advanced)
## Connecting to a Gerrit instance
-To connect to a gerrit instance, provide the `url` property to your config:
+### Basic Connection (Public Projects)
+
+For publicly accessible Gerrit projects:
+
+```json
+{
+ "type": "gerrit",
+ "url": "https://gerrit.example.com",
+ "projects": ["public-project-name"]
+}
+```
+
+### Authenticated Connection
+
+For private/authenticated Gerrit projects, you need to provide credentials:
```json
{
"type": "gerrit",
- "url": "https://gerrit.example.com"
- // .. rest of config ..
+ "url": "https://gerrit.example.com",
+ "projects": ["private-project-name"],
+ "auth": {
+ "username": "your-gerrit-username",
+ "password": {
+ "secret": "GERRIT_HTTP_PASSWORD"
+ }
+ }
+}
+```
+
+
+Use **HTTP Password**, not your Gerrit account password. Generate an HTTP password in Gerrit: **Settings → HTTP Credentials → Generate Password**.
+
+
+### Environment Variables
+
+Set your Gerrit HTTP password as an environment variable:
+
+```bash
+export GERRIT_HTTP_PASSWORD="your-generated-http-password"
+```
+
+When running with Docker:
+
+```bash
+docker run -e GERRIT_HTTP_PASSWORD="your-http-password" ...
+```
+
+## Authentication Setup Guide
+
+### Step 1: Generate HTTP Password in Gerrit
+
+1. Log into your Gerrit instance
+2. Go to **Settings** (top-right menu)
+3. Navigate to **HTTP Credentials**
+4. Click **Generate Password**
+5. Copy the generated password (this is your HTTP password)
+
+### Step 2: Test API Access
+
+Verify your credentials work with Gerrit's API:
+
+```bash
+curl -u "username:http-password" \
+ "https://gerrit.example.com/a/projects/?d"
+```
+
+Expected response: JSON list of projects you have access to.
+
+### Step 3: Test Git Clone Access
+
+Verify git clone works with your credentials:
+
+```bash
+git clone https://username@gerrit.example.com/a/project-name
+# When prompted, enter your HTTP password
+```
+
+
+**Special Characters in Passwords**: If your HTTP password contains special characters like `/`, `+`, or `=`, Sourcebot automatically handles URL encoding for git operations. No manual encoding is required on your part.
+
+
+### Step 4: Configure Sourcebot
+
+Add the authenticated connection to your `config.json`:
+
+```json
+{
+ "connections": {
+ "my-gerrit": {
+ "type": "gerrit",
+ "url": "https://gerrit.example.com",
+ "projects": ["project-name"],
+ "auth": {
+ "username": "your-username",
+ "password": {
+ "env": "GERRIT_HTTP_PASSWORD"
+ }
+ }
+ }
+ }
}
```
## Examples
+
+ ```json
+ {
+ "type": "gerrit",
+ "url": "https://gerrit.googlesource.com",
+ "projects": ["android/platform/build"]
+ }
+ ```
+
+
+
+ ```json
+ {
+ "type": "gerrit",
+ "url": "https://gerrit.company.com",
+ "projects": ["internal-project"],
+ "auth": {
+ "username": "john.doe",
+ "password": {
+ "env": "GERRIT_HTTP_PASSWORD"
+ }
+ }
+ }
+ ```
+
+
```json
{
"type": "gerrit",
"url": "https://gerrit.example.com",
- // Sync all repos under project1 and project2/sub-project
"projects": [
"project1/**",
"project2/sub-project/**"
- ]
+ ],
+ "auth": {
+ "username": "your-username",
+ "password": {
+ "env": "GERRIT_HTTP_PASSWORD"
+ }
+ }
}
```
+
```json
{
"type": "gerrit",
"url": "https://gerrit.example.com",
- // Sync all repos under project1 and project2/sub-project...
"projects": [
"project1/**",
"project2/sub-project/**"
],
- // ...except:
"exclude": {
- // any project that matches these glob patterns
"projects": [
"project1/foo-project",
"project2/sub-project/some-sub-folder/**"
],
-
- // projects that have state READ_ONLY
"readOnly": true,
-
- // projects that have state HIDDEN
"hidden": true
+ },
+ "auth": {
+ "username": "your-username",
+ "password": {
+ "env": "GERRIT_HTTP_PASSWORD"
+ }
}
}
```
-## Schema reference
+## Troubleshooting
+
+### Common Issues
+
+
+
+ **Symptoms**: Sourcebot logs show authentication errors or 401 status codes.
+
+ **Solutions**:
+ 1. Verify you're using the **HTTP password**, not your account password
+ 2. Test credentials manually:
+ ```bash
+ curl -u "username:password" "https://gerrit.example.com/a/projects/"
+ ```
+ 3. Check if your Gerrit username is correct
+ 4. Regenerate HTTP password in Gerrit settings
+ 5. Ensure the environment variable is properly set
+
+
+
+ **Symptoms**: Sourcebot connects but finds 0 repositories to sync.
+
+ **Solutions**:
+ 1. Verify project names exist and are accessible
+ 2. Check project permissions in Gerrit
+ 3. Test project access manually:
+ ```bash
+ curl -u "username:password" \
+ "https://gerrit.example.com/a/projects/project-name"
+ ```
+ 4. Use glob patterns if unsure of exact project names:
+ ```json
+ "projects": ["*"] // Sync all accessible projects
+ ```
+
+
+
+ **Symptoms**: Git clone operations fail during repository sync.
+
+ **Solutions**:
+ 1. Verify git clone works manually:
+ ```bash
+ git clone https://username@gerrit.example.com/a/project-name
+ ```
+ 2. Check network connectivity and firewall rules
+ 3. Ensure Gerrit server supports HTTPS
+ 4. Verify the `/a/` prefix is included in clone URLs
+
+
+
+ **Symptoms**: Config validation errors about additional properties.
+
+ **Solutions**:
+ 1. Ensure your configuration matches the schema exactly
+ 2. Check that all required fields are present
+ 3. Verify the `auth` object structure:
+ ```json
+ "auth": {
+ "username": "string",
+ "password": {
+ "env": "ENVIRONMENT_VARIABLE"
+ }
+ }
+ ```
+
+
+
+### Debug Steps
+
+1. **Enable Debug Logging**: Set log level to debug in Sourcebot configuration
+2. **Test API Access**: Verify Gerrit API responds correctly
+3. **Check Project Permissions**: Ensure your user has clone permissions
+4. **Validate Configuration**: Use JSON schema validation tools
+
+### Manual Testing Script
+
+You can test Gerrit authentication independently:
+
+```typescript
+// gerrit-test.ts - Test script for Gerrit authentication
+import * as https from 'https';
+import * as child_process from 'child_process';
+
+const GERRIT_URL = 'https://gerrit.example.com';
+const USERNAME = 'your-username';
+const HTTP_PASSWORD = 'your-http-password';
+const PROJECT = 'project-name';
+
+// Test API access
+const auth = 'Basic ' + Buffer.from(`${USERNAME}:${HTTP_PASSWORD}`).toString('base64');
+const options = {
+ hostname: new URL(GERRIT_URL).hostname,
+ path: '/a/projects/',
+ headers: { 'Authorization': auth }
+};
+
+https.get(options, (res) => {
+ console.log(`API Status: ${res.statusCode}`);
+ if (res.statusCode === 200) {
+ console.log('✅ API authentication successful');
+
+ // Test git clone
+ const cloneUrl = `https://${USERNAME}@${new URL(GERRIT_URL).host}/a/${PROJECT}`;
+ console.log(`Testing git clone: ${cloneUrl}`);
+
+ // Note: This requires proper credential handling in production
+ } else {
+ console.log('❌ API authentication failed');
+ }
+});
+```
+
+## Implementation Details
+
+### URL Structure
+
+Gerrit uses a specific URL structure for authenticated access:
+
+- **API Access**: `https://gerrit.example.com/a/endpoint`
+- **Git Clone**: `https://username@gerrit.example.com/a/project-name`
+
+The `/a/` prefix is crucial for authenticated operations.
+
+### Credential Flow
+
+1. Sourcebot validates configuration and credentials
+2. API call to `/a/projects/` to list accessible projects
+3. For each project, git clone using `https://username@host/a/project`
+4. Git authentication handled via URL-embedded username and credential helpers
+
+### Security Considerations
+
+- Store HTTP passwords in environment variables, never in config files
+- Use least-privilege Gerrit accounts for Sourcebot
+- Regularly rotate HTTP passwords
+- Monitor access logs for unusual activity
+
+## Schema Reference
[schemas/v3/gerrit.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/gerrit.json)
-
\ No newline at end of file
+
+
+## Additional Resources
+
+- [Gerrit HTTP Password Documentation](https://gerrit-review.googlesource.com/Documentation/user-upload.html#http)
+- [Gerrit REST API](https://gerrit-review.googlesource.com/Documentation/rest-api.html)
+- [Git Credential Helpers](https://git-scm.com/docs/gitcredentials)
+
+
+This documentation is based on extensive testing with Gerrit authentication. If you encounter issues not covered here, please [open an issue](https://github.com/sourcebot-dev/sourcebot/issues) with your specific configuration and error details.
+
\ No newline at end of file
diff --git a/docs/snippets/schemas/v3/bitbucket.schema.mdx b/docs/snippets/schemas/v3/bitbucket.schema.mdx
index 829d0254..54d13ff8 100644
--- a/docs/snippets/schemas/v3/bitbucket.schema.mdx
+++ b/docs/snippets/schemas/v3/bitbucket.schema.mdx
@@ -21,6 +21,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/docs/snippets/schemas/v3/connection.schema.mdx b/docs/snippets/schemas/v3/connection.schema.mdx
index 9731bdeb..7d4f86d9 100644
--- a/docs/snippets/schemas/v3/connection.schema.mdx
+++ b/docs/snippets/schemas/v3/connection.schema.mdx
@@ -21,6 +21,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -234,6 +239,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -436,6 +446,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -597,6 +612,68 @@
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
+ "auth": {
+ "type": "object",
+ "description": "Authentication configuration for Gerrit",
+ "properties": {
+ "username": {
+ "type": "string",
+ "description": "Gerrit username for authentication",
+ "examples": [
+ "john.doe"
+ ]
+ },
+ "password": {
+ "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.",
+ "examples": [
+ {
+ "env": "GERRIT_HTTP_PASSWORD"
+ },
+ {
+ "secret": "GERRIT_PASSWORD_SECRET"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "additionalProperties": false
+ },
"projects": {
"type": "array",
"items": {
@@ -667,6 +744,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/docs/snippets/schemas/v3/gerrit.schema.mdx b/docs/snippets/schemas/v3/gerrit.schema.mdx
index 561bda80..2d2fbb32 100644
--- a/docs/snippets/schemas/v3/gerrit.schema.mdx
+++ b/docs/snippets/schemas/v3/gerrit.schema.mdx
@@ -18,6 +18,68 @@
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
+ "auth": {
+ "type": "object",
+ "description": "Authentication configuration for Gerrit",
+ "properties": {
+ "username": {
+ "type": "string",
+ "description": "Gerrit username for authentication",
+ "examples": [
+ "john.doe"
+ ]
+ },
+ "password": {
+ "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.",
+ "examples": [
+ {
+ "env": "GERRIT_HTTP_PASSWORD"
+ },
+ {
+ "secret": "GERRIT_PASSWORD_SECRET"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "additionalProperties": false
+ },
"projects": {
"type": "array",
"items": {
diff --git a/docs/snippets/schemas/v3/gitea.schema.mdx b/docs/snippets/schemas/v3/gitea.schema.mdx
index f236e3fe..31f160c2 100644
--- a/docs/snippets/schemas/v3/gitea.schema.mdx
+++ b/docs/snippets/schemas/v3/gitea.schema.mdx
@@ -17,6 +17,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/docs/snippets/schemas/v3/github.schema.mdx b/docs/snippets/schemas/v3/github.schema.mdx
index 1858eee8..5521b324 100644
--- a/docs/snippets/schemas/v3/github.schema.mdx
+++ b/docs/snippets/schemas/v3/github.schema.mdx
@@ -17,6 +17,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/docs/snippets/schemas/v3/gitlab.schema.mdx b/docs/snippets/schemas/v3/gitlab.schema.mdx
index feadeaac..be0c00d3 100644
--- a/docs/snippets/schemas/v3/gitlab.schema.mdx
+++ b/docs/snippets/schemas/v3/gitlab.schema.mdx
@@ -17,6 +17,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx
index 79bcda80..32b9b9e3 100644
--- a/docs/snippets/schemas/v3/index.schema.mdx
+++ b/docs/snippets/schemas/v3/index.schema.mdx
@@ -260,6 +260,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -473,6 +478,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -675,6 +685,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -836,6 +851,68 @@
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
+ "auth": {
+ "type": "object",
+ "description": "Authentication configuration for Gerrit",
+ "properties": {
+ "username": {
+ "type": "string",
+ "description": "Gerrit username for authentication",
+ "examples": [
+ "john.doe"
+ ]
+ },
+ "password": {
+ "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.",
+ "examples": [
+ {
+ "env": "GERRIT_HTTP_PASSWORD"
+ },
+ {
+ "secret": "GERRIT_PASSWORD_SECRET"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "additionalProperties": false
+ },
"projects": {
"type": "array",
"items": {
@@ -906,6 +983,11 @@
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/docs/snippets/schemas/v3/shared.schema.mdx b/docs/snippets/schemas/v3/shared.schema.mdx
index 97fdbabf..7d05dfa7 100644
--- a/docs/snippets/schemas/v3/shared.schema.mdx
+++ b/docs/snippets/schemas/v3/shared.schema.mdx
@@ -6,6 +6,11 @@
"definitions": {
"Token": {
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts
index f025bdf7..7c143a9f 100644
--- a/packages/backend/src/connectionManager.ts
+++ b/packages/backend/src/connectionManager.ts
@@ -172,7 +172,7 @@ export class ConnectionManager implements IConnectionManager {
return await compileGiteaConfig(config, job.data.connectionId, orgId, this.db);
}
case 'gerrit': {
- return await compileGerritConfig(config, job.data.connectionId, orgId);
+ return await compileGerritConfig(config, job.data.connectionId, orgId, this.db);
}
case 'bitbucket': {
return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db);
diff --git a/packages/backend/src/gerrit.test.ts b/packages/backend/src/gerrit.test.ts
new file mode 100644
index 00000000..8bdf5a6f
--- /dev/null
+++ b/packages/backend/src/gerrit.test.ts
@@ -0,0 +1,950 @@
+import { expect, test, vi, beforeEach, afterEach } from 'vitest';
+import { shouldExcludeProject, GerritProject, getGerritReposFromConfig } from './gerrit';
+import { GerritConnectionConfig } from '@sourcebot/schemas/v3/index.type';
+import { PrismaClient } from '@sourcebot/db';
+import { BackendException, BackendError } from '@sourcebot/error';
+import fetch from 'cross-fetch';
+
+// Mock dependencies
+vi.mock('cross-fetch');
+vi.mock('./logger.js', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+ })
+}));
+vi.mock('./utils.js', async () => {
+ const actual = await vi.importActual('./utils.js');
+ return {
+ ...actual,
+ measure: vi.fn(async (fn) => {
+ const result = await fn();
+ return { data: result, durationMs: 100 };
+ }),
+ fetchWithRetry: vi.fn(async (fn) => {
+ const result = await fn();
+ return result;
+ }),
+ getTokenFromConfig: vi.fn().mockImplementation(async (token) => {
+ // If token is a string, return it directly (mimicking actual behavior)
+ if (typeof token === 'string') {
+ return token;
+ }
+ // For objects (env/secret), return mock value
+ return 'mock-password';
+ }),
+ };
+});
+vi.mock('@sentry/node', () => ({
+ captureException: vi.fn(),
+}));
+
+const mockFetch = vi.mocked(fetch);
+const mockDb = {} as PrismaClient;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+test('shouldExcludeProject returns false when the project is not excluded', () => {
+ const project: GerritProject = {
+ name: 'test/project',
+ id: 'test%2Fproject',
+ state: 'ACTIVE'
+ };
+
+ expect(shouldExcludeProject({
+ project,
+ })).toBe(false);
+});
+
+test('shouldExcludeProject returns true for special Gerrit projects', () => {
+ const specialProjects = [
+ 'All-Projects',
+ 'All-Users',
+ 'All-Avatars',
+ 'All-Archived-Projects'
+ ];
+
+ specialProjects.forEach(projectName => {
+ const project: GerritProject = {
+ name: projectName,
+ id: projectName.replace(/-/g, '%2D'),
+ state: 'ACTIVE'
+ };
+
+ expect(shouldExcludeProject({ project })).toBe(true);
+ });
+});
+
+test('shouldExcludeProject handles readOnly projects correctly', () => {
+ const project: GerritProject = {
+ name: 'test/readonly-project',
+ id: 'test%2Freadonly-project',
+ state: 'READ_ONLY'
+ };
+
+ expect(shouldExcludeProject({ project })).toBe(false);
+ expect(shouldExcludeProject({
+ project,
+ exclude: { readOnly: true }
+ })).toBe(true);
+ expect(shouldExcludeProject({
+ project,
+ exclude: { readOnly: false }
+ })).toBe(false);
+});
+
+test('shouldExcludeProject handles hidden projects correctly', () => {
+ const project: GerritProject = {
+ name: 'test/hidden-project',
+ id: 'test%2Fhidden-project',
+ state: 'HIDDEN'
+ };
+
+ expect(shouldExcludeProject({ project })).toBe(false);
+ expect(shouldExcludeProject({
+ project,
+ exclude: { hidden: true }
+ })).toBe(true);
+ expect(shouldExcludeProject({
+ project,
+ exclude: { hidden: false }
+ })).toBe(false);
+});
+
+test('shouldExcludeProject handles exclude.projects correctly', () => {
+ const project: GerritProject = {
+ name: 'test/example-project',
+ id: 'test%2Fexample-project',
+ state: 'ACTIVE'
+ };
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: []
+ }
+ })).toBe(false);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['test/example-project']
+ }
+ })).toBe(true);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['test/*']
+ }
+ })).toBe(true);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['other/project']
+ }
+ })).toBe(false);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['test/different-*']
+ }
+ })).toBe(false);
+});
+
+test('shouldExcludeProject handles complex glob patterns correctly', () => {
+ const project: GerritProject = {
+ name: 'android/platform/build',
+ id: 'android%2Fplatform%2Fbuild',
+ state: 'ACTIVE'
+ };
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['android/**']
+ }
+ })).toBe(true);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['android/platform/*']
+ }
+ })).toBe(true);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['android/*/build']
+ }
+ })).toBe(true);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['ios/**']
+ }
+ })).toBe(false);
+});
+
+test('shouldExcludeProject handles multiple exclusion criteria', () => {
+ const readOnlyProject: GerritProject = {
+ name: 'archived/old-project',
+ id: 'archived%2Fold-project',
+ state: 'READ_ONLY'
+ };
+
+ expect(shouldExcludeProject({
+ project: readOnlyProject,
+ exclude: {
+ readOnly: true,
+ projects: ['archived/*']
+ }
+ })).toBe(true);
+
+ const hiddenProject: GerritProject = {
+ name: 'secret/internal-project',
+ id: 'secret%2Finternal-project',
+ state: 'HIDDEN'
+ };
+
+ expect(shouldExcludeProject({
+ project: hiddenProject,
+ exclude: {
+ hidden: true,
+ projects: ['public/*']
+ }
+ })).toBe(true);
+});
+
+test('shouldExcludeProject handles edge cases', () => {
+ // Test with minimal project data
+ const minimalProject: GerritProject = {
+ name: 'minimal',
+ id: 'minimal'
+ };
+
+ expect(shouldExcludeProject({ project: minimalProject })).toBe(false);
+
+ // Test with empty exclude object
+ expect(shouldExcludeProject({
+ project: minimalProject,
+ exclude: {}
+ })).toBe(false);
+
+ // Test with undefined exclude
+ expect(shouldExcludeProject({
+ project: minimalProject,
+ exclude: undefined
+ })).toBe(false);
+});
+
+test('shouldExcludeProject handles case sensitivity in project names', () => {
+ const project: GerritProject = {
+ name: 'Test/Example-Project',
+ id: 'Test%2FExample-Project',
+ state: 'ACTIVE'
+ };
+
+ // micromatch should handle case sensitivity based on its default behavior
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['test/example-project']
+ }
+ })).toBe(false);
+
+ expect(shouldExcludeProject({
+ project,
+ exclude: {
+ projects: ['Test/Example-Project']
+ }
+ })).toBe(true);
+});
+
+test('shouldExcludeProject handles project with web_links', () => {
+ const projectWithLinks: GerritProject = {
+ name: 'test/project-with-links',
+ id: 'test%2Fproject-with-links',
+ state: 'ACTIVE',
+ web_links: [
+ {
+ name: 'browse',
+ url: 'https://gerrit.example.com/plugins/gitiles/test/project-with-links'
+ }
+ ]
+ };
+
+ expect(shouldExcludeProject({ project: projectWithLinks })).toBe(false);
+
+ expect(shouldExcludeProject({
+ project: projectWithLinks,
+ exclude: {
+ projects: ['test/*']
+ }
+ })).toBe(true);
+});
+
+// === HTTP Authentication Tests ===
+
+test('getGerritReposFromConfig handles public access without authentication', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project']
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({
+ name: 'test-project',
+ id: 'test%2Dproject',
+ state: 'ACTIVE'
+ });
+
+ // Verify that public endpoint was called (no /a/ prefix)
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://gerrit.example.com/projects/?S=0',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ 'Accept': 'application/json',
+ 'User-Agent': 'Sourcebot-Gerrit-Client/1.0'
+ })
+ })
+ );
+
+ // Verify no Authorization header for public access
+ const [, options] = mockFetch.mock.calls[0];
+ const headers = options?.headers as Record;
+ expect(headers).not.toHaveProperty('Authorization');
+});
+
+test('getGerritReposFromConfig handles authenticated access with HTTP Basic Auth', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'testuser',
+ password: 'test-password'
+ }
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({
+ name: 'test-project',
+ id: 'test%2Dproject',
+ state: 'ACTIVE'
+ });
+
+ // Verify that authenticated endpoint was called (with /a/ prefix)
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://gerrit.example.com/a/projects/?S=0',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ 'Accept': 'application/json',
+ 'User-Agent': 'Sourcebot-Gerrit-Client/1.0',
+ 'Authorization': expect.stringMatching(/^Basic /)
+ })
+ })
+ );
+
+ // Verify that Authorization header is present and properly formatted
+ const [, options] = mockFetch.mock.calls[0];
+ const headers = options?.headers as Record;
+ const authHeader = headers?.Authorization;
+
+ // Verify Basic Auth format exists
+ expect(authHeader).toMatch(/^Basic [A-Za-z0-9+/]+=*$/);
+
+ // Verify it contains the username (password will be mocked)
+ const encodedCredentials = authHeader?.replace('Basic ', '');
+ const decodedCredentials = Buffer.from(encodedCredentials || '', 'base64').toString();
+ expect(decodedCredentials).toContain('testuser:');
+});
+
+test('getGerritReposFromConfig handles environment variable password', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'testuser',
+ password: { env: 'GERRIT_HTTP_PASSWORD' }
+ }
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+
+ // Verify that getTokenFromConfig was called for environment variable
+ const { getTokenFromConfig } = await import('./utils.js');
+ expect(getTokenFromConfig).toHaveBeenCalledWith(
+ { env: 'GERRIT_HTTP_PASSWORD' },
+ 1,
+ mockDb,
+ expect.any(Object)
+ );
+});
+
+test('getGerritReposFromConfig handles secret-based password', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'testuser',
+ password: { secret: 'GERRIT_SECRET' }
+ }
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+
+ // Verify that getTokenFromConfig was called for secret
+ const { getTokenFromConfig } = await import('./utils.js');
+ expect(getTokenFromConfig).toHaveBeenCalledWith(
+ { secret: 'GERRIT_SECRET' },
+ 1,
+ mockDb,
+ expect.any(Object)
+ );
+});
+
+test('getGerritReposFromConfig handles authentication errors', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'testuser',
+ password: 'invalid-password'
+ }
+ };
+
+ const mockResponse = {
+ ok: false,
+ status: 401,
+ text: () => Promise.resolve('Unauthorized'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow(BackendException);
+});
+
+test('getGerritReposFromConfig handles network errors', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project']
+ };
+
+ const networkError = new Error('Network error');
+ (networkError as any).code = 'ECONNREFUSED';
+ mockFetch.mockRejectedValueOnce(networkError);
+
+ await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow(BackendException);
+});
+
+test('getGerritReposFromConfig handles malformed JSON response', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project']
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve('invalid json'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow();
+});
+
+test('getGerritReposFromConfig strips XSSI protection prefix correctly', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project']
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({
+ name: 'test-project',
+ id: 'test%2Dproject',
+ state: 'ACTIVE'
+ });
+});
+
+test('getGerritReposFromConfig handles pagination correctly', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com'
+ };
+
+ // First page response
+ const firstPageResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"project1": {"id": "project1", "_more_projects": true}}'),
+ };
+
+ // Second page response
+ const secondPageResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"project2": {"id": "project2"}}'),
+ };
+
+ mockFetch
+ .mockResolvedValueOnce(firstPageResponse as any)
+ .mockResolvedValueOnce(secondPageResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].name).toBe('project1');
+ expect(result[1].name).toBe('project2');
+
+ // Verify pagination calls
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+ expect(mockFetch).toHaveBeenNthCalledWith(1,
+ 'https://gerrit.example.com/projects/?S=0',
+ expect.any(Object)
+ );
+ expect(mockFetch).toHaveBeenNthCalledWith(2,
+ 'https://gerrit.example.com/projects/?S=1',
+ expect.any(Object)
+ );
+});
+
+test('getGerritReposFromConfig filters projects based on config.projects', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-*'] // Only projects matching this pattern
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}, "other-project": {"id": "other%2Dproject"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('test-project');
+});
+
+test('getGerritReposFromConfig excludes projects based on config.exclude', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ exclude: {
+ readOnly: true,
+ hidden: true,
+ projects: ['excluded-*']
+ }
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{' +
+ '"active-project": {"id": "active%2Dproject", "state": "ACTIVE"}, ' +
+ '"readonly-project": {"id": "readonly%2Dproject", "state": "READ_ONLY"}, ' +
+ '"hidden-project": {"id": "hidden%2Dproject", "state": "HIDDEN"}, ' +
+ '"excluded-project": {"id": "excluded%2Dproject", "state": "ACTIVE"}' +
+ '}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('active-project');
+});
+
+test('getGerritReposFromConfig handles trailing slash in URL correctly', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com/', // Note trailing slash
+ projects: ['test-project']
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ await getGerritReposFromConfig(config, 1, mockDb);
+
+ // Verify URL is normalized correctly
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://gerrit.example.com/projects/?S=0',
+ expect.any(Object)
+ );
+});
+
+test('getGerritReposFromConfig handles projects with web_links', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project']
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{' +
+ '"test-project": {' +
+ '"id": "test%2Dproject", ' +
+ '"state": "ACTIVE", ' +
+ '"web_links": [{"name": "browse", "url": "https://gerrit.example.com/plugins/gitiles/test-project"}]' +
+ '}' +
+ '}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({
+ name: 'test-project',
+ id: 'test%2Dproject',
+ state: 'ACTIVE',
+ web_links: [
+ {
+ name: 'browse',
+ url: 'https://gerrit.example.com/plugins/gitiles/test-project'
+ }
+ ]
+ });
+});
+
+test('getGerritReposFromConfig handles authentication credential retrieval errors', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'testuser',
+ password: { env: 'MISSING_ENV_VAR' }
+ }
+ };
+
+ // Mock getTokenFromConfig to throw an error
+ const { getTokenFromConfig } = await import('./utils.js');
+ vi.mocked(getTokenFromConfig).mockRejectedValueOnce(new Error('Environment variable not found'));
+
+ await expect(getGerritReposFromConfig(config, 1, mockDb)).rejects.toThrow('Environment variable not found');
+});
+
+test('getGerritReposFromConfig handles empty projects response', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com'
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(0);
+});
+
+test('getGerritReposFromConfig handles response without XSSI prefix', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project']
+ };
+
+ // Response without XSSI prefix (some Gerrit instances might not include it)
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve('{"test-project": {"id": "test%2Dproject", "state": "ACTIVE"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({
+ name: 'test-project',
+ id: 'test%2Dproject',
+ state: 'ACTIVE'
+ });
+});
+
+test('getGerritReposFromConfig validates Basic Auth header format', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'user@example.com',
+ password: 'complex-password-123!'
+ }
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ await getGerritReposFromConfig(config, 1, mockDb);
+
+ const [, options] = mockFetch.mock.calls[0];
+ const headers = options?.headers as Record;
+ const authHeader = headers?.Authorization;
+
+ // Verify Basic Auth format
+ expect(authHeader).toMatch(/^Basic [A-Za-z0-9+/]+=*$/);
+
+ // Verify credentials can be decoded and contain the username
+ const encodedCredentials = authHeader?.replace('Basic ', '');
+ const decodedCredentials = Buffer.from(encodedCredentials || '', 'base64').toString();
+ expect(decodedCredentials).toContain('user@example.com:');
+});
+
+test('getGerritReposFromConfig handles special characters in project names', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com'
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{' +
+ '"project/with-dashes": {"id": "project%2Fwith-dashes"}, ' +
+ '"project_with_underscores": {"id": "project_with_underscores"}, ' +
+ '"project.with.dots": {"id": "project.with.dots"}, ' +
+ '"project with spaces": {"id": "project%20with%20spaces"}' +
+ '}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(4);
+ expect(result.map(p => p.name)).toEqual([
+ 'project/with-dashes',
+ 'project_with_underscores',
+ 'project.with.dots',
+ 'project with spaces'
+ ]);
+});
+
+test('getGerritReposFromConfig handles large project responses', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com'
+ };
+
+ // Generate a large response with many projects
+ const projects: Record = {};
+ for (let i = 0; i < 100; i++) {
+ projects[`project-${i}`] = {
+ id: `project%2D${i}`,
+ state: 'ACTIVE'
+ };
+ }
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n' + JSON.stringify(projects)),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ const result = await getGerritReposFromConfig(config, 1, mockDb);
+
+ expect(result).toHaveLength(100);
+ expect(result[0].name).toBe('project-0');
+ expect(result[99].name).toBe('project-99');
+});
+
+test('getGerritReposFromConfig handles mixed authentication scenarios', async () => {
+ // Test that the function correctly chooses authenticated vs public endpoints
+ const publicConfig: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['public-project']
+ };
+
+ const authenticatedConfig: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['private-project'],
+ auth: {
+ username: 'testuser',
+ password: 'test-password'
+ }
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'),
+ };
+
+ // Test public access
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+ await getGerritReposFromConfig(publicConfig, 1, mockDb);
+
+ expect(mockFetch).toHaveBeenLastCalledWith(
+ 'https://gerrit.example.com/projects/?S=0',
+ expect.objectContaining({
+ headers: expect.not.objectContaining({
+ Authorization: expect.any(String)
+ })
+ })
+ );
+
+ // Test authenticated access
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+ await getGerritReposFromConfig(authenticatedConfig, 1, mockDb);
+
+ expect(mockFetch).toHaveBeenLastCalledWith(
+ 'https://gerrit.example.com/a/projects/?S=0',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: expect.stringMatching(/^Basic /)
+ })
+ })
+ );
+});
+
+test('getGerritReposFromConfig handles passwords with special characters', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'user@example.com',
+ password: { env: 'GERRIT_SPECIAL_PASSWORD' }
+ }
+ };
+
+ // Mock getTokenFromConfig to return password with special characters
+ const { getTokenFromConfig } = await import('./utils.js');
+ vi.mocked(getTokenFromConfig).mockResolvedValueOnce('pass/with+special=chars');
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'),
+ };
+ mockFetch.mockResolvedValueOnce(mockResponse as any);
+
+ await getGerritReposFromConfig(config, 1, mockDb);
+
+ const [, options] = mockFetch.mock.calls[0];
+ const headers = options?.headers as Record;
+ const authHeader = headers?.Authorization;
+
+ // Verify Basic Auth format
+ expect(authHeader).toMatch(/^Basic [A-Za-z0-9+/]+=*$/);
+
+ // Verify credentials can be decoded and contain the special characters
+ const encodedCredentials = authHeader?.replace('Basic ', '');
+ const decodedCredentials = Buffer.from(encodedCredentials || '', 'base64').toString();
+ expect(decodedCredentials).toContain('user@example.com:pass/with+special=chars');
+
+ // Verify that getTokenFromConfig was called for the password with special characters
+ expect(getTokenFromConfig).toHaveBeenCalledWith(
+ { env: 'GERRIT_SPECIAL_PASSWORD' },
+ 1,
+ mockDb,
+ expect.any(Object)
+ );
+});
+
+test('getGerritReposFromConfig handles concurrent authentication requests', async () => {
+ const config: GerritConnectionConfig = {
+ type: 'gerrit',
+ url: 'https://gerrit.example.com',
+ projects: ['test-project'],
+ auth: {
+ username: 'testuser',
+ password: { env: 'GERRIT_HTTP_PASSWORD' }
+ }
+ };
+
+ const mockResponse = {
+ ok: true,
+ text: () => Promise.resolve(')]}\'\n{"test-project": {"id": "test%2Dproject"}}'),
+ };
+
+ // Mock multiple concurrent calls
+ mockFetch.mockResolvedValue(mockResponse as any);
+
+ const promises = Array(5).fill(null).map(() =>
+ getGerritReposFromConfig(config, 1, mockDb)
+ );
+
+ const results = await Promise.all(promises);
+
+ // All should succeed
+ expect(results).toHaveLength(5);
+ results.forEach(result => {
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('test-project');
+ });
+
+ // Verify getTokenFromConfig was called for each request
+ const { getTokenFromConfig } = await import('./utils.js');
+ expect(getTokenFromConfig).toHaveBeenCalledTimes(5);
+});
\ No newline at end of file
diff --git a/packages/backend/src/gerrit.ts b/packages/backend/src/gerrit.ts
index 25e3cfa7..cd85e49e 100644
--- a/packages/backend/src/gerrit.ts
+++ b/packages/backend/src/gerrit.ts
@@ -1,10 +1,11 @@
import fetch from 'cross-fetch';
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/index.type"
-import { createLogger } from '@sourcebot/logger';
+import { createLogger } from "@sourcebot/logger";
import micromatch from "micromatch";
-import { measure, fetchWithRetry } from './utils.js';
+import { measure, fetchWithRetry, getTokenFromConfig } from './utils.js';
import { BackendError } from '@sourcebot/error';
import { BackendException } from '@sourcebot/error';
+import { PrismaClient } from "@sourcebot/db";
import * as Sentry from "@sentry/node";
// https://gerrit-review.googlesource.com/Documentation/rest-api.html
@@ -21,7 +22,7 @@ interface GerritProjectInfo {
web_links?: GerritWebLink[];
}
-interface GerritProject {
+export interface GerritProject {
name: string;
id: string;
state?: GerritProjectState;
@@ -33,15 +34,40 @@ interface GerritWebLink {
url: string;
}
+interface GerritAuthConfig {
+ username: string;
+ password: string;
+}
+
const logger = createLogger('gerrit');
-export const getGerritReposFromConfig = async (config: GerritConnectionConfig): Promise => {
+export const getGerritReposFromConfig = async (
+ config: GerritConnectionConfig,
+ orgId: number,
+ db: PrismaClient
+): Promise => {
const url = config.url.endsWith('/') ? config.url : `${config.url}/`;
const hostname = new URL(config.url).hostname;
+ // Get authentication credentials if provided
+ let auth: GerritAuthConfig | undefined;
+ if (config.auth) {
+ try {
+ const password = await getTokenFromConfig(config.auth.password, orgId, db, logger);
+ auth = {
+ username: config.auth.username,
+ password: password
+ };
+ logger.debug(`Using authentication for Gerrit instance ${hostname} with username: ${auth.username}`);
+ } catch (error) {
+ logger.error(`Failed to retrieve Gerrit authentication credentials: ${error}`);
+ throw error;
+ }
+ }
+
let { durationMs, data: projects } = await measure(async () => {
try {
- const fetchFn = () => fetchAllProjects(url);
+ const fetchFn = () => fetchAllProjects(url, auth);
return fetchWithRetry(fetchFn, `projects from ${url}`, logger);
} catch (err) {
Sentry.captureException(err);
@@ -81,23 +107,43 @@ export const getGerritReposFromConfig = async (config: GerritConnectionConfig):
return projects;
};
-const fetchAllProjects = async (url: string): Promise => {
- const projectsEndpoint = `${url}projects/`;
+const fetchAllProjects = async (url: string, auth?: GerritAuthConfig): Promise => {
+ // Use authenticated endpoint if auth is provided, otherwise use public endpoint
+ const projectsEndpoint = auth ? `${url}a/projects/` : `${url}projects/`;
let allProjects: GerritProject[] = [];
let start = 0; // Start offset for pagination
let hasMoreProjects = true;
+ // Prepare authentication headers if credentials are provided
+ const headers: Record = {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Sourcebot-Gerrit-Client/1.0'
+ };
+
+ if (auth) {
+ const authString = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
+ headers['Authorization'] = `Basic ${authString}`;
+ logger.debug(`Using HTTP Basic authentication for user: ${auth.username}`);
+ }
+
while (hasMoreProjects) {
const endpointWithParams = `${projectsEndpoint}?S=${start}`;
- logger.debug(`Fetching projects from Gerrit at ${endpointWithParams}`);
+ logger.debug(`Fetching projects from Gerrit at ${endpointWithParams} ${auth ? '(authenticated)' : '(public)'}`);
let response: Response;
try {
- response = await fetch(endpointWithParams);
+ response = await fetch(endpointWithParams, {
+ method: 'GET',
+ headers
+ });
+
if (!response.ok) {
- logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}`);
+ const errorText = await response.text().catch(() => 'Unknown error');
+ logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}: ${errorText}`);
const e = new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, {
status: response.status,
+ url: endpointWithParams,
+ authenticated: !!auth
});
Sentry.captureException(e);
throw e;
@@ -112,11 +158,14 @@ const fetchAllProjects = async (url: string): Promise => {
logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${status}`);
throw new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, {
status: status,
+ url: endpointWithParams,
+ authenticated: !!auth
});
}
const text = await response.text();
- const jsonText = text.replace(")]}'\n", ''); // Remove XSSI protection prefix
+ // Remove XSSI protection prefix that Gerrit adds to JSON responses
+ const jsonText = text.replace(/^\)\]\}'\n/, '');
const data: GerritProjects = JSON.parse(jsonText);
// Add fetched projects to allProjects
@@ -138,10 +187,11 @@ const fetchAllProjects = async (url: string): Promise => {
start += Object.keys(data).length;
}
+ logger.debug(`Successfully fetched ${allProjects.length} projects ${auth ? '(authenticated)' : '(public)'}`);
return allProjects;
};
-const shouldExcludeProject = ({
+export const shouldExcludeProject = ({
project,
exclude,
}: {
diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts
index 376ed039..02fb742d 100644
--- a/packages/backend/src/github.ts
+++ b/packages/backend/src/github.ts
@@ -66,7 +66,7 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
if (isHttpError(error, 401)) {
const e = new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, {
- ...(config.token && 'secret' in config.token ? {
+ ...(config.token && typeof config.token === 'object' && 'secret' in config.token ? {
secretKey: config.token.secret,
} : {}),
});
diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts
index 8722b928..d8995b56 100644
--- a/packages/backend/src/repoCompileUtils.ts
+++ b/packages/backend/src/repoCompileUtils.ts
@@ -246,16 +246,21 @@ export const compileGiteaConfig = async (
export const compileGerritConfig = async (
config: GerritConnectionConfig,
connectionId: number,
- orgId: number) => {
+ orgId: number,
+ db: PrismaClient) => {
- const gerritRepos = await getGerritReposFromConfig(config);
+ const gerritRepos = await getGerritReposFromConfig(config, orgId, db);
const hostUrl = config.url;
const repoNameRoot = new URL(hostUrl)
.toString()
.replace(/^https?:\/\//, '');
const repos = gerritRepos.map((project) => {
- const cloneUrl = new URL(path.join(hostUrl, encodeURIComponent(project.name)));
+ // Use authenticated clone URL (/a/) if auth is configured, otherwise use public URL
+ const cloneUrlPath = config.auth ?
+ path.join(hostUrl, 'a', encodeURIComponent(project.name)) :
+ path.join(hostUrl, encodeURIComponent(project.name));
+ const cloneUrl = new URL(cloneUrlPath);
const repoDisplayName = project.name;
const repoName = path.join(repoNameRoot, repoDisplayName);
diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts
index 50dc0478..c76eb46f 100644
--- a/packages/backend/src/repoManager.ts
+++ b/packages/backend/src/repoManager.ts
@@ -2,7 +2,7 @@ import { Job, Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { createLogger } from "@sourcebot/logger";
import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
-import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
+import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, GerritConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { AppContext, Settings, repoMetadataSchema } from "./types.js";
import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js";
import { cloneRepository, fetchRepository, upsertGitConfig } from "./git.js";
@@ -220,6 +220,17 @@ export class RepoManager implements IRepoManager {
}
}
}
+
+ else if (connection.connectionType === 'gerrit') {
+ const config = connection.config as unknown as GerritConnectionConfig;
+ if (config.auth) {
+ const password = await getTokenFromConfig(config.auth.password, connection.orgId, db, logger);
+ return {
+ username: config.auth.username,
+ password: password,
+ }
+ }
+ }
}
return undefined;
@@ -260,10 +271,10 @@ export class RepoManager implements IRepoManager {
// we only have a password, we set the username to the password.
// @see: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBArgJwDYwLwzAUwO4wKoBKAMgBQBEAFlFAA4QBcA9I5gB4CGAtjUpgHShOZADQBKANwAoREj412ECNhAIAJmhhl5i5WrJTQkELz5IQAcxIy+UEAGUoCAJZhLo0UA
if (!auth.username) {
- cloneUrl.username = auth.password;
+ cloneUrl.username = encodeURIComponent(auth.password);
} else {
- cloneUrl.username = auth.username;
- cloneUrl.password = auth.password;
+ cloneUrl.username = encodeURIComponent(auth.username);
+ cloneUrl.password = encodeURIComponent(auth.password);
}
}
diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts
index 3245828d..32aa481f 100644
--- a/packages/backend/src/utils.ts
+++ b/packages/backend/src/utils.ts
@@ -3,6 +3,7 @@ import { AppContext } from "./types.js";
import path from 'path';
import { PrismaClient, Repo } from "@sourcebot/db";
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
+import { Token } from "@sourcebot/schemas/v3/shared.type";
import { BackendException, BackendError } from "@sourcebot/error";
import * as Sentry from "@sentry/node";
@@ -20,7 +21,16 @@ export const marshalBool = (value?: boolean) => {
return !!value ? '1' : '0';
}
-export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => {
+export const isRemotePath = (path: string) => {
+ return path.startsWith('https://') || path.startsWith('http://');
+}
+
+export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient, logger?: Logger) => {
+ // Handle direct string tokens (for backward compatibility and Gerrit auth)
+ if (typeof token === 'string') {
+ return token;
+ }
+
try {
return await getTokenFromConfigBase(token, orgId, db);
} catch (error: unknown) {
diff --git a/packages/crypto/src/tokenUtils.ts b/packages/crypto/src/tokenUtils.ts
index be5a064d..c92ce08c 100644
--- a/packages/crypto/src/tokenUtils.ts
+++ b/packages/crypto/src/tokenUtils.ts
@@ -3,6 +3,11 @@ import { Token } from "@sourcebot/schemas/v3/shared.type";
import { decrypt } from "./index.js";
export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient) => {
+ // Handle direct string tokens
+ if (typeof token === 'string') {
+ return token;
+ }
+
if ('secret' in token) {
const secretKey = token.secret;
const secret = await db.secret.findUnique({
diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts
index a7c857ce..39d8e526 100644
--- a/packages/schemas/src/v3/bitbucket.schema.ts
+++ b/packages/schemas/src/v3/bitbucket.schema.ts
@@ -20,6 +20,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/schemas/src/v3/bitbucket.type.ts b/packages/schemas/src/v3/bitbucket.type.ts
index 260d949d..769fbfae 100644
--- a/packages/schemas/src/v3/bitbucket.type.ts
+++ b/packages/schemas/src/v3/bitbucket.type.ts
@@ -13,6 +13,7 @@ export interface BitbucketConnectionConfig {
* An authentication token.
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts
index d0a72c72..30b7ed3d 100644
--- a/packages/schemas/src/v3/connection.schema.ts
+++ b/packages/schemas/src/v3/connection.schema.ts
@@ -20,6 +20,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -233,6 +238,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -435,6 +445,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -596,6 +611,68 @@ const schema = {
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
+ "auth": {
+ "type": "object",
+ "description": "Authentication configuration for Gerrit",
+ "properties": {
+ "username": {
+ "type": "string",
+ "description": "Gerrit username for authentication",
+ "examples": [
+ "john.doe"
+ ]
+ },
+ "password": {
+ "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.",
+ "examples": [
+ {
+ "env": "GERRIT_HTTP_PASSWORD"
+ },
+ {
+ "secret": "GERRIT_PASSWORD_SECRET"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "additionalProperties": false
+ },
"projects": {
"type": "array",
"items": {
@@ -666,6 +743,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts
index d1d2bc18..b8055c68 100644
--- a/packages/schemas/src/v3/connection.type.ts
+++ b/packages/schemas/src/v3/connection.type.ts
@@ -17,6 +17,7 @@ export interface GithubConnectionConfig {
* A Personal Access Token (PAT).
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
@@ -106,6 +107,7 @@ export interface GitlabConnectionConfig {
* An authentication token.
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
@@ -173,6 +175,7 @@ export interface GiteaConnectionConfig {
* A Personal Access Token (PAT).
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
@@ -226,6 +229,32 @@ export interface GerritConnectionConfig {
* The URL of the Gerrit host.
*/
url: string;
+ /**
+ * Authentication configuration for Gerrit
+ */
+ auth?: {
+ /**
+ * Gerrit username for authentication
+ */
+ username: string;
+ /**
+ * Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.
+ */
+ password:
+ | string
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ };
/**
* List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported
*/
@@ -258,6 +287,7 @@ export interface BitbucketConnectionConfig {
* An authentication token.
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
diff --git a/packages/schemas/src/v3/gerrit.schema.ts b/packages/schemas/src/v3/gerrit.schema.ts
index b8b99e76..86ea2e3e 100644
--- a/packages/schemas/src/v3/gerrit.schema.ts
+++ b/packages/schemas/src/v3/gerrit.schema.ts
@@ -17,6 +17,68 @@ const schema = {
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
+ "auth": {
+ "type": "object",
+ "description": "Authentication configuration for Gerrit",
+ "properties": {
+ "username": {
+ "type": "string",
+ "description": "Gerrit username for authentication",
+ "examples": [
+ "john.doe"
+ ]
+ },
+ "password": {
+ "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.",
+ "examples": [
+ {
+ "env": "GERRIT_HTTP_PASSWORD"
+ },
+ {
+ "secret": "GERRIT_PASSWORD_SECRET"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "additionalProperties": false
+ },
"projects": {
"type": "array",
"items": {
diff --git a/packages/schemas/src/v3/gerrit.type.ts b/packages/schemas/src/v3/gerrit.type.ts
index 752a63b3..fd5b54c1 100644
--- a/packages/schemas/src/v3/gerrit.type.ts
+++ b/packages/schemas/src/v3/gerrit.type.ts
@@ -9,6 +9,32 @@ export interface GerritConnectionConfig {
* The URL of the Gerrit host.
*/
url: string;
+ /**
+ * Authentication configuration for Gerrit
+ */
+ auth?: {
+ /**
+ * Gerrit username for authentication
+ */
+ username: string;
+ /**
+ * Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.
+ */
+ password:
+ | string
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ };
/**
* List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported
*/
diff --git a/packages/schemas/src/v3/gitea.schema.ts b/packages/schemas/src/v3/gitea.schema.ts
index 1e1283ee..a2d397b7 100644
--- a/packages/schemas/src/v3/gitea.schema.ts
+++ b/packages/schemas/src/v3/gitea.schema.ts
@@ -16,6 +16,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/schemas/src/v3/gitea.type.ts b/packages/schemas/src/v3/gitea.type.ts
index ec9e3046..6702906b 100644
--- a/packages/schemas/src/v3/gitea.type.ts
+++ b/packages/schemas/src/v3/gitea.type.ts
@@ -9,6 +9,7 @@ export interface GiteaConnectionConfig {
* A Personal Access Token (PAT).
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
diff --git a/packages/schemas/src/v3/github.schema.ts b/packages/schemas/src/v3/github.schema.ts
index c29e1c08..eda0922c 100644
--- a/packages/schemas/src/v3/github.schema.ts
+++ b/packages/schemas/src/v3/github.schema.ts
@@ -16,6 +16,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/schemas/src/v3/github.type.ts b/packages/schemas/src/v3/github.type.ts
index 4cb73c9b..50b0c4e3 100644
--- a/packages/schemas/src/v3/github.type.ts
+++ b/packages/schemas/src/v3/github.type.ts
@@ -9,6 +9,7 @@ export interface GithubConnectionConfig {
* A Personal Access Token (PAT).
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
diff --git a/packages/schemas/src/v3/gitlab.schema.ts b/packages/schemas/src/v3/gitlab.schema.ts
index 891ca4eb..0b42db7c 100644
--- a/packages/schemas/src/v3/gitlab.schema.ts
+++ b/packages/schemas/src/v3/gitlab.schema.ts
@@ -16,6 +16,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/schemas/src/v3/gitlab.type.ts b/packages/schemas/src/v3/gitlab.type.ts
index f5a293ce..0d84ea55 100644
--- a/packages/schemas/src/v3/gitlab.type.ts
+++ b/packages/schemas/src/v3/gitlab.type.ts
@@ -9,6 +9,7 @@ export interface GitlabConnectionConfig {
* An authentication token.
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts
index 35e7a4fe..d5ade248 100644
--- a/packages/schemas/src/v3/index.schema.ts
+++ b/packages/schemas/src/v3/index.schema.ts
@@ -259,6 +259,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -472,6 +477,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -674,6 +684,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
@@ -835,6 +850,68 @@ const schema = {
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
+ "auth": {
+ "type": "object",
+ "description": "Authentication configuration for Gerrit",
+ "properties": {
+ "username": {
+ "type": "string",
+ "description": "Gerrit username for authentication",
+ "examples": [
+ "john.doe"
+ ]
+ },
+ "password": {
+ "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.",
+ "examples": [
+ {
+ "env": "GERRIT_HTTP_PASSWORD"
+ },
+ {
+ "secret": "GERRIT_PASSWORD_SECRET"
+ }
+ ],
+ "anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
+ {
+ "type": "object",
+ "properties": {
+ "secret": {
+ "type": "string",
+ "description": "The name of the secret that contains the token."
+ }
+ },
+ "required": [
+ "secret"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "env": {
+ "type": "string",
+ "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
+ }
+ },
+ "required": [
+ "env"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "additionalProperties": false
+ },
"projects": {
"type": "array",
"items": {
@@ -905,6 +982,11 @@ const schema = {
}
],
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts
index d239245f..5666ed6d 100644
--- a/packages/schemas/src/v3/index.type.ts
+++ b/packages/schemas/src/v3/index.type.ts
@@ -116,6 +116,7 @@ export interface GithubConnectionConfig {
* A Personal Access Token (PAT).
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
@@ -205,6 +206,7 @@ export interface GitlabConnectionConfig {
* An authentication token.
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
@@ -272,6 +274,7 @@ export interface GiteaConnectionConfig {
* A Personal Access Token (PAT).
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
@@ -325,6 +328,32 @@ export interface GerritConnectionConfig {
* The URL of the Gerrit host.
*/
url: string;
+ /**
+ * Authentication configuration for Gerrit
+ */
+ auth?: {
+ /**
+ * Gerrit username for authentication
+ */
+ username: string;
+ /**
+ * Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.
+ */
+ password:
+ | string
+ | {
+ /**
+ * The name of the secret that contains the token.
+ */
+ secret: string;
+ }
+ | {
+ /**
+ * The name of the environment variable that contains the token. Only supported in declarative connection configs.
+ */
+ env: string;
+ };
+ };
/**
* List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported
*/
@@ -357,6 +386,7 @@ export interface BitbucketConnectionConfig {
* An authentication token.
*/
token?:
+ | string
| {
/**
* The name of the secret that contains the token.
diff --git a/packages/schemas/src/v3/shared.schema.ts b/packages/schemas/src/v3/shared.schema.ts
index 0c1792ae..34be6db7 100644
--- a/packages/schemas/src/v3/shared.schema.ts
+++ b/packages/schemas/src/v3/shared.schema.ts
@@ -5,6 +5,11 @@ const schema = {
"definitions": {
"Token": {
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {
diff --git a/packages/schemas/src/v3/shared.type.ts b/packages/schemas/src/v3/shared.type.ts
index eeec734c..26e5e180 100644
--- a/packages/schemas/src/v3/shared.type.ts
+++ b/packages/schemas/src/v3/shared.type.ts
@@ -5,6 +5,7 @@
* via the `definition` "Token".
*/
export type Token =
+ | string
| {
/**
* The name of the secret that contains the token.
diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index e9adbc2a..0a039f20 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -2096,7 +2096,7 @@ const parseConnectionConfig = (config: string) => {
} satisfies ServiceError;
}
- if ('token' in parsedConfig && parsedConfig.token && 'env' in parsedConfig.token) {
+ if ('token' in parsedConfig && parsedConfig.token && typeof parsedConfig.token === 'object' && 'env' in parsedConfig.token) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
diff --git a/schemas/v3/gerrit.json b/schemas/v3/gerrit.json
index dccb4e10..3c157e5a 100644
--- a/schemas/v3/gerrit.json
+++ b/schemas/v3/gerrit.json
@@ -16,6 +16,36 @@
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
+ "auth": {
+ "type": "object",
+ "description": "Authentication configuration for Gerrit",
+ "properties": {
+ "username": {
+ "type": "string",
+ "description": "Gerrit username for authentication",
+ "examples": [
+ "john.doe"
+ ]
+ },
+ "password": {
+ "$ref": "./shared.json#/definitions/Token",
+ "description": "Gerrit HTTP password (not your account password). Generate this in Gerrit → Settings → HTTP Credentials → Generate Password.",
+ "examples": [
+ {
+ "env": "GERRIT_HTTP_PASSWORD"
+ },
+ {
+ "secret": "GERRIT_PASSWORD_SECRET"
+ }
+ ]
+ }
+ },
+ "required": [
+ "username",
+ "password"
+ ],
+ "additionalProperties": false
+ },
"projects": {
"type": "array",
"items": {
diff --git a/schemas/v3/shared.json b/schemas/v3/shared.json
index 8af4cbdc..a27e359f 100644
--- a/schemas/v3/shared.json
+++ b/schemas/v3/shared.json
@@ -4,6 +4,11 @@
"definitions": {
"Token": {
"anyOf": [
+ {
+ "type": "string",
+ "description": "Direct token value (SECURITY RISK: not recommended for production - use secrets or environment variables instead)",
+ "minLength": 1
+ },
{
"type": "object",
"properties": {