refactor: load settings once per run and reuse shared helpers

The application caches the settings snapshot and reloads it only after
an update through the store, so output and prompt helpers no longer
read and parse config.yml and hosts.yml several times per command.
canPrompt and chooser replace the inline prompt-policy checks.

Checks are tallied by one services.Tally, terminal.Plural replaces the
two app copies, refKind.article/noun replace the comment group's own
words, list commands use servicePage, and web URLs are built by
repositoryURL and webURL in browser.go.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 10:57:39 +02:00
1 parent 9ac5b79e9e
commit 29f62da006
42 files changed
+157 -204

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ $ tea run jobs 34 --json id,conclusion --jq '.[] | select(.conclusion == "failur
return err
}
spec := listSpec[services.Job]{Columns: jobColumns, Row: jobRow(a.deps.Now()), Empty: fmt.Sprintf("no jobs in run %d", run), Context: fmt.Sprintf("jobs in run %d", run)}
return writeTable(a, cmd, jobs.Items, spec, listPage{Number: page, More: jobs.More, Total: jobs.Total, Counted: jobs.Counted})
return writeTable(a, cmd, jobs.Items, spec, servicePage(jobs, page))
},
}
pageFlags(cmd, &limit, &page)
+3 -13
View File
@@ -86,7 +86,7 @@ $ tea run list --since 24h --json id,status,conclusion,workflow`,
empty = "no runs match your filters in " + t.Repo
}
spec := listSpec[services.Run]{Columns: runColumns, Row: runRow(a.deps.Now()), Empty: empty, Context: "runs in " + t.Repo}
return writeTable(a, cmd, page.Items, spec, listPage{Number: filters.Page, More: page.More, Total: page.Total, Counted: page.Counted})
return writeTable(a, cmd, page.Items, spec, servicePage(page, filters.Page))
},
}
pageFlags(cmd, &filters.Limit, &filters.Page)
@@ -202,7 +202,7 @@ $ tea run view 34 --json jobs --jq '.jobs[] | {name, conclusion}'`,
return err
}
if web {
return a.openBrowser(cmd, fmt.Sprintf("%s/%s/actions/runs/%d", strings.TrimSuffix(t.Host, "/"), t.Repo, id))
return a.openBrowser(cmd, fmt.Sprintf("%s/actions/runs/%d", repositoryURL(t), id))
}
service := services.Actions{Client: t.client, Repository: t.Repo}
if log {
@@ -413,7 +413,7 @@ func (a *application) runArg(cmd *cobra.Command, args []string, active bool) (ta
return a.resolveRef(cmd, args[0], runRef)
}
missing := clierr.Wrap(clierr.Usage, &argumentError{problem: "missing run ID", cmd: cmd})
chooser, ok := a.runPicker()
chooser, ok := a.chooser()
if !ok {
return target{}, 0, missing
}
@@ -449,16 +449,6 @@ func (a *application) runArg(cmd *cobra.Command, args []string, active bool) (ta
return t, id, err
}
// runPicker returns the prompter's selection prompt when prompts are allowed.
func (a *application) runPicker() (terminal.Chooser, bool) {
chooser, ok := a.deps.Prompter.(terminal.Chooser)
if !ok {
return nil, false
}
s, err := a.deps.Settings.Load()
return chooser, err == nil && a.policy(s).CanPrompt(a.deps.Streams)
}
// accountNote reminds people who pass the global --user, which selects the account tea acts as,
// of the flag they may have meant, as gh's --user means something else.
func (a *application) accountNote(cmd *cobra.Command, t target, hint string) {
+1 -5
View File
@@ -283,11 +283,7 @@ func (a *application) actionValue(cmd *cobra.Command, secret bool, name, body, b
}
return strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r"), nil
}
s, err := a.deps.Settings.Load()
if err != nil {
return "", err
}
if !a.policy(s).CanPrompt(a.deps.Streams) {
if !a.canPrompt() {
return "", missing
}
if secret {
+4 -6
View File
@@ -341,11 +341,7 @@ func (a *application) dispatchValues(cmd *cobra.Command, workflow services.Workf
}
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("unknown input %q for %s; inputs: %s", key, workflow.Label(), strings.Join(names, ", ")))
}
s, err := a.deps.Settings.Load()
if err != nil {
return nil, err
}
canPrompt := a.policy(s).CanPrompt(a.deps.Streams)
canPrompt := a.canPrompt()
for _, input := range declared {
value, given := values[input.Name]
missing := input.Required && input.Default == ""
@@ -357,9 +353,11 @@ func (a *application) dispatchValues(cmd *cobra.Command, workflow services.Workf
}
values[input.Name] = normalized
case canPrompt && (askAll || missing):
if values[input.Name], err = a.askInput(cmd, input); err != nil {
answer, err := a.askInput(cmd, input)
if err != nil {
return nil, err
}
values[input.Name] = answer
case missing:
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input %s for %s; pass -f %s=<value>", input.Name, workflow.Label(), input.Name))
}
+1 -5
View File
@@ -292,11 +292,7 @@ func (a *application) adminUserPassword(cmd *cobra.Command, username, password,
return "", err
}
if password == "" && !cmd.Flags().Changed("password-file") {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return "", err
}
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
if !a.canPrompt() {
return "", clierr.Wrap(clierr.Usage, errors.New("missing password: supply --password-file or --password"))
}
if password, err = a.deps.Prompter.SecretInput(cmd.Context(), "Password for "+username); err != nil {
+23 -1
View File
@@ -14,6 +14,7 @@ import (
"regexp"
"slices"
"strings"
"sync"
"time"
"unicode"
@@ -71,6 +72,27 @@ type options struct {
type application struct {
deps Dependencies
options options
// snapshot caches the settings for one run; generation is the store's generation it was loaded at.
mu sync.Mutex
snapshot *settings.Snapshot
generation uint64
}
// settings returns the settings, loaded once per run and again after tea changed them.
func (a *application) settings() (settings.Snapshot, error) {
a.mu.Lock()
defer a.mu.Unlock()
generation := a.deps.Settings.Generation()
if a.snapshot != nil && a.generation == generation {
return *a.snapshot, nil
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return snapshot, err
}
a.snapshot, a.generation = &snapshot, generation
return snapshot, nil
}
// NewRoot builds fresh commands. Construction performs no disk, network, or Git IO.
@@ -144,7 +166,7 @@ func NewRoot(deps Dependencies) *cobra.Command {
if offline(cmd) {
return nil
}
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
+1 -1
View File
@@ -46,7 +46,7 @@ $ tea run artifact list 34`,
return err
}
spec := listSpec[services.Artifact]{Columns: artifactColumns, Row: artifactRow, Empty: fmt.Sprintf("no artifacts in run %d", run), Context: fmt.Sprintf("artifacts of run %d", run)}
return writeTable(a, cmd, items.Items, spec, listPage{Number: page, More: items.More, Total: items.Total, Counted: items.Counted})
return writeTable(a, cmd, items.Items, spec, servicePage(items, page))
},
}
pageFlags(list, &limit, &page)
+7 -11
View File
@@ -161,7 +161,7 @@ $ tea auth login --host gitea.com --ssh-key ~/.ssh/id_ed25519`,
case selected == 1 && !oauth && cmd.Flags().Changed("scopes"):
return clierr.Wrap(clierr.Usage, errors.New("--scopes requires --oauth"))
}
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
@@ -321,7 +321,7 @@ $ tea auth login --host gitea.com --ssh-key ~/.ssh/id_ed25519`,
// confirmReplace asks before a login replaces the stored credential of an account that tea already
// has, and reports whether it does.
func (a *application) confirmReplace(cmd *cobra.Command, instance, user string) (bool, error) {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return false, err
}
@@ -437,7 +437,7 @@ credential is missing or rejected.`,
$ tea auth status --verify
$ tea auth status --host gitea.com --json`,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return err
}
@@ -708,12 +708,8 @@ asks which one on a terminal. The instance is the one of the current repository,
// chooseAccount asks which of the accounts of instance to use, or explains how to name one.
func (a *application) chooseAccount(cmd *cobra.Command, instance string, names []string, active string) (string, error) {
s, err := a.deps.Settings.Load()
if err != nil {
return "", err
}
chooser, ok := a.deps.Prompter.(terminal.Chooser)
if !ok || !a.policy(s).CanPrompt(a.deps.Streams) {
chooser, ok := a.chooser()
if !ok {
return "", clierr.Wrap(clierr.Usage, fmt.Errorf("name the account: tea auth switch <user> (accounts on %s: %s)", instance, strings.Join(names, ", ")))
}
choices := make([]terminal.Choice, len(names))
@@ -774,7 +770,7 @@ instance.`,
a.warn(cmd, "Could not ask git's credential helpers to forget %s on %s", t.User, t.Host)
}
}
if s, err := a.deps.Settings.Load(); err == nil {
if s, err := a.settings(); err == nil {
if active := s.Hosts.Hosts[t.Host].User; active != "" && active != t.host.User {
a.done(cmd, "Switched active account for %s to %s", t.Host, active)
}
@@ -833,7 +829,7 @@ $ tea auth token --host gitea.com --user alice`,
if err != nil {
return err
}
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return err
}
+3 -3
View File
@@ -104,7 +104,7 @@ $ tea branch list -R alice/project --limit 100 --json name,sha`,
},
Context: "branches in " + target.Repo, Empty: "no branches in " + target.Repo,
}
return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted})
return writeTable(a, cmd, result.Items, spec, servicePage(result, page))
},
}
pageFlags(command, &limit, &page)
@@ -145,7 +145,7 @@ func (a *application) branchViewCommand() *cobra.Command {
protection := ""
if item.Protected {
badges = append(badges, terminal.Cell{Text: "protected", Color: "yellow"})
protection = countNoun(int(item.RequiredApprovals), "required approval")
protection = terminal.Plural(int(item.RequiredApprovals), "required approval")
}
meta := ""
if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() {
@@ -358,7 +358,7 @@ $ tea branch protect --pattern 'release/*' --allow-push acme/release`,
// protectionSummary describes a protection rule, as in "1 approval required, no direct pushes".
func protectionSummary(rule services.Protection) string {
parts := []string{countNoun(int(rule.RequiredApprovals), "approval") + " required"}
parts := []string{terminal.Plural(int(rule.RequiredApprovals), "approval") + " required"}
if pushers := len(rule.PushUsers) + len(rule.PushTeams); pushers > 0 {
parts = append(parts, "pushes by "+strings.Join(append(append([]string{}, rule.PushUsers...), rule.PushTeams...), ", "))
} else {
+15
View File
@@ -5,6 +5,7 @@ package app
import (
"fmt"
"net/url"
"strings"
"gitea.dev/tea/internal/terminal"
@@ -27,3 +28,17 @@ func (a *application) openBrowser(command *cobra.Command, resource string) error
func displayURL(url string) string {
return terminal.SafeLine(strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://"))
}
// webURL is the page of a path on the target's instance, such as an organization's profile.
func webURL(t target, path ...string) string {
escaped := make([]string, len(path))
for i, segment := range path {
escaped[i] = url.PathEscape(segment)
}
return strings.TrimSuffix(t.Host, "/") + "/" + strings.Join(escaped, "/")
}
// repositoryURL returns the web URL of the target's repository.
func repositoryURL(t target) string {
return strings.TrimSuffix(t.Host, "/") + "/" + t.Repo
}
+1 -1
View File
@@ -91,7 +91,7 @@ argument select it, and the branch tracks the head branch, so that git push upda
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
+7 -33
View File
@@ -52,7 +52,8 @@ tea exits with 8 while checks are pending and with 1 when a check failed, like g
if err != nil {
waiting := "checks to be reported"
if len(latest) > 0 {
waiting = plural(pendingChecks(latest), "pending check")
_, pending, _ := services.Tally(latest)
waiting = terminal.Plural(pending, "pending check")
}
return timeoutError(err, timeout, "waiting for %s", waiting)
}
@@ -94,45 +95,18 @@ $ tea pr checks 13 --watch && tea pr merge 13`
// checksOutcome is the exit status of checks: 1 when one failed, 8 while any is pending.
func checksOutcome(items []services.Check) error {
pending := false
for _, item := range items {
switch {
case item.Pending():
pending = true
case !item.Passed():
return clierr.Quiet(clierr.Failure)
}
}
if pending {
switch failing, pending, _ := services.Tally(items); {
case failing > 0:
return clierr.Quiet(clierr.Failure)
case pending > 0:
return clierr.Quiet(clierr.Pending)
}
return nil
}
// pendingChecks counts the checks that have not finished.
func pendingChecks(items []services.Check) int {
pending := 0
for _, item := range items {
if item.Pending() {
pending++
}
}
return pending
}
// checkCounts words how the checks stand: "0 failing, 1 successful, and 2 pending checks".
func checkCounts(items []services.Check) string {
var failing, passed, pending int
for _, item := range items {
switch {
case item.Pending():
pending++
case item.Passed():
passed++
default:
failing++
}
}
failing, pending, passed := services.Tally(items)
return fmt.Sprintf("%d failing, %d successful, and %d pending checks", failing, passed, pending)
}
+8 -16
View File
@@ -21,14 +21,14 @@ const commentTitle = "Comment (markdown):"
// commentNouns are the words for the commands of an issue or pull request comment group.
type commentNouns struct {
group, noun, arguments, example string
group, arguments, example string
}
func commentWords(kind refKind) commentNouns {
if kind == pullRef {
return commentNouns{group: "pr", noun: "pull request", arguments: pullNumberArguments, example: "13"}
return commentNouns{group: "pr", arguments: pullNumberArguments, example: "13"}
}
return commentNouns{group: "issue", noun: "issue", arguments: issueArguments, example: "12"}
return commentNouns{group: "issue", arguments: issueArguments, example: "12"}
}
// pullNumberArguments is the ARGUMENTS help of pr comment and pr attachment commands, which take pull
@@ -258,10 +258,10 @@ func (a *application) commentCommand(kind refKind) *cobra.Command {
words := commentWords(kind)
var flags commentFlags
var editLast, deleteLast bool
group := &cobra.Command{Use: "comment {<number> | <url>}", Short: "Add a comment to " + article(words.noun), Long: fmt.Sprintf(`Add a comment to %[1]s, or edit or delete your last comment on it.
group := &cobra.Command{Use: "comment {<number> | <url>}", Short: "Add a comment to " + kind.article(), Long: fmt.Sprintf(`Add a comment to %[1]s, or edit or delete your last comment on it.
Without --body, --body-file or --editor, the configured editor opens, or a multi-line prompt
asks for the comment. The subcommands list, view, edit and delete comments by their ID.`, article(words.noun)), Example: fmt.Sprintf(`$ tea %[1]s comment %[2]s --body "Fixed in #14"
asks for the comment. The subcommands list, view, edit and delete comments by their ID.`, kind.article()), Example: fmt.Sprintf(`$ tea %[1]s comment %[2]s --body "Fixed in #14"
$ tea %[1]s comment %[2]s --body-file notes.md
# Change or remove your last comment
$ tea %[1]s comment %[2]s --edit-last --body "Fixed in #15"
@@ -269,7 +269,7 @@ $ tea %[1]s comment %[2]s --delete-last --yes
$ tea %[1]s comment list %[2]s`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments, "tea.confirm": "true"}, Args: commentArgs(kind), RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
if localFlagsChanged(cmd) {
return clierr.Wrap(clierr.Usage, &argumentError{problem: "missing " + words.noun + " number", cmd: cmd})
return clierr.Wrap(clierr.Usage, &argumentError{problem: "missing " + kind.noun() + " number", cmd: cmd})
}
return cmd.Help()
}
@@ -308,7 +308,7 @@ $ tea %[1]s comment list %[2]s`, words.group, words.example), Annotations: map[s
group.ValidArgsFunction = a.completeDiscussions(kind)
var limit, page int
list := &cobra.Command{Use: "list {<number> | <url>}", Short: "List the comments on " + article(words.noun), Example: fmt.Sprintf(`$ tea %[1]s comment list %[2]s
list := &cobra.Command{Use: "list {<number> | <url>}", Short: "List the comments on " + kind.article(), Example: fmt.Sprintf(`$ tea %[1]s comment list %[2]s
$ tea %[1]s comment list %[2]s --json id,author,body`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
t, n, err := a.resolveRef(cmd, args[0], kind)
if err != nil {
@@ -345,7 +345,7 @@ $ tea %[1]s comment list %[2]s --json id,author,body`, words.group, words.exampl
}}
var createFlags commentFlags
create := &cobra.Command{Use: "create {<number> | <url>}", Aliases: []string{"add"}, Short: "Add a comment to " + article(words.noun), Example: fmt.Sprintf(`$ tea %[1]s comment create %[2]s --body "Looks good"
create := &cobra.Command{Use: "create {<number> | <url>}", Aliases: []string{"add"}, Short: "Add a comment to " + kind.article(), Example: fmt.Sprintf(`$ tea %[1]s comment create %[2]s --body "Looks good"
$ tea %[1]s comment add %[2]s --editor`, words.group, words.example), Annotations: map[string]string{"tea.arguments": words.arguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if err := createFlags.validate(cmd); err != nil {
return err
@@ -415,14 +415,6 @@ func commentArgs(kind refKind) cobra.PositionalArgs {
}
}
// article returns noun with its indefinite article: "an issue", "a pull request".
func article(noun string) string {
if strings.ContainsRune("aeiou", rune(noun[0])) {
return "an " + noun
}
return "a " + noun
}
// completeDiscussions completes the open issues or pull requests that a command of kind takes.
func (a *application) completeDiscussions(kind refKind) cobra.CompletionFunc {
if kind == pullRef {
+4 -4
View File
@@ -174,7 +174,7 @@ func completionContext(cmd *cobra.Command) (context.Context, context.CancelFunc)
// completeHosts completes the configured instances with their accounts.
func (a *application) completeHosts(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
@@ -189,7 +189,7 @@ func (a *application) completeHosts(_ *cobra.Command, _ []string, toComplete str
func (a *application) completeAccounts(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
ctx, cancel := completionContext(cmd)
defer cancel()
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
@@ -244,7 +244,7 @@ func (a *application) completeLocalBranches(cmd *cobra.Command, _ []string, toCo
// completeAliases completes the configured alias names with their expansions.
func (a *application) completeAliases(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
@@ -449,7 +449,7 @@ func (a *application) completeRepos() cobra.CompletionFunc {
remotes := func(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
ctx, cancel := completionContext(cmd)
defer cancel()
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
+4 -4
View File
@@ -216,7 +216,7 @@ $ tea config get default_host --show-source
$ tea config get ssh_host --host https://gitea.com
$ tea config get ssh_key --host https://gitea.com --user alice`,
RunE: func(cmd *cobra.Command, args []string) error {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
@@ -247,7 +247,7 @@ $ tea config get ssh_key --host https://gitea.com --user alice`,
# Include the settings of an instance
$ tea config list --host https://gitea.com`,
RunE: func(cmd *cobra.Command, _ []string) error {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
@@ -348,7 +348,7 @@ func (a *application) reportSet(cmd *cobra.Command, key, value, scope string) {
// warnWithoutAccount points out a default_host that has no account to act as yet.
func (a *application) warnWithoutAccount(cmd *cobra.Command) {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if host := snapshot.Preferences.DefaultHost; err == nil && len(snapshot.Hosts.Hosts[host].Users) == 0 {
a.warn(cmd, "No account on %s yet; log in with: tea auth login --host %s", host, host)
}
@@ -564,7 +564,7 @@ $ tea alias set il "issue list --assignee @me" --clobber`,
Use: "list", Short: "List shortcuts", Args: argsExact(0),
Example: "$ tea alias list\n# Print the expansion of one alias\n$ tea alias list --jq .il",
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return err
}
+3 -20
View File
@@ -200,17 +200,7 @@ func reviewerSummary(item services.Pull, reviews []services.Review) string {
// checkSummary counts checks by outcome: "1 failing, 2 pending, 3 successful", or "" without checks.
func checkSummary(checks []services.Check) string {
var failing, pending, passed int
for _, check := range checks {
switch {
case check.Pending():
pending++
case check.Passed():
passed++
default:
failing++
}
}
failing, pending, passed := services.Tally(checks)
var parts []string
for _, count := range []struct {
n int
@@ -315,13 +305,13 @@ func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discu
_, err := fmt.Fprint(cmd.OutOrStdout(), b.String())
return err
}
view.Meta = v.summary(now) + " • " + plural(int(v.comments), "comment")
view.Meta = v.summary(now) + " • " + terminal.Plural(int(v.comments), "comment")
view.URL = ""
muted := func(text string) string { return terminal.Colorize("muted", text, d.Color) }
var b strings.Builder
b.WriteString(view.Render(d))
if !withComments && v.comments > 0 {
b.WriteString("\n" + muted(plural(int(v.comments), "comment")+" — use --comments to view them") + "\n")
b.WriteString("\n" + muted(terminal.Plural(int(v.comments), "comment")+" — use --comments to view them") + "\n")
}
for _, comment := range comments {
heading := []string{terminal.Emphasis(terminal.SafeLine(comment.author), d.Color)}
@@ -346,10 +336,3 @@ func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discu
b.WriteString("\n" + muted("View this "+v.view.Kind+" on Gitea: "+terminal.SafeLine(v.view.URL)) + "\n")
return a.writeHuman(cmd, b.String())
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", n, unit)
}
+1 -1
View File
@@ -83,7 +83,7 @@ problem comes with the command that fixes it. tea doctor fails when a check fail
func (a *application) diagnose(ctx context.Context) doctorReport {
var report doctorReport
add := func(check doctorCheck) { report.Checks = append(report.Checks, check) }
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
add(doctorCheck{"Config", checkFailed, err.Error(), "repair or move the file; tea config path prints both files"})
add(a.gitCheck(ctx, ""))
+1 -1
View File
@@ -16,7 +16,7 @@ func (a *application) git(ctx context.Context, args ...string) (string, error) {
// gitIn runs Git in dir, which is relative to the process's working directory, not to --cwd.
func (a *application) gitIn(ctx context.Context, dir string, args ...string) (string, error) {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return "", err
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (a *application) gitCredentialCommand() *cobra.Command {
if args[0] != "get" {
return nil
}
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// preferences applies environment overrides to the stored preferences, matching config get --show-source.
func (a *application) preferences() (settings.Preferences, error) {
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return settings.Preferences{}, err
}
+2 -2
View File
@@ -45,8 +45,8 @@ func attachmentView(item services.IssueAttachment) terminal.View {
func (a *application) issueAttachmentCommand(kind refKind) *cobra.Command {
words := commentWords(kind)
group := &cobra.Command{Use: "attachment", Short: "Manage the attachments of " + words.noun + "s", Long: fmt.Sprintf(`List, view, upload, download, rename and delete the files attached to %s. Attachments
are addressed by the %s number and the attachment ID that attachment list shows.`, article(words.noun), words.noun)}
group := &cobra.Command{Use: "attachment", Short: "Manage the attachments of " + kind.noun() + "s", Long: fmt.Sprintf(`List, view, upload, download, rename and delete the files attached to %s. Attachments
are addressed by the %s number and the attachment ID that attachment list shows.`, kind.article(), kind.noun())}
annotations := map[string]string{"tea.arguments": words.arguments}
// discussionTarget resolves the issue or pull request argument of an attachment command.
discussionTarget := func(cmd *cobra.Command, raw string) (target, int64, error) {
+2 -2
View File
@@ -111,7 +111,7 @@ func issuesURL(t target, opts services.ListOptions) string {
if opts.Search != "" {
query.Set("q", opts.Search)
}
return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/" + section + "?" + query.Encode()
return repositoryURL(t) + "/" + section + "?" + query.Encode()
}
// issueListSpec describes issue tables of the issues where, such as "in alice/x": STATE shows on a
@@ -258,7 +258,7 @@ $ tea issue create --title "Idea" --web`, Args: argsExact(0), RunE: func(cmd *co
return err
}
query := url.Values{"title": {title}, "body": {cmp.Or(body, templateBody)}}
return a.openBrowser(cmd, strings.TrimRight(target.Host, "/")+"/"+target.Repo+"/issues/new?"+query.Encode())
return a.openBrowser(cmd, repositoryURL(target)+"/issues/new?"+query.Encode())
}
if bodyFile == "-" && title == "" {
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires --title; stdin cannot also supply a prompt"))
+1 -1
View File
@@ -113,7 +113,7 @@ func milestoneName(t target, item services.Milestone) string {
// milestoneURL is the web page of a milestone, which the API does not return.
func milestoneURL(t target, item services.Milestone) string {
return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/milestone/" + strconv.FormatInt(item.ID, 10)
return repositoryURL(t) + "/milestone/" + strconv.FormatInt(item.ID, 10)
}
func milestoneView(t target, now time.Time) func(services.Milestone) terminal.View {
+3 -3
View File
@@ -99,7 +99,7 @@ list shows identify notification threads; they are not issue numbers.`}
}, func(item services.Notification) string { return fmt.Sprintf(mark.done, notificationLabel(item)) })
}
what := notificationsWith(filters) + " " + s.where
if settings, err := a.deps.Settings.Load(); err == nil && a.policy(settings).CanPrompt(a.deps.Streams) {
if a.canPrompt() {
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf(mark.ask, what), DefaultYes: true}); err != nil {
return err
}
@@ -115,7 +115,7 @@ list shows identify notification threads; they are not issue numbers.`}
a.warn(cmd, "No %s", what)
return nil
}
a.done(cmd, mark.done, plural(len(results), "notification"))
a.done(cmd, mark.done, terminal.Plural(len(results), "notification"))
return nil
},
}
@@ -176,7 +176,7 @@ $ tea notification list --type pull --since 24h`,
what := notificationsWith(opts.Statuses) + " " + s.where
return writeTable(a, cmd, page.Items, listSpec[services.Notification]{
Columns: notificationColumns, Row: notificationRow, Empty: "no " + what, Context: what,
}, listPage{Number: opts.Page, More: page.More, Total: page.Total, Counted: page.Counted})
}, servicePage(page, opts.Page))
},
}
notificationFilters(command, &opts, &updated)
-10
View File
@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
@@ -47,15 +46,6 @@ func countOf(n int, singular, plural string) string {
return strconv.Itoa(n) + " " + plural
}
// webURL is the page of a path on the target's instance, such as an organization's profile.
func webURL(t target, path ...string) string {
escaped := make([]string, len(path))
for i, segment := range path {
escaped[i] = url.PathEscape(segment)
}
return strings.TrimSuffix(t.Host, "/") + "/" + strings.Join(escaped, "/")
}
func (a *application) organizationCommand() *cobra.Command {
group := &cobra.Command{Use: "org", Short: "Manage organizations", Long: `Work with Gitea organizations: list the ones you belong to, view, create, edit and delete
them, and manage their members.
+14 -2
View File
@@ -12,13 +12,25 @@ import (
// DefaultYes, destructive ones keep the default no, and irreversible ones set Typed. A declined
// confirmation returns terminal.ErrCanceled.
func (a *application) confirmAction(cmd *cobra.Command, c terminal.Confirmation) error {
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return err
}
return terminal.ConfirmAction(cmd.Context(), c, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter)
}
// canPrompt reports whether tea may ask questions on this terminal.
func (a *application) canPrompt() bool {
s, err := a.settings()
return err == nil && a.policy(s).CanPrompt(a.deps.Streams)
}
// chooser returns the prompter's selection prompt when prompts are allowed.
func (a *application) chooser() (terminal.Chooser, bool) {
chooser, ok := a.deps.Prompter.(terminal.Chooser)
return chooser, ok && a.canPrompt()
}
// deleteConfirmed asks to delete subject, such as `label "bug" from alice/x`, deletes it with del
// and reports "Deleted <subject>".
func (a *application) deleteConfirmed(cmd *cobra.Command, subject string, del func() error) error {
@@ -36,7 +48,7 @@ func (a *application) deleteConfirmed(cmd *cobra.Command, subject string, del fu
// until the answer is non-empty and validate, when set, accepts it. Without prompts it returns a
// usage error naming --flag. It reports whether it prompted.
func (a *application) requireInput(cmd *cobra.Command, value *string, flag, label string, validate func(string) error) (bool, error) {
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return false, err
}
+1 -1
View File
@@ -24,7 +24,7 @@ func (a *application) pullCleanCommand() *cobra.Command {
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
+2 -2
View File
@@ -74,7 +74,7 @@ is not on the remote yet is pushed first, with its upstream set.`, Args: argsExa
return err
}
}
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return err
}
@@ -260,7 +260,7 @@ func (a *application) defaultPullHead(ctx context.Context) (owner, branch string
if err != nil || raw == "" {
return "", branch
}
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return "", branch
}
-6
View File
@@ -250,12 +250,6 @@ func (a *application) reviewCreateCommand(use, short string) *cobra.Command {
return cmd
}
// canPrompt reports whether tea may ask questions on this terminal.
func (a *application) canPrompt() bool {
s, err := a.deps.Settings.Load()
return err == nil && a.policy(s).CanPrompt(a.deps.Streams)
}
// ownReview refuses to approve or request changes on one's own pull request before any body is
// written, as Gitea would after.
func ownReview(pull services.Pull, t target, state string) error {
+1 -6
View File
@@ -109,7 +109,7 @@ each pull request, which tea only makes when --json names them.`, Args: argsExac
}
},
Empty: empty, Context: what,
}, listPage{Number: page, More: window.More, Total: window.Total, Counted: window.Counted})
}, servicePage(window, page))
}}
enumFlag(cmd, &state, "state", "s", "open", "Filter by state", "open", "closed", "merged", "all")
pageFlags(cmd, &limit, &page)
@@ -163,11 +163,6 @@ func (a *application) jsonSelects(fields ...string) bool {
return false
}
// repositoryURL returns the web URL of the target's repository.
func repositoryURL(t target) string {
return strings.TrimSuffix(t.Host, "/") + "/" + t.Repo
}
// milestoneFilter resolves a milestone given as an ID or a title (D10): a number is an ID first,
// then a title.
func milestoneFilter(ctx context.Context, t target, raw string) (int64, error) {
+1 -1
View File
@@ -177,7 +177,7 @@ func (a *application) resolveRefs(cmd *cobra.Command, raws []string, kind refKin
// sameRepository reports whether two repository arguments name the same repository.
func (a *application) sameRepository(first, second string) bool {
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return strings.EqualFold(first, second)
}
+1 -1
View File
@@ -82,7 +82,7 @@ $ tea release list -R alice/project --json tag,title,draft`,
},
Context: "releases in " + target.Repo, Empty: "no releases in " + target.Repo,
}
return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted})
return writeTable(a, cmd, result.Items, spec, servicePage(result, page))
},
}
pageFlags(command, &limit, &page)
+3 -11
View File
@@ -173,7 +173,7 @@ $ tea repo list --starred --json full_name,url`,
}
collection := repositoryCollection(opts)
spec := listSpec[services.Repository]{Columns: repositoryColumns, Row: repositoryRow, Context: collection, Empty: "no " + collection}
return writeTable(a, cmd, result.Items, spec, listPage{Number: opts.Page, More: result.More, Total: result.Total, Counted: result.Counted})
return writeTable(a, cmd, result.Items, spec, servicePage(result, opts.Page))
},
}
pageFlags(list, &opts.Limit, &opts.Page)
@@ -294,8 +294,8 @@ func (a *application) repositoryView(item services.Repository, readme string) te
badges = append(badges, badge)
}
meta := []string{
fmt.Sprintf("★ %d", item.Stars), countNoun(item.Forks, "fork"), countNoun(item.OpenIssues, "open issue"),
countNoun(item.OpenPulls, "open pull request"),
fmt.Sprintf("★ %d", item.Stars), terminal.Plural(item.Forks, "fork"), terminal.Plural(item.OpenIssues, "open issue"),
terminal.Plural(item.OpenPulls, "open pull request"),
}
if updated := terminal.ParseTime(item.UpdatedAt); !updated.IsZero() {
meta = append(meta, "updated "+terminal.RelativeTime(a.deps.Now(), updated))
@@ -328,11 +328,3 @@ func parentName(item services.Repository) string {
}
return item.Parent.FullName
}
// countNoun counts n of a noun, as in "1 fork" or "3 forks".
func countNoun(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return fmt.Sprintf("%d %ss", n, noun)
}
+1 -1
View File
@@ -90,7 +90,7 @@ $ tea repo create project --source . --push`,
if err := validateRepositoryName(opts.Name); err != nil {
return err
}
selected := *a
selected := &application{deps: a.deps, options: a.options}
selected.options.repo = template
target, err := selected.resolve(cmd.Context(), false)
if err != nil {
+1 -1
View File
@@ -50,7 +50,7 @@ $ tea repo sync bob/project`,
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
snapshot, err := a.settings()
if err != nil {
return err
}
+1 -1
View File
@@ -55,7 +55,7 @@ $ tea search repo go --topic --sort updated --order desc`,
what += " matching " + opts.Query
}
spec := listSpec[services.Repository]{Columns: repositoryColumns, Row: repositoryRow, Context: what, Empty: "no " + what}
return writeTable(a, cmd, result.Items, spec, listPage{Number: opts.Page, More: result.More, Total: result.Total, Counted: result.Counted})
return writeTable(a, cmd, result.Items, spec, servicePage(result, opts.Page))
},
}
flags := command.Flags()
+1 -1
View File
@@ -110,7 +110,7 @@ func (a *application) resolveRequest(ctx context.Context, req targetRequest) (ta
// connect selects the instance, account and repository and builds the authenticated client.
func (a *application) connect(ctx context.Context, req targetRequest) (target, error) {
s, err := a.deps.Settings.Load()
s, err := a.settings()
if err != nil {
return target{}, err
}
+1 -5
View File
@@ -365,11 +365,7 @@ $ tea webhook create https://hooks.slack.com/services/T0/B0/X --org acme --type
}
askAuthorization := !create && authSources == 0
if askAuthorization {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
if !a.canPrompt() {
return clierr.Wrap(clierr.Usage, errors.New("editing a webhook drops its stored authorization header unless it is sent again, and Gitea never returns it: pass --authorization-header-file or --authorization-header (use --clear-authorization only for a hook without one)"))
}
}
+3 -7
View File
@@ -57,7 +57,7 @@ func (a *application) wikiListCommand() *cobra.Command {
},
Context: "wiki pages in " + target.Repo, Empty: "no wiki pages in " + target.Repo,
}
return writeTable(a, cmd, result.Items, spec, listPage{Number: page, More: result.More, Total: result.Total, Counted: result.Counted})
return writeTable(a, cmd, result.Items, spec, servicePage(result, page))
},
}
pageFlags(command, &limit, &page)
@@ -102,7 +102,7 @@ func (a *application) wikiView(item services.WikiPage) terminal.View {
meta = append(meta, "updated "+terminal.RelativeTime(a.deps.Now(), updated))
}
if item.CommitCount > 0 {
meta = append(meta, countNoun(int(item.CommitCount), "revision"))
meta = append(meta, terminal.Plural(int(item.CommitCount), "revision"))
}
return terminal.View{
Title: item.Title, Meta: strings.Join(meta, " · "), Body: item.Body, Kind: "wiki page", URL: item.URL,
@@ -226,11 +226,7 @@ func (a *application) wikiBody(cmd *cobra.Command, editor bool, initial string)
if editor {
return a.editDraft(cmd, initial)
}
s, err := a.deps.Settings.Load()
if err != nil {
return terminal.Draft{}, err
}
if !a.policy(s).CanPrompt(a.deps.Streams) {
if !a.canPrompt() {
return terminal.Draft{}, clierr.Wrap(clierr.Usage, errors.New("missing required input: supply --body, --body-file or --editor"))
}
return a.bodyEditor(cmd)(cmd.Context(), initial)