Files
Containr/app/backend/internal/api/git.go
T
2026-04-10 12:02:36 +02:00

1309 lines
39 KiB
Go

package api
import (
"containr/internal/database"
"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 handleGetGitProviders(c *gin.Context) {
ctx, ok := requireGitRequestContext(c)
if !ok {
return
}
rows, err := ctx.db.Query(`
SELECT id, name, display_name, api_url, webhook_url, user_id, created_at, updated_at
FROM git_providers
WHERE user_id = $1
ORDER BY created_at DESC
`, ctx.userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
providers := make([]GitProvider, 0)
for rows.Next() {
var provider GitProvider
if err := rows.Scan(
&provider.ID,
&provider.Name,
&provider.DisplayName,
&provider.APIUrl,
&provider.WebhookUrl,
&provider.UserID,
&provider.CreatedAt,
&provider.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
providers = append(providers, provider)
}
c.JSON(http.StatusOK, gin.H{"providers": providers})
}
func handleGetGitHubAppInstallURL(c *gin.Context) {
slug := strings.TrimSpace(os.Getenv("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,
}
if err := ctx.db.QueryRow(`
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
ON CONFLICT (name, user_id)
DO UPDATE SET
display_name = EXCLUDED.display_name,
api_url = EXCLUDED.api_url,
webhook_url = EXCLUDED.webhook_url,
access_token = EXCLUDED.access_token,
updated_at = NOW()
RETURNING id, created_at, updated_at
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, provider.UserID).Scan(&provider.ID, &provider.CreatedAt, &provider.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to connect GitHub App installation"})
return
}
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
}
if _, err := ctx.db.Exec(`
INSERT INTO git_providers (id, name, display_name, api_url, webhook_url, access_token, user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
`, provider.ID, provider.Name, provider.DisplayName, provider.APIUrl, provider.WebhookUrl, provider.AccessToken, 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 err := ctx.db.QueryRow(`
SELECT created_at, updated_at
FROM git_providers
WHERE id = $1
`, provider.ID).Scan(&provider.CreatedAt, &provider.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read created provider"})
return
}
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
}
var providerName, providerDisplayName, providerAccessToken, providerAPIURL string
err := ctx.db.QueryRow(`
SELECT name, display_name, access_token, api_url
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, ctx.userID).Scan(&providerName, &providerDisplayName, &providerAccessToken, &providerAPIURL)
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
}
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"))
searchNullable := sql.NullString{String: search, Valid: search != ""}
rows, err := ctx.db.Query(`
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
ORDER BY updated_at DESC
LIMIT $4 OFFSET $5
`, providerID, ctx.userID, searchNullable, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
repositories := make([]GitRepository, 0)
for rows.Next() {
var repo GitRepository
if err := rows.Scan(
&repo.ID,
&repo.ProviderID,
&repo.Name,
&repo.FullName,
&repo.Description,
&repo.CloneURL,
&repo.WebhookURL,
&repo.DefaultBranch,
&repo.IsPrivate,
&repo.UserID,
&repo.CreatedAt,
&repo.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories = append(repositories, repo)
}
var total int
if err := ctx.db.QueryRow(`
SELECT COUNT(*)
FROM git_repositories
WHERE provider_id = $1 AND user_id = $2
AND ($3::text IS NULL OR full_name ILIKE ('%' || $3::text || '%') OR name ILIKE ('%' || $3::text || '%'))
`, providerID, ctx.userID, searchNullable).Scan(&total); 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
}
var providerName string
err := ctx.db.QueryRow(`
SELECT name
FROM git_providers
WHERE id = $1 AND user_id = $2
`, providerID, ctx.userID).Scan(&providerName)
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
}
var existing GitRepository
err = ctx.db.QueryRow(`
SELECT id, provider_id, name, full_name, COALESCE(description, ''), clone_url, COALESCE(webhook_url, ''),
default_branch, is_private, user_id, created_at, updated_at
FROM git_repositories
WHERE provider_id = $1 AND full_name = $2 AND user_id = $3
`, providerID, fullName, ctx.userID).Scan(
&existing.ID,
&existing.ProviderID,
&existing.Name,
&existing.FullName,
&existing.Description,
&existing.CloneURL,
&existing.WebhookURL,
&existing.DefaultBranch,
&existing.IsPrivate,
&existing.UserID,
&existing.CreatedAt,
&existing.UpdatedAt,
)
switch {
case err == nil:
c.JSON(http.StatusOK, gin.H{
"message": "Repository already connected",
"repository": existing,
})
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,
}
if err := ctx.db.QueryRow(`
INSERT INTO git_repositories (
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING created_at, updated_at
`, repo.ID, repo.ProviderID, repo.Name, repo.FullName, repo.Description, repo.CloneURL, repo.DefaultBranch, repo.IsPrivate, repo.UserID).Scan(&repo.CreatedAt, &repo.UpdatedAt); 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
}
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
}
var providerID string
err := ctx.db.QueryRow(`
SELECT provider_id
FROM git_repositories
WHERE id = $1 AND user_id = $2
`, repoID, ctx.userID).Scan(&providerID)
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
}
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,
}
if err := ctx.db.QueryRow(`
INSERT INTO git_webhooks (
id, repo_id, provider_id, events, webhook_secret, active, branch_filter, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, TRUE, $6, NOW(), NOW())
ON CONFLICT (repo_id, provider_id)
DO UPDATE SET
events = EXCLUDED.events,
webhook_secret = EXCLUDED.webhook_secret,
active = TRUE,
branch_filter = EXCLUDED.branch_filter,
updated_at = NOW()
RETURNING id, active, created_at, updated_at
`, webhook.ID, webhook.RepoID, webhook.ProviderID, webhook.Events, webhook.Secret, strings.TrimSpace(req.Branch)).Scan(&webhook.ID, &webhook.Active, &webhook.CreatedAt, &webhook.UpdatedAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure webhook"})
return
}
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
rows, err := ctx.db.Query(`
SELECT r.id, r.provider_id, r.name, r.full_name, COALESCE(r.description, ''), r.clone_url,
r.default_branch, r.is_private, r.user_id, r.created_at, r.updated_at,
p.name as provider_name, p.display_name
FROM git_repositories r
JOIN git_providers p ON r.provider_id = p.id
WHERE r.user_id = $1
ORDER BY r.updated_at DESC
LIMIT $2 OFFSET $3
`, ctx.userID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
defer rows.Close()
repositories := make([]map[string]interface{}, 0)
for rows.Next() {
var repo GitRepository
var providerName, providerDisplayName string
if err := rows.Scan(
&repo.ID,
&repo.ProviderID,
&repo.Name,
&repo.FullName,
&repo.Description,
&repo.CloneURL,
&repo.DefaultBranch,
&repo.IsPrivate,
&repo.UserID,
&repo.CreatedAt,
&repo.UpdatedAt,
&providerName,
&providerDisplayName,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
repositories = append(repositories, map[string]interface{}{
"id": repo.ID,
"provider_id": repo.ProviderID,
"name": repo.Name,
"full_name": repo.FullName,
"description": repo.Description,
"clone_url": repo.CloneURL,
"default_branch": repo.DefaultBranch,
"is_private": repo.IsPrivate,
"created_at": repo.CreatedAt,
"updated_at": repo.UpdatedAt,
"provider": map[string]string{
"name": providerName,
"display_name": providerDisplayName,
},
})
}
var total int
if err := ctx.db.QueryRow(`
SELECT COUNT(*)
FROM git_repositories
WHERE user_id = $1
`, ctx.userID).Scan(&total); 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 := db.Exec(`
INSERT INTO git_repositories (
id, provider_id, name, full_name, description, clone_url, default_branch, is_private, user_id, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
ON CONFLICT (provider_id, full_name)
DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
clone_url = EXCLUDED.clone_url,
default_branch = EXCLUDED.default_branch,
is_private = EXCLUDED.is_private,
updated_at = NOW()
`, uuid.NewString(), providerID, repository.Name, repository.FullName, repository.Description, cloneURL, defaultBranch, repository.IsPrivate, 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 := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
privateKeyPEM := strings.TrimSpace(os.Getenv("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
}