From cea501523e7c183be739680f134f1edf779279c3 Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis-Mekkelholt Date: Tue, 30 Dec 2025 19:40:19 +0100 Subject: [PATCH] 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) +}