mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Git integration and Project workspace fixes
This commit is contained in:
@@ -147,6 +147,29 @@ When a developer claims a git-enabled task, a **WorkSession** is created that tr
|
||||
- PR number/URL when created
|
||||
- Merge status and who merged
|
||||
|
||||
### Git Credentials
|
||||
|
||||
Git authentication is managed **per-project** through encrypted GitHub PATs:
|
||||
|
||||
- **Each project stores its own git token** - no global fallback
|
||||
- **Tokens are encrypted at rest** using Fernet symmetric encryption
|
||||
- **API never exposes tokens** - only returns `has_git_token: boolean`
|
||||
- **Self-service via UI** - users set/update tokens in project settings
|
||||
|
||||
**Project fields:**
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `git_token_encrypted` | Fernet-encrypted GitHub PAT (DB column) |
|
||||
| `has_git_token` | Boolean indicator for API responses |
|
||||
|
||||
**Token flow:**
|
||||
1. User creates project in UI, enters GitHub PAT
|
||||
2. Token encrypted and stored in `projects.git_token_encrypted`
|
||||
3. WorkspaceService decrypts token when cloning repos
|
||||
4. GitService decrypts token for PR operations (gh CLI)
|
||||
|
||||
**HTTPS URLs require tokens** - attempting to clone without a token will raise `WorkspaceError`.
|
||||
|
||||
## Task Lifecycle
|
||||
|
||||
### Task States
|
||||
@@ -320,6 +343,10 @@ ROBOCO_DATABASE_NAME=roboco
|
||||
ROBOCO_REDIS_HOST=localhost
|
||||
ROBOCO_REDIS_PORT=6379
|
||||
|
||||
# Security (REQUIRED)
|
||||
# Generate with: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
|
||||
ROBOCO_ENCRYPTION_KEY=<your-fernet-key>
|
||||
|
||||
# Workspaces
|
||||
ROBOCO_WORKSPACES_ROOT=/data/workspaces
|
||||
ROBOCO_WORKSPACE_AUTO_CLONE=true
|
||||
|
||||
@@ -63,15 +63,34 @@ roboco_git_create_branch(project_slug, task_id, branch_type, parent_branch)
|
||||
```
|
||||
|
||||
- **branch_type**: `feature`, `bug`, `chore`, `docs`, `hotfix`
|
||||
- **parent_branch**: Parent task's branch (or `main` for top-level)
|
||||
- **parent_branch**: Parent task's branch (or project's default branch for top-level)
|
||||
- Branch naming: `{type}/{team}/{task_id}` (auto-generated)
|
||||
|
||||
**Example:**
|
||||
```
|
||||
roboco_git_create_branch("roboco", "abc123", "feature", "main")
|
||||
roboco_git_create_branch("roboco", "abc123", "feature")
|
||||
# Creates: feature/backend/abc123
|
||||
```
|
||||
|
||||
**Branch Hierarchy (when using hierarchical branching):**
|
||||
Branches are typically created top-down:
|
||||
1. Main PM creates root branch from default branch
|
||||
2. You create subtask branch from root branch
|
||||
3. Dev works on subsubtask branch from your branch
|
||||
|
||||
**When creating a subtask branch:**
|
||||
- Check if parent task has a branch set
|
||||
- If so, use parent's branch as `parent_branch` parameter
|
||||
- If branching from default branch directly, that works too
|
||||
|
||||
**Branch hierarchy example:**
|
||||
```
|
||||
default (main/master/etc)
|
||||
└─ feature/backend/ROOT123 ← Main PM's branch
|
||||
└─ feature/backend/ROOT123/SUB456 ← Your branch
|
||||
└─ feature/backend/ROOT123/SUB456/DEV789 ← Dev branch
|
||||
```
|
||||
|
||||
### 6. ACTIVATE
|
||||
`roboco_task_activate()` moves backlog → pending. Now visible to devs.
|
||||
|
||||
@@ -175,6 +194,12 @@ When ALL subtasks done: reflect + complete your task.
|
||||
6. **Pause after delegating** - Don't spin waiting
|
||||
7. **Reflect before complete** - `roboco_journal_reflect()` required
|
||||
|
||||
**Task Delegation Options:**
|
||||
- `roboco_task_assign()` - Reassign an existing task to a different agent
|
||||
- `roboco_task_create(parent_task_id=...)` - Create a subtask under your task
|
||||
|
||||
For coordination tasks where you're managing work, subtasks are often cleaner for tracking.
|
||||
|
||||
## CEO Escalation
|
||||
|
||||
For major tasks, escalate to CEO instead of completing directly:
|
||||
|
||||
@@ -30,13 +30,23 @@ Claim → read full description → plan breakdown across cells → start → jo
|
||||
### 3. CREATE PARENT BRANCH (Git Tasks)
|
||||
**For tasks with `requires_git=True`:**
|
||||
```
|
||||
roboco_git_create_branch(project_slug, task_id, branch_type, "main")
|
||||
roboco_git_create_branch(project_slug, task_id, branch_type)
|
||||
```
|
||||
|
||||
- Creates parent branch from `main`
|
||||
- Creates parent branch from the project's default branch (e.g., `main`, `master`)
|
||||
- Cell PM subtask branches will fork from this
|
||||
- Example: `feature/cross/abc123` for cross-cell work
|
||||
|
||||
**Branch Hierarchy for Git Tasks:**
|
||||
- For hierarchical branching: default branch → Your branch → Cell PM branch → Dev branch
|
||||
- If using hierarchical branches, create root branch before Cell PMs create theirs
|
||||
- Cell PM branches fork from your branch (set as `parent_branch`)
|
||||
|
||||
**Typical order for hierarchical branching:**
|
||||
1. Create your root branch from project's default branch
|
||||
2. Create cell tasks, Cell PMs create branches from your branch
|
||||
3. Devs create branches from Cell PM branches
|
||||
|
||||
### 4. CREATE GROUP
|
||||
Use `roboco_group_create()` in each relevant cell channel. Cell PMs need groups to create sessions.
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ def upgrade() -> None:
|
||||
sa.Column(
|
||||
"protected_branches", postgresql.ARRAY(sa.String()), server_default="{}"
|
||||
),
|
||||
# Git Authentication (encrypted)
|
||||
sa.Column("git_token_encrypted", sa.Text(), nullable=True),
|
||||
# CI/CD Commands (optional)
|
||||
sa.Column("test_command", sa.String(500), nullable=True),
|
||||
sa.Column("lint_command", sa.String(500), nullable=True),
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
services:
|
||||
# ==========================================================================
|
||||
# PostgreSQL - Primary Database with pgvector for RAG
|
||||
# ==========================================================================
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: roboco-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: roboco
|
||||
POSTGRES_PASSWORD: roboco
|
||||
POSTGRES_DB: roboco
|
||||
ports:
|
||||
- "15432:5432"
|
||||
volumes:
|
||||
- ${ROBOCO_DATA_DIR:-./data}/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U roboco -d roboco"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ==========================================================================
|
||||
# Redis - Cache, Sessions, Event Bus
|
||||
# ==========================================================================
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: roboco-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "16379:6379"
|
||||
volumes:
|
||||
- ${ROBOCO_DATA_DIR:-./data}/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ==========================================================================
|
||||
# Ollama - Local LLM and Embedding Server
|
||||
# ==========================================================================
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: roboco-ollama
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
OLLAMA_API_KEY: ${OLLAMA_API_KEY}
|
||||
ports:
|
||||
- "11435:11434"
|
||||
volumes:
|
||||
- ${ROBOCO_DATA_DIR:-./data}/ollama:/root/.ollama
|
||||
healthcheck:
|
||||
# Use ollama CLI (guaranteed available) to check if server is responding
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# Ollama model puller - pulls required models on startup
|
||||
# Uses streaming curl to wait for full model download
|
||||
ollama-init:
|
||||
image: curlimages/curl:latest
|
||||
container_name: roboco-ollama-init
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
echo "=== Pulling embedding model (embeddinggemma:300m) ==="
|
||||
# Ollama /api/pull streams JSON lines until complete - consume full stream
|
||||
# Note: $$ escapes $ for docker-compose variable substitution
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"embeddinggemma:300m"}' | while read -r line; do
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done
|
||||
echo "=== Pulling LLM model (glm-4.7:cloud) ==="
|
||||
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-4.7:cloud"}' | while read -r line; do
|
||||
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
[ -n "$$status" ] && echo " $$status"
|
||||
done
|
||||
echo "=== Verifying models are available ==="
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "embeddinggemma" && echo " embeddinggemma: OK"
|
||||
curl -sf http://ollama:11434/api/tags | grep -q "glm-4.7" && echo " glm-4.7: OK"
|
||||
echo "=== All models ready! ==="
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Base Image Builder (specialized images built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-base-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-base.Dockerfile
|
||||
image: roboco-agent-base
|
||||
container_name: roboco-agent-base-builder
|
||||
entrypoint: ["/bin/sh", "-c", "echo 'Agent base image built successfully'"]
|
||||
restart: "no"
|
||||
|
||||
# ==========================================================================
|
||||
# Agent PM Image Builder (specialized image built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-pm-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-pm.Dockerfile
|
||||
image: roboco-agent-pm
|
||||
entrypoint: ["/bin/sh", "-c", "echo 'Agent PM image built'"]
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Backend Dev Image Builder (specialized image built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-dev-be-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-dev-be.Dockerfile
|
||||
image: roboco-agent-dev-be
|
||||
entrypoint: ["/bin/sh", "-c", "echo 'Agent Backend Dev image built'"]
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Frontend Dev Image Builder (specialized image built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-dev-fe-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-dev-fe.Dockerfile
|
||||
image: roboco-agent-dev-fe
|
||||
entrypoint: ["/bin/sh", "-c", "echo 'Agent Frontend Dev image built'"]
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Backend QA Image Builder (specialized image built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-qa-be-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-qa-be.Dockerfile
|
||||
image: roboco-agent-qa-be
|
||||
entrypoint: ["/bin/sh", "-c", 'echo "Agent Backend QA image built"']
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Frontend QA Image Builder (specialized image built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-qa-fe-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-qa-fe.Dockerfile
|
||||
image: roboco-agent-qa-fe
|
||||
entrypoint: ["/bin/sh", "-c", 'echo "Agent Frontend QA image built"']
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent UX/UI Dev Image Builder (specialized image built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-ux-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-ux.Dockerfile
|
||||
image: roboco-agent-ux
|
||||
entrypoint: ["/bin/sh", "-c", 'echo "Agent UX/UI image built"']
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Documenter Image Builder (specialized image built on-demand by orchestrator)
|
||||
# ==========================================================================
|
||||
agent-doc-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-doc.Dockerfile
|
||||
image: roboco-agent-doc
|
||||
entrypoint: ["/bin/sh", "-c", 'echo "Agent Documenter image built"']
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Orchestrator - API Server + Agent Spawner
|
||||
# ==========================================================================
|
||||
orchestrator:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/orchestrator.Dockerfile
|
||||
image: roboco-orchestrator
|
||||
container_name: roboco-orchestrator
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
# Database (use container name, not localhost)
|
||||
ROBOCO_DATABASE_HOST: roboco-postgres
|
||||
ROBOCO_DATABASE_PORT: 5432
|
||||
ROBOCO_DATABASE_USER: roboco
|
||||
ROBOCO_DATABASE_PASSWORD: roboco
|
||||
ROBOCO_DATABASE_NAME: roboco
|
||||
# Redis (use container name)
|
||||
ROBOCO_REDIS_HOST: roboco-redis
|
||||
ROBOCO_REDIS_PORT: 6379
|
||||
# API
|
||||
ROBOCO_HOST: 0.0.0.0
|
||||
ROBOCO_PORT: 8000
|
||||
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
|
||||
# Ollama (use container name)
|
||||
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-4.7:cloud
|
||||
ROBOCO_DEFAULT_EMBEDDING_MODEL: embeddinggemma:300m
|
||||
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
|
||||
# Host paths for spawning agent containers (required for Docker-in-Docker)
|
||||
# IMPORTANT: These must be ABSOLUTE paths on the host filesystem
|
||||
ROBOCO_HOST_PROJECT_DIR: ${ROBOCO_HOST_PROJECT_DIR:-/volume1/roboco}
|
||||
ROBOCO_HOST_CLAUDE_DIR: ${ROBOCO_HOST_CLAUDE_DIR:-/home/renzof/.claude}
|
||||
ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data}
|
||||
volumes:
|
||||
# Docker socket - allows spawning agent containers
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Claude Code auth - mount your ~/.claude directory
|
||||
- ${CLAUDE_AUTH_DIR:-/home/renzof/.claude}:/root/.claude
|
||||
# Shared config directory for MCP configs (writable)
|
||||
- ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs
|
||||
# Generated prompts directory - composed at runtime from layers
|
||||
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
||||
# Per-agent Claude settings (generated at spawn time)
|
||||
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
||||
# Agent workspaces (git clones) - persisted across restarts
|
||||
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
ollama-init:
|
||||
condition: service_completed_successfully
|
||||
agent-base-image:
|
||||
condition: service_completed_successfully
|
||||
# Default agents to spawn (override in .env or command line)
|
||||
# command: ["--spawn", "main-pm", "be-dev-1", "be-qa"]
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: roboco_default
|
||||
@@ -217,6 +217,7 @@ services:
|
||||
# API
|
||||
ROBOCO_HOST: 0.0.0.0
|
||||
ROBOCO_PORT: 8000
|
||||
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
|
||||
# Ollama (use container name)
|
||||
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
|
||||
ROBOCO_LOCAL_LLM_MODEL: glm-4.7:cloud
|
||||
@@ -238,6 +239,8 @@ services:
|
||||
- ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated
|
||||
# Per-agent Claude settings (generated at spawn time)
|
||||
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
|
||||
# Agent workspaces (git clones) - persisted across restarts
|
||||
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -76,3 +76,14 @@ roboco_workspace_status(project_slug="roboco")
|
||||
Path resolved automatically: `{workspaces_root}/{project}/{team}/{agent}/`
|
||||
|
||||
If `auto_clone=True` and workspace doesn't exist, it's created on first access.
|
||||
|
||||
## Authentication
|
||||
|
||||
HTTPS repositories require a GitHub PAT configured on the project:
|
||||
|
||||
- **Token configured**: Auto-clone works, git operations succeed
|
||||
- **Token missing**: Error "Project requires a git token for HTTPS repositories"
|
||||
|
||||
**If you see this error**: Contact your PM to configure the project's git token.
|
||||
|
||||
PMs use `roboco_project_update(slug, git_token="...")` to set credentials.
|
||||
|
||||
@@ -113,11 +113,19 @@ Register new git repositories:
|
||||
roboco_project_create(
|
||||
name="New Project",
|
||||
slug="new-project",
|
||||
git_url="git@github.com:org/repo.git",
|
||||
assigned_cell="backend"
|
||||
git_url="https://github.com/org/repo.git",
|
||||
assigned_cell="backend",
|
||||
git_token="ghp_xxxx..." # Required for HTTPS repos
|
||||
)
|
||||
```
|
||||
|
||||
**IMPORTANT:** Include `git_token` (GitHub PAT with `repo` scope) for HTTPS repositories. Without it, workspace creation and git operations will fail.
|
||||
|
||||
To update or rotate tokens:
|
||||
```python
|
||||
roboco_project_update(slug="new-project", git_token="ghp_newtoken...")
|
||||
```
|
||||
|
||||
Create tasks with project:
|
||||
|
||||
```python
|
||||
|
||||
@@ -19,7 +19,9 @@ Returns projects you have access to (cell-scoped for non-PMs).
|
||||
roboco_project_get(slug="roboco")
|
||||
```
|
||||
|
||||
Returns: `name`, `git_url`, `assigned_cell`, `default_branch`, `test_command`, etc.
|
||||
Returns: `name`, `git_url`, `assigned_cell`, `default_branch`, `has_git_token`, `test_command`, etc.
|
||||
|
||||
**Note:** `has_git_token` indicates if authentication is configured (required for HTTPS repos).
|
||||
|
||||
## Create Project (PM+ Only)
|
||||
|
||||
@@ -27,8 +29,9 @@ Returns: `name`, `git_url`, `assigned_cell`, `default_branch`, `test_command`, e
|
||||
roboco_project_create(
|
||||
name="RoboCo Panel",
|
||||
slug="roboco-panel",
|
||||
git_url="git@github.com:org/roboco-panel.git",
|
||||
git_url="https://github.com/org/roboco-panel.git",
|
||||
assigned_cell="frontend",
|
||||
git_token="ghp_xxxx...", # GitHub PAT with repo scope
|
||||
default_branch="main",
|
||||
test_command="pnpm test",
|
||||
lint_command="pnpm lint"
|
||||
@@ -37,11 +40,14 @@ roboco_project_create(
|
||||
|
||||
**Who can create:** Main PM, Board, CEO
|
||||
|
||||
**IMPORTANT:** `git_token` is **required** for HTTPS repositories. Without it, workspace creation and git operations will fail.
|
||||
|
||||
## Update Project
|
||||
|
||||
```python
|
||||
roboco_project_update(
|
||||
slug="roboco-panel",
|
||||
git_token="ghp_newtoken...", # Update/rotate token
|
||||
test_command="pnpm test:ci",
|
||||
lint_command="pnpm lint:fix"
|
||||
)
|
||||
@@ -51,6 +57,8 @@ roboco_project_update(
|
||||
- CEO, Main PM: Any project
|
||||
- Cell PM: Own cell's projects only
|
||||
|
||||
**Token rotation:** Pass `git_token` to update credentials. Pass empty string to clear.
|
||||
|
||||
## Workspace Tools
|
||||
|
||||
### Ensure Workspace
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Git Error Troubleshooting
|
||||
|
||||
## Missing Git Token
|
||||
|
||||
**Error**: "Project requires a git token for HTTPS repositories"
|
||||
|
||||
**Cause**: No GitHub PAT configured for this project
|
||||
|
||||
**Solutions**:
|
||||
1. Open project settings in UI
|
||||
2. Add GitHub token (Personal Access Token)
|
||||
3. Token needs `repo` scope for clone/push/PR
|
||||
|
||||
**Notes**:
|
||||
- Each project requires its own token (no global fallback)
|
||||
- Tokens are encrypted at rest
|
||||
- Token never exposed in API responses
|
||||
|
||||
## Workspace Not Found
|
||||
|
||||
**Error**: "Workspace does not exist"
|
||||
|
||||
@@ -54,6 +54,12 @@ In `awaiting_documentation`:
|
||||
|
||||
Task advances to `awaiting_pm_review` when BOTH are done.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Git token configured**: Project must have a GitHub PAT set
|
||||
- Token must have `repo` scope for PR creation
|
||||
- If missing, error: "Project has no git token configured"
|
||||
|
||||
## Before Creating PR
|
||||
|
||||
1. Push all commits: `roboco_git_push()`
|
||||
|
||||
@@ -96,9 +96,15 @@ async def get_git_status(
|
||||
|
||||
try:
|
||||
workspace = await git_service.get_workspace(project_slug, agent.agent_id)
|
||||
current_branch, has_changes, staged, unstaged, untracked, ahead, behind = (
|
||||
await git_service.get_status(workspace)
|
||||
)
|
||||
(
|
||||
current_branch,
|
||||
has_changes,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
ahead,
|
||||
behind,
|
||||
) = await git_service.get_status(workspace)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
@@ -273,9 +279,13 @@ async def create_commit(
|
||||
try:
|
||||
workspace = await git_service.get_workspace(data.project_slug, agent.agent_id)
|
||||
|
||||
commit_hash, message, files_changed, insertions, deletions = (
|
||||
await git_service.create_commit(workspace, agent.agent_id, data)
|
||||
)
|
||||
(
|
||||
commit_hash,
|
||||
message,
|
||||
files_changed,
|
||||
insertions,
|
||||
deletions,
|
||||
) = await git_service.create_commit(workspace, agent.agent_id, data)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
@@ -406,9 +416,13 @@ async def create_pull_request(
|
||||
try:
|
||||
workspace = await git_service.get_workspace(data.project_slug, agent.agent_id)
|
||||
|
||||
pr_number, pr_url, title, source_branch, target_branch = (
|
||||
await git_service.create_pull_request(workspace, data)
|
||||
)
|
||||
(
|
||||
pr_number,
|
||||
pr_url,
|
||||
title,
|
||||
source_branch,
|
||||
target_branch,
|
||||
) = await git_service.create_pull_request(workspace, data)
|
||||
except ServiceError as e:
|
||||
raise _translate_error(e) from e
|
||||
|
||||
|
||||
@@ -146,14 +146,20 @@ async def create_project(
|
||||
|
||||
service = get_project_service(db)
|
||||
|
||||
# If protected_branches wasn't provided, default to just the default_branch
|
||||
protected_branches = data.protected_branches
|
||||
if protected_branches is None:
|
||||
protected_branches = [data.default_branch]
|
||||
|
||||
# Convert request to service model
|
||||
create_data = ProjectCreate(
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
git_url=data.git_url,
|
||||
default_branch=data.default_branch,
|
||||
protected_branches=data.protected_branches,
|
||||
protected_branches=protected_branches,
|
||||
assigned_cell=data.assigned_cell,
|
||||
git_token=data.git_token,
|
||||
test_command=data.test_command,
|
||||
lint_command=data.lint_command,
|
||||
format_command=data.format_command,
|
||||
@@ -218,6 +224,7 @@ async def update_project(
|
||||
default_branch=data.default_branch,
|
||||
protected_branches=data.protected_branches,
|
||||
assigned_cell=data.assigned_cell,
|
||||
git_token=data.git_token,
|
||||
test_command=data.test_command,
|
||||
lint_command=data.lint_command,
|
||||
format_command=data.format_command,
|
||||
|
||||
@@ -93,6 +93,19 @@ async def create_task(
|
||||
detail="Not authorized to create tasks",
|
||||
)
|
||||
|
||||
# Validate: git tasks require project_id
|
||||
if data.requires_git and not data.project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "PROJECT_REQUIRED",
|
||||
"message": "Tasks with requires_git=True must have project_id set",
|
||||
"hint": "Specify project_id or set requires_git=False",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
service = get_task_service(db)
|
||||
req = TaskCreateRequest(
|
||||
title=data.title,
|
||||
@@ -109,6 +122,10 @@ async def create_task(
|
||||
status=data.status,
|
||||
sequence=data.sequence, # Task ordering within siblings
|
||||
dependency_ids=data.dependency_ids, # Dependencies for claim filtering
|
||||
# Git configuration
|
||||
task_type=data.task_type,
|
||||
requires_git=data.requires_git,
|
||||
project_id=data.project_id,
|
||||
)
|
||||
task = await service.create(req)
|
||||
await db.commit()
|
||||
|
||||
@@ -33,6 +33,9 @@ class ProjectResponse(BaseModel):
|
||||
protected_branches: list[str]
|
||||
assigned_cell: Team
|
||||
|
||||
# Git authentication status (token never exposed, only boolean)
|
||||
has_git_token: bool = False
|
||||
|
||||
# Optional commands
|
||||
test_command: str | None = None
|
||||
lint_command: str | None = None
|
||||
@@ -63,9 +66,11 @@ class ProjectSummaryResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
git_url: str
|
||||
assigned_cell: Team
|
||||
is_active: bool
|
||||
has_workspace: bool = False
|
||||
has_git_token: bool = False
|
||||
|
||||
class Config:
|
||||
"""Pydantic config."""
|
||||
@@ -85,9 +90,18 @@ class ProjectCreateRequest(BaseModel):
|
||||
slug: str = Field(..., min_length=1, max_length=50, pattern=r"^[a-z0-9-]+$")
|
||||
git_url: str
|
||||
default_branch: str = "main"
|
||||
protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"])
|
||||
protected_branches: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Branches to protect. Defaults to [default_branch].",
|
||||
)
|
||||
assigned_cell: Team
|
||||
|
||||
# Git authentication (will be encrypted and stored securely)
|
||||
git_token: str | None = Field(
|
||||
default=None,
|
||||
description="GitHub PAT for clone/push/PR (stored encrypted, never returned)",
|
||||
)
|
||||
|
||||
# Optional commands
|
||||
test_command: str | None = None
|
||||
lint_command: str | None = None
|
||||
@@ -105,6 +119,12 @@ class ProjectUpdateRequest(BaseModel):
|
||||
protected_branches: list[str] | None = None
|
||||
assigned_cell: Team | None = None
|
||||
|
||||
# Git authentication (empty string clears token, None leaves unchanged)
|
||||
git_token: str | None = Field(
|
||||
default=None,
|
||||
description="GitHub PAT (empty string clears, None leaves unchanged)",
|
||||
)
|
||||
|
||||
# Commands
|
||||
test_command: str | None = None
|
||||
lint_command: str | None = None
|
||||
@@ -144,6 +164,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
||||
default_branch=str(default_branch) if default_branch else "main",
|
||||
protected_branches=list(project.protected_branches or []),
|
||||
assigned_cell=project.assigned_cell,
|
||||
has_git_token=bool(project.git_token_encrypted),
|
||||
test_command=project.test_command,
|
||||
lint_command=project.lint_command,
|
||||
format_command=project.format_command,
|
||||
@@ -165,7 +186,9 @@ def project_to_summary(project: "ProjectTable") -> ProjectSummaryResponse:
|
||||
id=typing_cast("UUID", project.id),
|
||||
name=str(project.name),
|
||||
slug=str(project.slug),
|
||||
git_url=str(project.git_url),
|
||||
assigned_cell=project.assigned_cell,
|
||||
is_active=bool(project.is_active),
|
||||
has_workspace=bool(project.workspace_path),
|
||||
has_git_token=bool(project.git_token_encrypted),
|
||||
)
|
||||
|
||||
@@ -198,6 +198,10 @@ class Settings(BaseSettings):
|
||||
min_length=32,
|
||||
description="Secret key for JWT signing",
|
||||
)
|
||||
encryption_key: str = Field(
|
||||
default="",
|
||||
description="Fernet encryption key for secrets.",
|
||||
)
|
||||
access_token_expire_minutes: int = Field(default=60 * 24, ge=1) # 24 hours
|
||||
algorithm: str = "HS256"
|
||||
|
||||
|
||||
@@ -300,6 +300,9 @@ class ProjectTable(Base):
|
||||
protected_branches: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String), default=lambda: ["main", "master"]
|
||||
)
|
||||
git_token_encrypted: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
) # Fernet-encrypted GitHub PAT
|
||||
|
||||
# CI/CD Commands (optional)
|
||||
test_command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
@@ -345,7 +345,10 @@ async def _handle_workspace_ensure(
|
||||
"branch": git_status.get("current_branch"),
|
||||
"has_uncommitted": git_status.get("has_changes", False),
|
||||
},
|
||||
"guidance": "Workspace ready. Use git tools to make changes.",
|
||||
"guidance": (
|
||||
"Workspace ready. Use roboco_git_* MCP tools for git operations. "
|
||||
f"Direct filesystem access at /data/workspaces/{project_slug}/..."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -384,7 +387,11 @@ async def _handle_workspace_status(
|
||||
"staged_files": git_status.get("staged_files", []),
|
||||
"unstaged_files": git_status.get("unstaged_files", []),
|
||||
},
|
||||
"guidance": "Use git tools to make changes.",
|
||||
"guidance": (
|
||||
"Workspace ready. Use roboco_git_* MCP tools for git operations "
|
||||
"(commit, push, branch, etc). Direct filesystem access is available "
|
||||
f"at /data/workspaces/{project_slug}/... for your agent."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ Handler for claiming tasks.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from starlette import status
|
||||
|
||||
from roboco.agents_config import get_agent_role
|
||||
from roboco.mcp.tasks import format_task_response
|
||||
from roboco.mcp.tasks.handlers._helpers import (
|
||||
@@ -56,6 +58,51 @@ async def _is_pre_assigned_to_agent(
|
||||
return agent_uuid is not None and str(assigned_to) == agent_uuid
|
||||
|
||||
|
||||
async def _validate_branch_hierarchy(
|
||||
client: ApiClient, task: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Walk up task hierarchy ensuring all git-enabled ancestors have branches.
|
||||
|
||||
Returns error dict if hierarchy is incomplete, None if valid.
|
||||
Branch hierarchy must be created from root down:
|
||||
- Main PM creates root branch
|
||||
- Cell PM creates subtask branch
|
||||
- Developer works on subsubtask branch
|
||||
"""
|
||||
current_parent_id = task.get("parent_task_id")
|
||||
ancestors_checked: list[str] = []
|
||||
|
||||
while current_parent_id:
|
||||
parent_resp = await client.get(f"/tasks/{current_parent_id}")
|
||||
if parent_resp.status_code != status.HTTP_200_OK:
|
||||
# Can't fetch parent - may have been deleted, allow to proceed
|
||||
break
|
||||
|
||||
parent = parent_resp.json()
|
||||
parent_title = parent.get("title", str(current_parent_id)[:8])
|
||||
ancestors_checked.append(parent_title)
|
||||
|
||||
# Check if parent is a git task missing its branch
|
||||
if parent.get("requires_git") and not parent.get("branch_name"):
|
||||
return format_error_response(
|
||||
"BRANCH_HIERARCHY_INCOMPLETE",
|
||||
f"Parent task '{parent_title}' has no branch yet.",
|
||||
{
|
||||
"missing_branch_task": str(current_parent_id),
|
||||
"ancestors_checked": ancestors_checked,
|
||||
},
|
||||
hint=(
|
||||
"Branch hierarchy must be created from root down. "
|
||||
"Main PM creates root branch, Cell PM creates subtask branch. "
|
||||
"Ask your PM to create the parent branch first."
|
||||
),
|
||||
)
|
||||
|
||||
current_parent_id = parent.get("parent_task_id")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _execute_claim(
|
||||
client: ApiClient, task_id: str, agent_id: str
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
||||
@@ -72,6 +119,29 @@ async def _execute_claim(
|
||||
return claimed, None
|
||||
|
||||
|
||||
async def _validate_git_requirements(
|
||||
client: ApiClient, task: dict[str, Any], task_id: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Validate git-related requirements. Returns error or None."""
|
||||
if not task.get("requires_git"):
|
||||
return None
|
||||
|
||||
# Branch must exist for git tasks
|
||||
if not task.get("branch_name"):
|
||||
return format_error_response(
|
||||
"BRANCH_REQUIRED",
|
||||
"Cannot claim git task - PM must create branch first.",
|
||||
{"task_id": task_id, "requires_git": True},
|
||||
hint="PM should call roboco_git_create_branch() before developer can claim",
|
||||
)
|
||||
|
||||
# Validate full branch hierarchy for git tasks with parent
|
||||
if task.get("parent_task_id"):
|
||||
return await _validate_branch_hierarchy(client, task)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def handle_task_claim(
|
||||
client: ApiClient, task_id: str, agent_id: str
|
||||
) -> dict[str, Any]:
|
||||
@@ -104,14 +174,9 @@ async def handle_task_claim(
|
||||
if error := await validate_task_claimable(task, agent_role, agent_id, client):
|
||||
return error
|
||||
|
||||
# Validate branch exists for git tasks (PM must create branch first)
|
||||
if task.get("requires_git") and not task.get("branch_name"):
|
||||
return format_error_response(
|
||||
"BRANCH_REQUIRED",
|
||||
"Cannot claim git task - PM must create branch first.",
|
||||
{"task_id": task_id, "requires_git": True},
|
||||
hint="PM should call roboco_git_create_branch() before developer can claim",
|
||||
)
|
||||
# Validate git requirements (branch exists, hierarchy valid)
|
||||
if error := await _validate_git_requirements(client, task, task_id):
|
||||
return error
|
||||
|
||||
# Execute the claim
|
||||
claimed_task, error = await _execute_claim(client, task_id, agent_id)
|
||||
|
||||
@@ -451,6 +451,15 @@ async def handle_task_assign(
|
||||
if assign_error or assigned_task is None:
|
||||
return assign_error or format_error_response("ASSIGN_FAILED", "No task")
|
||||
|
||||
# Warning if reassigning a claimed task (not an error, just informational)
|
||||
reassign_warning = ""
|
||||
if task.get("status") == "claimed" and task.get("claimed_by"):
|
||||
reassign_warning = (
|
||||
"\n\n⚠️ Note: This task was already claimed. If you're delegating work, "
|
||||
"consider using roboco_task_create(parent_task_id=...) to create a subtask "
|
||||
"for better tracking and branch hierarchy management."
|
||||
)
|
||||
|
||||
guidance = (
|
||||
f"Task assigned to {input_data.assignee} and set to pending. "
|
||||
"Orchestrator will spawn them to claim and work on it."
|
||||
@@ -461,6 +470,9 @@ async def handle_task_assign(
|
||||
if role_warning:
|
||||
guidance += f"\n\n⚠️ ROLE WARNING: {role_warning}"
|
||||
|
||||
# Add reassign warning if applicable
|
||||
guidance += reassign_warning
|
||||
|
||||
return format_task_response(assigned_task, "ASSIGNED", guidance)
|
||||
|
||||
|
||||
|
||||
@@ -160,10 +160,14 @@ async def handle_task_start(
|
||||
# Build guidance based on whether git checkout happened
|
||||
guidance = "Task started. Work through your plan step by step:\n"
|
||||
if requires_git and branch_name:
|
||||
guidance += f"✓ Checked out branch: {branch_name}\n\n"
|
||||
guidance += (
|
||||
f"✓ Checked out branch: {branch_name}\n"
|
||||
f" Workspace: /data/workspaces/{project_slug}/...\n"
|
||||
" Use roboco_git_* tools for git operations.\n\n"
|
||||
)
|
||||
guidance += (
|
||||
"1. Implement each sub-task\n"
|
||||
"2. Commit frequently with clear messages\n"
|
||||
"2. Use roboco_git_commit() to commit frequently\n"
|
||||
"3. Call roboco_task_progress to update status\n"
|
||||
"4. If blocked, call roboco_task_block immediately\n"
|
||||
"5. When done, call roboco_task_submit_verification"
|
||||
|
||||
@@ -77,6 +77,12 @@ class Project(TimestampMixin):
|
||||
default=None, description="Specific agents allowed (None = all in cell)"
|
||||
)
|
||||
|
||||
# Git Authentication (token stored encrypted, never exposed)
|
||||
has_git_token: bool = Field(
|
||||
default=False,
|
||||
description="Whether a git token is configured (token never exposed)",
|
||||
)
|
||||
|
||||
# Runtime State
|
||||
workspace_path: str | None = Field(
|
||||
default=None,
|
||||
@@ -102,6 +108,12 @@ class ProjectCreate(RobocoBase):
|
||||
protected_branches: list[str] = Field(default_factory=lambda: ["main", "master"])
|
||||
assigned_cell: Team
|
||||
|
||||
# Git authentication (will be encrypted and stored securely)
|
||||
git_token: str | None = Field(
|
||||
default=None,
|
||||
description="GitHub PAT for clone/push/PR operations (stored encrypted)",
|
||||
)
|
||||
|
||||
# Optional commands
|
||||
test_command: str | None = None
|
||||
lint_command: str | None = None
|
||||
@@ -117,6 +129,13 @@ class ProjectUpdate(RobocoBase):
|
||||
git_url: str | None = None
|
||||
default_branch: str | None = None
|
||||
protected_branches: list[str] | None = None
|
||||
|
||||
# Git authentication (empty string clears token, None leaves unchanged)
|
||||
git_token: str | None = Field(
|
||||
default=None,
|
||||
description="GitHub PAT (empty string clears, None leaves unchanged)",
|
||||
)
|
||||
|
||||
test_command: str | None = None
|
||||
lint_command: str | None = None
|
||||
format_command: str | None = None
|
||||
|
||||
@@ -655,6 +655,7 @@ class AgentOrchestrator:
|
||||
# Running inside orchestrator container - use host paths
|
||||
blueprints_host = f"{PROJECT_HOST_PATH}/agents/blueprints"
|
||||
docs_host = f"{PROJECT_HOST_PATH}/docs"
|
||||
workspaces_host = f"{DATA_HOST_PATH}/workspaces"
|
||||
claude_host = CLAUDE_AUTH_HOST_PATH
|
||||
mcp_config_host = (
|
||||
f"{DATA_HOST_PATH}/mcp-configs/{config.mcp_config_path.name}"
|
||||
@@ -674,6 +675,7 @@ class AgentOrchestrator:
|
||||
# Running directly on host
|
||||
blueprints_host = str(self.blueprints_dir.absolute())
|
||||
docs_host = str(self.blueprints_dir.parent / "docs")
|
||||
workspaces_host = str(Path(settings.workspaces_root))
|
||||
claude_host = CLAUDE_AUTH_HOST_PATH
|
||||
mcp_config_host = str(config.mcp_config_path)
|
||||
# Generated prompts in temp dir
|
||||
@@ -721,6 +723,9 @@ class AgentOrchestrator:
|
||||
# - All other roles get read-only access
|
||||
"-v",
|
||||
f"{docs_host}:/app/docs{'' if config.agent_id in ALL_DOCS else ':ro'}",
|
||||
# Mount workspaces directory for git operations
|
||||
"-v",
|
||||
f"{workspaces_host}:/data/workspaces",
|
||||
# Mount MCP config
|
||||
"-v",
|
||||
f"{mcp_config_host}:/app/mcp-config.json:ro",
|
||||
|
||||
+61
-6
@@ -6,6 +6,7 @@ All business logic for git commands, commit templates, PR generation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
@@ -49,6 +50,24 @@ from roboco.templates.git.pr_root import CommitInfo as PRCommitInfo
|
||||
# Git command timeout in seconds
|
||||
_GIT_TIMEOUT = 30
|
||||
|
||||
|
||||
def _get_gh_env(token: str | None = None) -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables for gh CLI commands.
|
||||
|
||||
Args:
|
||||
token: Project-specific GitHub PAT (required for gh operations)
|
||||
|
||||
Returns:
|
||||
Environment dict with GITHUB_TOKEN set
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
if token:
|
||||
env["GITHUB_TOKEN"] = token
|
||||
env["GH_TOKEN"] = token
|
||||
return env
|
||||
|
||||
|
||||
# Expected number of parts in various git outputs
|
||||
_REV_LIST_PARTS = 2
|
||||
|
||||
@@ -285,9 +304,7 @@ class GitService(BaseService):
|
||||
commit_hash = parts[0] if parts else "unknown"
|
||||
|
||||
# Get stats
|
||||
stat_result = await self._run_git(
|
||||
workspace, ["diff", "--stat", "HEAD~1..HEAD"]
|
||||
)
|
||||
stat_result = await self._run_git(workspace, ["diff", "--stat", "HEAD~1..HEAD"])
|
||||
insertions, deletions, files_changed = self._parse_commit_stats(
|
||||
stat_result.stdout
|
||||
)
|
||||
@@ -335,6 +352,25 @@ class GitService(BaseService):
|
||||
project = await project_service.get_by_slug(request.project_slug)
|
||||
base_branch = str(project.default_branch) if project else "main"
|
||||
|
||||
# Validate parent branch exists on remote (unless it's the default branch)
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get_by_slug(request.project_slug)
|
||||
default_branch = str(project.default_branch) if project else "main"
|
||||
|
||||
if base_branch != default_branch:
|
||||
# Parent is not the default branch - verify it exists on remote
|
||||
result = await self._run_git(
|
||||
workspace,
|
||||
["ls-remote", "--heads", "origin", base_branch],
|
||||
check=False,
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
raise ValidationError(
|
||||
f"Parent branch '{base_branch}' does not exist on remote. "
|
||||
f"The parent task's branch must be created and pushed first. "
|
||||
f"Ensure Main PM/Cell PM created their branches before this task."
|
||||
)
|
||||
|
||||
# Create and push branch
|
||||
await self._run_git(workspace, ["checkout", base_branch])
|
||||
await self._run_git(workspace, ["pull", "origin", base_branch])
|
||||
@@ -498,11 +534,21 @@ class GitService(BaseService):
|
||||
|
||||
source_branch = await self.get_current_branch(workspace)
|
||||
|
||||
# Determine target branch
|
||||
# Determine target branch and get project token
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get_by_slug(request.project_slug)
|
||||
default_branch = str(project.default_branch) if project else "main"
|
||||
|
||||
# Get decrypted token from project (required for PR creation)
|
||||
git_token = await project_service.get_decrypted_token_by_slug(
|
||||
request.project_slug
|
||||
)
|
||||
if not git_token:
|
||||
raise GitError(
|
||||
f"Project '{request.project_slug}' has no git token configured. "
|
||||
"Configure a GitHub PAT in the project settings to create PRs."
|
||||
)
|
||||
|
||||
if request.is_root_pr:
|
||||
target_branch = default_branch
|
||||
elif task.parent_task_id:
|
||||
@@ -531,7 +577,7 @@ class GitService(BaseService):
|
||||
pr_title = pr_title or build_pr_title_internal(internal_ctx)
|
||||
pr_body = pr_body or build_pr_body_internal(internal_ctx, api_base)
|
||||
|
||||
# Create PR using gh CLI
|
||||
# Create PR using gh CLI with project token
|
||||
def _create_pr() -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
@@ -552,6 +598,7 @@ class GitService(BaseService):
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
check=True,
|
||||
env=_get_gh_env(git_token),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -575,6 +622,14 @@ class GitService(BaseService):
|
||||
|
||||
Returns: (target_branch, merge_commit)
|
||||
"""
|
||||
# Get project token for gh CLI
|
||||
project_service = get_project_service(self.session)
|
||||
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
|
||||
if not git_token:
|
||||
raise GitError(
|
||||
f"Project '{project_slug}' has no git token configured. "
|
||||
"Configure a GitHub PAT in the project settings to merge PRs."
|
||||
)
|
||||
|
||||
def _merge_pr() -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
@@ -591,6 +646,7 @@ class GitService(BaseService):
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
check=True,
|
||||
env=_get_gh_env(git_token),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -599,7 +655,6 @@ class GitService(BaseService):
|
||||
raise GitCommandError("gh pr merge", e.stderr or e.stdout or "") from e
|
||||
|
||||
# Get target branch
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get_by_slug(project_slug)
|
||||
target_branch = str(project.default_branch) if project else "main"
|
||||
|
||||
|
||||
+102
-3
@@ -15,6 +15,7 @@ from roboco.db.tables import ProjectTable
|
||||
from roboco.models.base import Team
|
||||
from roboco.models.project import ProjectCreate, ProjectUpdate
|
||||
from roboco.services.base import BaseService, ConflictError, NotFoundError
|
||||
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
|
||||
|
||||
|
||||
class ProjectService(BaseService):
|
||||
@@ -60,6 +61,15 @@ class ProjectService(BaseService):
|
||||
resource_type="project",
|
||||
)
|
||||
|
||||
# Encrypt git token if provided
|
||||
encrypted_token = None
|
||||
if data.git_token:
|
||||
try:
|
||||
encrypted_token = encrypt_token(data.git_token)
|
||||
except EncryptionError as e:
|
||||
self.log.error("Failed to encrypt git token", error=str(e))
|
||||
raise
|
||||
|
||||
project = ProjectTable(
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
@@ -67,6 +77,7 @@ class ProjectService(BaseService):
|
||||
default_branch=data.default_branch,
|
||||
protected_branches=data.protected_branches,
|
||||
assigned_cell=data.assigned_cell,
|
||||
git_token_encrypted=encrypted_token,
|
||||
test_command=data.test_command,
|
||||
lint_command=data.lint_command,
|
||||
format_command=data.format_command,
|
||||
@@ -83,6 +94,7 @@ class ProjectService(BaseService):
|
||||
project_id=str(project.id),
|
||||
slug=data.slug,
|
||||
git_url=data.git_url,
|
||||
has_git_token=bool(encrypted_token),
|
||||
cell=data.assigned_cell.value
|
||||
if isinstance(data.assigned_cell, Team)
|
||||
else data.assigned_cell,
|
||||
@@ -129,18 +141,42 @@ class ProjectService(BaseService):
|
||||
if not project:
|
||||
return None
|
||||
|
||||
# Apply updates for non-None fields
|
||||
update_data = data.model_dump(exclude_unset=True, exclude_none=True)
|
||||
# Handle git_token specially (empty string clears, None leaves unchanged)
|
||||
token_updated = False
|
||||
if data.git_token is not None:
|
||||
if data.git_token == "":
|
||||
# Clear the token
|
||||
project.git_token_encrypted = None
|
||||
token_updated = True
|
||||
self.log.info("Git token cleared", project_id=str(project_id))
|
||||
else:
|
||||
# Encrypt and set new token
|
||||
try:
|
||||
project.git_token_encrypted = encrypt_token(data.git_token)
|
||||
token_updated = True
|
||||
self.log.info("Git token updated", project_id=str(project_id))
|
||||
except EncryptionError as e:
|
||||
self.log.error("Failed to encrypt git token", error=str(e))
|
||||
raise
|
||||
|
||||
# Apply updates for non-None fields (excluding git_token which we handled)
|
||||
update_data = data.model_dump(
|
||||
exclude_unset=True, exclude_none=True, exclude={"git_token"}
|
||||
)
|
||||
for key, value in update_data.items():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
|
||||
await self.session.flush()
|
||||
|
||||
updated_fields = list(update_data.keys())
|
||||
if token_updated:
|
||||
updated_fields.append("git_token")
|
||||
|
||||
self.log.info(
|
||||
"Project updated",
|
||||
project_id=str(project_id),
|
||||
updates=list(update_data.keys()),
|
||||
updates=updated_fields,
|
||||
)
|
||||
return project
|
||||
|
||||
@@ -287,6 +323,69 @@ class ProjectService(BaseService):
|
||||
)
|
||||
return project
|
||||
|
||||
# =========================================================================
|
||||
# GIT TOKEN MANAGEMENT
|
||||
# =========================================================================
|
||||
|
||||
async def get_decrypted_token(self, project_id: UUID) -> str | None:
|
||||
"""
|
||||
Get the decrypted git token for a project.
|
||||
|
||||
Used by WorkspaceService and GitService for git operations.
|
||||
The token is decrypted on-demand and should not be cached.
|
||||
|
||||
Args:
|
||||
project_id: Project to get token for
|
||||
|
||||
Returns:
|
||||
Decrypted token or None if no token is set
|
||||
|
||||
Raises:
|
||||
EncryptionError: If decryption fails (key mismatch, corrupted data)
|
||||
"""
|
||||
project = await self.get(project_id)
|
||||
if not project or not project.git_token_encrypted:
|
||||
return None
|
||||
|
||||
try:
|
||||
return decrypt_token(project.git_token_encrypted)
|
||||
except EncryptionError:
|
||||
self.log.error(
|
||||
"Failed to decrypt git token",
|
||||
project_id=str(project_id),
|
||||
error="encryption_key_mismatch_or_corrupted",
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_decrypted_token_by_slug(self, slug: str) -> str | None:
|
||||
"""
|
||||
Get the decrypted git token for a project by slug.
|
||||
|
||||
Convenience method for services that work with project slugs.
|
||||
|
||||
Args:
|
||||
slug: Project slug
|
||||
|
||||
Returns:
|
||||
Decrypted token or None if no token is set
|
||||
|
||||
Raises:
|
||||
EncryptionError: If decryption fails
|
||||
"""
|
||||
project = await self.get_by_slug(slug)
|
||||
if not project or not project.git_token_encrypted:
|
||||
return None
|
||||
|
||||
try:
|
||||
return decrypt_token(project.git_token_encrypted)
|
||||
except EncryptionError:
|
||||
self.log.error(
|
||||
"Failed to decrypt git token",
|
||||
project_slug=slug,
|
||||
error="encryption_key_mismatch_or_corrupted",
|
||||
)
|
||||
raise
|
||||
|
||||
# =========================================================================
|
||||
# ACCESS CONTROL
|
||||
# =========================================================================
|
||||
|
||||
+106
-15
@@ -19,6 +19,7 @@ Example:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
@@ -26,13 +27,42 @@ from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.logging import get_logger
|
||||
from roboco.models.base import Team
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _inject_token_into_url(git_url: str, token: str | None) -> str:
|
||||
"""
|
||||
Inject GitHub PAT into HTTPS git URL for authentication.
|
||||
|
||||
Args:
|
||||
git_url: Original git URL (SSH or HTTPS)
|
||||
token: GitHub PAT (if None, returns original URL)
|
||||
|
||||
Returns:
|
||||
URL with embedded token for HTTPS, or original URL for SSH
|
||||
|
||||
Example:
|
||||
https://github.com/org/repo.git -> https://TOKEN@github.com/org/repo.git
|
||||
"""
|
||||
if not token:
|
||||
return git_url
|
||||
|
||||
# Only inject for HTTPS URLs
|
||||
if not git_url.startswith("https://"):
|
||||
return git_url
|
||||
|
||||
# Check if token already present
|
||||
if "@" in git_url.split("//")[1].split("/")[0]:
|
||||
return git_url
|
||||
|
||||
# Inject token: https://github.com -> https://TOKEN@github.com
|
||||
return re.sub(r"^https://", f"https://{token}@", git_url)
|
||||
|
||||
|
||||
class WorkspaceError(Exception):
|
||||
"""Raised when workspace operations fail."""
|
||||
|
||||
@@ -145,7 +175,27 @@ class WorkspaceService:
|
||||
Raises:
|
||||
WorkspaceError: If workspace creation fails
|
||||
"""
|
||||
workspace = await self.resolve_workspace(project_slug, agent_id)
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
# Look up agent for workspace path and git identity
|
||||
agent_id_str = str(agent_id)
|
||||
query = select(AgentTable)
|
||||
try:
|
||||
agent_uuid = UUID(agent_id_str)
|
||||
query = query.where(AgentTable.id == agent_uuid)
|
||||
except ValueError:
|
||||
query = query.where(AgentTable.slug == agent_id_str)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
agent = result.scalar_one_or_none()
|
||||
if not agent:
|
||||
raise WorkspaceError(f"Agent not found: {agent_id}")
|
||||
|
||||
# Compute workspace path
|
||||
team = agent.team if agent.team else Team.BACKEND
|
||||
workspace = self.get_workspace_path(project_slug, team, agent.slug)
|
||||
|
||||
# Check if already exists
|
||||
if (workspace / ".git").exists():
|
||||
@@ -156,21 +206,34 @@ class WorkspaceService:
|
||||
)
|
||||
return workspace
|
||||
|
||||
# Get git URL if not provided
|
||||
if not git_url:
|
||||
from sqlalchemy import select
|
||||
# Get git URL and token from project
|
||||
project_service = get_project_service(self.session)
|
||||
project = await project_service.get_by_slug(project_slug)
|
||||
if not project:
|
||||
raise WorkspaceError(f"Project not found: {project_slug}")
|
||||
|
||||
result = await self.session.execute(
|
||||
select(ProjectTable).where(ProjectTable.slug == project_slug)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise WorkspaceError(f"Project not found: {project_slug}")
|
||||
if not git_url:
|
||||
git_url = project.git_url
|
||||
default_branch = project.default_branch or default_branch
|
||||
|
||||
# Clone the repository
|
||||
await self._clone_repo(workspace, git_url, default_branch)
|
||||
# Get decrypted token from project (per-project token, no global fallback)
|
||||
git_token = await project_service.get_decrypted_token_by_slug(project_slug)
|
||||
|
||||
# Validate token is set for HTTPS URLs (no global fallback)
|
||||
if git_url.startswith("https://") and not git_token:
|
||||
raise WorkspaceError(
|
||||
f"Project '{project_slug}' requires a git token for HTTPS clone. "
|
||||
"Configure a GitHub PAT in the project settings."
|
||||
)
|
||||
|
||||
# Clone the repository with agent identity
|
||||
await self._clone_repo(
|
||||
workspace,
|
||||
git_url,
|
||||
default_branch,
|
||||
git_token,
|
||||
agent=agent,
|
||||
)
|
||||
return workspace
|
||||
|
||||
async def _clone_repo(
|
||||
@@ -178,6 +241,8 @@ class WorkspaceService:
|
||||
workspace: Path,
|
||||
git_url: str,
|
||||
default_branch: str,
|
||||
git_token: str | None = None,
|
||||
agent: AgentTable | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Clone a git repository to the workspace.
|
||||
@@ -186,6 +251,8 @@ class WorkspaceService:
|
||||
workspace: Target directory
|
||||
git_url: Git URL to clone
|
||||
default_branch: Branch to checkout
|
||||
git_token: GitHub PAT for authentication (per-project)
|
||||
agent: Agent for git identity (name/email in commits)
|
||||
|
||||
Raises:
|
||||
WorkspaceError: If clone fails
|
||||
@@ -193,11 +260,16 @@ class WorkspaceService:
|
||||
# Create parent directories
|
||||
workspace.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Inject project-specific token for HTTPS URLs
|
||||
auth_url = _inject_token_into_url(git_url, git_token)
|
||||
|
||||
# Log without exposing token
|
||||
logger.info(
|
||||
"Cloning repository",
|
||||
workspace=str(workspace),
|
||||
git_url=git_url,
|
||||
git_url=git_url, # Log original URL, not auth URL
|
||||
branch=default_branch,
|
||||
using_token=bool(git_token and auth_url != git_url),
|
||||
)
|
||||
|
||||
def _do_clone() -> subprocess.CompletedProcess[str]:
|
||||
@@ -208,7 +280,7 @@ class WorkspaceService:
|
||||
"--branch",
|
||||
default_branch,
|
||||
"--single-branch",
|
||||
git_url,
|
||||
auth_url,
|
||||
str(workspace),
|
||||
],
|
||||
capture_output=True,
|
||||
@@ -217,8 +289,27 @@ class WorkspaceService:
|
||||
check=True,
|
||||
)
|
||||
|
||||
def _configure_git() -> None:
|
||||
"""Configure git author info based on agent identity."""
|
||||
name = agent.name if agent else "RoboCo Agent"
|
||||
slug = agent.slug if agent else "agent"
|
||||
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", name],
|
||||
cwd=str(workspace),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", f"{slug}@agents.roboco.dev"],
|
||||
cwd=str(workspace),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_do_clone)
|
||||
await asyncio.to_thread(_configure_git)
|
||||
logger.info(
|
||||
"Repository cloned successfully",
|
||||
workspace=str(workspace),
|
||||
|
||||
@@ -5,8 +5,18 @@ Common utility functions and helpers.
|
||||
"""
|
||||
|
||||
from roboco.utils.converters import require_uuid, to_python_uuid, to_python_uuid_list
|
||||
from roboco.utils.crypto import (
|
||||
EncryptionError,
|
||||
decrypt_token,
|
||||
encrypt_token,
|
||||
is_encryption_configured,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EncryptionError",
|
||||
"decrypt_token",
|
||||
"encrypt_token",
|
||||
"is_encryption_configured",
|
||||
"require_uuid",
|
||||
"to_python_uuid",
|
||||
"to_python_uuid_list",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Cryptographic utilities for encrypting sensitive data at rest.
|
||||
|
||||
Uses Fernet symmetric encryption with a master key from settings.
|
||||
"""
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class EncryptionError(Exception):
|
||||
"""Raised when encryption/decryption operations fail."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""
|
||||
Get Fernet instance with master encryption key.
|
||||
|
||||
Raises:
|
||||
EncryptionError: If encryption key is not configured
|
||||
"""
|
||||
if not settings.encryption_key:
|
||||
raise EncryptionError(
|
||||
"ROBOCO_ENCRYPTION_KEY is not configured. "
|
||||
"Generate one with: python -c 'from cryptography.fernet "
|
||||
"import Fernet; print(Fernet.generate_key().decode())'"
|
||||
)
|
||||
try:
|
||||
return Fernet(settings.encryption_key.encode())
|
||||
except Exception as e:
|
||||
raise EncryptionError(f"Invalid encryption key format: {e}") from e
|
||||
|
||||
|
||||
def encrypt_token(token: str) -> str:
|
||||
"""
|
||||
Encrypt a token using Fernet symmetric encryption.
|
||||
|
||||
Args:
|
||||
token: The plaintext token to encrypt
|
||||
|
||||
Returns:
|
||||
Base64-encoded encrypted token string
|
||||
|
||||
Raises:
|
||||
EncryptionError: If encryption fails
|
||||
"""
|
||||
if not token:
|
||||
raise EncryptionError("Cannot encrypt empty token")
|
||||
|
||||
try:
|
||||
fernet = _get_fernet()
|
||||
encrypted = fernet.encrypt(token.encode())
|
||||
return encrypted.decode()
|
||||
except EncryptionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Token encryption failed", error=str(e))
|
||||
raise EncryptionError(f"Failed to encrypt token: {e}") from e
|
||||
|
||||
|
||||
def decrypt_token(encrypted: str) -> str:
|
||||
"""
|
||||
Decrypt a token that was encrypted with encrypt_token().
|
||||
|
||||
Args:
|
||||
encrypted: Base64-encoded encrypted token string
|
||||
|
||||
Returns:
|
||||
The original plaintext token
|
||||
|
||||
Raises:
|
||||
EncryptionError: If decryption fails (wrong key, corrupted data, etc.)
|
||||
"""
|
||||
if not encrypted:
|
||||
raise EncryptionError("Cannot decrypt empty value")
|
||||
|
||||
try:
|
||||
fernet = _get_fernet()
|
||||
decrypted = fernet.decrypt(encrypted.encode())
|
||||
return decrypted.decode()
|
||||
except InvalidToken as e:
|
||||
logger.error(
|
||||
"Token decryption failed - encryption key may have changed",
|
||||
error="InvalidToken",
|
||||
)
|
||||
raise EncryptionError(
|
||||
"Unable to decrypt token - encryption key may have changed"
|
||||
) from e
|
||||
except EncryptionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Token decryption failed", error=str(e))
|
||||
raise EncryptionError(f"Failed to decrypt token: {e}") from e
|
||||
|
||||
|
||||
def is_encryption_configured() -> bool:
|
||||
"""Check if encryption is properly configured."""
|
||||
if not settings.encryption_key:
|
||||
return False
|
||||
try:
|
||||
_get_fernet()
|
||||
return True
|
||||
except EncryptionError:
|
||||
return False
|
||||
Reference in New Issue
Block a user