Skip to content

Commit 65317c9

Browse files
authored
Merge pull request #435 from Mng-dev-ai/feat/github-service
Extract GitHubService from route handlers
2 parents 19a78c9 + ea2aa92 commit 65317c9

4 files changed

Lines changed: 323 additions & 261 deletions

File tree

Lines changed: 32 additions & 261 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import logging
2-
from typing import Any
1+
from typing import NoReturn
32

4-
import httpx
5-
from fastapi import APIRouter, Depends, HTTPException, Query, status
3+
from fastapi import APIRouter, Depends, HTTPException, Query
64

7-
from app.core.deps import require_github_token
5+
from app.core.deps import get_github_service
86
from app.core.security import get_current_user
97
from app.models.db_models.user import User
108
from app.models.schemas.github import (
@@ -13,40 +11,16 @@
1311
GitHubCollaborator,
1412
GitHubPRCommentsResponse,
1513
GitHubPRListResponse,
16-
GitHubPullRequest,
17-
GitHubRepo,
1814
GitHubReposResponse,
19-
GitHubReviewComment,
2015
)
16+
from app.services.exceptions import GitHubException
17+
from app.services.github import GitHubService
2118

2219
router = APIRouter()
23-
logger = logging.getLogger(__name__)
2420

25-
GITHUB_API_BASE = "https://api.github.com"
2621

27-
28-
def _github_headers(token: str) -> dict[str, str]:
29-
return {
30-
"Authorization": f"Bearer {token}",
31-
"Accept": "application/vnd.github+json",
32-
"X-GitHub-Api-Version": "2022-11-28",
33-
}
34-
35-
36-
def _check_github_response(response: httpx.Response) -> None:
37-
if response.status_code == 401:
38-
raise HTTPException(
39-
status_code=status.HTTP_401_UNAUTHORIZED,
40-
detail="GitHub token is invalid or expired",
41-
)
42-
if response.status_code != 200:
43-
logger.warning(
44-
"GitHub API returned %d: %s", response.status_code, response.text[:200]
45-
)
46-
raise HTTPException(
47-
status_code=status.HTTP_502_BAD_GATEWAY,
48-
detail="GitHub API request failed",
49-
)
22+
def _raise_from_github(exc: GitHubException) -> NoReturn:
23+
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
5024

5125

5226
@router.get("/repositories", response_model=GitHubReposResponse)
@@ -55,110 +29,25 @@ async def list_repositories(
5529
page: int = Query(default=1, ge=1),
5630
per_page: int = Query(default=20, ge=1, le=100),
5731
_current_user: User = Depends(get_current_user),
58-
github_token: str = Depends(require_github_token),
32+
github: GitHubService = Depends(get_github_service),
5933
) -> GitHubReposResponse:
60-
headers = _github_headers(github_token)
61-
62-
async with httpx.AsyncClient(timeout=10.0) as client:
63-
if q.strip():
64-
response = await client.get(
65-
f"{GITHUB_API_BASE}/search/repositories",
66-
params={
67-
"q": q.strip(),
68-
"sort": "updated",
69-
"order": "desc",
70-
"per_page": per_page,
71-
"page": page,
72-
},
73-
headers=headers,
74-
)
75-
else:
76-
response = await client.get(
77-
f"{GITHUB_API_BASE}/user/repos",
78-
params={
79-
"sort": "pushed",
80-
"direction": "desc",
81-
"per_page": per_page,
82-
"page": page,
83-
"affiliation": "owner,collaborator,organization_member",
84-
},
85-
headers=headers,
86-
)
87-
88-
_check_github_response(response)
89-
90-
data = response.json()
91-
raw_repos = data.get("items", data) if isinstance(data, dict) else data
92-
93-
repos = [
94-
GitHubRepo(
95-
name=r["name"],
96-
full_name=r["full_name"],
97-
description=r.get("description"),
98-
language=r.get("language"),
99-
html_url=r["html_url"],
100-
clone_url=r["clone_url"],
101-
private=r.get("private", False),
102-
pushed_at=r.get("pushed_at"),
103-
stargazers_count=r.get("stargazers_count", 0),
104-
)
105-
for r in raw_repos
106-
]
107-
108-
return GitHubReposResponse(items=repos, has_more=len(raw_repos) == per_page)
34+
try:
35+
return await github.list_repositories(q, page, per_page)
36+
except GitHubException as exc:
37+
_raise_from_github(exc)
10938

11039

11140
@router.get("/pulls", response_model=GitHubPRListResponse)
11241
async def list_pull_requests(
11342
owner: str = Query(..., min_length=1),
11443
repo: str = Query(..., min_length=1),
11544
_current_user: User = Depends(get_current_user),
116-
github_token: str = Depends(require_github_token),
45+
github: GitHubService = Depends(get_github_service),
11746
) -> GitHubPRListResponse:
118-
headers = _github_headers(github_token)
119-
120-
async with httpx.AsyncClient(timeout=10.0) as client:
121-
response = await client.get(
122-
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls",
123-
params={
124-
"state": "open",
125-
"per_page": 30,
126-
"sort": "updated",
127-
"direction": "desc",
128-
},
129-
headers=headers,
130-
)
131-
132-
_check_github_response(response)
133-
134-
raw_prs = response.json()
135-
prs = [
136-
GitHubPullRequest(
137-
number=pr["number"],
138-
title=pr["title"],
139-
body=pr.get("body"),
140-
state=pr["state"],
141-
html_url=pr["html_url"],
142-
head={
143-
"ref": pr["head"]["ref"],
144-
"repo": {
145-
"full_name": pr["head"]["repo"]["full_name"]
146-
if pr["head"].get("repo")
147-
else ""
148-
},
149-
},
150-
base={"ref": pr["base"]["ref"]},
151-
user={
152-
"login": pr["user"]["login"],
153-
"avatar_url": pr["user"].get("avatar_url", ""),
154-
},
155-
draft=pr.get("draft", False),
156-
review_comments=pr.get("review_comments", 0),
157-
)
158-
for pr in raw_prs
159-
]
160-
161-
return GitHubPRListResponse(items=prs)
47+
try:
48+
return await github.list_pull_requests(owner, repo)
49+
except GitHubException as exc:
50+
_raise_from_github(exc)
16251

16352

16453
@router.get(
@@ -170,152 +59,34 @@ async def get_pr_comments(
17059
repo: str,
17160
number: int,
17261
_current_user: User = Depends(get_current_user),
173-
github_token: str = Depends(require_github_token),
62+
github: GitHubService = Depends(get_github_service),
17463
) -> GitHubPRCommentsResponse:
175-
headers = _github_headers(github_token)
176-
url = f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{number}/comments"
177-
raw_comments: list[dict[str, Any]] = []
178-
179-
async with httpx.AsyncClient(timeout=10.0) as client:
180-
page = 1
181-
while page <= 10:
182-
response = await client.get(
183-
url,
184-
params={"per_page": 100, "page": page},
185-
headers=headers,
186-
)
187-
_check_github_response(response)
188-
batch = response.json()
189-
raw_comments.extend(batch)
190-
if len(batch) < 100:
191-
break
192-
page += 1
193-
194-
comments = [
195-
GitHubReviewComment(
196-
id=c["id"],
197-
body=c["body"],
198-
path=c.get("path"),
199-
line=c.get("line") or c.get("original_line"),
200-
user={
201-
"login": c["user"]["login"],
202-
"avatar_url": c["user"].get("avatar_url", ""),
203-
},
204-
created_at=c["created_at"],
205-
)
206-
for c in raw_comments
207-
]
208-
209-
return GitHubPRCommentsResponse(comments=comments)
64+
try:
65+
return await github.get_pr_comments(owner, repo, number)
66+
except GitHubException as exc:
67+
_raise_from_github(exc)
21068

21169

21270
@router.post("/pulls", response_model=CreatePullRequestResponse)
21371
async def create_pull_request(
21472
request: CreatePullRequestRequest,
21573
_current_user: User = Depends(get_current_user),
216-
github_token: str = Depends(require_github_token),
74+
github: GitHubService = Depends(get_github_service),
21775
) -> CreatePullRequestResponse:
218-
headers = _github_headers(github_token)
219-
220-
async with httpx.AsyncClient(timeout=15.0) as client:
221-
response = await client.post(
222-
f"{GITHUB_API_BASE}/repos/{request.owner}/{request.repo}/pulls",
223-
json={
224-
"title": request.title,
225-
"body": request.body,
226-
"head": request.head,
227-
"base": request.base,
228-
},
229-
headers=headers,
230-
)
231-
232-
if response.status_code == 401:
233-
raise HTTPException(
234-
status_code=status.HTTP_401_UNAUTHORIZED,
235-
detail="GitHub token is invalid or expired",
236-
)
237-
if response.status_code not in (200, 201):
238-
logger.warning(
239-
"GitHub API returned %d: %s",
240-
response.status_code,
241-
response.text[:200],
242-
)
243-
try:
244-
detail = response.json().get("message", "Failed to create pull request")
245-
except (ValueError, KeyError):
246-
detail = "Failed to create pull request"
247-
raise HTTPException(
248-
status_code=status.HTTP_502_BAD_GATEWAY,
249-
detail=detail,
250-
)
251-
252-
pr_data = response.json()
253-
254-
reviewer_warning = None
255-
if request.reviewers:
256-
try:
257-
reviewer_resp = await client.post(
258-
f"{GITHUB_API_BASE}/repos/{request.owner}/{request.repo}/pulls/{pr_data['number']}/requested_reviewers",
259-
json={"reviewers": request.reviewers},
260-
headers=headers,
261-
)
262-
if reviewer_resp.status_code not in (200, 201):
263-
reviewer_warning = "Failed to assign reviewers"
264-
logger.warning(
265-
"Failed to assign reviewers to PR #%d: %s",
266-
pr_data["number"],
267-
reviewer_resp.text[:200],
268-
)
269-
except httpx.HTTPError:
270-
reviewer_warning = "Failed to assign reviewers"
271-
logger.warning(
272-
"Failed to assign reviewers to PR #%d", pr_data["number"]
273-
)
274-
275-
return CreatePullRequestResponse(
276-
number=pr_data["number"],
277-
html_url=pr_data["html_url"],
278-
title=pr_data["title"],
279-
reviewer_warning=reviewer_warning,
280-
)
76+
try:
77+
return await github.create_pull_request(request)
78+
except GitHubException as exc:
79+
_raise_from_github(exc)
28180

28281

28382
@router.get("/collaborators")
28483
async def list_collaborators(
28584
owner: str = Query(..., min_length=1),
28685
repo: str = Query(..., min_length=1),
28786
_current_user: User = Depends(get_current_user),
288-
github_token: str = Depends(require_github_token),
87+
github: GitHubService = Depends(get_github_service),
28988
) -> list[GitHubCollaborator]:
290-
headers = _github_headers(github_token)
291-
292-
async with httpx.AsyncClient(timeout=10.0) as client:
293-
response = await client.get(
294-
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/collaborators",
295-
params={"per_page": 50},
296-
headers=headers,
297-
)
298-
299-
if response.status_code == 401:
300-
raise HTTPException(
301-
status_code=status.HTTP_401_UNAUTHORIZED,
302-
detail="GitHub token is invalid or expired",
303-
)
304-
# 403/404 are expected when user lacks push access to the repo
305-
if response.status_code in (403, 404):
306-
return []
307-
if response.status_code != 200:
308-
logger.warning(
309-
"GitHub collaborators API returned %d: %s",
310-
response.status_code,
311-
response.text[:200],
312-
)
313-
raise HTTPException(
314-
status_code=status.HTTP_502_BAD_GATEWAY,
315-
detail="Failed to load collaborators",
316-
)
317-
318-
return [
319-
GitHubCollaborator(login=c["login"], avatar_url=c.get("avatar_url", ""))
320-
for c in response.json()
321-
]
89+
try:
90+
return await github.list_collaborators(owner, repo)
91+
except GitHubException as exc:
92+
_raise_from_github(exc)

backend/app/core/deps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from app.services.chat import ChatService
1515
from app.services.command import CommandService
1616
from app.services.exceptions import UserException
17+
from app.services.github import GitHubService
1718
from app.services.marketplace import MarketplaceService
1819
from app.services.plugin_installer import PluginInstallerService
1920
from app.services.provider import ProviderService
@@ -79,6 +80,12 @@ async def require_github_token(
7980
return github_token
8081

8182

83+
def get_github_service(
84+
github_token: str = Depends(require_github_token),
85+
) -> GitHubService:
86+
return GitHubService(token=github_token)
87+
88+
8289
def get_marketplace_service() -> MarketplaceService:
8390
return MarketplaceService()
8491

backend/app/services/exceptions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class ErrorCode(str, Enum):
2323
WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND"
2424
VALIDATION_ERROR = "VALIDATION_ERROR"
2525
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
26+
GITHUB_TOKEN_INVALID = "GITHUB_TOKEN_INVALID"
27+
GITHUB_API_FAILED = "GITHUB_API_FAILED"
2628

2729

2830
class ServiceException(Exception):
@@ -172,6 +174,17 @@ def __init__(
172174
super().__init__(message, error_code, details, status_code)
173175

174176

177+
class GitHubException(ServiceException):
178+
def __init__(
179+
self,
180+
message: str,
181+
error_code: ErrorCode = ErrorCode.GITHUB_API_FAILED,
182+
details: dict[str, str] | None = None,
183+
status_code: int = 502,
184+
):
185+
super().__init__(message, error_code, details, status_code)
186+
187+
175188
class WorkspaceException(ServiceException):
176189
def __init__(
177190
self,

0 commit comments

Comments
 (0)