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
110 changes: 110 additions & 0 deletions backend/src/github_pm/sdlc_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,91 @@ def graphql_search_pull_requests(
title
url
createdAt
updatedAt
state
isDraft
mergedAt
additions
deletions
labels(first: 30) { nodes { name } }
milestone { title }
author {
__typename
... on User { login }
... on Bot { login }
... on Organization { login }
}
}
}
}
}
"""
while True:
payload = {
"query": gql,
"variables": {
"q": search_query,
"first": page_size,
"after": cursor,
},
}
data = post_graphql(payload)
errors = data.get("errors")
if errors:
logger.error("GraphQL errors: %s", errors)
raise RuntimeError(f"GitHub GraphQL error: {errors!r}")
search = data.get("data", {}).get("search") or {}
batch = search.get("nodes") or []
if filter_bot_authors:
nodes.extend(filter_out_bot_pr_nodes(batch))
else:
nodes.extend([n for n in batch if n and n.get("number") is not None])
page = search.get("pageInfo") or {}
if not page.get("hasNextPage"):
break
cursor = page.get("endCursor")
if not cursor:
break
return nodes


def graphql_search_open_pull_requests_attention(
post_graphql: Callable[[dict[str, Any]], dict[str, Any]],
search_query: str,
*,
page_size: int = 100,
filter_bot_authors: bool = False,
) -> list[dict[str, Any]]:
"""Paginate GitHub GraphQL search for open PRs including ``mergeable``,
``mergeStateStatus``, and ``reviews``.

Used for project status "attention" sections; callers filter on merge state and
review timestamps.
"""
nodes: list[dict[str, Any]] = []
cursor: str | None = None
gql = """
query($q: String!, $first: Int!, $after: String) {
search(query: $q, type: ISSUE, first: $first, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
... on PullRequest {
number
title
url
createdAt
updatedAt
state
isDraft
mergedAt
mergeable
mergeStateStatus
reviews(first: 100) {
nodes {
submittedAt
state
}
}
additions
deletions
labels(first: 30) { nodes { name } }
Expand Down Expand Up @@ -496,6 +580,32 @@ def opened_prs_between_query(github_repo: str, start_d: date, end_d: date) -> st
return f"{repo_search_fragment(github_repo)} is:pr created:{a}..{b}"


def open_pr_backlog_query(github_repo: str, start_d: date) -> str:
"""Open, non-draft PRs last updated strictly before ``start_d`` (UTC calendar day at 00:00).

Uses ``draft:false`` and ``updated:<YYYY-MM-DD``; callers should still verify
``updatedAt`` in UTC against ``start_d`` and ``isDraft`` for edge cases.
"""
d = start_d.isoformat()
return f"{repo_search_fragment(github_repo)} is:pr is:open draft:false updated:<{d}"


def open_pull_requests_for_attention_query(github_repo: str) -> str:
"""All open pull requests in the repo (drafts included) for merge/review heuristics."""
return f"{repo_search_fragment(github_repo)} is:pr is:open"


def open_prs_updated_between_query(github_repo: str, start_d: date, end_d: date) -> str:
"""Open, non-draft PRs with ``updated`` in ``[start_d, end_d]`` (UTC calendar days, inclusive)."""
a, b = start_d.isoformat(), end_d.isoformat()
if a > b:
a, b = b, a
return (
f"{repo_search_fragment(github_repo)} is:pr is:open draft:false "
f"updated:{a}..{b}"
)


def opened_issues_between_query(github_repo: str, start_d: date, end_d: date) -> str:
"""Issues (not PRs) with ``created`` in ``[start_d, end_d]`` (UTC calendar days, inclusive)."""
a, b = start_d.isoformat(), end_d.isoformat()
Expand Down
6 changes: 5 additions & 1 deletion backend/src/github_pm/status_report_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ 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).
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).
"""
resolved_end = end_date if end_date is not None else _default_end_date()
resolved_start = (
Expand Down
42 changes: 42 additions & 0 deletions backend/src/github_pm/status_report_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ class StatusReportItem(BaseModel):
html_url: str = Field(description="GitHub HTML URL for the issue or PR")


class PrBacklogItem(StatusReportItem):
"""Open stale PR row including age relative to the report ``end_date``."""

days_since_update: int = Field(
ge=0,
description=(
"Whole calendar days from the PR's last ``updatedAt`` (UTC date) through "
"``end_date`` (inclusive), matching the report window's last day"
),
)


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

Expand All @@ -35,3 +47,33 @@ class ProjectStatusReportResponse(BaseModel):
default_factory=list,
description="Issues created in the window (pull requests excluded)",
)
recently_updated_pull_requests: list[StatusReportItem] = Field(
default_factory=list,
description=(
"Open, non-draft pull requests updated in the window whose ``createdAt`` "
"UTC calendar date is outside the window (not newly opened this period)"
),
)
reviewer_attention_needed: list[StatusReportItem] = Field(
default_factory=list,
description=(
"Open, non-draft PRs with ``mergeable`` MERGEABLE and a clean branch "
"(``mergeStateStatus`` not BEHIND or DIRTY), with no submitted review or "
"``updatedAt`` after the latest review ``submittedAt``"
),
)
creator_attention_needed: list[StatusReportItem] = Field(
default_factory=list,
description=(
"Open PRs (including drafts) that need branch work or author follow-up: "
"``mergeable`` CONFLICTING, ``mergeStateStatus`` BEHIND or DIRTY, or latest "
"submitted review newer than ``updatedAt``"
),
)
pr_backlog: list[PrBacklogItem] = Field(
default_factory=list,
description=(
"Open, non-draft pull requests whose last update (UTC) is strictly before "
"start_date — not merged or closed, stale since the report window began"
),
)
139 changes: 138 additions & 1 deletion backend/src/github_pm/status_report_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@

from __future__ import annotations

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

from github_pm import sdlc_metrics as sm
from github_pm.api import Connector
from github_pm.status_report_models import (
PrBacklogItem,
ProjectStatusReportResponse,
StatusReportItem,
)
Expand Down Expand Up @@ -44,6 +45,93 @@ def _created_calendar_in_window(
return start_d <= cd <= end_d


def _updated_calendar_in_window(
node: dict[str, Any], start_d: date, end_d: date
) -> bool:
u = sm.parse_github_ts(node.get("updatedAt"))
if not u:
return False
ud = u.astimezone(UTC).date()
return start_d <= ud <= end_d


def _updated_strictly_before_start_date(node: dict[str, Any], start_d: date) -> bool:
"""True if ``updatedAt`` exists and its UTC calendar date is before ``start_d``."""
u = sm.parse_github_ts(node.get("updatedAt"))
if not u:
return False
return u.astimezone(UTC).date() < start_d


def _calendar_days_since_update_to_end(node: dict[str, Any], end_d: date) -> int:
"""Calendar days from ``updatedAt`` UTC date through ``end_d`` (inclusive span)."""
u = sm.parse_github_ts(node.get("updatedAt"))
if not u:
return 0
ud = u.astimezone(UTC).date()
return max(0, (end_d - ud).days)


def _backlog_item_from_gql_node(node: dict[str, Any], end_d: date) -> PrBacklogItem:
base = _item_from_gql_node(node)
return PrBacklogItem(
number=base.number,
title=base.title,
html_url=base.html_url,
days_since_update=_calendar_days_since_update_to_end(node, end_d),
)


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
for r in (node.get("reviews") or {}).get("nodes") or []:
if not isinstance(r, dict):
continue
if r.get("state") == "PENDING":
continue
ts = sm.parse_github_ts(r.get("submittedAt"))
if ts is None:
continue
if best is None or ts > best:
best = ts
return best


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."""
reviewer_out: list[StatusReportItem] = []
creator_out: list[StatusReportItem] = []
for n in nodes:
if n.get("state") != "OPEN" or n.get("mergedAt"):
continue
updated = sm.parse_github_ts(n.get("updatedAt"))
if updated is None:
continue
last_review = _max_submitted_review_time(n)
mergeable = str(n.get("mergeable") or "")
merge_state = str(n.get("mergeStateStatus") or "")
is_draft = bool(n.get("isDraft"))
needs_creator_branch = mergeable == "CONFLICTING" or merge_state in (
"BEHIND",
"DIRTY",
)
if (
not is_draft
and mergeable == "MERGEABLE"
and merge_state not in ("BEHIND", "DIRTY")
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)
creator_out.sort(key=lambda x: x.number)
return reviewer_out, creator_out


def build_project_status_report(
gitctx: Connector,
*,
Expand Down Expand Up @@ -91,10 +179,59 @@ def post_gql(payload: dict[str, Any]) -> dict[str, Any]:
]
opened_issue_filtered.sort(key=lambda n: int(n["number"]))

recently_q = sm.open_prs_updated_between_query(repo, start_date, end_date)
recently_nodes = sm.graphql_search_pull_requests(
post_gql,
recently_q,
filter_bot_authors=False,
)
recently_filtered = [
n
for n in recently_nodes
if n.get("state") == "OPEN"
and not n.get("mergedAt")
and not n.get("isDraft")
and _updated_calendar_in_window(n, start_date, end_date)
and not _created_calendar_in_window(n, start_date, end_date)
]
recently_filtered.sort(key=lambda n: int(n["number"]))
recently_items = [_item_from_gql_node(n) for n in recently_filtered]

attention_q = sm.open_pull_requests_for_attention_query(repo)
attention_nodes = sm.graphql_search_open_pull_requests_attention(
post_gql,
attention_q,
filter_bot_authors=False,
)
reviewer_attention, creator_attention = _partition_attention_open_prs(
attention_nodes
)

backlog_q = sm.open_pr_backlog_query(repo, start_date)
backlog_nodes = sm.graphql_search_pull_requests(
post_gql,
backlog_q,
filter_bot_authors=False,
)
backlog_filtered = [
n
for n in backlog_nodes
if n.get("state") == "OPEN"
and not n.get("mergedAt")
and not n.get("isDraft")
and _updated_strictly_before_start_date(n, start_date)
]
backlog_filtered.sort(key=lambda n: int(n["number"]))
backlog_items = [_backlog_item_from_gql_node(n, end_date) for n in backlog_filtered]

return ProjectStatusReportResponse(
start_date=start_date,
end_date=end_date,
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],
recently_updated_pull_requests=recently_items,
reviewer_attention_needed=reviewer_attention,
creator_attention_needed=creator_attention,
pr_backlog=backlog_items,
)
Loading
Loading