diff --git a/freenit/api/git.py b/freenit/api/git.py new file mode 100644 index 0000000..32916e2 --- /dev/null +++ b/freenit/api/git.py @@ -0,0 +1,539 @@ +import asyncio +import logging +import shlex +import subprocess # nosec: B404 +from datetime import datetime +import pydantic +from fastapi import BackgroundTasks, Depends, Header, HTTPException + +from freenit.api.router import route +from freenit.config import getConfig +from freenit.decorators import description +from freenit.git import repo as git_repo +from freenit.git import store +from freenit.models.git import GitPermission, GitPushLog, GitRepo +from freenit.models.pagination import Page +from freenit.models.user import User +from freenit.permissions import git_perms + +config = getConfig() +log = logging.getLogger("git") + +tags = ["git"] + + +def _current_user(user=Depends(git_perms)): + return user + + +def _require_admin(cur_user: User) -> None: + if not cur_user.admin: + raise HTTPException(status_code=403, detail="Only admin users can perform this action") + + +class GitRepoCreate(pydantic.BaseModel): + name: str + path: str + project_id: int + description: str | None = None + public: bool = False + default_branch: str = "main" + tests_enabled: bool = False + test_command: str | None = None + + +class GitRepoUpdate(pydantic.BaseModel): + name: str | None = None + path: str | None = None + project_id: int | None = None + description: str | None = None + public: bool | None = None + default_branch: str | None = None + tests_enabled: bool | None = None + test_command: str | None = None + + +class GitRepoResponse(pydantic.BaseModel): + model_config = pydantic.ConfigDict(from_attributes=True) + + id: int + project_id: int + name: str + path: str + description: str | None = None + public: bool + default_branch: str + tests_enabled: bool + test_command: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class GitPermissionCreate(pydantic.BaseModel): + user_email: pydantic.EmailStr + access: str + + +class GitPermissionResponse(pydantic.BaseModel): + model_config = pydantic.ConfigDict(from_attributes=True) + + id: int + repo_id: int + user_email: str + access: str + created_at: datetime | None = None + + +class GitPushLogResponse(pydantic.BaseModel): + model_config = pydantic.ConfigDict(from_attributes=True) + + id: int + repo_id: int + ref: str + old_rev: str | None = None + new_rev: str | None = None + pusher_email: str + status: str + output: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class HookPushInput(pydantic.BaseModel): + ref: str + old_rev: str | None = None + new_rev: str | None = None + pusher_email: pydantic.EmailStr + + +async def _get_repo(id: int) -> GitRepo: + return await store.get_repo(id) + + +async def _get_permission(perm_id: int) -> GitPermission: + return await store.get_permission(perm_id) + + +async def _run_tests(repo: GitRepo, push_log: GitPushLog) -> None: + command = repo.test_command + if not command: + return + try: + log.info("Running tests for repo %s: %s", repo.name, command) + proc = await asyncio.to_thread( + subprocess.run, + shlex.split(command), + capture_output=True, + text=True, + cwd=repo.path or None, + check=False, + ) # nosec + output = f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}".strip() + status = "success" if proc.returncode == 0 else "failure" + except Exception as exc: + log.error("Failed to run tests for repo %s: %s", repo.name, exc) + output = str(exc) + status = "failure" + await store.update_push_status(push_log, status, output) + + +@route("/git/repos", tags=tags) +class GitRepoListAPI: + @staticmethod + @description("Get repositories") + async def get( + page: int = Header(default=1), + perpage: int = Header(default=10), + cur_user: User = Depends(git_perms), + ) -> Page[GitRepoResponse]: + _require_admin(cur_user) + return await store.list_repos(page, perpage) + + @staticmethod + @description("Create repository") + async def post( + data: GitRepoCreate, + cur_user: User = Depends(git_perms), + ) -> GitRepoResponse: + _require_admin(cur_user) + if "/" in data.name or data.name.startswith("."): + raise HTTPException(status_code=400, detail="Invalid repository name") + existing = await store.count_repos_by_name_or_path(data.name, data.path) + if existing > 0: + raise HTTPException(status_code=409, detail="Repository name or path already in use") + now = datetime.utcnow() + repo = await store.create_repo( + name=data.name, + path=data.path, + project_id=data.project_id, + description=data.description, + public=data.public, + default_branch=data.default_branch, + tests_enabled=data.tests_enabled, + test_command=data.test_command, + created_at=now, + updated_at=now, + ) + return GitRepoResponse.model_validate(repo) + + +@route("/git/repos/public", tags=tags) +class GitRepoPublicAPI: + @staticmethod + @description("Get public repositories") + async def get( + page: int = Header(default=1), + perpage: int = Header(default=10), + ) -> Page[GitRepoResponse]: + return await store.list_repos(page, perpage, public=True) + + +@route("/git/repos/{id}", tags=tags) +class GitRepoDetailAPI: + @staticmethod + async def get( + id: int, + cur_user: User = Depends(git_perms), + ) -> GitRepoResponse: + _require_admin(cur_user) + repo = await _get_repo(id) + return GitRepoResponse.model_validate(repo) + + @staticmethod + async def patch( + id: int, + data: GitRepoUpdate, + cur_user: User = Depends(git_perms), + ) -> GitRepoResponse: + _require_admin(cur_user) + repo = await _get_repo(id) + await store.update_repo(repo, data) + return GitRepoResponse.model_validate(repo) + + @staticmethod + async def delete( + id: int, + cur_user: User = Depends(git_perms), + ) -> GitRepoResponse: + _require_admin(cur_user) + repo = await _get_repo(id) + await store.delete_repo(repo) + return GitRepoResponse.model_validate(repo) + + +@route("/git/repos/{id}/permissions", tags=tags) +class GitPermissionListAPI: + @staticmethod + @description("List repository permissions") + async def get( + id: int, + page: int = Header(default=1), + perpage: int = Header(default=10), + cur_user: User = Depends(git_perms), + ) -> Page[GitPermissionResponse]: + _require_admin(cur_user) + await _get_repo(id) + return await store.list_permissions(id, page, perpage) + + @staticmethod + @description("Add repository permission") + async def post( + id: int, + data: GitPermissionCreate, + cur_user: User = Depends(git_perms), + ) -> GitPermissionResponse: + _require_admin(cur_user) + repo = await _get_repo(id) + permission = await store.create_permission( + repo=repo, + user_email=data.user_email, + access=data.access, + ) + return GitPermissionResponse.model_validate(permission) + + +@route("/git/repos/{id}/permissions/{perm_id}", tags=tags) +class GitPermissionDetailAPI: + @staticmethod + @description("Remove repository permission") + async def delete( + id: int, + perm_id: int, + cur_user: User = Depends(git_perms), + ) -> GitPermissionResponse: + _require_admin(cur_user) + await _get_repo(id) + permission = await _get_permission(perm_id) + if permission.repo_id != id: + raise HTTPException(status_code=404, detail="Permission not found") + await store.delete_permission(permission) + return GitPermissionResponse.model_validate(permission) + + +@route("/git/repos/{id}/push-log", tags=tags) +class GitPushLogListAPI: + @staticmethod + @description("List push log") + async def get( + id: int, + page: int = Header(default=1), + perpage: int = Header(default=10), + cur_user: User = Depends(git_perms), + ) -> Page[GitPushLogResponse]: + _require_admin(cur_user) + await _get_repo(id) + return await store.list_push_logs(id, page, perpage) + + +@route("/git/repos/{id}/hooks/push", tags=tags) +class GitHookPushAPI: + @staticmethod + @description("Record a push and optionally trigger tests") + async def post( + id: int, + data: HookPushInput, + background_tasks: BackgroundTasks, + ) -> GitPushLogResponse: + repo = await _get_repo(id) + push_log = await store.create_push_log( + repo=repo, + ref=data.ref, + old_rev=data.old_rev, + new_rev=data.new_rev, + pusher_email=data.pusher_email, + status="pending", + ) + if repo.tests_enabled and repo.test_command: + background_tasks.add_task(_run_tests, repo, push_log) + else: + await store.update_push_status(push_log, "completed") + return GitPushLogResponse.model_validate(push_log) + + +@route("/git/repos/{id}/hooks", tags=tags) +class GitHooksAPI: + @staticmethod + @description("Get hook scripts for this repository") + async def get( + id: int, + cur_user: User = Depends(git_perms), + ) -> dict[str, str]: + _require_admin(cur_user) + repo = await _get_repo(id) + return { + "pre-receive": ( + "#!/bin/sh\n" + f"python -m freenit.git.hooks '{repo.name}' pre-receive\n" + ), + "update": ( + "#!/bin/sh\n" + "ref=$1\n" + "oldrev=$2\n" + "newrev=$3\n" + f"python -m freenit.git.hooks '{repo.name}' update \"$ref\" \"$oldrev\" \"$newrev\"\n" + ), + "post-receive": ( + "#!/bin/sh\n" + f"python -m freenit.git.hooks '{repo.name}' post-receive\n" + ), + } + + +class GitRefResponse(pydantic.BaseModel): + name: str + sha: str + + +class GitCommitTaskRefItem(pydantic.BaseModel): + task_id: int + relation: str + + +class GitCommitResponse(pydantic.BaseModel): + sha: str + message: str + author: str + committer: str + timestamp: datetime | None = None + task_refs: list[GitCommitTaskRefItem] = [] + + +class GitTreeEntryResponse(pydantic.BaseModel): + name: str + mode: str + type: str + sha: str + + +class GitBlobResponse(pydantic.BaseModel): + name: str + sha: str + size: int + content: str + binary: bool + + +class GitReadmeResponse(pydantic.BaseModel): + name: str + sha: str + content: str + binary: bool + + +@route("/git/repos/{id}/refs", tags=tags) +class GitRefsAPI: + @staticmethod + @description("List branches and tags") + async def get( + id: int, + cur_user: User = Depends(git_perms), + ) -> list[GitRefResponse]: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + return [ + GitRefResponse.model_validate(item) + for item in git_repo.list_refs(repo.path) + ] + + +@route("/git/repos/{id}/commits", tags=tags) +class GitCommitsAPI: + @staticmethod + @description("List commits") + async def get( + id: int, + ref: str = "main", + page: int = Header(default=1), + perpage: int = Header(default=20), + cur_user: User = Depends(git_perms), + ) -> list[GitCommitResponse]: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + commits = git_repo.list_commits(repo.path, ref, page, perpage) + task_ref_map = await store.get_commit_task_ref_map(id) + result = [] + for commit in commits: + data = dict(commit) + data["task_refs"] = task_ref_map.get(commit["sha"], []) + result.append(GitCommitResponse.model_validate(data)) + return result + + +@route("/git/repos/{id}/tree", tags=tags) +class GitTreeAPI: + @staticmethod + @description("List tree entries") + async def get( + id: int, + ref: str = "main", + path: str = "", + cur_user: User = Depends(git_perms), + ) -> list[GitTreeEntryResponse]: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + return [ + GitTreeEntryResponse.model_validate(item) + for item in git_repo.list_tree(repo.path, ref, path) + ] + + +@route("/git/repos/{id}/blob", tags=tags) +class GitBlobAPI: + @staticmethod + @description("Get file content") + async def get( + id: int, + ref: str = "main", + path: str = "", + cur_user: User = Depends(git_perms), + ) -> GitBlobResponse: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + if not path: + raise HTTPException(status_code=400, detail="Path is required") + return GitBlobResponse.model_validate( + git_repo.get_blob(repo.path, ref, path) + ) + + +@route("/git/repos/{id}/readme", tags=tags) +class GitReadmeAPI: + @staticmethod + @description("Get README content") + async def get( + id: int, + ref: str = "main", + cur_user: User = Depends(git_perms), + ) -> GitReadmeResponse: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + data = git_repo.get_readme(repo.path, ref) + if data is None: + raise HTTPException(status_code=404, detail="README not found") + return GitReadmeResponse.model_validate(data) + + +@route("/git/repos/{id}/clone-url", tags=tags) +class GitCloneUrlAPI: + @staticmethod + @description("Get HTTPS clone URL") + async def get( + id: int, + cur_user: User = Depends(git_perms), + ) -> dict[str, str]: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + host = config.hostname + if config.port != 443 and config.port != 80: + host = f"{host}:{config.port}" + return {"clone_url": f"https://{host}/git/{repo.name}"} + + +class GitCommitTaskRefResponse(pydantic.BaseModel): + model_config = pydantic.ConfigDict(from_attributes=True) + + id: int + repo_id: int + task_id: int + commit_sha: str + relation: str + created_at: datetime | None = None + + +@route("/git/repos/{id}/task-refs", tags=tags) +class GitCommitTaskRefListAPI: + @staticmethod + @description("List commit-task references") + async def get( + id: int, + page: int = Header(default=1), + perpage: int = Header(default=20), + cur_user: User = Depends(git_perms), + ) -> Page[GitCommitTaskRefResponse]: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + return await store.list_commit_task_refs(id, page, perpage) + + +@route("/git/repos/{id}/task-refs/by-task/{task_id}", tags=tags) +class GitCommitTaskRefByTaskAPI: + @staticmethod + @description("List commit-task references for a task") + async def get( + id: int, + task_id: int, + page: int = Header(default=1), + perpage: int = Header(default=20), + cur_user: User = Depends(git_perms), + ) -> Page[GitCommitTaskRefResponse]: + repo = await _get_repo(id) + if not repo.public: + _require_admin(cur_user) + return await store.list_commit_task_refs_by_task(id, task_id, page, perpage) diff --git a/freenit/api/git_http.py b/freenit/api/git_http.py new file mode 100644 index 0000000..7d02a3e --- /dev/null +++ b/freenit/api/git_http.py @@ -0,0 +1,204 @@ +import base64 +import logging +import subprocess # nosec: B404 +from typing import Optional + +import pydantic +from fastapi import APIRouter, Header, HTTPException, Request +from fastapi.responses import Response as FastAPIResponse + +from freenit.config import getConfig +from freenit.git import store +from freenit.models.user import User + +config = getConfig() +log = logging.getLogger("git_http") + +router = APIRouter() + + +class Credentials(pydantic.BaseModel): + email: str + password: str + + +def _packet_line(line: str) -> bytes: + data = line.encode("utf-8") + length = len(data) + 4 + return f"{length:04x}".encode("ascii") + data + + +def _service_command(service: str) -> str: + if service == "git-upload-pack": + return "upload-pack" + if service == "git-receive-pack": + return "receive-pack" + raise HTTPException(status_code=400, detail="Invalid git service") + + +def _required_access(service: str) -> str: + if service == "git-upload-pack": + return "read" + if service == "git-receive-pack": + return "write" + raise HTTPException(status_code=400, detail="Invalid git service") + + +async def _authenticate(request: Request) -> Optional[str]: + auth_header = request.headers.get("authorization") + if not auth_header: + return None + scheme, _, token = auth_header.partition(" ") + if scheme.lower() != "basic" or not token: + return None + try: + decoded = base64.b64decode(token).decode("utf-8") + except Exception: + return None + email, _, password = decoded.partition(":") + if not email or not password: + return None + try: + user = await User.login(Credentials(email=email, password=password)) + except HTTPException: + return None + return user.email + + +def _basic_auth_challenge() -> FastAPIResponse: + return FastAPIResponse( + status_code=401, + headers={"WWW-Authenticate": 'Basic realm="git"'}, + content=b"Authentication required", + ) + + +async def _get_repo(repo_name: str): + return await store.get_repo_by_name(repo_name) + + +async def _check_service_access( + repo, + service: str, + user_email: Optional[str], +) -> None: + required = _required_access(service) + if required == "read" and repo.public and not user_email: + return + if not user_email: + raise HTTPException(status_code=401, detail="Authentication required") + allowed = await store.check_access(repo, user_email, required) + if not allowed: + raise HTTPException(status_code=403, detail="Access denied") + + +@router.get("/git/{repo_name}/info/refs") +async def git_info_refs( + repo_name: str, + service: str, + request: Request, +): + if not service: + return FastAPIResponse(status_code=400, content=b"Service required") + repo = await _get_repo(repo_name) + user_email = await _authenticate(request) + try: + await _check_service_access(repo, service, user_email) + except HTTPException as exc: + if exc.status_code == 401: + return _basic_auth_challenge() + raise + + command = _service_command(service) + try: + proc = await __import__("asyncio").to_thread( + subprocess.run, + ["git", "-C", repo.path, command, "--advertise-refs", "."], + capture_output=True, + check=False, + ) # nosec + except Exception as exc: + log.error("Failed to advertise refs for %s: %s", repo_name, exc) + raise HTTPException(status_code=502, detail="Git backend error") + + if proc.returncode != 0: + log.error("git advertise-refs failed: %s", proc.stderr.decode("utf-8", errors="replace")) + raise HTTPException(status_code=502, detail="Git backend error") + + body = ( + _packet_line(f"# service={service}\n") + + b"0000" + + proc.stdout + ) + return FastAPIResponse( + content=body, + media_type=f"application/x-{service}-advertisement", + ) + + +@router.post("/git/{repo_name}/git-upload-pack") +async def git_upload_pack( + repo_name: str, + request: Request, + content_type: Optional[str] = Header(default=None), +): + repo = await _get_repo(repo_name) + user_email = await _authenticate(request) + try: + await _check_service_access(repo, "git-upload-pack", user_email) + except HTTPException as exc: + if exc.status_code == 401: + return _basic_auth_challenge() + raise + + body = await request.body() + try: + proc = await __import__("asyncio").to_thread( + subprocess.run, + ["git", "-C", repo.path, "upload-pack", "--stateless-rpc", "."], + input=body, + capture_output=True, + check=False, + ) # nosec + except Exception as exc: + log.error("Failed upload-pack for %s: %s", repo_name, exc) + raise HTTPException(status_code=502, detail="Git backend error") + + return FastAPIResponse( + content=proc.stdout, + media_type="application/x-git-upload-pack-result", + ) + + +@router.post("/git/{repo_name}/git-receive-pack") +async def git_receive_pack( + repo_name: str, + request: Request, + content_type: Optional[str] = Header(default=None), +): + repo = await _get_repo(repo_name) + user_email = await _authenticate(request) + try: + await _check_service_access(repo, "git-receive-pack", user_email) + except HTTPException as exc: + if exc.status_code == 401: + return _basic_auth_challenge() + raise + + body = await request.body() + try: + proc = await __import__("asyncio").to_thread( + subprocess.run, + ["git", "-C", repo.path, "receive-pack", "--stateless-rpc", "."], + input=body, + capture_output=True, + check=False, + ) # nosec + except Exception as exc: + log.error("Failed receive-pack for %s: %s", repo_name, exc) + raise HTTPException(status_code=502, detail="Git backend error") + + return FastAPIResponse( + content=proc.stdout, + media_type="application/x-git-receive-pack-result", + ) diff --git a/freenit/app.py b/freenit/app.py index bea7a66..e59f048 100644 --- a/freenit/app.py +++ b/freenit/app.py @@ -30,3 +30,8 @@ async def lifespan(_: FastAPI): app = FastAPI(lifespan=lifespan) app.mount(config.api_root, api) + +if "git" in config.modules: + from .api.git_http import router as git_http_router + + app.include_router(git_http_router) diff --git a/freenit/base_config.py b/freenit/base_config.py index 38fcf2e..b1442ea 100644 --- a/freenit/base_config.py +++ b/freenit/base_config.py @@ -92,6 +92,15 @@ def __init__( mlidNextClass="freenitMailingListIdNext", mlidNextDN="cn=mlidnext,dc=ldap", mlidNextField="mlidNumber", + gitBase="ou=git,dc=ldap", + gitRepoDN="cn={}", + gitRepoClasses=["freenitGitRepo"], + gitPermissionClasses=["freenitGitPermission"], + gitPushLogClasses=["freenitGitPushLog"], + gitCommitTaskRefClasses=["freenitGitCommitTaskRef"], + gitIdNextClass="freenitGitIdNext", + gitIdNextDN="cn=gitidnext,dc=ldap", + gitIdNextField="gitIdNumber", ): self.host = host self.tls = tls @@ -124,6 +133,15 @@ def __init__( self.mlidNextClass = mlidNextClass self.mlidNextDN = mlidNextDN self.mlidNextField = mlidNextField + self.gitBase = gitBase + self.gitRepoDN = f"{gitRepoDN},{gitBase}" + self.gitRepoClasses = gitRepoClasses + self.gitPermissionClasses = gitPermissionClasses + self.gitPushLogClasses = gitPushLogClasses + self.gitCommitTaskRefClasses = gitCommitTaskRefClasses + self.gitIdNextClass = gitIdNextClass + self.gitIdNextDN = gitIdNextDN + self.gitIdNextField = gitIdNextField class BaseConfig: @@ -141,6 +159,7 @@ class BaseConfig: mailinglist = "freenit.models.sql.mailinglist" project = "freenit.models.sql.project" lms = "freenit.models.sql.lms" + git = "freenit.models.sql.git" modules = ["auth"] meta = None auth = Auth() @@ -208,6 +227,7 @@ class TestConfig(BaseConfig): "sieve", "jabber", "omemo", + "git", ] diff --git a/freenit/git/__init__.py b/freenit/git/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/freenit/git/hooks.py b/freenit/git/hooks.py new file mode 100644 index 0000000..99befe0 --- /dev/null +++ b/freenit/git/hooks.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Git hook helper for Freenit-managed repositories. + +This module is meant to be called from the git hooks of a bare repository: + + python -m freenit.git.hooks pre-receive + python -m freenit.git.hooks update + python -m freenit.git.hooks post-receive + +The pusher identity is taken from the FREENIT_PUSHER_EMAIL environment +variable, falling back to ``$USER@localhost``. +""" + +import asyncio +import os +import subprocess # nosec: B404 +import sys +from typing import List, Tuple + +from freenit.config import getConfig +from freenit.git import repo as git_repo +from freenit.git import store +from freenit.models.git import GitRepo + +config = getConfig() + + +ACCESS_ORDER = {"read": 0, "write": 1, "admin": 2} + + +def _pusher_email() -> str: + email = os.environ.get("FREENIT_PUSHER_EMAIL") + if email: + return email + user = os.environ.get("USER") or os.environ.get("LOGNAME") or "unknown" + return f"{user}@localhost" + + +async def _ensure_db(): + if GitRepo.dbtype() == "sql" and not config.database.connected: + await config.database.connect() + + +async def _get_repo_by_name(name: str) -> GitRepo: + await _ensure_db() + return await store.get_repo_by_name(name) + + +def _error(message: str) -> None: + print(message, file=sys.stderr) + + +def _parse_ref_line(line: str) -> Tuple[str, str, str]: + parts = line.strip().split(" ") + if len(parts) != 3: + raise ValueError(f"Invalid ref line: {line!r}") + return parts[0], parts[1], parts[2] + + +def _refs_from_stdin() -> List[Tuple[str, str, str]]: + refs = [] + for line in sys.stdin: + if line.strip(): + refs.append(_parse_ref_line(line)) + return refs + + +def _is_deletion(new_rev: str) -> bool: + return new_rev == "0" * len(new_rev) + + +def _is_creation(old_rev: str) -> bool: + return old_rev == "0" * len(old_rev) + + +async def _check_write(repo: GitRepo, ref: str) -> bool: + email = _pusher_email() + allowed = await store.check_access(repo, email, "write") + if not allowed: + _error(f"User {email} does not have write access to {repo.name}") + return allowed + + +async def pre_receive(repo_name: str) -> int: + """Validate all refs in this push before accepting it.""" + try: + repo = await _get_repo_by_name(repo_name) + except Exception as exc: + _error(f"Failed to load repository {repo_name}: {exc}") + return 1 + + refs = _refs_from_stdin() + if not refs: + return 0 + + for _old, _new, ref in refs: + if not await _check_write(repo, ref): + return 1 + return 0 + + +async def update(repo_name: str, ref: str, old_rev: str, new_rev: str) -> int: + """Validate a single ref update.""" + try: + repo = await _get_repo_by_name(repo_name) + except Exception as exc: + _error(f"Failed to load repository {repo_name}: {exc}") + return 1 + + if not await _check_write(repo, ref): + return 1 + return 0 + + +async def _run_tests_detached(repo_name: str, log_id: int) -> None: + """Start a detached process that executes the tests.""" + subprocess.Popen( # nosec + [ + sys.executable, + "-m", + "freenit.git.hooks", + "run-tests", + repo_name, + str(log_id), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +async def _record_task_refs(repo, old_rev: str, new_rev: str) -> None: + try: + commits = git_repo.walk_push_commits(repo.path, old_rev, new_rev) + except Exception as exc: + _error(f"Failed to walk commits for task refs: {exc}") + return + for commit in commits: + for ref in git_repo.extract_task_refs(commit["message"]): + try: + await store.create_commit_task_ref( + repo=repo, + task_id=ref["task_id"], + commit_sha=commit["sha"], + relation=ref["relation"], + ) + except Exception as exc: + _error( + f"Failed to record task ref {ref['relation']} #{ref['task_id']}: {exc}" + ) + + +async def post_receive(repo_name: str) -> int: + """Record pushes, trigger tests, and update task references.""" + try: + repo = await _get_repo_by_name(repo_name) + except Exception as exc: + _error(f"Failed to load repository {repo_name}: {exc}") + return 1 + + refs = _refs_from_stdin() + for old_rev, new_rev, ref in refs: + try: + push_log = await store.create_push_log( + repo=repo, + ref=ref, + old_rev=old_rev, + new_rev=new_rev, + pusher_email=_pusher_email(), + status="pending", + ) + if repo.tests_enabled and repo.test_command: + await _run_tests_detached(repo_name, push_log.id) + else: + await store.update_push_status(push_log, "completed") + await _record_task_refs(repo, old_rev, new_rev) + except Exception as exc: + _error(f"Failed to record push for {ref}: {exc}") + return 0 + + +async def run_tests(repo_name: str, log_id: str) -> int: + """Run the configured test command and update the push log.""" + try: + await _ensure_db() + repo = await _get_repo_by_name(repo_name) + log = await store.get_push_log(int(log_id)) + except Exception as exc: + _error(f"Failed to load repo/log: {exc}") + return 1 + + command = repo.test_command + if not command: + await store.update_push_status(log, "completed") + return 0 + + try: + proc = await asyncio.to_thread( + subprocess.run, + command, + shell=True, + capture_output=True, + text=True, + cwd=repo.path or None, + check=False, + ) # nosec + output = f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}".strip() + status = "success" if proc.returncode == 0 else "failure" + except Exception as exc: + output = str(exc) + status = "failure" + + await store.update_push_status(log, status, output) + return 0 if status == "success" else 1 + + +async def main(argv: List[str]) -> int: + if len(argv) < 2: + _error("Usage: freenit.git.hooks [args...]") + return 1 + + repo_name = argv[0] + hook = argv[1] + args = argv[2:] + + await _ensure_db() + + if hook == "pre-receive": + return await pre_receive(repo_name) + if hook == "update": + if len(args) != 3: + _error("Usage: update ") + return 1 + return await update(repo_name, args[0], args[1], args[2]) + if hook == "post-receive": + return await post_receive(repo_name) + if hook == "run-tests": + if len(args) != 1: + _error("Usage: run-tests ") + return 1 + return await run_tests(repo_name, args[0]) + + _error(f"Unknown hook: {hook}") + return 1 + + +if __name__ == "__main__": + try: + sys.exit(asyncio.run(main(sys.argv[1:]))) + except KeyboardInterrupt: + sys.exit(130) diff --git a/freenit/git/repo.py b/freenit/git/repo.py new file mode 100644 index 0000000..9c8fdbc --- /dev/null +++ b/freenit/git/repo.py @@ -0,0 +1,235 @@ +import os +import re +from datetime import datetime, timedelta, timezone + +from dulwich import errors as dulwich_errors +from dulwich.repo import Repo +from fastapi import HTTPException + + +def _ensure_repo(path: str) -> Repo: + if not path or not os.path.isdir(path): + raise HTTPException(status_code=404, detail="Repository path not found") + try: + return Repo(path) + except dulwich_errors.NotGitRepository: + raise HTTPException(status_code=404, detail="Not a git repository") + + +def _resolve_ref(repo: Repo, ref: str) -> bytes: + try: + return repo.refs[f"refs/heads/{ref}".encode()] + except KeyError: + pass + try: + return repo.refs[f"refs/tags/{ref}".encode()] + except KeyError: + pass + # Allow full sha/ref names. + try: + return repo.refs[ref.encode()] + except KeyError: + pass + try: + return repo[ref.encode()].id + except KeyError: + raise HTTPException(status_code=404, detail="Ref not found") + + +def _commit_timestamp(commit) -> datetime | None: + timestamp = getattr(commit, "commit_time", None) + if timestamp is None: + return None + tz_offset = getattr(commit, "commit_timezone", 0) + tz = timezone.utc if tz_offset == 0 else timezone( + offset=timedelta(seconds=tz_offset) + ) + return datetime.fromtimestamp(timestamp, tz=tz) + + +def _decode_message(message: bytes) -> str: + return message.decode("utf-8", errors="replace") + + +def list_refs(path: str) -> list[dict]: + repo = _ensure_repo(path) + result = [] + for ref_name, sha in repo.refs.as_dict().items(): + if ref_name.startswith(b"refs/heads/") or ref_name.startswith(b"refs/tags/"): + result.append({ + "name": ref_name.decode("utf-8", errors="replace"), + "sha": sha.decode("ascii"), + }) + result.sort(key=lambda r: r["name"]) + return result + + +def list_commits(path: str, ref: str, page: int, perpage: int) -> list[dict]: + repo = _ensure_repo(path) + sha = _resolve_ref(repo, ref) + walker = repo.get_walker(include=[sha]) + commits = [] + for entry in walker: + commit = entry.commit + commits.append({ + "sha": entry.commit.id.decode("ascii"), + "message": _decode_message(commit.message).strip(), + "author": _decode_message(commit.author), + "committer": _decode_message(commit.committer), + "timestamp": _commit_timestamp(commit), + }) + offset = (max(page, 1) - 1) * perpage + return commits[offset : offset + perpage] + + +def _object_type(repo: Repo, sha: bytes) -> str: + try: + obj = repo[sha] + return obj.type_name.decode("ascii") + except KeyError: + return "unknown" + + +def list_tree(path: str, ref: str, tree_path: str) -> list[dict]: + repo = _ensure_repo(path) + sha = _resolve_ref(repo, ref) + commit = repo[sha] + if commit.type_name != b"commit": + raise HTTPException(status_code=400, detail="Ref is not a commit") + tree = repo[commit.tree] + if tree_path: + parts = [p for p in tree_path.split("/") if p] + for part in parts: + found = False + for name, mode, entry_sha in tree.iteritems(): + if name.decode("utf-8", errors="replace") == part: + child = repo[entry_sha] + if child.type_name != b"tree": + raise HTTPException(status_code=400, detail="Path is not a tree") + tree = child + found = True + break + if not found: + raise HTTPException(status_code=404, detail="Path not found") + result = [] + for name, mode, entry_sha in tree.iteritems(): + result.append({ + "name": name.decode("utf-8", errors="replace"), + "mode": oct(mode)[2:], + "type": _object_type(repo, entry_sha), + "sha": entry_sha.decode("ascii"), + }) + result.sort(key=lambda e: (e["type"] != "tree", e["name"].lower())) + return result + + +def get_blob(path: str, ref: str, blob_path: str) -> dict: + repo = _ensure_repo(path) + sha = _resolve_ref(repo, ref) + commit = repo[sha] + if commit.type_name != b"commit": + raise HTTPException(status_code=400, detail="Ref is not a commit") + tree = repo[commit.tree] + parts = [p for p in blob_path.split("/") if p] + if not parts: + raise HTTPException(status_code=400, detail="Path is required") + for part in parts[:-1]: + found = False + for name, mode, entry_sha in tree.iteritems(): + if name.decode("utf-8", errors="replace") == part: + child = repo[entry_sha] + if child.type_name != b"tree": + raise HTTPException(status_code=400, detail="Path is not a tree") + tree = child + found = True + break + if not found: + raise HTTPException(status_code=404, detail="Path not found") + target_name = parts[-1] + for name, mode, entry_sha in tree.iteritems(): + if name.decode("utf-8", errors="replace") == target_name: + obj = repo[entry_sha] + if obj.type_name != b"blob": + raise HTTPException(status_code=400, detail="Path is not a file") + data = obj.data + return { + "name": target_name, + "sha": entry_sha.decode("ascii"), + "size": len(data), + "content": data.decode("utf-8", errors="replace"), + "binary": b"\0" in data, + } + raise HTTPException(status_code=404, detail="File not found") + + +def get_readme(path: str, ref: str) -> dict | None: + repo = _ensure_repo(path) + sha = _resolve_ref(repo, ref) + commit = repo[sha] + if commit.type_name != b"commit": + return None + tree = repo[commit.tree] + candidates = [b"README.md", b"README", b"readme.md", b"readme"] + for candidate in candidates: + for name, mode, entry_sha in tree.iteritems(): + if name == candidate: + obj = repo[entry_sha] + if obj.type_name == b"blob": + data = obj.data + return { + "name": candidate.decode("utf-8", errors="replace"), + "sha": entry_sha.decode("ascii"), + "content": data.decode("utf-8", errors="replace"), + "binary": b"\0" in data, + } + return None + + +_FIX_RE = re.compile(r"(?:fixes|fix)\s*:\s*((?:#\d+(?:\s*,\s*#\d+)*))", re.IGNORECASE) +_REFER_RE = re.compile(r"(?:refers|refer)\s*:\s*((?:#\d+(?:\s*,\s*#\d+)*))", re.IGNORECASE) + + +def _extract_ids(text: str) -> list[int]: + return [int(num) for num in re.findall(r"#(\d+)", text)] + + +def extract_task_refs(message: str) -> list[dict]: + """Extract task references from a commit message. + + Supports patterns like: + fixes: #1 + fixes: #1, #2, #3 + refer: #42 + """ + result = [] + for match in _FIX_RE.finditer(message): + for task_id in _extract_ids(match.group(1)): + result.append({"task_id": task_id, "relation": "fix"}) + for match in _REFER_RE.finditer(message): + for task_id in _extract_ids(match.group(1)): + result.append({"task_id": task_id, "relation": "refer"}) + return result + + +def walk_push_commits( + path: str, + old_rev: str | None, + new_rev: str | None, +) -> list[dict]: + """Return commits introduced by a push, each with sha and message.""" + repo = _ensure_repo(path) + if not new_rev or new_rev == "0" * len(new_rev): + return [] + new_sha = new_rev.encode("ascii") + include = [new_sha] + exclude = [] + if old_rev and old_rev != "0" * len(old_rev): + exclude = [old_rev.encode("ascii")] + walker = repo.get_walker(include=include, exclude=exclude) + return [ + { + "sha": entry.commit.id.decode("ascii"), + "message": _decode_message(entry.commit.message), + } + for entry in walker + ] diff --git a/freenit/git/store.py b/freenit/git/store.py new file mode 100644 index 0000000..3ac0c9e --- /dev/null +++ b/freenit/git/store.py @@ -0,0 +1,76 @@ +from freenit.models.git import GitRepo + +if GitRepo.dbtype() == "sql": + from .store_sql import ( + check_access, + count_repos_by_name_or_path, + create_commit_task_ref, + create_permission, + create_push_log, + create_repo, + delete_permission, + delete_repo, + get_commit_task_ref_map, + get_permission, + get_push_log, + get_repo, + get_repo_by_name, + list_commit_task_refs, + list_commit_task_refs_by_commit, + list_commit_task_refs_by_task, + list_permissions, + list_push_logs, + list_repos, + update_push_status, + update_repo, + ) +elif GitRepo.dbtype() == "ldap": + from .store_ldap import ( + check_access, + count_repos_by_name_or_path, + create_commit_task_ref, + create_permission, + create_push_log, + create_repo, + delete_permission, + delete_repo, + get_commit_task_ref_map, + get_permission, + get_push_log, + get_repo, + get_repo_by_name, + list_commit_task_refs, + list_commit_task_refs_by_commit, + list_commit_task_refs_by_task, + list_permissions, + list_push_logs, + list_repos, + update_push_status, + update_repo, + ) +else: + raise RuntimeError(f"Unsupported git backend: {GitRepo.dbtype()}") + +__all__ = [ + "check_access", + "count_repos_by_name_or_path", + "create_commit_task_ref", + "create_permission", + "create_push_log", + "create_repo", + "delete_permission", + "delete_repo", + "get_commit_task_ref_map", + "get_permission", + "get_push_log", + "get_repo", + "get_repo_by_name", + "list_commit_task_refs", + "list_commit_task_refs_by_commit", + "list_commit_task_refs_by_task", + "list_permissions", + "list_push_logs", + "list_repos", + "update_push_status", + "update_repo", +] diff --git a/freenit/git/store_ldap.py b/freenit/git/store_ldap.py new file mode 100644 index 0000000..ec6ee23 --- /dev/null +++ b/freenit/git/store_ldap.py @@ -0,0 +1,639 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from math import ceil + +from bonsai import LDAPEntry, LDAPSearchScope, errors +from fastapi import HTTPException + +from freenit.config import getConfig +from freenit.models.git import GitCommitTaskRef, GitPermission, GitPushLog, GitRepo +from freenit.models.ldap.base import class2filter, get_client, next_git_id, save_data +from freenit.models.pagination import Page +from .tasks import task_in_project + +config = getConfig() + +_ACCESS_LEVELS = { + "read": 0, + "write": 1, + "admin": 2, +} + + +def _first(entry, attr, default=None): + value = entry.get(attr) + if not value: + return default + if isinstance(value, (list, tuple)): + value = value[0] + return value + + +def _int(entry, attr, default=None): + value = _first(entry, attr) + if value is None: + return default + return int(value) + + +def _ldap_dt(dt: datetime | None) -> str | None: + if dt is None: + return None + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt.strftime("%Y%m%d%H%M%SZ") + + +def _ldap_bool(value: bool) -> str: + return "TRUE" if value else "FALSE" + + +def _repo_dn(name: str) -> str: + return config.ldap.gitRepoDN.format(name) + + +def _permission_dn(repo_name: str, perm_id: int) -> str: + return f"cn={perm_id},{_repo_dn(repo_name)}" + + +def _push_log_dn(repo_name: str, log_id: int) -> str: + return f"cn={log_id},{_repo_dn(repo_name)}" + + +def _repo_filter(): + return class2filter(config.ldap.gitRepoClasses) + + +def _permission_filter(): + return class2filter(config.ldap.gitPermissionClasses) + + +def _push_log_filter(): + return class2filter(config.ldap.gitPushLogClasses) + + +def _task_ref_filter(): + return class2filter(config.ldap.gitCommitTaskRefClasses) + + +def _task_ref_dn(repo_name: str, ref_id: int) -> str: + return f"cn={ref_id},{_repo_dn(repo_name)}" + + +async def _search_repo_by_id(repo_id: int): + classes = _repo_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gitBase, + LDAPSearchScope.SUB, + f"(&{classes}(gitIdNumber={repo_id}))", + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such repository") + if len(res) > 1: + raise HTTPException(status_code=409, detail="Multiple repositories found") + return res[0] + + +async def _search_repo_by_name(name: str): + classes = _repo_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gitBase, + LDAPSearchScope.SUB, + f"(&{classes}(cn={name}))", + ) + return res[0] if res else None + + +async def _count_by_name_or_path(name: str, path: str) -> int: + classes = _repo_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gitBase, + LDAPSearchScope.SUB, + f"(&{classes}(|(cn={name})(gitPath={path})))", + ) + return len(res) + + +async def get_repo(id: int) -> GitRepo: + entry = await _search_repo_by_id(id) + return GitRepo.from_entry(entry) + + +async def get_repo_by_name(name: str) -> GitRepo: + entry = await _search_repo_by_name(name) + if entry is None: + raise HTTPException(status_code=404, detail="No such repository") + return GitRepo.from_entry(entry) + + +async def list_repos( + page: int, + perpage: int, + public: bool | None = None, +) -> Page[GitRepo]: + classes = _repo_filter() + filter_exp = classes + if public is not None: + filter_exp = f"(&{classes}(gitPublic={_ldap_bool(public)}))" + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gitBase, + LDAPSearchScope.SUB, + filter_exp, + ) + data = [GitRepo.from_entry(entry) for entry in res] + data.sort(key=lambda r: r.id, reverse=True) + total = len(data) + pages = ceil(total / perpage) if perpage else 1 + if total > 0 and page > pages: + raise HTTPException(status_code=404, detail="No such page") + offset = max(page - 1, 0) * perpage + page_data = data[offset : offset + perpage] + return Page(total=total, page=page, pages=pages, perpage=perpage, data=page_data) + + +async def count_repos_by_name_or_path(name: str, path: str) -> int: + return await _count_by_name_or_path(name, path) + + +async def create_repo(**kwargs) -> GitRepo: + name = kwargs["name"] + path = kwargs["path"] + project_id = kwargs.get("project_id") + if project_id is None: + raise HTTPException(status_code=400, detail="project_id is required") + + existing = await _search_repo_by_name(name) + if existing is not None: + raise HTTPException(status_code=409, detail="Repository already exists") + + if await _count_by_name_or_path(name, path) > 0: + raise HTTPException( + status_code=409, detail="Repository name or path already in use" + ) + + repo_id = await next_git_id() + now = kwargs.get("created_at") or datetime.utcnow() + dn = _repo_dn(name) + entry = LDAPEntry(dn) + entry["objectClass"] = config.ldap.gitRepoClasses + entry["cn"] = name + entry["gitIdNumber"] = repo_id + entry["gitProjectId"] = project_id + entry["gitPath"] = path + description = kwargs.get("description") + if description: + entry["description"] = description + entry["gitPublic"] = _ldap_bool(kwargs.get("public", False)) + entry["gitDefaultBranch"] = kwargs.get("default_branch", "main") + entry["gitTestsEnabled"] = _ldap_bool(kwargs.get("tests_enabled", False)) + test_command = kwargs.get("test_command") + if test_command: + entry["gitTestCommand"] = test_command + entry["createdAt"] = _ldap_dt(now) + entry["updatedAt"] = _ldap_dt(now) + + try: + await save_data(entry) + except errors.AlreadyExists: + raise HTTPException(status_code=409, detail="Repository already exists") + + return GitRepo( + dn=dn, + id=repo_id, + project_id=project_id, + name=name, + path=path, + description=description, + public=kwargs.get("public", False), + default_branch=kwargs.get("default_branch", "main"), + tests_enabled=kwargs.get("tests_enabled", False), + test_command=test_command, + created_at=now, + updated_at=now, + ) + + +async def update_repo(repo: GitRepo, data) -> GitRepo: + fields = data.model_dump(exclude_none=True) + if not fields: + return repo + + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search(repo.dn, LDAPSearchScope.BASE) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such repository") + entry = res[0] + + if "name" in fields: + entry["cn"] = fields["name"] + if "path" in fields: + entry["gitPath"] = fields["path"] + if "project_id" in fields: + entry["gitProjectId"] = fields["project_id"] + if "description" in fields: + entry["description"] = fields["description"] or [] + if "public" in fields: + entry["gitPublic"] = _ldap_bool(fields["public"]) + if "default_branch" in fields: + entry["gitDefaultBranch"] = fields["default_branch"] + if "tests_enabled" in fields: + entry["gitTestsEnabled"] = _ldap_bool(fields["tests_enabled"]) + if "test_command" in fields: + value = fields["test_command"] + if value: + entry["gitTestCommand"] = value + elif "gitTestCommand" in entry: + del entry["gitTestCommand"] + entry["updatedAt"] = _ldap_dt(datetime.utcnow()) + await entry.modify() + + for key, value in fields.items(): + setattr(repo, key, value) + repo.updated_at = datetime.utcnow() + return repo + + +async def delete_repo(repo: GitRepo) -> None: + client = get_client() + async with client.connect(is_async=True) as conn: + children = await conn.search( + repo.dn, + LDAPSearchScope.SUB, + "(objectClass=*)", + attrlist=["objectClass"], + ) + for child in children: + child_dn = str(child["dn"]) + if child_dn != repo.dn: + await child.delete() + res = await conn.search(repo.dn, LDAPSearchScope.BASE) + if res: + await res[0].delete() + + +async def list_permissions( + repo_id: int, + page: int, + perpage: int, +) -> Page[GitPermission]: + repo_entry = await _search_repo_by_id(repo_id) + repo_name = str(repo_entry["cn"][0]) + classes = _permission_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + _repo_dn(repo_name), + LDAPSearchScope.ONELEVEL, + classes, + ) + data = [GitPermission.from_entry(entry, repo_id) for entry in res] + data.sort(key=lambda p: p.id, reverse=True) + total = len(data) + pages = ceil(total / perpage) if perpage else 1 + if total > 0 and page > pages: + raise HTTPException(status_code=404, detail="No such page") + offset = max(page - 1, 0) * perpage + page_data = data[offset : offset + perpage] + return Page(total=total, page=page, pages=pages, perpage=perpage, data=page_data) + + +async def get_permission(id: int) -> GitPermission: + classes = _permission_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gitBase, + LDAPSearchScope.SUB, + f"(&{classes}(gitIdNumber={id}))", + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such permission") + repo_id = 0 + repo_dn = str(res[0]["dn"]).split(",", 1)[1] + repo_res = await _search_repo_by_dn(repo_dn) + if repo_res: + repo_id = _int(repo_res, "gitIdNumber", 0) + return GitPermission.from_entry(res[0], repo_id) + + +async def _search_repo_by_dn(dn: str): + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search(dn, LDAPSearchScope.BASE) + return res[0] if res else None + + +async def create_permission(repo: GitRepo, user_email: str, access: str) -> GitPermission: + if access not in _ACCESS_LEVELS: + raise HTTPException(status_code=400, detail="Invalid access level") + perm_id = await next_git_id() + created_at = datetime.utcnow() + dn = _permission_dn(repo.name, perm_id) + entry = LDAPEntry(dn) + entry["objectClass"] = config.ldap.gitPermissionClasses + entry["cn"] = str(perm_id) + entry["gitIdNumber"] = perm_id + entry["mail"] = user_email + entry["gitAccess"] = access + entry["createdAt"] = _ldap_dt(created_at) + try: + await save_data(entry) + except errors.AlreadyExists: + raise HTTPException(status_code=409, detail="Permission already exists") + return GitPermission( + dn=dn, + id=perm_id, + repo_id=repo.id, + user_email=user_email, + access=access, + created_at=created_at, + ) + + +async def delete_permission(permission: GitPermission) -> None: + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search(permission.dn, LDAPSearchScope.BASE) + if res: + await res[0].delete() + + +async def check_access( + repo: GitRepo, + user_email: str, + required: str, +) -> bool: + required_level = _ACCESS_LEVELS.get(required) + if required_level is None: + return False + if required == "read" and repo.public: + return True + classes = _permission_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + _repo_dn(repo.name), + LDAPSearchScope.ONELEVEL, + f"(&{classes}(mail={user_email}))", + ) + if not res: + return False + access = _first(res[0], "gitAccess", "read") + return _ACCESS_LEVELS.get(access, -1) >= required_level + + +async def list_push_logs( + repo_id: int, + page: int, + perpage: int, +) -> Page[GitPushLog]: + repo_entry = await _search_repo_by_id(repo_id) + repo_name = str(repo_entry["cn"][0]) + classes = _push_log_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + _repo_dn(repo_name), + LDAPSearchScope.ONELEVEL, + classes, + ) + data = [GitPushLog.from_entry(entry, repo_id) for entry in res] + data.sort(key=lambda x: x.created_at or datetime.min, reverse=True) + total = len(data) + pages = ceil(total / perpage) if perpage else 1 + if total > 0 and page > pages: + raise HTTPException(status_code=404, detail="No such page") + offset = max(page - 1, 0) * perpage + page_data = data[offset : offset + perpage] + return Page(total=total, page=page, pages=pages, perpage=perpage, data=page_data) + + +async def get_push_log(id: int) -> GitPushLog: + classes = _push_log_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gitBase, + LDAPSearchScope.SUB, + f"(&{classes}(gitIdNumber={id}))", + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such push log") + repo_id = 0 + repo_dn = str(res[0]["dn"]).split(",", 1)[1] + repo_res = await _search_repo_by_dn(repo_dn) + if repo_res: + repo_id = _int(repo_res, "gitIdNumber", 0) + return GitPushLog.from_entry(res[0], repo_id) + + +async def create_push_log( + repo: GitRepo, + ref: str, + old_rev: str | None, + new_rev: str | None, + pusher_email: str, + status: str = "pending", + output: str | None = None, +) -> GitPushLog: + log_id = await next_git_id() + now = datetime.utcnow() + dn = _push_log_dn(repo.name, log_id) + entry = LDAPEntry(dn) + entry["objectClass"] = config.ldap.gitPushLogClasses + entry["cn"] = str(log_id) + entry["gitIdNumber"] = log_id + entry["gitRef"] = ref + if old_rev: + entry["gitOldRev"] = old_rev + if new_rev: + entry["gitNewRev"] = new_rev + entry["gitPusherEmail"] = pusher_email + entry["gitStatus"] = status + if output: + entry["gitOutput"] = output + entry["createdAt"] = _ldap_dt(now) + entry["updatedAt"] = _ldap_dt(now) + await save_data(entry) + return GitPushLog( + dn=dn, + id=log_id, + repo_id=repo.id, + ref=ref, + old_rev=old_rev, + new_rev=new_rev, + pusher_email=pusher_email, + status=status, + output=output, + created_at=now, + updated_at=now, + ) + + +async def update_push_status( + push_log: GitPushLog, + status: str, + output: str | None = None, +) -> GitPushLog: + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search(push_log.dn, LDAPSearchScope.BASE) + if len(res) < 1: + raise HTTPException(status_code=404, detail="No such push log") + entry = res[0] + entry["gitStatus"] = status + if output is None: + if "gitOutput" in entry: + del entry["gitOutput"] + else: + entry["gitOutput"] = output + entry["updatedAt"] = _ldap_dt(datetime.utcnow()) + await entry.modify() + push_log.status = status + push_log.output = output + push_log.updated_at = datetime.utcnow() + return push_log + + +async def create_commit_task_ref( + repo: GitRepo, + task_id: int, + commit_sha: str, + relation: str, +) -> GitCommitTaskRef: + if not await task_in_project(task_id, repo.project_id): + raise HTTPException( + status_code=400, detail="Task does not belong to repository project" + ) + list_entry = await _search_repo_by_id(repo.id) + repo_name = str(list_entry["cn"][0]) + ref_id = await next_git_id() + created_at = datetime.utcnow() + dn = _task_ref_dn(repo_name, ref_id) + entry = LDAPEntry(dn) + entry["objectClass"] = config.ldap.gitCommitTaskRefClasses + entry["cn"] = str(ref_id) + entry["gitIdNumber"] = ref_id + entry["gitTaskId"] = task_id + entry["gitCommitSha"] = commit_sha + entry["gitRelation"] = relation + entry["createdAt"] = _ldap_dt(created_at) + try: + await save_data(entry) + except errors.AlreadyExists: + existing = await _search_task_ref(repo_name, task_id, commit_sha, relation) + return GitCommitTaskRef.from_entry(existing, repo.id) + return GitCommitTaskRef( + dn=dn, + id=ref_id, + repo_id=repo.id, + task_id=task_id, + commit_sha=commit_sha, + relation=relation, + created_at=created_at, + ) + + +async def _search_task_ref(repo_name: str, task_id: int, commit_sha: str, relation: str): + classes = _task_ref_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + _repo_dn(repo_name), + LDAPSearchScope.ONELEVEL, + f"(&{classes}(gitTaskId={task_id})(gitCommitSha={commit_sha})(gitRelation={relation}))", + ) + if not res: + raise HTTPException(status_code=404, detail="Task reference not found") + return res[0] + + +async def list_commit_task_refs( + repo_id: int, + page: int, + perpage: int, +) -> Page[GitCommitTaskRef]: + repo_entry = await _search_repo_by_id(repo_id) + repo_name = str(repo_entry["cn"][0]) + classes = _task_ref_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + _repo_dn(repo_name), + LDAPSearchScope.ONELEVEL, + classes, + ) + data = [GitCommitTaskRef.from_entry(entry, repo_id) for entry in res] + data.sort(key=lambda r: r.created_at or datetime.min, reverse=True) + total = len(data) + pages = ceil(total / perpage) if perpage else 1 + if total > 0 and page > pages: + raise HTTPException(status_code=404, detail="No such page") + offset = max(page - 1, 0) * perpage + page_data = data[offset : offset + perpage] + return Page(total=total, page=page, pages=pages, perpage=perpage, data=page_data) + + +async def list_commit_task_refs_by_commit( + repo_id: int, + commit_sha: str, +) -> list[GitCommitTaskRef]: + repo_entry = await _search_repo_by_id(repo_id) + repo_name = str(repo_entry["cn"][0]) + classes = _task_ref_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + _repo_dn(repo_name), + LDAPSearchScope.ONELEVEL, + f"(&{classes}(gitCommitSha={commit_sha}))", + ) + return [GitCommitTaskRef.from_entry(entry, repo_id) for entry in res] + + +async def list_commit_task_refs_by_task( + repo_id: int, + task_id: int, + page: int, + perpage: int, +) -> Page[GitCommitTaskRef]: + repo_entry = await _search_repo_by_id(repo_id) + repo_name = str(repo_entry["cn"][0]) + classes = _task_ref_filter() + client = get_client() + async with client.connect(is_async=True) as conn: + res = await conn.search( + _repo_dn(repo_name), + LDAPSearchScope.ONELEVEL, + f"(&{classes}(gitTaskId={task_id}))", + ) + data = [GitCommitTaskRef.from_entry(entry, repo_id) for entry in res] + data.sort(key=lambda r: r.created_at or datetime.min, reverse=True) + total = len(data) + pages = ceil(total / perpage) if perpage else 1 + if total > 0 and page > pages: + raise HTTPException(status_code=404, detail="No such page") + offset = max(page - 1, 0) * perpage + page_data = data[offset : offset + perpage] + return Page(total=total, page=page, pages=pages, perpage=perpage, data=page_data) + + +async def get_commit_task_ref_map(repo_id: int) -> dict[str, list[dict]]: + refs = await list_commit_task_refs_by_commit(repo_id, "") + result: dict[str, list[dict]] = {} + for ref in refs: + result.setdefault(ref.commit_sha, []).append({ + "task_id": ref.task_id, + "relation": ref.relation, + }) + return result diff --git a/freenit/git/store_sql.py b/freenit/git/store_sql.py new file mode 100644 index 0000000..8f024f6 --- /dev/null +++ b/freenit/git/store_sql.py @@ -0,0 +1,261 @@ +from datetime import datetime + +from fastapi import HTTPException + +from freenit.models.git import ( + GitCommitTaskRef, + GitPermission, + GitPushLog, + GitRepo, + IntegrityError, + NotFoundError, +) +from freenit.models.pagination import Page, paginate +from .tasks import task_in_project + + +_ACCESS_LEVELS = { + "read": 0, + "write": 1, + "admin": 2, +} + + +async def get_repo(id: int) -> GitRepo: + try: + return await GitRepo.objects.get(id=id) + except NotFoundError: + raise HTTPException(status_code=404, detail="No such repository") + + +async def get_repo_by_name(name: str) -> GitRepo: + try: + return await GitRepo.objects.get(name=name) + except NotFoundError: + raise HTTPException(status_code=404, detail="No such repository") + + +async def list_repos( + page: int, + perpage: int, + public: bool | None = None, +) -> Page[GitRepo]: + query = GitRepo.objects + if public is not None: + query = query.filter(public=public) + return await paginate(query.order_by("-created_at"), page, perpage) + + +async def count_repos_by_name_or_path(name: str, path: str) -> int: + name_count = await GitRepo.objects.filter(name=name).count() + path_count = await GitRepo.objects.filter(path=path).count() + return name_count + path_count + + +async def create_repo(**kwargs) -> GitRepo: + try: + return await GitRepo.objects.create(**kwargs) + except IntegrityError as e: + raise HTTPException(status_code=409, detail=f"Repository already exists: {e}") + + +async def update_repo(repo: GitRepo, data) -> GitRepo: + await repo.patch(data) + return repo + + +async def delete_repo(repo: GitRepo) -> None: + await repo.delete() + + +async def list_permissions( + repo_id: int, + page: int, + perpage: int, +) -> Page[GitPermission]: + return await paginate( + GitPermission.objects.filter(repo_id=repo_id).order_by("-created_at"), + page, + perpage, + ) + + +async def get_permission(id: int) -> GitPermission: + try: + return await GitPermission.objects.get(id=id) + except NotFoundError: + raise HTTPException(status_code=404, detail="No such permission") + + +async def create_permission(repo: GitRepo, user_email: str, access: str) -> GitPermission: + if access not in _ACCESS_LEVELS: + raise HTTPException(status_code=400, detail="Invalid access level") + try: + return await GitPermission.objects.create( + repo=repo, + repo_id=repo.id, + user_email=user_email, + access=access, + created_at=datetime.utcnow(), + ) + except IntegrityError as e: + raise HTTPException( + status_code=409, detail=f"Permission already exists: {e}" + ) + + +async def delete_permission(permission: GitPermission) -> None: + await permission.delete() + + +async def check_access( + repo: GitRepo, + user_email: str, + required: str, +) -> bool: + required_level = _ACCESS_LEVELS.get(required) + if required_level is None: + return False + if required == "read" and repo.public: + return True + try: + permission = await GitPermission.objects.filter( + repo_id=repo.id, + user_email=user_email, + ).get() + except NotFoundError: + return False + return _ACCESS_LEVELS.get(permission.access, -1) >= required_level + + +async def list_push_logs( + repo_id: int, + page: int, + perpage: int, +) -> Page[GitPushLog]: + return await paginate( + GitPushLog.objects.filter(repo_id=repo_id).order_by("-created_at"), + page, + perpage, + ) + + +async def get_push_log(id: int) -> GitPushLog: + try: + return await GitPushLog.objects.get(id=id) + except NotFoundError: + raise HTTPException(status_code=404, detail="No such push log") + + +async def create_push_log( + repo: GitRepo, + ref: str, + old_rev: str | None, + new_rev: str | None, + pusher_email: str, + status: str = "pending", + output: str | None = None, +) -> GitPushLog: + now = datetime.utcnow() + return await GitPushLog.objects.create( + repo=repo, + repo_id=repo.id, + ref=ref, + old_rev=old_rev, + new_rev=new_rev, + pusher_email=pusher_email, + status=status, + output=output, + created_at=now, + updated_at=now, + ) + + +async def update_push_status( + push_log: GitPushLog, + status: str, + output: str | None = None, +) -> GitPushLog: + push_log.status = status + push_log.output = output + push_log.updated_at = datetime.utcnow() + await push_log.save(update_fields={"status", "output", "updated_at"}) + return push_log + + +async def create_commit_task_ref( + repo: GitRepo, + task_id: int, + commit_sha: str, + relation: str, +) -> GitCommitTaskRef: + if not await task_in_project(task_id, repo.project_id): + raise HTTPException( + status_code=400, detail="Task does not belong to repository project" + ) + try: + return await GitCommitTaskRef.objects.create( + repo=repo, + repo_id=repo.id, + task_id=task_id, + commit_sha=commit_sha, + relation=relation, + created_at=datetime.utcnow(), + ) + except IntegrityError: + # Already recorded; return the existing one. + return await GitCommitTaskRef.objects.filter( + repo_id=repo.id, + task_id=task_id, + commit_sha=commit_sha, + relation=relation, + ).get() + + +async def list_commit_task_refs( + repo_id: int, + page: int, + perpage: int, +) -> Page[GitCommitTaskRef]: + return await paginate( + GitCommitTaskRef.objects.filter(repo_id=repo_id).order_by("-created_at"), + page, + perpage, + ) + + +async def list_commit_task_refs_by_commit( + repo_id: int, + commit_sha: str, +) -> list[GitCommitTaskRef]: + return await GitCommitTaskRef.objects.filter( + repo_id=repo_id, + commit_sha=commit_sha, + ).all() + + +async def list_commit_task_refs_by_task( + repo_id: int, + task_id: int, + page: int, + perpage: int, +) -> Page[GitCommitTaskRef]: + return await paginate( + GitCommitTaskRef.objects.filter( + repo_id=repo_id, + task_id=task_id, + ).order_by("-created_at"), + page, + perpage, + ) + + +async def get_commit_task_ref_map(repo_id: int) -> dict[str, list[dict]]: + refs = await GitCommitTaskRef.objects.filter(repo_id=repo_id).all() + result: dict[str, list[dict]] = {} + for ref in refs: + result.setdefault(ref.commit_sha, []).append({ + "task_id": ref.task_id, + "relation": ref.relation, + }) + return result diff --git a/freenit/git/tasks.py b/freenit/git/tasks.py new file mode 100644 index 0000000..7bf1d7a --- /dev/null +++ b/freenit/git/tasks.py @@ -0,0 +1,25 @@ +from freenit.models.project import Board, Column, Task +from freenit.models.git import NotFoundError + + +async def task_project_id(task_id: int) -> int | None: + """Return the project ID a kanban task belongs to.""" + try: + task = await Task.objects.get(id=task_id) + except NotFoundError: + return None + if task.column_id is None: + return None + try: + column = await Column.objects.get(id=task.column_id) + except NotFoundError: + return None + try: + board = await Board.objects.get(id=column.board_id) + except NotFoundError: + return None + return board.project_id + + +async def task_in_project(task_id: int, project_id: int) -> bool: + return await task_project_id(task_id) == project_id diff --git a/freenit/git/templates/post-receive b/freenit/git/templates/post-receive new file mode 100644 index 0000000..715e9f9 --- /dev/null +++ b/freenit/git/templates/post-receive @@ -0,0 +1,3 @@ +#!/bin/sh +# Freenit post-receive hook. Records the push and triggers tests if configured. +exec python -m freenit.git.hooks "REPO_NAME" post-receive diff --git a/freenit/git/templates/pre-receive b/freenit/git/templates/pre-receive new file mode 100644 index 0000000..835102b --- /dev/null +++ b/freenit/git/templates/pre-receive @@ -0,0 +1,3 @@ +#!/bin/sh +# Freenit pre-receive hook. Rejects the push if the user lacks write access. +exec python -m freenit.git.hooks "REPO_NAME" pre-receive diff --git a/freenit/git/templates/update b/freenit/git/templates/update new file mode 100644 index 0000000..cd90f84 --- /dev/null +++ b/freenit/git/templates/update @@ -0,0 +1,6 @@ +#!/bin/sh +# Freenit update hook. Rejects the ref update if the user lacks write access. +ref="$1" +oldrev="$2" +newrev="$3" +exec python -m freenit.git.hooks "REPO_NAME" update "$ref" "$oldrev" "$newrev" diff --git a/freenit/models/git.py b/freenit/models/git.py new file mode 100644 index 0000000..bb3f103 --- /dev/null +++ b/freenit/models/git.py @@ -0,0 +1,11 @@ +from freenit.config import getConfig + +config = getConfig() +git = config.get_model("git") + +GitRepo = git.GitRepo +GitPermission = git.GitPermission +GitPushLog = git.GitPushLog +GitCommitTaskRef = git.GitCommitTaskRef +NotFoundError = git.NotFoundError +IntegrityError = git.IntegrityError diff --git a/freenit/models/ldap/base.py b/freenit/models/ldap/base.py index 2fbbdd0..e7b1966 100644 --- a/freenit/models/ldap/base.py +++ b/freenit/models/ldap/base.py @@ -91,6 +91,26 @@ async def next_mlid(increment=True) -> int: raise HTTPException(status_code=403, detail="Failed to login") +async def next_git_id(increment=True) -> int: + client = get_client() + try: + async with client.connect(is_async=True) as conn: + res = await conn.search( + config.ldap.gitIdNextDN, + LDAPSearchScope.BASE, + f"objectClass={config.ldap.gitIdNextClass}", + ) + if len(res) < 1: + raise HTTPException(status_code=404, detail="Can not find next Git ID") + gitIdNext = int(res[0][config.ldap.gitIdNextField][0]) + if increment: + res[0][config.ldap.gitIdNextField] = gitIdNext + 1 + await res[0].modify() + return gitIdNext + except errors.AuthenticationError: + raise HTTPException(status_code=403, detail="Failed to login") + + def class2filter(classes): return "".join([f"(objectClass={group})" for group in classes]) diff --git a/freenit/models/ldap/git.py b/freenit/models/ldap/git.py new file mode 100644 index 0000000..d5fbc1a --- /dev/null +++ b/freenit/models/ldap/git.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + +from freenit.models.ldap.base import LDAPBaseModel + + +class NotFoundError(Exception): + pass + + +class IntegrityError(Exception): + pass + + +def _first(entry, attr, default=None): + value = entry.get(attr) + if not value: + return default + if isinstance(value, (list, tuple)): + value = value[0] + return value + + +def _int(entry, attr, default=None): + value = _first(entry, attr) + if value is None: + return default + return int(value) + + +def _bool(entry, attr, default=False): + value = _first(entry, attr) + if value is None: + return default + return str(value).upper() == "TRUE" + + +def _datetime(entry, attr, default=None): + value = _first(entry, attr) + if value is None: + return default + value = str(value) + if value.endswith("Z"): + value = value[:-1] + "+00:00" + return datetime.fromisoformat(value) + + +class GitRepo(LDAPBaseModel): + id: int = Field(0, description="Numeric repository ID") + project_id: int = Field(0, description="Parent project ID") + name: str = Field("", description="Repository name") + path: str = Field("", description="Filesystem path to the bare repository") + description: str | None = Field(None, description="Optional description") + public: bool = Field(False, description="Publicly visible") + default_branch: str = Field("main", description="Default branch name") + tests_enabled: bool = Field(False, description="Run tests on push") + test_command: str | None = Field(None, description="Shell command used for tests") + created_at: datetime | None = Field(None, description="Creation timestamp") + updated_at: datetime | None = Field(None, description="Last update timestamp") + + @classmethod + def from_entry(cls, entry): + return cls( + dn=str(entry["dn"]), + id=_int(entry, "gitIdNumber", 0), + project_id=_int(entry, "gitProjectId", 0), + name=_first(entry, "cn", ""), + path=_first(entry, "gitPath", ""), + description=_first(entry, "description"), + public=_bool(entry, "gitPublic", False), + default_branch=_first(entry, "gitDefaultBranch", "main"), + tests_enabled=_bool(entry, "gitTestsEnabled", False), + test_command=_first(entry, "gitTestCommand"), + created_at=_datetime(entry, "createdAt"), + updated_at=_datetime(entry, "updatedAt"), + ) + + +class GitPermission(LDAPBaseModel): + id: int = Field(0, description="Numeric permission ID") + repo_id: int = Field(0, description="Parent repository ID") + user_email: str = Field("", description="User email") + access: str = Field("read", description="read/write/admin") + created_at: datetime | None = Field(None, description="Creation timestamp") + + @classmethod + def from_entry(cls, entry, repo_id: int = 0): + return cls( + dn=str(entry["dn"]), + id=_int(entry, "gitIdNumber", 0), + repo_id=repo_id, + user_email=_first(entry, "mail", ""), + access=_first(entry, "gitAccess", "read"), + created_at=_datetime(entry, "createdAt"), + ) + + +class GitPushLog(LDAPBaseModel): + id: int = Field(0, description="Numeric push log ID") + repo_id: int = Field(0, description="Parent repository ID") + ref: str = Field("", description="Git ref") + old_rev: str | None = Field(None, description="Old revision") + new_rev: str | None = Field(None, description="New revision") + pusher_email: str = Field("", description="Pusher email") + status: str = Field("pending", description="pending/completed/success/failure") + output: str | None = Field(None, description="Test output") + created_at: datetime | None = Field(None, description="Creation timestamp") + updated_at: datetime | None = Field(None, description="Last update timestamp") + + @classmethod + def from_entry(cls, entry, repo_id: int = 0): + return cls( + dn=str(entry["dn"]), + id=_int(entry, "gitIdNumber", 0), + repo_id=repo_id, + ref=_first(entry, "gitRef", ""), + old_rev=_first(entry, "gitOldRev"), + new_rev=_first(entry, "gitNewRev"), + pusher_email=_first(entry, "gitPusherEmail", ""), + status=_first(entry, "gitStatus", "pending"), + output=_first(entry, "gitOutput"), + created_at=_datetime(entry, "createdAt"), + updated_at=_datetime(entry, "updatedAt"), + ) + + +class GitCommitTaskRef(LDAPBaseModel): + id: int = Field(0, description="Numeric task reference ID") + repo_id: int = Field(0, description="Parent repository ID") + task_id: int = Field(0, description="Referenced task ID") + commit_sha: str = Field("", description="Git commit SHA") + relation: str = Field("refer", description="fix or refer") + created_at: datetime | None = Field(None, description="Creation timestamp") + + @classmethod + def from_entry(cls, entry, repo_id: int = 0): + return cls( + dn=str(entry["dn"]), + id=_int(entry, "gitIdNumber", 0), + repo_id=repo_id, + task_id=_int(entry, "gitTaskId", 0), + commit_sha=_first(entry, "gitCommitSha", ""), + relation=_first(entry, "gitRelation", "refer"), + created_at=_datetime(entry, "createdAt"), + ) + + +class GitRepoCreate(BaseModel): + name: str + path: str + project_id: int + description: str | None = None + public: bool = False + default_branch: str = "main" + tests_enabled: bool = False + test_command: str | None = None + + +class GitRepoUpdate(BaseModel): + name: str | None = None + path: str | None = None + project_id: int | None = None + description: str | None = None + public: bool | None = None + default_branch: str | None = None + tests_enabled: bool | None = None + test_command: str | None = None diff --git a/freenit/models/sql/git.py b/freenit/models/sql/git.py new file mode 100644 index 0000000..d07d8bf --- /dev/null +++ b/freenit/models/sql/git.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import datetime + +import oxyde + +from freenit.models.project import Project +from freenit.models.sql.base import OxydeBaseModel + +NotFoundError = oxyde.NotFoundError +IntegrityError = oxyde.IntegrityError + + +class GitRepo(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + project: Project | None = oxyde.Field( + default=None, db_fk="id", db_on_delete="CASCADE" + ) + name: str = oxyde.Field(db_unique=True) + path: str = oxyde.Field(db_unique=True) + description: str | None = oxyde.Field(default=None) + public: bool = oxyde.Field(default=False) + default_branch: str = oxyde.Field(default="main") + tests_enabled: bool = oxyde.Field(default=False) + test_command: str | None = oxyde.Field(default=None) + created_at: datetime | None = oxyde.Field(default=None) + updated_at: datetime | None = oxyde.Field(default=None) + + class Meta: + is_table = True + table_name = "git_repo" + + +class GitPermission(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + repo: GitRepo | None = oxyde.Field( + default=None, db_fk="id", db_on_delete="CASCADE" + ) + user_email: str = oxyde.Field() + access: str = oxyde.Field(default="read") + created_at: datetime | None = oxyde.Field(default=None) + + class Meta: + is_table = True + table_name = "git_permission" + unique_together = [("repo_id", "user_email")] + + +class GitPushLog(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + repo: GitRepo | None = oxyde.Field( + default=None, db_fk="id", db_on_delete="CASCADE" + ) + ref: str = oxyde.Field() + old_rev: str | None = oxyde.Field(default=None) + new_rev: str | None = oxyde.Field(default=None) + pusher_email: str = oxyde.Field() + status: str = oxyde.Field(default="pending") + output: str | None = oxyde.Field(default=None) + created_at: datetime | None = oxyde.Field(default=None) + updated_at: datetime | None = oxyde.Field(default=None) + + class Meta: + is_table = True + table_name = "git_push_log" + + +class GitCommitTaskRef(OxydeBaseModel): + id: int | None = oxyde.Field(default=None, db_pk=True) + repo: GitRepo | None = oxyde.Field( + default=None, db_fk="id", db_on_delete="CASCADE" + ) + task_id: int = oxyde.Field() + commit_sha: str = oxyde.Field() + relation: str = oxyde.Field(default="refer") + created_at: datetime | None = oxyde.Field(default=None) + + class Meta: + is_table = True + table_name = "git_commit_task_ref" + unique_together = [("repo_id", "task_id", "commit_sha", "relation")] + + +GitRepo.model_rebuild() +GitPermission.model_rebuild() +GitPushLog.model_rebuild() +GitCommitTaskRef.model_rebuild() diff --git a/freenit/modules.py b/freenit/modules.py index 73d0251..cb0f6cf 100644 --- a/freenit/modules.py +++ b/freenit/modules.py @@ -78,6 +78,12 @@ def __hash__(self): dependencies=["user"], api="freenit.api.omemo", ), + "git": Module( + name="git", + dependencies=["user"], + models=["freenit.models.sql.git"], + api="freenit.api.git", + ), } diff --git a/freenit/permissions.py b/freenit/permissions.py index 294d76a..4e789c3 100644 --- a/freenit/permissions.py +++ b/freenit/permissions.py @@ -1,6 +1,7 @@ from freenit.auth import permissions domain_perms = permissions() +git_perms = permissions() group_perms = permissions() lms_perms = permissions() mailinglist_perms = permissions() diff --git a/freenit/project/project/app.py b/freenit/project/project/app.py index 3bfb915..0de7e2b 100644 --- a/freenit/project/project/app.py +++ b/freenit/project/project/app.py @@ -19,3 +19,8 @@ async def lifespan(_: FastAPI): app = FastAPI(lifespan=lifespan) app.mount(config.api_root, api) + +if "git" in config.modules: + from freenit.api.git_http import router as git_http_router + + app.include_router(git_http_router) diff --git a/freenit/project/project/base_config.py b/freenit/project/project/base_config.py index 9ebe963..f1198cf 100644 --- a/freenit/project/project/base_config.py +++ b/freenit/project/project/base_config.py @@ -4,9 +4,7 @@ class BaseConfig(FreenitBaseConfig): name = "NAME" version = "0.0.1" - # Modules are inherited from FreenitBaseConfig (default: ["auth"]). - # Add feature modules here, e.g.: - # modules = ["auth", "project", "lms"] + modules = ["auth", "git"] stalwart_url = "http://stalwart.example.com" stalwart_admin = "%admin" stalwart_admin_pass = "" # nosec: B105 diff --git a/freenit/project/tests/test_git.py b/freenit/project/tests/test_git.py new file mode 100644 index 0000000..af7fc40 --- /dev/null +++ b/freenit/project/tests/test_git.py @@ -0,0 +1,456 @@ +import subprocess # nosec: B404 +import tempfile +from pathlib import Path + +import io +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from freenit.git import repo as git_repo +from freenit.git import store +from freenit.git.hooks import post_receive +from freenit.models.git import GitRepo +from freenit.models.user import User + +from . import factories + + +def _init_temp_repo(message: str = "init"): + tmp = tempfile.TemporaryDirectory() + path = Path(tmp.name) + subprocess.run(["git", "init", "-b", "main"], cwd=path, check=True) # nosec + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=path, + check=True, + ) # nosec + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=path, + check=True, + ) # nosec + (path / "README.md").write_text("# Hello\n") + (path / "src" / "main.py").parent.mkdir(parents=True, exist_ok=True) + (path / "src" / "main.py").write_text("print('hello')\n") + subprocess.run(["git", "add", "."], cwd=path, check=True) # nosec + subprocess.run(["git", "commit", "-m", message], cwd=path, check=True) # nosec + return tmp + + +def _create_project(client, name: str | None = None): + if name is None: + name = f"git-project-{tempfile.mkdtemp(prefix='').split('/')[-1]}" + response = client.post("/projects", {"name": name}) + assert response.status_code == 200 # nosec + return response.json()["id"] + + +def _tmp_dir() -> str: + return tempfile.mkdtemp(prefix="freenit-git-") + + +class TestGit: + @pytest.mark.asyncio + async def test_create_and_list_repos(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + response = client.post( + "/git/repos", + { + "name": "test-repo", + "path": _tmp_dir(), + "project_id": project_id, + "description": "A test repo", + "public": True, + }, + ) + assert response.status_code == 200 # nosec + data = response.json() + assert data["name"] == "test-repo" # nosec + assert data["public"] is True # nosec + + response = client.get("/git/repos") + assert response.status_code == 200 # nosec + data = response.json() + assert data["total"] == 1 # nosec + assert data["data"][0]["name"] == "test-repo" # nosec + + @pytest.mark.asyncio + async def test_public_repos(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + client.post( + "/git/repos", + { + "name": "public-repo", + "path": _tmp_dir(), + "project_id": project_id, + "public": True, + }, + ) + client.post( + "/git/repos", + { + "name": "private-repo", + "path": _tmp_dir(), + "project_id": project_id, + "public": False, + }, + ) + + # No authentication required for public list. + client.cookies = {} + response = client.get("/git/repos/public") + assert response.status_code == 200 # nosec + data = response.json() + assert data["total"] == 1 # nosec + assert data["data"][0]["name"] == "public-repo" # nosec + + @pytest.mark.asyncio + async def test_update_and_delete_repo(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + response = client.post( + "/git/repos", + {"name": "mutable-repo", "path": _tmp_dir(), "project_id": project_id}, + ) + repo_id = response.json()["id"] + + response = client.patch( + f"/git/repos/{repo_id}", + {"description": "Updated description"}, + ) + assert response.status_code == 200 # nosec + assert response.json()["description"] == "Updated description" # nosec + + response = client.delete(f"/git/repos/{repo_id}") + assert response.status_code == 200 # nosec + + response = client.get(f"/git/repos/{repo_id}") + assert response.status_code == 404 # nosec + + @pytest.mark.asyncio + async def test_permissions(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + response = client.post( + "/git/repos", + {"name": "perm-repo", "path": _tmp_dir(), "project_id": project_id}, + ) + repo_id = response.json()["id"] + + response = client.post( + f"/git/repos/{repo_id}/permissions", + {"user_email": "writer@example.com", "access": "write"}, + ) + assert response.status_code == 200 # nosec + perm_id = response.json()["id"] + + response = client.get(f"/git/repos/{repo_id}/permissions") + assert response.status_code == 200 # nosec + assert response.json()["total"] == 1 # nosec + + response = client.delete(f"/git/repos/{repo_id}/permissions/{perm_id}") + assert response.status_code == 200 # nosec + + response = client.get(f"/git/repos/{repo_id}/permissions") + assert response.json()["total"] == 0 # nosec + + @pytest.mark.asyncio + async def test_hook_access(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + response = client.post( + "/git/repos", + { + "name": "hook-repo", + "path": _tmp_dir(), + "project_id": project_id, + "public": True, + }, + ) + repo = response.json() + repo_id = repo["id"] + + # Direct store access used by the shell hook helper. + repo_model = await GitRepo.objects.get(id=repo_id) + assert await store.check_access(repo_model, "anonymous@example.com", "read") is True # nosec + assert await store.check_access(repo_model, "anonymous@example.com", "write") is False # nosec + + await store.create_permission( + repo_model, + "writer@example.com", + "write", + ) + assert await store.check_access(repo_model, "writer@example.com", "write") is True # nosec + + @pytest.mark.asyncio + async def test_hook_push_endpoint(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + response = client.post( + "/git/repos", + { + "name": "push-repo", + "path": _tmp_dir(), + "project_id": project_id, + "tests_enabled": False, + }, + ) + repo_id = response.json()["id"] + + response = client.post( + f"/git/repos/{repo_id}/hooks/push", + { + "ref": "refs/heads/main", + "old_rev": "0000000000000000000000000000000000000000", + "new_rev": "a" * 40, + "pusher_email": "writer@example.com", + }, + ) + assert response.status_code == 200 # nosec + data = response.json() + assert data["ref"] == "refs/heads/main" # nosec + assert data["status"] == "completed" # nosec + + response = client.get(f"/git/repos/{repo_id}/push-log") + assert response.status_code == 200 # nosec + assert response.json()["total"] == 1 # nosec + + @pytest.mark.asyncio + async def test_hooks_script_endpoint(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + response = client.post( + "/git/repos", + {"name": "script-repo", "path": _tmp_dir(), "project_id": project_id}, + ) + repo_id = response.json()["id"] + + response = client.get(f"/git/repos/{repo_id}/hooks") + assert response.status_code == 200 # nosec + data = response.json() + assert "pre-receive" in data # nosec + assert "update" in data # nosec + assert "post-receive" in data # nosec + + @pytest.mark.asyncio + async def test_view_endpoints(self, client): + tmp = _init_temp_repo() + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + response = client.post( + "/git/repos", + {"name": "view-repo", "path": tmp.name, "project_id": project_id, "public": True}, + ) + repo_id = response.json()["id"] + + response = client.get(f"/git/repos/{repo_id}/refs") + assert response.status_code == 200 # nosec + refs = response.json() + assert any("main" in ref["name"] for ref in refs) # nosec + + response = client.get(f"/git/repos/{repo_id}/tree?ref=main") + assert response.status_code == 200 # nosec + tree = response.json() + assert any(entry["name"] == "README.md" for entry in tree) # nosec + + response = client.get(f"/git/repos/{repo_id}/blob?ref=main&path=README.md") + assert response.status_code == 200 # nosec + assert response.json()["content"] == "# Hello\n" # nosec + + response = client.get(f"/git/repos/{repo_id}/readme?ref=main") + assert response.status_code == 200 # nosec + assert response.json()["content"] == "# Hello\n" # nosec + + response = client.get(f"/git/repos/{repo_id}/commits?ref=main") + assert response.status_code == 200 # nosec + assert len(response.json()) == 1 # nosec + + @pytest.mark.asyncio + async def test_http_clone_public(self, client): + tmp = _init_temp_repo() + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + client.post( + "/git/repos", + {"name": "clone-repo", "path": tmp.name, "project_id": project_id, "public": True}, + ) + + tc = TestClient(client.app) + response = tc.get( + "http://localhost/git/clone-repo/info/refs?service=git-upload-pack" + ) + assert response.status_code == 200 # nosec + assert response.headers["content-type"] == "application/x-git-upload-pack-advertisement" # nosec + assert b"# service=git-upload-pack" in response.content # nosec + + # Stateful RPC request: send an empty request body. + response = tc.post( + "http://localhost/git/clone-repo/git-upload-pack", + content=b"0000", + headers={"content-type": "application/x-git-upload-pack-request"}, + ) + assert response.status_code == 200 # nosec + assert response.headers["content-type"] == "application/x-git-upload-pack-result" # nosec + + @pytest.mark.asyncio + async def test_http_clone_private_requires_auth(self, client): + tmp = _init_temp_repo() + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + client.post( + "/git/repos", + {"name": "private-clone-repo", "path": tmp.name, "project_id": project_id, "public": False}, + ) + + tc = TestClient(client.app) + response = tc.get( + "http://localhost/git/private-clone-repo/info/refs?service=git-upload-pack" + ) + assert response.status_code == 401 # nosec + assert "WWW-Authenticate" in response.headers # nosec + + @pytest.mark.asyncio + async def test_extract_task_refs(self): + message = "fixes: #1, #2\nrefer: #3" + refs = git_repo.extract_task_refs(message) + assert len(refs) == 3 # nosec + assert {"task_id": 1, "relation": "fix"} in refs # nosec + assert {"task_id": 2, "relation": "fix"} in refs # nosec + assert {"task_id": 3, "relation": "refer"} in refs # nosec + + @pytest.mark.asyncio + async def test_post_receive_records_task_refs(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + project_id = _create_project(client) + + # Create a board and column so tasks belong to the repository's project. + response = client.post(f"/projects/{project_id}/boards", {"name": "board"}) + assert response.status_code == 200 # nosec + board_id = response.json()["id"] + response = client.post(f"/boards/{board_id}/columns", {"name": "column"}) + assert response.status_code == 200 # nosec + column_id = response.json()["id"] + task_ids = [] + for title in ["Task 1", "Task 2", "Task 3"]: + response = client.post(f"/columns/{column_id}/tasks", {"title": title}) + assert response.status_code == 200 # nosec + task_ids.append(response.json()["id"]) + + message = f"fixes: #{task_ids[0]}, #{task_ids[1]}\nrefer: #{task_ids[2]}" + tmp = _init_temp_repo(message=message) + + response = client.post( + "/git/repos", + {"name": "task-repo", "path": tmp.name, "project_id": project_id, "public": True}, + ) + repo_id = response.json()["id"] + + proc = subprocess.run( + ["git", "-C", tmp.name, "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) # nosec + head = proc.stdout.strip() + + stdin_data = f"0000000000000000000000000000000000000000 {head} refs/heads/main\n" + with patch("sys.stdin", io.StringIO(stdin_data)): + result = await post_receive("task-repo") + assert result == 0 # nosec + + response = client.get(f"/git/repos/{repo_id}/task-refs") + assert response.status_code == 200 # nosec + data = response.json() + assert data["total"] == 3 # nosec + relations = sorted(r["relation"] for r in data["data"]) + assert relations == ["fix", "fix", "refer"] # nosec + + response = client.get(f"/git/repos/{repo_id}/commits?ref=main") + assert response.status_code == 200 # nosec + commit = response.json()[0] + assert len(commit["task_refs"]) == 3 # nosec + + @pytest.mark.asyncio + async def test_post_receive_ignores_task_refs_outside_project(self, client): + admin: User = factories.User(admin=True) + await admin.save() + client.login(user=admin) + repo_project_id = _create_project(client) + other_project_id = _create_project(client) + + response = client.post(f"/projects/{other_project_id}/boards", {"name": "other-board"}) + assert response.status_code == 200 # nosec + board_id = response.json()["id"] + response = client.post(f"/boards/{board_id}/columns", {"name": "other-column"}) + assert response.status_code == 200 # nosec + column_id = response.json()["id"] + response = client.post(f"/columns/{column_id}/tasks", {"title": "Other task"}) + assert response.status_code == 200 # nosec + other_task_id = response.json()["id"] + + message = f"fixes: #{other_task_id}" + tmp = _init_temp_repo(message=message) + + response = client.post( + "/git/repos", + { + "name": "outside-task-repo", + "path": tmp.name, + "project_id": repo_project_id, + "public": True, + }, + ) + repo_id = response.json()["id"] + + proc = subprocess.run( + ["git", "-C", tmp.name, "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ) # nosec + head = proc.stdout.strip() + + stdin_data = f"0000000000000000000000000000000000000000 {head} refs/heads/main\n" + with patch("sys.stdin", io.StringIO(stdin_data)): + result = await post_receive("outside-task-repo") + assert result == 0 # nosec + + response = client.get(f"/git/repos/{repo_id}/task-refs") + assert response.status_code == 200 # nosec + assert response.json()["total"] == 0 # nosec diff --git a/migrations/0007_add_git.py b/migrations/0007_add_git.py new file mode 100644 index 0000000..9de5907 --- /dev/null +++ b/migrations/0007_add_git.py @@ -0,0 +1,525 @@ +"""Auto-generated migration. + +Created: 2026-06-21 19:00:00 +""" + +depends_on = "0006_add_lms" + + +def upgrade(ctx): + """Apply migration.""" + ctx.create_table( + "git_repo", + fields=[ + { + "name": "id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": True, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "project_id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "name", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "path", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "description", + "python_type": "str", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "public", + "python_type": "bool", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": "0", + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "default_branch", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": "'main'", + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "tests_enabled", + "python_type": "bool", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": "0", + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "test_command", + "python_type": "str", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "created_at", + "python_type": "datetime", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "updated_at", + "python_type": "datetime", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + ], + foreign_keys=[ + { + "name": "fk_git_repo_project_id", + "columns": ["project_id"], + "ref_table": "project", + "ref_columns": ["id"], + "on_delete": "CASCADE", + "on_update": "CASCADE", + } + ], + indexes=[ + { + "name": "idx_git_repo_name", + "fields": ["name"], + "unique": True, + }, + { + "name": "idx_git_repo_path", + "fields": ["path"], + "unique": True, + }, + { + "name": "idx_git_repo_project_id", + "fields": ["project_id"], + "unique": False, + }, + ], + ) + ctx.create_table( + "git_permission", + fields=[ + { + "name": "id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": True, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "user_email", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "access", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": "'read'", + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "created_at", + "python_type": "datetime", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "repo_id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + ], + foreign_keys=[ + { + "name": "fk_git_permission_repo_id", + "columns": ["repo_id"], + "ref_table": "git_repo", + "ref_columns": ["id"], + "on_delete": "CASCADE", + "on_update": "CASCADE", + } + ], + indexes=[ + { + "name": "idx_git_permission_repo_id_user_email", + "fields": ["repo_id", "user_email"], + "unique": True, + } + ], + ) + ctx.create_table( + "git_push_log", + fields=[ + { + "name": "id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": True, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "ref", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "old_rev", + "python_type": "str", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "new_rev", + "python_type": "str", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "pusher_email", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "status", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": "'pending'", + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "output", + "python_type": "str", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "created_at", + "python_type": "datetime", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "updated_at", + "python_type": "datetime", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "repo_id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + ], + foreign_keys=[ + { + "name": "fk_git_push_log_repo_id", + "columns": ["repo_id"], + "ref_table": "git_repo", + "ref_columns": ["id"], + "on_delete": "CASCADE", + "on_update": "CASCADE", + } + ], + ) + ctx.create_table( + "git_commit_task_ref", + fields=[ + { + "name": "id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": True, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "task_id", + "python_type": "int", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "commit_sha", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "relation", + "python_type": "str", + "db_type": None, + "nullable": False, + "primary_key": False, + "unique": False, + "default": "'refer'", + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "created_at", + "python_type": "datetime", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + { + "name": "repo_id", + "python_type": "int", + "db_type": None, + "nullable": True, + "primary_key": False, + "unique": False, + "default": None, + "auto_increment": False, + "max_length": None, + "max_digits": None, + "decimal_places": None, + }, + ], + foreign_keys=[ + { + "name": "fk_git_commit_task_ref_repo_id", + "columns": ["repo_id"], + "ref_table": "git_repo", + "ref_columns": ["id"], + "on_delete": "CASCADE", + "on_update": "CASCADE", + } + ], + indexes=[ + { + "name": "idx_git_commit_task_ref_unique", + "fields": ["repo_id", "task_id", "commit_sha", "relation"], + "unique": True, + } + ], + ) + + +def downgrade(ctx): + """Revert migration.""" + ctx.drop_table("git_commit_task_ref") + ctx.drop_table("git_push_log") + ctx.drop_table("git_permission") + ctx.drop_table("git_repo") diff --git a/pyproject.toml b/pyproject.toml index d3b62cc..f10f49a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ license = {file = "LICENSE"} requires-python = ">=3.8" dependencies = [ "cryptography", + "dulwich", "fastapi", "httpx", "passlib",