From bd959a118282a9b7af846f6fd5908d7d60cee9ba Mon Sep 17 00:00:00 2001 From: alex289 Date: Mon, 20 Apr 2026 13:38:39 +0200 Subject: [PATCH 01/10] feat: Add endpoints fo branch and tree --- backend/internal/hub/handlers.go | 2 + backend/internal/hub/repositories/gitea.go | 197 ++++++++++++++- .../internal/hub/repositories/gitea_test.go | 84 +++++++ backend/internal/hub/repositories/github.go | 158 +++++++++++- .../internal/hub/repositories/github_test.go | 92 ++++++- backend/internal/hub/repositories/gitlab.go | 236 +++++++++++++++++- .../internal/hub/repositories/gitlab_test.go | 87 +++++++ backend/internal/hub/repositories/provider.go | 18 +- .../hub/repositories/provider_test.go | 16 ++ backend/internal/hub/routes/repositories.go | 60 +++++ .../internal/hub/routes/repositories_test.go | 150 ++++++++++- 11 files changed, 1072 insertions(+), 28 deletions(-) diff --git a/backend/internal/hub/handlers.go b/backend/internal/hub/handlers.go index 2d8009eb..4175a263 100644 --- a/backend/internal/hub/handlers.go +++ b/backend/internal/hub/handlers.go @@ -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) diff --git a/backend/internal/hub/repositories/gitea.go b/backend/internal/hub/repositories/gitea.go index 5c3a3f77..c8b260c8 100644 --- a/backend/internal/hub/repositories/gitea.go +++ b/backend/internal/hub/repositories/gitea.go @@ -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" @@ -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() { @@ -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 { @@ -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) + + branches := make([]string, 0) + limit := 100 + + 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) + } + + 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) + } + } +} diff --git a/backend/internal/hub/repositories/gitea_test.go b/backend/internal/hub/repositories/gitea_test.go index c952e1b8..dc66b645 100644 --- a/backend/internal/hub/repositories/gitea_test.go +++ b/backend/internal/hub/repositories/gitea_test.go @@ -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"]) + } +} diff --git a/backend/internal/hub/repositories/github.go b/backend/internal/hub/repositories/github.go index e305bdbd..3501e39b 100644 --- a/backend/internal/hub/repositories/github.go +++ b/backend/internal/hub/repositories/github.go @@ -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" @@ -16,6 +18,17 @@ const githubAPIBase = "https://api.github.com" type githubProvider struct{} +type githubBranch struct { + Name string `json:"name"` +} + +type githubTreeResponse struct { + Tree []struct { + Path string `json:"path"` + Type string `json:"type"` + } `json:"tree"` +} + func init() { Register(models.GitHub, githubProvider{}) } @@ -79,14 +92,7 @@ func (githubProvider) TestConnection(ctx context.Context, repo *models.Repositor if err != nil { return fmt.Errorf("failed to build GitHub request: %w", err) } - req.Header.Set("Accept", "application/vnd.github+json") - - if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil { - token := strings.TrimSpace(repo.AuthToken.String()) - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - } + addGitHubHeaders(req, repo) resp, err := httpclient.Default.Do(req) if err != nil { @@ -110,3 +116,139 @@ func (githubProvider) TestConnection(ctx context.Context, repo *models.Repositor return fmt.Errorf("GitHub API returned unexpected status: %d", resp.StatusCode) } } + +func (githubProvider) ListBranches(ctx context.Context, repo *models.Repository) ([]string, error) { + if repo == nil { + return nil, errors.New("repository is required") + } + + owner, repoName, err := parseGitHubURL(repo.Url) + if err != nil { + return nil, fmt.Errorf("invalid repository URL: %w", err) + } + + apiURL := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100", githubAPIBase, owner, repoName) + req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build GitHub request: %w", err) + } + addGitHubHeaders(req, repo) + + resp, err := httpclient.Default.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch GitHub branches: %w", err) + } + defer func() { + err := resp.Body.Close() + if err != nil { + fmt.Printf("warning: failed to close GitHub response body: %v\n", err) + } + }() + + switch resp.StatusCode { + case http.StatusOK: + var parsed []githubBranch + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("failed to decode GitHub branches response: %w", err) + } + + branches := make([]string, 0, len(parsed)) + for _, branch := range parsed { + if branch.Name != "" { + branches = append(branches, branch.Name) + } + } + sort.Strings(branches) + return branches, 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("GitHub API returned unexpected status: %d", resp.StatusCode) + } +} + +func (githubProvider) 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 := parseGitHubURL(repo.Url) + if err != nil { + return nil, fmt.Errorf("invalid repository URL: %w", err) + } + + apiURL := fmt.Sprintf( + "%s/repos/%s/%s/git/trees/%s?recursive=1", + githubAPIBase, + owner, + repoName, + url.PathEscape(branch), + ) + req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build GitHub request: %w", err) + } + addGitHubHeaders(req, repo) + + resp, err := httpclient.Default.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch GitHub repository tree: %w", err) + } + defer func() { + err := resp.Body.Close() + if err != nil { + fmt.Printf("warning: failed to close GitHub response body: %v\n", err) + } + }() + + switch resp.StatusCode { + case http.StatusOK: + var parsed githubTreeResponse + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, fmt.Errorf("failed to decode GitHub 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("GitHub API returned unexpected status: %d", resp.StatusCode) + } +} + +func addGitHubHeaders(req *http.Request, repo *models.Repository) { + req.Header.Set("Accept", "application/vnd.github+json") + + if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil { + token := strings.TrimSpace(repo.AuthToken.String()) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + } +} diff --git a/backend/internal/hub/repositories/github_test.go b/backend/internal/hub/repositories/github_test.go index eb39da2b..c53a99e1 100644 --- a/backend/internal/hub/repositories/github_test.go +++ b/backend/internal/hub/repositories/github_test.go @@ -28,9 +28,13 @@ func mockClient(fn roundTripFunc) *http.Client { } func jsonResponse(code int) *http.Response { + return jsonResponseWithBody(code, "{}") +} + +func jsonResponseWithBody(code int, body string) *http.Response { return &http.Response{ StatusCode: code, - Body: io.NopCloser(strings.NewReader("{}")), + Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), } } @@ -149,3 +153,89 @@ func TestGitHubTestConnection(t *testing.T) { } }) } + +func TestGitHubListBranches(t *testing.T) { + p := githubProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + expectedURL := githubAPIBase + "/repos/OrcaCD/orca-cd/branches?per_page=100" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected URL: %s", req.URL.String()) + } + if req.Header.Get("Authorization") != "Bearer secret-token" { + t.Fatalf("unexpected authorization header: %q", req.Header.Get("Authorization")) + } + + return jsonResponseWithBody(http.StatusOK, `[{"name":"release"},{"name":"main"}]`), nil + }) + + token := crypto.EncryptedString("secret-token") + repo := &models.Repository{ + Url: testRepoURL, + 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 TestGitHubListTree(t *testing.T) { + p := githubProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + expectedURL := githubAPIBase + "/repos/OrcaCD/orca-cd/git/trees/feature%2Fprod?recursive=1" + 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"}, + {"path":"README.md","type":"blob"} + ] + }`), nil + }) + + repo := &models.Repository{Url: testRepoURL, AuthMethod: models.AuthMethodNone} + + tree, err := p.ListTree(context.Background(), repo, "feature/prod") + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + if len(tree) != 3 { + t.Fatalf("expected 3 entries, got %d", len(tree)) + } + + byPath := map[string]TreeEntryType{} + for _, entry := range tree { + byPath[entry.Path] = entry.Type + } + + if byPath["docker-compose.yml"] != TreeEntryTypeFile { + t.Fatalf("expected docker-compose.yml to be file, got %q", byPath["docker-compose.yml"]) + } + if byPath["services"] != TreeEntryTypeDir { + t.Fatalf("expected services to be dir, got %q", byPath["services"]) + } +} diff --git a/backend/internal/hub/repositories/gitlab.go b/backend/internal/hub/repositories/gitlab.go index f5b4673e..8811a1cb 100644 --- a/backend/internal/hub/repositories/gitlab.go +++ b/backend/internal/hub/repositories/gitlab.go @@ -2,10 +2,13 @@ package repositories import ( "context" + "encoding/json" "errors" "fmt" "net/http" "net/url" + "sort" + "strconv" "strings" "github.com/OrcaCD/orca-cd/internal/hub/models" @@ -14,6 +17,15 @@ import ( type gitlabProvider struct{} +type gitlabBranch struct { + Name string `json:"name"` +} + +type gitlabTreeEntry struct { + Path string `json:"path"` + Type string `json:"type"` +} + func init() { Register(models.GitLab, gitlabProvider{}) } @@ -91,13 +103,7 @@ func (gitlabProvider) TestConnection(ctx context.Context, repo *models.Repositor if err != nil { return fmt.Errorf("failed to build GitLab request: %w", err) } - - if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil { - token := strings.TrimSpace(repo.AuthToken.String()) - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - } + addGitLabAuthHeader(req, repo) resp, err := httpclient.Default.Do(req) if err != nil { @@ -121,3 +127,219 @@ func (gitlabProvider) TestConnection(ctx context.Context, repo *models.Repositor return fmt.Errorf("GitLab API returned unexpected status: %d", resp.StatusCode) } } + +func (gitlabProvider) ListBranches(ctx context.Context, repo *models.Repository) ([]string, error) { + if repo == nil { + return nil, errors.New("repository is required") + } + + namespace, project, err := parseGitLabURL(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) + projectPath := url.PathEscape(namespace + "/" + project) + + branches := make([]string, 0) + page := 1 + + for { + apiURL := fmt.Sprintf( + "%s/api/v4/projects/%s/repository/branches?per_page=100&page=%d", + baseURL, + projectPath, + page, + ) + + req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build GitLab request: %w", err) + } + addGitLabAuthHeader(req, repo) + + resp, err := httpclient.Default.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch GitLab branches: %w", err) + } + + nextPage := strings.TrimSpace(resp.Header.Get("X-Next-Page")) + + switch resp.StatusCode { + case http.StatusOK: + var parsed []gitlabBranch + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitLab response body: %v\n", closeErr) + } + return nil, fmt.Errorf("failed to decode GitLab branches response: %w", err) + } + + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitLab response body: %v\n", closeErr) + } + + for _, branch := range parsed { + if branch.Name != "" { + branches = append(branches, branch.Name) + } + } + case http.StatusUnauthorized, http.StatusForbidden: + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitLab 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 GitLab 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 GitLab response body: %v\n", closeErr) + } + return nil, fmt.Errorf("GitLab API returned unexpected status: %d", statusCode) + } + + if nextPage == "" { + break + } + + nextPageNumber, err := strconv.Atoi(nextPage) + if err != nil || nextPageNumber <= 0 { + break + } + + page = nextPageNumber + } + + sort.Strings(branches) + return branches, nil +} + +func (gitlabProvider) 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") + } + + namespace, project, err := parseGitLabURL(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) + projectPath := url.PathEscape(namespace + "/" + project) + + entries := make([]TreeEntry, 0) + page := 1 + + for { + apiURL := fmt.Sprintf( + "%s/api/v4/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d", + baseURL, + projectPath, + url.QueryEscape(branch), + page, + ) + + req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build GitLab request: %w", err) + } + addGitLabAuthHeader(req, repo) + + resp, err := httpclient.Default.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch GitLab repository tree: %w", err) + } + + nextPage := strings.TrimSpace(resp.Header.Get("X-Next-Page")) + + switch resp.StatusCode { + case http.StatusOK: + var parsed []gitlabTreeEntry + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitLab response body: %v\n", closeErr) + } + return nil, fmt.Errorf("failed to decode GitLab tree response: %w", err) + } + + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitLab response body: %v\n", closeErr) + } + + for _, entry := range parsed { + 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}) + } + } + case http.StatusUnauthorized, http.StatusForbidden: + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitLab 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 GitLab 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 GitLab response body: %v\n", closeErr) + } + return nil, fmt.Errorf("GitLab API returned unexpected status: %d", statusCode) + } + + if nextPage == "" { + break + } + + nextPageNumber, err := strconv.Atoi(nextPage) + if err != nil || nextPageNumber <= 0 { + break + } + + page = nextPageNumber + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Path < entries[j].Path + }) + + return entries, nil +} + +func addGitLabAuthHeader(req *http.Request, repo *models.Repository) { + if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil { + token := strings.TrimSpace(repo.AuthToken.String()) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + } +} diff --git a/backend/internal/hub/repositories/gitlab_test.go b/backend/internal/hub/repositories/gitlab_test.go index a5398723..08594391 100644 --- a/backend/internal/hub/repositories/gitlab_test.go +++ b/backend/internal/hub/repositories/gitlab_test.go @@ -241,3 +241,90 @@ func TestGitLabTestConnection(t *testing.T) { } }) } + +func TestGitLabListBranches(t *testing.T) { + p := gitlabProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + expectedURL := "https://gitlab.com/api/v4/projects/OrcaCD%2Forca-cd/repository/branches?per_page=100&page=1" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected URL: %s", req.URL.String()) + } + + return jsonResponseWithBody(http.StatusOK, `[{"name":"release"},{"name":"main"}]`), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL, AuthMethod: models.AuthMethodNone} + 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 TestGitLabListTree(t *testing.T) { + p := gitlabProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + requestCount := 0 + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + + switch requestCount { + case 1: + expectedURL := "https://gitlab.com/api/v4/projects/OrcaCD%2Forca-cd/repository/tree?ref=release%2Fprod&recursive=true&per_page=100&page=1" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 1 URL: %s", req.URL.String()) + } + + resp := jsonResponseWithBody(http.StatusOK, `[{"path":"services","type":"tree"}]`) + resp.Header.Set("X-Next-Page", "2") + return resp, nil + case 2: + expectedURL := "https://gitlab.com/api/v4/projects/OrcaCD%2Forca-cd/repository/tree?ref=release%2Fprod&recursive=true&per_page=100&page=2" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 2 URL: %s", req.URL.String()) + } + + return jsonResponseWithBody(http.StatusOK, `[{"path":"docker-compose.yml","type":"blob"}]`), nil + default: + t.Fatalf("unexpected request count: %d", requestCount) + return nil, nil + } + }) + + repo := &models.Repository{Url: testGitLabRepoURL, 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"]) + } +} diff --git a/backend/internal/hub/repositories/provider.go b/backend/internal/hub/repositories/provider.go index b585316f..65ea8f26 100644 --- a/backend/internal/hub/repositories/provider.go +++ b/backend/internal/hub/repositories/provider.go @@ -15,8 +15,22 @@ type Provider interface { SupportedAuthMethods() []models.RepositoryAuthMethod // Tests the connection to the repository using the provided credentials. TestConnection(ctx context.Context, repo *models.Repository) error - // ListBranches(ctx context.Context, repo *models.Repository) ([]string, error) - // ... + // Lists available branches for the repository. + ListBranches(ctx context.Context, repo *models.Repository) ([]string, error) + // Lists repository tree entries for a branch. + ListTree(ctx context.Context, repo *models.Repository, branch string) ([]TreeEntry, error) +} + +type TreeEntryType string + +const ( + TreeEntryTypeFile TreeEntryType = "file" + TreeEntryTypeDir TreeEntryType = "dir" +) + +type TreeEntry struct { + Path string `json:"path"` + Type TreeEntryType `json:"type"` } var registry = map[models.RepositoryProvider]Provider{} diff --git a/backend/internal/hub/repositories/provider_test.go b/backend/internal/hub/repositories/provider_test.go index e87c1d72..8fd72eb1 100644 --- a/backend/internal/hub/repositories/provider_test.go +++ b/backend/internal/hub/repositories/provider_test.go @@ -12,6 +12,8 @@ type stubProvider struct { parseURLErr error authMethods []models.RepositoryAuthMethod testConnectionFn func(ctx context.Context, repo *models.Repository) error + listBranchesFn func(ctx context.Context, repo *models.Repository) ([]string, error) + listTreeFn func(ctx context.Context, repo *models.Repository, branch string) ([]TreeEntry, error) } func (s *stubProvider) ParseURL(url string) (string, string, error) { @@ -29,6 +31,20 @@ func (s *stubProvider) TestConnection(ctx context.Context, repo *models.Reposito return nil } +func (s *stubProvider) ListBranches(ctx context.Context, repo *models.Repository) ([]string, error) { + if s.listBranchesFn != nil { + return s.listBranchesFn(ctx, repo) + } + return []string{}, nil +} + +func (s *stubProvider) ListTree(ctx context.Context, repo *models.Repository, branch string) ([]TreeEntry, error) { + if s.listTreeFn != nil { + return s.listTreeFn(ctx, repo, branch) + } + return []TreeEntry{}, nil +} + func withIsolatedRegistry(t *testing.T, fn func()) { t.Helper() original := registry diff --git a/backend/internal/hub/routes/repositories.go b/backend/internal/hub/routes/repositories.go index 91ca1edc..87f45836 100644 --- a/backend/internal/hub/routes/repositories.go +++ b/backend/internal/hub/routes/repositories.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "slices" + "strings" "time" "github.com/OrcaCD/orca-cd/internal/hub/auth" @@ -245,6 +246,44 @@ func TestConnectionHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "connection successful"}) } +func ListRepositoryBranchesHandler(c *gin.Context) { + repoID := c.Param("id") + repo, provider, ok := resolveRepositoryByID(c, repoID) + if !ok { + return + } + + branches, err := provider.ListBranches(c.Request.Context(), &repo) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, branches) +} + +func ListRepositoryTreeHandler(c *gin.Context) { + repoID := c.Param("id") + branch := strings.TrimSpace(c.Query("branch")) + if branch == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "branch query parameter is required"}) + return + } + + repo, provider, ok := resolveRepositoryByID(c, repoID) + if !ok { + return + } + + entries, err := provider.ListTree(c.Request.Context(), &repo, branch) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, entries) +} + func DeleteRepositoryHandler(c *gin.Context) { id := c.Param("id") @@ -404,3 +443,24 @@ func resolveProvider( func isAuthMethodSupported(method models.RepositoryAuthMethod, supported []models.RepositoryAuthMethod) bool { return slices.Contains(supported, method) } + +func resolveRepositoryByID(c *gin.Context, id string) (models.Repository, repositories.Provider, bool) { + repo, err := gorm.G[models.Repository](db.DB).Where("id = ?", id).First(c.Request.Context()) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "repository not found"}) + return models.Repository{}, nil, false + } + + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return models.Repository{}, nil, false + } + + provider, err := repositories.Get(repo.Provider) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"}) + return models.Repository{}, nil, false + } + + return repo, provider, true +} diff --git a/backend/internal/hub/routes/repositories_test.go b/backend/internal/hub/routes/repositories_test.go index 4b04d473..0f6e40b5 100644 --- a/backend/internal/hub/routes/repositories_test.go +++ b/backend/internal/hub/routes/repositories_test.go @@ -362,12 +362,16 @@ func TestCreateRepositoryHandler_Success_Polling(t *testing.T) { } func mockHTTPClient(statusCode int) func() { + return mockHTTPClientWithBody(statusCode, "{}") +} + +func mockHTTPClientWithBody(statusCode int, body string) func() { original := httpclient.Default httpclient.Default = &http.Client{ Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: statusCode, - Body: io.NopCloser(strings.NewReader("{}")), + Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), }, nil }), @@ -379,6 +383,150 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestListRepositoryBranchesHandler_NotFound(t *testing.T) { + setupTestDBWithRepos(t) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/repositories/nonexistent/branches", nil) + c.Params = gin.Params{{Key: "id", Value: "nonexistent"}} + + ListRepositoryBranchesHandler(c) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListRepositoryBranchesHandler_Success(t *testing.T) { + setupTestDBWithRepos(t) + + repo := models.Repository{ + Name: "owner/repo", + Url: "https://github.com/owner/repo", + Provider: models.GitHub, + AuthMethod: models.AuthMethodNone, + SyncType: models.SyncTypeManual, + SyncStatus: models.SyncStatusUnknown, + CreatedBy: "user-1", + } + if err := db.DB.Select("*").Create(&repo).Error; err != nil { + t.Fatalf("failed to seed repo: %v", err) + } + + restore := mockHTTPClientWithBody(http.StatusOK, `[{"name":"release"},{"name":"main"}]`) + t.Cleanup(restore) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/repositories/"+repo.Id+"/branches", nil) + c.Params = gin.Params{{Key: "id", Value: repo.Id}} + + ListRepositoryBranchesHandler(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body []string + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(body) != 2 { + t.Fatalf("expected 2 branches, got %d", len(body)) + } + if body[0] != "main" || body[1] != "release" { + t.Fatalf("expected sorted branches [main release], got %v", body) + } +} + +func TestListRepositoryBranchesHandler_UnsupportedProvider(t *testing.T) { + setupTestDBWithRepos(t) + + repo := models.Repository{ + Name: "owner/repo", + Url: "https://bitbucket.org/owner/repo", + Provider: models.Bitbucket, + AuthMethod: models.AuthMethodNone, + SyncType: models.SyncTypeManual, + SyncStatus: models.SyncStatusUnknown, + CreatedBy: "user-1", + } + if err := db.DB.Select("*").Create(&repo).Error; err != nil { + t.Fatalf("failed to seed repo: %v", err) + } + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/repositories/"+repo.Id+"/branches", nil) + c.Params = gin.Params{{Key: "id", Value: repo.Id}} + + ListRepositoryBranchesHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListRepositoryTreeHandler_RequiresBranch(t *testing.T) { + setupTestDBWithRepos(t) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/repositories/repo-id/tree", nil) + c.Params = gin.Params{{Key: "id", Value: "repo-id"}} + + ListRepositoryTreeHandler(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestListRepositoryTreeHandler_Success(t *testing.T) { + setupTestDBWithRepos(t) + + repo := models.Repository{ + Name: "owner/repo", + Url: "https://github.com/owner/repo", + Provider: models.GitHub, + AuthMethod: models.AuthMethodNone, + SyncType: models.SyncTypeManual, + SyncStatus: models.SyncStatusUnknown, + CreatedBy: "user-1", + } + if err := db.DB.Select("*").Create(&repo).Error; err != nil { + t.Fatalf("failed to seed repo: %v", err) + } + + restore := mockHTTPClientWithBody(http.StatusOK, `{ + "tree": [ + {"path":"services","type":"tree"}, + {"path":"docker-compose.yml","type":"blob"} + ] + }`) + t.Cleanup(restore) + + c, w := makeAuthContext(t, "user-1") + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/repositories/"+repo.Id+"/tree?branch=main", nil) + c.Params = gin.Params{{Key: "id", Value: repo.Id}} + + ListRepositoryTreeHandler(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body []struct { + Path string `json:"path"` + Type string `json:"type"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(body) != 2 { + t.Fatalf("expected 2 tree entries, got %d", len(body)) + } +} + func TestCreateRepositoryHandler_DuplicateUrlAndSyncType(t *testing.T) { setupTestDBWithRepos(t) From 509e8feae832e2099f907515f6adff79732e979c Mon Sep 17 00:00:00 2001 From: alex289 Date: Mon, 20 Apr 2026 14:11:52 +0200 Subject: [PATCH 02/10] feat: Add ui for branches and tree --- .../components/dialogs/upsert-application.tsx | 262 ++++++++++++++++-- frontend/src/components/ui/collapsible.tsx | 19 ++ frontend/src/lib/api.ts | 4 +- 3 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/ui/collapsible.tsx diff --git a/frontend/src/components/dialogs/upsert-application.tsx b/frontend/src/components/dialogs/upsert-application.tsx index 9c61a13d..229c48eb 100644 --- a/frontend/src/components/dialogs/upsert-application.tsx +++ b/frontend/src/components/dialogs/upsert-application.tsx @@ -1,11 +1,11 @@ // oxlint-disable react/no-children-prop -import { Pencil, Plus } from "lucide-react"; +import { ChevronRightIcon, FileText, FolderIcon, Pencil, Plus } from "lucide-react"; import { Button } from "../ui/button"; import z from "zod"; import { createApplication, updateApplication, type Application } from "@/lib/applications"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; -import { useForm } from "@tanstack/react-form"; +import { useForm, useStore } from "@tanstack/react-form"; import { Dialog, DialogContent, @@ -20,8 +20,10 @@ import { Label } from "../ui/label"; import { Input } from "../ui/input"; import { useFetch } from "@/lib/api"; import type { Agent } from "@/lib/agents"; -import type { Repository } from "@/lib/repsitories"; +import { type Repository } from "@/lib/repsitories"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; +import { cn } from "@/lib/utils"; const applicationSchema = z.object({ name: z @@ -43,6 +45,143 @@ const applicationSchema = z.object({ .max(512, "Path must be at most 512 characters"), }); +type FileTreeNode = { + name: string; + path: string; + type: "file" | "dir"; + children: FileTreeNode[]; +}; + +interface RepositoryTreeEntry { + path: string; + type: "file" | "dir"; +} + +function sortTreeNodes(nodes: FileTreeNode[]): void { + nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "dir" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + for (const node of nodes) { + sortTreeNodes(node.children); + } +} + +function buildFileTree(entries: RepositoryTreeEntry[]): FileTreeNode[] { + const root: FileTreeNode = { + name: "", + path: "", + type: "dir", + children: [], + }; + + const nodeMap = new Map([["", root]]); + + for (const entry of entries) { + const cleanPath = entry.path.trim().replace(/^\/+/, ""); + if (!cleanPath) { + continue; + } + + const segments = cleanPath.split("/"); + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + const currentPath = segments.slice(0, index + 1).join("/"); + const parentPath = segments.slice(0, index).join("/"); + const isLeaf = index === segments.length - 1; + + const parent = nodeMap.get(parentPath); + if (!parent) { + continue; + } + + let node = nodeMap.get(currentPath); + if (!node) { + node = { + name: segment, + path: currentPath, + type: isLeaf ? entry.type : "dir", + children: [], + }; + nodeMap.set(currentPath, node); + parent.children.push(node); + } else if (!isLeaf || entry.type === "dir") { + node.type = "dir"; + } + } + } + + sortTreeNodes(root.children); + return root.children; +} + +function TreeNodeList({ + nodes, + depth, + selectedPath, + onSelectPath, +}: { + nodes: FileTreeNode[]; + depth: number; + selectedPath: string; + onSelectPath: (path: string) => void; +}) { + return ( + <> + {nodes.map((node) => { + if (node.type === "dir") { + return ( + + + + + +
+ {node.children.map((child) => ( + + ))} +
+
+
+ ); + } + + const isSelected = node.path === selectedPath; + return ( + + ); + })} + + ); +} + export default function UpsertApplicationDialog({ application, asDropdownItem = false, @@ -86,8 +225,32 @@ export default function UpsertApplicationDialog({ } }, }); + + const repositoryId = useStore(form.store, (state) => state.values.repositoryId); + const branch = useStore(form.store, (state) => state.values.branch); + + const { data: branches, isLoading: isBranchesLoading } = useFetch( + repositoryId ? `/repositories/${repositoryId}/branches` : null, + ); + + const { data: fileTreeEntries, isLoading: isFileTreeLoading } = useFetch( + branch ? `/repositories/${repositoryId}/tree?branch=${branch}` : null, + ); + + const fileTree = useMemo(() => { + return buildFileTree(fileTreeEntries ?? []); + }, [fileTreeEntries]); + return ( - setOpen(open)}> + { + setOpen(nextOpen); + if (!nextOpen) { + form.reset(); + } + }} + > {asDropdownItem ? ( e.preventDefault()}> @@ -158,7 +321,11 @@ export default function UpsertApplicationDialog({ + + Branch + {isInvalid && } + + ); }} @@ -250,14 +443,41 @@ export default function UpsertApplicationDialog({ const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - + +
+ {!repositoryId ? ( +
+ Select a repository first. +
+ ) : !branch ? ( +
+ Select a branch first. +
+ ) : isFileTreeLoading ? ( +
+ Loading repository tree... +
+ ) : fileTree.length === 0 ? ( +
+ No files found in this branch. +
+ ) : ( +
+ field.handleChange(path)} + /> +
+ )} +
field.handleChange(e.target.value)} - placeholder='e.g. "/docker-compose.yml"' - autoFocus + readOnly + className="mt-2" + placeholder="Selected file path" /> {isInvalid && }
diff --git a/frontend/src/components/ui/collapsible.tsx b/frontend/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..33b1ffaf --- /dev/null +++ b/frontend/src/components/ui/collapsible.tsx @@ -0,0 +1,19 @@ +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; + +function Collapsible({ ...props }: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6ab0dc43..175fe53c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,8 +76,8 @@ export async function fetcher | void = any>( } export const useFetch = = any>( - url: string, + url: string | null, config?: SWRConfiguration, ) => { - return useSWR(API_BASE + url, fetchWrapper, config); + return useSWR(url ? API_BASE + url : null, fetchWrapper, config); }; From 98fd967ad3bb1bb66b22032459caa1273e21ef9e Mon Sep 17 00:00:00 2001 From: alex289 Date: Mon, 20 Apr 2026 14:12:23 +0200 Subject: [PATCH 03/10] feat: Disable goconst --- backend/.golangci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/.golangci.yml b/backend/.golangci.yml index b023b9eb..5873360c 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -16,6 +16,5 @@ linters: - zerologlint - gocritic - bodyclose - - goconst - modernize - unparam From 4bbb6ff89895068504aa8ee2bbfabcbe77b312c3 Mon Sep 17 00:00:00 2001 From: alex289 Date: Mon, 20 Apr 2026 17:49:42 +0200 Subject: [PATCH 04/10] feat: Improve file selection --- frontend/src/components/dialogs/upsert-application.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/dialogs/upsert-application.tsx b/frontend/src/components/dialogs/upsert-application.tsx index 229c48eb..2da37e20 100644 --- a/frontend/src/components/dialogs/upsert-application.tsx +++ b/frontend/src/components/dialogs/upsert-application.tsx @@ -172,6 +172,7 @@ function TreeNodeList({ size="sm" onClick={() => onSelectPath(node.path)} className={cn("text-foreground mr-auto", isSelected ? "font-medium" : "")} + disabled={!node.name.endsWith(".yml") && !node.name.endsWith(".yaml")} > {node.name} @@ -474,8 +475,7 @@ export default function UpsertApplicationDialog({ From 78f5f05ba2bfbe5e7491f83132cf1e3f7f795e09 Mon Sep 17 00:00:00 2001 From: alex289 Date: Mon, 20 Apr 2026 19:00:19 +0200 Subject: [PATCH 05/10] feat: Improve test coverage --- .../internal/hub/repositories/gitea_test.go | 311 +++++++++++++- .../internal/hub/repositories/github_test.go | 277 ++++++++++++- .../internal/hub/repositories/gitlab_test.go | 378 +++++++++++++++++- 3 files changed, 956 insertions(+), 10 deletions(-) diff --git a/backend/internal/hub/repositories/gitea_test.go b/backend/internal/hub/repositories/gitea_test.go index dc66b645..d2ec065a 100644 --- a/backend/internal/hub/repositories/gitea_test.go +++ b/backend/internal/hub/repositories/gitea_test.go @@ -2,8 +2,10 @@ package repositories import ( "context" + "errors" "fmt" "net/http" + "strconv" "strings" "testing" @@ -62,6 +64,8 @@ func TestGiteaParseURL(t *testing.T) { "https://gitea.com", // missing path "https:///owner/repo", // empty host "https://gitea.com/owner/repo/extra", // extra path segment + "https://gitea.com/-owner/repo", // invalid owner + "https://gitea.com/owner/re po", // invalid repo } for _, u := range invalid { @@ -71,6 +75,21 @@ func TestGiteaParseURL(t *testing.T) { } } +func TestGiteaSupportedAuthMethods(t *testing.T) { + p := giteaProvider{} + got := p.SupportedAuthMethods() + + if len(got) != 2 { + t.Fatalf("expected 2 auth methods, got %d", len(got)) + } + if got[0] != models.AuthMethodNone { + t.Fatalf("expected first auth method to be %q, got %q", models.AuthMethodNone, got[0]) + } + if got[1] != models.AuthMethodToken { + t.Fatalf("expected second auth method to be %q, got %q", models.AuthMethodToken, got[1]) + } +} + func TestGiteaTestConnection(t *testing.T) { p := giteaProvider{} originalClient := httpclient.Default @@ -187,6 +206,18 @@ func TestGiteaTestConnection(t *testing.T) { t.Fatalf("expected unexpected status error, got: %v", err) } }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + err := p.TestConnection(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to connect to Gitea") { + t.Fatalf("expected connect error, got: %v", err) + } + }) } func TestGiteaListBranches(t *testing.T) { @@ -229,6 +260,167 @@ func TestGiteaListBranches(t *testing.T) { } } +func TestGiteaListBranchesPagination(t *testing.T) { + p := giteaProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + requestCount := 0 + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + + switch requestCount { + case 1: + expectedURL := "https://gitea.com/api/v1/repos/OrcaCD/orca-cd/branches?page=1&limit=100" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 1 URL: %s", req.URL.String()) + } + + var b strings.Builder + b.WriteString("[") + for i := range 100 { + if i > 0 { + b.WriteString(",") + } + b.WriteString(`{"name":"branch-` + strconv.Itoa(i) + `"}`) + } + b.WriteString("]") + return jsonResponseWithBody(http.StatusOK, b.String()), nil + case 2: + expectedURL := "https://gitea.com/api/v1/repos/OrcaCD/orca-cd/branches?page=2&limit=100" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 2 URL: %s", req.URL.String()) + } + return jsonResponseWithBody(http.StatusOK, `[{"name":"main"}]`), nil + default: + t.Fatalf("unexpected request count: %d", requestCount) + return nil, nil + } + }) + + repo := &models.Repository{Url: testGiteaRepoURL, AuthMethod: models.AuthMethodNone} + branches, err := p.ListBranches(context.Background(), repo) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + if len(branches) != 101 { + t.Fatalf("expected 101 branches, got %d", len(branches)) + } + if branches[0] != "branch-0" { + t.Fatalf("expected first sorted branch to be branch-0, got %q", branches[0]) + } + if branches[len(branches)-1] != "main" { + t.Fatalf("expected last sorted branch to be main, got %q", branches[len(branches)-1]) + } +} + +func TestGiteaListBranchesErrors(t *testing.T) { + p := giteaProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + t.Run("nil repository", func(t *testing.T) { + branches, err := p.ListBranches(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "repository is required") { + t.Fatalf("expected repository is required error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("invalid repository URL", func(t *testing.T) { + repo := &models.Repository{Url: "https://gitea.com/owner"} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "invalid repository URL") { + t.Fatalf("expected invalid repository URL error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("timeout") + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to fetch Gitea branches") { + t.Fatalf("expected fetch error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("decode error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponseWithBody(http.StatusOK, `{invalid-json`), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to decode Gitea branches response") { + t.Fatalf("expected decode error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("forbidden response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "authentication failed or access denied") { + t.Fatalf("expected auth error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("not found response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusNotFound), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "repository not found or access denied") { + t.Fatalf("expected not found error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("unexpected status", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusInternalServerError), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("expected unexpected status error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) +} + func TestGiteaListTree(t *testing.T) { p := giteaProvider{} originalClient := httpclient.Default @@ -245,7 +437,9 @@ func TestGiteaListTree(t *testing.T) { return jsonResponseWithBody(http.StatusOK, `{ "tree": [ {"path":"docker-compose.yml","type":"blob"}, - {"path":"services","type":"tree"} + {"path":"services","type":"tree"}, + {"path":"ignored","type":"commit"}, + {"path":"","type":"blob"} ] }`), nil }) @@ -272,3 +466,118 @@ func TestGiteaListTree(t *testing.T) { t.Fatalf("expected docker-compose.yml to be file, got %q", byPath["docker-compose.yml"]) } } + +func TestGiteaListTreeErrors(t *testing.T) { + p := giteaProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + t.Run("nil repository", func(t *testing.T) { + tree, err := p.ListTree(context.Background(), nil, "main") + if err == nil || !strings.Contains(err.Error(), "repository is required") { + t.Fatalf("expected repository is required error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("branch is required", func(t *testing.T) { + repo := &models.Repository{Url: testGiteaRepoURL} + tree, err := p.ListTree(context.Background(), repo, " ") + if err == nil || !strings.Contains(err.Error(), "branch is required") { + t.Fatalf("expected branch is required error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("invalid repository URL", func(t *testing.T) { + repo := &models.Repository{Url: "https://gitea.com/owner"} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "invalid repository URL") { + t.Fatalf("expected invalid repository URL error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp timeout") + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "failed to fetch Gitea repository tree") { + t.Fatalf("expected fetch error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("decode error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponseWithBody(http.StatusOK, `{"tree": [invalid-json]}`), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "failed to decode Gitea tree response") { + t.Fatalf("expected decode error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("forbidden response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "authentication failed or access denied") { + t.Fatalf("expected auth error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("not found response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusNotFound), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "repository not found or access denied") { + t.Fatalf("expected not found error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("unexpected status", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusInternalServerError), nil + }) + + repo := &models.Repository{Url: testGiteaRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("expected unexpected status error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) +} diff --git a/backend/internal/hub/repositories/github_test.go b/backend/internal/hub/repositories/github_test.go index c53a99e1..21975fac 100644 --- a/backend/internal/hub/repositories/github_test.go +++ b/backend/internal/hub/repositories/github_test.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "errors" "io" "net/http" "strings" @@ -69,6 +70,8 @@ func TestGitHubParseURL(t *testing.T) { "ftp://github.com/owner/repo", // wrong scheme "https://github.com/-owner/repo", // owner starts with hyphen "https://github.com/owner-/repo", // owner ends with hyphen + "https://github.com/owner/", // missing repo segment + "https://github.com/owner/%20repo", // invalid repo characters } for _, u := range invalid { @@ -78,6 +81,21 @@ func TestGitHubParseURL(t *testing.T) { } } +func TestGitHubSupportedAuthMethods(t *testing.T) { + p := githubProvider{} + got := p.SupportedAuthMethods() + + if len(got) != 2 { + t.Fatalf("expected 2 auth methods, got %d", len(got)) + } + if got[0] != models.AuthMethodNone { + t.Fatalf("expected first auth method to be %q, got %q", models.AuthMethodNone, got[0]) + } + if got[1] != models.AuthMethodToken { + t.Fatalf("expected second auth method to be %q, got %q", models.AuthMethodToken, got[1]) + } +} + func TestGitHubTestConnection(t *testing.T) { p := githubProvider{} originalClient := httpclient.Default @@ -152,6 +170,42 @@ func TestGitHubTestConnection(t *testing.T) { t.Fatalf("expected not found/access denied error, got: %v", err) } }) + + t.Run("forbidden response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden), nil + }) + + repo := &models.Repository{Url: testRepoURL} + err := p.TestConnection(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "authentication failed or access denied") { + t.Fatalf("expected auth error, got: %v", err) + } + }) + + t.Run("unexpected status code", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusInternalServerError), nil + }) + + repo := &models.Repository{Url: testRepoURL} + err := p.TestConnection(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("expected unexpected status error, got: %v", err) + } + }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + }) + + repo := &models.Repository{Url: testRepoURL} + err := p.TestConnection(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to connect to GitHub") { + t.Fatalf("expected connect error, got: %v", err) + } + }) } func TestGitHubListBranches(t *testing.T) { @@ -194,6 +248,110 @@ func TestGitHubListBranches(t *testing.T) { } } +func TestGitHubListBranchesErrors(t *testing.T) { + p := githubProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + t.Run("nil repository", func(t *testing.T) { + branches, err := p.ListBranches(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "repository is required") { + t.Fatalf("expected repository is required error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("invalid repository URL", func(t *testing.T) { + repo := &models.Repository{Url: "https://github.com/invalid-only-owner"} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "invalid repository URL") { + t.Fatalf("expected invalid repository URL error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("dial timeout") + }) + + repo := &models.Repository{Url: testRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to fetch GitHub branches") { + t.Fatalf("expected fetch error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("decode error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponseWithBody(http.StatusOK, `{invalid-json`), nil + }) + + repo := &models.Repository{Url: testRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to decode GitHub branches response") { + t.Fatalf("expected decode error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("forbidden response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden), nil + }) + + repo := &models.Repository{Url: testRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "authentication failed or access denied") { + t.Fatalf("expected auth error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("not found response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusNotFound), nil + }) + + repo := &models.Repository{Url: testRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "repository not found or access denied") { + t.Fatalf("expected not found error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("unexpected status", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway), nil + }) + + repo := &models.Repository{Url: testRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("expected unexpected status error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) +} + func TestGitHubListTree(t *testing.T) { p := githubProvider{} originalClient := httpclient.Default @@ -211,7 +369,9 @@ func TestGitHubListTree(t *testing.T) { "tree": [ {"path":"docker-compose.yml","type":"blob"}, {"path":"services","type":"tree"}, - {"path":"README.md","type":"blob"} + {"path":"README.md","type":"blob"}, + {"path":"submodule","type":"commit"}, + {"path":"","type":"blob"} ] }`), nil }) @@ -239,3 +399,118 @@ func TestGitHubListTree(t *testing.T) { t.Fatalf("expected services to be dir, got %q", byPath["services"]) } } + +func TestGitHubListTreeErrors(t *testing.T) { + p := githubProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + t.Run("nil repository", func(t *testing.T) { + tree, err := p.ListTree(context.Background(), nil, "main") + if err == nil || !strings.Contains(err.Error(), "repository is required") { + t.Fatalf("expected repository is required error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("branch is required", func(t *testing.T) { + repo := &models.Repository{Url: testRepoURL} + tree, err := p.ListTree(context.Background(), repo, " ") + if err == nil || !strings.Contains(err.Error(), "branch is required") { + t.Fatalf("expected branch is required error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("invalid repository URL", func(t *testing.T) { + repo := &models.Repository{Url: "https://github.com/invalid-only-owner"} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "invalid repository URL") { + t.Fatalf("expected invalid repository URL error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("temporary network error") + }) + + repo := &models.Repository{Url: testRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "failed to fetch GitHub repository tree") { + t.Fatalf("expected fetch error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("decode error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponseWithBody(http.StatusOK, `{"tree": [invalid-json]}`), nil + }) + + repo := &models.Repository{Url: testRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "failed to decode GitHub tree response") { + t.Fatalf("expected decode error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("forbidden response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden), nil + }) + + repo := &models.Repository{Url: testRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "authentication failed or access denied") { + t.Fatalf("expected auth error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("not found response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusNotFound), nil + }) + + repo := &models.Repository{Url: testRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "repository not found or access denied") { + t.Fatalf("expected not found error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("unexpected status", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusBadGateway), nil + }) + + repo := &models.Repository{Url: testRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("expected unexpected status error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) +} diff --git a/backend/internal/hub/repositories/gitlab_test.go b/backend/internal/hub/repositories/gitlab_test.go index 08594391..ee7d4414 100644 --- a/backend/internal/hub/repositories/gitlab_test.go +++ b/backend/internal/hub/repositories/gitlab_test.go @@ -2,9 +2,11 @@ package repositories import ( "context" + "errors" "fmt" "io" "net/http" + "strconv" "strings" "testing" @@ -62,13 +64,15 @@ func TestGitLabParseURL(t *testing.T) { invalid := []string{ "", "not-a-url", - "http://gitlab.com/owner/repo", // http not allowed - "ftp://gitlab.com/owner/repo", // wrong scheme - "https://gitlab.com/owner", // missing project - "https://gitlab.com/", // missing namespace and project - "https://gitlab.com", // missing path - "https:///owner/repo", // empty host - "https://github.com/owner/repo", // github.com not allowed + "http://gitlab.com/owner/repo", // http not allowed + "ftp://gitlab.com/owner/repo", // wrong scheme + "https://gitlab.com/owner", // missing project + "https://gitlab.com/", // missing namespace and project + "https://gitlab.com", // missing path + "https:///owner/repo", // empty host + "https://github.com/owner/repo", // github.com not allowed + "https://gitlab.com/owner//repo", // empty path segment + "https://gitlab.com/owner/re po", // invalid project name } for _, u := range invalid { @@ -78,6 +82,21 @@ func TestGitLabParseURL(t *testing.T) { } } +func TestGitLabSupportedAuthMethods(t *testing.T) { + p := gitlabProvider{} + got := p.SupportedAuthMethods() + + if len(got) != 2 { + t.Fatalf("expected 2 auth methods, got %d", len(got)) + } + if got[0] != models.AuthMethodNone { + t.Fatalf("expected first auth method to be %q, got %q", models.AuthMethodNone, got[0]) + } + if got[1] != models.AuthMethodToken { + t.Fatalf("expected second auth method to be %q, got %q", models.AuthMethodToken, got[1]) + } +} + func TestGitLabTestConnection(t *testing.T) { p := gitlabProvider{} originalClient := httpclient.Default @@ -240,6 +259,18 @@ func TestGitLabTestConnection(t *testing.T) { t.Fatalf("expected unexpected status error, got: %v", err) } }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("connection reset") + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + err := p.TestConnection(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to connect to GitLab") { + t.Fatalf("expected connect error, got: %v", err) + } + }) } func TestGitLabListBranches(t *testing.T) { @@ -272,6 +303,157 @@ func TestGitLabListBranches(t *testing.T) { } } +func TestGitLabListBranchesPagination(t *testing.T) { + p := gitlabProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + requestCount := 0 + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + + switch requestCount { + case 1: + expectedURL := "https://gitlab.com/api/v4/projects/OrcaCD%2Forca-cd/repository/branches?per_page=100&page=1" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 1 URL: %s", req.URL.String()) + } + resp := jsonResponseWithBody(http.StatusOK, `[{"name":"branch-z"}]`) + resp.Header.Set("X-Next-Page", "2") + return resp, nil + case 2: + expectedURL := "https://gitlab.com/api/v4/projects/OrcaCD%2Forca-cd/repository/branches?per_page=100&page=2" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 2 URL: %s", req.URL.String()) + } + resp := jsonResponseWithBody(http.StatusOK, `[{"name":"branch-a"}]`) + resp.Header.Set("X-Next-Page", "not-a-number") + return resp, nil + default: + t.Fatalf("unexpected request count: %d", requestCount) + return nil, nil + } + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + 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] != "branch-a" || branches[1] != "branch-z" { + t.Fatalf("expected sorted branches [branch-a branch-z], got %v", branches) + } +} + +func TestGitLabListBranchesErrors(t *testing.T) { + p := gitlabProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + t.Run("nil repository", func(t *testing.T) { + branches, err := p.ListBranches(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "repository is required") { + t.Fatalf("expected repository is required error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("invalid repository URL", func(t *testing.T) { + repo := &models.Repository{Url: "https://gitlab.com/owner"} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "invalid repository URL") { + t.Fatalf("expected invalid repository URL error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("dial timeout") + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to fetch GitLab branches") { + t.Fatalf("expected fetch error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("decode error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponseWithBody(http.StatusOK, `{invalid-json`), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "failed to decode GitLab branches response") { + t.Fatalf("expected decode error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("forbidden response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "authentication failed or access denied") { + t.Fatalf("expected auth error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("not found response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusNotFound), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "repository not found or access denied") { + t.Fatalf("expected not found error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) + + t.Run("unexpected status", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusInternalServerError), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + branches, err := p.ListBranches(context.Background(), repo) + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("expected unexpected status error, got: %v", err) + } + if branches != nil { + t.Fatalf("expected nil branches, got %v", branches) + } + }) +} + func TestGitLabListTree(t *testing.T) { p := gitlabProvider{} originalClient := httpclient.Default @@ -299,7 +481,13 @@ func TestGitLabListTree(t *testing.T) { t.Fatalf("unexpected page 2 URL: %s", req.URL.String()) } - return jsonResponseWithBody(http.StatusOK, `[{"path":"docker-compose.yml","type":"blob"}]`), nil + resp := jsonResponseWithBody(http.StatusOK, `[ + {"path":"docker-compose.yml","type":"blob"}, + {"path":"","type":"blob"}, + {"path":"ignored","type":"commit"} + ]`) + resp.Header.Set("X-Next-Page", "abc") + return resp, nil default: t.Fatalf("unexpected request count: %d", requestCount) return nil, nil @@ -328,3 +516,177 @@ func TestGitLabListTree(t *testing.T) { t.Fatalf("expected docker-compose.yml to be file, got %q", byPath["docker-compose.yml"]) } } + +func TestGitLabListTreeErrors(t *testing.T) { + p := gitlabProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + t.Run("nil repository", func(t *testing.T) { + tree, err := p.ListTree(context.Background(), nil, "main") + if err == nil || !strings.Contains(err.Error(), "repository is required") { + t.Fatalf("expected repository is required error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("branch is required", func(t *testing.T) { + repo := &models.Repository{Url: testGitLabRepoURL} + tree, err := p.ListTree(context.Background(), repo, " ") + if err == nil || !strings.Contains(err.Error(), "branch is required") { + t.Fatalf("expected branch is required error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("invalid repository URL", func(t *testing.T) { + repo := &models.Repository{Url: "https://gitlab.com/owner"} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "invalid repository URL") { + t.Fatalf("expected invalid repository URL error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("request transport error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("connection failed") + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "failed to fetch GitLab repository tree") { + t.Fatalf("expected fetch error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("decode error", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponseWithBody(http.StatusOK, `{invalid-json`), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "failed to decode GitLab tree response") { + t.Fatalf("expected decode error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("forbidden response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusForbidden), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "authentication failed or access denied") { + t.Fatalf("expected auth error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("not found response", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusNotFound), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "repository not found or access denied") { + t.Fatalf("expected not found error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) + + t.Run("unexpected status", func(t *testing.T) { + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + return jsonResponse(http.StatusInternalServerError), nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err == nil || !strings.Contains(err.Error(), "unexpected status") { + t.Fatalf("expected unexpected status error, got: %v", err) + } + if tree != nil { + t.Fatalf("expected nil tree, got %v", tree) + } + }) +} + +func TestGitLabListBranchesAuthHeader(t *testing.T) { + p := gitlabProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + token := crypto.EncryptedString("secret-token") + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer secret-token" { + t.Fatalf("unexpected authorization header: %q", req.Header.Get("Authorization")) + } + return jsonResponseWithBody(http.StatusOK, `[{"name":"main"}]`), nil + }) + + repo := &models.Repository{ + Url: testGitLabRepoURL, + 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) != 1 || branches[0] != "main" { + t.Fatalf("expected [main], got %v", branches) + } +} + +func TestGitLabListTreeNextPageInvalidStops(t *testing.T) { + p := gitlabProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + requestCount := 0 + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + if requestCount != 1 { + t.Fatalf("expected single request, got %d", requestCount) + } + + resp := jsonResponseWithBody(http.StatusOK, `[{"path":"app.yaml","type":"blob"}]`) + resp.Header.Set("X-Next-Page", strconv.Itoa(0)) + return resp, nil + }) + + repo := &models.Repository{Url: testGitLabRepoURL} + tree, err := p.ListTree(context.Background(), repo, "main") + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if len(tree) != 1 || tree[0].Path != "app.yaml" { + t.Fatalf("unexpected tree: %v", tree) + } +} From 602b474cbe6d87657f36ef1c49bdde570b114b79 Mon Sep 17 00:00:00 2001 From: alex289 Date: Tue, 21 Apr 2026 09:27:38 +0200 Subject: [PATCH 06/10] feat: Apply review suggestions --- backend/internal/hub/repositories/gitea.go | 78 ++++++++------- .../internal/hub/repositories/gitea_test.go | 8 +- backend/internal/hub/repositories/github.go | 97 ++++++++++++------- .../internal/hub/repositories/github_test.go | 60 +++++++++++- backend/internal/hub/repositories/gitlab.go | 91 ++++++++++------- backend/internal/hub/repositories/provider.go | 42 ++++++++ .../hub/repositories/provider_test.go | 12 +++ .../components/dialogs/upsert-application.tsx | 2 +- 8 files changed, 284 insertions(+), 106 deletions(-) diff --git a/backend/internal/hub/repositories/gitea.go b/backend/internal/hub/repositories/gitea.go index c8b260c8..f60d8b6d 100644 --- a/backend/internal/hub/repositories/gitea.go +++ b/backend/internal/hub/repositories/gitea.go @@ -16,6 +16,12 @@ import ( type giteaProvider struct{} +type parsedGiteaRepositoryURL struct { + baseURL string + owner string + repo string +} + type giteaBranch struct { Name string `json:"name"` } @@ -27,8 +33,6 @@ type giteaTreeResponse struct { } `json:"tree"` } -const httpsScheme = "https" - func init() { Register(models.Gitea, giteaProvider{}) } @@ -36,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 @@ -53,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) { @@ -83,15 +100,17 @@ 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) @@ -126,25 +145,21 @@ func (giteaProvider) ListBranches(ctx context.Context, repo *models.Repository) return nil, errors.New("repository is required") } - owner, repoName, err := parseGiteaURL(repo.Url) + parsedRepoURL, err := parseGiteaRepositoryURL(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) - branches := make([]string, 0) - limit := 100 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), + parsedRepoURL.baseURL, + url.PathEscape(parsedRepoURL.owner), + url.PathEscape(parsedRepoURL.repo), page, - limit, + providerPageSize, ) req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) @@ -180,8 +195,8 @@ func (giteaProvider) ListBranches(ctx context.Context, repo *models.Repository) } } - if len(parsed) < limit { - sort.Strings(branches) + if len(parsed) < providerPageSize { + sortBranches(branches) return branches, nil } case http.StatusUnauthorized, http.StatusForbidden: @@ -216,19 +231,16 @@ func (giteaProvider) ListTree(ctx context.Context, repo *models.Repository, bran return nil, errors.New("branch is required") } - owner, repoName, err := parseGiteaURL(repo.Url) + parsedRepoURL, err := parseGiteaRepositoryURL(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), + parsedRepoURL.baseURL, + url.PathEscape(parsedRepoURL.owner), + url.PathEscape(parsedRepoURL.repo), url.PathEscape(branch), ) diff --git a/backend/internal/hub/repositories/gitea_test.go b/backend/internal/hub/repositories/gitea_test.go index d2ec065a..fff71818 100644 --- a/backend/internal/hub/repositories/gitea_test.go +++ b/backend/internal/hub/repositories/gitea_test.go @@ -309,11 +309,11 @@ func TestGiteaListBranchesPagination(t *testing.T) { if len(branches) != 101 { t.Fatalf("expected 101 branches, got %d", len(branches)) } - if branches[0] != "branch-0" { - t.Fatalf("expected first sorted branch to be branch-0, got %q", branches[0]) + if branches[0] != "main" { + t.Fatalf("expected first sorted branch to be main, got %q", branches[0]) } - if branches[len(branches)-1] != "main" { - t.Fatalf("expected last sorted branch to be main, got %q", branches[len(branches)-1]) + if branches[1] != "branch-0" { + t.Fatalf("expected second sorted branch to be branch-0, got %q", branches[1]) } } diff --git a/backend/internal/hub/repositories/github.go b/backend/internal/hub/repositories/github.go index 3501e39b..d89bab37 100644 --- a/backend/internal/hub/repositories/github.go +++ b/backend/internal/hub/repositories/github.go @@ -39,8 +39,8 @@ func parseGitHubURL(rawURL string) (owner, repo string, err error) { if err != nil { return "", "", errors.New("invalid URL") } - if u.Scheme != "https" { - return "", "", errors.New("URL must use https") + if u.Scheme != httpsScheme { + return "", "", fmt.Errorf("URL must use %s", httpsScheme) } if u.Host != "github.com" { return "", "", errors.New("URL host must be github.com") @@ -127,45 +127,74 @@ func (githubProvider) ListBranches(ctx context.Context, repo *models.Repository) return nil, fmt.Errorf("invalid repository URL: %w", err) } - apiURL := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100", githubAPIBase, owner, repoName) - req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to build GitHub request: %w", err) - } - addGitHubHeaders(req, repo) - - resp, err := httpclient.Default.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch GitHub branches: %w", err) - } - defer func() { - err := resp.Body.Close() + branches := make([]string, 0) + + for page := 1; ; page++ { + apiURL := fmt.Sprintf( + "%s/repos/%s/%s/branches?per_page=%d&page=%d", + githubAPIBase, + owner, + repoName, + providerPageSize, + page, + ) + req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) if err != nil { - fmt.Printf("warning: failed to close GitHub response body: %v\n", err) + return nil, fmt.Errorf("failed to build GitHub request: %w", err) } - }() + addGitHubHeaders(req, repo) - switch resp.StatusCode { - case http.StatusOK: - var parsed []githubBranch - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return nil, fmt.Errorf("failed to decode GitHub branches response: %w", err) + resp, err := httpclient.Default.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch GitHub branches: %w", err) } - branches := make([]string, 0, len(parsed)) - for _, branch := range parsed { - if branch.Name != "" { - branches = append(branches, branch.Name) + switch resp.StatusCode { + case http.StatusOK: + var parsed []githubBranch + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitHub response body: %v\n", closeErr) + } + return nil, fmt.Errorf("failed to decode GitHub branches response: %w", err) + } + + closeErr := resp.Body.Close() + if closeErr != nil { + fmt.Printf("warning: failed to close GitHub response body: %v\n", closeErr) + } + + 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 GitHub 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 GitHub 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 GitHub response body: %v\n", closeErr) + } + return nil, fmt.Errorf("GitHub API returned unexpected status: %d", statusCode) } - sort.Strings(branches) - return branches, 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("GitHub API returned unexpected status: %d", resp.StatusCode) } } diff --git a/backend/internal/hub/repositories/github_test.go b/backend/internal/hub/repositories/github_test.go index 21975fac..63b4e4c6 100644 --- a/backend/internal/hub/repositories/github_test.go +++ b/backend/internal/hub/repositories/github_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http" + "strconv" "strings" "testing" @@ -216,7 +217,7 @@ func TestGitHubListBranches(t *testing.T) { }) httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { - expectedURL := githubAPIBase + "/repos/OrcaCD/orca-cd/branches?per_page=100" + expectedURL := githubAPIBase + "/repos/OrcaCD/orca-cd/branches?per_page=100&page=1" if req.URL.String() != expectedURL { t.Fatalf("unexpected URL: %s", req.URL.String()) } @@ -248,6 +249,63 @@ func TestGitHubListBranches(t *testing.T) { } } +func TestGitHubListBranchesPagination(t *testing.T) { + p := githubProvider{} + originalClient := httpclient.Default + t.Cleanup(func() { + httpclient.Default = originalClient + }) + + requestCount := 0 + httpclient.Default = mockClient(func(req *http.Request) (*http.Response, error) { + requestCount++ + + switch requestCount { + case 1: + expectedURL := githubAPIBase + "/repos/OrcaCD/orca-cd/branches?per_page=100&page=1" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 1 URL: %s", req.URL.String()) + } + + var b strings.Builder + b.WriteString("[") + for i := range 100 { + if i > 0 { + b.WriteString(",") + } + b.WriteString(`{"name":"branch-` + strconv.Itoa(i) + `"}`) + } + b.WriteString("]") + return jsonResponseWithBody(http.StatusOK, b.String()), nil + case 2: + expectedURL := githubAPIBase + "/repos/OrcaCD/orca-cd/branches?per_page=100&page=2" + if req.URL.String() != expectedURL { + t.Fatalf("unexpected page 2 URL: %s", req.URL.String()) + } + return jsonResponseWithBody(http.StatusOK, `[{"name":"main"}]`), nil + default: + t.Fatalf("unexpected request count: %d", requestCount) + return nil, nil + } + }) + + repo := &models.Repository{Url: testRepoURL, AuthMethod: models.AuthMethodNone} + branches, err := p.ListBranches(context.Background(), repo) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + if len(branches) != 101 { + t.Fatalf("expected 101 branches, got %d", len(branches)) + } + if branches[0] != "main" { + t.Fatalf("expected first sorted branch to be main, got %q", branches[0]) + } + if branches[1] != "branch-0" { + t.Fatalf("expected second sorted branch to be branch-0, got %q", branches[1]) + } +} + func TestGitHubListBranchesErrors(t *testing.T) { p := githubProvider{} originalClient := httpclient.Default diff --git a/backend/internal/hub/repositories/gitlab.go b/backend/internal/hub/repositories/gitlab.go index 8811a1cb..95edc270 100644 --- a/backend/internal/hub/repositories/gitlab.go +++ b/backend/internal/hub/repositories/gitlab.go @@ -17,6 +17,12 @@ import ( type gitlabProvider struct{} +type parsedGitLabRepositoryURL struct { + baseURL string + namespace string + project string +} + type gitlabBranch struct { Name string `json:"name"` } @@ -34,18 +40,27 @@ func init() { // and returns the namespace and project name. The host is not constrained to gitlab.com // to support self-hosted deployments. func parseGitLabURL(rawURL string) (namespace, project string, err error) { + parsedRepoURL, err := parseGitLabRepositoryURL(rawURL) + if err != nil { + return "", "", err + } + + return parsedRepoURL.namespace, parsedRepoURL.project, nil +} + +func parseGitLabRepositoryURL(rawURL string) (parsedGitLabRepositoryURL, error) { u, err := url.Parse(rawURL) if err != nil { - return "", "", errors.New("invalid URL") + return parsedGitLabRepositoryURL{}, errors.New("invalid URL") } - if u.Scheme != "https" { - return "", "", errors.New("URL must use https") + if u.Scheme != httpsScheme { + return parsedGitLabRepositoryURL{}, fmt.Errorf("URL must use %s", httpsScheme) } if u.Host == "" { - return "", "", errors.New("URL must have a valid host") + return parsedGitLabRepositoryURL{}, errors.New("URL must have a valid host") } if u.Host == "github.com" { - return "", "", errors.New("github.com is not a valid GitLab host") + return parsedGitLabRepositoryURL{}, errors.New("github.com is not a valid GitLab host") } // Allow URLs ending with .git @@ -55,20 +70,24 @@ func parseGitLabURL(rawURL string) (namespace, project string, err error) { // Need at least namespace/project (two path segments) parts := strings.Split(path, "/") if len(parts) < 2 || parts[0] == "" || parts[len(parts)-1] == "" { - return "", "", errors.New("URL must be in the format https://{host}/{namespace}/{project}") + return parsedGitLabRepositoryURL{}, fmt.Errorf("URL must be in the format %s://{host}/{namespace}/{project}", httpsScheme) } // Validate each path segment for _, seg := range parts { if !repoRe.MatchString(seg) { - return "", "", fmt.Errorf("invalid path segment %q in URL", seg) + return parsedGitLabRepositoryURL{}, fmt.Errorf("invalid path segment %q in URL", seg) } } - project = parts[len(parts)-1] - namespace = strings.Join(parts[:len(parts)-1], "/") + project := parts[len(parts)-1] + namespace := strings.Join(parts[:len(parts)-1], "/") - return namespace, project, nil + return parsedGitLabRepositoryURL{ + baseURL: fmt.Sprintf("%s://%s", u.Scheme, u.Host), + namespace: namespace, + project: project, + }, nil } func (gitlabProvider) ParseURL(rawURL string) (string, string, error) { @@ -87,17 +106,14 @@ func (gitlabProvider) TestConnection(ctx context.Context, repo *models.Repositor return errors.New("repository is required") } - namespace, project, err := parseGitLabURL(repo.Url) + parsedRepoURL, err := parseGitLabRepositoryURL(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) - // GitLab API: GET /api/v4/projects/:id where :id is the URL-encoded namespace/project path - projectPath := url.PathEscape(namespace + "/" + project) - apiURL := fmt.Sprintf("%s/api/v4/projects/%s", baseURL, projectPath) + projectPath := url.PathEscape(parsedRepoURL.namespace + "/" + parsedRepoURL.project) + apiURL := fmt.Sprintf("%s/api/v4/projects/%s", parsedRepoURL.baseURL, projectPath) req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil) if err != nil { @@ -133,23 +149,21 @@ func (gitlabProvider) ListBranches(ctx context.Context, repo *models.Repository) return nil, errors.New("repository is required") } - namespace, project, err := parseGitLabURL(repo.Url) + parsedRepoURL, err := parseGitLabRepositoryURL(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) - projectPath := url.PathEscape(namespace + "/" + project) + projectPath := url.PathEscape(parsedRepoURL.namespace + "/" + parsedRepoURL.project) branches := make([]string, 0) page := 1 for { apiURL := fmt.Sprintf( - "%s/api/v4/projects/%s/repository/branches?per_page=100&page=%d", - baseURL, + "%s/api/v4/projects/%s/repository/branches?per_page=%d&page=%d", + parsedRepoURL.baseURL, projectPath, + providerPageSize, page, ) @@ -165,6 +179,7 @@ func (gitlabProvider) ListBranches(ctx context.Context, repo *models.Repository) } nextPage := strings.TrimSpace(resp.Header.Get("X-Next-Page")) + responseCount := 0 switch resp.StatusCode { case http.StatusOK: @@ -187,6 +202,8 @@ func (gitlabProvider) ListBranches(ctx context.Context, repo *models.Repository) branches = append(branches, branch.Name) } } + + responseCount = len(parsed) case http.StatusUnauthorized, http.StatusForbidden: closeErr := resp.Body.Close() if closeErr != nil { @@ -209,18 +226,28 @@ func (gitlabProvider) ListBranches(ctx context.Context, repo *models.Repository) } if nextPage == "" { - break + if responseCount < providerPageSize { + break + } + + page++ + continue } nextPageNumber, err := strconv.Atoi(nextPage) if err != nil || nextPageNumber <= 0 { - break + if responseCount < providerPageSize { + break + } + + page++ + continue } page = nextPageNumber } - sort.Strings(branches) + sortBranches(branches) return branches, nil } @@ -233,24 +260,22 @@ func (gitlabProvider) ListTree(ctx context.Context, repo *models.Repository, bra return nil, errors.New("branch is required") } - namespace, project, err := parseGitLabURL(repo.Url) + parsedRepoURL, err := parseGitLabRepositoryURL(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) - projectPath := url.PathEscape(namespace + "/" + project) + projectPath := url.PathEscape(parsedRepoURL.namespace + "/" + parsedRepoURL.project) entries := make([]TreeEntry, 0) page := 1 for { apiURL := fmt.Sprintf( - "%s/api/v4/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d", - baseURL, + "%s/api/v4/projects/%s/repository/tree?ref=%s&recursive=true&per_page=%d&page=%d", + parsedRepoURL.baseURL, projectPath, url.QueryEscape(branch), + providerPageSize, page, ) diff --git a/backend/internal/hub/repositories/provider.go b/backend/internal/hub/repositories/provider.go index 65ea8f26..0e5b1127 100644 --- a/backend/internal/hub/repositories/provider.go +++ b/backend/internal/hub/repositories/provider.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "regexp" + "sort" + "strings" "github.com/OrcaCD/orca-cd/internal/hub/models" ) @@ -26,6 +28,8 @@ type TreeEntryType string const ( TreeEntryTypeFile TreeEntryType = "file" TreeEntryTypeDir TreeEntryType = "dir" + httpsScheme = "https" + providerPageSize = 100 ) type TreeEntry struct { @@ -43,6 +47,44 @@ var ownerRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$` // underscores, or dots var repoRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,100}$`) +var commonBranchesByPriority = map[string]int{ + "main": 0, + "master": 1, + "production": 2, + "prod": 3, + "staging": 4, + "develop": 5, + "development": 6, + "dev": 7, +} + +// sortBranches orders common default branches first, then the rest alphabetically. +func sortBranches(branches []string) { + sort.Slice(branches, func(i, j int) bool { + leftBranch := strings.ToLower(branches[i]) + rightBranch := strings.ToLower(branches[j]) + + leftPriority, leftIsCommon := commonBranchesByPriority[leftBranch] + rightPriority, rightIsCommon := commonBranchesByPriority[rightBranch] + + if leftIsCommon && rightIsCommon { + if leftPriority != rightPriority { + return leftPriority < rightPriority + } + } + + if leftIsCommon != rightIsCommon { + return leftIsCommon + } + + if leftBranch == rightBranch { + return branches[i] < branches[j] + } + + return leftBranch < rightBranch + }) +} + func Register(t models.RepositoryProvider, p Provider) { registry[t] = p } diff --git a/backend/internal/hub/repositories/provider_test.go b/backend/internal/hub/repositories/provider_test.go index 8fd72eb1..7c731bb9 100644 --- a/backend/internal/hub/repositories/provider_test.go +++ b/backend/internal/hub/repositories/provider_test.go @@ -3,6 +3,7 @@ package repositories import ( "context" "errors" + "reflect" "testing" "github.com/OrcaCD/orca-cd/internal/hub/models" @@ -132,3 +133,14 @@ func TestStubProvider_Interface(t *testing.T) { // Compile-time check: *stubProvider must satisfy Provider. var _ Provider = (*stubProvider)(nil) } + +func TestSortBranches_PrioritizesCommonBranches(t *testing.T) { + branches := []string{"release", "production", "feature/auth", "master", "main", "develop"} + + sortBranches(branches) + + expected := []string{"main", "master", "production", "develop", "feature/auth", "release"} + if !reflect.DeepEqual(branches, expected) { + t.Fatalf("expected %v, got %v", expected, branches) + } +} diff --git a/frontend/src/components/dialogs/upsert-application.tsx b/frontend/src/components/dialogs/upsert-application.tsx index 2da37e20..24a56c70 100644 --- a/frontend/src/components/dialogs/upsert-application.tsx +++ b/frontend/src/components/dialogs/upsert-application.tsx @@ -139,7 +139,7 @@ function TreeNodeList({