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
197 changes: 188 additions & 9 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,6 +16,17 @@ import (

type giteaProvider struct{}

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

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

const httpsScheme = "https"

func init() {
Expand Down Expand Up @@ -83,15 +96,7 @@ func (giteaProvider) TestConnection(ctx context.Context, repo *models.Repository
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 +120,177 @@ 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")
}

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

u, _ := url.Parse(repo.Url)
baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
Comment thread
alex289 marked this conversation as resolved.
Outdated

branches := make([]string, 0)
limit := 100
Comment thread
alex289 marked this conversation as resolved.
Outdated

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

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) < limit {
sort.Strings(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")
}

owner, repoName, err := parseGiteaURL(repo.Url)
if err != nil {
return nil, 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/git/trees/%s?recursive=true",
baseURL,
url.PathEscape(owner),
url.PathEscape(repoName),
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)
}
}
}
84 changes: 84 additions & 0 deletions backend/internal/hub/repositories/gitea_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,87 @@ func TestGiteaTestConnection(t *testing.T) {
}
})
}

func TestGiteaListBranches(t *testing.T) {
p := giteaProvider{}
originalClient := httpclient.Default
t.Cleanup(func() {
httpclient.Default = originalClient
})

httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) {
expectedURL := "https://gitea.com/api/v1/repos/OrcaCD/orca-cd/branches?page=1&limit=100"
if req.URL.String() != expectedURL {
t.Fatalf("unexpected URL: %s", req.URL.String())
}

if req.Header.Get(testGiteaTokenHeaderName) != "token secret-token" {
t.Fatalf("unexpected authorization header: %q", req.Header.Get(testGiteaTokenHeaderName))
}

return jsonResponseWithBody(http.StatusOK, `[{"name":"release"},{"name":"main"}]`), nil
})

token := crypto.EncryptedString("secret-token")
repo := &models.Repository{
Url: testGiteaRepoURL,
AuthMethod: models.AuthMethodToken,
AuthToken: &token,
}

branches, err := p.ListBranches(context.Background(), repo)
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}

if len(branches) != 2 {
t.Fatalf("expected 2 branches, got %d", len(branches))
}
if branches[0] != "main" || branches[1] != "release" {
t.Fatalf("expected sorted branches [main release], got %v", branches)
}
}

func TestGiteaListTree(t *testing.T) {
p := giteaProvider{}
originalClient := httpclient.Default
t.Cleanup(func() {
httpclient.Default = originalClient
})

httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) {
expectedURL := "https://gitea.com/api/v1/repos/OrcaCD/orca-cd/git/trees/release%2Fprod?recursive=true"
if req.URL.String() != expectedURL {
t.Fatalf("unexpected URL: %s", req.URL.String())
}

return jsonResponseWithBody(http.StatusOK, `{
"tree": [
{"path":"docker-compose.yml","type":"blob"},
{"path":"services","type":"tree"}
]
}`), nil
})

repo := &models.Repository{Url: testGiteaRepoURL, AuthMethod: models.AuthMethodNone}
tree, err := p.ListTree(context.Background(), repo, "release/prod")
if err != nil {
t.Fatalf("expected success, got error: %v", err)
}

if len(tree) != 2 {
t.Fatalf("expected 2 entries, got %d", len(tree))
}

byPath := map[string]TreeEntryType{}
for _, entry := range tree {
byPath[entry.Path] = entry.Type
}

if byPath["services"] != TreeEntryTypeDir {
t.Fatalf("expected services to be dir, got %q", byPath["services"])
}
if byPath["docker-compose.yml"] != TreeEntryTypeFile {
t.Fatalf("expected docker-compose.yml to be file, got %q", byPath["docker-compose.yml"])
}
}
Loading
Loading