feat: add json output for list and view commands

This commit is contained in:
Romain Bertrand 2026-01-18 11:48:08 +01:00
parent fe23f2fce3
commit 3ccef4e1c6
5 changed files with 192 additions and 52 deletions

View file

@ -16,17 +16,17 @@ import (
// ActionRun represents a workflow run // ActionRun represents a workflow run
type ActionRun struct { type ActionRun struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
WorkflowID string `json:"workflow_id"` WorkflowID string `json:"workflow_id"`
IndexInRepo int64 `json:"index_in_repo"` IndexInRepo int64 `json:"index_in_repo"`
Event string `json:"event"` Event string `json:"event"`
Status string `json:"status"` Status string `json:"status"`
CommitSHA string `json:"commit_sha"` CommitSHA string `json:"commit_sha"`
PrettyRef string `json:"prettyref"` PrettyRef string `json:"prettyref"`
Created string `json:"created"` Created string `json:"created"`
Updated string `json:"updated"` Updated string `json:"updated"`
Started string `json:"started"` Started string `json:"started"`
} }
// ActionRunList represents a list of workflow runs // ActionRunList represents a list of workflow runs
@ -37,19 +37,19 @@ type ActionRunList struct {
// ActionTask represents a job/task within a workflow run // ActionTask represents a job/task within a workflow run
type ActionTask struct { type ActionTask struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
HeadBranch string `json:"head_branch"` HeadBranch string `json:"head_branch"`
HeadSHA string `json:"head_sha"` HeadSHA string `json:"head_sha"`
RunNumber int64 `json:"run_number"` RunNumber int64 `json:"run_number"`
Event string `json:"event"` Event string `json:"event"`
DisplayTitle string `json:"display_title"` DisplayTitle string `json:"display_title"`
Status string `json:"status"` Status string `json:"status"`
WorkflowID string `json:"workflow_id"` WorkflowID string `json:"workflow_id"`
URL string `json:"url"` URL string `json:"url"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
RunStartedAt string `json:"run_started_at"` RunStartedAt string `json:"run_started_at"`
} }
// ActionTaskList represents a list of tasks/jobs // ActionTaskList represents a list of tasks/jobs
@ -246,16 +246,20 @@ func init() {
// Add flags for run commands // Add flags for run commands
addRepoFlags(runListCmd) addRepoFlags(runListCmd)
runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
runListCmd.Flags().Bool("json", false, "Output workflow runs as JSON")
addRepoFlags(runViewCmd) addRepoFlags(runViewCmd)
runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps") runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job") runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run") runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job") runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
runViewCmd.Flags().Bool("json", false, "Output workflow run as JSON")
// Add flags for workflow commands // Add flags for workflow commands
addRepoFlags(workflowListCmd) addRepoFlags(workflowListCmd)
workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
workflowListCmd.Flags().Bool("json", false, "Output workflows as JSON")
addRepoFlags(workflowViewCmd) addRepoFlags(workflowViewCmd)
workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON")
addRepoFlags(workflowRunCmd) addRepoFlags(workflowRunCmd)
workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)") workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)") workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
@ -307,6 +311,10 @@ func runRunList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to list runs: %w", err) return fmt.Errorf("failed to list runs: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(runList.WorkflowRuns)
}
if len(runList.WorkflowRuns) == 0 { if len(runList.WorkflowRuns) == 0 {
fmt.Println("No workflow runs found") fmt.Println("No workflow runs found")
return nil return nil
@ -364,6 +372,7 @@ func runRunView(cmd *cobra.Command, args []string) error {
showLog, _ := cmd.Flags().GetBool("log") showLog, _ := cmd.Flags().GetBool("log")
jobIDStr, _ := cmd.Flags().GetString("job") jobIDStr, _ := cmd.Flags().GetString("job")
showLogFailed, _ := cmd.Flags().GetBool("log-failed") showLogFailed, _ := cmd.Flags().GetBool("log-failed")
jsonOutput, _ := cmd.Flags().GetBool("json")
var jobID int64 var jobID int64
if jobIDStr != "" { if jobIDStr != "" {
@ -374,6 +383,10 @@ func runRunView(cmd *cobra.Command, args []string) error {
} }
} }
if jsonOutput && (showLog || showLogFailed) {
return fmt.Errorf("--json cannot be used with --log or --log-failed")
}
// Call the API endpoint directly since SDK doesn't have it yet // Call the API endpoint directly since SDK doesn't have it yet
endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID) endpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID)
@ -382,6 +395,48 @@ func runRunView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to get run: %w", err) return fmt.Errorf("failed to get run: %w", err)
} }
needsJobs := verbose || showLog || showLogFailed || jobID > 0
if jsonOutput {
var runTasks []ActionTask
if needsJobs {
tasksEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/tasks", owner, name)
var taskList ActionTaskList
if err := client.GetJSON(tasksEndpoint, &taskList); err != nil {
return fmt.Errorf("failed to get tasks: %w", err)
}
for _, task := range taskList.WorkflowRuns {
if task.RunNumber == run.IndexInRepo {
runTasks = append(runTasks, task)
}
}
if jobID > 0 {
var filtered []ActionTask
for _, task := range runTasks {
if task.ID == jobID {
filtered = append(filtered, task)
break
}
}
if len(filtered) == 0 {
return fmt.Errorf("job %d not found in this run", jobID)
}
runTasks = filtered
}
}
payload := struct {
Run ActionRun `json:"run"`
Tasks []ActionTask `json:"tasks,omitempty"`
}{
Run: run,
Tasks: runTasks,
}
return writeJSON(payload)
}
// Display run information // Display run information
fmt.Printf("Title: %s\n", run.Title) fmt.Printf("Title: %s\n", run.Title)
fmt.Printf("Workflow: %s\n", run.WorkflowID) fmt.Printf("Workflow: %s\n", run.WorkflowID)
@ -409,7 +464,6 @@ func runRunView(cmd *cobra.Command, args []string) error {
} }
// Fetch jobs if needed for verbose, log, or job-specific views // Fetch jobs if needed for verbose, log, or job-specific views
needsJobs := verbose || showLog || showLogFailed || jobID > 0
if !needsJobs { if !needsJobs {
return nil return nil
} }
@ -620,10 +674,17 @@ func runWorkflowList(cmd *cobra.Command, args []string) error {
} }
if len(allWorkflows) == 0 { if len(allWorkflows) == 0 {
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(allWorkflows)
}
fmt.Println("No workflows found") fmt.Println("No workflows found")
return nil return nil
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(allWorkflows)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil {
return fmt.Errorf("failed to write header: %w", err) return fmt.Errorf("failed to write header: %w", err)
@ -694,26 +755,39 @@ func runWorkflowView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("workflow '%s' not found", workflowIdentifier) return fmt.Errorf("workflow '%s' not found", workflowIdentifier)
} }
jsonOutput, _ := cmd.Flags().GetBool("json")
var latestRun *ActionRun
// Get the latest run for this workflow
runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path)
var runList ActionRunList
if err := client.GetJSON(runsEndpoint, &runList); err == nil && len(runList.WorkflowRuns) > 0 {
latestRun = &runList.WorkflowRuns[0]
}
if jsonOutput {
payload := struct {
Workflow *Workflow `json:"workflow"`
LatestRun *ActionRun `json:"latest_run,omitempty"`
}{
Workflow: workflow,
LatestRun: latestRun,
}
return writeJSON(payload)
}
// Display workflow information // Display workflow information
fmt.Printf("Name: %s\n", workflow.Name) fmt.Printf("Name: %s\n", workflow.Name)
fmt.Printf("Path: %s\n", workflow.Path) fmt.Printf("Path: %s\n", workflow.Path)
fmt.Printf("State: %s\n", workflow.State) fmt.Printf("State: %s\n", workflow.State)
// Get the latest run for this workflow if latestRun != nil {
runsEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?workflow_id=%s&limit=1", owner, name, workflow.Path)
var runList ActionRunList
if err := client.GetJSON(runsEndpoint, &runList); err != nil {
// If we can't get runs, just display workflow info without latest run
return nil
}
if len(runList.WorkflowRuns) > 0 {
run := runList.WorkflowRuns[0]
fmt.Printf("\nLatest run:\n") fmt.Printf("\nLatest run:\n")
fmt.Printf(" Status: %s\n", formatStatus(run.Status)) fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status))
fmt.Printf(" Event: %s\n", run.Event) fmt.Printf(" Event: %s\n", latestRun.Event)
fmt.Printf(" Ref: %s\n", run.PrettyRef) fmt.Printf(" Ref: %s\n", latestRun.PrettyRef)
if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil {
fmt.Printf(" Created: %s\n", formatTimeSince(createdTime)) fmt.Printf(" Created: %s\n", formatTimeSince(createdTime))
} }
} }

View file

@ -76,8 +76,10 @@ func init() {
issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
issueListCmd.Flags().Bool("json", false, "Output issues as JSON")
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueViewCmd.Flags().Bool("json", false, "Output issue as JSON")
issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue") issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
@ -133,17 +135,26 @@ func runIssueList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to list issues: %w", err) return fmt.Errorf("failed to list issues: %w", err)
} }
if len(issues) == 0 { nonPRIssues := make([]*gitea.Issue, 0, len(issues))
for _, issue := range issues {
if issue.PullRequest == nil {
nonPRIssues = append(nonPRIssues, issue)
}
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(nonPRIssues)
}
if len(nonPRIssues) == 0 {
fmt.Printf("No %s issues in %s/%s\n", state, owner, name) fmt.Printf("No %s issues in %s/%s\n", state, owner, name)
return nil return nil
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
for _, issue := range issues { for _, issue := range nonPRIssues {
if issue.PullRequest == nil { _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
_, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
}
} }
_ = w.Flush() _ = w.Flush()
@ -177,6 +188,23 @@ func runIssueView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to get issue: %w", err) return fmt.Errorf("failed to get issue: %w", err)
} }
var comments []*gitea.Comment
comments, _, err = client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{})
if err != nil {
comments = nil
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Issue *gitea.Issue `json:"issue"`
Comments []*gitea.Comment `json:"comments,omitempty"`
}{
Issue: issue,
Comments: comments,
}
return writeJSON(payload)
}
fmt.Printf("Issue #%d\n", issue.Index) fmt.Printf("Issue #%d\n", issue.Index)
fmt.Printf("Title: %s\n", issue.Title) fmt.Printf("Title: %s\n", issue.Title)
fmt.Printf("State: %s\n", issue.State) fmt.Printf("State: %s\n", issue.State)
@ -187,8 +215,7 @@ func runIssueView(cmd *cobra.Command, args []string) error {
fmt.Printf("\n%s\n", issue.Body) fmt.Printf("\n%s\n", issue.Body)
} }
comments, _, err := client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) if len(comments) > 0 {
if err == nil && len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments)) fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments { for _, comment := range comments {
fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", fmt.Printf("\n---\n%s (@%s) - %s\n%s\n",

12
cmd/json.go Normal file
View file

@ -0,0 +1,12 @@
package cmd
import (
"encoding/json"
"os"
)
func writeJSON(value any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}

View file

@ -8,9 +8,9 @@ import (
"text/tabwriter" "text/tabwriter"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/spf13/cobra"
"codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/api"
"codeberg.org/romaintb/fgj/internal/config" "codeberg.org/romaintb/fgj/internal/config"
"github.com/spf13/cobra"
) )
var prCmd = &cobra.Command{ var prCmd = &cobra.Command{
@ -59,8 +59,10 @@ func init() {
prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") prListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
prListCmd.Flags().Bool("json", false, "Output pull requests as JSON")
prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prViewCmd.Flags().Bool("json", false, "Output pull request as JSON")
prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request") prCreateCmd.Flags().StringP("title", "t", "", "Title for the pull request")
@ -111,6 +113,10 @@ func runPRList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to list pull requests: %w", err) return fmt.Errorf("failed to list pull requests: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(prs)
}
if len(prs) == 0 { if len(prs) == 0 {
fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name)
return nil return nil
@ -153,6 +159,10 @@ func runPRView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to get pull request: %w", err) return fmt.Errorf("failed to get pull request: %w", err)
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(pr)
}
fmt.Printf("Pull Request #%d\n", pr.Index) fmt.Printf("Pull Request #%d\n", pr.Index)
fmt.Printf("Title: %s\n", pr.Title) fmt.Printf("Title: %s\n", pr.Title)
fmt.Printf("State: %s\n", pr.State) fmt.Printf("State: %s\n", pr.State)
@ -279,4 +289,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error {
return nil return nil
} }

View file

@ -72,8 +72,10 @@ func init() {
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch") releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch")
releaseListCmd.Flags().Bool("json", false, "Output releases as JSON")
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseViewCmd.Flags().Bool("json", false, "Output release as JSON")
releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)") releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
@ -146,6 +148,10 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
releases = releases[:limit] releases = releases[:limit]
} }
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(releases)
}
if len(releases) == 0 { if len(releases) == 0 {
fmt.Printf("No releases in %s/%s\n", owner, name) fmt.Printf("No releases in %s/%s\n", owner, name)
return nil return nil
@ -186,6 +192,22 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
return err return err
} }
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
if err != nil {
return err
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Release *gitea.Release `json:"release"`
Assets []*gitea.Attachment `json:"assets,omitempty"`
}{
Release: release,
Assets: attachments,
}
return writeJSON(payload)
}
fmt.Printf("Release %s\n", release.TagName) fmt.Printf("Release %s\n", release.TagName)
fmt.Printf("Title: %s\n", release.Title) fmt.Printf("Title: %s\n", release.Title)
fmt.Printf("Type: %s\n", releaseType(release)) fmt.Printf("Type: %s\n", releaseType(release))
@ -206,10 +228,6 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
fmt.Printf("\n%s\n", release.Note) fmt.Printf("\n%s\n", release.Note)
} }
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
if err != nil {
return err
}
if len(attachments) > 0 { if len(attachments) > 0 {
fmt.Printf("\nAssets (%d):\n", len(attachments)) fmt.Printf("\nAssets (%d):\n", len(attachments))
for _, asset := range attachments { for _, asset := range attachments {