feat: add Issue Tracking Metrics
This commit is contained in:
parent
7b20d2c26d
commit
7c35c889dc
|
|
@ -43,10 +43,13 @@ SESSION_DEP = Depends(get_session)
|
||||||
"example": {
|
"example": {
|
||||||
"open_issues": 25,
|
"open_issues": 25,
|
||||||
"closed_issues": 150,
|
"closed_issues": 150,
|
||||||
|
"closed_in_selected_range": 12,
|
||||||
|
"selected_range_days": 7,
|
||||||
"closed_last_7_days": 12,
|
"closed_last_7_days": 12,
|
||||||
"closed_last_30_days": 35,
|
"closed_last_30_days": 35,
|
||||||
"stale_open_issues": 5,
|
"stale_open_issues": 5,
|
||||||
"repositories_synced": 3,
|
"repositories_synced": 3,
|
||||||
|
"repository_sync_error_count": 1,
|
||||||
"last_sync_timestamps": {
|
"last_sync_timestamps": {
|
||||||
"repo_1": "2026-05-19T03:00:00+00:00",
|
"repo_1": "2026-05-19T03:00:00+00:00",
|
||||||
"repo_2": "2026-05-19T02:30:00+00:00",
|
"repo_2": "2026-05-19T02:30:00+00:00",
|
||||||
|
|
@ -79,6 +82,12 @@ async def get_forgejo_metrics(
|
||||||
None,
|
None,
|
||||||
description="Filter by specific repository ID",
|
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,
|
session: AsyncSession = SESSION_DEP,
|
||||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||||
) -> MetricsResponse:
|
) -> MetricsResponse:
|
||||||
|
|
@ -156,8 +165,21 @@ async def get_forgejo_metrics(
|
||||||
closed_count = await session.exec(closed_statement)
|
closed_count = await session.exec(closed_statement)
|
||||||
closed_issues = closed_count.one_or_none() or 0
|
closed_issues = closed_count.one_or_none() or 0
|
||||||
|
|
||||||
# 3. Closed in last 7 days
|
# 3. Closed in selected range
|
||||||
now = utcnow()
|
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)
|
seven_days_ago = now - timedelta(days=7)
|
||||||
closed_7_statement = select(func.count(ForgejoIssue.id)).where(
|
closed_7_statement = select(func.count(ForgejoIssue.id)).where(
|
||||||
and_(
|
and_(
|
||||||
|
|
@ -216,14 +238,18 @@ async def get_forgejo_metrics(
|
||||||
sync_error_counts[repo_key] = 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)
|
||||||
|
repository_sync_error_count = sum(sync_error_counts.values())
|
||||||
|
|
||||||
return MetricsResponse(
|
return MetricsResponse(
|
||||||
open_issues=open_issues,
|
open_issues=open_issues,
|
||||||
closed_issues=closed_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_7_days=closed_last_7_days,
|
||||||
closed_last_30_days=closed_last_30_days,
|
closed_last_30_days=closed_last_30_days,
|
||||||
stale_open_issues=stale_open_issues,
|
stale_open_issues=stale_open_issues,
|
||||||
repositories_synced=repositories_synced,
|
repositories_synced=repositories_synced,
|
||||||
|
repository_sync_error_count=repository_sync_error_count,
|
||||||
last_sync_timestamps=last_sync_timestamps,
|
last_sync_timestamps=last_sync_timestamps,
|
||||||
sync_error_counts=sync_error_counts,
|
sync_error_counts=sync_error_counts,
|
||||||
)
|
)
|
||||||
|
|
@ -234,10 +260,13 @@ def _zeroed_metrics() -> MetricsResponse:
|
||||||
return MetricsResponse(
|
return MetricsResponse(
|
||||||
open_issues=0,
|
open_issues=0,
|
||||||
closed_issues=0,
|
closed_issues=0,
|
||||||
|
closed_in_selected_range=0,
|
||||||
|
selected_range_days=7,
|
||||||
closed_last_7_days=0,
|
closed_last_7_days=0,
|
||||||
closed_last_30_days=0,
|
closed_last_30_days=0,
|
||||||
stale_open_issues=0,
|
stale_open_issues=0,
|
||||||
repositories_synced=0,
|
repositories_synced=0,
|
||||||
|
repository_sync_error_count=0,
|
||||||
last_sync_timestamps={},
|
last_sync_timestamps={},
|
||||||
sync_error_counts={},
|
sync_error_counts={},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,13 @@ class ForgejoIssueMetrics(SQLModel):
|
||||||
|
|
||||||
open_issues: int
|
open_issues: int
|
||||||
closed_issues: int
|
closed_issues: int
|
||||||
|
closed_in_selected_range: int
|
||||||
|
selected_range_days: int
|
||||||
closed_last_7_days: int
|
closed_last_7_days: int
|
||||||
closed_last_30_days: int
|
closed_last_30_days: int
|
||||||
stale_open_issues: int
|
stale_open_issues: int
|
||||||
repositories_synced: int
|
repositories_synced: int
|
||||||
|
repository_sync_error_count: int
|
||||||
last_sync_timestamps: dict[str, str]
|
last_sync_timestamps: dict[str, str]
|
||||||
sync_error_counts: dict[str, int]
|
sync_error_counts: dict[str, int]
|
||||||
|
|
||||||
|
|
@ -123,9 +126,12 @@ class MetricsResponse(SQLModel):
|
||||||
|
|
||||||
open_issues: int = 0
|
open_issues: int = 0
|
||||||
closed_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_7_days: int = 0
|
||||||
closed_last_30_days: int = 0
|
closed_last_30_days: int = 0
|
||||||
stale_open_issues: int = 0
|
stale_open_issues: int = 0
|
||||||
repositories_synced: int = 0
|
repositories_synced: int = 0
|
||||||
|
repository_sync_error_count: int = 0
|
||||||
last_sync_timestamps: dict[str, str] = {}
|
last_sync_timestamps: dict[str, str] = {}
|
||||||
sync_error_counts: dict[str, int] = {}
|
sync_error_counts: dict[str, int] = {}
|
||||||
|
|
|
||||||
|
|
@ -275,3 +275,186 @@ async def test_forgejo_metrics_use_remote_issue_timestamps() -> None:
|
||||||
assert data["stale_open_issues"] == 1
|
assert data["stale_open_issues"] == 1
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue