diff --git a/internal/app/action_jobs.go b/internal/app/action_jobs.go index e4d8aef6..11a9eda8 100644 --- a/internal/app/action_jobs.go +++ b/internal/app/action_jobs.go @@ -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) diff --git a/internal/app/action_runs.go b/internal/app/action_runs.go index e60a2623..3542fa8d 100644 --- a/internal/app/action_runs.go +++ b/internal/app/action_runs.go @@ -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) { diff --git a/internal/app/action_values.go b/internal/app/action_values.go index b8763f7d..40e13380 100644 --- a/internal/app/action_values.go +++ b/internal/app/action_values.go @@ -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 { diff --git a/internal/app/actions.go b/internal/app/actions.go index 7100c271..30da3526 100644 --- a/internal/app/actions.go +++ b/internal/app/actions.go @@ -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=", input.Name, workflow.Label(), input.Name)) } diff --git a/internal/app/admin_users.go b/internal/app/admin_users.go index 80df1408..9056a07e 100644 --- a/internal/app/admin_users.go +++ b/internal/app/admin_users.go @@ -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 { diff --git a/internal/app/app.go b/internal/app/app.go index eaec9e16..d811f99a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 } diff --git a/internal/app/artifacts.go b/internal/app/artifacts.go index 8b89dbbb..fff6d7fa 100644 --- a/internal/app/artifacts.go +++ b/internal/app/artifacts.go @@ -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) diff --git a/internal/app/auth.go b/internal/app/auth.go index e1277729..415103de 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -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 (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 } diff --git a/internal/app/branches.go b/internal/app/branches.go index 252785be..076d1a2e 100644 --- a/internal/app/branches.go +++ b/internal/app/branches.go @@ -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 { diff --git a/internal/app/browser.go b/internal/app/browser.go index 0b2a34bb..6a286c0f 100644 --- a/internal/app/browser.go +++ b/internal/app/browser.go @@ -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 +} diff --git a/internal/app/checkout.go b/internal/app/checkout.go index 1a2795f6..0cd4ce02 100644 --- a/internal/app/checkout.go +++ b/internal/app/checkout.go @@ -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 } diff --git a/internal/app/checks.go b/internal/app/checks.go index f037f63d..228abc2a 100644 --- a/internal/app/checks.go +++ b/internal/app/checks.go @@ -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) } diff --git a/internal/app/comments.go b/internal/app/comments.go index a924df49..4fd5e924 100644 --- a/internal/app/comments.go +++ b/internal/app/comments.go @@ -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 { | }", 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 { | }", 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 { | }", Short: "List the comments on " + article(words.noun), Example: fmt.Sprintf(`$ tea %[1]s comment list %[2]s + list := &cobra.Command{Use: "list { | }", 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 { | }", 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 { | }", 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 { diff --git a/internal/app/completion.go b/internal/app/completion.go index 786fd8c5..ecf53f2b 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -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 } diff --git a/internal/app/config.go b/internal/app/config.go index be4f130f..3794c471 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -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 } diff --git a/internal/app/discussion_view.go b/internal/app/discussion_view.go index e6148aa5..78f674b0 100644 --- a/internal/app/discussion_view.go +++ b/internal/app/discussion_view.go @@ -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) -} diff --git a/internal/app/doctor.go b/internal/app/doctor.go index 2183a60a..cc6beb8e 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -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, "")) diff --git a/internal/app/git.go b/internal/app/git.go index 75b639ad..8392f6e9 100644 --- a/internal/app/git.go +++ b/internal/app/git.go @@ -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 } diff --git a/internal/app/git_credentials.go b/internal/app/git_credentials.go index d39958b8..53ef5697 100644 --- a/internal/app/git_credentials.go +++ b/internal/app/git_credentials.go @@ -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 } diff --git a/internal/app/human_output.go b/internal/app/human_output.go index fbe406ee..75474ca9 100644 --- a/internal/app/human_output.go +++ b/internal/app/human_output.go @@ -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 } diff --git a/internal/app/issue_attachments.go b/internal/app/issue_attachments.go index 269e6aa1..3c274c06 100644 --- a/internal/app/issue_attachments.go +++ b/internal/app/issue_attachments.go @@ -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) { diff --git a/internal/app/issues.go b/internal/app/issues.go index 4e1295ac..6a9544f2 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -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")) diff --git a/internal/app/metadata.go b/internal/app/metadata.go index 9b7334fe..a98605af 100644 --- a/internal/app/metadata.go +++ b/internal/app/metadata.go @@ -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 { diff --git a/internal/app/notifications.go b/internal/app/notifications.go index a19d997c..7ae7fae1 100644 --- a/internal/app/notifications.go +++ b/internal/app/notifications.go @@ -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) diff --git a/internal/app/organizations.go b/internal/app/organizations.go index 9a05b17a..397b5713 100644 --- a/internal/app/organizations.go +++ b/internal/app/organizations.go @@ -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. diff --git a/internal/app/prompts.go b/internal/app/prompts.go index c8a9a034..72681fef 100644 --- a/internal/app/prompts.go +++ b/internal/app/prompts.go @@ -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 ". 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 } diff --git a/internal/app/pull_clean.go b/internal/app/pull_clean.go index 9ba42b8c..d1ce3e80 100644 --- a/internal/app/pull_clean.go +++ b/internal/app/pull_clean.go @@ -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 } diff --git a/internal/app/pull_create.go b/internal/app/pull_create.go index 5ff16f4b..40008ba8 100644 --- a/internal/app/pull_create.go +++ b/internal/app/pull_create.go @@ -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 } diff --git a/internal/app/pull_review.go b/internal/app/pull_review.go index 2aa7ec7a..c1379528 100644 --- a/internal/app/pull_review.go +++ b/internal/app/pull_review.go @@ -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 { diff --git a/internal/app/pulls.go b/internal/app/pulls.go index d6b331b2..8ae2f527 100644 --- a/internal/app/pulls.go +++ b/internal/app/pulls.go @@ -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) { diff --git a/internal/app/ref.go b/internal/app/ref.go index d713d16f..0cf7ee8c 100644 --- a/internal/app/ref.go +++ b/internal/app/ref.go @@ -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) } diff --git a/internal/app/releases.go b/internal/app/releases.go index 44160d2b..210a9477 100644 --- a/internal/app/releases.go +++ b/internal/app/releases.go @@ -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) diff --git a/internal/app/repos.go b/internal/app/repos.go index 6924a57a..a0154493 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -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) -} diff --git a/internal/app/repository_create.go b/internal/app/repository_create.go index 5ec68c69..b7fe1ff2 100644 --- a/internal/app/repository_create.go +++ b/internal/app/repository_create.go @@ -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 { diff --git a/internal/app/repository_sync.go b/internal/app/repository_sync.go index 659bf18e..0a7e52b5 100644 --- a/internal/app/repository_sync.go +++ b/internal/app/repository_sync.go @@ -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 } diff --git a/internal/app/search.go b/internal/app/search.go index 09059c2d..1fb01912 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -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() diff --git a/internal/app/target.go b/internal/app/target.go index b04337d8..95613409 100644 --- a/internal/app/target.go +++ b/internal/app/target.go @@ -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 } diff --git a/internal/app/webhooks.go b/internal/app/webhooks.go index 6507c050..440ce3b1 100644 --- a/internal/app/webhooks.go +++ b/internal/app/webhooks.go @@ -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)")) } } diff --git a/internal/app/wiki.go b/internal/app/wiki.go index e45719a4..9d9a41c6 100644 --- a/internal/app/wiki.go +++ b/internal/app/wiki.go @@ -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) diff --git a/internal/services/checks.go b/internal/services/checks.go index 12b7967e..1b79645f 100644 --- a/internal/services/checks.go +++ b/internal/services/checks.go @@ -32,6 +32,21 @@ func (c Check) Pending() bool { return c.State == "pending" } // Passed reports whether the check finished without blocking a merge; Gitea counts skipped as success. func (c Check) Passed() bool { return c.State == "success" || c.State == "skipped" } +// Tally counts checks by outcome. +func Tally(items []Check) (failing, pending, passed int) { + for _, item := range items { + switch { + case item.Pending(): + pending++ + case item.Passed(): + passed++ + default: + failing++ + } + } + return failing, pending, passed +} + // Checks returns latest contexts for the PR head in its base repository, as Gitea does. func (s Pulls) Checks(ctx context.Context, n int64) ([]Check, error) { sha, err := s.checkHead(ctx, n) @@ -126,16 +141,8 @@ func (s Pulls) WatchChecks(ctx context.Context, n int64, interval time.Duration, } previous = items } - pending := len(items) == 0 - failed := false - for _, item := range items { - if item.Pending() { - pending = true - } else if !item.Passed() { - failed = true - } - } - if failed || !pending { + failing, pending, _ := Tally(items) + if failing > 0 || (pending == 0 && len(items) > 0) { current, err := s.checkHead(ctx, n) if err != nil { return nil, err diff --git a/internal/settings/store.go b/internal/settings/store.go index 9f376236..532b675f 100644 --- a/internal/settings/store.go +++ b/internal/settings/store.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "time" "gitea.dev/tea/internal/filelock" @@ -102,8 +103,14 @@ type Store struct { Dir string LegacyPath string mu sync.Mutex + // generation counts the updates made through the store. + generation atomic.Uint64 } +// Generation changes whenever an update through the store ran, so callers can tell when a +// snapshot they loaded earlier may be stale. +func (s *Store) Generation() uint64 { return s.generation.Load() } + // New selects an explicit directory or the platform configuration directory. func New(dir string) *Store { legacy := "" @@ -328,6 +335,7 @@ func (s *Store) Update(target Target, update func(*Snapshot) error) error { // Transaction runs a compensating action under the lock if publishing the target file fails. // It is used to keep credential storage and account metadata consistent. func (s *Store) Transaction(target Target, update func(*Snapshot) (func() error, error)) error { + defer s.generation.Add(1) return s.Locked(func(snapshot Snapshot) error { unpublished := func() ([]byte, error) { if target == TargetHosts { diff --git a/internal/terminal/time.go b/internal/terminal/time.go index 5df2485a..693422de 100644 --- a/internal/terminal/time.go +++ b/internal/terminal/time.go @@ -32,7 +32,7 @@ func RelativeTime(now, t time.Time) string { default: n, unit = d/(365*day), "year" } - return prefix + "about " + plural(int(n), unit) + suffix + return prefix + "about " + Plural(int(n), unit) + suffix } // Timestamp formats t for scripts: RFC3339 in UTC at second precision. @@ -49,7 +49,8 @@ func ParseTime(raw string) time.Time { return t } -func plural(n int, unit string) string { +// Plural counts n of a unit, as in "1 fork" or "3 forks". +func Plural(n int, unit string) string { if n == 1 { return "1 " + unit }