feat: v0.3.0d — add PR checks, iostreams, aliases, and broad enhancements
Add PR checks command, iostreams/text packages for colored table output, top-level run/workflow aliases matching gh CLI structure. Enhance actions, issues, PRs, releases, repos, labels, milestones, and wiki commands with improved flags, JSON output, and error handling.
This commit is contained in:
parent
7c0dcc8696
commit
113505de95
29 changed files with 3131 additions and 542 deletions
|
|
@ -6,11 +6,16 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
)
|
||||
|
||||
var sharedHTTPClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
*gitea.Client
|
||||
hostname string
|
||||
|
|
@ -63,8 +68,7 @@ func (c *Client) GetJSON(path string, result any) error {
|
|||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
|
|
@ -74,8 +78,11 @@ func (c *Client) GetJSON(path string, result any) error {
|
|||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("failed to read error response body: %w", readErr)
|
||||
}
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(body),
|
||||
|
|
@ -125,8 +132,7 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
|
|
@ -136,8 +142,11 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
|
|||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return resp.StatusCode, fmt.Errorf("failed to read error response body: %w", readErr)
|
||||
}
|
||||
return resp.StatusCode, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(bodyBytes),
|
||||
|
|
@ -154,6 +163,40 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
|
|||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// Token returns the client's authentication token.
|
||||
func (c *Client) Token() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
// DownloadFile performs an authenticated GET request and writes the response body to the given writer.
|
||||
func (c *Client) DownloadFile(url string, w io.Writer) error {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "token "+c.token)
|
||||
}
|
||||
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRawLog performs a GET request and returns the raw response body as string
|
||||
func (c *Client) GetRawLog(url string) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
|
|
@ -166,8 +209,7 @@ func (c *Client) GetRawLog(url string) (string, error) {
|
|||
req.Header.Set("Authorization", "token "+c.token)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
|
|
@ -178,7 +220,10 @@ func (c *Client) GetRawLog(url string) (string, error) {
|
|||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("failed to read error response body: %w", readErr)
|
||||
}
|
||||
return "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(body),
|
||||
|
|
|
|||
|
|
@ -114,6 +114,48 @@ func parseGitConfig(configPath string) (string, error) {
|
|||
return "", fmt.Errorf("no origin remote found in git config")
|
||||
}
|
||||
|
||||
// GetCurrentBranch returns the name of the currently checked-out branch.
|
||||
func GetCurrentBranch() (string, error) {
|
||||
gitDir, err := findGitDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
headPath := filepath.Join(gitDir, "HEAD")
|
||||
data, err := os.ReadFile(headPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read .git/HEAD: %w", err)
|
||||
}
|
||||
|
||||
headStr := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(headStr, "ref: refs/heads/") {
|
||||
return strings.TrimPrefix(headStr, "ref: refs/heads/"), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("HEAD is not on a branch (detached HEAD)")
|
||||
}
|
||||
|
||||
// findGitDir searches for the .git directory starting from the current directory
|
||||
func findGitDir() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
|
||||
dir := cwd
|
||||
for {
|
||||
gitDir := filepath.Join(dir, ".git")
|
||||
if info, err := os.Stat(gitDir); err == nil && info.IsDir() {
|
||||
return gitDir, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("not in a git repository")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// parseRemoteURL extracts owner/name/hostname from various git URL formats:
|
||||
// - https://codeberg.org/owner/name.git
|
||||
// - git@codeberg.org:owner/name.git
|
||||
|
|
|
|||
77
internal/iostreams/color.go
Normal file
77
internal/iostreams/color.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package iostreams
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ColorScheme provides semantic color methods that respect whether color output is enabled.
|
||||
type ColorScheme struct {
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewColorScheme creates a ColorScheme. When enabled is false, all methods return
|
||||
// undecorated text.
|
||||
func NewColorScheme(enabled bool) *ColorScheme {
|
||||
return &ColorScheme{enabled: enabled}
|
||||
}
|
||||
|
||||
// colorize wraps text in ANSI escape codes if color is enabled.
|
||||
func (cs *ColorScheme) colorize(code string, text string) string {
|
||||
if !cs.enabled {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprintf("\033[%sm%s\033[0m", code, text)
|
||||
}
|
||||
|
||||
// Bold renders text in bold.
|
||||
func (cs *ColorScheme) Bold(s string) string {
|
||||
return cs.colorize("1", s)
|
||||
}
|
||||
|
||||
// Red renders text in red.
|
||||
func (cs *ColorScheme) Red(s string) string {
|
||||
return cs.colorize("31", s)
|
||||
}
|
||||
|
||||
// Green renders text in green.
|
||||
func (cs *ColorScheme) Green(s string) string {
|
||||
return cs.colorize("32", s)
|
||||
}
|
||||
|
||||
// Yellow renders text in yellow.
|
||||
func (cs *ColorScheme) Yellow(s string) string {
|
||||
return cs.colorize("33", s)
|
||||
}
|
||||
|
||||
// Cyan renders text in cyan.
|
||||
func (cs *ColorScheme) Cyan(s string) string {
|
||||
return cs.colorize("36", s)
|
||||
}
|
||||
|
||||
// Magenta renders text in magenta.
|
||||
func (cs *ColorScheme) Magenta(s string) string {
|
||||
return cs.colorize("35", s)
|
||||
}
|
||||
|
||||
// Muted renders text in gray (dimmed).
|
||||
func (cs *ColorScheme) Muted(s string) string {
|
||||
return cs.colorize("90", s)
|
||||
}
|
||||
|
||||
// SuccessIcon returns a green check mark if color is enabled, plain otherwise.
|
||||
func (cs *ColorScheme) SuccessIcon() string {
|
||||
return cs.Green("✓")
|
||||
}
|
||||
|
||||
// WarningIcon returns a yellow exclamation mark if color is enabled, plain otherwise.
|
||||
func (cs *ColorScheme) WarningIcon() string {
|
||||
return cs.Yellow("!")
|
||||
}
|
||||
|
||||
// FailureIcon returns a red X mark if color is enabled, plain otherwise.
|
||||
func (cs *ColorScheme) FailureIcon() string {
|
||||
return cs.Red("✗")
|
||||
}
|
||||
|
||||
// SuccessIconWithColor returns the success icon followed by the message in green.
|
||||
func (cs *ColorScheme) SuccessIconWithColor(msg string) string {
|
||||
return cs.SuccessIcon() + " " + cs.Green(msg)
|
||||
}
|
||||
272
internal/iostreams/iostreams.go
Normal file
272
internal/iostreams/iostreams.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package iostreams
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// IOStreams provides the standard streams for the CLI along with TTY detection,
|
||||
// color support, pager integration, and other terminal helpers.
|
||||
type IOStreams struct {
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
|
||||
// Private fields for state
|
||||
isStdinTTY bool
|
||||
isStdoutTTY bool
|
||||
isStderrTTY bool
|
||||
|
||||
pagerProcess *exec.Cmd
|
||||
pagerPipe io.WriteCloser
|
||||
originalOut io.Writer
|
||||
|
||||
colorScheme *ColorScheme
|
||||
|
||||
spinnerMu sync.Mutex
|
||||
spinnerCancel chan struct{}
|
||||
}
|
||||
|
||||
// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr,
|
||||
// with TTY status auto-detected. Setting FGJ_FORCE_TTY=1 forces all streams to
|
||||
// be treated as TTYs.
|
||||
func New() *IOStreams {
|
||||
forceTTY := os.Getenv("FGJ_FORCE_TTY") != ""
|
||||
|
||||
stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd()))
|
||||
stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd()))
|
||||
stderrTTY := forceTTY || (isTerminal(os.Stderr.Fd()))
|
||||
|
||||
return &IOStreams{
|
||||
In: os.Stdin,
|
||||
Out: os.Stdout,
|
||||
ErrOut: os.Stderr,
|
||||
isStdinTTY: stdinTTY,
|
||||
isStdoutTTY: stdoutTTY,
|
||||
isStderrTTY: stderrTTY,
|
||||
}
|
||||
}
|
||||
|
||||
// Test creates an IOStreams backed by bytes.Buffers, suitable for unit tests.
|
||||
// All TTY flags are false.
|
||||
func Test() *IOStreams {
|
||||
return &IOStreams{
|
||||
In: &bytes.Buffer{},
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
isStdinTTY: false,
|
||||
isStdoutTTY: false,
|
||||
isStderrTTY: false,
|
||||
}
|
||||
}
|
||||
|
||||
// IsStdinTTY reports whether standard input is connected to a terminal.
|
||||
func (s *IOStreams) IsStdinTTY() bool {
|
||||
return s.isStdinTTY
|
||||
}
|
||||
|
||||
// IsStdoutTTY reports whether standard output is connected to a terminal.
|
||||
func (s *IOStreams) IsStdoutTTY() bool {
|
||||
return s.isStdoutTTY
|
||||
}
|
||||
|
||||
// IsStderrTTY reports whether standard error is connected to a terminal.
|
||||
func (s *IOStreams) IsStderrTTY() bool {
|
||||
return s.isStderrTTY
|
||||
}
|
||||
|
||||
// TerminalWidth returns the width of the terminal connected to stdout. If stdout
|
||||
// is not a terminal, it returns 80.
|
||||
func (s *IOStreams) TerminalWidth() int {
|
||||
if !s.isStdoutTTY {
|
||||
return 80
|
||||
}
|
||||
if f, ok := s.Out.(*os.File); ok {
|
||||
w, _, err := term.GetSize(int(f.Fd()))
|
||||
if err == nil && w > 0 {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return 80
|
||||
}
|
||||
|
||||
// ColorEnabled returns true when color output should be used. Color is enabled
|
||||
// when stdout is a TTY and the NO_COLOR environment variable is not set.
|
||||
func (s *IOStreams) ColorEnabled() bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
return s.isStdoutTTY
|
||||
}
|
||||
|
||||
// ColorScheme returns a lazily-initialized ColorScheme that respects the current
|
||||
// color settings.
|
||||
func (s *IOStreams) ColorScheme() *ColorScheme {
|
||||
if s.colorScheme == nil {
|
||||
s.colorScheme = NewColorScheme(s.ColorEnabled())
|
||||
}
|
||||
return s.colorScheme
|
||||
}
|
||||
|
||||
// StartPager starts an external pager process and redirects Out to its stdin.
|
||||
// It checks FGJ_PAGER, then PAGER, then defaults to "less". If LESS is not
|
||||
// already set, it is set to "FRX" for a good default experience.
|
||||
func (s *IOStreams) StartPager() error {
|
||||
if !s.isStdoutTTY {
|
||||
return nil
|
||||
}
|
||||
|
||||
pagerCmd := os.Getenv("FGJ_PAGER")
|
||||
if pagerCmd == "" {
|
||||
pagerCmd = os.Getenv("PAGER")
|
||||
}
|
||||
if pagerCmd == "" {
|
||||
pagerCmd = "less"
|
||||
}
|
||||
|
||||
if os.Getenv("LESS") == "" {
|
||||
os.Setenv("LESS", "FRX")
|
||||
}
|
||||
|
||||
parts := strings.Fields(pagerCmd)
|
||||
//nolint:gosec // pager command is user-configured
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
cmd.Stdout = s.Out
|
||||
cmd.Stderr = s.ErrOut
|
||||
|
||||
pipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating pager pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting pager: %w", err)
|
||||
}
|
||||
|
||||
s.pagerProcess = cmd
|
||||
s.pagerPipe = pipe
|
||||
s.originalOut = s.Out
|
||||
s.Out = pipe
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopPager closes the pager's stdin pipe and waits for the process to exit.
|
||||
// It restores Out to the original writer.
|
||||
func (s *IOStreams) StopPager() {
|
||||
if s.pagerPipe == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.pagerPipe.Close()
|
||||
_ = s.pagerProcess.Wait()
|
||||
|
||||
s.Out = s.originalOut
|
||||
s.pagerPipe = nil
|
||||
s.pagerProcess = nil
|
||||
s.originalOut = nil
|
||||
}
|
||||
|
||||
// spinnerFrames are the Braille-based animation frames for the spinner.
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
// StartSpinner shows an animated spinner with the given label on stderr. It only
|
||||
// runs when stderr is a TTY. Call StopSpinner to halt it.
|
||||
func (s *IOStreams) StartSpinner(label string) {
|
||||
if !s.isStderrTTY {
|
||||
return
|
||||
}
|
||||
|
||||
s.spinnerMu.Lock()
|
||||
defer s.spinnerMu.Unlock()
|
||||
|
||||
// Stop any existing spinner first.
|
||||
if s.spinnerCancel != nil {
|
||||
close(s.spinnerCancel)
|
||||
s.spinnerCancel = nil
|
||||
}
|
||||
|
||||
cancel := make(chan struct{})
|
||||
s.spinnerCancel = cancel
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
i := 0
|
||||
for {
|
||||
select {
|
||||
case <-cancel:
|
||||
// Clear the spinner line.
|
||||
fmt.Fprintf(s.ErrOut, "\r\033[K")
|
||||
return
|
||||
case <-ticker.C:
|
||||
frame := spinnerFrames[i%len(spinnerFrames)]
|
||||
fmt.Fprintf(s.ErrOut, "\r%s %s", frame, label)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopSpinner halts the spinner and clears the line on stderr.
|
||||
func (s *IOStreams) StopSpinner() {
|
||||
s.spinnerMu.Lock()
|
||||
defer s.spinnerMu.Unlock()
|
||||
|
||||
if s.spinnerCancel != nil {
|
||||
close(s.spinnerCancel)
|
||||
s.spinnerCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
// OpenInBrowser opens the given URL in the user's default browser.
|
||||
func (s *IOStreams) OpenInBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
default: // linux, freebsd, etc.
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// ConfirmAction prompts the user with a yes/no question and returns their
|
||||
// answer. It returns an error if stdin is not a TTY (non-interactive).
|
||||
func (s *IOStreams) ConfirmAction(prompt string) (bool, error) {
|
||||
if !s.isStdinTTY {
|
||||
return false, fmt.Errorf("cannot prompt for confirmation: not an interactive terminal")
|
||||
}
|
||||
|
||||
fmt.Fprintf(s.ErrOut, "%s [y/N]: ", prompt)
|
||||
|
||||
var response string
|
||||
if _, err := fmt.Fscan(s.In, &response); err != nil {
|
||||
return false, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
return response == "y" || response == "yes", nil
|
||||
}
|
||||
|
||||
// NewTablePrinter creates a TablePrinter that writes to this IOStreams' output.
|
||||
func (s *IOStreams) NewTablePrinter() *TablePrinter {
|
||||
return NewTablePrinter(s)
|
||||
}
|
||||
|
||||
// isTerminal reports whether the given file descriptor is a terminal.
|
||||
func isTerminal(fd uintptr) bool {
|
||||
return term.IsTerminal(int(fd))
|
||||
}
|
||||
64
internal/iostreams/table.go
Normal file
64
internal/iostreams/table.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package iostreams
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// TablePrinter prints TTY-aware tables. In TTY mode it uses aligned columns with
|
||||
// bold headers. In pipe mode it emits tab-separated values without headers.
|
||||
type TablePrinter struct {
|
||||
ios *IOStreams
|
||||
headers []string
|
||||
rows [][]string
|
||||
}
|
||||
|
||||
// NewTablePrinter creates a TablePrinter that writes to ios.Out.
|
||||
func NewTablePrinter(ios *IOStreams) *TablePrinter {
|
||||
return &TablePrinter{
|
||||
ios: ios,
|
||||
}
|
||||
}
|
||||
|
||||
// AddHeader sets the column headers. Headers are only displayed in TTY mode.
|
||||
func (t *TablePrinter) AddHeader(headers ...string) {
|
||||
t.headers = headers
|
||||
}
|
||||
|
||||
// AddRow appends a row of fields to the table.
|
||||
func (t *TablePrinter) AddRow(fields ...string) {
|
||||
t.rows = append(t.rows, fields)
|
||||
}
|
||||
|
||||
// Render writes the table to the IOStreams output. In TTY mode it uses tabwriter
|
||||
// with bold headers. In pipe mode it emits tab-separated values without headers.
|
||||
func (t *TablePrinter) Render() error {
|
||||
if !t.ios.IsStdoutTTY() {
|
||||
// Pipe mode: tab-separated, no headers
|
||||
for _, row := range t.rows {
|
||||
if _, err := fmt.Fprintln(t.ios.Out, strings.Join(row, "\t")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TTY mode: use tabwriter with aligned columns
|
||||
w := tabwriter.NewWriter(t.ios.Out, 0, 0, 2, ' ', 0)
|
||||
|
||||
if len(t.headers) > 0 {
|
||||
cs := t.ios.ColorScheme()
|
||||
boldHeaders := make([]string, len(t.headers))
|
||||
for i, h := range t.headers {
|
||||
boldHeaders[i] = cs.Bold(h)
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(boldHeaders, "\t"))
|
||||
}
|
||||
|
||||
for _, row := range t.rows {
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
}
|
||||
|
||||
return w.Flush()
|
||||
}
|
||||
70
internal/text/text.go
Normal file
70
internal/text/text.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package text
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Pluralize returns "1 issue" or "2 issues" depending on count.
|
||||
// It applies a simple "s" suffix rule.
|
||||
func Pluralize(count int, singular string) string {
|
||||
if count == 1 {
|
||||
return fmt.Sprintf("%d %s", count, singular)
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", count, singular)
|
||||
}
|
||||
|
||||
// FuzzyAgo returns a human-friendly relative time string like "just now",
|
||||
// "2 minutes ago", "3 hours ago", etc.
|
||||
func FuzzyAgo(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
|
||||
if d < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
|
||||
minutes := int(math.Floor(d.Minutes()))
|
||||
if minutes < 60 {
|
||||
return fmt.Sprintf("%s ago", Pluralize(minutes, "minute"))
|
||||
}
|
||||
|
||||
hours := int(math.Floor(d.Hours()))
|
||||
if hours < 24 {
|
||||
return fmt.Sprintf("%s ago", Pluralize(hours, "hour"))
|
||||
}
|
||||
|
||||
days := hours / 24
|
||||
if days < 30 {
|
||||
return fmt.Sprintf("%s ago", Pluralize(days, "day"))
|
||||
}
|
||||
|
||||
months := days / 30
|
||||
if months < 12 {
|
||||
return fmt.Sprintf("%s ago", Pluralize(months, "month"))
|
||||
}
|
||||
|
||||
years := months / 12
|
||||
return fmt.Sprintf("%s ago", Pluralize(years, "year"))
|
||||
}
|
||||
|
||||
// Truncate shortens text to maxWidth, replacing the end with "..." if it exceeds
|
||||
// the limit. If maxWidth is less than or equal to 3, the result is just "...".
|
||||
func Truncate(text string, maxWidth int) string {
|
||||
if len(text) <= maxWidth {
|
||||
return text
|
||||
}
|
||||
if maxWidth <= 3 {
|
||||
return "..."[:maxWidth]
|
||||
}
|
||||
return text[:maxWidth-3] + "..."
|
||||
}
|
||||
|
||||
// FormatDate returns a human-friendly relative time for TTY output, or an
|
||||
// RFC3339 timestamp for piped output.
|
||||
func FormatDate(t time.Time, isTTY bool) string {
|
||||
if isTTY {
|
||||
return FuzzyAgo(t)
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue