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:
sid 2026-03-23 11:42:44 -06:00
parent 7c0dcc8696
commit 113505de95
29 changed files with 3131 additions and 542 deletions

View 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)
}

View 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))
}

View 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()
}