diff --git a/AGENTS.md b/AGENTS.md index a93769c..2083515 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Git root caps `.br.yml` walk (not `bitrise.yml`) because Bitrise monorepos typic **Build status** (`internal/api: BuildStatus`) — Named constants (`StatusRunning=0 … StatusAborted=3`); never compare bare ints. Filters use `*BuildStatus` (nil = no filter). -**`--json` flag** — Normal string flag (no `NoOptDefVal`) so the field list stays a separate token; an optional-value flag would swallow `status,branch` as a positional arg. +**`--json` flag** — Normal string flag (no `NoOptDefVal`) so the field list stays a separate token; an optional-value flag would swallow `status,branch` as a positional arg. `build view --json` emits a single object and adds `failedSteps`; log is fetched only when that field is requested (or with `all`). **Log parsing** (`cmd/logparse.go: parseLogSteps`) — Shared by `build view` and `build logs --failed-only` so step summaries and filtered logs never disagree. Best-effort; depends on Bitrise log format. diff --git a/README.md b/README.md index 588612a..8a80508 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,21 @@ br build list --limit 3 --json status,buildNumber,branch,workflow ### Build details +```bash +br build view 123 +br build view 123 --json status,buildNumber,failedSteps +``` + +```json +{ + "status": "error", + "buildNumber": 123, + "failedSteps": [{"name": "run-xcode-tests@2.4.1", "exitCode": 1}] +} +``` + +Human output example: + ```bash br build view 123 # ✗ failed #123 deploy (branch: feature/auth) diff --git a/cmd/build_view.go b/cmd/build_view.go index 37950d6..863da6c 100644 --- a/cmd/build_view.go +++ b/cmd/build_view.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "strconv" @@ -10,14 +11,18 @@ import ( ) func init() { - buildCmd.AddCommand(&cobra.Command{ + cmd := &cobra.Command{ Use: "view ", Short: "Show details of a specific build", Args: cobra.ExactArgs(1), Example: ` br build view 123 - br build view 123 --app my-app-slug`, + br build view 123 --app my-app-slug + br build view 123 --json status,buildNumber,failedSteps + br build view 123 --json all`, RunE: runBuildView, - }) + } + cmd.Flags().String("json", "", "Output JSON: comma-separated fields (e.g. status,buildNumber,failedSteps) or 'all'") + buildCmd.AddCommand(cmd) } func runBuildView(cmd *cobra.Command, args []string) error { @@ -25,6 +30,11 @@ func runBuildView(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("invalid build number: %s", args[0]) } + jsonFields, _ := cmd.Flags().GetString("json") + requestedFields, err := parseJSONFields(jsonFields, validBuildViewFields()) + if err != nil { + return err + } client, err := newAPIClient() if err != nil { @@ -41,6 +51,50 @@ func runBuildView(cmd *cobra.Command, args []string) error { return err } + if jsonFields != "" { + failed := failedStepsForView(ctx, client, appSlug, build, requestedFields) + return printJSONObject(buildViewToFieldMap(*build, failed), requestedFields) + } + + return printBuildViewHuman(ctx, client, appSlug, build) +} + +func buildViewToFieldMap(b api.Build, failed []logStep) map[string]interface{} { + m := buildToFieldMap(b) + steps := make([]map[string]interface{}, 0, len(failed)) + for _, s := range failed { + steps = append(steps, map[string]interface{}{ + "name": s.Name, + "exitCode": s.ExitCode, + }) + } + m["failedSteps"] = steps + return m +} + +func validBuildViewFields() []string { + return sortedKeys(buildViewToFieldMap(api.Build{}, nil)) +} + +func needsFailedStepLog(requested map[string]bool) bool { + if requested == nil { + return true + } + return requested["failedSteps"] +} + +func failedStepsForView(ctx context.Context, client *api.Client, appSlug string, build *api.Build, requested map[string]bool) []logStep { + if build.Status != api.StatusError || !needsFailedStepLog(requested) { + return nil + } + logText, _, err := client.FetchLog(ctx, appSlug, build.Slug) + if err != nil || logText == "" { + return nil + } + return failedSteps(parseLogSteps(logText)) +} + +func printBuildViewHuman(ctx context.Context, client *api.Client, appSlug string, build *api.Build) error { icon, statusText := statusDisplay(*build) fmt.Printf("%s %s #%d %s (branch: %s)\n", icon, statusText, build.BuildNumber, build.TriggeredWorkflow, build.Branch) @@ -56,14 +110,10 @@ func runBuildView(cmd *cobra.Command, args []string) error { fmt.Printf(" Duration: %s\n", formatDuration(d)) } - // For finished failed builds, try to parse step failures from the log if build.Status == api.StatusError { fmt.Println() - logText, _, err := client.FetchLog(ctx, appSlug, build.Slug) - if err == nil && logText != "" { - for _, s := range failedSteps(parseLogSteps(logText)) { - fmt.Printf(" ✗ Step failed: %s (exit code: %d)\n", s.Name, s.ExitCode) - } + for _, s := range failedStepsForView(ctx, client, appSlug, build, map[string]bool{"failedSteps": true}) { + fmt.Printf(" ✗ Step failed: %s (exit code: %d)\n", s.Name, s.ExitCode) } fmt.Printf("\n To see full logs: br build logs %d\n", build.BuildNumber) fmt.Printf(" To see errors only: br build logs %d --failed-only\n", build.BuildNumber) diff --git a/cmd/build_view_test.go b/cmd/build_view_test.go new file mode 100644 index 0000000..e8e7176 --- /dev/null +++ b/cmd/build_view_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/novr/bitrise-cli/internal/api" +) + +func TestNeedsFailedStepLog(t *testing.T) { + if !needsFailedStepLog(nil) { + t.Error("nil requested should fetch failed steps") + } + if needsFailedStepLog(map[string]bool{"status": true}) { + t.Error("status-only should not fetch failed steps") + } + if !needsFailedStepLog(map[string]bool{"failedSteps": true}) { + t.Error("failedSteps requested should fetch log") + } +} + +func TestBuildViewToFieldMap(t *testing.T) { + now := time.Now() + build := api.Build{ + BuildNumber: 42, + Branch: "main", + TriggeredWorkflow: "primary", + Status: api.StatusError, + StatusText: "error", + TriggeredAt: now, + } + failed := []logStep{{Name: "run-tests", ExitCode: 1}} + m := buildViewToFieldMap(build, failed) + + if m["buildNumber"] != 42 { + t.Fatalf("buildNumber = %v, want 42", m["buildNumber"]) + } + steps, ok := m["failedSteps"].([]map[string]interface{}) + if !ok || len(steps) != 1 { + t.Fatalf("failedSteps = %#v, want one step", m["failedSteps"]) + } + if steps[0]["name"] != "run-tests" || steps[0]["exitCode"] != 1 { + t.Fatalf("failed step = %#v", steps[0]) + } +} + +func TestValidBuildViewFieldsIncludesFailedSteps(t *testing.T) { + fields := validBuildViewFields() + for _, f := range fields { + if f == "failedSteps" { + return + } + } + t.Fatalf("validBuildViewFields = %v, missing failedSteps", fields) +} diff --git a/cmd/jsonout.go b/cmd/jsonout.go index cb6609f..89f39ec 100644 --- a/cmd/jsonout.go +++ b/cmd/jsonout.go @@ -63,3 +63,23 @@ func printJSON(rows []map[string]interface{}, requested map[string]bool) error { fmt.Println(string(out)) return nil } + +// printJSONObject marshals one object to indented JSON, keeping only requested +// fields (nil/empty requested = all fields). +func printJSONObject(row map[string]interface{}, requested map[string]bool) error { + if len(requested) > 0 { + filtered := map[string]interface{}{} + for k, v := range row { + if requested[k] { + filtered[k] = v + } + } + row = filtered + } + out, err := json.MarshalIndent(row, "", " ") + if err != nil { + return err + } + fmt.Println(string(out)) + return nil +} diff --git a/skills/br/SKILL.md b/skills/br/SKILL.md index d9fb1e5..c7f983f 100644 --- a/skills/br/SKILL.md +++ b/skills/br/SKILL.md @@ -9,7 +9,7 @@ description: >- # br — Bitrise CLI for agents -`br` is a `gh`-style CLI for Bitrise. Prefer **`--json` output** for inspection; use human commands (`build view`) only for short summaries. +`br` is a `gh`-style CLI for Bitrise. Prefer **`--json` output** for inspection. ## Prerequisites @@ -59,8 +59,8 @@ br build list --limit 1 --json status,statusCode,buildNumber,branch,workflow # 2. If failed — errors only (smaller than full log) br build logs --failed-only -# 3. Optional human summary (no --json; may fetch log for failed builds) -br build view +# 3. Optional detail with failed steps (single object, not an array) +br build view --json status,buildNumber,failedSteps ``` `build logs` and `build view` take **build number** (`#123`), not build slug. @@ -75,6 +75,8 @@ br build view **Build list fields:** `branch`, `buildNumber`, `commitHash`, `commitMessage`, `durationSeconds`, `finishedAt`, `slug`, `status`, `statusCode`, `triggeredAt`, `workflow` — or `all` / `*`. +**Build view fields:** same as list plus `failedSteps` (`[{name, exitCode}]`). Log is fetched only when `failedSteps` is requested (or with `all`). Output is a single JSON object. + **App list fields:** `repoURL`, `slug`, `title` — or `all` / `*`. `--json` takes a **separate token** (not `=`): @@ -91,7 +93,7 @@ Unknown field names error with the valid list. | Command | Notes | |---------|-------| | `br build list` | `--limit`, `--branch`, `--workflow`, `--status`, `--json` | -| `br build view ` | Human output only | +| `br build view ` | `--json`; includes `failedSteps` on failed builds | | `br build logs ` | Full log; `--failed-only` for failed steps | | `br app list` | Apps visible to the token; `--json` | | `br config show` | Token config + effective `.br.yml` |