This commit is contained in:
null 2026-05-20 01:35:45 -05:00
parent 0d5d6573ed
commit d45fd42c7f
4 changed files with 226 additions and 17 deletions

View File

@ -4,21 +4,19 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import select, func
from sqlalchemy import and_ from sqlalchemy import and_
from sqlmodel import func, select
from app.api.deps import ORG_MEMBER_DEP, OrganizationContext from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
from app.core.agent_auth import get_agent_auth_context, AgentAuthContext
from app.db.session import get_session from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository from app.models.forgejo_repositories import ForgejoRepository
from app.schemas.metrics import MetricsResponse from app.schemas.metrics import MetricsResponse
from app.core.agent_auth import get_agent_auth_context, AgentAuthContext
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@ -68,15 +66,15 @@ SESSION_DEP = Depends(get_session)
}, },
) )
async def get_forgejo_metrics( async def get_forgejo_metrics(
organization_id: str | None = Query( organization_id: UUID | None = Query(
None, None,
description="Filter by organization ID", description="Filter by organization ID",
), ),
board_id: str | None = Query( board_id: UUID | None = Query(
None, None,
description="Filter by board ID (via linked repositories)", description="Filter by board ID (via linked repositories)",
), ),
repository_id: str | None = Query( repository_id: UUID | None = Query(
None, None,
description="Filter by specific repository ID", description="Filter by specific repository ID",
), ),
@ -93,18 +91,29 @@ async def get_forgejo_metrics(
Empty scope (no filters) returns zeroed metrics. Empty scope (no filters) returns zeroed metrics.
""" """
# Determine scope # Determine scope
if organization_id and organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if repository_id: if repository_id:
# Single repository # Single repository
repo_ids = [repository_id]
if board_id or organization_id: if board_id or organization_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot combine repository_id with board_id or organization_id", detail="Cannot combine repository_id with board_id or organization_id",
) )
repo_statement = select(ForgejoRepository).where(
ForgejoRepository.id == repository_id,
ForgejoRepository.organization_id == ctx.organization.id,
)
repo = (await session.exec(repo_statement)).first()
if repo is None:
return _zeroed_metrics()
repo_ids = [repo.id]
elif board_id: elif board_id:
# Board-scoped: get linked repositories # Board-scoped: get linked repositories
link_statement = select(BoardRepositoryLink).where( link_statement = select(BoardRepositoryLink).where(
BoardRepositoryLink.board_id == board_id BoardRepositoryLink.board_id == board_id,
BoardRepositoryLink.organization_id == ctx.organization.id,
) )
links = (await session.exec(link_statement)).all() links = (await session.exec(link_statement)).all()
repo_ids = [link.repository_id for link in links] repo_ids = [link.repository_id for link in links]
@ -113,10 +122,10 @@ async def get_forgejo_metrics(
elif organization_id: elif organization_id:
# Organization-scoped: all repositories in org # Organization-scoped: all repositories in org
repo_statement = select(ForgejoRepository.id).where( repo_statement = select(ForgejoRepository.id).where(
ForgejoRepository.organization_id == organization_id ForgejoRepository.organization_id == ctx.organization.id,
) )
repos = (await session.exec(repo_statement)).all() repos = (await session.exec(repo_statement)).all()
repo_ids = [r.id for r in repos] repo_ids = list(repos)
if not repo_ids: if not repo_ids:
return _zeroed_metrics() return _zeroed_metrics()
else: else:
@ -197,12 +206,13 @@ async def get_forgejo_metrics(
) )
repo = (await session.exec(repo_statement)).first() repo = (await session.exec(repo_statement)).first()
if repo: if repo:
last_sync_timestamps[repo_id] = ( repo_key = str(repo_id)
last_sync_timestamps[repo_key] = (
repo.last_sync_at.isoformat() repo.last_sync_at.isoformat()
if repo.last_sync_at if repo.last_sync_at
else "" else ""
) )
sync_error_counts[repo_id] = 1 if repo.last_sync_error else 0 sync_error_counts[repo_key] = 1 if repo.last_sync_error else 0
repositories_synced = len(repo_ids) repositories_synced = len(repo_ids)

View File

@ -0,0 +1,191 @@
# ruff: noqa: INP001
"""Integration tests for Forgejo metrics scoping."""
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
import pytest
from fastapi import APIRouter, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from app import models as _models
from app.api.deps import require_org_member
from app.api.forgejo_metrics import router as forgejo_metrics_router
from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
from app.models.boards import Board
from app.models.forgejo_connections import ForgejoConnection
from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.services.organizations import OrganizationContext
async def _make_engine() -> AsyncEngine:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
return engine
def _build_test_app(
session_maker: async_sessionmaker[AsyncSession],
ctx: OrganizationContext,
) -> FastAPI:
app = FastAPI()
api_v1 = APIRouter(prefix="/api/v1")
api_v1.include_router(forgejo_metrics_router)
app.include_router(api_v1)
async def _override_get_session() -> AsyncSession:
async with session_maker() as session:
yield session
async def _override_require_org_member() -> OrganizationContext:
return ctx
app.dependency_overrides[get_session] = _override_get_session
app.dependency_overrides[require_org_member] = _override_require_org_member
return app
def _issue(
*,
organization_id,
repository_id,
issue_number: int,
) -> ForgejoIssue:
return ForgejoIssue(
id=uuid4(),
organization_id=organization_id,
repository_id=repository_id,
forgejo_issue_number=issue_number,
title=f"Issue {issue_number}",
body_preview="Cached issue body",
state="open",
is_pull_request=False,
labels=[],
assignees=[],
author="kaspa",
html_url=f"https://forgejo.example.local/openclaw/pipeline/issues/{issue_number}",
forgejo_created_at=datetime(2026, 5, 19, 12, 0, 0),
forgejo_updated_at=datetime(2026, 5, 19, 12, 5, 0),
)
@pytest.mark.asyncio
async def test_forgejo_metrics_are_scoped_to_active_organization() -> None:
engine = await _make_engine()
session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
org_a = Organization(id=uuid4(), name="Org A")
org_b = Organization(id=uuid4(), name="Org B")
member = OrganizationMember(
id=uuid4(),
organization_id=org_a.id,
user_id=uuid4(),
role="owner",
)
app = _build_test_app(
session_maker,
OrganizationContext(organization=org_a, member=member),
)
conn_a = ForgejoConnection(
id=uuid4(),
organization_id=org_a.id,
name="A Forgejo",
base_url="https://forgejo-a.example.local",
)
conn_b = ForgejoConnection(
id=uuid4(),
organization_id=org_b.id,
name="B Forgejo",
base_url="https://forgejo-b.example.local",
)
repo_a = ForgejoRepository(
id=uuid4(),
organization_id=org_a.id,
connection_id=conn_a.id,
owner="openclaw",
repo="pipeline",
display_name="Pipeline",
)
repo_b = ForgejoRepository(
id=uuid4(),
organization_id=org_b.id,
connection_id=conn_b.id,
owner="other",
repo="private",
display_name="Private",
)
board_b = Board(
id=uuid4(),
organization_id=org_b.id,
name="Other Board",
slug="other-board",
)
link_b = BoardRepositoryLink(
id=uuid4(),
organization_id=org_b.id,
board_id=board_b.id,
repository_id=repo_b.id,
)
try:
async with session_maker() as session:
session.add(org_a)
session.add(org_b)
session.add(conn_a)
session.add(conn_b)
session.add(repo_a)
session.add(repo_b)
session.add(board_b)
session.add(link_b)
session.add(
_issue(
organization_id=org_a.id,
repository_id=repo_a.id,
issue_number=1,
)
)
session.add(
_issue(
organization_id=org_b.id,
repository_id=repo_b.id,
issue_number=2,
)
)
await session.commit()
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://testserver",
) as client:
own_org = await client.get(
f"/api/v1/forgejo/metrics?organization_id={org_a.id}"
)
other_org = await client.get(
f"/api/v1/forgejo/metrics?organization_id={org_b.id}"
)
other_repo = await client.get(
f"/api/v1/forgejo/metrics?repository_id={repo_b.id}"
)
other_board = await client.get(
f"/api/v1/forgejo/metrics?board_id={board_b.id}"
)
assert own_org.status_code == 200
assert own_org.json()["open_issues"] == 1
assert other_org.status_code == 403
assert other_repo.status_code == 200
assert other_repo.json()["open_issues"] == 0
assert other_board.status_code == 200
assert other_board.json()["open_issues"] == 0
finally:
await engine.dispose()

View File

@ -230,6 +230,14 @@ const formatPerDay = (total: number, days: number): string => {
return `${(total / days).toFixed(1)}/day`; return `${(total / days).toFixed(1)}/day`;
}; };
const formatForgejoDashboardError = (error: Error | null): string | null => {
if (!error) return null;
if (error.message === "Failed to fetch") {
return "Pipeline could not load Git Project data. Check that the frontend can reach the backend API.";
}
return error.message;
};
const toSessionSummaries = ( const toSessionSummaries = (
sessions: unknown[] | null | undefined, sessions: unknown[] | null | undefined,
mainSession: unknown, mainSession: unknown,
@ -813,8 +821,8 @@ export default function DashboardPage() {
const activityFeedHref = "/activity"; const activityFeedHref = "/activity";
const forgejoIssueMetrics = forgejoMetricsQuery.data ?? null; const forgejoIssueMetrics = forgejoMetricsQuery.data ?? null;
const forgejoIssueMetricsError = const forgejoIssueMetricsError =
forgejoRepositoriesQuery.error?.message ?? formatForgejoDashboardError(forgejoRepositoriesQuery.error) ??
forgejoMetricsQuery.error?.message ?? formatForgejoDashboardError(forgejoMetricsQuery.error) ??
null; null;
const forgejoIssueMetricsLoading = const forgejoIssueMetricsLoading =
forgejoRepositoriesQuery.isLoading || forgejoMetricsQuery.isLoading; forgejoRepositoriesQuery.isLoading || forgejoMetricsQuery.isLoading;

View File

@ -271,12 +271,12 @@ export function ForgejoIssueMetricCards({
))} ))}
</div> </div>
{!isLoading && repositories.length === 0 ? ( {!isLoading && !error && repositories.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted"> <div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
No Git Project repositories are configured yet. Metrics will populate No Git Project repositories are configured yet. Metrics will populate
after repositories are added and synced. after repositories are added and synced.
</div> </div>
) : !isLoading && repositoriesSynced === 0 ? ( ) : !isLoading && !error && repositoriesSynced === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted"> <div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
Git Project repositories are configured, but Pipeline has not synced Git Project repositories are configured, but Pipeline has not synced
issue metrics yet. issue metrics yet.