Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
68 changes: 59 additions & 9 deletions cmd/build_view.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"context"
"fmt"
"strconv"

Expand All @@ -10,21 +11,30 @@ import (
)

func init() {
buildCmd.AddCommand(&cobra.Command{
cmd := &cobra.Command{
Use: "view <build-number>",
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 {
buildNumber, err := strconv.Atoi(args[0])
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 {
Expand All @@ -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)
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions cmd/build_view_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
20 changes: 20 additions & 0 deletions cmd/jsonout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
10 changes: 6 additions & 4 deletions skills/br/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <buildNumber> --failed-only

# 3. Optional human summary (no --json; may fetch log for failed builds)
br build view <buildNumber>
# 3. Optional detail with failed steps (single object, not an array)
br build view <buildNumber> --json status,buildNumber,failedSteps
```

`build logs` and `build view` take **build number** (`#123`), not build slug.
Expand All @@ -75,6 +75,8 @@ br build view <buildNumber>

**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 `=`):
Expand All @@ -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 <n>` | Human output only |
| `br build view <n>` | `--json`; includes `failedSteps` on failed builds |
| `br build logs <n>` | Full log; `--failed-only` for failed steps |
| `br app list` | Apps visible to the token; `--json` |
| `br config show` | Token config + effective `.br.yml` |
Expand Down