Skip to content

Commit ae41045

Browse files
alex289Copilot
andauthored
feat: Branch and file selector (#102)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 5e75aa0 commit ae41045

17 files changed

Lines changed: 2630 additions & 101 deletions

backend/.golangci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,5 @@ linters:
1616
- zerologlint
1717
- gocritic
1818
- bodyclose
19-
- goconst
2019
- modernize
2120
- unparam

backend/internal/hub/handlers.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ func RegisterRoutes(router *gin.Engine, cfg Config) error {
6262
protected.DELETE("/repositories/:id", routes.DeleteRepositoryHandler)
6363
protected.POST("/repositories/test-connection", routes.TestConnectionHandler)
6464
protected.PUT("/repositories/:id", routes.UpdateRepositoryHandler)
65+
protected.GET("/repositories/:id/branches", routes.ListRepositoryBranchesHandler)
66+
protected.GET("/repositories/:id/tree", routes.ListRepositoryTreeHandler)
6567

6668
protected.GET("/agents", routes.ListAgentsHandler)
6769
protected.POST("/agents", routes.CreateAgentHandler)

backend/internal/hub/repositories/gitea.go

Lines changed: 214 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package repositories
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"net/http"
89
"net/url"
10+
"sort"
911
"strings"
1012

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

1517
type giteaProvider struct{}
1618

17-
const httpsScheme = "https"
19+
type parsedGiteaRepositoryURL struct {
20+
baseURL string
21+
owner string
22+
repo string
23+
}
24+
25+
type giteaBranch struct {
26+
Name string `json:"name"`
27+
}
28+
29+
type giteaTreeResponse struct {
30+
Tree []struct {
31+
Path string `json:"path"`
32+
Type string `json:"type"`
33+
} `json:"tree"`
34+
}
1835

1936
func init() {
2037
Register(models.Gitea, giteaProvider{})
@@ -23,15 +40,24 @@ func init() {
2340
// parseGiteaURL validates a Gitea repository URL (including self-hosted instances)
2441
// and returns the owner and repository name.
2542
func parseGiteaURL(rawURL string) (owner, repo string, err error) {
43+
parsedRepoURL, err := parseGiteaRepositoryURL(rawURL)
44+
if err != nil {
45+
return "", "", err
46+
}
47+
48+
return parsedRepoURL.owner, parsedRepoURL.repo, nil
49+
}
50+
51+
func parseGiteaRepositoryURL(rawURL string) (parsedGiteaRepositoryURL, error) {
2652
u, err := url.Parse(rawURL)
2753
if err != nil {
28-
return "", "", errors.New("invalid URL")
54+
return parsedGiteaRepositoryURL{}, errors.New("invalid URL")
2955
}
3056
if u.Scheme != httpsScheme {
31-
return "", "", fmt.Errorf("URL must use %s", httpsScheme)
57+
return parsedGiteaRepositoryURL{}, fmt.Errorf("URL must use %s", httpsScheme)
3258
}
3359
if u.Host == "" {
34-
return "", "", errors.New("URL must have a valid host")
60+
return parsedGiteaRepositoryURL{}, errors.New("URL must have a valid host")
3561
}
3662

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

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

46-
owner, repo = parts[0], parts[1]
72+
owner, repo := parts[0], parts[1]
4773
if !ownerRe.MatchString(owner) {
48-
return "", "", errors.New("invalid Gitea owner name")
74+
return parsedGiteaRepositoryURL{}, errors.New("invalid Gitea owner name")
4975
}
5076
if !repoRe.MatchString(repo) {
51-
return "", "", errors.New("invalid Gitea repository name")
77+
return parsedGiteaRepositoryURL{}, errors.New("invalid Gitea repository name")
5278
}
5379

54-
return owner, repo, nil
80+
return parsedGiteaRepositoryURL{
81+
baseURL: fmt.Sprintf("%s://%s", u.Scheme, u.Host),
82+
owner: owner,
83+
repo: repo,
84+
}, nil
5585
}
5686

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

73-
owner, repoName, err := parseGiteaURL(repo.Url)
103+
parsedRepoURL, err := parseGiteaRepositoryURL(repo.Url)
74104
if err != nil {
75105
return fmt.Errorf("invalid repository URL: %w", err)
76106
}
77107

78-
u, _ := url.Parse(repo.Url)
79-
baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
80-
81-
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s", baseURL, url.PathEscape(owner), url.PathEscape(repoName))
108+
apiURL := fmt.Sprintf(
109+
"%s/api/v1/repos/%s/%s",
110+
parsedRepoURL.baseURL,
111+
url.PathEscape(parsedRepoURL.owner),
112+
url.PathEscape(parsedRepoURL.repo),
113+
)
82114
req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil)
83115
if err != nil {
84116
return fmt.Errorf("failed to build Gitea request: %w", err)
85117
}
86-
87-
req.Header.Set("Accept", "application/json")
88-
89-
if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil {
90-
token := strings.TrimSpace(repo.AuthToken.String())
91-
if token != "" {
92-
req.Header.Set("Authorization", "token "+token)
93-
}
94-
}
118+
addGiteaHeaders(req, repo)
95119

96120
resp, err := httpclient.Default.Do(req)
97121
if err != nil {
@@ -115,3 +139,170 @@ func (giteaProvider) TestConnection(ctx context.Context, repo *models.Repository
115139
return fmt.Errorf("gitea API returned unexpected status: %d", resp.StatusCode)
116140
}
117141
}
142+
143+
func (giteaProvider) ListBranches(ctx context.Context, repo *models.Repository) ([]string, error) {
144+
if repo == nil {
145+
return nil, errors.New("repository is required")
146+
}
147+
148+
parsedRepoURL, err := parseGiteaRepositoryURL(repo.Url)
149+
if err != nil {
150+
return nil, fmt.Errorf("invalid repository URL: %w", err)
151+
}
152+
153+
branches := make([]string, 0)
154+
155+
for page := 1; ; page++ {
156+
apiURL := fmt.Sprintf(
157+
"%s/api/v1/repos/%s/%s/branches?page=%d&limit=%d",
158+
parsedRepoURL.baseURL,
159+
url.PathEscape(parsedRepoURL.owner),
160+
url.PathEscape(parsedRepoURL.repo),
161+
page,
162+
providerPageSize,
163+
)
164+
165+
req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil)
166+
if err != nil {
167+
return nil, fmt.Errorf("failed to build Gitea request: %w", err)
168+
}
169+
addGiteaHeaders(req, repo)
170+
171+
resp, err := httpclient.Default.Do(req)
172+
if err != nil {
173+
return nil, fmt.Errorf("failed to fetch Gitea branches: %w", err)
174+
}
175+
176+
switch resp.StatusCode {
177+
case http.StatusOK:
178+
var parsed []giteaBranch
179+
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
180+
closeErr := resp.Body.Close()
181+
if closeErr != nil {
182+
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
183+
}
184+
return nil, fmt.Errorf("failed to decode Gitea branches response: %w", err)
185+
}
186+
187+
closeErr := resp.Body.Close()
188+
if closeErr != nil {
189+
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
190+
}
191+
192+
for _, branch := range parsed {
193+
if branch.Name != "" {
194+
branches = append(branches, branch.Name)
195+
}
196+
}
197+
198+
if len(parsed) < providerPageSize {
199+
sortBranches(branches)
200+
return branches, nil
201+
}
202+
case http.StatusUnauthorized, http.StatusForbidden:
203+
closeErr := resp.Body.Close()
204+
if closeErr != nil {
205+
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
206+
}
207+
return nil, errors.New("authentication failed or access denied")
208+
case http.StatusNotFound:
209+
closeErr := resp.Body.Close()
210+
if closeErr != nil {
211+
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
212+
}
213+
return nil, errors.New("repository not found or access denied")
214+
default:
215+
statusCode := resp.StatusCode
216+
closeErr := resp.Body.Close()
217+
if closeErr != nil {
218+
fmt.Printf("warning: failed to close Gitea response body: %v\n", closeErr)
219+
}
220+
return nil, fmt.Errorf("gitea API returned unexpected status: %d", statusCode)
221+
}
222+
}
223+
}
224+
225+
func (giteaProvider) ListTree(ctx context.Context, repo *models.Repository, branch string) ([]TreeEntry, error) {
226+
if repo == nil {
227+
return nil, errors.New("repository is required")
228+
}
229+
230+
if strings.TrimSpace(branch) == "" {
231+
return nil, errors.New("branch is required")
232+
}
233+
234+
parsedRepoURL, err := parseGiteaRepositoryURL(repo.Url)
235+
if err != nil {
236+
return nil, fmt.Errorf("invalid repository URL: %w", err)
237+
}
238+
239+
apiURL := fmt.Sprintf(
240+
"%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true",
241+
parsedRepoURL.baseURL,
242+
url.PathEscape(parsedRepoURL.owner),
243+
url.PathEscape(parsedRepoURL.repo),
244+
url.PathEscape(branch),
245+
)
246+
247+
req, err := httpclient.NewRequest(ctx, http.MethodGet, apiURL, nil)
248+
if err != nil {
249+
return nil, fmt.Errorf("failed to build Gitea request: %w", err)
250+
}
251+
addGiteaHeaders(req, repo)
252+
253+
resp, err := httpclient.Default.Do(req)
254+
if err != nil {
255+
return nil, fmt.Errorf("failed to fetch Gitea repository tree: %w", err)
256+
}
257+
defer func() {
258+
err := resp.Body.Close()
259+
if err != nil {
260+
fmt.Printf("warning: failed to close Gitea response body: %v\n", err)
261+
}
262+
}()
263+
264+
switch resp.StatusCode {
265+
case http.StatusOK:
266+
var parsed giteaTreeResponse
267+
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
268+
return nil, fmt.Errorf("failed to decode Gitea tree response: %w", err)
269+
}
270+
271+
entries := make([]TreeEntry, 0, len(parsed.Tree))
272+
for _, entry := range parsed.Tree {
273+
if entry.Path == "" {
274+
continue
275+
}
276+
277+
switch entry.Type {
278+
case "blob":
279+
entries = append(entries, TreeEntry{Path: entry.Path, Type: TreeEntryTypeFile})
280+
case "tree":
281+
entries = append(entries, TreeEntry{Path: entry.Path, Type: TreeEntryTypeDir})
282+
}
283+
}
284+
285+
sort.Slice(entries, func(i, j int) bool {
286+
return entries[i].Path < entries[j].Path
287+
})
288+
289+
return entries, nil
290+
case http.StatusUnauthorized, http.StatusForbidden:
291+
return nil, errors.New("authentication failed or access denied")
292+
case http.StatusNotFound:
293+
return nil, errors.New("repository not found or access denied")
294+
default:
295+
return nil, fmt.Errorf("gitea API returned unexpected status: %d", resp.StatusCode)
296+
}
297+
}
298+
299+
func addGiteaHeaders(req *http.Request, repo *models.Repository) {
300+
req.Header.Set("Accept", "application/json")
301+
302+
if repo.AuthMethod == models.AuthMethodToken && repo.AuthToken != nil {
303+
token := strings.TrimSpace(repo.AuthToken.String())
304+
if token != "" {
305+
req.Header.Set("Authorization", "token "+token)
306+
}
307+
}
308+
}

0 commit comments

Comments
 (0)