Files
Containr/app/backend/internal/api/git.go
T
Tomáš Dvořák 6bcbc1369e refactor(db): port git providers, repos, and webhook receiver to sqlc (#82)
- git.sql: provider/repo/webhook CRUD, push-dispatch service matcher
- ListServicesForPush folds the dynamic branch clause into a static
  predicate; COALESCE fallbacks match other service loaders
- git_receiver dispatch writes reuse deployment queries
- TestGitLiveInvariants: live coverage for upsert, search, webhook,
  and branch matching semantics

Authored By: TDvorak <[email protected]>
2026-10-10 05:31:21 +02:00

1492 lines
45 KiB
Go

package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// GitProvider represents a Git provider configuration per user.
type GitProvider struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"` // github, gitlab, bitbucket, gitea, github_app
DisplayName string `json:"display_name" db:"display_name"`
APIUrl string `json:"api_url" db:"api_url"`
WebhookUrl string `json:"webhook_url" db:"webhook_url"`
UserID string `json:"user_id" db:"user_id"`
AccessToken string `json:"-" db:"access_token"` // Hidden in JSON responses
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// GitRepository represents a connected Git repository
type GitRepository struct {
ID string `json:"id" db:"id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Name string `json:"name" db:"name"`
FullName string `json:"full_name" db:"full_name"`
Description string `json:"description" db:"description"`
CloneURL string `json:"clone_url" db:"clone_url"`
WebhookURL string `json:"webhook_url" db:"webhook_url"`
DefaultBranch string `json:"default_branch" db:"default_branch"`
IsPrivate bool `json:"is_private" db:"is_private"`
UserID string `json:"user_id" db:"user_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// GitWebhook represents a webhook configuration
type GitWebhook struct {
ID string `json:"id" db:"id"`
RepoID string `json:"repo_id" db:"repo_id"`
ProviderID string `json:"provider_id" db:"provider_id"`
Events string `json:"events" db:"events"` // JSON array of events
Secret string `json:"-" db:"webhook_secret"` // Hidden in JSON responses
Active bool `json:"active" db:"active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateGitProviderRequest represents a request to create a Git provider
type CreateGitProviderRequest struct {
Name string `json:"name" binding:"required,oneof=github gitlab bitbucket gitea github_app"`
DisplayName string `json:"display_name" binding:"required"`
AccessToken string `json:"access_token" binding:"required"`
APIUrl string `json:"api_url,omitempty"`
}
type ConnectGitHubAppRequest struct {
InstallationID int64 `json:"installation_id" binding:"required,gt=0"`
DisplayName string `json:"display_name,omitempty"`
}
// CreateGitRepoRequest represents a request to connect a Git repository
type CreateGitRepoRequest struct {
ProviderID string `json:"provider_id" binding:"required"`
RepoFullName string `json:"repo_full_name" binding:"required"`
}
// CreateWebhookRequest represents a request to create a webhook
type CreateWebhookRequest struct {
RepoID string `json:"repo_id" binding:"required"`
Events []string `json:"events" binding:"required"`
Branch string `json:"branch"`
}
func gitRepoFrom(r sqlcdb.ListGitRepositoriesRow) GitRepository {
return GitRepository{
ID: r.ID.String(),
ProviderID: r.ProviderID.String(),
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
CloneURL: r.CloneUrl,
WebhookURL: r.WebhookUrl,
DefaultBranch: r.DefaultBranch.String,
IsPrivate: r.IsPrivate.Bool,
UserID: r.UserID.String(),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func gitRepoFromFullNameRow(r sqlcdb.GetGitRepositoryByFullNameRow) GitRepository {
return GitRepository{
ID: r.ID.String(),
ProviderID: r.ProviderID.String(),
Name: r.Name,
FullName: r.FullName,
Description: r.Description,
CloneURL: r.CloneUrl,
WebhookURL: r.WebhookUrl,
DefaultBranch: r.DefaultBranch.String,
IsPrivate: r.IsPrivate.Bool,
UserID: r.UserID.String(),
CreatedAt: r.CreatedAt.Time,
UpdatedAt: r.UpdatedAt.Time,
}
}
func gitProviderFrom(p sqlcdb.ListGitProvidersRow) GitProvider {
return GitProvider{
ID: p.ID.String(),
Name: p.Name,
DisplayName: p.DisplayName,
APIUrl: p.ApiUrl,
WebhookUrl: p.WebhookUrl,
UserID: p.UserID.String(),
CreatedAt: p.CreatedAt.Time,
UpdatedAt: p.UpdatedAt.Time,
}
}
func handleGetGitProviders(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
userID, err := uuid.Parse(ctx.userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
rows, err := sqlcdb.New(ctx.db.DB).ListGitProviders(context.Background(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providers := make([]GitProvider, 0, len(rows))
for _, p := range rows {
providers = append(providers, gitProviderFrom(p))
}
c.JSON(http.StatusOK, gin.H{"providers": providers})
}
func handleGetGitHubAppInstallURL(c *gin.Context) {
db, _ := c.Get("db")
d, _ := db.(*database.DB)
slug := strings.TrimSpace(settingValue(d, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""))
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"})
return
}
c.JSON(http.StatusOK, gin.H{
"install_url": fmt.Sprintf("https://github.com/apps/%s/installations/new", slug),
})
}
func handleConnectGitHubApp(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
var req ConnectGitHubAppRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
displayName := strings.TrimSpace(req.DisplayName)
if displayName == "" {
displayName = fmt.Sprintf("GitHub App #%d", req.InstallationID)
}
provider := GitProvider{
ID: uuid.NewString(),
Name: "github_app",
DisplayName: displayName,
APIUrl: strings.TrimSpace(getenvOrDefault("GITHUB_APP_BASE_URL", "https://api.github.com")),
WebhookUrl: "https://github.com",
AccessToken: strconv.FormatInt(req.InstallationID, 10),
UserID: ctx.userID,
}
prow, err := sqlcdb.New(ctx.db.DB).UpsertGitHubAppProvider(context.Background(), sqlcdb.UpsertGitHubAppProviderParams{
ID: uuid.MustParse(provider.ID),
Name: provider.Name,
DisplayName: provider.DisplayName,
ApiUrl: provider.APIUrl,
WebhookUrl: provider.WebhookUrl,
AccessToken: provider.AccessToken,
UserID: uuid.MustParse(provider.UserID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect GitHub App installation"})
return
}
provider.ID = prow.ID.String()
provider.CreatedAt = prow.CreatedAt.Time
provider.UpdatedAt = prow.UpdatedAt.Time
provider.AccessToken = ""
c.JSON(http.StatusOK, gin.H{
"message": "GitHub App connected",
"provider": provider,
})
}
func handleCreateGitProvider(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
var req CreateGitProviderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.ToLower(strings.TrimSpace(req.Name))
req.DisplayName = strings.TrimSpace(req.DisplayName)
req.AccessToken = strings.TrimSpace(req.AccessToken)
req.APIUrl = strings.TrimSpace(req.APIUrl)
if !validateGitToken(req.Name, req.AccessToken) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid access token for " + req.Name})
return
}
provider := GitProvider{
ID: uuid.NewString(),
Name: req.Name,
DisplayName: req.DisplayName,
AccessToken: req.AccessToken,
UserID: ctx.userID,
}
switch req.Name {
case "github":
provider.APIUrl = "https://api.github.com"
provider.WebhookUrl = "https://api.github.com"
case "gitlab":
provider.APIUrl = req.APIUrl
if provider.APIUrl == "" {
provider.APIUrl = strings.TrimSpace(getenvOrDefault("GITLAB_API_URL", "https://gitlab.com/api/v4"))
}
provider.APIUrl = strings.TrimSuffix(provider.APIUrl, "/")
provider.WebhookUrl = strings.TrimSpace(getenvOrDefault("GITLAB_BASE_URL", "https://gitlab.com"))
case "bitbucket":
provider.APIUrl = req.APIUrl
if provider.APIUrl == "" {
provider.APIUrl = strings.TrimSpace(getenvOrDefault("BITBUCKET_API_URL", "https://api.bitbucket.org/2.0"))
}
provider.APIUrl = strings.TrimSuffix(provider.APIUrl, "/")
provider.WebhookUrl = strings.TrimSpace(getenvOrDefault("BITBUCKET_BASE_URL", "https://bitbucket.org"))
case "gitea":
provider.APIUrl = req.APIUrl
if provider.APIUrl == "" {
provider.APIUrl = strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "https://gitea.example.com"))
}
provider.APIUrl = strings.TrimSuffix(provider.APIUrl, "/")
provider.WebhookUrl = provider.APIUrl
case "github_app":
provider.APIUrl = strings.TrimSpace(getenvOrDefault("GITHUB_APP_BASE_URL", "https://api.github.com"))
provider.WebhookUrl = "https://github.com"
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported provider"})
return
}
q := sqlcdb.New(ctx.db.DB)
if err := q.InsertGitProvider(context.Background(), sqlcdb.InsertGitProviderParams{
ID: uuid.MustParse(provider.ID),
Name: provider.Name,
DisplayName: provider.DisplayName,
ApiUrl: provider.APIUrl,
WebhookUrl: provider.WebhookUrl,
AccessToken: provider.AccessToken,
UserID: uuid.MustParse(provider.UserID),
}); err != nil {
if isUniqueConstraintError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "Provider already connected for this account"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Git provider"})
return
}
if ts, err := q.GetGitProviderTimestamps(context.Background(), uuid.MustParse(provider.ID)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read created provider"})
return
} else {
provider.CreatedAt = ts.CreatedAt.Time
provider.UpdatedAt = ts.UpdatedAt.Time
}
provider.AccessToken = ""
c.JSON(http.StatusCreated, provider)
}
func handleGetGitRepositories(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
providerID := strings.TrimSpace(c.Param("providerId"))
if _, err := uuid.Parse(providerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
q := sqlcdb.New(ctx.db.DB)
provRow, err := q.GetGitProviderForRepos(context.Background(), sqlcdb.GetGitProviderForReposParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providerName := provRow.Name
providerDisplayName := provRow.DisplayName
providerAccessToken := provRow.AccessToken
providerAPIURL := provRow.ApiUrl
if c.DefaultQuery("sync", "1") != "0" {
if syncErr := syncProviderRepositories(ctx.db, providerID, ctx.userID, providerName, providerAPIURL, providerAccessToken); syncErr != nil {
log.Printf("Git repository sync failed for provider %s (%s): %v", providerName, providerID, syncErr)
}
}
page := positiveIntOrDefault(c.DefaultQuery("page", "1"), 1)
limit := positiveIntOrDefault(c.DefaultQuery("limit", "20"), 20)
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
search := strings.TrimSpace(c.Query("search"))
repoRows, err := q.ListGitRepositories(context.Background(), sqlcdb.ListGitRepositoriesParams{
ProviderID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
Column3: search,
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories := make([]GitRepository, 0, len(repoRows))
for _, r := range repoRows {
repositories = append(repositories, gitRepoFrom(r))
}
total, err := q.CountGitRepositories(context.Background(), sqlcdb.CountGitRepositoriesParams{
ProviderID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
Column3: search,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, gin.H{
"provider": gin.H{
"id": providerID,
"name": providerName,
"display_name": providerDisplayName,
},
"repositories": repositories,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
},
})
}
func handleConnectGitRepository(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
var req CreateGitRepoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
providerID := strings.TrimSpace(req.ProviderID)
if _, err := uuid.Parse(providerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
q := sqlcdb.New(ctx.db.DB)
providerName, err := q.GetGitProviderName(context.Background(), sqlcdb.GetGitProviderNameParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repoName, fullName, err := normalizeRepositoryFullName(req.RepoFullName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
existingRow, err := q.GetGitRepositoryByFullName(context.Background(), sqlcdb.GetGitRepositoryByFullNameParams{
ProviderID: uuid.MustParse(providerID),
FullName: fullName,
UserID: uuid.MustParse(ctx.userID),
})
switch {
case err == nil:
c.JSON(http.StatusOK, gin.H{
"message": "Repository already connected",
"repository": gitRepoFromFullNameRow(existingRow),
})
return
case !errors.Is(err, sql.ErrNoRows):
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repo := GitRepository{
ID: uuid.NewString(),
ProviderID: providerID,
Name: repoName,
FullName: fullName,
Description: "",
CloneURL: deriveCloneURL(providerName, fullName),
DefaultBranch: "main",
IsPrivate: false,
UserID: ctx.userID,
}
ins, err := q.InsertGitRepository(context.Background(), sqlcdb.InsertGitRepositoryParams{
ID: uuid.MustParse(repo.ID),
ProviderID: uuid.MustParse(repo.ProviderID),
Name: repo.Name,
FullName: repo.FullName,
Description: sql.NullString{String: repo.Description, Valid: true},
CloneUrl: repo.CloneURL,
DefaultBranch: sql.NullString{String: repo.DefaultBranch, Valid: true},
IsPrivate: sql.NullBool{Bool: repo.IsPrivate, Valid: true},
UserID: uuid.MustParse(repo.UserID),
})
if err != nil {
if isUniqueConstraintError(err) {
c.JSON(http.StatusConflict, gin.H{"error": "Repository is already connected"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect repository"})
return
}
repo.CreatedAt = ins.CreatedAt.Time
repo.UpdatedAt = ins.UpdatedAt.Time
c.JSON(http.StatusCreated, gin.H{
"message": "Repository connected successfully",
"repository": repo,
})
}
func handleCreateWebhook(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
var req CreateWebhookRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Events) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "events cannot be empty"})
return
}
repoID := strings.TrimSpace(req.RepoID)
if _, err := uuid.Parse(repoID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository ID"})
return
}
providerUUID, err := sqlcdb.New(ctx.db.DB).GetGitRepositoryProviderID(context.Background(), sqlcdb.GetGitRepositoryProviderIDParams{
ID: uuid.MustParse(repoID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providerID := providerUUID.String()
eventsJSON, err := json.Marshal(req.Events)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid events payload"})
return
}
secret, err := generateWebhookSecret()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate webhook secret"})
return
}
webhook := GitWebhook{
ID: uuid.NewString(),
RepoID: repoID,
ProviderID: providerID,
Events: string(eventsJSON),
Secret: secret,
Active: true,
}
wrow, err := sqlcdb.New(ctx.db.DB).UpsertGitWebhook(context.Background(), sqlcdb.UpsertGitWebhookParams{
ID: uuid.MustParse(webhook.ID),
RepoID: uuid.MustParse(webhook.RepoID),
ProviderID: uuid.MustParse(webhook.ProviderID),
Events: webhook.Events,
WebhookSecret: webhook.Secret,
BranchFilter: sql.NullString{String: strings.TrimSpace(req.Branch), Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure webhook"})
return
}
webhook.ID = wrow.ID.String()
webhook.Active = wrow.Active.Bool
webhook.CreatedAt = wrow.CreatedAt.Time
webhook.UpdatedAt = wrow.UpdatedAt.Time
c.JSON(http.StatusCreated, gin.H{
"message": "Webhook configured successfully",
"webhook": gin.H{
"id": webhook.ID,
"repo_id": webhook.RepoID,
"provider_id": webhook.ProviderID,
"events": req.Events,
"branch": strings.TrimSpace(req.Branch),
"active": webhook.Active,
"created_at": webhook.CreatedAt,
"updated_at": webhook.UpdatedAt,
},
})
}
func handleGetConnectedRepositories(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
page := positiveIntOrDefault(c.DefaultQuery("page", "1"), 1)
limit := positiveIntOrDefault(c.DefaultQuery("limit", "10"), 10)
if limit > 100 {
limit = 100
}
offset := (page - 1) * limit
q := sqlcdb.New(ctx.db.DB)
repoRows, err := q.ListConnectedRepositories(context.Background(), sqlcdb.ListConnectedRepositoriesParams{
UserID: uuid.MustParse(ctx.userID),
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories := make([]map[string]interface{}, 0, len(repoRows))
for _, r := range repoRows {
repositories = append(repositories, map[string]interface{}{
"id": r.ID.String(),
"provider_id": r.ProviderID.String(),
"name": r.Name,
"full_name": r.FullName,
"description": r.Description,
"clone_url": r.CloneUrl,
"default_branch": r.DefaultBranch.String,
"is_private": r.IsPrivate.Bool,
"created_at": r.CreatedAt.Time,
"updated_at": r.UpdatedAt.Time,
"provider": map[string]string{
"name": r.ProviderName,
"display_name": r.DisplayName,
},
})
}
total, err := q.CountUserRepositories(context.Background(), uuid.MustParse(ctx.userID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, gin.H{
"repositories": repositories,
"pagination": gin.H{
"page": page,
"limit": limit,
"total": total,
},
})
}
type remoteGitRepository struct {
Name string
FullName string
Description string
CloneURL string
DefaultBranch string
IsPrivate bool
}
func syncProviderRepositories(db *database.DB, providerID, userID, providerName, providerAPIURL, accessToken string) error {
repositories, err := fetchProviderRepositories(providerName, providerAPIURL, accessToken)
if err != nil {
return err
}
for _, repository := range repositories {
if strings.TrimSpace(repository.FullName) == "" || strings.TrimSpace(repository.Name) == "" {
continue
}
cloneURL := strings.TrimSpace(repository.CloneURL)
if cloneURL == "" {
cloneURL = deriveCloneURL(providerName, repository.FullName)
}
defaultBranch := strings.TrimSpace(repository.DefaultBranch)
if defaultBranch == "" {
defaultBranch = "main"
}
err := sqlcdb.New(db.DB).UpsertGitRepository(context.Background(), sqlcdb.UpsertGitRepositoryParams{
ID: uuid.New(),
ProviderID: uuid.MustParse(providerID),
Name: repository.Name,
FullName: repository.FullName,
Description: sql.NullString{String: repository.Description, Valid: true},
CloneUrl: cloneURL,
DefaultBranch: sql.NullString{String: defaultBranch, Valid: true},
IsPrivate: sql.NullBool{Bool: repository.IsPrivate, Valid: true},
UserID: uuid.MustParse(userID),
})
if err != nil {
return err
}
}
return nil
}
func fetchProviderRepositories(providerName, providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
switch strings.ToLower(strings.TrimSpace(providerName)) {
case "github":
return fetchGitHubRepositories(providerAPIURL, accessToken)
case "github_app":
return fetchGitHubAppRepositories(providerAPIURL, accessToken)
case "gitlab":
return fetchGitLabRepositories(providerAPIURL, accessToken)
case "bitbucket":
return fetchBitbucketRepositories(providerAPIURL, accessToken)
case "gitea":
return fetchGiteaRepositories(providerAPIURL, accessToken)
default:
return nil, nil
}
}
func fetchGitHubRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("github access token is missing")
}
baseURL := strings.TrimSpace(providerAPIURL)
if baseURL == "" {
baseURL = "https://api.github.com"
}
baseURL = strings.TrimSuffix(baseURL, "/")
request, err := http.NewRequest(http.MethodGet, baseURL+"/user/repos?per_page=100&sort=updated", nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return nil, fmt.Errorf("github repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var rows []map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
return nil, err
}
result := make([]remoteGitRepository, 0, len(rows))
for _, row := range rows {
fullName := asGitString(row["full_name"])
name := asGitString(row["name"])
if fullName == "" || name == "" {
continue
}
result = append(result, remoteGitRepository{
Name: name,
FullName: fullName,
Description: asGitString(row["description"]),
CloneURL: asGitString(row["clone_url"]),
DefaultBranch: asGitString(row["default_branch"]),
IsPrivate: asBoolValue(row["private"]),
})
}
return result, nil
}
func fetchGitHubAppRepositories(providerAPIURL, installationIDRaw string) ([]remoteGitRepository, error) {
installationID, err := strconv.ParseInt(strings.TrimSpace(installationIDRaw), 10, 64)
if err != nil || installationID <= 0 {
return nil, fmt.Errorf("github_app installation id is invalid")
}
baseURL := strings.TrimSpace(providerAPIURL)
if baseURL == "" {
baseURL = strings.TrimSpace(getenvOrDefault("GITHUB_APP_BASE_URL", "https://api.github.com"))
}
baseURL = strings.TrimSuffix(baseURL, "/")
token, err := getGitHubAppInstallationToken(baseURL, installationID)
if err != nil {
return nil, err
}
request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/installation/repositories?per_page=100", baseURL), nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return nil, fmt.Errorf("github app repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var wrapper struct {
Repositories []map[string]interface{} `json:"repositories"`
}
if err := json.NewDecoder(response.Body).Decode(&wrapper); err != nil {
return nil, err
}
result := make([]remoteGitRepository, 0, len(wrapper.Repositories))
for _, row := range wrapper.Repositories {
fullName := asGitString(row["full_name"])
name := asGitString(row["name"])
if fullName == "" || name == "" {
continue
}
result = append(result, remoteGitRepository{
Name: name,
FullName: fullName,
Description: asGitString(row["description"]),
CloneURL: asGitString(row["clone_url"]),
DefaultBranch: asGitString(row["default_branch"]),
IsPrivate: asBoolValue(row["private"]),
})
}
return result, nil
}
func fetchGiteaRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("gitea access token is missing")
}
baseURL := strings.TrimSpace(providerAPIURL)
if baseURL == "" {
baseURL = strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "https://gitea.example.com"))
}
baseURL = strings.TrimSuffix(baseURL, "/")
request, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/user/repos?limit=100", nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "token "+strings.TrimSpace(accessToken))
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return nil, fmt.Errorf("gitea repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var rows []map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
return nil, err
}
result := make([]remoteGitRepository, 0, len(rows))
for _, row := range rows {
fullName := asGitString(row["full_name"])
name := asGitString(row["name"])
if fullName == "" || name == "" {
continue
}
result = append(result, remoteGitRepository{
Name: name,
FullName: fullName,
Description: asGitString(row["description"]),
CloneURL: asGitString(row["clone_url"]),
DefaultBranch: asGitString(row["default_branch"]),
IsPrivate: asBoolValue(row["private"]),
})
}
return result, nil
}
func fetchGitLabRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("gitlab access token is missing")
}
baseURL := strings.TrimSpace(providerAPIURL)
if baseURL == "" {
baseURL = strings.TrimSpace(getenvOrDefault("GITLAB_API_URL", "https://gitlab.com/api/v4"))
}
baseURL = strings.TrimSuffix(baseURL, "/")
request, err := http.NewRequest(http.MethodGet, baseURL+"/projects?membership=true&simple=true&per_page=100&order_by=last_activity_at&sort=desc", nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("PRIVATE-TOKEN", strings.TrimSpace(accessToken))
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return nil, fmt.Errorf("gitlab repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var rows []map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
return nil, err
}
result := make([]remoteGitRepository, 0, len(rows))
for _, row := range rows {
fullName := asGitString(row["path_with_namespace"])
name := asGitString(row["name"])
if fullName == "" || name == "" {
continue
}
visibility := strings.ToLower(asGitString(row["visibility"]))
isPrivate := visibility == "private"
if visibility == "" {
isPrivate = asBoolValue(row["private"])
}
result = append(result, remoteGitRepository{
Name: name,
FullName: fullName,
Description: asGitString(row["description"]),
CloneURL: asGitString(row["http_url_to_repo"]),
DefaultBranch: asGitString(row["default_branch"]),
IsPrivate: isPrivate,
})
}
return result, nil
}
func fetchBitbucketRepositories(providerAPIURL, accessToken string) ([]remoteGitRepository, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("bitbucket access token is missing")
}
baseURL := strings.TrimSpace(providerAPIURL)
if baseURL == "" {
baseURL = strings.TrimSpace(getenvOrDefault("BITBUCKET_API_URL", "https://api.bitbucket.org/2.0"))
}
baseURL = strings.TrimSuffix(baseURL, "/")
client := &http.Client{Timeout: 12 * time.Second}
nextURL := baseURL + "/repositories?role=member&pagelen=100"
maxPages := 5
result := make([]remoteGitRepository, 0, 128)
for page := 0; page < maxPages && nextURL != ""; page++ {
request, err := http.NewRequest(http.MethodGet, nextURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
response, err := client.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode != http.StatusOK {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
response.Body.Close()
return nil, fmt.Errorf("bitbucket repos request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var payload struct {
Values []map[string]interface{} `json:"values"`
Next string `json:"next"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
response.Body.Close()
return nil, err
}
response.Body.Close()
for _, row := range payload.Values {
fullName := asGitString(row["full_name"])
name := asGitString(row["name"])
if fullName == "" || name == "" {
continue
}
defaultBranch := "main"
if branch, ok := row["mainbranch"].(map[string]interface{}); ok {
if parsed := asGitString(branch["name"]); parsed != "" {
defaultBranch = parsed
}
}
cloneURL := ""
if links, ok := row["links"].(map[string]interface{}); ok {
if cloneLinks, ok := links["clone"].([]interface{}); ok {
for _, raw := range cloneLinks {
link, ok := raw.(map[string]interface{})
if !ok {
continue
}
if strings.EqualFold(asGitString(link["name"]), "https") {
cloneURL = asGitString(link["href"])
break
}
}
}
}
result = append(result, remoteGitRepository{
Name: name,
FullName: fullName,
Description: asGitString(row["description"]),
CloneURL: cloneURL,
DefaultBranch: defaultBranch,
IsPrivate: asBoolValue(row["is_private"]),
})
}
nextURL = strings.TrimSpace(payload.Next)
}
return result, nil
}
func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) {
appID := platformSetting(settingGitHubAppID, "GITHUB_APP_ID")
privateKeyPEM := platformSetting(settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY")
if appID == "" || privateKeyPEM == "" {
return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured")
}
jwtToken, err := createGitHubAppJWT(appID, privateKeyPEM)
if err != nil {
return "", err
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/app/installations/%d/access_tokens", strings.TrimSuffix(baseURL, "/"), installationID), nil)
if err != nil {
return "", err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("Authorization", "Bearer "+jwtToken)
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return "", fmt.Errorf("github app token request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var body struct {
Token string `json:"token"`
}
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
return "", err
}
if strings.TrimSpace(body.Token) == "" {
return "", fmt.Errorf("github app token response missing token")
}
return body.Token, nil
}
func createGitHubAppJWT(appID, privateKeyPEM string) (string, error) {
privateKey, err := parseGitHubAppPrivateKey(privateKeyPEM)
if err != nil {
return "", err
}
now := time.Now().UTC()
claims := jwt.RegisteredClaims{
Issuer: strings.TrimSpace(appID),
IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)),
ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
func parseGitHubAppPrivateKey(raw string) (*rsa.PrivateKey, error) {
normalized := strings.ReplaceAll(strings.TrimSpace(raw), "\\n", "\n")
block, _ := pem.Decode([]byte(normalized))
if block == nil {
return nil, fmt.Errorf("invalid github app private key format")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
privateKey, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("github app private key is not RSA")
}
return privateKey, nil
}
func asGitString(value interface{}) string {
if value == nil {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func asBoolValue(value interface{}) bool {
switch typed := value.(type) {
case bool:
return typed
case string:
parsed, _ := strconv.ParseBool(strings.TrimSpace(typed))
return parsed
default:
return false
}
}
func getenvOrDefault(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func validateGitToken(provider, token string) bool {
token = strings.TrimSpace(token)
if token == "" {
return false
}
if strings.EqualFold(strings.TrimSpace(provider), "github_app") {
installationID, err := strconv.ParseInt(token, 10, 64)
return err == nil && installationID > 0
}
return true
}
type gitRequestContext struct {
userID string
db *database.DB
}
func requireGitRequestContext(c *gin.Context) (*gitRequestContext, bool) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return nil, false
}
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database context not found"})
return nil, false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid database context"})
return nil, false
}
return &gitRequestContext{
userID: userID,
db: db,
}, true
}
func positiveIntOrDefault(raw string, fallback int) int {
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return fallback
}
return value
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "duplicate key") || strings.Contains(lower, "unique constraint")
}
func normalizeRepositoryFullName(input string) (name string, fullName string, err error) {
value := strings.TrimSpace(input)
if value == "" {
return "", "", fmt.Errorf("repo_full_name is required")
}
if parsed, parseErr := url.Parse(value); parseErr == nil && parsed.Scheme != "" && parsed.Host != "" {
path := strings.Trim(parsed.Path, "/")
if path != "" {
value = path
}
}
if strings.HasPrefix(strings.ToLower(value), "git@") {
if separator := strings.Index(value, ":"); separator != -1 && separator+1 < len(value) {
value = value[separator+1:]
}
}
value = strings.TrimSuffix(value, ".git")
parts := strings.Split(strings.Trim(value, "/"), "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("repo_full_name must be in owner/repository format")
}
if strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return "", "", fmt.Errorf("repo_full_name must be in owner/repository format")
}
for _, part := range parts {
if strings.ContainsAny(part, " \t\r\n") {
return "", "", fmt.Errorf("repo_full_name cannot include whitespace")
}
}
return parts[1], parts[0] + "/" + parts[1], nil
}
func deriveCloneURL(provider, repoFullName string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "github":
return fmt.Sprintf("https://github.com/%s.git", repoFullName)
case "github_app":
return fmt.Sprintf("https://github.com/%s.git", repoFullName)
case "gitlab":
baseURL := strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITLAB_BASE_URL", "https://gitlab.com")), "/")
return fmt.Sprintf("%s/%s.git", baseURL, repoFullName)
case "bitbucket":
baseURL := strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("BITBUCKET_BASE_URL", "https://bitbucket.org")), "/")
return fmt.Sprintf("%s/%s.git", baseURL, repoFullName)
case "gitea":
baseURL := strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "https://gitea.example.com")), "/")
return fmt.Sprintf("%s/%s.git", baseURL, repoFullName)
default:
return repoFullName
}
}
func generateWebhookSecret() (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// handleDeleteGitProvider removes a provider connection. Repositories and
// webhooks are removed by ON DELETE CASCADE.
func handleDeleteGitProvider(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
providerID := strings.TrimSpace(c.Param("providerId"))
if _, err := uuid.Parse(providerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
affected, err := sqlcdb.New(ctx.db.DB).DeleteGitProvider(context.Background(), sqlcdb.DeleteGitProviderParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"})
}
// handleGetGitRepositoryBranches lists branches for a repository through the
// provider's stored credentials.
func handleGetGitRepositoryBranches(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
providerID := strings.TrimSpace(c.Param("providerId"))
if _, err := uuid.Parse(providerID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
owner := strings.TrimSpace(c.Param("owner"))
repo := strings.TrimSpace(c.Param("repo"))
if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"})
return
}
pf, err := sqlcdb.New(ctx.db.DB).GetGitProviderForFetch(context.Background(), sqlcdb.GetGitProviderForFetchParams{
ID: uuid.MustParse(providerID),
UserID: uuid.MustParse(ctx.userID),
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providerName := pf.Name
providerAPIURL := pf.ApiUrl
providerAccessToken := pf.AccessToken
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
if err != nil {
log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"})
return
}
c.JSON(http.StatusOK, gin.H{"branches": branches})
}
type remoteBranch struct {
Name string `json:"name"`
Protected bool `json:"protected"`
}
func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
switch strings.ToLower(strings.TrimSpace(providerName)) {
case "github", "github_app":
return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo)
case "gitea":
return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo)
default:
return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName)
}
}
func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
if baseURL == "" {
baseURL = "https://api.github.com"
}
token := strings.TrimSpace(accessToken)
if strings.EqualFold(providerName, "github_app") {
installationID, err := strconv.ParseInt(token, 10, 64)
if err != nil || installationID <= 0 {
return nil, fmt.Errorf("github_app installation id is invalid")
}
token, err = getGitHubAppInstallationToken(baseURL, installationID)
if err != nil {
return nil, err
}
}
if token == "" {
return nil, fmt.Errorf("github access token is missing")
}
branches := make([]remoteBranch, 0)
for page := 1; page <= 5; page++ {
request, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode != http.StatusOK {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
response.Body.Close()
return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var rows []map[string]interface{}
decodeErr := json.NewDecoder(response.Body).Decode(&rows)
response.Body.Close()
if decodeErr != nil {
return nil, decodeErr
}
for _, row := range rows {
name := asGitString(row["name"])
if name == "" {
continue
}
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
}
if len(rows) < 100 {
break
}
}
return branches, nil
}
func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
if baseURL == "" {
baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/")
}
if baseURL == "" {
return nil, fmt.Errorf("gitea api_url is required")
}
request, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
if token := strings.TrimSpace(accessToken); token != "" {
request.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: 10 * time.Second}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
}
var rows []map[string]interface{}
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
return nil, err
}
branches := make([]remoteBranch, 0, len(rows))
for _, row := range rows {
if name := asGitString(row["name"]); name != "" {
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
}
}
return branches, nil
}