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
54 changes: 54 additions & 0 deletions app/nominal_code/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ class PlatformName(StrEnum):
GITLAB = "gitlab"


class PullRequestState(StrEnum):
"""
Normalized lifecycle state of a PR/MR across platforms.

GitHub exposes ``state`` (``"open"``/``"closed"``) plus a separate
``merged`` boolean; GitLab exposes a single ``state`` enum
(``"opened"``/``"closed"``/``"merged"``/``"locked"``). This enum
collapses both shapes into a small alphabet that callers can act on
without knowing which platform they're talking to.

Values:
OPEN: PR/MR is open and reviewable.
CLOSED: PR/MR was closed without merging (rare; also covers
GitLab's ``locked`` since both leave nothing to review).
MERGED: PR/MR was merged into the base branch.
UNKNOWN: State could not be determined (API failure, unmappable
value, etc.). Callers should typically fall through to their
existing behavior rather than blocking on this signal.
"""

OPEN = "open"
CLOSED = "closed"
MERGED = "merged"
UNKNOWN = "unknown"


@dataclass(frozen=True)
class PullRequestEvent:
"""
Expand Down Expand Up @@ -299,6 +325,34 @@ async def fetch_pr_branch(self, repo_full_name: str, pr_number: int) -> str:

...

async def fetch_pr_state(
self,
repo_full_name: str,
pr_number: int,
) -> PullRequestState:
"""
Fetch the lifecycle state of a PR/MR.

Used by callers that need to short-circuit on PRs that aren't
worth reviewing — typically merged or closed ones, whose head
branch is often gone (auto-delete on merge) and would otherwise
cause a downstream clone to fail.

Returns ``PullRequestState.UNKNOWN`` on API failure or for any
platform-specific state value that doesn't map cleanly so
callers can fall through to their existing behavior rather than
blocking on a flaky check.

Args:
repo_full_name (str): Full repository name (e.g. ``owner/repo``).
pr_number (int): Pull request or merge request number.

Returns:
PullRequestState: Normalized state enum.
"""

...

async def authenticate(
self,
*,
Expand Down
53 changes: 53 additions & 0 deletions app/nominal_code/platforms/github/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
PlatformName,
PullRequestEvent,
PullRequestMetadata,
PullRequestState,
)
from nominal_code.platforms.github.auth import (
NO_INSTALLATION,
Expand Down Expand Up @@ -481,6 +482,58 @@ async def fetch_pr_branch(self, repo_full_name: str, pr_number: int) -> str:

return ""

async def fetch_pr_state(
self,
repo_full_name: str,
pr_number: int,
) -> PullRequestState:
"""
Fetch the lifecycle state of a GitHub PR.

GitHub exposes ``state`` (``"open"``/``"closed"``) plus a separate
``merged`` boolean, so a closed-and-merged PR has both
``state == "closed"`` and ``merged is True``. This implementation
prioritises the ``merged`` flag over ``state`` so the caller sees
``MERGED`` (semantically more useful) instead of ``CLOSED`` for
merged PRs.

Args:
repo_full_name (str): Full repository name (e.g. ``owner/repo``).
pr_number (int): Pull request number.

Returns:
PullRequestState: Normalized state, or ``UNKNOWN`` on API
failure or unmappable response.
"""

url: str = f"/repos/{repo_full_name}/pulls/{pr_number}"

try:
response: httpx.Response = await self._request("GET", url)
response.raise_for_status()
data: dict[str, Any] = response.json()
except httpx.HTTPError:
logger.exception(
"Failed to fetch PR state for %s#%d",
repo_full_name,
pr_number,
)

return PullRequestState.UNKNOWN

if data.get("merged"):
return PullRequestState.MERGED

raw_state: object = data.get("state")

if raw_state == "open":
return PullRequestState.OPEN

if raw_state == "closed":
return PullRequestState.CLOSED

return PullRequestState.UNKNOWN

async def fetch_pr_comments(
self,
repo_full_name: str,
Expand Down
52 changes: 52 additions & 0 deletions app/nominal_code/platforms/gitlab/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
PlatformName,
PullRequestEvent,
PullRequestMetadata,
PullRequestState,
)
from nominal_code.platforms.gitlab.auth import GitLabPatAuth
from nominal_code.platforms.http import request_with_retry
Expand Down Expand Up @@ -344,6 +345,57 @@ async def fetch_pr_branch(self, repo_full_name: str, pr_number: int) -> str:

return ""

async def fetch_pr_state(
self,
repo_full_name: str,
pr_number: int,
) -> PullRequestState:
"""
Fetch the lifecycle state of a GitLab MR.

GitLab's MR ``state`` is a single enum
(``opened``/``closed``/``merged``/``locked``); ``locked`` is
treated as ``CLOSED`` because the MR is no longer reviewable
either way.

Args:
repo_full_name (str): Full repository name (e.g. ``group/repo``).
pr_number (int): Merge request IID.

Returns:
PullRequestState: Normalized state, or ``UNKNOWN`` on API
failure or unmappable response.
"""

encoded_project: str = quote(repo_full_name, safe="")
url: str = f"/projects/{encoded_project}/merge_requests/{pr_number}"

try:
response: httpx.Response = await self._request("GET", url)
response.raise_for_status()
data: dict[str, Any] = response.json()
except httpx.HTTPError:
logger.exception(
"Failed to fetch MR state for %s!%d",
repo_full_name,
pr_number,
)

return PullRequestState.UNKNOWN

raw_state: object = data.get("state")

if raw_state == "opened":
return PullRequestState.OPEN

if raw_state == "merged":
return PullRequestState.MERGED

if raw_state in {"closed", "locked"}:
return PullRequestState.CLOSED

return PullRequestState.UNKNOWN

async def fetch_pr_comments(
self,
repo_full_name: str,
Expand Down
Loading