mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [4c179e3a] Add git pull, fetch, and rebase backend endpoints (#190) * [f966f772] feat(git): add pull, fetch, and rebase endpoints with integration tests (#185) - Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response schemas - Add GitService.pull(), fetch(), and rebase() methods using _network_git_timeout() - Add POST /api/git/pull, /api/git/fetch, /api/git/rebase route handlers - Rebase detects conflicts via git diff --name-only --diff-filter=U and aborts cleanly - Integration tests cover success path and GitCommandError→500 for all three endpoints - Rebase conflict test verifies conflict=True with populated conflicted_files list Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [26e2b7af] test(git): add AsyncMock unit tests for rebase_onto_base conflict-state handling (#186) New test_git_rebase.py covers three branches of rebase_onto_base: - success path: rebase exits 0, returns rebased status, abort never called - conflict path: non-zero exit → diff → abort → returns conflict+files - resilience: both rebase and abort exit non-zero, still returns conflict dict without exception All tests use AsyncMock with side_effect sequences to mock _run_git at the service-method level. Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> * [551b1dbf] Panel-wide frontend UI standardization and page fixes (#193) * [1ec787b2] feat(panel): design-system sweep — full-width layouts, scrollbar fix, Secretary button, component audit (#188) - Settings page: remove max-w-3xl, wrap cards in grid-cols-1 lg:grid-cols-2 two-column layout - AI Providers page: remove max-w-5xl so AIRoutingCard fills available width - Journals AgentList: replace ScrollArea with overflow-y-auto div to eliminate nested scrollbar - Secretary chat input: add items-stretch to flex row so Send/Start button matches Textarea height - Component audit: replace all raw <button>/<input>/hand-rolled badge spans outside components/ui/ with canonical Button, Checkbox, Badge variants across 15 files: - ai-routing-card.tsx: ModeButton → Button, checkbox → Checkbox, badge spans → Badge - self-hosted-section.tsx: eye-toggle → Button ghost icon-sm, badge spans → Badge - journals/agent-item.tsx, communications/channel-item.tsx → Button ghost - kb-search-bar.tsx, kb-filters.tsx → Button ghost - kb-category-nav.tsx, git-log-panel.tsx → Button ghost - communications/page.tsx (channel + group lists) → Button ghost - projects/project-table.tsx, products/product-table.tsx → Button link - git-branch-panel.tsx (local + remote lists) → Button ghost - tasks/dependency-selector.tsx: Button ghost + Checkbox for visual indicator - tasks/task-table.tsx: sortable header + expand toggle → Button ghost - business/goals-tab.tsx: hidden button → Button Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [435b37b4] feat(metrics,notifications): URL-persisted tab state, semantic chart colors, humanized counts (#187) - Notifications page: replace useState with useSearchParams/useRouter for ?tab= URL parameter (all/unread/pending, default: unread); Suspense wrapper with skeleton fallback for SSR compatibility. - Metrics page: split into Performance tab (Velocity + Task Status + Agent Status + Team Health) and Token Usage tab (TokenUsageCostsSection) with ?tab= URL parameter (default: performance); Suspense wrapper; Refresh button moved inside PerformanceTabContent; humanizeCount() helper applies K/M suffixes to all MetricCard numeric values >= 1000. - Chart components (usage-time-series, agent-usage, team-usage, model-donut): replace var(--chart-N) CSS vars with explicit semantic hex colors — #3b82f6 blue for informational, #f59e0b amber for warning/pending, #22c55e green for success/healthy, #ef4444 red for error/blocked, #a855f7 purple for supplemental. pnpm lint and pnpm typecheck pass with zero new errors. Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [ccd256f4] Kanban mobile viewport: 375px layout, column navigation, 44px touch targets (#191) * [ccd256f4] feat(kanban): mobile 375px layout with column navigator and 44px touch targets - KanbanBoard: add activeColumnIndex state + mobile prev/next column navigator (lg:hidden); existing horizontal-scroll layout hidden on mobile (hidden lg:flex). Desktop DnD behavior unchanged. - KanbanColumn: add optional className prop (cn-based) so mobile view can pass w-full/sm:w-full to fill the viewport. - KanbanCard: bump all action buttons to min-h-11 (44px) touch targets (Assign, Pass, Fail, Move-forward). * [ccd256f4] fix(kanban): change breakpoint from lg to sm for mobile/desktop layout switch AC3 requires >=640px viewport to show multi-column layout (sm: breakpoint). Previous impl used lg: (1024px), leaving 640-1023px in single-column mode. Change: - Mobile navigator div: lg:hidden → sm:hidden - Desktop multi-column div: hidden lg:flex → hidden sm:flex At <640px: single-column with prev/next navigator (375px mobile use case). At >=640px: full horizontal-scroll multi-column layout (per AC3). DnD behavior and all other layout unchanged. --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [23f02af4] Agents page On-Demand section + Board composition; Overview Quick Actions visibility + Team Health Intake/Secretary (#189) * [23f02af4] feat(agents,overview): On-Demand section, Board composition fix, Intake/Secretary in Quick Actions + Team Health - agent-definitions.ts: remove AgentRole.MAIN_PM from getBoardAgents (Main PM has its own dedicated section; including it there was redundant). Add getOnDemandAgents() that catches agents not in any standard team (board/main_pm/backend/frontend/ux_ui/marketing) and not a standard cell role — surfaces prompter/intake agents that the API may return. - agents/page.tsx: import getOnDemandAgents; add a conditional 'On-Demand Agents' AgentGrid section (only rendered when the API returns at least one matching agent, e.g. the Intake interviewer). - quick-actions-bar.tsx: add 'Task Intake' button (→/prompter, Sparkles icon) and 'Secretary' button (→/business?tab=secretary, Bot icon) alongside existing quick actions so operators can reach on-demand agents from the Overview in one click. - team-health-cards.tsx: add OnDemandAgentCard sub-component (link card with On-Demand badge) and render static cards for 'Task Intake' and 'Secretary' appended after the API-driven TeamHealthCard list, giving them equal visual presence in the Team Health section. pnpm lint and pnpm typecheck pass with zero new errors. * [23f02af4] fix(agents,overview): QA revision — enum entries, QuickActions placement, On-Demand title, Board PR_REVIEWER AC3: types/index.ts AgentRole enum adds PR_REVIEWER, PROMPTER, SECRETARY. agent-selector.tsx ROLE_LABELS exhaustive Record updated accordingly. AC4: command-center.tsx QuickActionsBar moved to after Team Health section, before CEO Approval Queue and data-heavy grid rows — visible without scrolling on a 900px viewport. AC1: agents/page.tsx On-Demand AgentGrid title fixed to 'On-Demand' (was 'On-Demand Agents' in prior commit). AC2: agent-definitions.ts getBoardAgents adds explicit PR_REVIEWER inclusion and uses inclusion-based getOnDemandAgents (PROMPTER|SECRETARY roles). AC5: team-health-cards.tsx static OnDemandAgentCard implementation refined with correct fallback rendering when no API team data. AC6: pnpm lint and pnpm typecheck (src only) pass with zero new errors. --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [b1c59206] Git page: Pull, Fetch, Rebase buttons wired to backend; Rebase destructive confirmation dialog (#192) * [b1c59206] feat(git): add Pull, Fetch, Rebase operations to Git page with destructive confirmation dialog for Rebase - Add GitPullRequest/Response, GitFetchRequest/Response, GitRebaseRequest/Response types - Add gitApi.pull(), gitApi.fetch(), gitApi.rebase() with mock stubs for /git/pull, /git/fetch, /git/rebase - Add useGitPull, useGitFetch, useGitRebase mutation hooks with cache invalidation; exported via useGitOperations - Add Pull (Download icon), Fetch (RefreshCcw icon), Rebase (GitGraph icon) buttons to GitActionsPanel - Rebase button triggers AlertDialog with destructive confirmation before calling API - Wire handlePull, handleFetch, handleRebase handlers in git-browser.tsx with toast feedback * [b1c59206] fix(git): add destructive styling and branch name to Rebase AlertDialog - Add className='border-destructive bg-destructive/5' to AlertDialogContent so the dialog container has the required red-tinted styling (AC3) - Update AlertDialogDescription to interpolate status?.current_branch so the dialog body explicitly names the branch being rebased (AC3) --------- Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [3f305ed9] Frontend: Fix git control contract, complete Secretary restyling, and apply polish (CEO revision) (#199) * [72de8a65] fix(git): correct Pull/Fetch/Rebase types, API mocks, request fields, and toast handlers (#197) - types/git.ts: GitPullResponse and GitFetchResponse now have current_branch, has_changes, staged_files, unstaged_files, untracked_files, ahead, behind (matching backend GitStatusResponse); removed nonexistent commits_received/ refs_updated/remote fields - types/git.ts: GitRebaseRequest now uses target_branch: string (not onto?: string); GitRebaseResponse now has conflict: boolean and conflicted_files: string[] (removed branch/onto/commits_rebased); task_id made optional on all three request types - lib/api/git.ts: Updated mock returns for pull/fetch/rebase to match new types - git-actions-panel.tsx: onRebase prop now (targetBranch: string) => void; Rebase AlertDialog now contains an Input for target_branch; AlertDialogAction disabled when targetBranch empty and passes the value to onRebase - git-browser.tsx: handlePull and handleFetch toast references result.current_branch; handleRebase accepts targetBranch, sends target_branch in payload, toasts result.conflict and result.conflicted_files; no 'manual' task_id for any pull/fetch/rebase operation Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [be6a17fc] feat(ui): design-system polish — chart tokens, KB aria-label, Kanban touch targets (#196) - kb-search-bar.tsx: add aria-label="Clear search" to the clear (X) button - model-usage-donut.tsx: replace hex CHART_COLORS with var(--chart-1)…var(--chart-5) - usage-time-series-chart.tsx: replace hex stopColor/stroke with var(--chart-1)/var(--chart-2) - agent-usage-chart.tsx: Bar fill hex → var(--chart-1) - team-usage-chart.tsx: Bar fill hex → var(--chart-1) - kanban-card.tsx: min-h-11 → max-sm:min-h-11 (44px touch target mobile-only, 3 buttons) - secretary-tab.tsx: already compliant (Button + design-system tokens), no change needed Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [d62036bd] Backend: Fix git endpoint schemas, add safety gates, and unit tests (CEO revision) (#200) * [d0593fe3] feat(git): remove agent_id from schemas and add service-layer safety gates (#195) - Remove agent_id field from all 9 git request schemas (GitCreateBranchRequest, GitCheckoutRequest, GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest, GitPullRequest, GitFetchRequest, GitRebaseRequest); agent identity comes from JWT auth context - Make task_id Optional[UUID]=None in GitPullRequest, GitFetchRequest, GitRebaseRequest - Add field_validator to GitRebaseRequest rejecting target_branch starting with '-' or equal to 'master'/'main' - Add lightweight PullRequest, FetchRequest, RebaseRequest schemas for gateway layer - Add dirty-workspace check to GitService.pull() (raises ValidationError if porcelain output) - Switch GitService.pull() to --ff-only; raises ValidationError with diverged-branch message on non-zero exit - Add master/main guard to GitService.rebase() for both head_branch and target_branch - Update callers: routes/tasks.py (2x), services/task.py, tests/unit/services/test_git.py (2x) Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [a2f96961] Add role-gated rebase endpoint and unit tests (test_git_rebase.py) (#198) * [a2f96961] feat(git): add role-gated rebase endpoint and unit tests Add role-gate to POST /rebase restricting access to DEVELOPER and CELL_PM roles; add master/main protected-branch guard to GitService.rebase() before any git subprocess runs; add 4 unit tests in tests/unit/services/test_git_rebase.py covering both target-branch and head-branch REBASE_FORBIDDEN cases * [a2f96961] fix(git): invert rebase role gate, add ownership check, schema validator, and missing tests - _REBASE_ALLOWED_ROLES changed from {DEVELOPER, CELL_PM} to {CEO, CELL_PM, MAIN_PM} so developers correctly receive 403 per AC1/AC2 - rebase_branch() now verifies task ownership for non-CEO PM callers: if task_id is provided and the task is not assigned to the calling agent, returns 403/404 - GitRebaseRequest.target_branch gets a @field_validator rejecting '-' prefix names and protected branch names (main, master, develop) - GitService.pull() gains pre-flight safety gates: raises ValidationError DIRTY_TREE when staged/unstaged changes exist, DIVERGED_BRANCH when ahead > 0 and behind > 0 - test_git_rebase.py adds 9 new tests: pull() dirty-tree ValidationError, pull() diverged-branch ValidationError, pull() success path, schema validator for '-' prefix and protected names, and route-level tests confirming HTTP 403 for DEVELOPER and HTTP 200 for CELL_PM on POST /rebase * [a2f96961] fix(tests): add type annotations for tuple variables in test_git_rebase.py mypy needs explicit tuple type annotations when assigning bare tuples to variables used as mock side_effect return values — fixes var-annotated error caught by the server-side quality gate --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com> --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com> * [94015c6d] Frontend R3: Fix legacy git taskId coercion + rebase placeholder + phantom fields (#204) * [401ddb40] fix(git): remove phantom fields from GitPullRequest/GitFetchRequest and make task_id optional in write request interfaces; use taskId || undefined in git-browser.tsx handlers to avoid 422 errors when no task context is active (#201) Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [cca8d0c0] fix(git): fix rebase placeholder and surface backend error in toast (#202) git-actions-panel.tsx: change rebase target_branch Input placeholder from "e.g. main or origin/main" to "Remote ref (e.g. origin/HEAD)" so no default branch name (main/master/develop) is suggested. git-browser.tsx: import getErrorMessage from @/lib/api/client and use it in handleRebase catch block instead of the hardcoded string "Failed to rebase". getErrorMessage extracts the real detail from AxiosError.response.data.detail and falls back to a non-empty generic message, satisfying both the detail-surfacing and fallback criteria. Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> * [1ea0fbcb] Backend R3: Relax legacy git schemas + fix integration tests (#206) * [219c539b] Make task_id Optional in git request schemas and update service methods (#205) * [219c539b] feat(git): make task_id Optional in git schemas and add None-guards in service methods - GitCommitRequest, GitPushRequest, GitCreatePRRequest, GitMergePRRequest now have task_id: UUID | None = None - commit_for_task, push_for_task, create_pr_for_task, merge_pr_for_task skip ownership/state checks when task_id is None and proceed to the git operation - Added 16 unit tests in tests/unit/api/routes/test_git_optional_task_id.py covering schema validation and HTTP endpoint responses - Added 4 integration tests in tests/integration/test_git_routes.py for no-422 behaviour - All quality gates pass: ruff format, ruff check, mypy, pytest * [219c539b] fix(tests): remove unused type-ignore comments, redundant cast, and invalid agent_id kwarg in git_optional_task_id unit tests --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [de95ce94] test(git): fix 3 rebase integration tests to use non-protected target_branch (#203) - Add pm_git_client fixture (CELL_PM role) needed for the role-gated rebase endpoint - Change target_branch from 'main' to 'develop' in test_rebase_success, test_rebase_conflict, and test_rebase_git_command_error - Remove task_id from request bodies (optional field; random UUIDs trigger 404) - Switch all 3 rebase tests to use pm_git_client instead of git_client Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> * chore: ruff format test_agent_image_registry.py (unblock quality gate) --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
526 lines
18 KiB
Python
526 lines
18 KiB
Python
"""Unit tests for GitService rebase conflict-state handling.
|
|
|
|
Pins the three critical control-flow branches of ``rebase_onto_base``:
|
|
|
|
1. **Success** — the underlying ``git rebase`` exits 0 → method returns a
|
|
non-conflict result dict and never calls ``git rebase --abort``.
|
|
2. **Conflict** — ``git rebase`` exits non-zero → method calls
|
|
``git diff --name-only --diff-filter=U`` to collect conflicted files,
|
|
calls ``git rebase --abort`` to restore the workspace, and returns a
|
|
conflict result dict.
|
|
3. **Resilience** — both ``git rebase`` and ``git rebase --abort`` exit
|
|
non-zero (e.g. abort fails mid-stream). The method must still return
|
|
the conflict dict without propagating an exception, because both are
|
|
invoked with ``check=False``.
|
|
|
|
All tests mock ``_run_git`` at the service-method level using
|
|
``AsyncMock`` with a ``side_effect`` list so each awaited call consumes
|
|
the next pre-configured result in order.
|
|
|
|
Also covers the ``rebase()`` safety gate added by the git-schema cleanup
|
|
task: rebasing onto or from a protected branch (master/main) is rejected
|
|
with a service-layer ``ValidationError`` before any git command runs.
|
|
|
|
Also covers:
|
|
* ``pull()`` dirty-tree and diverged-branch ``ValidationError`` gates.
|
|
* ``pull()`` success path.
|
|
* ``GitRebaseRequest.target_branch`` Pydantic field validator.
|
|
* Route-level role gate: DEVELOPER → 403, CELL_PM → 200.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
from uuid import uuid4
|
|
|
|
import pydantic
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
from roboco.api.deps import get_agent_context, get_db
|
|
from roboco.api.routes.git import router as git_router
|
|
from roboco.api.schemas.git import GitRebaseRequest
|
|
from roboco.models.base import AgentRole
|
|
from roboco.models.permissions import AgentContext
|
|
from roboco.services.base import ValidationError
|
|
from roboco.services.git import GitService
|
|
|
|
_HTTP_200 = 200
|
|
_HTTP_403 = 403
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_HEAD = "feature/backend/root--task"
|
|
_BASE = "feature/backend/root"
|
|
_WORKSPACE = Path("/tmp/fake-ws")
|
|
_TOKEN = "ghp_fake"
|
|
|
|
|
|
def _git_service() -> GitService:
|
|
"""Instantiate GitService without a real DB session."""
|
|
svc = GitService.__new__(GitService)
|
|
svc.log = MagicMock() # silence warning/info calls
|
|
return svc
|
|
|
|
|
|
def _result(returncode: int = 0, stdout: str = "", stderr: str = "") -> Any:
|
|
"""Minimal subprocess result stand-in."""
|
|
r = MagicMock()
|
|
r.returncode = returncode
|
|
r.stdout = stdout
|
|
r.stderr = stderr
|
|
return r
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1 — success path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_path_returns_rebased_and_does_not_call_abort(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When git rebase exits 0 the method returns a non-conflict result and
|
|
never invokes ``git rebase --abort``.
|
|
|
|
Call sequence for the success path (rebase OK, 2 unique commits):
|
|
[0] fetch origin
|
|
[1] checkout HEAD branch
|
|
[2] reset --hard origin/HEAD
|
|
[3] rebase origin/BASE ← exits 0
|
|
[4] rev-list --count ← returns "2"
|
|
[5] push --force-with-lease ← pushes the rebased branch
|
|
"""
|
|
run = AsyncMock(
|
|
side_effect=[
|
|
_result(), # [0] fetch
|
|
_result(), # [1] checkout
|
|
_result(), # [2] reset
|
|
_result(), # [3] rebase ← success
|
|
_result(stdout="2\n"), # [4] rev-list
|
|
_result(), # [5] push
|
|
]
|
|
)
|
|
monkeypatch.setattr(GitService, "_run_git", run)
|
|
|
|
svc = _git_service()
|
|
result = await svc.rebase_onto_base(
|
|
_WORKSPACE,
|
|
head_branch=_HEAD,
|
|
base_branch=_BASE,
|
|
git_token=_TOKEN,
|
|
)
|
|
|
|
assert result == {"status": "rebased", "unique_commits": 2}
|
|
|
|
# Verify abort was never called
|
|
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
|
|
assert abort_call not in run.call_args_list, (
|
|
"git rebase --abort must NOT be called on a clean rebase"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2 — conflict path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_conflict_path_calls_diff_then_abort_and_returns_conflict_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When git rebase exits non-zero the method:
|
|
|
|
* calls ``git diff --name-only --diff-filter=U`` to identify conflicted files,
|
|
* calls ``git rebase --abort`` to restore the workspace,
|
|
* returns ``{"status": "conflicts", "files": [<conflicted files>]}``.
|
|
|
|
Call sequence:
|
|
[0] fetch origin
|
|
[1] checkout HEAD branch
|
|
[2] reset --hard origin/HEAD
|
|
[3] rebase origin/BASE ← exits 1 (conflict)
|
|
[4] diff --name-only ← lists conflicted files
|
|
[5] rebase --abort ← exits 0
|
|
"""
|
|
run = AsyncMock(
|
|
side_effect=[
|
|
_result(), # [0] fetch
|
|
_result(), # [1] checkout
|
|
_result(), # [2] reset
|
|
_result(returncode=1), # [3] rebase ← conflict
|
|
_result(stdout="src/a.py\nsrc/b.py\n"), # [4] diff
|
|
_result(), # [5] rebase --abort
|
|
]
|
|
)
|
|
monkeypatch.setattr(GitService, "_run_git", run)
|
|
|
|
svc = _git_service()
|
|
result = await svc.rebase_onto_base(
|
|
_WORKSPACE,
|
|
head_branch=_HEAD,
|
|
base_branch=_BASE,
|
|
git_token=_TOKEN,
|
|
)
|
|
|
|
assert result == {"status": "conflicts", "files": ["src/a.py", "src/b.py"]}
|
|
|
|
# Verify the diff call was made with the correct flags
|
|
diff_call = call(
|
|
_WORKSPACE,
|
|
["diff", "--name-only", "--diff-filter=U"],
|
|
check=False,
|
|
)
|
|
assert diff_call in run.call_args_list, (
|
|
"git diff --name-only --diff-filter=U must be called to collect conflicted"
|
|
" files"
|
|
)
|
|
|
|
# Verify abort was called
|
|
abort_call = call(_WORKSPACE, ["rebase", "--abort"], check=False)
|
|
assert abort_call in run.call_args_list, (
|
|
"git rebase --abort must be called to restore the workspace after a conflict"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 3 — resilience: both rebase and abort exit non-zero
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resilience_when_both_rebase_and_abort_fail_returns_conflict_no_exception(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When ``git rebase`` exits non-zero AND ``git rebase --abort`` also
|
|
exits non-zero, the method must still return a conflict result dict
|
|
without raising an exception.
|
|
|
|
Both are called with ``check=False`` so a non-zero exit code from
|
|
either command produces a result object (not a raised exception).
|
|
|
|
Call sequence:
|
|
[0] fetch origin
|
|
[1] checkout HEAD branch
|
|
[2] reset --hard origin/HEAD
|
|
[3] rebase origin/BASE ← exits 1 (conflict)
|
|
[4] diff --name-only ← lists conflicted files
|
|
[5] rebase --abort ← exits 1 (abort also fails)
|
|
"""
|
|
run = AsyncMock(
|
|
side_effect=[
|
|
_result(), # [0] fetch
|
|
_result(), # [1] checkout
|
|
_result(), # [2] reset
|
|
_result(returncode=1), # [3] rebase ← conflict
|
|
_result(stdout="src/conflict.py\n"), # [4] diff
|
|
_result(returncode=1), # [5] rebase --abort ← also fails
|
|
]
|
|
)
|
|
monkeypatch.setattr(GitService, "_run_git", run)
|
|
|
|
svc = _git_service()
|
|
|
|
# Must not raise even though both rebase and abort return non-zero
|
|
result = await svc.rebase_onto_base(
|
|
_WORKSPACE,
|
|
head_branch=_HEAD,
|
|
base_branch=_BASE,
|
|
git_token=_TOKEN,
|
|
)
|
|
|
|
assert result == {"status": "conflicts", "files": ["src/conflict.py"]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Safety gate tests for rebase() — protected-branch guard
|
|
# ---------------------------------------------------------------------------
|
|
# These test the service-layer ``rebase()`` method (the workspace-scoped API
|
|
# endpoint helper), NOT ``rebase_onto_base()`` (the internal gateway helper).
|
|
# The guard runs BEFORE any git command, so no ``_run_git`` mock is needed
|
|
# for target-branch cases; the head-branch case requires a stubbed
|
|
# ``get_current_branch``.
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_target_is_master() -> None:
|
|
"""rebase() must raise ValidationError for target_branch='master'."""
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
await svc.rebase(_WORKSPACE, "master")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_target_is_main() -> None:
|
|
"""rebase() must raise ValidationError for target_branch='main'."""
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
await svc.rebase(_WORKSPACE, "main")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_head_branch_is_master(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""rebase() must raise ValidationError when HEAD is 'master'.
|
|
|
|
The target-branch check passes (we pass a safe target), but the
|
|
head-branch guard fires when get_current_branch returns 'master'.
|
|
"""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"get_current_branch",
|
|
AsyncMock(return_value="master"),
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_raises_validation_error_when_head_branch_is_main(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""rebase() must raise ValidationError when HEAD is 'main'."""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"get_current_branch",
|
|
AsyncMock(return_value="main"),
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="REBASE_FORBIDDEN"):
|
|
await svc.rebase(_WORKSPACE, "feature/backend/some-task")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pull() safety-gate tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_raises_validation_error_on_dirty_tree(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""pull() raises ValidationError(DIRTY_WORKSPACE) when the tree is dirty.
|
|
|
|
The pre-flight ``git status --porcelain`` returns modified files, so pull
|
|
must reject immediately before any network call.
|
|
"""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"_run_git",
|
|
AsyncMock(return_value=_result(stdout=" M dirty.py\n")),
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="DIRTY_WORKSPACE"):
|
|
await svc.pull(_WORKSPACE)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_raises_validation_error_on_diverged_branch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""pull() raises ValidationError(DIVERGED_BRANCH) when --ff-only fails.
|
|
|
|
The pre-flight status is clean, but ``git pull --ff-only`` exits non-zero
|
|
with a "not possible to fast-forward" message because the branch has
|
|
diverged from origin.
|
|
"""
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"_run_git",
|
|
AsyncMock(
|
|
side_effect=[
|
|
_result(stdout=""), # status --porcelain → clean
|
|
_result( # pull --ff-only → diverged
|
|
returncode=1,
|
|
stderr="fatal: Not possible to fast-forward, aborting.",
|
|
),
|
|
]
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
GitService, "_token_for_workspace", AsyncMock(return_value=None)
|
|
)
|
|
svc = _git_service()
|
|
with pytest.raises(ValidationError, match="DIVERGED_BRANCH"):
|
|
await svc.pull(_WORKSPACE)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pull_success_returns_post_pull_status(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""pull() returns the post-pull status on a clean, fast-forwardable branch.
|
|
|
|
The pre-flight ``git status --porcelain`` is clean and ``git pull --ff-only``
|
|
succeeds, so pull() returns the post-pull ``get_status`` tuple.
|
|
"""
|
|
_post_pull: tuple[str, bool, list[str], list[str], list[str], int, int] = (
|
|
"feature/backend/task",
|
|
False,
|
|
[],
|
|
[],
|
|
[],
|
|
0,
|
|
0,
|
|
)
|
|
monkeypatch.setattr(
|
|
GitService,
|
|
"_run_git",
|
|
AsyncMock(side_effect=[_result(stdout=""), _result(returncode=0)]),
|
|
)
|
|
monkeypatch.setattr(
|
|
GitService, "_token_for_workspace", AsyncMock(return_value=None)
|
|
)
|
|
monkeypatch.setattr(GitService, "get_status", AsyncMock(return_value=_post_pull))
|
|
|
|
svc = _git_service()
|
|
result = await svc.pull(_WORKSPACE)
|
|
|
|
assert result == _post_pull
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GitRebaseRequest.target_branch field validator tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_rebase_request_target_branch_dash_prefix_rejected() -> None:
|
|
"""GitRebaseRequest rejects target_branch that starts with '-'.
|
|
|
|
Branch names beginning with '-' are not valid git ref names and look
|
|
like CLI flags, so the schema validator rejects them with a clear error.
|
|
"""
|
|
with pytest.raises(pydantic.ValidationError, match="INVALID_TARGET_BRANCH"):
|
|
GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="-bad-branch",
|
|
)
|
|
|
|
|
|
def test_rebase_request_target_branch_protected_name_rejected() -> None:
|
|
"""GitRebaseRequest rejects target_branch 'main' (a protected branch name)."""
|
|
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
|
|
GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="main",
|
|
)
|
|
|
|
|
|
def test_rebase_request_target_branch_master_rejected() -> None:
|
|
"""GitRebaseRequest rejects target_branch 'master' (a protected branch name)."""
|
|
with pytest.raises(pydantic.ValidationError, match="PROTECTED_BRANCH"):
|
|
GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="master",
|
|
)
|
|
|
|
|
|
def test_rebase_request_valid_target_branch_accepted() -> None:
|
|
"""GitRebaseRequest accepts a valid, non-protected target_branch."""
|
|
req = GitRebaseRequest(
|
|
project_slug="roboco",
|
|
target_branch="feature/backend/some-task",
|
|
)
|
|
assert req.target_branch == "feature/backend/some-task"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route-level tests: role gate on POST /rebase
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _mock_db_generator() -> Any:
|
|
"""Async generator yielding a MagicMock as the database session.
|
|
|
|
FastAPI's original ``get_db`` is an async generator (uses ``yield``).
|
|
The override must also be a generator (or at least async) so FastAPI
|
|
handles the dependency lifecycle correctly.
|
|
"""
|
|
yield MagicMock()
|
|
|
|
|
|
def _build_git_app(agent_context: AgentContext) -> FastAPI:
|
|
"""Minimal FastAPI app with the git router and overridden agent context."""
|
|
app = FastAPI()
|
|
app.include_router(git_router, prefix="/git")
|
|
app.dependency_overrides[get_agent_context] = lambda: agent_context
|
|
app.dependency_overrides[get_db] = _mock_db_generator
|
|
return app
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_endpoint_developer_gets_403() -> None:
|
|
"""POST /git/rebase returns HTTP 403 for a DEVELOPER-role agent.
|
|
|
|
The role gate fires before any service call, so no git service mock
|
|
is needed.
|
|
"""
|
|
agent = AgentContext(agent_id=uuid4(), role=AgentRole.DEVELOPER)
|
|
app = _build_git_app(agent)
|
|
transport = ASGITransport(app=app)
|
|
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/git/rebase",
|
|
json={
|
|
"project_slug": "roboco",
|
|
"target_branch": "feature/backend/some-task",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == _HTTP_403
|
|
detail = response.json()["detail"]
|
|
assert "REBASE_ROLE_RESTRICTED" in detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebase_endpoint_pm_gets_200() -> None:
|
|
"""POST /git/rebase returns HTTP 200 for a CELL_PM-role agent.
|
|
|
|
The role gate passes; no task_id is supplied so the ownership check
|
|
is skipped; project resolution and the git service are patched.
|
|
"""
|
|
agent = AgentContext(agent_id=uuid4(), role=AgentRole.CELL_PM)
|
|
app = _build_git_app(agent)
|
|
|
|
# Mock project service → returns a project with slug "roboco"
|
|
mock_project = MagicMock()
|
|
mock_project.slug = "roboco"
|
|
mock_project_svc = MagicMock()
|
|
mock_project_svc.get_by_slug = AsyncMock(return_value=mock_project)
|
|
|
|
# Mock git service → workspace + rebase succeed without conflict
|
|
mock_git_svc = MagicMock()
|
|
mock_git_svc.get_workspace = AsyncMock(return_value=Path("/tmp/fake-ws"))
|
|
mock_git_svc.rebase = AsyncMock(return_value=(False, []))
|
|
|
|
transport = ASGITransport(app=app)
|
|
|
|
with (
|
|
patch(
|
|
"roboco.api.routes.git.get_project_service", return_value=mock_project_svc
|
|
),
|
|
patch("roboco.api.routes.git.get_git_service", return_value=mock_git_svc),
|
|
):
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
response = await client.post(
|
|
"/git/rebase",
|
|
json={
|
|
"project_slug": "roboco",
|
|
"target_branch": "feature/backend/some-task",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == _HTTP_200
|
|
body = response.json()
|
|
assert body["project_slug"] == "roboco"
|
|
assert body["conflict"] is False
|
|
assert body["conflicted_files"] == []
|