Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@

- **Open `AGENTS.md` again** when you begin a task on this repo or return after a long gap, so required checks (below) stay in context until **`tox`** and frontend checks are green.

## Attributing AI coding assistance

When using AI coding assistants to generate a module or a new function, annotate that code with a module comment or a function docstring using the `Generated-by: <name of the AI coding assistant>` tag. When substantial code changes (not including minor formatting or typo fixes) are made to a module or function not already annotated with "Generated-by" or "Assisted-by", annotate the changes with the `Assisted-by: <name of the AI coding assistant>` tag.

```markdown
Assisted-by: <name of the AI coding assistant>
```

For example:

```markdown
Assisted-by: Cursor
Assisted-by: GitHub Copilot
Assisted-by: Claude
Assisted-by: gemini-code-assist
Assisted-by: openai-code-assist
```

## Required checks before finishing any task

### Python backend (`backend/`)
Expand Down
5 changes: 4 additions & 1 deletion backend/src/github_pm/sdlc_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""SDLC KPI REST endpoints (GitHub-backed)."""
"""SDLC KPI REST endpoints (GitHub-backed).

Generated-by: Cursor
"""

from __future__ import annotations

Expand Down
5 changes: 4 additions & 1 deletion backend/src/github_pm/sdlc_metrics.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""SDLC KPI helpers: PR classification, size buckets, medians, GitHub search."""
"""SDLC KPI helpers: PR classification, size buckets, medians, GitHub search.

Generated-by: Cursor
"""

from __future__ import annotations

Expand Down
5 changes: 4 additions & 1 deletion backend/src/github_pm/sdlc_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Pydantic models for SDLC KPI API responses."""
"""Pydantic models for SDLC KPI API responses.

Generated-by: Cursor
"""

from __future__ import annotations

Expand Down
2 changes: 2 additions & 0 deletions backend/src/github_pm/sdlc_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
The standalone ``sdlc-report`` script (``scripts/sdlc_report.py``) mirrors this
logic without importing ``github_pm``; keep behavior aligned when changing
metrics.

Generated-by: Cursor
"""

from __future__ import annotations
Expand Down
42 changes: 36 additions & 6 deletions backend/src/github_pm/status_report_api.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
"""REST API for the weekly project status report."""
"""REST API for the project status report.

Generated-by: Cursor
"""

from __future__ import annotations

from datetime import date, datetime, UTC
from datetime import date, datetime, timedelta, UTC
from typing import Annotated

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query

from github_pm.api import connection, Connector
from github_pm.status_report_models import ProjectStatusReportResponse
from github_pm.status_report_service import build_project_status_report

status_report_router = APIRouter(tags=["project-status"])

_MAX_RANGE_DAYS = 365


def _default_end_date() -> date:
return datetime.now(UTC).date()
Expand All @@ -21,17 +26,42 @@ def _default_end_date() -> date:
@status_report_router.get("/project-status", response_model=ProjectStatusReportResponse)
async def get_project_status_report(
gitctx: Annotated[Connector, Depends(connection)],
start_date: Annotated[
date | None,
Query(
description="First day of the window (UTC calendar date). Defaults to 7 days before end_date.",
),
] = None,
end_date: Annotated[
date | None,
Query(
description="Last day of the 7-day window (UTC calendar date). Defaults to today in UTC.",
description="Last day of the window (UTC calendar date). Defaults to today in UTC.",
),
] = None,
):
"""
Status for seven **calendar** days inclusive: ``end_date - 6 days`` through ``end_date``.
Status for the inclusive calendar range ``start_date`` … ``end_date`` (UTC).

Defaults: ``end_date`` = today (UTC), ``start_date`` = ``end_date`` minus 7 calendar days.

Sections: merged pull requests (by merge date), pull requests opened, issues opened (PRs excluded).
"""
resolved_end = end_date if end_date is not None else _default_end_date()
return build_project_status_report(gitctx, end_date=resolved_end)
resolved_start = (
start_date if start_date is not None else resolved_end - timedelta(days=7)
)
if resolved_start > resolved_end:
raise HTTPException(
status_code=422,
detail="start_date must be on or before end_date",
)
if (resolved_end - resolved_start).days > _MAX_RANGE_DAYS:
raise HTTPException(
status_code=422,
detail="Date range must not span more than 366 calendar days",
)
return build_project_status_report(
gitctx,
start_date=resolved_start,
end_date=resolved_end,
)
7 changes: 5 additions & 2 deletions backend/src/github_pm/status_report_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Pydantic models for the weekly project status report API."""
"""Pydantic models for the weekly project status report API.

Generated-by: Cursor
"""

from __future__ import annotations

Expand All @@ -16,7 +19,7 @@ class StatusReportItem(BaseModel):


class ProjectStatusReportResponse(BaseModel):
"""Seven calendar days inclusive ending on ``end_date`` (UTC calendar dates)."""
"""Inclusive calendar window from ``start_date`` through ``end_date`` (UTC calendar dates)."""

start_date: date = Field(description="First calendar day of the window (inclusive)")
end_date: date = Field(description="Last calendar day of the window (inclusive)")
Expand Down
33 changes: 12 additions & 21 deletions backend/src/github_pm/status_report_service.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Weekly project status via GraphQL ``search`` (REST ``/search/issues`` returns 422 for these queries)."""
"""Project status report via GraphQL ``search``

Generated-by: Cursor
"""

from __future__ import annotations

from datetime import date, datetime, timedelta, UTC
from datetime import date, UTC
from typing import Any

from github_pm import sdlc_metrics as sm
Expand All @@ -21,20 +24,6 @@ def _item_from_gql_node(node: dict[str, Any]) -> StatusReportItem:
)


def _merged_at_sort_key(node: dict[str, Any]) -> datetime:
m = sm.parse_github_ts(node.get("mergedAt"))
if not m:
return datetime.min.replace(tzinfo=UTC)
return m


def _created_at_sort_key(node: dict[str, Any]) -> datetime:
c = sm.parse_github_ts(node.get("createdAt"))
if not c:
return datetime.min.replace(tzinfo=UTC)
return c


def _merged_calendar_in_window(
node: dict[str, Any], start_d: date, end_d: date
) -> bool:
Expand All @@ -58,10 +47,12 @@ def _created_calendar_in_window(
def build_project_status_report(
gitctx: Connector,
*,
start_date: date,
end_date: date,
) -> ProjectStatusReportResponse:
"""Build the report for seven calendar days ending on ``end_date`` (inclusive)."""
start_date = end_date - timedelta(days=6)
"""Build the report for ``start_date`` through ``end_date`` (inclusive, UTC calendar dates)."""
if start_date > end_date:
raise ValueError("start_date must be on or before end_date")
repo = f"{gitctx.owner}/{gitctx.repo}"

def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
Expand All @@ -76,7 +67,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
merged_in_window = [
n for n in merged_nodes if _merged_calendar_in_window(n, start_date, end_date)
]
merged_in_window.sort(key=_merged_at_sort_key, reverse=True)
merged_in_window.sort(key=lambda n: int(n["number"]))
merged_items = [_item_from_gql_node(n) for n in merged_in_window]

opened_pr_q = sm.opened_prs_between_query(repo, start_date, end_date)
Expand All @@ -87,7 +78,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
if n.get("__typename") == "PullRequest"
and _created_calendar_in_window(n, start_date, end_date)
]
opened_pr_filtered.sort(key=_created_at_sort_key, reverse=True)
opened_pr_filtered.sort(key=lambda n: int(n["number"]))

opened_issue_q = sm.opened_issues_between_query(repo, start_date, end_date)
opened_issue_nodes = sm.graphql_search_timeline_nodes(post_gql, opened_issue_q)
Expand All @@ -97,7 +88,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
if n.get("__typename") == "Issue"
and _created_calendar_in_window(n, start_date, end_date)
]
opened_issue_filtered.sort(key=_created_at_sort_key, reverse=True)
opened_issue_filtered.sort(key=lambda n: int(n["number"]))

return ProjectStatusReportResponse(
start_date=start_date,
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the api module.

ai-generated: Cursor
Generated-by: Cursor
"""

from unittest.mock import Mock, patch
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the app module.

ai-generated: Cursor
Generated-by: Cursor
"""

from fastapi.testclient import TestClient
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the cli module.

ai-generated: Cursor
Generated-by: Cursor
"""

import sys
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_context.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for the context module.

ai-generated: Cursor
Generated-by: Cursor
"""

import os
Expand Down
5 changes: 4 additions & 1 deletion backend/tests/test_sdlc_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Tests for SDLC KPI API routes (mocked GitHub)."""
"""Tests for SDLC KPI API routes (mocked GitHub).

Generated-by: Cursor
"""

from datetime import datetime, UTC
from unittest.mock import MagicMock, patch
Expand Down
5 changes: 4 additions & 1 deletion backend/tests/test_sdlc_metrics.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Unit tests for sdlc_metrics helpers."""
"""Unit tests for sdlc_metrics helpers.

Generated-by: Cursor
"""

from datetime import datetime, UTC
import re
Expand Down
82 changes: 78 additions & 4 deletions backend/tests/test_status_report_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
"""Tests for project status report API (mocked GitHub GraphQL)."""
"""Tests for project status report API (mocked GitHub GraphQL).

Generated-by: Cursor
"""

from unittest.mock import MagicMock

Expand Down Expand Up @@ -94,7 +97,9 @@ def post_side(path: str, data=None, **kwargs):


class TestProjectStatusReport:
def test_report_ok_with_end_date(self, client, mock_connector_graphql):
def test_report_ok_with_end_date_only_defaults_start(
self, client, mock_connector_graphql
):
async def override_conn():
yield mock_connector_graphql

Expand All @@ -106,7 +111,7 @@ async def override_conn():

assert r.status_code == 200
body = r.json()
assert body["start_date"] == "2025-04-04"
assert body["start_date"] == "2025-04-03"
assert body["end_date"] == "2025-04-10"
assert body["merged_pull_requests"] == [
{
Expand All @@ -118,13 +123,82 @@ async def override_conn():
assert body["opened_pull_requests"][0]["number"] == 11
assert body["opened_issues"][0]["number"] == 12

def test_report_single_calendar_day(self, client, mock_connector_graphql):
async def override_conn():
yield mock_connector_graphql

app.dependency_overrides[connection] = override_conn
try:
r = client.get(
"/api/v1/project-status",
params={"start_date": "2025-04-10", "end_date": "2025-04-10"},
)
finally:
app.dependency_overrides.clear()

assert r.status_code == 200
body = r.json()
assert body["start_date"] == "2025-04-10"
assert body["end_date"] == "2025-04-10"

def test_report_explicit_range(self, client, mock_connector_graphql):
async def override_conn():
yield mock_connector_graphql

app.dependency_overrides[connection] = override_conn
try:
r = client.get(
"/api/v1/project-status",
params={"start_date": "2025-03-28", "end_date": "2025-04-10"},
)
finally:
app.dependency_overrides.clear()

assert r.status_code == 200
body = r.json()
assert body["start_date"] == "2025-03-28"
assert body["end_date"] == "2025-04-10"

def test_start_after_end(self, client, mock_connector_graphql):
async def override_conn():
yield mock_connector_graphql

app.dependency_overrides[connection] = override_conn
try:
r = client.get(
"/api/v1/project-status",
params={"start_date": "2025-04-11", "end_date": "2025-04-10"},
)
finally:
app.dependency_overrides.clear()

assert r.status_code == 422

def test_range_too_long(self, client, mock_connector_graphql):
async def override_conn():
yield mock_connector_graphql

app.dependency_overrides[connection] = override_conn
try:
r = client.get(
"/api/v1/project-status",
params={"start_date": "2024-01-01", "end_date": "2025-01-02"},
)
finally:
app.dependency_overrides.clear()

assert r.status_code == 422

def test_graphql_queries_cover_window(self, client, mock_connector_graphql):
async def override_conn():
yield mock_connector_graphql

app.dependency_overrides[connection] = override_conn
try:
client.get("/api/v1/project-status", params={"end_date": "2025-04-10"})
client.get(
"/api/v1/project-status",
params={"start_date": "2025-04-04", "end_date": "2025-04-10"},
)
finally:
app.dependency_overrides.clear()

Expand Down
2 changes: 1 addition & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!-- ai-generated: Cursor -->
<!-- Generated-by: Cursor -->
<!DOCTYPE html>
<html lang="en">
<head>
Expand Down
Loading
Loading