From d39525c5d62b2d5865ce27333da6ad36489b97a7 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 10 Mar 2026 19:04:07 +0000 Subject: [PATCH 1/2] Implement CI service for Gitea/Forgejo Implement ListRuns, GetRun, and GetJobLog using the Gitea SDK's ActionWorkflowRun APIs (requires Gitea 1.25.0+). TriggerRun, CancelRun, and RetryRun still return ErrNotSupported since the SDK lacks those endpoints. --- gitea/ci.go | 142 ++++++++++++++++++++++++++-- gitea/ci_test.go | 234 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 gitea/ci_test.go diff --git a/gitea/ci.go b/gitea/ci.go index f39a43b..db2ddd8 100644 --- a/gitea/ci.go +++ b/gitea/ci.go @@ -1,23 +1,140 @@ package gitea import ( + "bytes" "context" - forge "github.com/git-pkgs/forge" "io" + "net/http" + + forge "github.com/git-pkgs/forge" + + "code.gitea.io/sdk/gitea" ) -type giteaCIService struct{} +type giteaCIService struct { + client *gitea.Client +} func (f *giteaForge) CI() forge.CIService { - return &giteaCIService{} + return &giteaCIService{client: f.client} } -func (s *giteaCIService) ListRuns(_ context.Context, _, _ string, _ forge.ListCIRunOpts) ([]forge.CIRun, error) { - return nil, forge.ErrNotSupported +func convertGiteaWorkflowRun(r *gitea.ActionWorkflowRun) forge.CIRun { + result := forge.CIRun{ + ID: r.ID, + Title: r.DisplayTitle, + Status: r.Status, + Branch: r.HeadBranch, + SHA: r.HeadSha, + Event: r.Event, + HTMLURL: r.HTMLURL, + CreatedAt: r.StartedAt, + } + + if r.Conclusion != "" { + result.Conclusion = r.Conclusion + } + + if r.Actor != nil { + result.Author = forge.User{ + Login: r.Actor.UserName, + AvatarURL: r.Actor.AvatarURL, + } + } + + if !r.CompletedAt.IsZero() { + t := r.CompletedAt + result.FinishedAt = &t + } + + return result } -func (s *giteaCIService) GetRun(_ context.Context, _, _ string, _ int64) (*forge.CIRun, error) { - return nil, forge.ErrNotSupported +func convertGiteaWorkflowJob(j *gitea.ActionWorkflowJob) forge.CIJob { + job := forge.CIJob{ + ID: j.ID, + Name: j.Name, + Status: j.Status, + Conclusion: j.Conclusion, + HTMLURL: j.HTMLURL, + } + if !j.StartedAt.IsZero() { + t := j.StartedAt + job.StartedAt = &t + } + if !j.CompletedAt.IsZero() { + t := j.CompletedAt + job.FinishedAt = &t + } + return job +} + +func (s *giteaCIService) ListRuns(_ context.Context, owner, repo string, opts forge.ListCIRunOpts) ([]forge.CIRun, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = 20 + } + page := opts.Page + if page <= 0 { + page = 1 + } + + gOpts := gitea.ListRepoActionRunsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: perPage}, + } + if opts.Branch != "" { + gOpts.Branch = opts.Branch + } + if opts.Status != "" { + gOpts.Status = opts.Status + } + if opts.User != "" { + gOpts.Actor = opts.User + } + + var all []forge.CIRun + for { + resp, httpResp, err := s.client.ListRepoActionRuns(owner, repo, gOpts) + if err != nil { + if httpResp != nil && httpResp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, r := range resp.WorkflowRuns { + all = append(all, convertGiteaWorkflowRun(r)) + } + if len(resp.WorkflowRuns) < perPage || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + gOpts.Page++ + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +func (s *giteaCIService) GetRun(_ context.Context, owner, repo string, runID int64) (*forge.CIRun, error) { + r, resp, err := s.client.GetRepoActionRun(owner, repo, runID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGiteaWorkflowRun(r) + + jobs, _, err := s.client.ListRepoActionRunJobs(owner, repo, runID, gitea.ListRepoActionJobsOptions{}) + if err == nil { + for _, j := range jobs.Jobs { + result.Jobs = append(result.Jobs, convertGiteaWorkflowJob(j)) + } + } + + return &result, nil } func (s *giteaCIService) TriggerRun(_ context.Context, _, _ string, _ forge.TriggerCIRunOpts) error { @@ -32,6 +149,13 @@ func (s *giteaCIService) RetryRun(_ context.Context, _, _ string, _ int64) error return forge.ErrNotSupported } -func (s *giteaCIService) GetJobLog(_ context.Context, _, _ string, _ int64) (io.ReadCloser, error) { - return nil, forge.ErrNotSupported +func (s *giteaCIService) GetJobLog(_ context.Context, owner, repo string, jobID int64) (io.ReadCloser, error) { + data, resp, err := s.client.GetRepoActionJobLogs(owner, repo, jobID) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + return io.NopCloser(bytes.NewReader(data)), nil } diff --git a/gitea/ci_test.go b/gitea/ci_test.go new file mode 100644 index 0000000..ecf3695 --- /dev/null +++ b/gitea/ci_test.go @@ -0,0 +1,234 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" + forge "github.com/git-pkgs/forge" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func giteaVersionHandler125(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprintf(w, `{"version":"1.25.0"}`) +} + +func TestGiteaCIListRuns(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/actions/runs", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "total_count": 1, + "workflow_runs": []map[string]any{ + { + "id": 42, + "display_title": "CI Pipeline", + "status": "completed", + "conclusion": "success", + "head_branch": "main", + "head_sha": "abc123", + "event": "push", + "html_url": "https://codeberg.org/testorg/testrepo/actions/runs/42", + "actor": map[string]any{ + "login": "testuser", + "avatar_url": "https://codeberg.org/avatars/1", + }, + }, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + runs, err := f.CI().ListRuns(context.Background(), "testorg", "testrepo", forge.ListCIRunOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(runs) != 1 { + t.Fatalf("expected 1 run, got %d", len(runs)) + } + + r := runs[0] + if r.ID != 42 { + t.Errorf("ID: want 42, got %d", r.ID) + } + assertEqual(t, "Title", "CI Pipeline", r.Title) + assertEqual(t, "Status", "completed", r.Status) + assertEqual(t, "Conclusion", "success", r.Conclusion) + assertEqual(t, "Branch", "main", r.Branch) + assertEqual(t, "SHA", "abc123", r.SHA) + assertEqual(t, "Event", "push", r.Event) + assertEqual(t, "Author.Login", "testuser", r.Author.Login) +} + +func TestGiteaCIListRunsWithFilters(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/actions/runs", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("branch") != "develop" { + t.Errorf("expected branch=develop, got %q", q.Get("branch")) + } + if q.Get("status") != "running" { + t.Errorf("expected status=running, got %q", q.Get("status")) + } + if q.Get("actor") != "testuser" { + t.Errorf("expected actor=testuser, got %q", q.Get("actor")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "total_count": 0, + "workflow_runs": []map[string]any{}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + runs, err := f.CI().ListRuns(context.Background(), "testorg", "testrepo", forge.ListCIRunOpts{ + Branch: "develop", + Status: "running", + User: "testuser", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(runs) != 0 { + t.Fatalf("expected 0 runs, got %d", len(runs)) + } +} + +func TestGiteaCIGetRun(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/actions/runs/42", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 42, + "display_title": "CI Pipeline", + "status": "completed", + "conclusion": "success", + "head_branch": "main", + "head_sha": "abc123", + }) + }) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/actions/runs/42/jobs", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "total_count": 1, + "jobs": []map[string]any{ + { + "id": 100, + "name": "build", + "status": "completed", + "conclusion": "success", + "html_url": "https://codeberg.org/testorg/testrepo/actions/runs/42/jobs/100", + }, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + run, err := f.CI().GetRun(context.Background(), "testorg", "testrepo", 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run.ID != 42 { + t.Errorf("ID: want 42, got %d", run.ID) + } + assertEqual(t, "Title", "CI Pipeline", run.Title) + assertEqual(t, "Conclusion", "success", run.Conclusion) + if len(run.Jobs) != 1 { + t.Fatalf("expected 1 job, got %d", len(run.Jobs)) + } + assertEqual(t, "Jobs[0].Name", "build", run.Jobs[0].Name) + assertEqual(t, "Jobs[0].Status", "completed", run.Jobs[0].Status) +} + +func TestGiteaCIGetRunNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/actions/runs/999", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + _, err := f.CI().GetRun(context.Background(), "testorg", "testrepo", 999) + if err != forge.ErrNotFound { + t.Fatalf("expected forge.ErrNotFound, got %v", err) + } +} + +func TestGiteaCIGetJobLog(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + mux.HandleFunc("GET /api/v1/repos/testorg/testrepo/actions/jobs/100/logs", func(w http.ResponseWriter, r *http.Request) { + _, _ = fmt.Fprint(w, "Build started\nStep 1: compile\nBuild finished") + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + rc, err := f.CI().GetJobLog(context.Background(), "testorg", "testrepo", 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("failed to read log: %v", err) + } + assertEqual(t, "log content", "Build started\nStep 1: compile\nBuild finished", string(data)) +} + +func TestGiteaCITriggerRunNotSupported(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + err := f.CI().TriggerRun(context.Background(), "testorg", "testrepo", forge.TriggerCIRunOpts{}) + if err != forge.ErrNotSupported { + t.Fatalf("expected forge.ErrNotSupported, got %v", err) + } +} + +func TestGiteaCICancelRunNotSupported(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + err := f.CI().CancelRun(context.Background(), "testorg", "testrepo", 42) + if err != forge.ErrNotSupported { + t.Fatalf("expected forge.ErrNotSupported, got %v", err) + } +} + +func TestGiteaCIRetryRunNotSupported(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler125) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + err := f.CI().RetryRun(context.Background(), "testorg", "testrepo", 42) + if err != forge.ErrNotSupported { + t.Fatalf("expected forge.ErrNotSupported, got %v", err) + } +} From ca34bfacb56e325584257faf7c7560b90967d414 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 10 Mar 2026 19:06:14 +0000 Subject: [PATCH 2/2] Fix errcheck lint on deferred Close call --- gitea/ci_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gitea/ci_test.go b/gitea/ci_test.go index ecf3695..b29d650 100644 --- a/gitea/ci_test.go +++ b/gitea/ci_test.go @@ -182,7 +182,7 @@ func TestGiteaCIGetJobLog(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - defer rc.Close() + defer func() { _ = rc.Close() }() data, err := io.ReadAll(rc) if err != nil {