diff --git a/backend/app/api/forgejo_connections.py b/backend/app/api/forgejo_connections.py index 11cdb71..ae94e14 100644 --- a/backend/app/api/forgejo_connections.py +++ b/backend/app/api/forgejo_connections.py @@ -138,11 +138,11 @@ async def update_connection( raw_url = match.group(1).rstrip("/") updates["base_url"] = raw_url - # Handle token update - empty string leaves existing unchanged + # Handle token update. The update schema normalizes blank strings to None; + # treat both as "leave the existing token unchanged" for edit forms. if "token" in updates: raw_token = updates["token"] - if raw_token == "": - # Empty string - leave existing token unchanged + if raw_token in ("", None): del updates["token"] elif raw_token is not None: updates["token_last_eight"] = _extract_token_last_eight(raw_token) diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index 0a8845a..3a4f444 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import timedelta from typing import TYPE_CHECKING from uuid import UUID @@ -11,6 +11,7 @@ from sqlalchemy import and_ from sqlmodel import func, select from app.api.deps import ORG_MEMBER_DEP, OrganizationContext +from app.core.time import utcnow from app.db.session import get_session from app.models.board_repository_links import BoardRepositoryLink from app.models.forgejo_issues import ForgejoIssue @@ -156,14 +157,14 @@ async def get_forgejo_metrics( closed_issues = closed_count.one_or_none() or 0 # 3. Closed in last 7 days - now = datetime.now(timezone.utc) + now = utcnow() seven_days_ago = now - timedelta(days=7) closed_7_statement = select(func.count(ForgejoIssue.id)).where( and_( ForgejoIssue.repository_id.in_(repo_ids), ForgejoIssue.state == "closed", ForgejoIssue.is_pull_request.is_(False), - ForgejoIssue.updated_at >= seven_days_ago, + ForgejoIssue.forgejo_closed_at >= seven_days_ago, ) ) closed_7_count = await session.exec(closed_7_statement) @@ -176,7 +177,7 @@ async def get_forgejo_metrics( ForgejoIssue.repository_id.in_(repo_ids), ForgejoIssue.state == "closed", ForgejoIssue.is_pull_request.is_(False), - ForgejoIssue.updated_at >= thirty_days_ago, + ForgejoIssue.forgejo_closed_at >= thirty_days_ago, ) ) closed_30_count = await session.exec(closed_30_statement) @@ -189,7 +190,7 @@ async def get_forgejo_metrics( ForgejoIssue.repository_id.in_(repo_ids), ForgejoIssue.state == "open", ForgejoIssue.is_pull_request.is_(False), - ForgejoIssue.updated_at < fourteen_days_ago, + ForgejoIssue.forgejo_updated_at < fourteen_days_ago, ) ) stale_count = await session.exec(stale_statement) diff --git a/backend/tests/test_forgejo_connections_api.py b/backend/tests/test_forgejo_connections_api.py index af74945..abe3bf5 100644 --- a/backend/tests/test_forgejo_connections_api.py +++ b/backend/tests/test_forgejo_connections_api.py @@ -157,3 +157,61 @@ async def test_delete_connection_removes_repositories_and_dependents() -> None: ).first() is not None finally: await engine.dispose() + + +@pytest.mark.asyncio +async def test_update_connection_blank_token_keeps_existing_token() -> None: + engine = await _make_engine() + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + organization = Organization(id=uuid4(), name="Pipeline") + member = OrganizationMember( + id=uuid4(), + organization_id=organization.id, + user_id=uuid4(), + role="owner", + ) + app = _build_test_app( + session_maker, + OrganizationContext(organization=organization, member=member), + ) + + connection = ForgejoConnection( + id=uuid4(), + organization_id=organization.id, + name="Dream Forgejo", + base_url="https://forgejo.example.local", + token="temp-token-12345678", + token_last_eight="12345678", + ) + + try: + async with session_maker() as session: + session.add(organization) + session.add(connection) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.patch( + f"/api/v1/forgejo/connections/{connection.id}", + json={"name": "Dream Forgejo Updated", "token": ""}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Dream Forgejo Updated" + assert data["has_token"] is True + assert data["token_last_eight"] == "12345678" + + async with session_maker() as session: + saved = ( + await session.exec( + select(ForgejoConnection).where(col(ForgejoConnection.id) == connection.id) + ) + ).one() + assert saved.token == "temp-token-12345678" + assert saved.token_last_eight == "12345678" + finally: + await engine.dispose() diff --git a/backend/tests/test_forgejo_metrics_api.py b/backend/tests/test_forgejo_metrics_api.py index 533f63d..8a7ccee 100644 --- a/backend/tests/test_forgejo_metrics_api.py +++ b/backend/tests/test_forgejo_metrics_api.py @@ -3,7 +3,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from uuid import uuid4 import pytest @@ -24,6 +24,7 @@ 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.core.time import utcnow from app.services.organizations import OrganizationContext @@ -189,3 +190,88 @@ async def test_forgejo_metrics_are_scoped_to_active_organization() -> None: assert other_board.json()["open_issues"] == 0 finally: await engine.dispose() + + +@pytest.mark.asyncio +async def test_forgejo_metrics_use_remote_issue_timestamps() -> None: + engine = await _make_engine() + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + organization = Organization(id=uuid4(), name="Pipeline") + member = OrganizationMember( + id=uuid4(), + organization_id=organization.id, + user_id=uuid4(), + role="owner", + ) + app = _build_test_app( + session_maker, + OrganizationContext(organization=organization, member=member), + ) + connection = ForgejoConnection( + id=uuid4(), + organization_id=organization.id, + name="Dream Forgejo", + base_url="https://forgejo.example.local", + ) + repository = ForgejoRepository( + id=uuid4(), + organization_id=organization.id, + connection_id=connection.id, + owner="openclaw", + repo="pipeline", + display_name="Pipeline", + ) + now = utcnow() + stale_open = _issue( + organization_id=organization.id, + repository_id=repository.id, + issue_number=1, + ) + stale_open.forgejo_updated_at = now - timedelta(days=20) + stale_open.updated_at = now + + recently_closed = _issue( + organization_id=organization.id, + repository_id=repository.id, + issue_number=2, + ) + recently_closed.state = "closed" + recently_closed.forgejo_closed_at = now - timedelta(days=2) + recently_closed.updated_at = now - timedelta(days=90) + + old_closed = _issue( + organization_id=organization.id, + repository_id=repository.id, + issue_number=3, + ) + old_closed.state = "closed" + old_closed.forgejo_closed_at = now - timedelta(days=45) + old_closed.updated_at = now + + try: + async with session_maker() as session: + session.add(organization) + session.add(connection) + session.add(repository) + session.add(stale_open) + session.add(recently_closed) + session.add(old_closed) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get( + f"/api/v1/forgejo/metrics?organization_id={organization.id}" + ) + + assert response.status_code == 200 + data = response.json() + assert data["open_issues"] == 1 + assert data["closed_issues"] == 2 + assert data["closed_last_7_days"] == 1 + assert data["closed_last_30_days"] == 1 + assert data["stale_open_issues"] == 1 + finally: + await engine.dispose()