diff --git a/.env.dev.example b/.env.dev.example index aabfa9ec..f94d0954 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -12,13 +12,17 @@ DEPLOY_DOMAIN=localhost SERVER_IP=127.0.0.1 LE_EMAIL=dev@example.com +# Git provider (at least one of GitHub or Gitea must be configured) # GitHub App (see https://devpu.sh/gh-app) -GITHUB_APP_ID= -GITHUB_APP_NAME= -GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines -GITHUB_APP_WEBHOOK_SECRET= -GITHUB_APP_CLIENT_ID= -GITHUB_APP_CLIENT_SECRET= +# GITHUB_APP_ID= +# GITHUB_APP_NAME= +# GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines +# GITHUB_APP_WEBHOOK_SECRET= +# GITHUB_APP_CLIENT_ID= +# GITHUB_APP_CLIENT_SECRET= + +# Gitea +# GITEA_WEBHOOK_SECRET= # Email (only needed for email login/invites) EMAIL_SENDER_ADDRESS= diff --git a/.env.example b/.env.example index fd0bfb07..3af70377 100644 --- a/.env.example +++ b/.env.example @@ -18,13 +18,17 @@ LE_EMAIL=admin@example.com CERT_CHALLENGE_PROVIDER=default # default|cloudflare|route53|gcloud|digitalocean|azure # CF_DNS_API_TOKEN= +# Git provider (at least one of GitHub or Gitea must be configured) # GitHub App (see https://devpu.sh/gh-app) -GITHUB_APP_ID= -GITHUB_APP_NAME= -GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines -GITHUB_APP_WEBHOOK_SECRET= -GITHUB_APP_CLIENT_ID= -GITHUB_APP_CLIENT_SECRET= +# GITHUB_APP_ID= +# GITHUB_APP_NAME= +# GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines +# GITHUB_APP_WEBHOOK_SECRET= +# GITHUB_APP_CLIENT_ID= +# GITHUB_APP_CLIENT_SECRET= + +# Gitea +# GITEA_WEBHOOK_SECRET= # Email EMAIL_SENDER_ADDRESS= diff --git a/README.md b/README.md index 330054dd..2e9e5121 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ An open-source and self-hostable alternative to Vercel, Render, Netlify and the ## Key features -- **Git-based deployments**: Push to deploy from GitHub with zero-downtime rollouts and instant rollback. +- **Git-based deployments**: Push to deploy from GitHub or Gitea with zero-downtime rollouts and instant rollback. - **Multi-language support**: Python, Node.js, PHP... basically anything that can run on Docker. - **Environment management**: Multiple environments with branch mapping and encrypted environment variables. - **Real-time monitoring**: Live and searchable build and runtime logs. @@ -26,7 +26,7 @@ See [devpu.sh/docs](https://devpu.sh/docs) for installation, configuration, and - **Server**: Ubuntu 20.04+ or Debian 11+ with SSH access and sudo privileges. A [Hetzner CPX31](https://devpu.sh/docs/guides/create-hetzner-server) works well. - **DNS**: We recommend [Cloudflare](https://cloudflare.com). -- **GitHub account**: You'll create a GitHub App for login and repository access. +- **GitHub account**: You'll create a GitHub App for login and repository access. Gitea instances can also be connected via Personal Access Tokens. - **Email provider**: A [Resend](https://resend.com) account or SMTP credentials for login emails and invitations. ## Quickstart @@ -125,6 +125,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for codebase structure. | `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook secret. | | `GITHUB_APP_CLIENT_ID` | GitHub OAuth client ID. | | `GITHUB_APP_CLIENT_SECRET` | GitHub OAuth client secret. | +| `GITEA_WEBHOOK_SECRET` | Shared secret for verifying Gitea webhook payloads (optional, required if using Gitea). | | `APP_HOSTNAME` | Domain for the app (e.g., `example.com`). | | `DEPLOY_DOMAIN` | Domain for deployments (wildcard root). No default—set explicitly (e.g., `deploy.example.com`). | | `LE_EMAIL` | Email for Let's Encrypt notifications. | diff --git a/app/config.py b/app/config.py index c6d3440a..e9a46d63 100644 --- a/app/config.py +++ b/app/config.py @@ -11,7 +11,7 @@ class Settings(BaseSettings): app_name: str = "/dev/push" app_description: str = ( - "An open-source platform to build and deploy any app from GitHub." + "An open-source platform to build and deploy any app from a Git repository." ) url_scheme: str = "https" app_hostname: str = "" @@ -22,6 +22,7 @@ class Settings(BaseSettings): github_app_webhook_secret: str = "" github_app_client_id: str = "" github_app_client_secret: str = "" + gitea_webhook_secret: str = "" google_client_id: str = "" google_client_secret: str = "" resend_api_key: str = "" @@ -78,6 +79,21 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(extra="ignore") + @property + def has_github(self) -> bool: + return bool( + self.github_app_id + and self.github_app_name + and self.github_app_private_key + and self.github_app_webhook_secret + and self.github_app_client_id + and self.github_app_client_secret + ) + + @property + def has_gitea(self) -> bool: + return bool(self.gitea_webhook_secret) + @property def allow_custom_cpu(self) -> bool: return self.default_cpus is not None and self.max_cpus is not None diff --git a/app/dependencies.py b/app/dependencies.py index 72ac5c30..90650dfd 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -41,8 +41,11 @@ def get_github_installation_service() -> GitHubInstallationService: @lru_cache -def get_github_oauth_client() -> OAuth: +def get_github_oauth_client() -> OAuth | None: settings = get_settings() + if not settings.github_app_client_id or not settings.github_app_client_secret: + return None + oauth = OAuth() oauth.register( "github", @@ -534,6 +537,8 @@ def time_ago_filter(value): templates.env.globals["app_description"] = settings.app_description templates.env.globals["get_flashed_messages"] = get_flashed_messages templates.env.globals["toaster_header"] = settings.toaster_header +templates.env.globals["has_github"] = settings.has_github +templates.env.globals["has_gitea"] = settings.has_gitea templates.env.filters["time_ago"] = time_ago_filter templates.env.globals["get_access"] = get_access templates.env.globals["is_superadmin"] = is_superadmin diff --git a/app/forms/project.py b/app/forms/project.py index 0f2c96bd..c7d762d8 100644 --- a/app/forms/project.py +++ b/app/forms/project.py @@ -463,6 +463,10 @@ class ProjectGeneralForm(StarletteForm): avatar = FileField(_l("Avatar")) delete_avatar = BooleanField(_l("Delete avatar"), default=False) repo_id = IntegerField(_l("Repo ID"), validators=[DataRequired()]) + repo_full_name = HiddenField() + repo_provider = HiddenField() + repo_base_url = HiddenField() + connection_id = HiddenField() def validate_avatar(self, field): if field.data: diff --git a/app/forms/user.py b/app/forms/user.py index 1627260e..1a65f632 100644 --- a/app/forms/user.py +++ b/app/forms/user.py @@ -101,3 +101,20 @@ class UserOAuthAccessRevokeForm(StarletteForm): choices=["github", "google"], ) submit = SubmitField(_l("Disconnect")) + + +class GiteaConnectionCreateForm(StarletteForm): + base_url = StringField( + _l("Instance URL"), + validators=[DataRequired(), Length(max=512)], + ) + token = StringField( + _l("Personal access token"), + validators=[DataRequired(), Length(max=512)], + ) + submit = SubmitField(_l("Connect")) + + +class GiteaConnectionDeleteForm(StarletteForm): + connection_id = HiddenField(validators=[DataRequired()]) + submit = SubmitField(_l("Remove")) diff --git a/app/main.py b/app/main.py index 6e221300..b5980012 100644 --- a/app/main.py +++ b/app/main.py @@ -17,7 +17,7 @@ from db import get_db, AsyncSessionLocal from dependencies import get_current_user, TemplateResponse from models import User, Team, Deployment, Project -from routers import auth, project, github, google, team, user, event, admin +from routers import auth, project, github, gitea, google, team, user, event, admin from services.loki import LokiService settings = get_settings() @@ -179,6 +179,7 @@ async def root( app.include_router(user.router) app.include_router(project.router) app.include_router(github.router) +app.include_router(gitea.router) app.include_router(google.router) app.include_router(team.router) app.include_router(event.router) diff --git a/app/migrations/versions/a1b2c3d4e5f6_gitea_provider.py b/app/migrations/versions/a1b2c3d4e5f6_gitea_provider.py new file mode 100644 index 00000000..640bc89e --- /dev/null +++ b/app/migrations/versions/a1b2c3d4e5f6_gitea_provider.py @@ -0,0 +1,73 @@ +"""Add Gitea provider support + +Revision ID: a1b2c3d4e5f6 +Revises: 4fe4c96ad3dd +Create Date: 2026-02-22 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "4fe4c96ad3dd" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +repo_provider_enum = sa.Enum("github", "gitea", name="repo_provider") + + +def upgrade() -> None: + repo_provider_enum.create(op.get_bind(), checkfirst=True) + + op.create_table( + "gitea_connection", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, index=True), + sa.Column("base_url", sa.String(512), nullable=False), + sa.Column("username", sa.String(255), nullable=False), + sa.Column("token", sa.String(2048), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "base_url", name="uq_gitea_connection_user_url"), + ) + + # Project: add repo_provider, repo_base_url, gitea_connection_id; make github_installation_id nullable + op.add_column("project", sa.Column("repo_provider", repo_provider_enum, nullable=True)) + op.add_column("project", sa.Column("repo_base_url", sa.String(512), nullable=True)) + op.add_column( + "project", + sa.Column("gitea_connection_id", sa.Integer(), sa.ForeignKey("gitea_connection.id"), nullable=True, index=True), + ) + + op.execute("UPDATE project SET repo_provider = 'github', repo_base_url = 'https://github.com'") + + op.alter_column("project", "repo_provider", nullable=False) + op.alter_column("project", "repo_base_url", nullable=False) + op.alter_column("project", "github_installation_id", nullable=True) + + # Deployment: add repo_provider, repo_base_url + op.add_column("deployment", sa.Column("repo_provider", repo_provider_enum, nullable=True)) + op.add_column("deployment", sa.Column("repo_base_url", sa.String(512), nullable=True)) + + op.execute("UPDATE deployment SET repo_provider = 'github', repo_base_url = 'https://github.com'") + + op.alter_column("deployment", "repo_provider", nullable=False) + op.alter_column("deployment", "repo_base_url", nullable=False) + + +def downgrade() -> None: + op.drop_column("deployment", "repo_base_url") + op.drop_column("deployment", "repo_provider") + + op.alter_column("project", "github_installation_id", nullable=False) + op.drop_column("project", "gitea_connection_id") + op.drop_column("project", "repo_base_url") + op.drop_column("project", "repo_provider") + + op.drop_table("gitea_connection") + + repo_provider_enum.drop(op.get_bind(), checkfirst=True) diff --git a/app/models.py b/app/models.py index 3c93bbf2..2d1b2a47 100644 --- a/app/models.py +++ b/app/models.py @@ -272,6 +272,42 @@ class TeamInvite(Base): inviter: Mapped[User] = relationship() +class GiteaConnection(Base): + __tablename__: str = "gitea_connection" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True) + base_url: Mapped[str] = mapped_column(String(512), nullable=False) + username: Mapped[str] = mapped_column(String(255), nullable=False) + _token: Mapped[str] = mapped_column("token", String(2048), nullable=False) + created_at: Mapped[datetime] = mapped_column(default=utc_now) + updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now) + + # Relationships + user: Mapped[User] = relationship() + projects: Mapped[list["Project"]] = relationship( + back_populates="gitea_connection" + ) + + __table_args__ = ( + UniqueConstraint("user_id", "base_url", name="uq_gitea_connection_user_url"), + ) + + @property + def token(self) -> str: + fernet = get_fernet() + return fernet.decrypt(self._token.encode()).decode() + + @token.setter + def token(self, value: str): + fernet = get_fernet() + self._token = fernet.encrypt(value.encode()).decode() + + @override + def __repr__(self): + return f"" + + class GithubInstallation(Base): __tablename__: str = "github_installation" @@ -317,8 +353,16 @@ class Project(Base): ) name: Mapped[str] = mapped_column(String(100), index=True) has_avatar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + repo_provider: Mapped[str] = mapped_column( + SQLAEnum("github", "gitea", name="repo_provider"), + nullable=False, + default="github", + ) repo_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + repo_base_url: Mapped[str] = mapped_column( + String(512), nullable=False, default="https://github.com" + ) repo_status: Mapped[str] = mapped_column( SQLAEnum( "active", "deleted", "removed", "transferred", name="project_github_status" @@ -326,8 +370,11 @@ class Project(Base): nullable=False, default="active", ) - github_installation_id: Mapped[int] = mapped_column( - ForeignKey("github_installation.installation_id"), nullable=False, index=True + github_installation_id: Mapped[int | None] = mapped_column( + ForeignKey("github_installation.installation_id"), nullable=True, index=True + ) + gitea_connection_id: Mapped[int | None] = mapped_column( + ForeignKey("gitea_connection.id"), nullable=True, index=True ) environments: Mapped[list[dict[str, str]]] = mapped_column( JSON, nullable=False, default=list @@ -354,7 +401,10 @@ class Project(Base): team_id: Mapped[str] = mapped_column(ForeignKey("team.id"), index=True) # Relationships - github_installation: Mapped[GithubInstallation] = relationship( + github_installation: Mapped[GithubInstallation | None] = relationship( + back_populates="projects" + ) + gitea_connection: Mapped[GiteaConnection | None] = relationship( back_populates="projects" ) deployments: Mapped[list["Deployment"]] = relationship(back_populates="project") @@ -736,8 +786,16 @@ class Deployment(Base): String(32), primary_key=True, default=lambda: token_hex(16) ) project_id: Mapped[str] = mapped_column(ForeignKey("project.id"), index=True) + repo_provider: Mapped[str] = mapped_column( + SQLAEnum("github", "gitea", name="repo_provider", create_type=False), + nullable=False, + default="github", + ) repo_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + repo_base_url: Mapped[str] = mapped_column( + String(512), nullable=False, default="https://github.com" + ) environment_id: Mapped[str] = mapped_column(String(8), nullable=False) branch: Mapped[str] = mapped_column(String(255), index=True) commit_sha: Mapped[str] = mapped_column(String(40), index=True) @@ -798,9 +856,10 @@ class Deployment(Base): def __init__(self, *args, project: "Project", environment_id: str, **kwargs): super().__init__(project=project, environment_id=environment_id, **kwargs) - # Snapshot repo, config, environments and env_vars from project at time of creation + self.repo_provider = project.repo_provider self.repo_id = project.repo_id self.repo_full_name = project.repo_full_name + self.repo_base_url = project.repo_base_url self.config = project.config environment = project.get_environment_by_id(environment_id) self.env_vars = project.get_env_vars(environment["slug"]) if environment else [] diff --git a/app/routers/auth.py b/app/routers/auth.py index 23f7bca4..bb5fff78 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -261,6 +261,7 @@ async def auth_login( else "auth/pages/login.html", context={ "form": form, + "has_github_login": settings.has_github, "has_google_login": bool( settings.google_client_id and settings.google_client_secret ), diff --git a/app/routers/gitea.py b/app/routers/gitea.py new file mode 100644 index 00000000..d7547988 --- /dev/null +++ b/app/routers/gitea.py @@ -0,0 +1,233 @@ +import logging +import hmac +import hashlib +from fastapi import APIRouter, Request, Depends, HTTPException, Query +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from redis.asyncio import Redis +from arq.connections import ArqRedis + +from dependencies import ( + get_current_user, + TemplateResponse, + flash, + get_db, + get_translation as _, + get_redis_client, + get_queue, +) +from models import User, GiteaConnection, Project +from services.gitea import GiteaService +from services.deployment import DeploymentService +from config import get_settings, Settings + +router = APIRouter(prefix="/api/gitea") + +logger = logging.getLogger(__name__) + + +@router.get("/repo-select", name="gitea_repo_select") +async def gitea_repo_select( + request: Request, + connection_id: int | None = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(GiteaConnection) + .where(GiteaConnection.user_id == current_user.id) + .order_by(GiteaConnection.created_at.desc()) + ) + connections = result.scalars().all() + + selected_connection = None + if connections: + if connection_id: + selected_connection = next( + (c for c in connections if c.id == connection_id), connections[0] + ) + else: + selected_connection = connections[0] + + repos: list[dict] = [] + base_url = "" + if selected_connection: + try: + svc = GiteaService(selected_connection.base_url, selected_connection.token) + repos = await svc.list_repos() + base_url = selected_connection.base_url + except Exception: + logger.exception("Error fetching repositories from Gitea") + flash(request, _("Error fetching repositories from Gitea."), "error") + + return TemplateResponse( + request=request, + name="gitea/partials/_repo-select.html", + context={ + "current_user": current_user, + "connections": connections, + "selected_connection": selected_connection, + "connection_id": selected_connection.id if selected_connection else None, + "repos": repos, + "base_url": base_url, + }, + ) + + +@router.get("/repo-list", name="gitea_repo_list") +async def gitea_repo_list( + request: Request, + connection_id: str | None = None, + query: str | None = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + repos: list[dict] = [] + base_url = "" + + try: + conn_id = int(connection_id) if connection_id else None + except (ValueError, TypeError): + conn_id = None + + if not conn_id: + return TemplateResponse( + request=request, + name="gitea/partials/_repo-select-list.html", + context={"repos": repos, "connection_id": conn_id, "base_url": base_url}, + ) + + try: + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == conn_id, + GiteaConnection.user_id == current_user.id, + ) + ) + if not conn: + return TemplateResponse( + request=request, + name="gitea/partials/_repo-select-list.html", + context={"repos": repos, "connection_id": conn_id, "base_url": base_url}, + ) + + svc = GiteaService(conn.base_url, conn.token) + repos = await svc.list_repos(query=query) + base_url = conn.base_url + + except Exception: + logger.exception("Error fetching repositories from Gitea") + flash(request, _("Error fetching repositories from Gitea."), "error") + + return TemplateResponse( + request=request, + name="gitea/partials/_repo-select-list.html", + context={"repos": repos, "connection_id": conn_id, "base_url": base_url}, + ) + + +async def _verify_gitea_webhook( + request: Request, settings: Settings = Depends(get_settings) +) -> tuple[dict, str]: + signature = request.headers.get("X-Gitea-Signature") + event = request.headers.get("X-Gitea-Event") + + if not signature: + raise HTTPException(status_code=401, detail="Missing signature") + if not event: + raise HTTPException(status_code=400, detail="Missing event type") + if not settings.gitea_webhook_secret: + raise HTTPException(status_code=500, detail="Gitea webhook secret not configured") + + payload = await request.body() + expected = hmac.new( + settings.gitea_webhook_secret.encode(), msg=payload, digestmod=hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(signature, expected): + raise HTTPException(status_code=401, detail="Invalid signature") + + data = await request.json() + return data, event + + +@router.post("/webhook", name="gitea_webhook") +async def gitea_webhook( + request: Request, + webhook_data: tuple[dict, str] = Depends(_verify_gitea_webhook), + db: AsyncSession = Depends(get_db), + redis_client: Redis = Depends(get_redis_client), + queue: ArqRedis = Depends(get_queue), +): + try: + data, event = webhook_data + + logger.info(f"Received Gitea webhook event: {event}") + + if event == "push": + repo = data.get("repository", {}) + repo_id = repo.get("id") + html_url = repo.get("html_url", "") + base_url = html_url.rsplit("/", 2)[0] if "/" in html_url else html_url + + result = await db.execute( + select(Project).where( + Project.repo_id == repo_id, + Project.repo_provider == "gitea", + Project.repo_base_url == base_url, + Project.status == "active", + ) + ) + projects = result.scalars().all() + + if not projects: + logger.info(f"No Gitea projects found for repo {repo_id}") + return Response(status_code=200) + + ref = data.get("ref", "") + branch = ref.replace("refs/heads/", "") + head_commit = data.get("commits", [{}])[-1] if data.get("commits") else {} + pusher = data.get("pusher", {}) + + commit_data = { + "sha": data.get("after", ""), + "author": {"login": pusher.get("login", pusher.get("username", ""))}, + "commit": { + "message": head_commit.get("message", ""), + "author": {"date": head_commit.get("timestamp", "")}, + }, + } + + deployment_service = DeploymentService() + + for project in projects: + try: + deployment = await deployment_service.create( + project=project, + branch=branch, + commit=commit_data, + db=db, + redis_client=redis_client, + trigger="webhook", + ) + job = await queue.enqueue_job("start_deployment", deployment.id) + deployment.job_id = job.job_id + await db.commit() + + logger.info( + f"Deployment {deployment.id} created for Gitea commit {commit_data['sha']} on project {project.name}" + ) + except Exception as e: + logger.error( + f"Failed to create deployment for project {project.name}: {e}", + exc_info=True, + ) + continue + + return Response(status_code=200) + + except Exception as e: + logger.error(f"Error processing Gitea webhook: {e}", exc_info=True) + await db.rollback() + return Response(status_code=500) diff --git a/app/routers/github.py b/app/routers/github.py index a402389c..bc6226fd 100644 --- a/app/routers/github.py +++ b/app/routers/github.py @@ -493,12 +493,11 @@ async def github_webhook( case "installation_repositories": if data["action"] == "removed": - # Repositories removed from installation removed_repos = data["repositories_removed"] repo_ids = [repo["id"] for repo in removed_repos] await db.execute( update(Project) - .where(Project.repo_id.in_(repo_ids)) + .where(Project.repo_id.in_(repo_ids), Project.repo_provider == "github") .values(repo_status="removed") ) await db.commit() @@ -507,12 +506,11 @@ async def github_webhook( ) elif data["action"] == "added": - # Repositories are added to installation added_repos = data["repositories_added"] repo_ids = [repo["id"] for repo in added_repos] await db.execute( update(Project) - .where(Project.repo_id.in_(repo_ids)) + .where(Project.repo_id.in_(repo_ids), Project.repo_provider == "github") .values(repo_status="active") ) await db.commit() @@ -522,20 +520,18 @@ async def github_webhook( case "repository": if data["action"] in ["deleted", "transferred"]: - # Repository is deleted or transferred await db.execute( update(Project) - .where(Project.repo_id == data["repository"]["id"]) + .where(Project.repo_id == data["repository"]["id"], Project.repo_provider == "github") .values(repo_status=data["action"]) ) await db.commit() logger.info(f"Repo {data['repository']['id']} is {data['action']}") if data["action"] == "renamed": - # Repository is renamed await db.execute( update(Project) - .where(Project.repo_id == data["repository"]["id"]) + .where(Project.repo_id == data["repository"]["id"], Project.repo_provider == "github") .values(repo_full_name=data["repository"]["full_name"]) ) await db.commit() @@ -544,10 +540,10 @@ async def github_webhook( ) case "push": - # Code pushed to a repository result = await db.execute( select(Project).where( Project.repo_id == data["repository"]["id"], + Project.repo_provider == "github", Project.status == "active", ) ) diff --git a/app/routers/project.py b/app/routers/project.py index ce8567bd..ac67a6f0 100644 --- a/app/routers/project.py +++ b/app/routers/project.py @@ -36,6 +36,7 @@ User, Team, TeamMember, + GiteaConnection, Storage, StorageProject, utc_now, @@ -65,6 +66,7 @@ from db import get_db from services.github import GitHubService from services.github_installation import GitHubInstallationService +from services.gitea import GiteaService from services.deployment import DeploymentService from services.domain import DomainService from services.preset_detector import PresetDetector @@ -196,6 +198,8 @@ async def new_project_details( repo_owner: str = Query(None), repo_name: str = Query(None), repo_default_branch: str = Query(None), + provider: str = Query("github"), + connection_id: int | None = Query(None), fragment: str = Query(None), current_user: User = Depends(get_current_user), team_and_membership: tuple[Team, TeamMember] = Depends(get_team_by_slug), @@ -228,55 +232,72 @@ async def new_project_details( form.name.data = repo_name form.production_branch.data = repo_default_branch - # Handle build_and_deploy fragment with framework detection if fragment == "build_and_deploy": try: - github_oauth_token = await get_user_github_token(db, current_user) - if github_oauth_token: - detector = PresetDetector(enabled_presets) - - # Run detection with 5 second timeout - detection = await asyncio.wait_for( - detector.detect_with_commands( - github_service, - github_oauth_token, - int(repo_id), - repo_default_branch, - ), - timeout=5.0, + if provider == "gitea" and connection_id: + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == connection_id, + GiteaConnection.user_id == current_user.id, + ) ) - - # If preset detected, get preset config and set all form fields - if detection["preset"]: - preset_entry = next( - ( - p - for p in enabled_presets - if p["slug"] == detection["preset"] + if conn: + gitea_svc = GiteaService(conn.base_url, conn.token) + detector = PresetDetector(enabled_presets) + detection = await asyncio.wait_for( + detector.detect_with_commands( + gitea_svc, + None, + int(repo_id), + repo_default_branch, + repo_owner=repo_owner, + repo_name=repo_name, ), - None, + timeout=5.0, ) - - if preset_entry: - preset_config = preset_entry.get("config", {}) - form.preset.data = detection["preset"] - form.runner.data = detection.get( - "runner" - ) or preset_config.get("runner") - root_directory = detection.get( - "root_directory" - ) or preset_config.get("root_directory") - if root_directory: - form.root_directory.data = root_directory - form.build_command.data = detection.get( - "build_command" - ) or preset_config.get("build_command") - form.start_command.data = detection.get( - "start_command" - ) or preset_config.get("start_command") - form.pre_deploy_command.data = detection.get( - "pre_deploy_command" - ) or preset_config.get("pre_deploy_command") + if detection["preset"]: + preset_entry = next( + (p for p in enabled_presets if p["slug"] == detection["preset"]), + None, + ) + if preset_entry: + preset_config = preset_entry.get("config", {}) + form.preset.data = detection["preset"] + form.runner.data = detection.get("runner") or preset_config.get("runner") + root_directory = detection.get("root_directory") or preset_config.get("root_directory") + if root_directory: + form.root_directory.data = root_directory + form.build_command.data = detection.get("build_command") or preset_config.get("build_command") + form.start_command.data = detection.get("start_command") or preset_config.get("start_command") + form.pre_deploy_command.data = detection.get("pre_deploy_command") or preset_config.get("pre_deploy_command") + else: + github_oauth_token = await get_user_github_token(db, current_user) + if github_oauth_token: + detector = PresetDetector(enabled_presets) + detection = await asyncio.wait_for( + detector.detect_with_commands( + github_service, + github_oauth_token, + int(repo_id), + repo_default_branch, + ), + timeout=5.0, + ) + if detection["preset"]: + preset_entry = next( + (p for p in enabled_presets if p["slug"] == detection["preset"]), + None, + ) + if preset_entry: + preset_config = preset_entry.get("config", {}) + form.preset.data = detection["preset"] + form.runner.data = detection.get("runner") or preset_config.get("runner") + root_directory = detection.get("root_directory") or preset_config.get("root_directory") + if root_directory: + form.root_directory.data = root_directory + form.build_command.data = detection.get("build_command") or preset_config.get("build_command") + form.start_command.data = detection.get("start_command") or preset_config.get("start_command") + form.pre_deploy_command.data = detection.get("pre_deploy_command") or preset_config.get("pre_deploy_command") except asyncio.TimeoutError: logger.warning(f"Framework detection timed out for repo {repo_id}") @@ -300,84 +321,135 @@ async def new_project_details( ) if request.method == "POST" and await form.validate_on_submit(): - try: - github_oauth_token = await get_user_github_token(db, current_user) - if not github_oauth_token: - raise ValueError("GitHub OAuth token missing.") - - if not form.repo_id.data: - raise ValueError("Repository ID missing.") + env_vars = [ + { + "key": entry.key.data, + "value": entry.value.data, + "environment": entry.environment.data, + } + for entry in form.env_vars + ] - repo = await github_service.get_repository( - github_oauth_token, int(form.repo_id.data) - ) - except Exception: - flash(request, "You do not have access to this repository.", "error") - return RedirectResponse( - request.url_for("new_project", team_slug=team.slug), status_code=303 + if provider == "gitea" and connection_id: + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == connection_id, + GiteaConnection.user_id == current_user.id, + ) ) + if not conn: + flash(request, _("Gitea connection not found."), "error") + return RedirectResponse( + request.url_for("new_project", team_slug=team.slug), status_code=303 + ) + try: + gitea_svc = GiteaService(conn.base_url, conn.token) + repo = await gitea_svc.get_repo(repo_owner, repo_name) + except Exception: + flash(request, _("You do not have access to this repository."), "error") + return RedirectResponse( + request.url_for("new_project", team_slug=team.slug), status_code=303 + ) - try: - installation = await github_service.get_repository_installation( - repo["full_name"] + project = Project( + name=form.name.data, + repo_provider="gitea", + repo_id=repo["id"], + repo_full_name=repo["full_name"], + repo_base_url=conn.base_url, + gitea_connection_id=conn.id, + config={ + "preset": form.preset.data, + "runner": form.runner.data, + "root_directory": form.root_directory.data, + "build_command": form.build_command.data, + "pre_deploy_command": form.pre_deploy_command.data, + "start_command": form.start_command.data, + }, + env_vars=env_vars, + environments=[ + { + "id": "prod", + "color": "blue", + "name": "Production", + "slug": "production", + "branch": form.production_branch.data, + "status": "active", + } + ], + team=team, + created_by_user_id=current_user.id, ) - except httpx.HTTPStatusError as exc: - if exc.response.status_code == 404: - flash( - request, - _( - "Install the GitHub app on %(repo)s to continue.", - repo=repo["full_name"], - ), - "error", + else: + try: + github_oauth_token = await get_user_github_token(db, current_user) + if not github_oauth_token: + raise ValueError("GitHub OAuth token missing.") + if not form.repo_id.data: + raise ValueError("Repository ID missing.") + repo = await github_service.get_repository( + github_oauth_token, int(form.repo_id.data) ) + except Exception: + flash(request, _("You do not have access to this repository."), "error") return RedirectResponse( request.url_for("new_project", team_slug=team.slug), status_code=303 ) - raise - github_installation = ( - await github_installation_service.get_or_refresh_installation( - installation["id"], db - ) - ) + try: + installation = await github_service.get_repository_installation( + repo["full_name"] + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + flash( + request, + _( + "Install the GitHub app on %(repo)s to continue.", + repo=repo["full_name"], + ), + "error", + ) + return RedirectResponse( + request.url_for("new_project", team_slug=team.slug), status_code=303 + ) + raise - env_vars = [ - { - "key": entry.key.data, - "value": entry.value.data, - "environment": entry.environment.data, - } - for entry in form.env_vars - ] + github_installation = ( + await github_installation_service.get_or_refresh_installation( + installation["id"], db + ) + ) - project = Project( - name=form.name.data, - repo_id=form.repo_id.data, - repo_full_name=repo["full_name"], - github_installation=github_installation, - config={ - "preset": form.preset.data, - "runner": form.runner.data, - "root_directory": form.root_directory.data, - "build_command": form.build_command.data, - "pre_deploy_command": form.pre_deploy_command.data, - "start_command": form.start_command.data, - }, - env_vars=env_vars, - environments=[ - { - "id": "prod", - "color": "blue", - "name": "Production", - "slug": "production", - "branch": form.production_branch.data, - "status": "active", - } - ], - team=team, - created_by_user_id=current_user.id, - ) + project = Project( + name=form.name.data, + repo_provider="github", + repo_id=form.repo_id.data, + repo_full_name=repo["full_name"], + repo_base_url="https://github.com", + github_installation=github_installation, + config={ + "preset": form.preset.data, + "runner": form.runner.data, + "root_directory": form.root_directory.data, + "build_command": form.build_command.data, + "pre_deploy_command": form.pre_deploy_command.data, + "start_command": form.start_command.data, + }, + env_vars=env_vars, + environments=[ + { + "id": "prod", + "color": "blue", + "name": "Production", + "slug": "production", + "branch": form.production_branch.data, + "status": "active", + } + ], + team=team, + created_by_user_id=current_user.id, + ) db.add(project) await db.commit() @@ -1014,20 +1086,32 @@ async def project_deploy( try: branch, commit_sha = form.commit.data.split(":") - github_installation = ( - await github_installation_service.get_or_refresh_installation( - project.github_installation_id, db + if project.repo_provider == "gitea": + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == project.gitea_connection_id + ) + ) + if not conn: + raise ValueError("Gitea connection not found.") + gitea_svc = GiteaService(conn.base_url, conn.token) + owner, repo_name_part = project.repo_full_name.split("/", 1) + raw_commit = await gitea_svc.get_commit(owner, repo_name_part, commit_sha) + commit = gitea_svc.normalize_commit(raw_commit) + else: + github_installation = ( + await github_installation_service.get_or_refresh_installation( + project.github_installation_id, db + ) + ) + if not github_installation.token: + raise ValueError("GitHub installation token missing.") + commit = await github_service.get_repository_commit( + user_access_token=github_installation.token, + repo_id=project.repo_id, + commit_sha=commit_sha, + branch=branch, ) - ) - if not github_installation.token: - raise ValueError("GitHub installation token missing.") - - commit = await github_service.get_repository_commit( - user_access_token=github_installation.token, - repo_id=project.repo_id, - commit_sha=commit_sha, - branch=branch, - ) deployment = await DeploymentService().create( project=project, @@ -1070,28 +1154,39 @@ async def project_deploy( logger.error(error_message) flash(request, error_message, "error") - # Get the list of commits for the selected environment branch_names = [] commits = [] + try: - github_installation = ( - await github_installation_service.get_or_refresh_installation( - project.github_installation_id, db + if project.repo_provider == "gitea": + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == project.gitea_connection_id + ) ) - ) - if not github_installation.token: - raise ValueError("GitHub installation token missing.") - - branches = await github_service.get_repository_branches( - github_installation.token, project.repo_id - ) - branch_names = [branch["name"] for branch in branches] + if not conn: + raise ValueError("Gitea connection not found.") + gitea_svc = GiteaService(conn.base_url, conn.token) + owner, repo_name_part = project.repo_full_name.split("/", 1) + branches_data = await gitea_svc.list_branches(owner, repo_name_part) + branch_names = [b["name"] for b in branches_data] + else: + github_installation = ( + await github_installation_service.get_or_refresh_installation( + project.github_installation_id, db + ) + ) + if not github_installation.token: + raise ValueError("GitHub installation token missing.") + branches_data = await github_service.get_repository_branches( + github_installation.token, project.repo_id + ) + branch_names = [b["name"] for b in branches_data] except Exception as e: logger.error(f"Error fetching branches: {str(e)}") - flash(request, _("Error fetching branches from GitHub."), "error") + flash(request, _("Error fetching branches."), "error") if len(branch_names) > 0: - # Find branches that match this environment branches_by_environment = group_branches_by_environment( project.active_environments, branch_names ) @@ -1100,21 +1195,26 @@ async def project_deploy( raise ValueError("Environment not found.") matching_branches = branches_by_environment.get(environment["slug"]) - # Get the latest 5 commits for each matching branch if matching_branches: for branch in matching_branches: try: - if not github_installation.token: - raise ValueError("GitHub installation token missing.") - - branch_commits = await github_service.get_repository_commits( - github_installation.token, project.repo_id, branch, per_page=5 - ) - - # Add branch information to each commit - for commit in branch_commits: - commit["branch"] = branch - commits.append(commit) + if project.repo_provider == "gitea": + raw_commits = await gitea_svc.list_commits( + owner, repo_name_part, sha=branch, limit=5 + ) + for c in raw_commits: + normalized = gitea_svc.normalize_commit(c) + normalized["branch"] = branch + commits.append(normalized) + else: + if not github_installation.token: + raise ValueError("GitHub installation token missing.") + branch_commits = await github_service.get_repository_commits( + github_installation.token, project.repo_id, branch, per_page=5 + ) + for commit in branch_commits: + commit["branch"] = branch + commits.append(commit) except Exception as e: warning_message = _( "Could not fetch commits for branch %(branch)s: %(error)s" @@ -1123,7 +1223,6 @@ async def project_deploy( flash(request, warning_message, "warning") continue - # Sort commits by date (newest first) commits.sort(key=lambda x: x["commit"]["author"]["date"], reverse=True) return TemplateResponse( @@ -1168,20 +1267,32 @@ async def project_redeploy( if environment and request.method == "POST" and await form.validate_on_submit(): try: - github_installation = ( - await github_installation_service.get_or_refresh_installation( - project.github_installation_id, db + if project.repo_provider == "gitea": + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == project.gitea_connection_id + ) + ) + if not conn: + raise ValueError("Gitea connection not found.") + gitea_svc = GiteaService(conn.base_url, conn.token) + owner, repo_name_part = project.repo_full_name.split("/", 1) + raw_commit = await gitea_svc.get_commit(owner, repo_name_part, deployment.commit_sha) + commit = gitea_svc.normalize_commit(raw_commit) + else: + github_installation = ( + await github_installation_service.get_or_refresh_installation( + project.github_installation_id, db + ) + ) + if not github_installation.token: + raise ValueError("GitHub installation token missing.") + commit = await github_service.get_repository_commit( + user_access_token=github_installation.token, + repo_id=project.repo_id, + commit_sha=deployment.commit_sha, + branch=deployment.branch, ) - ) - if not github_installation.token: - raise ValueError("GitHub installation token missing.") - - commit = await github_service.get_repository_commit( - user_access_token=github_installation.token, - repo_id=project.repo_id, - commit_sha=deployment.commit_sha, - branch=deployment.branch, - ) new_deployment = await DeploymentService().create( project=project, @@ -1504,7 +1615,13 @@ async def project_settings( # General general_form: Any = await ProjectGeneralForm.from_formdata( request, - data={"name": project.name, "repo_id": project.repo_id}, + data={ + "name": project.name, + "repo_id": project.repo_id, + "repo_full_name": project.repo_full_name, + "repo_provider": project.repo_provider, + "repo_base_url": project.repo_base_url, + }, db=db, team=team, project=project, @@ -1518,20 +1635,52 @@ async def project_settings( # Repo if general_form.repo_id.data != project.repo_id: - try: - github_service = get_github_service() - github_oauth_token = await get_user_github_token(db, current_user) - repo = await github_service.get_repository( - github_oauth_token or "", general_form.repo_id.data - ) - except Exception: - flash( - request, - _("You do not have access to this repository."), - "error", - ) - project.repo_id = general_form.repo_id.data - project.repo_full_name = repo.get("full_name") or "" + new_provider = general_form.repo_provider.data or project.repo_provider + if new_provider == "gitea": + conn_id = general_form.connection_id.data + try: + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == int(conn_id), + GiteaConnection.user_id == current_user.id, + ) + ) + if not conn: + raise ValueError("Connection not found") + gitea_svc = GiteaService(conn.base_url, conn.token) + full_name = general_form.repo_full_name.data or "" + owner, repo_name_part = full_name.split("/", 1) + repo = await gitea_svc.get_repo(owner, repo_name_part) + except Exception: + flash( + request, + _("You do not have access to this repository."), + "error", + ) + project.repo_provider = "gitea" + project.repo_id = general_form.repo_id.data + project.repo_full_name = repo.get("full_name") or full_name + project.repo_base_url = conn.base_url + project.github_installation_id = None + project.gitea_connection_id = conn.id + else: + try: + github_service = get_github_service() + github_oauth_token = await get_user_github_token(db, current_user) + repo = await github_service.get_repository( + github_oauth_token or "", general_form.repo_id.data + ) + except Exception: + flash( + request, + _("You do not have access to this repository."), + "error", + ) + project.repo_provider = "github" + project.repo_id = general_form.repo_id.data + project.repo_full_name = repo.get("full_name") or "" + project.repo_base_url = "https://github.com" + project.gitea_connection_id = None # Avatar upload avatar_file = general_form.avatar.data diff --git a/app/routers/user.py b/app/routers/user.py index 57831a57..07f2b4d4 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -23,14 +23,17 @@ get_redis_client, ) from db import get_db -from models import User, UserIdentity, Team, TeamMember, TeamInvite, utc_now +from models import User, UserIdentity, GiteaConnection, Team, TeamMember, TeamInvite, utc_now from forms.user import ( UserDeleteForm, UserGeneralForm, UserEmailForm, UserOAuthAccessRevokeForm, + GiteaConnectionCreateForm, + GiteaConnectionDeleteForm, ) from forms.team import TeamLeaveForm, TeamInviteAcceptForm +from services.gitea import GiteaService from utils.email import send_email logger = logging.getLogger(__name__) @@ -376,7 +379,6 @@ async def user_settings( ), "success", ) - # Update the appropriate variable if provider == "github": github_username = None else: @@ -398,6 +400,14 @@ async def user_settings( ) if request.headers.get("HX-Request"): + gitea_connections_result = await db.execute( + select(GiteaConnection) + .where(GiteaConnection.user_id == current_user.id) + .order_by(GiteaConnection.created_at.desc()) + ) + gitea_connections = gitea_connections_result.scalars().all() + gitea_create_form = await GiteaConnectionCreateForm.from_formdata(request) + gitea_delete_form = await GiteaConnectionDeleteForm.from_formdata(request) return TemplateResponse( request=request, name="user/partials/_settings-authentication.html", @@ -406,9 +416,98 @@ async def user_settings( "current_user": current_user, "github_username": github_username, "google_email": google_email, + "gitea_connections": gitea_connections, + "gitea_create_form": gitea_create_form, + "gitea_delete_form": gitea_delete_form, }, ) + # Gitea connections + gitea_create_form: Any = await GiteaConnectionCreateForm.from_formdata(request) + gitea_delete_form: Any = await GiteaConnectionDeleteForm.from_formdata(request) + + if request.method == "POST" and fragment == "gitea_create": + if await gitea_create_form.validate_on_submit(): + base_url = gitea_create_form.base_url.data.rstrip("/") + token = gitea_create_form.token.data + + try: + svc = GiteaService(base_url, token) + user_info = await svc.get_current_user() + username = user_info.get("login", "") + + existing = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.user_id == current_user.id, + GiteaConnection.base_url == base_url, + ) + ) + if existing: + flash(request, _("This Gitea instance is already connected."), "warning") + else: + conn = GiteaConnection( + user_id=current_user.id, + base_url=base_url, + username=username, + ) + conn.token = token + db.add(conn) + await db.commit() + flash( + request, + _("Gitea instance %(url)s connected.", url=base_url), + "success", + ) + except Exception as e: + logger.error(f"Error connecting Gitea instance: {e}") + flash( + request, + _("Could not connect to Gitea instance. Please check the URL and token."), + "error", + ) + + if request.method == "POST" and fragment == "gitea_delete": + if await gitea_delete_form.validate_on_submit(): + conn_id = gitea_delete_form.connection_id.data + try: + conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == int(conn_id), + GiteaConnection.user_id == current_user.id, + ) + ) + if conn: + await db.delete(conn) + await db.commit() + flash(request, _("Gitea instance removed."), "success") + else: + flash(request, _("Gitea instance not found."), "warning") + except Exception as e: + logger.error(f"Error removing Gitea connection: {e}") + flash(request, _("Error removing Gitea instance."), "error") + + gitea_connections_result = await db.execute( + select(GiteaConnection) + .where(GiteaConnection.user_id == current_user.id) + .order_by(GiteaConnection.created_at.desc()) + ) + gitea_connections = gitea_connections_result.scalars().all() + + if request.headers.get("HX-Request") and fragment in ("gitea_create", "gitea_delete"): + return TemplateResponse( + request=request, + name="user/partials/_settings-authentication.html", + context={ + "revoke_oauth_access_form": revoke_oauth_access_form, + "current_user": current_user, + "github_username": github_username, + "google_email": google_email, + "gitea_connections": gitea_connections, + "gitea_create_form": gitea_create_form, + "gitea_delete_form": gitea_delete_form, + }, + ) + return TemplateResponse( request=request, name="user/pages/settings.html", @@ -422,6 +521,9 @@ async def user_settings( "teams_and_roles": teams_and_roles, "leave_team_form": leave_team_form, "revoke_oauth_access_form": revoke_oauth_access_form, + "gitea_connections": gitea_connections, + "gitea_create_form": gitea_create_form, + "gitea_delete_form": gitea_delete_form, }, ) diff --git a/app/services/deployment.py b/app/services/deployment.py index fa5b8038..db729032 100644 --- a/app/services/deployment.py +++ b/app/services/deployment.py @@ -132,7 +132,8 @@ def get_runtime_env_vars( "DEVPUSH_ENVIRONMENT": environment.get("slug") or deployment.environment_id, "DEVPUSH_DEPLOYMENT_ID": deployment.id, "DEVPUSH_DEPLOYMENT_CREATED_AT": deployment.created_at.isoformat() + "Z", - "DEVPUSH_GIT_PROVIDER": "github", + "DEVPUSH_GIT_PROVIDER": deployment.repo_provider, + "DEVPUSH_GIT_BASE_URL": deployment.repo_base_url, "DEVPUSH_GIT_REPO": deployment.repo_full_name, "DEVPUSH_GIT_REF": deployment.branch, "DEVPUSH_GIT_COMMIT_SHA": deployment.commit_sha, diff --git a/app/services/gitea.py b/app/services/gitea.py new file mode 100644 index 00000000..5949a94c --- /dev/null +++ b/app/services/gitea.py @@ -0,0 +1,161 @@ +import httpx +import base64 +import logging +from urllib.parse import urljoin + +logger = logging.getLogger(__name__) + + +class GiteaService: + """Gitea API client using Personal Access Token authentication.""" + + def __init__(self, base_url: str, token: str): + self.base_url = base_url.rstrip("/") + self.api_url = f"{self.base_url}/api/v1" + self.token = token + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"token {self.token}"} + + async def get_current_user(self) -> dict: + response = httpx.get( + f"{self.api_url}/user", + headers=self._headers(), + timeout=10.0, + ) + response.raise_for_status() + return response.json() + + async def list_repos( + self, query: str | None = None, page: int = 1, limit: int = 50 + ) -> list[dict]: + repos: list[dict] = [] + params: dict = {"page": page, "limit": limit, "sort": "updated", "order": "desc"} + if query: + params["q"] = query + + response = httpx.get( + f"{self.api_url}/user/repos", + headers=self._headers(), + params=params, + timeout=10.0, + ) + response.raise_for_status() + repos = response.json() + return repos + + async def get_repo(self, owner: str, repo: str) -> dict: + response = httpx.get( + f"{self.api_url}/repos/{owner}/{repo}", + headers=self._headers(), + timeout=10.0, + ) + response.raise_for_status() + return response.json() + + async def list_branches(self, owner: str, repo: str) -> list[dict]: + branches: list[dict] = [] + page = 1 + limit = 50 + + while True: + response = httpx.get( + f"{self.api_url}/repos/{owner}/{repo}/branches", + headers=self._headers(), + params={"page": page, "limit": limit}, + timeout=10.0, + ) + response.raise_for_status() + batch = response.json() + branches.extend(batch) + if len(batch) < limit: + break + page += 1 + + return branches + + async def list_commits( + self, + owner: str, + repo: str, + sha: str | None = None, + page: int = 1, + limit: int = 30, + ) -> list[dict]: + params: dict = {"page": page, "limit": limit} + if sha: + params["sha"] = sha + + response = httpx.get( + f"{self.api_url}/repos/{owner}/{repo}/commits", + headers=self._headers(), + params=params, + timeout=10.0, + ) + response.raise_for_status() + return response.json() + + async def get_commit(self, owner: str, repo: str, sha: str) -> dict: + response = httpx.get( + f"{self.api_url}/repos/{owner}/{repo}/git/commits/{sha}", + headers=self._headers(), + timeout=10.0, + ) + response.raise_for_status() + return response.json() + + async def get_git_tree( + self, owner: str, repo: str, sha: str = "HEAD", recursive: bool = True + ) -> dict: + params = {} + if recursive: + params["recursive"] = "true" + + response = httpx.get( + f"{self.api_url}/repos/{owner}/{repo}/git/trees/{sha}", + headers=self._headers(), + params=params, + timeout=10.0, + ) + response.raise_for_status() + return response.json() + + async def get_file_content( + self, owner: str, repo: str, path: str, ref: str = "HEAD" + ) -> str | None: + try: + response = httpx.get( + f"{self.api_url}/repos/{owner}/{repo}/contents/{path}", + headers=self._headers(), + params={"ref": ref}, + timeout=10.0, + ) + response.raise_for_status() + data = response.json() + return base64.b64decode(data["content"]).decode("utf-8") + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + raise + + def normalize_commit(self, commit: dict) -> dict: + """Normalize a Gitea commit into the shape GitHub commits have, + so callers (DeploymentService, etc.) can work with both.""" + c = commit.get("commit", commit) + author_info = c.get("author", {}) + committer_info = commit.get("author") or commit.get("committer") or {} + return { + "sha": commit.get("sha", c.get("sha", "")), + "commit": { + "message": c.get("message", ""), + "author": { + "name": author_info.get("name", ""), + "email": author_info.get("email", ""), + "date": author_info.get("date", ""), + }, + }, + "author": { + "login": committer_info.get("login", author_info.get("name", "")), + "avatar_url": committer_info.get("avatar_url", ""), + }, + } diff --git a/app/services/preset_detector.py b/app/services/preset_detector.py index 612a4f28..132d18aa 100644 --- a/app/services/preset_detector.py +++ b/app/services/preset_detector.py @@ -5,8 +5,6 @@ import logging from typing import Optional -from services.github import GitHubService - logger = logging.getLogger(__name__) @@ -66,21 +64,18 @@ def __init__(self, presets: list[dict]): async def detect( self, - github_service: GitHubService, - user_access_token: str, + service, + user_access_token: str | None, repo_id: int, default_branch: str, + *, + repo_owner: str | None = None, + repo_name: str | None = None, ) -> Optional[dict]: """Detect preset from repository and return merged config. - Args: - github_service: GitHubService instance - user_access_token: User's GitHub OAuth token - repo_id: Repository ID - default_branch: Default branch name - - Returns: - Dict with preset slug + merged config, or None if no match + For GitHub, pass the GitHubService + user_access_token + repo_id. + For Gitea, pass the GiteaService (token embedded) + repo_owner + repo_name. """ if not self.patterns: logger.warning("No detection patterns configured") @@ -88,9 +83,14 @@ async def detect( try: logger.info(f"Fetching git tree for repo {repo_id}") - tree = await github_service.get_git_tree( - user_access_token, repo_id, sha=default_branch, recursive=True - ) + if repo_owner and repo_name: + tree = await service.get_git_tree( + repo_owner, repo_name, sha=default_branch, recursive=True + ) + else: + tree = await service.get_git_tree( + user_access_token, repo_id, sha=default_branch, recursive=True + ) paths = { item["path"] for item in tree.get("tree", []) if item["type"] == "blob" @@ -106,10 +106,12 @@ async def detect( if await self._matches_pattern( paths, pattern, - github_service, + service, user_access_token, repo_id, default_branch, + repo_owner=repo_owner, + repo_name=repo_name, ): matches.append(pattern) logger.debug(f"Pattern matched: {pattern['preset']}") @@ -139,10 +141,13 @@ async def _matches_pattern( self, paths: set[str], pattern: dict, - github_service: GitHubService, - user_access_token: str, + service, + user_access_token: str | None, repo_id: int, default_branch: str, + *, + repo_owner: str | None = None, + repo_name: str | None = None, ) -> bool: """Check if paths match a detection pattern.""" if pattern.get("any_files"): @@ -170,8 +175,9 @@ async def _matches_pattern( ] for py_file in py_files: try: - content = await github_service.get_file_content( - user_access_token, repo_id, py_file, ref=default_branch + content = await self._get_file( + service, user_access_token, repo_id, py_file, + default_branch, repo_owner, repo_name, ) if content and pkg_check.lower() in content.lower(): found = True @@ -181,8 +187,9 @@ async def _matches_pattern( if not found and "package.json" in paths: try: - content = await github_service.get_file_content( - user_access_token, repo_id, "package.json", ref=default_branch + content = await self._get_file( + service, user_access_token, repo_id, "package.json", + default_branch, repo_owner, repo_name, ) if content and pkg_check.lower() in content.lower(): found = True @@ -194,6 +201,20 @@ async def _matches_pattern( return True + async def _get_file( + self, + service, + user_access_token: str | None, + repo_id: int, + filepath: str, + ref: str, + repo_owner: str | None, + repo_name: str | None, + ) -> str | None: + if repo_owner and repo_name: + return await service.get_file_content(repo_owner, repo_name, filepath, ref=ref) + return await service.get_file_content(user_access_token, repo_id, filepath, ref=ref) + def _path_matches(self, paths: set[str], pattern: str) -> bool: """Check if pattern matches any path (supports globs).""" if "*" in pattern or "?" in pattern: @@ -202,15 +223,18 @@ def _path_matches(self, paths: set[str], pattern: str) -> bool: async def detect_with_commands( self, - github_service: GitHubService, - user_access_token: str, + service, + user_access_token: str | None, repo_id: int, default_branch: str, + *, + repo_owner: str | None = None, + repo_name: str | None = None, ) -> dict: """Detect preset and extract build/start commands from package.json. - Returns: - Dictionary with preset + merged config fields + For GitHub, pass the GitHubService + user_access_token + repo_id. + For Gitea, pass the GiteaService (token embedded) + repo_owner + repo_name. """ result = { "preset": None, @@ -222,7 +246,8 @@ async def detect_with_commands( } detection = await self.detect( - github_service, user_access_token, repo_id, default_branch + service, user_access_token, repo_id, default_branch, + repo_owner=repo_owner, repo_name=repo_name, ) if not detection: return result @@ -238,8 +263,9 @@ async def detect_with_commands( if preset in ("nodejs", "bun"): try: - content = await github_service.get_file_content( - user_access_token, repo_id, "package.json", ref=default_branch + content = await self._get_file( + service, user_access_token, repo_id, "package.json", + default_branch, repo_owner, repo_name, ) if content: diff --git a/app/templates/auth/pages/login.html b/app/templates/auth/pages/login.html index 9bc8f6db..0222e003 100644 --- a/app/templates/auth/pages/login.html +++ b/app/templates/auth/pages/login.html @@ -14,21 +14,25 @@

Sign in to {{ app_name }}

}" > {% include "auth/partials/_form-email.html" %} -
- + {% if has_github_login or has_google_login %} +
+ {% endif %} + {% if has_github_login %} + + {% endif %} {% if has_google_login %} + {% endif %} + + {% endfor %} + +{% else %} +

+ {{ _('No repositories found') }} +

+{% endif %} diff --git a/app/templates/gitea/partials/_repo-select.html b/app/templates/gitea/partials/_repo-select.html new file mode 100644 index 00000000..f0ab5620 --- /dev/null +++ b/app/templates/gitea/partials/_repo-select.html @@ -0,0 +1,75 @@ +{% if not connections %} +
+
+
+

{{ _('Connect a Gitea instance') }}

+

+ {{ _('Add a Gitea instance in your account settings to select repositories.') }} +

+ + {% include "icons/gitea.svg" %} {{ _('Go to settings') }} + +
+
+
+{% else %} +
+
+ + {% from "macros/select.html" import select %} + {% call select( + id="gitea-connection-select", + selected=selected_connection.id|string if selected_connection else '', + main_attrs={"@change": " + $el.closest('form').querySelector('input[name=connection_id]').value = $event.detail?.value || ''; + "} + ) %} + {% for conn in connections %} +
+ + {% include "icons/gitea.svg" %} + {{ conn.base_url | replace('https://', '') | replace('http://', '') }} + ({{ conn.username }}) + +
+ {% endfor %} + {% endcall %} + +
+ + + {% include "icons/search.svg" %} + +
+
+ +
+
+ {% include "github/partials/_repo-select-list-skeleton.html" %} +
+ +
+ {% include "gitea/partials/_repo-select-list.html" %} +
+
+
+{% endif %} diff --git a/app/templates/icons/gitea.svg b/app/templates/icons/gitea.svg new file mode 100644 index 00000000..9df6b83b --- /dev/null +++ b/app/templates/icons/gitea.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/app/templates/project/macros/teaser.html b/app/templates/project/macros/teaser.html index e12bbef5..c54bcaf4 100644 --- a/app/templates/project/macros/teaser.html +++ b/app/templates/project/macros/teaser.html @@ -27,11 +27,15 @@

{{ project.name }}

  • - {% include "icons/github.svg" %} + {% if project.repo_provider == 'gitea' %} + {% include "icons/gitea.svg" %} + {% else %} + {% include "icons/github.svg" %} + {% endif %} {{ project.repo_full_name }}
  • diff --git a/app/templates/project/pages/new.html b/app/templates/project/pages/new.html index 74c0e70a..971a8093 100644 --- a/app/templates/project/pages/new.html +++ b/app/templates/project/pages/new.html @@ -3,13 +3,19 @@ {% block app_content %}
    @@ -38,13 +44,51 @@

    {{ _('Deploy') }}

    -
    - {% include "github/partials/_repo-select-skeleton.html" %} + {% if has_github and has_gitea %} +
    + +
    + {% endif %} + + {% if has_github %} +
    +
    + {% include "github/partials/_repo-select-skeleton.html" %} +
    +
    + {% endif %} + + {% if has_gitea %} +
    +
    + {% include "github/partials/_repo-select-skeleton.html" %} +
    +
    + {% endif %}
    {% endblock %} \ No newline at end of file diff --git a/app/templates/project/partials/_dialog-deploy-commits.html b/app/templates/project/partials/_dialog-deploy-commits.html index c2997fe5..14a4bd88 100644 --- a/app/templates/project/partials/_dialog-deploy-commits.html +++ b/app/templates/project/partials/_dialog-deploy-commits.html @@ -68,10 +68,10 @@

    {{ _('No commits') }}

    {% include "icons/arrow-up-right.svg" %} diff --git a/app/templates/project/partials/_dialog-redeploy-form.html b/app/templates/project/partials/_dialog-redeploy-form.html index 9e2996ec..d88c44d2 100644 --- a/app/templates/project/partials/_dialog-redeploy-form.html +++ b/app/templates/project/partials/_dialog-redeploy-form.html @@ -25,7 +25,7 @@

    {{ _('Commit info:') }}

    {% include "icons/git-commit-horizontal.svg" %} {{ deployment.commit_meta.message }} - + {% include "icons/arrow-up-right.svg" %}
    diff --git a/app/templates/project/partials/_settings-general.html b/app/templates/project/partials/_settings-general.html index 7ab27b8b..58c899e9 100644 --- a/app/templates/project/partials/_settings-general.html +++ b/app/templates/project/partials/_settings-general.html @@ -12,7 +12,7 @@ } ) %}
    @@ -52,12 +52,18 @@ repoId: {{ project.repo_id }}, repoOwner: '{{ project.repo_full_name.split('/')[0] }}', repoFullName: '{{ project.repo_full_name }}', + repoProvider: '{{ project.repo_provider }}', + repoBaseUrl: '{{ project.repo_base_url }}', + connectionId: '{{ project.gitea_connection_id or '' }}', deleteAvatar: false }" @repo-selected.document=" repoId = $event.detail.id; repoOwner = $event.detail.owner; repoFullName = $event.detail.fullName; + if ($event.detail.provider) repoProvider = $event.detail.provider; + if ($event.detail.baseUrl) repoBaseUrl = $event.detail.baseUrl; + if ($event.detail.connectionId) connectionId = $event.detail.connectionId; changed = true; " @input="changed = true" @@ -109,6 +115,26 @@

    {{ _("General") }}

    class_="hidden", **{":value": "repoId"} ) }} + {{ general_form.repo_full_name( + id=False, + class_="hidden", + **{":value": "repoFullName"} + ) }} + {{ general_form.repo_provider( + id=False, + class_="hidden", + **{":value": "repoProvider"} + ) }} + {{ general_form.repo_base_url( + id=False, + class_="hidden", + **{":value": "repoBaseUrl"} + ) }} + {{ general_form.connection_id( + id=False, + class_="hidden", + **{":value": "connectionId"} + ) }}
    {% include "icons/arrow-up-right.svg" %} diff --git a/app/templates/team/pages/index.html b/app/templates/team/pages/index.html index 9e9336c6..9e6245cf 100644 --- a/app/templates/team/pages/index.html +++ b/app/templates/team/pages/index.html @@ -35,7 +35,7 @@

    {{ _('Recent projects') }}

    {{ _('Deploy your first project.') }}

    -

    {{ _('Add a new project from an existing GitHub repository.') }} +

    {{ _('Add a new project from an existing Git repository.') }}

    {% include "icons/plus.svg" %} diff --git a/app/templates/team/pages/projects.html b/app/templates/team/pages/projects.html index ee05d84f..befdaaf5 100644 --- a/app/templates/team/pages/projects.html +++ b/app/templates/team/pages/projects.html @@ -41,10 +41,14 @@

    {{ project.hostname }} - - {% include "icons/github.svg" %} + data-tooltip="{{ _('Repository') }}"> + {% if project.repo_provider == 'gitea' %} + {% include "icons/gitea.svg" %} + {% else %} + {% include "icons/github.svg" %} + {% endif %} {{ project.repo_full_name }} @@ -64,7 +68,7 @@

    +
    +
    +

    {{ _("Gitea instances") }}

    +

    {{ _("Connect self-hosted Gitea instances using a personal access token.") }}

    +
    + +
    + {% if gitea_connections %} +
      + {% for conn in gitea_connections %} +
    • + {% include "icons/gitea.svg" %} +
      {{ conn.base_url }}
      +
      {{ conn.username }}
      +
      + {{ gitea_delete_form.csrf_token }} + + +
      +
    • + {% endfor %} +
    + {% endif %} + +
    + {{ gitea_create_form.csrf_token }} +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    {% include "icons/loader.svg" %} diff --git a/app/workers/tasks/deployment.py b/app/workers/tasks/deployment.py index 8c443cbb..e528c265 100644 --- a/app/workers/tasks/deployment.py +++ b/app/workers/tasks/deployment.py @@ -6,13 +6,14 @@ from pathlib import Path import shlex -from models import Alias, Deployment, Project +from models import Alias, Deployment, GiteaConnection, Project from db import AsyncSessionLocal from dependencies import ( get_redis_client, get_github_installation_service, ) from config import get_settings +from sqlalchemy.orm import selectinload from arq.connections import ArqRedis from services.deployment import DeploymentService from services.registry import RegistryService @@ -84,29 +85,53 @@ async def start_deployment(ctx, deployment_id: str): # Prepare commands commands = [] - # Step 1: Clone the repository commands.append( f"echo 'Cloning {deployment.repo_full_name} (Branch: {deployment.branch}, Commit: {deployment.commit_sha[:7]})'" ) - github_installation = ( - await github_installation_service.get_or_refresh_installation( - deployment.project.github_installation_id, db + + if deployment.repo_provider == "gitea": + gitea_conn = await db.scalar( + select(GiteaConnection).where( + GiteaConnection.id == deployment.project.gitea_connection_id + ) + ) + if not gitea_conn: + raise ValueError("Gitea connection not found for deployment.") + env_vars_dict["DEVPUSH_GIT_TOKEN"] = gitea_conn.token + clone_url = f"{deployment.repo_base_url}/{deployment.repo_full_name}.git" + commands.append( + "git init -q && " + "printf '%s\\n' " + "'#!/bin/sh' " + "'case \"$1\" in *Username*) echo \"x-access-token\";; *) echo \"$DEVPUSH_GIT_TOKEN\";; esac' " + "> /tmp/devpush-git-askpass && " + "chmod 700 /tmp/devpush-git-askpass && " + "export GIT_ASKPASS=/tmp/devpush-git-askpass GIT_TERMINAL_PROMPT=0 && " + f"git fetch -q --depth 1 {clone_url} {deployment.commit_sha} && " + "git checkout -q FETCH_HEAD && " + "unset GIT_ASKPASS GIT_TERMINAL_PROMPT DEVPUSH_GIT_TOKEN && " + "rm -f /tmp/devpush-git-askpass" + ) + else: + github_installation = ( + await github_installation_service.get_or_refresh_installation( + deployment.project.github_installation_id, db + ) + ) + env_vars_dict["DEVPUSH_GITHUB_TOKEN"] = github_installation.token + commands.append( + "git init -q && " + "printf '%s\\n' " + "'#!/bin/sh' " + "'case \"$1\" in *Username*) echo \"x-access-token\";; *) echo \"$DEVPUSH_GITHUB_TOKEN\";; esac' " + "> /tmp/devpush-git-askpass && " + "chmod 700 /tmp/devpush-git-askpass && " + "export GIT_ASKPASS=/tmp/devpush-git-askpass GIT_TERMINAL_PROMPT=0 && " + f"git fetch -q --depth 1 https://github.com/{deployment.repo_full_name}.git {deployment.commit_sha} && " + "git checkout -q FETCH_HEAD && " + "unset GIT_ASKPASS GIT_TERMINAL_PROMPT DEVPUSH_GITHUB_TOKEN && " + "rm -f /tmp/devpush-git-askpass" ) - ) - env_vars_dict["DEVPUSH_GITHUB_TOKEN"] = github_installation.token - commands.append( - "git init -q && " - "printf '%s\n' " - "'#!/bin/sh' " - '\'case "$1" in *Username*) echo "x-access-token";; *) echo "$DEVPUSH_GITHUB_TOKEN";; esac\' ' - "> /tmp/devpush-git-askpass && " - "chmod 700 /tmp/devpush-git-askpass && " - "export GIT_ASKPASS=/tmp/devpush-git-askpass GIT_TERMINAL_PROMPT=0 && " - f"git fetch -q --depth 1 https://github.com/{deployment.repo_full_name}.git {deployment.commit_sha} && " - "git checkout -q FETCH_HEAD && " - "unset GIT_ASKPASS GIT_TERMINAL_PROMPT DEVPUSH_GITHUB_TOKEN && " - "rm -f /tmp/devpush-git-askpass" - ) # Step 2: Change root directory normalized_root_directory = ( diff --git a/scripts/install.sh b/scripts/install.sh index a457ac8f..fb730d4c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -327,6 +327,7 @@ LE_EMAIL= CERT_CHALLENGE_PROVIDER=default # default|cloudflare|route53|gcloud|digitalocean|azure # CF_DNS_API_TOKEN= +# Git provider (at least one of GitHub or Gitea must be configured) # GitHub App (see https://devpu.sh/gh-app) GITHUB_APP_ID= GITHUB_APP_NAME= @@ -335,6 +336,9 @@ GITHUB_APP_WEBHOOK_SECRET= GITHUB_APP_CLIENT_ID= GITHUB_APP_CLIENT_SECRET= +# Gitea +# GITEA_WEBHOOK_SECRET= + # Email EMAIL_SENDER_ADDRESS= RESEND_API_KEY= diff --git a/scripts/lib.sh b/scripts/lib.sh index 2df26dd2..df1ae53b 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -407,12 +407,6 @@ validate_env(){ APP_HOSTNAME DEPLOY_DOMAIN EMAIL_SENDER_ADDRESS - GITHUB_APP_ID - GITHUB_APP_NAME - GITHUB_APP_PRIVATE_KEY - GITHUB_APP_WEBHOOK_SECRET - GITHUB_APP_CLIENT_ID - GITHUB_APP_CLIENT_SECRET SECRET_KEY ENCRYPTION_KEY POSTGRES_PASSWORD @@ -427,6 +421,20 @@ validate_env(){ [[ -n "$value" ]] || missing+=("$key") done + # Git provider: at least one of GitHub or Gitea must be configured + local github_keys=(GITHUB_APP_ID GITHUB_APP_NAME GITHUB_APP_PRIVATE_KEY GITHUB_APP_WEBHOOK_SECRET GITHUB_APP_CLIENT_ID GITHUB_APP_CLIENT_SECRET) + local has_all_github=true + for key in "${github_keys[@]}"; do + [[ -n "$(read_env_value "$env_file" "$key")" ]] || { has_all_github=false; break; } + done + + local has_gitea=false + [[ -n "$(read_env_value "$env_file" GITEA_WEBHOOK_SECRET)" ]] && has_gitea=true + + if [[ "$has_all_github" == false && "$has_gitea" == false ]]; then + missing+=("GITHUB_APP_* or GITEA_WEBHOOK_SECRET (at least one git provider required)") + fi + # Email configuration: RESEND_API_KEY or SMTP settings local resend_key smtp_host smtp_username smtp_password resend_key="$(read_env_value "$env_file" RESEND_API_KEY)"