From cea501523e7c183be739680f134f1edf779279c3 Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis-Mekkelholt Date: Tue, 30 Dec 2025 19:40:19 +0100 Subject: [PATCH 1/5] feat(actions): add runs, jobs, and logs commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement tea actions commands to view workflow runs and logs using the Gitea 1.25 API endpoints directly. This adds: - `tea actions runs` - list workflow runs for a repository - `tea actions jobs ` - list jobs for a specific run - `tea actions logs ` - display logs for a specific job Also adds a new `modules/api` package for making raw authenticated HTTP requests to Gitea API endpoints not yet supported by the go-sdk. Closes #1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/actions.go | 3 ++ cmd/actions/jobs.go | 56 +++++++++++++++++++++++ cmd/actions/logs.go | 55 +++++++++++++++++++++++ cmd/actions/runs.go | 50 +++++++++++++++++++++ modules/api/client.go | 97 ++++++++++++++++++++++++++++++++++++++++ modules/api/types.go | 59 ++++++++++++++++++++++++ modules/print/actions.go | 92 +++++++++++++++++++++++++++++++++++++ 7 files changed, 412 insertions(+) create mode 100644 cmd/actions/jobs.go create mode 100644 cmd/actions/logs.go create mode 100644 cmd/actions/runs.go create mode 100644 modules/api/client.go create mode 100644 modules/api/types.go diff --git a/cmd/actions.go b/cmd/actions.go index c6aeb0d..0b887c6 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -22,6 +22,9 @@ var CmdActions = cli.Command{ Commands: []*cli.Command{ &actions.CmdActionsSecrets, &actions.CmdActionsVariables, + &actions.CmdActionsRuns, + &actions.CmdActionsJobs, + &actions.CmdActionsLogs, }, Flags: []cli.Flag{ &cli.StringFlag{ diff --git a/cmd/actions/jobs.go b/cmd/actions/jobs.go new file mode 100644 index 0000000..920b1f6 --- /dev/null +++ b/cmd/actions/jobs.go @@ -0,0 +1,56 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/api" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + "code.gitea.io/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdActionsJobs represents the actions jobs command +var CmdActionsJobs = cli.Command{ + Name: "jobs", + Aliases: []string{"job"}, + Usage: "List jobs for a workflow run", + Description: "List jobs for a specific workflow run", + ArgsUsage: "", + Action: RunActionJobs, + Flags: flags.AllDefaultFlags, +} + +// RunActionJobs lists jobs for a workflow run +func RunActionJobs(ctx stdctx.Context, cmd *cli.Command) error { + c := context.InitCommand(cmd) + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 1 { + return fmt.Errorf("run ID is required") + } + + runID, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return fmt.Errorf("invalid run ID: %w", err) + } + + client := api.NewClient(c.Login) + + path := fmt.Sprintf("/repos/%s/%s/actions/runs/%d/jobs", + c.Owner, c.Repo, runID) + + var jobs api.ActionJobList + if _, err := client.Get(path, &jobs); err != nil { + return err + } + + print.ActionJobsList(jobs.Jobs, c.Output) + return nil +} diff --git a/cmd/actions/logs.go b/cmd/actions/logs.go new file mode 100644 index 0000000..5d127ff --- /dev/null +++ b/cmd/actions/logs.go @@ -0,0 +1,55 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/api" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdActionsLogs represents the actions logs command +var CmdActionsLogs = cli.Command{ + Name: "logs", + Aliases: []string{"log"}, + Usage: "Display logs for a job", + Description: "Display logs for a specific job", + ArgsUsage: "", + Action: RunActionLogs, + Flags: flags.AllDefaultFlags, +} + +// RunActionLogs displays logs for a job +func RunActionLogs(ctx stdctx.Context, cmd *cli.Command) error { + c := context.InitCommand(cmd) + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 1 { + return fmt.Errorf("job ID is required") + } + + jobID, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return fmt.Errorf("invalid job ID: %w", err) + } + + client := api.NewClient(c.Login) + + path := fmt.Sprintf("/repos/%s/%s/actions/jobs/%d/logs", + c.Owner, c.Repo, jobID) + + logs, err := client.GetRaw(path) + if err != nil { + return err + } + + fmt.Print(string(logs)) + return nil +} diff --git a/cmd/actions/runs.go b/cmd/actions/runs.go new file mode 100644 index 0000000..ad80ac4 --- /dev/null +++ b/cmd/actions/runs.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/api" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdActionsRuns represents the actions runs command +var CmdActionsRuns = cli.Command{ + Name: "runs", + Aliases: []string{"run"}, + Usage: "List workflow runs", + Description: "List workflow runs for a repository", + Action: RunActionRuns, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunActionRuns lists workflow runs +func RunActionRuns(ctx stdctx.Context, cmd *cli.Command) error { + c := context.InitCommand(cmd) + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + client := api.NewClient(c.Login) + + path := fmt.Sprintf("/repos/%s/%s/actions/runs?page=%d&limit=%d", + c.Owner, c.Repo, + flags.GetListOptions().Page, + flags.GetListOptions().PageSize) + + var runs api.ActionRunList + if _, err := client.Get(path, &runs); err != nil { + return err + } + + print.ActionRunsList(runs.WorkflowRuns, c.Output) + return nil +} diff --git a/modules/api/client.go b/modules/api/client.go new file mode 100644 index 0000000..d079f5f --- /dev/null +++ b/modules/api/client.go @@ -0,0 +1,97 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + + "code.gitea.io/tea/modules/config" +) + +// Client provides methods for making raw API calls to Gitea +type Client struct { + login *config.Login + httpClient *http.Client +} + +// NewClient creates a new API client from a login +func NewClient(login *config.Login) *Client { + httpClient := &http.Client{} + if login.Insecure { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + } + return &Client{ + login: login, + httpClient: httpClient, + } +} + +// Get makes an authenticated GET request to the API +func (c *Client) Get(path string, result interface{}) (*http.Response, error) { + url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+c.login.Token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return resp, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return resp, fmt.Errorf("failed to decode response: %w", err) + } + } + + return resp, nil +} + +// GetRaw makes an authenticated GET request and returns the raw response body +func (c *Client) GetRaw(path string) ([]byte, error) { + url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "token "+c.login.Token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} diff --git a/modules/api/types.go b/modules/api/types.go new file mode 100644 index 0000000..af479e6 --- /dev/null +++ b/modules/api/types.go @@ -0,0 +1,59 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import "time" + +// ActionRun represents a workflow run +type ActionRun struct { + ID int64 `json:"id"` + Title string `json:"display_title"` + Path string `json:"path"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + RunNumber int64 `json:"run_number"` + RunAttempt int64 `json:"run_attempt"` + HTMLURL string `json:"html_url"` + URL string `json:"url"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +// ActionRunList is a list of workflow runs +type ActionRunList struct { + TotalCount int64 `json:"total_count"` + WorkflowRuns []*ActionRun `json:"workflow_runs"` +} + +// ActionJob represents a job within a workflow run +type ActionJob struct { + ID int64 `json:"id"` + RunID int64 `json:"run_id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + Steps []*ActionJobStep `json:"steps"` +} + +// ActionJobStep represents a step within a job +type ActionJobStep struct { + Name string `json:"name"` + Number int64 `json:"number"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +// ActionJobList is a list of jobs +type ActionJobList struct { + TotalCount int64 `json:"total_count"` + Jobs []*ActionJob `json:"jobs"` +} diff --git a/modules/print/actions.go b/modules/print/actions.go index 39e8680..49f95d4 100644 --- a/modules/print/actions.go +++ b/modules/print/actions.go @@ -7,6 +7,7 @@ import ( "fmt" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/api" ) // ActionSecretsList prints a list of action secrets @@ -74,3 +75,94 @@ func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) { t.sort(0, true) t.print(output) } + +// ActionRunsList prints a list of workflow runs +func ActionRunsList(runs []*api.ActionRun, output string) { + t := table{ + headers: []string{ + "ID", + "Title", + "Status", + "Conclusion", + "Event", + "Branch", + "Started", + }, + } + + for _, run := range runs { + conclusion := run.Conclusion + if conclusion == "" { + conclusion = "-" + } + + started := "" + if run.StartedAt != nil { + started = FormatTime(*run.StartedAt, output != "") + } + + t.addRow( + fmt.Sprintf("%d", run.ID), + run.Title, + run.Status, + conclusion, + run.Event, + run.HeadBranch, + started, + ) + } + + if len(runs) == 0 { + fmt.Printf("No workflow runs found\n") + return + } + + t.print(output) +} + +// ActionJobsList prints a list of jobs +func ActionJobsList(jobs []*api.ActionJob, output string) { + t := table{ + headers: []string{ + "ID", + "Name", + "Status", + "Conclusion", + "Started", + "Completed", + }, + } + + for _, job := range jobs { + conclusion := job.Conclusion + if conclusion == "" { + conclusion = "-" + } + + started := "" + if job.StartedAt != nil { + started = FormatTime(*job.StartedAt, output != "") + } + + completed := "" + if job.CompletedAt != nil { + completed = FormatTime(*job.CompletedAt, output != "") + } + + t.addRow( + fmt.Sprintf("%d", job.ID), + job.Name, + job.Status, + conclusion, + started, + completed, + ) + } + + if len(jobs) == 0 { + fmt.Printf("No jobs found\n") + return + } + + t.print(output) +} From 7aa00e6f54266cf9828f93a016bf7a2c408283db Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis-Mekkelholt Date: Tue, 30 Dec 2025 19:51:52 +0100 Subject: [PATCH 2/5] fix: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix GetRaw() error handling to check status before reading body - Add context.Context support to all HTTP requests - Use consistent capitalized error messages - Update copyright year from 2024 to 2025 - Add unit tests for modules/api/client.go - Add tests for runs, jobs, logs commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/actions/jobs.go | 8 +- cmd/actions/jobs_test.go | 62 +++++++++++ cmd/actions/logs.go | 8 +- cmd/actions/logs_test.go | 62 +++++++++++ cmd/actions/runs.go | 4 +- cmd/actions/runs_test.go | 58 +++++++++++ modules/api/client.go | 32 +++--- modules/api/client_test.go | 203 +++++++++++++++++++++++++++++++++++++ modules/api/types.go | 2 +- 9 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 cmd/actions/jobs_test.go create mode 100644 cmd/actions/logs_test.go create mode 100644 cmd/actions/runs_test.go create mode 100644 modules/api/client_test.go diff --git a/cmd/actions/jobs.go b/cmd/actions/jobs.go index 920b1f6..83e0332 100644 --- a/cmd/actions/jobs.go +++ b/cmd/actions/jobs.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions @@ -33,12 +33,12 @@ func RunActionJobs(ctx stdctx.Context, cmd *cli.Command) error { c.Ensure(context.CtxRequirement{RemoteRepo: true}) if cmd.Args().Len() < 1 { - return fmt.Errorf("run ID is required") + return fmt.Errorf("Run ID is required") } runID, err := utils.ArgToIndex(cmd.Args().First()) if err != nil { - return fmt.Errorf("invalid run ID: %w", err) + return fmt.Errorf("Invalid run ID: %w", err) } client := api.NewClient(c.Login) @@ -47,7 +47,7 @@ func RunActionJobs(ctx stdctx.Context, cmd *cli.Command) error { c.Owner, c.Repo, runID) var jobs api.ActionJobList - if _, err := client.Get(path, &jobs); err != nil { + if _, err := client.Get(ctx, path, &jobs); err != nil { return err } diff --git a/cmd/actions/jobs_test.go b/cmd/actions/jobs_test.go new file mode 100644 index 0000000..5a4f535 --- /dev/null +++ b/cmd/actions/jobs_test.go @@ -0,0 +1,62 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" +) + +func TestJobsCommandFlags(t *testing.T) { + cmd := CmdActionsJobs + + // Test that required flags exist + expectedFlags := []string{"output", "remote", "login", "repo"} + + for _, flagName := range expectedFlags { + found := false + for _, flag := range cmd.Flags { + for _, name := range flag.Names() { + if name == flagName { + found = true + break + } + } + if found { + break + } + } + + if !found { + t.Errorf("Expected flag %s not found in CmdActionsJobs", flagName) + } + } +} + +func TestJobsCommandProperties(t *testing.T) { + cmd := CmdActionsJobs + + if cmd.Name != "jobs" { + t.Errorf("Expected command name 'jobs', got %s", cmd.Name) + } + + if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "job" { + t.Errorf("Expected alias 'job' for jobs command") + } + + if cmd.Usage == "" { + t.Error("Jobs command should have usage text") + } + + if cmd.Description == "" { + t.Error("Jobs command should have description") + } + + if cmd.ArgsUsage != "" { + t.Errorf("Expected ArgsUsage '', got %s", cmd.ArgsUsage) + } + + if cmd.Action == nil { + t.Error("Jobs command should have an action") + } +} diff --git a/cmd/actions/logs.go b/cmd/actions/logs.go index 5d127ff..772ffff 100644 --- a/cmd/actions/logs.go +++ b/cmd/actions/logs.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions @@ -32,12 +32,12 @@ func RunActionLogs(ctx stdctx.Context, cmd *cli.Command) error { c.Ensure(context.CtxRequirement{RemoteRepo: true}) if cmd.Args().Len() < 1 { - return fmt.Errorf("job ID is required") + return fmt.Errorf("Job ID is required") } jobID, err := utils.ArgToIndex(cmd.Args().First()) if err != nil { - return fmt.Errorf("invalid job ID: %w", err) + return fmt.Errorf("Invalid job ID: %w", err) } client := api.NewClient(c.Login) @@ -45,7 +45,7 @@ func RunActionLogs(ctx stdctx.Context, cmd *cli.Command) error { path := fmt.Sprintf("/repos/%s/%s/actions/jobs/%d/logs", c.Owner, c.Repo, jobID) - logs, err := client.GetRaw(path) + logs, err := client.GetRaw(ctx, path) if err != nil { return err } diff --git a/cmd/actions/logs_test.go b/cmd/actions/logs_test.go new file mode 100644 index 0000000..bfdc2ca --- /dev/null +++ b/cmd/actions/logs_test.go @@ -0,0 +1,62 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" +) + +func TestLogsCommandFlags(t *testing.T) { + cmd := CmdActionsLogs + + // Test that required flags exist + expectedFlags := []string{"output", "remote", "login", "repo"} + + for _, flagName := range expectedFlags { + found := false + for _, flag := range cmd.Flags { + for _, name := range flag.Names() { + if name == flagName { + found = true + break + } + } + if found { + break + } + } + + if !found { + t.Errorf("Expected flag %s not found in CmdActionsLogs", flagName) + } + } +} + +func TestLogsCommandProperties(t *testing.T) { + cmd := CmdActionsLogs + + if cmd.Name != "logs" { + t.Errorf("Expected command name 'logs', got %s", cmd.Name) + } + + if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "log" { + t.Errorf("Expected alias 'log' for logs command") + } + + if cmd.Usage == "" { + t.Error("Logs command should have usage text") + } + + if cmd.Description == "" { + t.Error("Logs command should have description") + } + + if cmd.ArgsUsage != "" { + t.Errorf("Expected ArgsUsage '', got %s", cmd.ArgsUsage) + } + + if cmd.Action == nil { + t.Error("Logs command should have an action") + } +} diff --git a/cmd/actions/runs.go b/cmd/actions/runs.go index ad80ac4..7daabae 100644 --- a/cmd/actions/runs.go +++ b/cmd/actions/runs.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions @@ -41,7 +41,7 @@ func RunActionRuns(ctx stdctx.Context, cmd *cli.Command) error { flags.GetListOptions().PageSize) var runs api.ActionRunList - if _, err := client.Get(path, &runs); err != nil { + if _, err := client.Get(ctx, path, &runs); err != nil { return err } diff --git a/cmd/actions/runs_test.go b/cmd/actions/runs_test.go new file mode 100644 index 0000000..aad2ac1 --- /dev/null +++ b/cmd/actions/runs_test.go @@ -0,0 +1,58 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" +) + +func TestRunsCommandFlags(t *testing.T) { + cmd := CmdActionsRuns + + // Test that required flags exist + expectedFlags := []string{"page", "limit", "output", "remote", "login", "repo"} + + for _, flagName := range expectedFlags { + found := false + for _, flag := range cmd.Flags { + for _, name := range flag.Names() { + if name == flagName { + found = true + break + } + } + if found { + break + } + } + + if !found { + t.Errorf("Expected flag %s not found in CmdActionsRuns", flagName) + } + } +} + +func TestRunsCommandProperties(t *testing.T) { + cmd := CmdActionsRuns + + if cmd.Name != "runs" { + t.Errorf("Expected command name 'runs', got %s", cmd.Name) + } + + if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "run" { + t.Errorf("Expected alias 'run' for runs command") + } + + if cmd.Usage == "" { + t.Error("Runs command should have usage text") + } + + if cmd.Description == "" { + t.Error("Runs command should have description") + } + + if cmd.Action == nil { + t.Error("Runs command should have an action") + } +} diff --git a/modules/api/client.go b/modules/api/client.go index d079f5f..31b2a4d 100644 --- a/modules/api/client.go +++ b/modules/api/client.go @@ -1,9 +1,10 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package api import ( + "context" "crypto/tls" "encoding/json" "fmt" @@ -35,13 +36,13 @@ func NewClient(login *config.Login) *Client { } } -// Get makes an authenticated GET request to the API -func (c *Client) Get(path string, result interface{}) (*http.Response, error) { +// Get makes an authenticated GET request to the API and decodes the JSON response +func (c *Client) Get(ctx context.Context, path string, result interface{}) (*http.Response, error) { url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.login.Token) @@ -49,7 +50,7 @@ func (c *Client) Get(path string, result interface{}) (*http.Response, error) { resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() @@ -68,29 +69,30 @@ func (c *Client) Get(path string, result interface{}) (*http.Response, error) { } // GetRaw makes an authenticated GET request and returns the raw response body -func (c *Client) GetRaw(path string) ([]byte, error) { +func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "token "+c.login.Token) resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) } return body, nil diff --git a/modules/api/client_test.go b/modules/api/client_test.go new file mode 100644 index 0000000..963d265 --- /dev/null +++ b/modules/api/client_test.go @@ -0,0 +1,203 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "code.gitea.io/tea/modules/config" +) + +func TestNewClient(t *testing.T) { + login := &config.Login{ + URL: "https://gitea.example.com", + Token: "test-token", + Insecure: false, + } + + client := NewClient(login) + if client == nil { + t.Fatal("NewClient returned nil") + } + + if client.login != login { + t.Error("Client login not set correctly") + } + + if client.httpClient == nil { + t.Error("Client httpClient not set") + } +} + +func TestNewClientInsecure(t *testing.T) { + login := &config.Login{ + URL: "https://gitea.example.com", + Token: "test-token", + Insecure: true, + } + + client := NewClient(login) + if client == nil { + t.Fatal("NewClient returned nil") + } + + // Verify that insecure transport is configured + if client.httpClient.Transport == nil { + t.Error("Expected custom transport for insecure client") + } +} + +func TestGet(t *testing.T) { + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "GET" { + t.Errorf("Expected GET request, got %s", r.Method) + } + + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected authorization header, got %s", r.Header.Get("Authorization")) + } + + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Expected accept header, got %s", r.Header.Get("Accept")) + } + + // Return test response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + })) + defer server.Close() + + login := &config.Login{ + URL: server.URL, + Token: "test-token", + } + + client := NewClient(login) + + var result map[string]string + _, err := client.Get(context.Background(), "/test", &result) + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + + if result["status"] != "ok" { + t.Errorf("Expected status 'ok', got %s", result["status"]) + } +} + +func TestGetError(t *testing.T) { + // Create a test server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + })) + defer server.Close() + + login := &config.Login{ + URL: server.URL, + Token: "test-token", + } + + client := NewClient(login) + + var result map[string]string + _, err := client.Get(context.Background(), "/test", &result) + if err == nil { + t.Fatal("Expected error for 404 response") + } + + expectedError := "API request failed with status 404" + if err.Error()[:len(expectedError)] != expectedError { + t.Errorf("Expected error starting with '%s', got '%s'", expectedError, err.Error()) + } +} + +func TestGetRaw(t *testing.T) { + expectedBody := "raw log content here" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("Expected GET request, got %s", r.Method) + } + + if r.Header.Get("Authorization") != "token test-token" { + t.Errorf("Expected authorization header, got %s", r.Header.Get("Authorization")) + } + + w.Write([]byte(expectedBody)) + })) + defer server.Close() + + login := &config.Login{ + URL: server.URL, + Token: "test-token", + } + + client := NewClient(login) + + body, err := client.GetRaw(context.Background(), "/logs") + if err != nil { + t.Fatalf("GetRaw returned error: %v", err) + } + + if string(body) != expectedBody { + t.Errorf("Expected body '%s', got '%s'", expectedBody, string(body)) + } +} + +func TestGetRawError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer server.Close() + + login := &config.Login{ + URL: server.URL, + Token: "test-token", + } + + client := NewClient(login) + + _, err := client.GetRaw(context.Background(), "/logs") + if err == nil { + t.Fatal("Expected error for 500 response") + } + + expectedError := "API request failed with status 500" + if err.Error()[:len(expectedError)] != expectedError { + t.Errorf("Expected error starting with '%s', got '%s'", expectedError, err.Error()) + } +} + +func TestGetWithContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // This should not be reached if context is cancelled + w.Write([]byte("ok")) + })) + defer server.Close() + + login := &config.Login{ + URL: server.URL, + Token: "test-token", + } + + client := NewClient(login) + + // Create a cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + var result map[string]string + _, err := client.Get(ctx, "/test", &result) + if err == nil { + t.Fatal("Expected error for cancelled context") + } +} diff --git a/modules/api/types.go b/modules/api/types.go index af479e6..92dd912 100644 --- a/modules/api/types.go +++ b/modules/api/types.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package api From 9fdbd4aafcb21d957e63824b2926e2558bef2c94 Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis-Mekkelholt Date: Tue, 30 Dec 2025 19:57:49 +0100 Subject: [PATCH 3/5] fix: formatting and error message capitalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run go fmt on modules/api/types.go to fix struct alignment - Use lowercase error messages to match codebase conventions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/actions/jobs.go | 4 ++-- cmd/actions/logs.go | 4 ++-- modules/api/types.go | 28 ++++++++++++++-------------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd/actions/jobs.go b/cmd/actions/jobs.go index 83e0332..dd914b0 100644 --- a/cmd/actions/jobs.go +++ b/cmd/actions/jobs.go @@ -33,12 +33,12 @@ func RunActionJobs(ctx stdctx.Context, cmd *cli.Command) error { c.Ensure(context.CtxRequirement{RemoteRepo: true}) if cmd.Args().Len() < 1 { - return fmt.Errorf("Run ID is required") + return fmt.Errorf("run ID is required") } runID, err := utils.ArgToIndex(cmd.Args().First()) if err != nil { - return fmt.Errorf("Invalid run ID: %w", err) + return fmt.Errorf("invalid run ID: %w", err) } client := api.NewClient(c.Login) diff --git a/cmd/actions/logs.go b/cmd/actions/logs.go index 772ffff..34d0ab8 100644 --- a/cmd/actions/logs.go +++ b/cmd/actions/logs.go @@ -32,12 +32,12 @@ func RunActionLogs(ctx stdctx.Context, cmd *cli.Command) error { c.Ensure(context.CtxRequirement{RemoteRepo: true}) if cmd.Args().Len() < 1 { - return fmt.Errorf("Job ID is required") + return fmt.Errorf("job ID is required") } jobID, err := utils.ArgToIndex(cmd.Args().First()) if err != nil { - return fmt.Errorf("Invalid job ID: %w", err) + return fmt.Errorf("invalid job ID: %w", err) } client := api.NewClient(c.Login) diff --git a/modules/api/types.go b/modules/api/types.go index 92dd912..7ecce2b 100644 --- a/modules/api/types.go +++ b/modules/api/types.go @@ -7,20 +7,20 @@ import "time" // ActionRun represents a workflow run type ActionRun struct { - ID int64 `json:"id"` - Title string `json:"display_title"` - Path string `json:"path"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - Event string `json:"event"` - HeadBranch string `json:"head_branch"` - HeadSHA string `json:"head_sha"` - RunNumber int64 `json:"run_number"` - RunAttempt int64 `json:"run_attempt"` - HTMLURL string `json:"html_url"` - URL string `json:"url"` - StartedAt *time.Time `json:"started_at"` - CompletedAt *time.Time `json:"completed_at"` + ID int64 `json:"id"` + Title string `json:"display_title"` + Path string `json:"path"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + RunNumber int64 `json:"run_number"` + RunAttempt int64 `json:"run_attempt"` + HTMLURL string `json:"html_url"` + URL string `json:"url"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` } // ActionRunList is a list of workflow runs From 38f93a4997484c461611cc9d597c417949b14fb4 Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis-Mekkelholt Date: Tue, 30 Dec 2025 20:02:57 +0100 Subject: [PATCH 4/5] docs: generate documentation for actions commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/CLI.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/CLI.md b/docs/CLI.md index fae47c6..6307b6f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1377,6 +1377,46 @@ Delete an action variable **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +### runs, run + +List workflow runs + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### jobs, job + +List jobs for a workflow run + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### logs, log + +Display logs for a job + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + ## webhooks, webhook, hooks, hook Manage webhooks From 6e8813230549f24223ca2dedf90c5920c0030ab2 Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis-Mekkelholt Date: Tue, 30 Dec 2025 20:07:01 +0100 Subject: [PATCH 5/5] chore: retrigger CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5