feat(app): report mutations on stderr and say what --web opens

Mutations printed a list row, or nothing, so a person could not tell
what changed and scripts parsed rows meant for people. Commands now get
the helpers for the gh convention:

- done and warn print "✓ Closed issue alice/x#5 (Fix login)" and
  "! … is already closed" to stderr, only when stdout is a terminal and
  --json is not set.
- printURL prints a created resource's URL to stdout unless --json is
  set, so url=$(tea issue create …) works.
- batchDone applies a mutation to several selections like batch and
  reports each success with a done line instead of a table.
- issueRef formats "alice/x#5 (title)" references.

--web now prints "Opening localhost:3001/alice/project/issues/1 in your
browser." to stderr on a terminal before the browser starts.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:43:11 +02:00
1 parent 55574a9fd0
commit 4fddcc6781
4 files changed
+167 -12

No files matched your search

+72 -11
View File
@@ -133,15 +133,48 @@ func writeView[T any](a *application, cmd *cobra.Command, item T, view func(T) t
return a.writeHuman(cmd, view(item).Render(a.display()))
}
// done reports a completed mutation on stderr, such as "✓ Closed issue alice/x#5 (Fix login)",
// when people read stdout: on a TTY and without --json.
func (a *application) done(cmd *cobra.Command, format string, args ...any) {
a.feedback(cmd, "✓", "green", format, args...)
}
// warn reports a mutation that had nothing to do, such as "! Issue alice/x#5 (Fix login) is already
// closed", under the same conditions as done.
func (a *application) warn(cmd *cobra.Command, format string, args ...any) {
a.feedback(cmd, "!", "yellow", format, args...)
}
func (a *application) feedback(cmd *cobra.Command, icon, color, format string, args ...any) {
if !a.deps.Streams.OutTTY || a.options.json != "" {
return
}
fmt.Fprintln(cmd.ErrOrStderr(), terminal.Colorize(color, icon, a.colorEnabled())+" "+terminal.SafeLine(fmt.Sprintf(format, args...)))
}
// printURL writes the URL of a created resource to stdout unless --json is set, so scripts can
// capture it: url=$(tea issue create …).
func (a *application) printURL(cmd *cobra.Command, url string) error {
if a.options.json != "" {
return nil
}
_, err := fmt.Fprintln(cmd.OutOrStdout(), terminal.SafeLine(url))
return err
}
// issueRef names an issue or pull request in feedback: "alice/x#5 (Fix login)".
func issueRef(repo string, number int64, title string) string {
return fmt.Sprintf("%s#%d (%s)", repo, number, title)
}
// abortBatch stops a batch instead of recording an item failure.
type abortBatch struct{ error }
func (e abortBatch) Unwrap() error { return e.error }
// batch applies run to each selection and continues past individual failures, which
// it reports prefixed with the selection's label. Human output is one table of the
// results; JSON is an object for one selection (none if it failed), otherwise an array.
func batch[K, T any](a *application, cmd *cobra.Command, keys []K, label func(K) string, run func(K) (T, error), row func(T) []terminal.Cell) error {
// applyBatch applies run to each selection and continues past individual failures, which it
// returns prefixed with the selection's label. succeeded sees each result as it arrives.
func applyBatch[K, T any](cmd *cobra.Command, keys []K, label func(K) string, run func(K) (T, error), succeeded func(T)) ([]T, []error) {
results := []T{}
var failures []error
for _, key := range keys {
@@ -159,15 +192,42 @@ func batch[K, T any](a *application, cmd *cobra.Command, keys []K, label func(K)
continue
}
results = append(results, item)
succeeded(item)
}
var err error
return results, failures
}
// writeBatchJSON writes an object for one selection (none if it failed), otherwise an array.
func writeBatchJSON[T any](a *application, cmd *cobra.Command, selections int, results []T) error {
switch {
case a.options.json == "":
err = a.writeHuman(cmd, legacyTable(a, cmd, results, row))
case len(keys) != 1:
err = a.writeJSON(cmd, results)
case selections != 1:
return a.writeJSON(cmd, results)
case len(results) == 1:
err = a.writeJSON(cmd, results[0])
return a.writeJSON(cmd, results[0])
}
return nil
}
// batch applies run to each selection like batchDone, for commands that predate it: its human
// output is one table of the results.
func batch[K, T any](a *application, cmd *cobra.Command, keys []K, label func(K) string, run func(K) (T, error), row func(T) []terminal.Cell) error {
results, failures := applyBatch(cmd, keys, label, run, func(T) {})
var err error
if a.options.json == "" {
err = a.writeHuman(cmd, legacyTable(a, cmd, results, row))
} else {
err = writeBatchJSON(a, cmd, len(keys), results)
}
return errors.Join(append(failures, err)...)
}
// batchDone applies a mutation to each selection like batch, reporting each success with a done
// line from report, such as "Closed issue alice/x#5 (Fix login)", and the results as JSON.
func batchDone[K, T any](a *application, cmd *cobra.Command, keys []K, label func(K) string, run func(K) (T, error), report func(T) string) error {
results, failures := applyBatch(cmd, keys, label, run, func(item T) { a.done(cmd, "%s", report(item)) })
var err error
if a.options.json != "" {
err = writeBatchJSON(a, cmd, len(keys), results)
}
return errors.Join(append(failures, err)...)
}
@@ -181,7 +241,8 @@ func writeList[T any](a *application, cmd *cobra.Command, items []T, row func(T)
return a.writeHuman(cmd, legacyTable(a, cmd, items, row))
}
// writeItem writes one item as a JSON object, or as its list row. Views move to writeView.
// writeItem writes one item as a JSON object, or as its list row. Views move to writeView, and
// mutations to done and printURL.
func writeItem[T any](a *application, cmd *cobra.Command, item T, row func(T) []terminal.Cell) error {
if a.options.json != "" {
return a.writeJSON(cmd, item)