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..dd914b0 --- /dev/null +++ b/cmd/actions/jobs.go @@ -0,0 +1,56 @@ +// Copyright 2025 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(ctx, path, &jobs); err != nil { + return err + } + + print.ActionJobsList(jobs.Jobs, c.Output) + return nil +} 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 new file mode 100644 index 0000000..34d0ab8 --- /dev/null +++ b/cmd/actions/logs.go @@ -0,0 +1,55 @@ +// Copyright 2025 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(ctx, path) + if err != nil { + return err + } + + fmt.Print(string(logs)) + return nil +} 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 new file mode 100644 index 0000000..7daabae --- /dev/null +++ b/cmd/actions/runs.go @@ -0,0 +1,50 @@ +// Copyright 2025 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(ctx, path, &runs); err != nil { + return err + } + + print.ActionRunsList(runs.WorkflowRuns, c.Output) + return nil +} 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/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 diff --git a/modules/api/client.go b/modules/api/client.go new file mode 100644 index 0000000..31b2a4d --- /dev/null +++ b/modules/api/client.go @@ -0,0 +1,99 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import ( + "context" + "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 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.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", 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, fmt.Errorf("failed to execute request: %w", 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(ctx context.Context, path string) ([]byte, error) { + url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + 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, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + 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 new file mode 100644 index 0000000..7ecce2b --- /dev/null +++ b/modules/api/types.go @@ -0,0 +1,59 @@ +// Copyright 2025 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) +}