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
6 changes: 6 additions & 0 deletions backend/src/github_pm/sdlc_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,12 @@ def graphql_search_open_pull_requests_attention(
nodes {
submittedAt
state
author {
__typename
... on User { login }
... on Bot { login }
... on Organization { login }
}
}
}
additions
Expand Down
8 changes: 4 additions & 4 deletions backend/src/github_pm/status_report_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ async def get_project_status_report(
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), recently updated PRs (open, non-draft, touched in the
window but not opened in it), reviewer and creator attention (current open PRs
by merge/review state), and PR backlog (open, non-draft PRs not updated on or
after ``start_date``, UTC calendar dates).
opened (PRs excluded), PRs needing a second approval, recently updated PRs
(open, non-draft, touched in the window but not opened in it), reviewer and
creator attention (current open PRs by merge/review state), and PR backlog
(open, non-draft PRs not updated on or after ``start_date``, UTC calendar dates).
"""
resolved_end = end_date if end_date is not None else _default_end_date()
resolved_start = (
Expand Down
13 changes: 13 additions & 0 deletions backend/src/github_pm/status_report_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ class PrBacklogItem(StatusReportItem):
)


class SecondApprovalItem(StatusReportItem):
"""Ready-to-review PR row including its approving reviewer handle."""

reviewer_login: str = Field(description="GitHub handle of the approving reviewer")


class ProjectStatusReportResponse(BaseModel):
"""Inclusive calendar window from ``start_date`` through ``end_date`` (UTC calendar dates)."""

Expand All @@ -47,6 +53,13 @@ class ProjectStatusReportResponse(BaseModel):
default_factory=list,
description="Issues created in the window (pull requests excluded)",
)
second_approval_needed: list[SecondApprovalItem] = Field(
default_factory=list,
description=(
"Open, non-draft pull requests that are mergeable and clean, with exactly "
"one submitted approval"
),
)
recently_updated_pull_requests: list[StatusReportItem] = Field(
default_factory=list,
description=(
Expand Down
52 changes: 44 additions & 8 deletions backend/src/github_pm/status_report_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from github_pm.status_report_models import (
PrBacklogItem,
ProjectStatusReportResponse,
SecondApprovalItem,
StatusReportItem,
)

Expand Down Expand Up @@ -82,6 +83,20 @@ def _backlog_item_from_gql_node(node: dict[str, Any], end_d: date) -> PrBacklogI
)


def _second_approval_item_from_gql_node(
node: dict[str, Any], review: dict[str, Any]
) -> SecondApprovalItem:
base = _item_from_gql_node(node)
author = review.get("author")
reviewer_login = author.get("login") if isinstance(author, dict) else None
return SecondApprovalItem(
number=base.number,
title=base.title,
html_url=base.html_url,
reviewer_login=str(reviewer_login or ""),
)


def _max_submitted_review_time(node: dict[str, Any]) -> datetime | None:
"""Latest ``submittedAt`` among non-pending PR reviews, or ``None`` if none."""
best: datetime | None = None
Expand All @@ -98,11 +113,25 @@ def _max_submitted_review_time(node: dict[str, Any]) -> datetime | None:
return best


def _submitted_approval_reviews(node: dict[str, Any]) -> list[dict[str, Any]]:
"""Return submitted approval reviews on a pull request."""
return [
review
for review in (node.get("reviews") or {}).get("nodes") or []
if isinstance(review, dict) and review.get("state") == "APPROVED"
]


def _partition_attention_open_prs(
nodes: list[dict[str, Any]],
) -> tuple[list[StatusReportItem], list[StatusReportItem]]:
"""Split open PR nodes into reviewer vs creator attention lists."""
) -> tuple[
list[StatusReportItem],
list[StatusReportItem],
list[StatusReportItem],
]:
"""Split open PR nodes into reviewer, second-approval, and creator lists."""
reviewer_out: list[StatusReportItem] = []
second_approval_out: list[StatusReportItem] = []
creator_out: list[StatusReportItem] = []
for n in nodes:
if n.get("state") != "OPEN" or n.get("mergedAt"):
Expand All @@ -118,18 +147,24 @@ def _partition_attention_open_prs(
"BEHIND",
"DIRTY",
)
if (
is_ready_for_review = (
not is_draft
and mergeable == "MERGEABLE"
and merge_state not in ("BEHIND", "DIRTY")
and (last_review is None or updated > last_review)
):
)
approval_reviews = _submitted_approval_reviews(n)
if is_ready_for_review and len(approval_reviews) == 1:
second_approval_out.append(
_second_approval_item_from_gql_node(n, approval_reviews[0])
)
if is_ready_for_review and (last_review is None or updated > last_review):
reviewer_out.append(_item_from_gql_node(n))
if needs_creator_branch or (last_review is not None and last_review > updated):
creator_out.append(_item_from_gql_node(n))
reviewer_out.sort(key=lambda x: x.number)
second_approval_out.sort(key=lambda x: x.number)
creator_out.sort(key=lambda x: x.number)
return reviewer_out, creator_out
return reviewer_out, second_approval_out, creator_out


def build_project_status_report(
Expand Down Expand Up @@ -203,8 +238,8 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
attention_q,
filter_bot_authors=False,
)
reviewer_attention, creator_attention = _partition_attention_open_prs(
attention_nodes
reviewer_attention, second_approval, creator_attention = (
_partition_attention_open_prs(attention_nodes)
)

backlog_q = sm.open_pr_backlog_query(repo, start_date)
Expand Down Expand Up @@ -235,6 +270,7 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
merged_pull_requests=merged_items,
opened_pull_requests=[_item_from_gql_node(n) for n in opened_pr_filtered],
opened_issues=[_item_from_gql_node(n) for n in opened_issue_filtered],
second_approval_needed=second_approval,
recently_updated_pull_requests=recently_items,
reviewer_attention_needed=reviewer_attention,
creator_attention_needed=creator_attention,
Expand Down
71 changes: 70 additions & 1 deletion backend/tests/test_status_report_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ async def override_conn():
]
assert body["opened_pull_requests"][0]["number"] == 11
assert body["opened_issues"][0]["number"] == 12
assert body["second_approval_needed"] == []
assert body["recently_updated_pull_requests"] == []
assert body["reviewer_attention_needed"] == []
assert body["creator_attention_needed"] == []
Expand Down Expand Up @@ -372,6 +373,61 @@ def test_attention_sections_partition_open_prs(
"milestone": None,
"author": {"__typename": "User", "login": "u"},
},
{
"number": 75,
"title": "Needs second approval",
"url": "https://github.com/test/repo/pull/75",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-04-05T12:00:00Z",
"state": "OPEN",
"isDraft": False,
"mergedAt": None,
"mergeable": "MERGEABLE",
"mergeStateStatus": "CLEAN",
"reviews": {
"nodes": [
{
"submittedAt": "2025-04-04T10:00:00Z",
"state": "APPROVED",
"author": {"__typename": "User", "login": "reviewer"},
}
]
},
"additions": 1,
"deletions": 0,
"labels": {"nodes": []},
"milestone": None,
"author": {"__typename": "User", "login": "u"},
},
{
"number": 76,
"title": "Already has two approvals",
"url": "https://github.com/test/repo/pull/76",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-04-05T12:00:00Z",
"state": "OPEN",
"isDraft": False,
"mergedAt": None,
"mergeable": "MERGEABLE",
"mergeStateStatus": "CLEAN",
"reviews": {
"nodes": [
{
"submittedAt": "2025-04-03T10:00:00Z",
"state": "APPROVED",
},
{
"submittedAt": "2025-04-04T10:00:00Z",
"state": "APPROVED",
},
]
},
"additions": 1,
"deletions": 0,
"labels": {"nodes": []},
"milestone": None,
"author": {"__typename": "User", "login": "u"},
},
]

async def override_conn():
Expand All @@ -388,7 +444,20 @@ async def override_conn():

assert r.status_code == 200
body = r.json()
assert {p["number"] for p in body["reviewer_attention_needed"]} == {70, 71}
assert {p["number"] for p in body["reviewer_attention_needed"]} == {
70,
71,
75,
76,
}
assert body["second_approval_needed"] == [
{
"number": 75,
"title": "Needs second approval",
"html_url": "https://github.com/test/repo/pull/75",
"reviewer_login": "reviewer",
}
]
assert {p["number"] for p in body["creator_attention_needed"]} == {72, 73, 74}

def test_recently_updated_pull_requests_excludes_opened_in_window(
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/components/ProjectStatusPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ const getInitialDateRange = () => {

const emptyListMessage = 'None in this period.';

const secondApprovalReviewerSuffix = (row) => (
<strong style={{ marginLeft: '0.25rem' }}>({row.reviewer_login})</strong>
);

const backlogAgeSuffix = (row) => {
const n = row.days_since_update;
if (typeof n !== 'number' || Number.isNaN(n)) {
Expand Down Expand Up @@ -168,6 +172,7 @@ const ProjectStatusPanel = () => {

const [startDate, setStartDate] = useState(null);
const [endDate, setEndDate] = useState(null);
const [applyRevision, setApplyRevision] = useState(0);
const [draftStartDate, setDraftStartDate] = useState(initial.start);
const [draftEndDate, setDraftEndDate] = useState(initial.end);
const [loading, setLoading] = useState(false);
Expand All @@ -193,7 +198,7 @@ const ProjectStatusPanel = () => {
return;
}
load(startDate, endDate);
}, [startDate, endDate, load]);
}, [startDate, endDate, applyRevision, load]);

useEffect(() => {
if (!startDate || !endDate) {
Expand All @@ -215,6 +220,7 @@ const ProjectStatusPanel = () => {
}
setStartDate(draftStartDate);
setEndDate(draftEndDate);
setApplyRevision((revision) => revision + 1);
};

const windowLabel =
Expand Down Expand Up @@ -344,6 +350,13 @@ const ProjectStatusPanel = () => {
description="Issues opened in this date range."
items={report.opened_issues}
/>
<StatusSection
heading="Needs second approval"
description="Ready-to-review PRs with one approval already."
items={report.second_approval_needed}
renderTitleSuffix={secondApprovalReviewerSuffix}
emptyMessage="None match."
/>
<StatusSection
heading="Recently updated PRs"
description="Already-open, non-draft PRs updated in this date range."
Expand Down
Loading
Loading