Skip to content

Commit ca3b31a

Browse files
Merge pull request #214 from MathisVerstrepen/feat/gitlab
feat: Expand Github node support for Gitlab
2 parents 6eb5a68 + 29d1571 commit ca3b31a

30 files changed

+1771
-1068
lines changed

api/app/database/pg/token_ops/provider_token_crud.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,41 @@
1010
logger = logging.getLogger("uvicorn.error")
1111

1212

13-
async def store_github_token_for_user(
14-
pg_engine: SQLAlchemyAsyncEngine, user_id: str, encrypted_token: str
13+
async def store_provider_token(
14+
pg_engine: SQLAlchemyAsyncEngine, user_id: str, provider: str, encrypted_token: str
1515
):
1616
async with AsyncSession(pg_engine) as session:
17-
db_token = ProviderToken(user_id=user_id, provider="github", access_token=encrypted_token)
17+
db_token = ProviderToken(user_id=user_id, provider=provider, access_token=encrypted_token)
1818
session.add(db_token)
1919
await session.commit()
2020
await session.refresh(db_token)
2121
return db_token
2222

2323

2424
async def get_provider_token(
25-
pg_engine: SQLAlchemyAsyncEngine, user_id: str, provider: str
25+
pg_engine: SQLAlchemyAsyncEngine, user_id: str, provider_prefix: str
2626
) -> Optional[ProviderToken]:
2727
async with AsyncSession(pg_engine) as session:
2828
stmt = select(ProviderToken).where(
29-
and_(ProviderToken.user_id == user_id, ProviderToken.provider == provider)
29+
and_(
30+
ProviderToken.user_id == user_id,
31+
ProviderToken.provider.like(f"{provider_prefix}%"), # type: ignore
32+
)
3033
)
3134
result = await session.exec(stmt) # type: ignore
3235
provider_token: ProviderToken = result.scalar_one_or_none()
3336
return provider_token if provider_token else None
3437

3538

36-
async def delete_provider_token(pg_engine: SQLAlchemyAsyncEngine, user_id: str, provider: str):
39+
async def delete_provider_token(
40+
pg_engine: SQLAlchemyAsyncEngine, user_id: str, provider_prefix: str
41+
):
3742
async with AsyncSession(pg_engine) as session:
3843
stmt = select(ProviderToken).where(
39-
and_(ProviderToken.user_id == user_id, ProviderToken.provider == provider)
44+
and_(
45+
ProviderToken.user_id == user_id,
46+
ProviderToken.provider.like(f"{provider_prefix}%"), # type: ignore
47+
)
4048
)
4149
result = await session.exec(stmt) # type: ignore
4250
token = result.scalar_one_or_none()

api/app/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from fastapi.responses import JSONResponse
1818
from fastapi.staticfiles import StaticFiles
1919
from models.usersDTO import SettingsDTO
20-
from routers import chat, files, github, graph, models, users
20+
from routers import chat, files, github, gitlab, graph, models, repository, users
2121
from sentry_sdk.integrations.fastapi import FastApiIntegration
2222
from sentry_sdk.integrations.httpx import HttpxIntegration
2323
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
@@ -182,6 +182,8 @@ async def generic_exception_handler(request: Request, exc: Exception):
182182
app.include_router(models.router)
183183
app.include_router(users.router)
184184
app.include_router(github.router)
185+
app.include_router(gitlab.router)
186+
app.include_router(repository.router)
185187
app.include_router(files.router)
186188

187189
app.mount("/static", StaticFiles(directory="data"), name="data")

api/app/models/github.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,3 @@ class FileTreeNode(BaseModel):
2525
type: str # "file" or "directory"
2626
path: str
2727
children: list["FileTreeNode"] = []
28-
29-
30-
class GithubCommitInfo(BaseModel):
31-
hash: str
32-
author: str
33-
date: datetime
34-
35-
36-
class GithubCommitState(BaseModel):
37-
latest_local: GithubCommitInfo
38-
latest_online: GithubCommitInfo
39-
is_up_to_date: bool

api/app/models/repository.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from datetime import datetime
2+
from typing import Optional
3+
4+
from pydantic import BaseModel
5+
6+
7+
class RepositoryInfo(BaseModel):
8+
provider: str
9+
encoded_provider: str
10+
full_name: str
11+
description: Optional[str] = None
12+
clone_url_ssh: str
13+
clone_url_https: str
14+
default_branch: str
15+
stargazers_count: Optional[int] = None
16+
17+
18+
class GitCommitInfo(BaseModel):
19+
hash: str
20+
author: str
21+
date: datetime
22+
23+
24+
class GitCommitState(BaseModel):
25+
latest_local: GitCommitInfo
26+
latest_online: GitCommitInfo
27+
is_up_to_date: bool

api/app/routers/github.py

Lines changed: 9 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,15 @@
1-
import asyncio
21
import logging
32
import os
43
from urllib.parse import urlencode
54

65
import httpx
7-
from database.pg.token_ops.provider_token_crud import (
8-
delete_provider_token,
9-
store_github_token_for_user,
10-
)
6+
from database.pg.token_ops.provider_token_crud import delete_provider_token, store_provider_token
117
from fastapi import APIRouter, Depends, HTTPException, Request, status
12-
from models.github import GithubCommitState, GitHubStatusResponse, Repo
8+
from models.github import GitHubStatusResponse, Repo
139
from pydantic import BaseModel, ValidationError
1410
from services.auth import get_current_user_id
1511
from services.crypto import encrypt_api_key
16-
from services.github import (
17-
CLONED_REPOS_BASE_DIR,
18-
build_file_tree_for_branch,
19-
clone_repo,
20-
get_files_content_for_branch,
21-
get_github_access_token,
22-
get_latest_local_commit_info,
23-
get_latest_online_commit_info,
24-
list_branches,
25-
pull_repo,
26-
)
12+
from services.github import get_github_access_token
2713
from slowapi import Limiter
2814
from slowapi.util import get_remote_address
2915
from starlette.responses import RedirectResponse
@@ -85,7 +71,6 @@ async def github_callback(
8571
detail="GitHub OAuth is not configured on the server.",
8672
)
8773

88-
# Exchange the code for an access token
8974
async with httpx.AsyncClient() as client:
9075
token_response = await client.post(
9176
"https://github.com/login/oauth/access_token",
@@ -128,11 +113,13 @@ async def github_callback(
128113
github_user = user_response.json()
129114
github_username = github_user.get("login")
130115

131-
# Encrypt + store the token
132116
encrypted_token = await encrypt_api_key(access_token)
133-
await delete_provider_token(request.app.state.pg_engine, user_id, "github")
117+
if not encrypted_token:
118+
raise HTTPException(
119+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to secure token."
120+
)
134121

135-
await store_github_token_for_user(request.app.state.pg_engine, user_id, encrypted_token or "")
122+
await store_provider_token(request.app.state.pg_engine, user_id, "github", encrypted_token)
136123

137124
return {
138125
"message": "GitHub account connected successfully.",
@@ -180,7 +167,7 @@ async def get_github_connection_status(
180167
@router.get("/github/repos", response_model=list[Repo])
181168
async def get_github_repos(request: Request, user_id: str = Depends(get_current_user_id)):
182169
"""
183-
Fetches repositories using GitHub App permissions.
170+
Fetches repositories using GitHub App permissions via the API.
184171
"""
185172
access_token = await get_github_access_token(request, user_id)
186173

@@ -217,158 +204,3 @@ async def get_github_repos(request: Request, user_id: str = Depends(get_current_
217204
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
218205
detail=f"Failed to parse repository data: {e}",
219206
)
220-
221-
222-
@router.get("/github/repos/{owner}/{repo}/branches")
223-
async def get_github_repo_branches(
224-
owner: str,
225-
repo: str,
226-
request: Request,
227-
user_id: str = Depends(get_current_user_id),
228-
):
229-
"""
230-
Get a list of branches for a GitHub repository.
231-
"""
232-
access_token = await get_github_access_token(request, user_id)
233-
repo_dir = CLONED_REPOS_BASE_DIR / owner / repo
234-
235-
if not repo_dir.exists():
236-
try:
237-
await clone_repo(owner, repo, access_token or "", repo_dir)
238-
except Exception as e:
239-
logger.error(f"Error cloning repo: {e}")
240-
raise HTTPException(
241-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
242-
detail=f"Failed to clone repository: {str(e)}",
243-
)
244-
245-
try:
246-
branches = await list_branches(repo_dir)
247-
return branches
248-
except Exception as e:
249-
logger.error(f"Error listing branches: {e}")
250-
raise HTTPException(
251-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
252-
detail=f"Failed to list branches: {str(e)}",
253-
)
254-
255-
256-
@router.get("/github/repos/{owner}/{repo}/tree")
257-
async def get_github_repo_tree(
258-
owner: str,
259-
repo: str,
260-
branch: str,
261-
request: Request,
262-
user_id: str = Depends(get_current_user_id),
263-
force_pull: bool = False,
264-
):
265-
"""
266-
Get file tree structure for a specific branch of a GitHub repository.
267-
Clones the repo if not already cloned locally.
268-
"""
269-
access_token = await get_github_access_token(request, user_id)
270-
repo_dir = CLONED_REPOS_BASE_DIR / owner / repo
271-
272-
# Clone repo if it doesn't exist
273-
if not repo_dir.exists():
274-
try:
275-
await clone_repo(owner, repo, access_token or "", repo_dir)
276-
except Exception as e:
277-
logger.error(f"Error cloning repo: {e}")
278-
raise HTTPException(
279-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
280-
detail=f"Failed to clone repository: {str(e)}",
281-
)
282-
283-
if force_pull:
284-
try:
285-
await pull_repo(repo_dir, branch)
286-
except Exception as e:
287-
logger.error(f"Error pulling repo: {e}")
288-
raise HTTPException(
289-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
290-
detail=f"Failed to pull repository: {str(e)}",
291-
)
292-
293-
# Build file tree structure for the specific branch
294-
try:
295-
tree = await build_file_tree_for_branch(repo_dir, branch)
296-
return tree
297-
except FileNotFoundError as e:
298-
raise HTTPException(
299-
status_code=status.HTTP_404_NOT_FOUND,
300-
detail=str(e),
301-
)
302-
except Exception as e:
303-
logger.error(f"Error building file tree for branch {branch}: {e}")
304-
raise HTTPException(
305-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
306-
detail=f"Failed to build file tree: {str(e)}",
307-
)
308-
309-
310-
@router.get("/github/repos/{owner}/{repo}/commit/state")
311-
async def get_github_repo_commit_state(
312-
owner: str,
313-
repo: str,
314-
branch: str,
315-
request: Request,
316-
user_id: str = Depends(get_current_user_id),
317-
) -> GithubCommitState:
318-
"""
319-
Get the state of a GitHub repository branch.
320-
Return latest commit information and if the cloned version is latest
321-
"""
322-
access_token = await get_github_access_token(request, user_id)
323-
324-
repo_dir = CLONED_REPOS_BASE_DIR / owner / repo
325-
repo_id = f"{owner}/{repo}"
326-
327-
latest_local_commit_info, latest_online_commit_info = await asyncio.gather(
328-
get_latest_local_commit_info(repo_dir, branch),
329-
get_latest_online_commit_info(repo_id, access_token, branch),
330-
)
331-
332-
return GithubCommitState(
333-
latest_local=latest_local_commit_info,
334-
latest_online=latest_online_commit_info,
335-
is_up_to_date=latest_local_commit_info.hash == latest_online_commit_info.hash,
336-
)
337-
338-
339-
@router.get("/github/repos/{owner}/{repo}/contents/{file_path:path}")
340-
async def get_github_repo_file(
341-
owner: str,
342-
repo: str,
343-
file_path: str,
344-
branch: str,
345-
request: Request,
346-
user_id: str = Depends(get_current_user_id),
347-
):
348-
"""
349-
Get the content of a file in a specific branch of a GitHub repository.
350-
"""
351-
await get_github_access_token(request, user_id)
352-
353-
repo_dir = CLONED_REPOS_BASE_DIR / owner / repo
354-
355-
if not repo_dir.exists():
356-
raise HTTPException(
357-
status_code=status.HTTP_404_NOT_FOUND,
358-
detail="Repository not found locally. Please select it first to clone.",
359-
)
360-
361-
try:
362-
content = await get_files_content_for_branch(repo_dir, branch, [file_path])
363-
return {"content": content.get(file_path, "")}
364-
except FileNotFoundError as e:
365-
raise HTTPException(
366-
status_code=status.HTTP_404_NOT_FOUND,
367-
detail=str(e),
368-
)
369-
except Exception as e:
370-
logger.error(f"Error reading file content: {e}")
371-
raise HTTPException(
372-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
373-
detail=f"Failed to read file content: {str(e)}",
374-
)

0 commit comments

Comments
 (0)