feat: add Issue Tracking Metrics

This commit is contained in:
null 2026-05-20 03:40:07 -05:00
parent 7b20d2c26d
commit 7c35c889dc
3 changed files with 219 additions and 1 deletions

View File

@ -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={},
)

View File

@ -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] = {}

View File

@ -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()