Skip to content
1 change: 0 additions & 1 deletion backend/.golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,5 @@ linters:
- zerologlint
- gocritic
- bodyclose
- goconst
Comment thread
timokoessler marked this conversation as resolved.
- modernize
- unparam
2 changes: 2 additions & 0 deletions backend/internal/hub/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func RegisterRoutes(router *gin.Engine, cfg Config) error {
protected.DELETE("/repositories/:id", routes.DeleteRepositoryHandler)
protected.POST("/repositories/test-connection", routes.TestConnectionHandler)
protected.PUT("/repositories/:id", routes.UpdateRepositoryHandler)
protected.GET("/repositories/:id/branches", routes.ListRepositoryBranchesHandler)
protected.GET("/repositories/:id/tree", routes.ListRepositoryTreeHandler)

protected.GET("/agents", routes.ListAgentsHandler)
protected.POST("/agents", routes.CreateAgentHandler)
Expand Down
237 changes: 214 additions & 23 deletions backend/internal/hub/repositories/gitea.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package repositories

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"

"github.com/OrcaCD/orca-cd/internal/hub/models"
Expand All @@ -14,7 +16,22 @@ import (

type giteaProvider struct{}

const httpsScheme = "https"
type parsedGiteaRepositoryURL struct {
baseURL string
owner string
repo string
}

type giteaBranch struct {
Name string `json:"name"`
}

type giteaTreeResponse struct {
Tree []struct {
Path string `json:"path"`
Type string `json:"type"`
} `json:"tree"`
}

func init() {
Register(models.Gitea, giteaProvider{})
Expand All @@ -23,15 +40,24 @@ func init() {
// parseGiteaURL validates a Gitea repository URL (including self-hosted instances)
// and returns the owner and repository name.
func parseGiteaURL(rawURL string) (owner, repo string, err error) {
parsedRepoURL, err := parseGiteaRepositoryURL(rawURL)
if err != nil {
return "", "", err
}

return parsedRepoURL.owner, parsedRepoURL.repo, nil
}

func parseGiteaRepositoryURL(rawURL string) (parsedGiteaRepositoryURL, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", "", errors.New("invalid URL")
return parsedGiteaRepositoryURL{}, errors.New("invalid URL")
}
if u.Scheme != httpsScheme {
return "", "", fmt.Errorf("URL must use %s", httpsScheme)
return parsedGiteaRepositoryURL{}, fmt.Errorf("URL must use %s", httpsScheme)
}
if u.Host == "" {
return "", "", errors.New("URL must have a valid host")
return parsedGiteaRepositoryURL{}, errors.New("URL must have a valid host")
}

// Allow URLs ending with .git
Expand All @@ -40,18 +66,22 @@ func parseGiteaURL(rawURL string) (owner, repo string, err error) {

parts := strings.SplitN(path, "/", 3)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("URL must be in the format %s://{host}/{owner}/{repo}", httpsScheme)
return parsedGiteaRepositoryURL{}, fmt.Errorf("URL must be in the format %s://{host}/{owner}/{repo}", httpsScheme)
}

owner, repo = parts[0], parts[1]
owner, repo := parts[0], parts[1]
if !ownerRe.MatchString(owner) {
return "", "", errors.New("invalid Gitea owner name")
return parsedGiteaRepositoryURL{}, errors.New("invalid Gitea owner name")
}
if !repoRe.MatchString(repo) {
return "", "", errors.New("invalid Gitea repository name")
return parsedGiteaRepositoryURL{}, errors.New("invalid Gitea repository name")
}

return owner, repo, nil
return parsedGiteaRepositoryURL{
baseURL: fmt.Sprintf("%s://%s", u.Scheme, u.Host),
owner: owner,
repo: repo,
}, nil
}

func (giteaProvider) ParseURL(rawURL string) (string, string, error) {
Expand All @@ -70,28 +100,22 @@ func (giteaProvider) TestConnection(ctx context.Context, repo *models.Repository
return errors.New("repository is required")
}

owner, repoName, err := parseGiteaURL(repo.Url)
parsedRepoURL, err := parseGiteaRepositoryURL(repo.Url)
if err != nil {
return fmt.Errorf("invalid repository URL: %w", err)
}

u, _ := url.Parse(repo.Url)
baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)

apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", baseURL, url.PathEscape(owner), url.PathEscape(repoName))
apiURL := fmt.Sprintf(
"%s/api/v1/repos/%s/%s",
parsedRepoURL.baseURL,
url.PathEscape(parsedRepoURL.owner),
url.PathEscape(parsedRepoURL.repo),
)
req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return fmt.Errorf("failed to build Gitea request: %w", err)
}

req.Header.Set("Accept", "application/json")

if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil {
token := strings.TrimSpace(repo.AuthToken.String())
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
}
addGiteaHeaders(req, repo)

resp, err := httpclient.Default.Do(req)
if err != nil {
Expand All @@ -115,3 +139,170 @@ func (giteaProvider) TestConnection(ctx context.Context, repo *models.Repository
return fmt.Errorf("gitea API returned unexpected status: %d", resp.StatusCode)
}
}

func (giteaProvider) ListBranches(ctx context.Context, repo *models.Repository) ([]string, error) {
if repo == nil {
return nil, errors.New("repository is required")
}

parsedRepoURL, err := parseGiteaRepositoryURL(repo.Url)
if err != nil {
return nil, fmt.Errorf("invalid repository URL: %w", err)
}

branches := make([]string, 0)

for page := 1; ; page++ {
apiURL := fmt.Sprintf(
"%s/api/v1/repos/%s/%s/branches?page=%d&limit=%d",
parsedRepoURL.baseURL,
url.PathEscape(parsedRepoURL.owner),
url.PathEscape(parsedRepoURL.repo),
page,
providerPageSize,
)

req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build Gitea request: %w", err)
}
addGiteaHeaders(req, repo)

resp, err := httpclient.Default.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch Gitea branches: %w", err)
}

switch resp.StatusCode {
case http.StatusOK:
var parsed []giteaBranch
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
closeErr := resp.Body.Close()
if closeErr != nil {
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
}
return nil, fmt.Errorf("failed to decode Gitea branches response: %w", err)
}

closeErr := resp.Body.Close()
if closeErr != nil {
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
}
Comment thread
alex289 marked this conversation as resolved.

for _, branch := range parsed {
if branch.Name != "" {
branches = append(branches, branch.Name)
}
}

if len(parsed) < providerPageSize {
sortBranches(branches)
return branches, nil
}
case http.StatusUnauthorized, http.StatusForbidden:
closeErr := resp.Body.Close()
if closeErr != nil {
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
}
return nil, errors.New("authentication failed or access denied")
case http.StatusNotFound:
closeErr := resp.Body.Close()
if closeErr != nil {
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
}
return nil, errors.New("repository not found or access denied")
default:
statusCode := resp.StatusCode
closeErr := resp.Body.Close()
if closeErr != nil {
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
}
return nil, fmt.Errorf("gitea API returned unexpected status: %d", statusCode)
}
}
}

func (giteaProvider) ListTree(ctx context.Context, repo *models.Repository, branch string) ([]TreeEntry, error) {
if repo == nil {
return nil, errors.New("repository is required")
}

if strings.TrimSpace(branch) == "" {
return nil, errors.New("branch is required")
}

parsedRepoURL, err := parseGiteaRepositoryURL(repo.Url)
if err != nil {
return nil, fmt.Errorf("invalid repository URL: %w", err)
}

apiURL := fmt.Sprintf(
"%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
parsedRepoURL.baseURL,
url.PathEscape(parsedRepoURL.owner),
url.PathEscape(parsedRepoURL.repo),
url.PathEscape(branch),
)

req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to build Gitea request: %w", err)
}
addGiteaHeaders(req, repo)

resp, err := httpclient.Default.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch Gitea repository tree: %w", err)
}
defer func() {
err := resp.Body.Close()
if err != nil {
fmt.Printf("warning: failed to close Gitea response body: %v\n", err)
}
}()

switch resp.StatusCode {
case http.StatusOK:
var parsed giteaTreeResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, fmt.Errorf("failed to decode Gitea tree response: %w", err)
}

entries := make([]TreeEntry, 0, len(parsed.Tree))
for _, entry := range parsed.Tree {
if entry.Path == "" {
continue
}

switch entry.Type {
case "blob":
entries = append(entries, TreeEntry{Path: entry.Path, Type: TreeEntryTypeFile})
case "tree":
entries = append(entries, TreeEntry{Path: entry.Path, Type: TreeEntryTypeDir})
}
}

sort.Slice(entries, func(i, j int) bool {
return entries[i].Path < entries[j].Path
})

return entries, nil
case http.StatusUnauthorized, http.StatusForbidden:
return nil, errors.New("authentication failed or access denied")
case http.StatusNotFound:
return nil, errors.New("repository not found or access denied")
default:
return nil, fmt.Errorf("gitea API returned unexpected status: %d", resp.StatusCode)
}
}

func addGiteaHeaders(req *http.Request, repo *models.Repository) {
req.Header.Set("Accept", "application/json")

if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil {
token := strings.TrimSpace(repo.AuthToken.String())
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
}
}
Loading
Loading