diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py index 3a4f444..6a90d57 100644 --- a/backend/app/api/forgejo_metrics.py +++ b/backend/app/api/forgejo_metrics.py @@ -43,10 +43,13 @@ SESSION_DEP = Depends(get_session) "example": { "open_issues": 25, "closed_issues": 150, + "closed_in_selected_range": 12, + "selected_range_days": 7, "closed_last_7_days": 12, "closed_last_30_days": 35, "stale_open_issues": 5, "repositories_synced": 3, + "repository_sync_error_count": 1, "last_sync_timestamps": { "repo_1": "2026-05-19T03:00:00+00:00", "repo_2": "2026-05-19T02:30:00+00:00", @@ -79,6 +82,12 @@ async def get_forgejo_metrics( None, description="Filter by specific repository ID", ), + closed_range_days: int = Query( + 7, + ge=1, + le=365, + description="Window (in days) for closed_in_selected_range", + ), session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> MetricsResponse: @@ -156,8 +165,21 @@ async def get_forgejo_metrics( closed_count = await session.exec(closed_statement) closed_issues = closed_count.one_or_none() or 0 - # 3. Closed in last 7 days + # 3. Closed in selected range now = utcnow() + selected_range_start = now - timedelta(days=closed_range_days) + closed_selected_statement = select(func.count(ForgejoIssue.id)).where( + and_( + ForgejoIssue.repository_id.in_(repo_ids), + ForgejoIssue.state == "closed", + ForgejoIssue.is_pull_request.is_(False), + ForgejoIssue.forgejo_closed_at >= selected_range_start, + ) + ) + closed_selected_count = await session.exec(closed_selected_statement) + closed_in_selected_range = closed_selected_count.one_or_none() or 0 + + # 3. Closed in last 7 days seven_days_ago = now - timedelta(days=7) closed_7_statement = select(func.count(ForgejoIssue.id)).where( and_( @@ -216,14 +238,18 @@ async def get_forgejo_metrics( sync_error_counts[repo_key] = 1 if repo.last_sync_error else 0 repositories_synced = len(repo_ids) + repository_sync_error_count = sum(sync_error_counts.values()) return MetricsResponse( open_issues=open_issues, closed_issues=closed_issues, + closed_in_selected_range=closed_in_selected_range, + selected_range_days=closed_range_days, closed_last_7_days=closed_last_7_days, closed_last_30_days=closed_last_30_days, stale_open_issues=stale_open_issues, repositories_synced=repositories_synced, + repository_sync_error_count=repository_sync_error_count, last_sync_timestamps=last_sync_timestamps, sync_error_counts=sync_error_counts, ) @@ -234,10 +260,13 @@ def _zeroed_metrics() -> MetricsResponse: return MetricsResponse( open_issues=0, closed_issues=0, + closed_in_selected_range=0, + selected_range_days=7, closed_last_7_days=0, closed_last_30_days=0, stale_open_issues=0, repositories_synced=0, + repository_sync_error_count=0, last_sync_timestamps={}, sync_error_counts={}, ) diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py index ca0ab87..72d990b 100644 --- a/backend/app/schemas/metrics.py +++ b/backend/app/schemas/metrics.py @@ -110,10 +110,13 @@ class ForgejoIssueMetrics(SQLModel): open_issues: int closed_issues: int + closed_in_selected_range: int + selected_range_days: int closed_last_7_days: int closed_last_30_days: int stale_open_issues: int repositories_synced: int + repository_sync_error_count: int last_sync_timestamps: dict[str, str] sync_error_counts: dict[str, int] @@ -123,9 +126,12 @@ class MetricsResponse(SQLModel): open_issues: int = 0 closed_issues: int = 0 + closed_in_selected_range: int = 0 + selected_range_days: int = 7 closed_last_7_days: int = 0 closed_last_30_days: int = 0 stale_open_issues: int = 0 repositories_synced: int = 0 + repository_sync_error_count: int = 0 last_sync_timestamps: dict[str, str] = {} sync_error_counts: dict[str, int] = {} diff --git a/backend/tests/test_forgejo_metrics_api.py b/backend/tests/test_forgejo_metrics_api.py index 8a7ccee..e3c1fa2 100644 --- a/backend/tests/test_forgejo_metrics_api.py +++ b/backend/tests/test_forgejo_metrics_api.py @@ -275,3 +275,186 @@ async def test_forgejo_metrics_use_remote_issue_timestamps() -> None: assert data["stale_open_issues"] == 1 finally: await engine.dispose() + + +@pytest.mark.asyncio +async def test_forgejo_metrics_empty_scope_returns_zeroes() -> 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), + ) + + try: + async with session_maker() as session: + session.add(organization) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get("/api/v1/forgejo/metrics") + + assert response.status_code == 200 + data = response.json() + assert data["open_issues"] == 0 + assert data["closed_issues"] == 0 + assert data["closed_in_selected_range"] == 0 + assert data["stale_open_issues"] == 0 + assert data["repositories_synced"] == 0 + assert data["repository_sync_error_count"] == 0 + assert data["last_sync_timestamps"] == {} + assert data["sync_error_counts"] == {} + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_forgejo_metrics_board_and_repo_filters_exclude_pull_requests() -> 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", + ) + repo_a = ForgejoRepository( + id=uuid4(), + organization_id=organization.id, + connection_id=connection.id, + owner="openclaw", + repo="pipeline-a", + display_name="Pipeline A", + last_sync_at=utcnow(), + ) + repo_b = ForgejoRepository( + id=uuid4(), + organization_id=organization.id, + connection_id=connection.id, + owner="openclaw", + repo="pipeline-b", + display_name="Pipeline B", + last_sync_error="Sync failed", + ) + board = Board( + id=uuid4(), + organization_id=organization.id, + name="Pipeline Board", + slug="pipeline-board", + ) + link = BoardRepositoryLink( + id=uuid4(), + organization_id=organization.id, + board_id=board.id, + repository_id=repo_a.id, + ) + now = utcnow() + + open_issue = _issue( + organization_id=organization.id, + repository_id=repo_a.id, + issue_number=1, + ) + closed_issue = _issue( + organization_id=organization.id, + repository_id=repo_a.id, + issue_number=2, + ) + closed_issue.state = "closed" + closed_issue.forgejo_closed_at = now - timedelta(days=3) + closed_issue.forgejo_updated_at = now - timedelta(days=3) + + stale_issue = _issue( + organization_id=organization.id, + repository_id=repo_a.id, + issue_number=3, + ) + stale_issue.forgejo_updated_at = now - timedelta(days=20) + + pull_request = _issue( + organization_id=organization.id, + repository_id=repo_a.id, + issue_number=4, + ) + pull_request.is_pull_request = True + pull_request.state = "closed" + pull_request.forgejo_closed_at = now - timedelta(days=1) + + repo_b_issue = _issue( + organization_id=organization.id, + repository_id=repo_b.id, + issue_number=5, + ) + repo_b_issue.state = "closed" + repo_b_issue.forgejo_closed_at = now - timedelta(days=2) + + try: + async with session_maker() as session: + session.add(organization) + session.add(connection) + session.add(repo_a) + session.add(repo_b) + session.add(board) + session.add(link) + session.add(open_issue) + session.add(closed_issue) + session.add(stale_issue) + session.add(pull_request) + session.add(repo_b_issue) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + board_metrics = await client.get(f"/api/v1/forgejo/metrics?board_id={board.id}") + repo_metrics = await client.get(f"/api/v1/forgejo/metrics?repository_id={repo_a.id}") + org_metrics = await client.get( + f"/api/v1/forgejo/metrics?organization_id={organization.id}&closed_range_days=14" + ) + + assert board_metrics.status_code == 200 + board_data = board_metrics.json() + assert board_data["open_issues"] == 2 + assert board_data["closed_issues"] == 1 + assert board_data["stale_open_issues"] == 1 + assert board_data["closed_in_selected_range"] == 1 + assert board_data["repository_sync_error_count"] == 0 + + assert repo_metrics.status_code == 200 + repo_data = repo_metrics.json() + assert repo_data["open_issues"] == 2 + assert repo_data["closed_issues"] == 1 + assert repo_data["closed_in_selected_range"] == 1 + assert repo_data["stale_open_issues"] == 1 + + assert org_metrics.status_code == 200 + org_data = org_metrics.json() + assert org_data["open_issues"] == 2 + assert org_data["closed_issues"] == 2 + assert org_data["selected_range_days"] == 14 + assert org_data["closed_in_selected_range"] == 2 + assert org_data["repository_sync_error_count"] == 1 + finally: + await engine.dispose()