diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..67c849e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.worktrees +bin/ +dist/ +cover.out +*.md +docs/ +.github/ +Dockerfile +.dockerignore +.goreleaser.yml +Makefile +*.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43a2a40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +ARG BUILD_DATE=unknown + +RUN CGO_ENABLED=0 go build \ + -ldflags "-s -w -X github.com/rbansal42/bitbucket-cli/internal/cmd.Version=${VERSION} -X github.com/rbansal42/bitbucket-cli/internal/cmd.BuildDate=${BUILD_DATE}" \ + -o /bin/bb ./cmd/bb + +# Runtime stage +FROM alpine:3.21 + +LABEL org.opencontainers.image.source="https://github.com/rbansal42/bitbucket-cli" +LABEL org.opencontainers.image.description="Unofficial CLI for Bitbucket Cloud" + +RUN apk add --no-cache git ca-certificates && \ + adduser -D -h /home/bb bb + +USER bb + +COPY --from=builder /bin/bb /usr/local/bin/bb + +ENTRYPOINT ["bb"] diff --git a/Makefile b/Makefile index 8fa6661..cf029ad 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") -LDFLAGS := -ldflags "-X github.com/rbansal42/bb/internal/cmd.Version=$(VERSION) -X github.com/rbansal42/bb/internal/cmd.BuildDate=$(BUILD_DATE)" +LDFLAGS := -ldflags "-X github.com/rbansal42/bitbucket-cli/internal/cmd.Version=$(VERSION) -X github.com/rbansal42/bitbucket-cli/internal/cmd.BuildDate=$(BUILD_DATE)" build: go build $(LDFLAGS) -o bin/bb ./cmd/bb diff --git a/docs/plans/2026-02-06-dedup-refactor-and-dockerfile.md b/docs/plans/2026-02-06-dedup-refactor-and-dockerfile.md new file mode 100644 index 0000000..bf505c8 --- /dev/null +++ b/docs/plans/2026-02-06-dedup-refactor-and-dockerfile.md @@ -0,0 +1,298 @@ +# Code Deduplication, Dockerfile & Cleanup - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Eliminate all code duplication across command packages by extracting shared utilities into `cmdutil`, remove duplicate type definitions in the `pr` package, and add a production-ready Dockerfile. + +**Architecture:** Three phases executed sequentially. Phase 1 extracts 5 shared utility functions + 2 shared output helpers into `cmdutil`. Phase 2 eliminates duplicate type definitions in `internal/cmd/pr/shared.go` by reusing `api` package types. Phase 3 adds a multi-stage Dockerfile. + +**Tech Stack:** Go 1.25.7, Docker (multi-stage build), existing `cmdutil` package + +--- + +## Phase 1: Extract Shared Utilities into `cmdutil` + +### Task 1: Add `TimeAgo` and `FormatTimeAgoString` to `cmdutil` + +**Files:** +- Create: `internal/cmdutil/time.go` +- Create: `internal/cmdutil/time_test.go` + +**What:** Extract the `timeAgo(t time.Time) string` function that is duplicated in: +- `internal/cmd/pr/view.go:279-316` +- `internal/cmd/issue/shared.go:100-137` +- `internal/cmd/pipeline/shared.go:102-143` +- `internal/cmd/repo/list.go:169-211` + +The canonical version should handle both `time.Time` input and string input (for `snippet/list.go`'s `formatTime`), and include the `.IsZero()` guard from the pipeline version. + +```go +// internal/cmdutil/time.go +package cmdutil + +import ( + "fmt" + "time" +) + +// TimeAgo returns a human-readable relative time string for a time.Time value. +// Returns "-" for zero time values. +func TimeAgo(t time.Time) string { + if t.IsZero() { + return "-" + } + + duration := time.Since(t) + + switch { + case duration < time.Minute: + return "just now" + case duration < time.Hour: + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case duration < 24*time.Hour: + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case duration < 30*24*time.Hour: + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + case duration < 365*24*time.Hour: + months := int(duration.Hours() / 24 / 30) + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + default: + years := int(duration.Hours() / 24 / 365) + if years == 1 { + return "1 year ago" + } + return fmt.Sprintf("%d years ago", years) + } +} + +// TimeAgoFromString parses an ISO 8601 / RFC3339 timestamp string and returns +// a human-readable relative time. Returns the raw string on parse failure. +func TimeAgoFromString(isoTime string) string { + if isoTime == "" { + return "-" + } + + t, err := time.Parse(time.RFC3339, isoTime) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05.000000-07:00", isoTime) + if err != nil { + return isoTime + } + } + + return TimeAgo(t) +} +``` + +### Task 2: Add `GetUserDisplayName` to `cmdutil` + +**Files:** +- Create: `internal/cmdutil/user.go` + +**What:** Extract the `getUserDisplayName` function duplicated in: +- `internal/cmd/pr/view.go:265-276` (takes `PRUser` value) +- `internal/cmd/issue/shared.go:151-162` (takes `*api.User` pointer) + +The canonical version uses `*api.User` since that's the API-level type. + +```go +// internal/cmdutil/user.go +package cmdutil + +import "github.com/rbansal42/bitbucket-cli/internal/api" + +// GetUserDisplayName returns the best available display name for a user. +// Returns "-" if user is nil, falls back to Username, then "unknown". +func GetUserDisplayName(user *api.User) string { + if user == nil { + return "-" + } + if user.DisplayName != "" { + return user.DisplayName + } + if user.Username != "" { + return user.Username + } + return "unknown" +} +``` + +### Task 3: Add `PrintJSON` and `PrintTableHeader` to `cmdutil` + +**Files:** +- Create: `internal/cmdutil/output.go` + +**What:** Extract the repeated JSON output pattern (10+ copies) and table header pattern (7 copies). + +```go +// internal/cmdutil/output.go +package cmdutil + +import ( + "encoding/json" + "fmt" + + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +// PrintJSON marshals v as indented JSON and writes it to streams.Out. +func PrintJSON(streams *iostreams.IOStreams, v interface{}) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Fprintln(streams.Out, string(data)) + return nil +} + +// PrintTableHeader writes a bold header line if color is enabled. +func PrintTableHeader(streams *iostreams.IOStreams, w *tabwriter.Writer, header string) { + if streams.ColorEnabled() { + fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) + } else { + fmt.Fprintln(w, header) + } +} +``` + +### Task 4: Add `ConfirmPrompt` to `cmdutil` + +**Files:** +- Modify: `internal/cmdutil/output.go` (add to same file) + +**What:** Extract the confirmation prompt duplicated in: +- `internal/cmd/issue/shared.go:165-172` +- `internal/cmd/snippet/delete.go:86-93` (inline) + +```go +// ConfirmPrompt reads a line from reader and returns true if user typed y/yes. +func ConfirmPrompt(reader io.Reader) bool { + scanner := bufio.NewScanner(reader) + if scanner.Scan() { + input := strings.TrimSpace(strings.ToLower(scanner.Text())) + return input == "y" || input == "yes" + } + return false +} +``` + +### Task 5: Replace all local `truncateString` with `cmdutil.TruncateString` + +**Files to modify:** +- `internal/cmd/pr/list.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/issue/shared.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/pipeline/shared.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/repo/list.go` -- remove `truncateString`, use `cmdutil.TruncateString` +- `internal/cmd/branch/list.go` -- remove `truncateMessage`, use `cmdutil.TruncateString` + +### Task 6: Replace all local `timeAgo`/`formatTimeAgo`/`formatUpdated`/`formatTime` with `cmdutil.TimeAgo` + +**Files to modify:** +- `internal/cmd/pr/view.go` -- remove `timeAgo`, use `cmdutil.TimeAgo` +- `internal/cmd/issue/shared.go` -- remove `timeAgo`, use `cmdutil.TimeAgo` +- `internal/cmd/pipeline/shared.go` -- remove `formatTimeAgo`, use `cmdutil.TimeAgo` +- `internal/cmd/repo/list.go` -- remove `formatUpdated`, use `cmdutil.TimeAgo` +- `internal/cmd/snippet/list.go` -- remove `formatTime`, use `cmdutil.TimeAgoFromString` + +### Task 7: Replace all local `getUserDisplayName` with `cmdutil.GetUserDisplayName` + +**Files to modify:** +- `internal/cmd/issue/shared.go` -- remove `getUserDisplayName`, use `cmdutil.GetUserDisplayName` +- NOTE: `internal/cmd/pr/view.go` uses `PRUser` type -- this is fixed in Phase 2 + +### Task 8: Replace all JSON output boilerplate with `cmdutil.PrintJSON` + +**Files to modify (list commands):** +- `internal/cmd/pr/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/branch/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/repo/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/snippet/list.go` -- use `cmdutil.PrintJSON` +- `internal/cmd/pr/view.go` -- use `cmdutil.PrintJSON` + +### Task 9: Replace all table header boilerplate with `cmdutil.PrintTableHeader` + +**Files to modify:** +- `internal/cmd/pr/list.go` +- `internal/cmd/issue/list.go` +- `internal/cmd/pipeline/list.go` +- `internal/cmd/repo/list.go` +- `internal/cmd/branch/list.go` +- `internal/cmd/workspace/list.go` +- `internal/cmd/snippet/list.go` + +--- + +## Phase 2: Eliminate Duplicate Types in PR Package + +### Task 10: Remove duplicate `PRUser`, `PRParticipant`, `PullRequest`, `PRComment` from `internal/cmd/pr/shared.go` + +**Files to modify:** +- `internal/cmd/pr/shared.go` -- Remove types `PRUser` (lines 98-112), `PRParticipant` (lines 114-120), `PullRequest` (lines 122-163), `PRComment` (lines 166-176), and `getPullRequest` (lines 178-187) +- `internal/cmd/pr/view.go` -- Update to use `api.PullRequest`, `api.User`, `api.Participant`, replace `getUserDisplayName(PRUser)` with `cmdutil.GetUserDisplayName(&api.User)`, fix `time.Parse` of `CreatedOn` (api type uses `time.Time` not `string`) + +--- + +## Phase 3: Dockerfile + +### Task 11: Create multi-stage Dockerfile + +**Files:** +- Create: `Dockerfile` +- Create: `.dockerignore` + +```dockerfile +# Build stage +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +ARG BUILD_DATE + +RUN CGO_ENABLED=0 go build \ + -ldflags "-s -w -X github.com/rbansal42/bitbucket-cli/internal/cmd.Version=${VERSION} -X github.com/rbansal42/bitbucket-cli/internal/cmd.BuildDate=${BUILD_DATE}" \ + -o /bin/bb ./cmd/bb + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache git ca-certificates + +COPY --from=builder /bin/bb /usr/local/bin/bb + +ENTRYPOINT ["bb"] +``` + +``` +# .dockerignore +.git +.worktrees +bin/ +cover.out +*.md +!README.md +docs/ +.github/ +``` diff --git a/internal/api/client.go b/internal/api/client.go index 7c7d8a8..77753aa 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -273,6 +273,7 @@ type User struct { UUID string `json:"uuid"` Username string `json:"username"` DisplayName string `json:"display_name"` + Nickname string `json:"nickname"` AccountID string `json:"account_id"` Links struct { Avatar struct { diff --git a/internal/cmd/branch/list.go b/internal/cmd/branch/list.go index aa7971b..a7c04b1 100644 --- a/internal/cmd/branch/list.go +++ b/internal/cmd/branch/list.go @@ -2,9 +2,7 @@ package branch import ( "context" - "encoding/json" "fmt" - "strings" "text/tabwriter" "time" @@ -115,13 +113,7 @@ func outputListJSON(streams *iostreams.IOStreams, branches []api.BranchFull) err output[i] = item } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error { @@ -129,11 +121,7 @@ func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error // Print header header := "NAME\tCOMMIT\tMESSAGE" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, branch := range branches { @@ -149,7 +137,7 @@ func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error commit = branch.Target.Hash } // Truncate message to 50 chars and replace newlines - message = truncateMessage(branch.Target.Message, 50) + message = cmdutil.TruncateString(branch.Target.Message, 50) } fmt.Fprintf(w, "%s\t%s\t%s\n", name, commit, message) @@ -157,23 +145,3 @@ func outputTable(streams *iostreams.IOStreams, branches []api.BranchFull) error return w.Flush() } - -// truncateMessage truncates a message to maxLen characters and replaces newlines -func truncateMessage(s string, maxLen int) string { - // Replace newlines with spaces - s = strings.ReplaceAll(s, "\n", " ") - s = strings.ReplaceAll(s, "\r", " ") - // Collapse multiple spaces - for strings.Contains(s, " ") { - s = strings.ReplaceAll(s, " ", " ") - } - s = strings.TrimSpace(s) - - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/issue/list.go b/internal/cmd/issue/list.go index 6459282..1e80c86 100644 --- a/internal/cmd/issue/list.go +++ b/internal/cmd/issue/list.go @@ -2,10 +2,8 @@ package issue import ( "context" - "encoding/json" "fmt" "text/tabwriter" - "time" "github.com/spf13/cobra" @@ -130,8 +128,8 @@ func outputListJSON(streams *iostreams.IOStreams, issues []api.Issue) error { "state": issue.State, "kind": issue.Kind, "priority": issue.Priority, - "reporter": getUserDisplayName(issue.Reporter), - "assignee": getUserDisplayName(issue.Assignee), + "reporter": cmdutil.GetUserDisplayName(issue.Reporter), + "assignee": cmdutil.GetUserDisplayName(issue.Assignee), "votes": issue.Votes, "created_on": issue.CreatedOn, "updated_on": issue.UpdatedOn, @@ -141,13 +139,7 @@ func outputListJSON(streams *iostreams.IOStreams, issues []api.Issue) error { } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputIssueTable(streams *iostreams.IOStreams, issues []api.Issue) error { @@ -155,21 +147,17 @@ func outputIssueTable(streams *iostreams.IOStreams, issues []api.Issue) error { // Print header header := "#\tTITLE\tSTATE\tKIND\tPRIORITY\tASSIGNEE\tUPDATED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, issue := range issues { id := fmt.Sprintf("%d", issue.ID) - title := truncateString(issue.Title, 40) + title := cmdutil.TruncateString(issue.Title, 40) state := formatIssueState(streams, issue.State) kind := formatIssueKind(streams, issue.Kind) priority := formatIssuePriority(streams, issue.Priority) - assignee := truncateString(getUserDisplayName(issue.Assignee), 15) - updated := formatUpdated(issue.UpdatedOn) + assignee := cmdutil.TruncateString(cmdutil.GetUserDisplayName(issue.Assignee), 15) + updated := cmdutil.TimeAgo(issue.UpdatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, title, state, kind, priority, assignee, updated) @@ -177,10 +165,3 @@ func outputIssueTable(streams *iostreams.IOStreams, issues []api.Issue) error { return w.Flush() } - -func formatUpdated(t time.Time) string { - if t.IsZero() { - return "-" - } - return timeAgo(t) -} diff --git a/internal/cmd/issue/shared.go b/internal/cmd/issue/shared.go index d78e04c..0f376c4 100644 --- a/internal/cmd/issue/shared.go +++ b/internal/cmd/issue/shared.go @@ -9,7 +9,6 @@ import ( "os" "strconv" "strings" - "time" "github.com/rbansal42/bitbucket-cli/internal/api" "github.com/rbansal42/bitbucket-cli/internal/iostreams" @@ -96,71 +95,6 @@ func formatIssueKind(streams *iostreams.IOStreams, kind string) string { } } -// timeAgo returns a human-readable relative time string -func timeAgo(t time.Time) string { - duration := time.Since(t) - - switch { - case duration < time.Minute: - return "just now" - case duration < time.Hour: - mins := int(duration.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case duration < 24*time.Hour: - hours := int(duration.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case duration < 30*24*time.Hour: - days := int(duration.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case duration < 365*24*time.Hour: - months := int(duration.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(duration.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} - -// truncateString truncates a string to maxLen characters with ellipsis -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - -// getUserDisplayName returns the best available display name for a user -func getUserDisplayName(user *api.User) string { - if user == nil { - return "-" - } - if user.DisplayName != "" { - return user.DisplayName - } - if user.Username != "" { - return user.Username - } - return "unknown" -} - // confirmPrompt prompts the user with a yes/no question and returns true if they confirm func confirmPrompt(reader io.Reader) bool { scanner := bufio.NewScanner(reader) diff --git a/internal/cmd/issue/view.go b/internal/cmd/issue/view.go index b46aca9..bc83957 100644 --- a/internal/cmd/issue/view.go +++ b/internal/cmd/issue/view.go @@ -2,7 +2,6 @@ package issue import ( "context" - "encoding/json" "fmt" "time" @@ -128,8 +127,8 @@ func outputViewJSON(streams *iostreams.IOStreams, issue *api.Issue, comments []a "state": issue.State, "kind": issue.Kind, "priority": issue.Priority, - "reporter": getUserDisplayName(issue.Reporter), - "assignee": getUserDisplayName(issue.Assignee), + "reporter": cmdutil.GetUserDisplayName(issue.Reporter), + "assignee": cmdutil.GetUserDisplayName(issue.Assignee), "votes": issue.Votes, "created_on": issue.CreatedOn, "updated_on": issue.UpdatedOn, @@ -148,7 +147,7 @@ func outputViewJSON(streams *iostreams.IOStreams, issue *api.Issue, comments []a for i, c := range comments { commentList[i] = map[string]interface{}{ "id": c.ID, - "user": getUserDisplayName(c.User), + "user": cmdutil.GetUserDisplayName(c.User), "created_on": c.CreatedOn, "updated_on": c.UpdatedOn, } @@ -159,13 +158,7 @@ func outputViewJSON(streams *iostreams.IOStreams, issue *api.Issue, comments []a output["comments"] = commentList } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api.IssueComment, showComments bool) error { @@ -180,8 +173,8 @@ func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api fmt.Fprintln(streams.Out) // Reporter and Assignee - fmt.Fprintf(streams.Out, "Reporter: %s\n", getUserDisplayName(issue.Reporter)) - fmt.Fprintf(streams.Out, "Assignee: %s\n", getUserDisplayName(issue.Assignee)) + fmt.Fprintf(streams.Out, "Reporter: %s\n", cmdutil.GetUserDisplayName(issue.Reporter)) + fmt.Fprintf(streams.Out, "Assignee: %s\n", cmdutil.GetUserDisplayName(issue.Assignee)) fmt.Fprintln(streams.Out) // Votes @@ -198,8 +191,8 @@ func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api } // Timestamps - fmt.Fprintf(streams.Out, "Created: %s\n", timeAgo(issue.CreatedOn)) - fmt.Fprintf(streams.Out, "Updated: %s\n", timeAgo(issue.UpdatedOn)) + fmt.Fprintf(streams.Out, "Created: %s\n", cmdutil.TimeAgo(issue.CreatedOn)) + fmt.Fprintf(streams.Out, "Updated: %s\n", cmdutil.TimeAgo(issue.UpdatedOn)) // URL if issue.Links != nil && issue.Links.HTML != nil { @@ -214,8 +207,8 @@ func displayIssue(streams *iostreams.IOStreams, issue *api.Issue, comments []api fmt.Fprintln(streams.Out) for _, comment := range comments { - author := getUserDisplayName(comment.User) - timestamp := timeAgo(comment.CreatedOn) + author := cmdutil.GetUserDisplayName(comment.User) + timestamp := cmdutil.TimeAgo(comment.CreatedOn) if streams.ColorEnabled() { fmt.Fprintf(streams.Out, "%s%s%s commented %s:\n", iostreams.Bold, author, iostreams.Reset, timestamp) diff --git a/internal/cmd/pipeline/list.go b/internal/cmd/pipeline/list.go index d659600..6552372 100644 --- a/internal/cmd/pipeline/list.go +++ b/internal/cmd/pipeline/list.go @@ -2,7 +2,6 @@ package pipeline import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" @@ -169,13 +168,7 @@ func outputListJSON(streams *iostreams.IOStreams, pipelines []api.Pipeline) erro } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) error { @@ -183,11 +176,7 @@ func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) err // Print header header := "#\tSTATUS\tBRANCH\tCOMMIT\tTRIGGER\tDURATION\tSTARTED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, p := range pipelines { @@ -197,7 +186,7 @@ func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) err branch := "-" commit := "-" if p.Target != nil { - branch = truncateString(p.Target.RefName, 25) + branch = cmdutil.TruncateString(p.Target.RefName, 25) if p.Target.Commit != nil { commit = getCommitShort(p.Target.Commit.Hash) } @@ -205,7 +194,7 @@ func outputListTable(streams *iostreams.IOStreams, pipelines []api.Pipeline) err trigger := getTriggerType(p.Trigger) duration := formatDuration(p.BuildSecondsUsed) - started := formatTimeAgo(p.CreatedOn) + started := cmdutil.TimeAgo(p.CreatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", buildNum, status, branch, commit, trigger, duration, started) diff --git a/internal/cmd/pipeline/shared.go b/internal/cmd/pipeline/shared.go index 944a98f..f0271c5 100644 --- a/internal/cmd/pipeline/shared.go +++ b/internal/cmd/pipeline/shared.go @@ -98,61 +98,6 @@ func formatDuration(seconds int) string { return fmt.Sprintf("%ds", secs) } -// formatTimeAgo formats a time as a human-readable relative time -func formatTimeAgo(t time.Time) string { - if t.IsZero() { - return "-" - } - - duration := time.Since(t) - - switch { - case duration < time.Minute: - return "just now" - case duration < time.Hour: - mins := int(duration.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case duration < 24*time.Hour: - hours := int(duration.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case duration < 30*24*time.Hour: - days := int(duration.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case duration < 365*24*time.Hour: - months := int(duration.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(duration.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} - -// truncateString truncates a string to a maximum length -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - // getCommitShort returns the first 7 characters of a commit hash func getCommitShort(hash string) string { if len(hash) > 7 { diff --git a/internal/cmd/pipeline/steps.go b/internal/cmd/pipeline/steps.go index d3fbd58..4b628fc 100644 --- a/internal/cmd/pipeline/steps.go +++ b/internal/cmd/pipeline/steps.go @@ -100,8 +100,6 @@ func runSteps(ctx context.Context, opts *StepsOptions, pipelineArg string) error return outputStepsTable(opts.Streams, result.Values) } - - func outputStepsJSON(streams *iostreams.IOStreams, steps []api.PipelineStep) error { output := make([]map[string]interface{}, len(steps)) for i, step := range steps { @@ -155,7 +153,7 @@ func outputStepsTable(streams *iostreams.IOStreams, steps []api.PipelineStep) er if name == "" { name = "(unnamed)" } - name = truncateString(name, 40) + name = cmdutil.TruncateString(name, 40) status := formatStepStatus(streams, step.State) duration := formatStepDuration(step.StartedOn, step.CompletedOn) diff --git a/internal/cmd/pipeline/view.go b/internal/cmd/pipeline/view.go index 98650eb..a7da2dc 100644 --- a/internal/cmd/pipeline/view.go +++ b/internal/cmd/pipeline/view.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "time" "github.com/spf13/cobra" @@ -114,8 +113,6 @@ func runView(ctx context.Context, opts *ViewOptions) error { return displayPipeline(opts.Streams, pipeline, steps) } - - func getPipelineWebURL(workspace, repoSlug string, buildNumber int) string { return fmt.Sprintf("https://bitbucket.org/%s/%s/pipelines/results/%d", workspace, repoSlug, buildNumber) @@ -229,9 +226,9 @@ func displayPipeline(streams *iostreams.IOStreams, pipeline *api.Pipeline, steps } // Timestamps - fmt.Fprintf(streams.Out, "Started: %s\n", formatTimeAgo(pipeline.CreatedOn)) + fmt.Fprintf(streams.Out, "Started: %s\n", cmdutil.TimeAgo(pipeline.CreatedOn)) if pipeline.CompletedOn != nil && !pipeline.CompletedOn.IsZero() { - fmt.Fprintf(streams.Out, "Completed: %s\n", formatTimeAgo(*pipeline.CompletedOn)) + fmt.Fprintf(streams.Out, "Completed: %s\n", cmdutil.TimeAgo(*pipeline.CompletedOn)) } // Steps summary @@ -302,11 +299,3 @@ func capitalize(s string) string { } return string(s[0]-32) + s[1:] } - -// formatTimestamp formats a time as a readable timestamp -func formatTimestamp(t time.Time) string { - if t.IsZero() { - return "-" - } - return t.Format("Jan 2, 2006 15:04:05") -} diff --git a/internal/cmd/pr/checkout.go b/internal/cmd/pr/checkout.go index 4d3d0d9..702338a 100644 --- a/internal/cmd/pr/checkout.go +++ b/internal/cmd/pr/checkout.go @@ -86,7 +86,7 @@ func runCheckout(opts *checkoutOptions) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - pr, err := getPullRequest(ctx, client, workspace, repoSlug, opts.prNumber) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(opts.prNumber)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } diff --git a/internal/cmd/pr/checks.go b/internal/cmd/pr/checks.go index 06dbc62..8dccc82 100644 --- a/internal/cmd/pr/checks.go +++ b/internal/cmd/pr/checks.go @@ -139,7 +139,7 @@ func outputChecksTable(streams *iostreams.IOStreams, statuses []api.CommitStatus if name == "" { name = s.Key } - desc := truncateString(s.Description, 50) + desc := cmdutil.TruncateString(s.Description, 50) fmt.Fprintf(w, "%s\t%s\t%s\n", status, name, desc) } diff --git a/internal/cmd/pr/comment.go b/internal/cmd/pr/comment.go index c46fba0..497ab7f 100644 --- a/internal/cmd/pr/comment.go +++ b/internal/cmd/pr/comment.go @@ -94,7 +94,7 @@ func runComment(opts *commentOptions, args []string) error { } // Parse response to get comment ID - comment, err := api.ParseResponse[*PRComment](resp) + comment, err := api.ParseResponse[*api.PRComment](resp) if err != nil { // Still print success even if we can't parse the comment ID opts.streams.Success("Added comment to pull request #%d", prNum) diff --git a/internal/cmd/pr/diff.go b/internal/cmd/pr/diff.go index 6711240..ab058f9 100644 --- a/internal/cmd/pr/diff.go +++ b/internal/cmd/pr/diff.go @@ -73,7 +73,7 @@ func runDiff(opts *diffOptions, args []string) error { ctx := context.Background() // Get the PR to get the diff link - pr, err := getPullRequest(ctx, client, workspace, repoSlug, prNum) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(prNum)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } diff --git a/internal/cmd/pr/list.go b/internal/cmd/pr/list.go index 41ec279..8cb5fb3 100644 --- a/internal/cmd/pr/list.go +++ b/internal/cmd/pr/list.go @@ -2,7 +2,6 @@ package pr import ( "context" - "encoding/json" "fmt" "strings" "text/tabwriter" @@ -16,12 +15,12 @@ import ( // ListOptions holds the options for the list command type ListOptions struct { - State string - Author string - Limit int - JSON bool - Repo string - Streams *iostreams.IOStreams + State string + Author string + Limit int + JSON bool + Repo string + Streams *iostreams.IOStreams } // NewCmdList creates the pr list command @@ -125,13 +124,7 @@ func outputListJSON(streams *iostreams.IOStreams, prs []api.PullRequest) error { output[i] = api.PullRequestJSON{PullRequest: &prs[i]} } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputTable(streams *iostreams.IOStreams, prs []api.PullRequest) error { @@ -139,17 +132,13 @@ func outputTable(streams *iostreams.IOStreams, prs []api.PullRequest) error { // Print header header := "ID\tTITLE\tBRANCH\tAUTHOR\tSTATUS" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, pr := range prs { - title := truncateString(pr.Title, 50) - branch := truncateString(pr.Source.Branch.Name, 30) - author := truncateString(pr.Author.DisplayName, 20) + title := cmdutil.TruncateString(pr.Title, 50) + branch := cmdutil.TruncateString(pr.Source.Branch.Name, 30) + author := cmdutil.TruncateString(pr.Author.DisplayName, 20) status := formatStatus(streams, string(pr.State)) fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", @@ -175,13 +164,3 @@ func formatStatus(streams *iostreams.IOStreams, state string) string { return state } } - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/pr/merge.go b/internal/cmd/pr/merge.go index 3ce76dc..b8cd1db 100644 --- a/internal/cmd/pr/merge.go +++ b/internal/cmd/pr/merge.go @@ -136,13 +136,13 @@ func runMerge(opts *mergeOptions) error { } // Get PR details - pr, err := getPullRequest(ctx, client, workspace, repoSlug, opts.prNumber) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(opts.prNumber)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } // Check PR state - if pr.State != "OPEN" { + if pr.State != api.PRStateOpen { return fmt.Errorf("pull request #%d is not open (state: %s)", opts.prNumber, pr.State) } diff --git a/internal/cmd/pr/pr_test.go b/internal/cmd/pr/pr_test.go index d2b2a71..2e6acde 100644 --- a/internal/cmd/pr/pr_test.go +++ b/internal/cmd/pr/pr_test.go @@ -3,6 +3,7 @@ package pr import ( "testing" + "github.com/rbansal42/bitbucket-cli/internal/api" "github.com/rbansal42/bitbucket-cli/internal/cmdutil" ) @@ -155,9 +156,9 @@ func TestParseRepository(t *testing.T) { wantErr: true, // empty repo is invalid }, { - name: "empty flag falls back to git detection", - repoFlag: "", - wantErr: true, // Will error in test environment without git + name: "empty flag falls back to git detection", + repoFlag: "", + wantErr: true, // Will error in test environment without git }, } @@ -189,7 +190,7 @@ func TestGetEditor(t *testing.T) { // Test that getEditor returns a non-empty string // The actual value depends on environment variables editor := getEditor() - + if editor == "" { t.Error("getEditor() returned empty string") } @@ -202,7 +203,7 @@ func TestGetEditorPriority(t *testing.T) { // Store original values // Note: In a real test, we'd use t.Setenv() which automatically restores // For now, just test that the function returns a non-empty value - + editor := getEditor() if editor == "" { t.Error("expected non-empty editor") @@ -211,12 +212,12 @@ func TestGetEditorPriority(t *testing.T) { // TestPullRequestTypes verifies the PR types can be used correctly func TestPullRequestTypes(t *testing.T) { - // Test that PullRequest struct can be instantiated - pr := PullRequest{ + // Test that api.PullRequest struct can be instantiated + pr := api.PullRequest{ ID: 1, Title: "Test PR", Description: "Test description", - State: "OPEN", + State: api.PRStateOpen, } if pr.ID != 1 { @@ -239,9 +240,9 @@ func TestPullRequestTypes(t *testing.T) { } } -// TestPRCommentTypes verifies the PRComment struct +// TestPRCommentTypes verifies the api.PRComment struct func TestPRCommentTypes(t *testing.T) { - comment := PRComment{ + comment := api.PRComment{ ID: 42, } comment.Content.Raw = "This is a comment" @@ -263,9 +264,9 @@ func TestPRCommentTypes(t *testing.T) { // TestParsePRNumberErrorMessages verifies error message quality func TestParsePRNumberErrorMessages(t *testing.T) { tests := []struct { - name string - args []string - wantErrMsg string + name string + args []string + wantErrMsg string }{ { name: "empty args error message", diff --git a/internal/cmd/pr/reopen.go b/internal/cmd/pr/reopen.go index c87f8b5..a31c334 100644 --- a/internal/cmd/pr/reopen.go +++ b/internal/cmd/pr/reopen.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" + "github.com/rbansal42/bitbucket-cli/internal/api" "github.com/rbansal42/bitbucket-cli/internal/cmdutil" "github.com/rbansal42/bitbucket-cli/internal/iostreams" ) @@ -62,12 +63,12 @@ func runReopen(opts *reopenOptions, args []string) error { ctx := context.Background() // First, check if PR is declined - pr, err := getPullRequest(ctx, client, workspace, repoSlug, prNum) + pr, err := client.GetPullRequest(ctx, workspace, repoSlug, int64(prNum)) if err != nil { return fmt.Errorf("failed to get pull request: %w", err) } - if pr.State != "DECLINED" { + if pr.State != api.PRStateDeclined { return fmt.Errorf("pull request #%d is not declined (current state: %s)", prNum, pr.State) } diff --git a/internal/cmd/pr/shared.go b/internal/cmd/pr/shared.go index 816bf2e..e9daf6a 100644 --- a/internal/cmd/pr/shared.go +++ b/internal/cmd/pr/shared.go @@ -1,14 +1,12 @@ package pr import ( - "context" "fmt" "os" "os/exec" "strconv" "strings" - "github.com/rbansal42/bitbucket-cli/internal/api" "github.com/rbansal42/bitbucket-cli/internal/config" ) @@ -93,95 +91,3 @@ func getEditor() string { // Default to vi return "vi" } - -// PRUser represents a user in a pull request context -type PRUser struct { - UUID string `json:"uuid"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - AccountID string `json:"account_id"` - Nickname string `json:"nickname"` - Links struct { - Avatar struct { - Href string `json:"href"` - } `json:"avatar"` - HTML struct { - Href string `json:"href"` - } `json:"html"` - } `json:"links"` -} - -// PRParticipant represents a participant in a pull request -type PRParticipant struct { - User PRUser `json:"user"` - Role string `json:"role"` // PARTICIPANT, REVIEWER - Approved bool `json:"approved"` - State string `json:"state"` // approved, changes_requested, etc. -} - -// PullRequest represents a Bitbucket pull request -type PullRequest struct { - ID int `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - State string `json:"state"` - Author PRUser `json:"author"` - Source struct { - Branch struct { - Name string `json:"name"` - } `json:"branch"` - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` - } `json:"source"` - Destination struct { - Branch struct { - Name string `json:"name"` - } `json:"branch"` - Repository struct { - FullName string `json:"full_name"` - } `json:"repository"` - } `json:"destination"` - Reviewers []PRUser `json:"reviewers"` - Participants []PRParticipant `json:"participants"` - CommentCount int `json:"comment_count"` - TaskCount int `json:"task_count"` - CloseSourceBranch bool `json:"close_source_branch"` - CreatedOn string `json:"created_on"` - UpdatedOn string `json:"updated_on"` - Links struct { - HTML struct { - Href string `json:"href"` - } `json:"html"` - Diff struct { - Href string `json:"href"` - } `json:"diff"` - Self struct { - Href string `json:"href"` - } `json:"self"` - } `json:"links"` -} - -// PRComment represents a pull request comment -type PRComment struct { - ID int `json:"id"` - Content struct { - Raw string `json:"raw"` - } `json:"content"` - Links struct { - HTML struct { - Href string `json:"href"` - } `json:"html"` - } `json:"links"` -} - -// getPullRequest fetches a pull request by number -func getPullRequest(ctx context.Context, client *api.Client, workspace, repoSlug string, prNum int) (*PullRequest, error) { - path := fmt.Sprintf("/repositories/%s/%s/pullrequests/%d", workspace, repoSlug, prNum) - resp, err := client.Get(ctx, path, nil) - if err != nil { - return nil, err - } - - return api.ParseResponse[*PullRequest](resp) -} diff --git a/internal/cmd/pr/view.go b/internal/cmd/pr/view.go index 03ec14d..9958555 100644 --- a/internal/cmd/pr/view.go +++ b/internal/cmd/pr/view.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/rbansal42/bitbucket-cli/internal/api" "github.com/rbansal42/bitbucket-cli/internal/browser" "github.com/rbansal42/bitbucket-cli/internal/cmdutil" "github.com/rbansal42/bitbucket-cli/internal/git" @@ -108,7 +109,7 @@ func runView(opts *viewOptions) error { } // Fetch PR details - pr, err := getPullRequest(ctx, client, opts.workspace, opts.repoSlug, prNumber) + pr, err := client.GetPullRequest(ctx, opts.workspace, opts.repoSlug, int64(prNumber)) if err != nil { return err } @@ -185,8 +186,8 @@ func findPRForBranch(ctx context.Context, workspace, repoSlug, branch string) (i } var result struct { - Values []PullRequest `json:"values"` - Size int `json:"size"` + Values []api.PullRequest `json:"values"` + Size int `json:"size"` } if err := json.Unmarshal(resp.Body, &result); err != nil { return 0, fmt.Errorf("failed to parse response: %w", err) @@ -196,25 +197,20 @@ func findPRForBranch(ctx context.Context, workspace, repoSlug, branch string) (i return 0, fmt.Errorf("no open pull request found for branch %q", branch) } - return result.Values[0].ID, nil + return int(result.Values[0].ID), nil } -func outputJSON(streams *iostreams.IOStreams, pr *PullRequest) error { - data, err := json.MarshalIndent(pr, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Fprintln(streams.Out, string(data)) - return nil +func outputJSON(streams *iostreams.IOStreams, pr *api.PullRequest) error { + return cmdutil.PrintJSON(streams, pr) } -func displayPR(streams *iostreams.IOStreams, pr *PullRequest) error { +func displayPR(streams *iostreams.IOStreams, pr *api.PullRequest) error { // Title and state fmt.Fprintf(streams.Out, "Title: %s\n", pr.Title) - fmt.Fprintf(streams.Out, "State: %s\n", strings.ToUpper(pr.State)) + fmt.Fprintf(streams.Out, "State: %s\n", strings.ToUpper(string(pr.State))) // Author - authorName := getUserDisplayName(pr.Author) + authorName := cmdutil.GetUserDisplayName(&pr.Author) fmt.Fprintf(streams.Out, "Author: %s\n", authorName) // Description @@ -231,7 +227,7 @@ func displayPR(streams *iostreams.IOStreams, pr *PullRequest) error { fmt.Fprintln(streams.Out, "Reviewers:") for _, p := range pr.Participants { if p.Role == "REVIEWER" { - name := getUserDisplayName(p.User) + name := cmdutil.GetUserDisplayName(&p.User) status := "pending" if p.Approved { status = "approved" @@ -253,64 +249,7 @@ func displayPR(streams *iostreams.IOStreams, pr *PullRequest) error { fmt.Fprintf(streams.Out, "Comments: %d\n", pr.CommentCount) // Created date - createdAt, err := time.Parse(time.RFC3339, pr.CreatedOn) - if err == nil { - fmt.Fprintf(streams.Out, "Created: %s\n", timeAgo(createdAt)) - } + fmt.Fprintf(streams.Out, "Created: %s\n", cmdutil.TimeAgo(pr.CreatedOn)) return nil } - -// getUserDisplayName returns the best available display name for a user -func getUserDisplayName(user PRUser) string { - if user.DisplayName != "" { - return user.DisplayName - } - if user.Username != "" { - return user.Username - } - if user.Nickname != "" { - return user.Nickname - } - return "unknown" -} - -// timeAgo returns a human-readable relative time string -func timeAgo(t time.Time) string { - duration := time.Since(t) - - switch { - case duration < time.Minute: - return "just now" - case duration < time.Hour: - mins := int(duration.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case duration < 24*time.Hour: - hours := int(duration.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case duration < 30*24*time.Hour: - days := int(duration.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case duration < 365*24*time.Hour: - months := int(duration.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(duration.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} diff --git a/internal/cmd/project/list.go b/internal/cmd/project/list.go index 3dd991a..caf332a 100644 --- a/internal/cmd/project/list.go +++ b/internal/cmd/project/list.go @@ -2,7 +2,6 @@ package project import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" @@ -116,13 +115,7 @@ func outputListJSON(streams *iostreams.IOStreams, projects []api.ProjectFull) er } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, projects []api.ProjectFull) error { @@ -130,17 +123,13 @@ func outputListTable(streams *iostreams.IOStreams, projects []api.ProjectFull) e // Print header header := "KEY\tNAME\tDESCRIPTION\tVISIBILITY" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, proj := range projects { key := proj.Key - name := truncateString(proj.Name, 30) - desc := truncateString(proj.Description, 40) + name := cmdutil.TruncateString(proj.Name, 30) + desc := cmdutil.TruncateString(proj.Description, 40) visibility := formatVisibility(streams, proj.IsPrivate) fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", key, name, desc, visibility) @@ -162,13 +151,3 @@ func formatVisibility(streams *iostreams.IOStreams, isPrivate bool) string { } return "public" } - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/repo/list.go b/internal/cmd/repo/list.go index 71de9f1..db937bc 100644 --- a/internal/cmd/repo/list.go +++ b/internal/cmd/repo/list.go @@ -2,10 +2,8 @@ package repo import ( "context" - "encoding/json" "fmt" "text/tabwriter" - "time" "github.com/spf13/cobra" @@ -119,13 +117,7 @@ func outputListJSON(streams *iostreams.IOStreams, repos []api.RepositoryFull) er } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputTable(streams *iostreams.IOStreams, repos []api.RepositoryFull) error { @@ -133,18 +125,14 @@ func outputTable(streams *iostreams.IOStreams, repos []api.RepositoryFull) error // Print header header := "NAME\tDESCRIPTION\tVISIBILITY\tUPDATED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, repo := range repos { - name := truncateString(repo.FullName, 40) - desc := truncateString(repo.Description, 40) + name := cmdutil.TruncateString(repo.FullName, 40) + desc := cmdutil.TruncateString(repo.Description, 40) visibility := formatVisibility(streams, repo.IsPrivate) - updated := formatUpdated(repo.UpdatedOn) + updated := cmdutil.TimeAgo(repo.UpdatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, desc, visibility, updated) } @@ -165,57 +153,3 @@ func formatVisibility(streams *iostreams.IOStreams, isPrivate bool) string { } return "public" } - -func formatUpdated(t time.Time) string { - if t.IsZero() { - return "-" - } - - now := time.Now() - diff := now.Sub(t) - - switch { - case diff < time.Minute: - return "just now" - case diff < time.Hour: - mins := int(diff.Minutes()) - if mins == 1 { - return "1 minute ago" - } - return fmt.Sprintf("%d minutes ago", mins) - case diff < 24*time.Hour: - hours := int(diff.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case diff < 30*24*time.Hour: - days := int(diff.Hours() / 24) - if days == 1 { - return "1 day ago" - } - return fmt.Sprintf("%d days ago", days) - case diff < 365*24*time.Hour: - months := int(diff.Hours() / 24 / 30) - if months == 1 { - return "1 month ago" - } - return fmt.Sprintf("%d months ago", months) - default: - years := int(diff.Hours() / 24 / 365) - if years == 1 { - return "1 year ago" - } - return fmt.Sprintf("%d years ago", years) - } -} - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} diff --git a/internal/cmd/snippet/list.go b/internal/cmd/snippet/list.go index 9f4f720..937f1e6 100644 --- a/internal/cmd/snippet/list.go +++ b/internal/cmd/snippet/list.go @@ -2,7 +2,6 @@ package snippet import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" @@ -140,13 +139,7 @@ func outputListJSON(streams *iostreams.IOStreams, snippets []api.Snippet) error } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, snippets []api.Snippet) error { @@ -154,11 +147,7 @@ func outputListTable(streams *iostreams.IOStreams, snippets []api.Snippet) error // Print header header := "ID\tTITLE\tVISIBILITY\tUPDATED" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, snippet := range snippets { @@ -173,53 +162,10 @@ func outputListTable(streams *iostreams.IOStreams, snippets []api.Snippet) error visibility = "private" } - updated := formatTime(snippet.UpdatedOn) + updated := cmdutil.TimeAgoFromString(snippet.UpdatedOn) fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", id, title, visibility, updated) } return w.Flush() } - -// formatTime formats an ISO 8601 timestamp to a human-readable format -func formatTime(isoTime string) string { - if isoTime == "" { - return "" - } - - t, err := time.Parse(time.RFC3339, isoTime) - if err != nil { - // Try alternative format - t, err = time.Parse("2006-01-02T15:04:05.000000-07:00", isoTime) - if err != nil { - return isoTime - } - } - - // Format as relative time or date - now := time.Now() - diff := now.Sub(t) - - switch { - case diff < time.Hour: - mins := int(diff.Minutes()) - if mins <= 1 { - return "just now" - } - return fmt.Sprintf("%dm ago", mins) - case diff < 24*time.Hour: - hours := int(diff.Hours()) - if hours == 1 { - return "1 hour ago" - } - return fmt.Sprintf("%d hours ago", hours) - case diff < 7*24*time.Hour: - days := int(diff.Hours() / 24) - if days == 1 { - return "yesterday" - } - return fmt.Sprintf("%d days ago", days) - default: - return t.Format("Jan 2, 2006") - } -} diff --git a/internal/cmd/workspace/list.go b/internal/cmd/workspace/list.go index 04e997b..bfb0ae1 100644 --- a/internal/cmd/workspace/list.go +++ b/internal/cmd/workspace/list.go @@ -2,7 +2,6 @@ package workspace import ( "context" - "encoding/json" "fmt" "text/tabwriter" "time" @@ -111,13 +110,7 @@ func outputListJSON(streams *iostreams.IOStreams, memberships []api.WorkspaceMem } } - data, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - - fmt.Fprintln(streams.Out, string(data)) - return nil + return cmdutil.PrintJSON(streams, output) } func outputListTable(streams *iostreams.IOStreams, memberships []api.WorkspaceMembership) error { @@ -125,11 +118,7 @@ func outputListTable(streams *iostreams.IOStreams, memberships []api.WorkspaceMe // Print header header := "SLUG\tNAME\tROLE" - if streams.ColorEnabled() { - fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) - } else { - fmt.Fprintln(w, header) - } + cmdutil.PrintTableHeader(streams, w, header) // Print rows for _, m := range memberships { diff --git a/internal/cmdutil/output.go b/internal/cmdutil/output.go new file mode 100644 index 0000000..9d9c5c5 --- /dev/null +++ b/internal/cmdutil/output.go @@ -0,0 +1,42 @@ +package cmdutil + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/rbansal42/bitbucket-cli/internal/iostreams" +) + +// PrintJSON marshals v as indented JSON and writes it to streams.Out. +func PrintJSON(streams *iostreams.IOStreams, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Fprintln(streams.Out, string(data)) + return nil +} + +// PrintTableHeader writes a bold header line to a tabwriter if color is enabled, +// otherwise writes a plain header. +func PrintTableHeader(streams *iostreams.IOStreams, w *tabwriter.Writer, header string) { + if streams.ColorEnabled() { + fmt.Fprintln(w, iostreams.Bold+header+iostreams.Reset) + } else { + fmt.Fprintln(w, header) + } +} + +// ConfirmPrompt reads a line from reader and returns true if user typed y/yes. +func ConfirmPrompt(reader io.Reader) bool { + scanner := bufio.NewScanner(reader) + if scanner.Scan() { + input := strings.TrimSpace(strings.ToLower(scanner.Text())) + return input == "y" || input == "yes" + } + return false +} diff --git a/internal/cmdutil/time.go b/internal/cmdutil/time.go new file mode 100644 index 0000000..c05fb7a --- /dev/null +++ b/internal/cmdutil/time.go @@ -0,0 +1,74 @@ +package cmdutil + +import ( + "fmt" + "time" +) + +// TimeAgo returns a human-readable relative time string for a time.Time value. +// Returns "-" for zero time values. +func TimeAgo(t time.Time) string { + if t.IsZero() { + return "-" + } + + duration := time.Since(t) + + // Guard against future timestamps (clock skew, test data) + if duration < 0 { + return "in the future" + } + + switch { + case duration < time.Minute: + return "just now" + case duration < time.Hour: + mins := int(duration.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case duration < 24*time.Hour: + hours := int(duration.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + case duration < 30*24*time.Hour: + days := int(duration.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + case duration < 365*24*time.Hour: + months := int(duration.Hours() / 24 / 30) + if months == 1 { + return "1 month ago" + } + return fmt.Sprintf("%d months ago", months) + default: + years := int(duration.Hours() / 24 / 365) + if years == 1 { + return "1 year ago" + } + return fmt.Sprintf("%d years ago", years) + } +} + +// TimeAgoFromString parses an ISO 8601 / RFC3339 timestamp string and returns +// a human-readable relative time. Returns the raw string on parse failure. +func TimeAgoFromString(isoTime string) string { + if isoTime == "" { + return "-" + } + + t, err := time.Parse(time.RFC3339, isoTime) + if err != nil { + t, err = time.Parse("2006-01-02T15:04:05.000000-07:00", isoTime) + if err != nil { + return isoTime + } + } + + return TimeAgo(t) +} diff --git a/internal/cmdutil/user.go b/internal/cmdutil/user.go new file mode 100644 index 0000000..444cd08 --- /dev/null +++ b/internal/cmdutil/user.go @@ -0,0 +1,21 @@ +package cmdutil + +import "github.com/rbansal42/bitbucket-cli/internal/api" + +// GetUserDisplayName returns the best available display name for a user. +// Returns "-" if user is nil, falls back through Username → Nickname → "unknown". +func GetUserDisplayName(user *api.User) string { + if user == nil { + return "-" + } + if user.DisplayName != "" { + return user.DisplayName + } + if user.Username != "" { + return user.Username + } + if user.Nickname != "" { + return user.Nickname + } + return "unknown" +}