From 3ccef4e1c6857d23a3bb4a778066c362fe6f2e25 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Sun, 18 Jan 2026 11:48:08 +0100 Subject: [PATCH] feat: add json output for list and view commands --- cmd/actions.go | 152 ++++++++++++++++++++++++++++++++++++------------- cmd/issue.go | 41 ++++++++++--- cmd/json.go | 12 ++++ cmd/pr.go | 13 ++++- cmd/release.go | 26 +++++++-- 5 files changed, 192 insertions(+), 52 deletions(-) create mode 100644 cmd/json.go diff --git a/cmd/actions.go b/cmd/actions.go index 53f5a58..8d7aa28 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -16,17 +16,17 @@ import ( // ActionRun represents a workflow run type ActionRun struct { - ID int64 `json:"id"` - Title string `json:"title"` - WorkflowID string `json:"workflow_id"` - IndexInRepo int64 `json:"index_in_repo"` - Event string `json:"event"` - Status string `json:"status"` - CommitSHA string `json:"commit_sha"` - PrettyRef string `json:"prettyref"` - Created string `json:"created"` - Updated string `json:"updated"` - Started string `json:"started"` + ID int64 `json:"id"` + Title string `json:"title"` + WorkflowID string `json:"workflow_id"` + IndexInRepo int64 `json:"index_in_repo"` + Event string `json:"event"` + Status string `json:"status"` + CommitSHA string `json:"commit_sha"` + PrettyRef string `json:"prettyref"` + Created string `json:"created"` + Updated string `json:"updated"` + Started string `json:"started"` } // ActionRunList represents a list of workflow runs @@ -37,19 +37,19 @@ type ActionRunList struct { // ActionTask represents a job/task within a workflow run type ActionTask struct { - ID int64 `json:"id"` - Name string `json:"name"` - HeadBranch string `json:"head_branch"` - HeadSHA string `json:"head_sha"` - RunNumber int64 `json:"run_number"` - Event string `json:"event"` - DisplayTitle string `json:"display_title"` - Status string `json:"status"` - WorkflowID string `json:"workflow_id"` - URL string `json:"url"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - RunStartedAt string `json:"run_started_at"` + ID int64 `json:"id"` + Name string `json:"name"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + RunNumber int64 `json:"run_number"` + Event string `json:"event"` + DisplayTitle string `json:"display_title"` + Status string `json:"status"` + WorkflowID string `json:"workflow_id"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + RunStartedAt string `json:"run_started_at"` } // ActionTaskList represents a list of tasks/jobs @@ -246,16 +246,20 @@ func init() { // Add flags for run commands addRepoFlags(runListCmd) runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list") + runListCmd.Flags().Bool("json", false, "Output workflow runs as JSON") addRepoFlags(runViewCmd) 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().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().Bool("json", false, "Output workflow run as JSON") // Add flags for workflow commands addRepoFlags(workflowListCmd) workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list") + workflowListCmd.Flags().Bool("json", false, "Output workflows as JSON") addRepoFlags(workflowViewCmd) + workflowViewCmd.Flags().Bool("json", false, "Output workflow as JSON") addRepoFlags(workflowRunCmd) 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)") @@ -307,6 +311,10 @@ func runRunList(cmd *cobra.Command, args []string) error { 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 { fmt.Println("No workflow runs found") return nil @@ -364,6 +372,7 @@ func runRunView(cmd *cobra.Command, args []string) error { showLog, _ := cmd.Flags().GetBool("log") jobIDStr, _ := cmd.Flags().GetString("job") showLogFailed, _ := cmd.Flags().GetBool("log-failed") + jsonOutput, _ := cmd.Flags().GetBool("json") var jobID int64 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 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) } + 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 fmt.Printf("Title: %s\n", run.Title) 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 - needsJobs := verbose || showLog || showLogFailed || jobID > 0 if !needsJobs { return nil } @@ -620,10 +674,17 @@ func runWorkflowList(cmd *cobra.Command, args []string) error { } if len(allWorkflows) == 0 { + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(allWorkflows) + } fmt.Println("No workflows found") return nil } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(allWorkflows) + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) if _, err := fmt.Fprintln(w, "NAME\tSTATE\tPATH"); err != nil { 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) } + 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 fmt.Printf("Name: %s\n", workflow.Name) fmt.Printf("Path: %s\n", workflow.Path) fmt.Printf("State: %s\n", workflow.State) - // 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 { - // If we can't get runs, just display workflow info without latest run - return nil - } - - if len(runList.WorkflowRuns) > 0 { - run := runList.WorkflowRuns[0] + if latestRun != nil { fmt.Printf("\nLatest run:\n") - fmt.Printf(" Status: %s\n", formatStatus(run.Status)) - fmt.Printf(" Event: %s\n", run.Event) - fmt.Printf(" Ref: %s\n", run.PrettyRef) - if createdTime, err := time.Parse(time.RFC3339, run.Created); err == nil { + fmt.Printf(" Status: %s\n", formatStatus(latestRun.Status)) + fmt.Printf(" Event: %s\n", latestRun.Event) + fmt.Printf(" Ref: %s\n", latestRun.PrettyRef) + if createdTime, err := time.Parse(time.RFC3339, latestRun.Created); err == nil { fmt.Printf(" Created: %s\n", formatTimeSince(createdTime)) } } diff --git a/cmd/issue.go b/cmd/issue.go index 5537105..d582d01 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -76,8 +76,10 @@ func init() { issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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().Bool("json", false, "Output issue as JSON") issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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) } - 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) return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n") - for _, issue := range issues { - if issue.PullRequest == nil { - _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) - } + for _, issue := range nonPRIssues { + _, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State) } _ = w.Flush() @@ -177,6 +188,23 @@ func runIssueView(cmd *cobra.Command, args []string) error { 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("Title: %s\n", issue.Title) 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) } - comments, _, err := client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{}) - if err == nil && len(comments) > 0 { + if len(comments) > 0 { fmt.Printf("\nComments (%d):\n", len(comments)) for _, comment := range comments { fmt.Printf("\n---\n%s (@%s) - %s\n%s\n", diff --git a/cmd/json.go b/cmd/json.go new file mode 100644 index 0000000..21f1b25 --- /dev/null +++ b/cmd/json.go @@ -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) +} diff --git a/cmd/pr.go b/cmd/pr.go index 9c15401..1f35638 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -8,9 +8,9 @@ import ( "text/tabwriter" "code.gitea.io/sdk/gitea" - "github.com/spf13/cobra" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/internal/config" + "github.com/spf13/cobra" ) var prCmd = &cobra.Command{ @@ -59,8 +59,10 @@ func init() { prListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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().Bool("json", false, "Output pull request as JSON") prCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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) } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(prs) + } + if len(prs) == 0 { fmt.Printf("No %s pull requests in %s/%s\n", state, owner, name) return nil @@ -153,6 +159,10 @@ func runPRView(cmd *cobra.Command, args []string) error { 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("Title: %s\n", pr.Title) fmt.Printf("State: %s\n", pr.State) @@ -279,4 +289,3 @@ func runPRMerge(cmd *cobra.Command, args []string) error { return nil } - diff --git a/cmd/release.go b/cmd/release.go index 8c04768..8ad9413 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -72,8 +72,10 @@ func init() { releaseListCmd.Flags().Bool("draft", false, "Filter by draft status") releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status") 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().Bool("json", false, "Output release as JSON") releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") 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] } + if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { + return writeJSON(releases) + } + if len(releases) == 0 { fmt.Printf("No releases in %s/%s\n", owner, name) return nil @@ -186,6 +192,22 @@ func runReleaseView(cmd *cobra.Command, args []string) error { 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("Title: %s\n", release.Title) 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) } - attachments, err := listReleaseAttachments(client, owner, name, release.ID) - if err != nil { - return err - } if len(attachments) > 0 { fmt.Printf("\nAssets (%d):\n", len(attachments)) for _, asset := range attachments {