From d638e53e37a4b14d636cb533b6368f51078a4688 Mon Sep 17 00:00:00 2001 From: Milan Gohel Date: Mon, 3 Nov 2025 12:04:46 +0000 Subject: [PATCH] feat: Backend API for PRs Merged Without Review --- .../analytics_server/mhq/api/pull_requests.py | 35 +++++++++++++++++++ .../mhq/service/code/pr_analytics.py | 6 +++- .../analytics_server/mhq/store/repos/code.py | 33 +++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/backend/analytics_server/mhq/api/pull_requests.py b/backend/analytics_server/mhq/api/pull_requests.py index 302387462..a6eb97f5a 100644 --- a/backend/analytics_server/mhq/api/pull_requests.py +++ b/backend/analytics_server/mhq/api/pull_requests.py @@ -167,3 +167,38 @@ def get_team_lead_time_trends( week.isoformat(): adapt_lead_time_metrics(average_lead_time_metrics) for week, average_lead_time_metrics in weekly_lead_time_metrics_avg_map.items() } + +@app.route("/teams//prs/merged_without_review", methods={"GET"}) +@queryschema( + Schema({ + Required("from_time"): All(str, Coerce(datetime.fromisoformat)), + Required("to_time"): All(str, Coerce(datetime.fromisoformat)), + Optional("pr_filter"): All(str, Coerce(json.loads)), + }) +) +def get_prs_merged_without_review( + team_id: str, + from_time: datetime, + to_time: datetime, + pr_filter: Dict = None, +) -> List[PullRequest]: + query_validator = get_query_validator() + + pr_analytics = get_pr_analytics_service() + + interval: Interval = query_validator.interval_validator(from_time, to_time) + + pr_filter: PRFilter = apply_pr_filter( + pr_filter, EntityType.TEAM, team_id, [SettingType.EXCLUDED_PRS_SETTING] + ) + + team_repos = pr_analytics.get_team_repos(team_id) + if not team_repos: + return [] + + repo_ids = [repo.id for repo in team_repos] + + prs = pr_analytics.get_prs_merged_without_review(repo_ids, interval, pr_filter) + + repo_id_repo_map = {repo.id: repo for repo in team_repos} + return get_non_paginated_pr_response(prs, repo_id_repo_map, len(prs)) \ No newline at end of file diff --git a/backend/analytics_server/mhq/service/code/pr_analytics.py b/backend/analytics_server/mhq/service/code/pr_analytics.py index b2a765d08..2c50f8e0e 100644 --- a/backend/analytics_server/mhq/service/code/pr_analytics.py +++ b/backend/analytics_server/mhq/service/code/pr_analytics.py @@ -1,6 +1,8 @@ +from mhq.store.models.code.filter import PRFilter +from mhq.store.models.core.teams import Team +from mhq.utils.time import Interval from mhq.store.models.code import OrgRepo, PullRequest from mhq.store.repos.code import CodeRepoService - from typing import List, Optional @@ -17,6 +19,8 @@ def get_team_repos(self, team_id: str) -> List[OrgRepo]: def get_repo_by_id(self, repo_id: str) -> Optional[OrgRepo]: return self.code_repo_service.get_repo_by_id(repo_id) + def get_prs_merged_without_review(self, repo_ids: List[str], interval: Interval, pr_filter: PRFilter) -> List[PullRequest]: + return self.code_repo_service.get_prs_merged_without_review(repo_ids, interval, pr_filter) def get_pr_analytics_service(): return PullRequestAnalyticsService(CodeRepoService()) diff --git a/backend/analytics_server/mhq/store/repos/code.py b/backend/analytics_server/mhq/store/repos/code.py index 310be35c3..dafc44e0f 100644 --- a/backend/analytics_server/mhq/store/repos/code.py +++ b/backend/analytics_server/mhq/store/repos/code.py @@ -2,8 +2,8 @@ from operator import and_ from typing import Optional, List -from mhq.store.models.code.enums import CodeProvider -from sqlalchemy import or_ +from mhq.store.models.code.enums import CodeProvider, PullRequestEventType +from sqlalchemy import or_, not_, exists from sqlalchemy.orm import defer from mhq.store.models.core import Team @@ -283,6 +283,35 @@ def get_active_org_repos_by_ids(self, repo_ids: List[str]) -> List[OrgRepo]: .all() ) + @rollback_on_exc + def get_prs_merged_without_review( + self, + repo_ids: List[str], + interval: Interval, + pr_filter: PRFilter = None, + ) -> List[PullRequest]: + query = self._db.session.query(PullRequest).options(defer(PullRequest.data)) + + query = self._filter_prs_by_repo_ids(query, repo_ids) + query = self._filter_prs_merged_in_interval(query, interval) + + query = self._filter_prs(query, pr_filter) + + query = query.filter( + not_( + exists().where( + and_( + PullRequestEvent.pull_request_id == PullRequest.id, + PullRequestEvent.type == PullRequestEventType.REVIEW, + ) + ) + ) + ) + + query = query.order_by(PullRequest.state_changed_at.asc()) + + return query.all() + @rollback_on_exc def get_prs_merged_in_interval( self,