diff --git a/docs/migration-1.0.md b/docs/migration-1.0.md index 43feeb3b..23b3ff80 100644 --- a/docs/migration-1.0.md +++ b/docs/migration-1.0.md @@ -210,6 +210,7 @@ Subcommands: | `labels update --id ID` | `label edit ` | | `labels delete --id ID` | `label delete ...` | | `labels list --save` | `label export` | +| `labels list` (also listed organization labels; `--exclude-org`) | `label list` lists the repository's labels only; use `label list --org OWNER` for an organization's, and drop `--exclude-org` | | `milestones issues ` | `milestone issue list ` (title or ID) | | `milestones issues add\|remove` | `milestone issue add\|remove ...` | | `releases assets create` | `release asset upload` | @@ -249,6 +250,8 @@ Renamed flags that appear on many commands: | `--template true\|false`, `--private true\|false`, `--archived true\|false` (repo edit) | `--as-template`, `--private`, `tea repo archive` / `unarchive`; these are on/off switches, so write `--private=false` (`--private false` names a repository `false`) | | `--owner` / `-O` (repo fork) | `--org` | | `--inactive` (webhook) | `--active=false` | +| `--expires` / `-x` (milestones create) | `--deadline` | +| `--kind issue\|pull` (milestones issues) | `--kind issues\|pulls\|all` | | `--out` (man) | `--output-file` / `-o` | Other long names changed too, mostly on `admin user`, `repo fork`, `repo edit` and diff --git a/internal/app/alias_completion_test.go b/internal/app/alias_completion_test.go index bab58520..9d53d817 100644 --- a/internal/app/alias_completion_test.go +++ b/internal/app/alias_completion_test.go @@ -443,3 +443,25 @@ func TestActionValueCompletionOutsideARepository(t *testing.T) { require.ElementsMatch(t, tc.want, values, name) } } + +func TestMilestoneCompletionOffersTheMilestonesTheCommandAccepts(t *testing.T) { + var states []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/alice/project/milestones" { + states = append(states, r.URL.Query().Get("state")) + } + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + a := &application{deps: deps} + a.options.repo = "alice/project" + root := a.milestoneCommand() + for _, path := range [][]string{{"reopen"}, {"close"}, {"view"}, {"issue", "list"}} { + cmd, _, err := root.Find(path) + require.NoError(t, err) + cmd.SetContext(context.Background()) + cmd.ValidArgsFunction(cmd, nil, "") + } + require.Equal(t, []string{"closed", "open", "all", "all"}, states) +} diff --git a/internal/app/completion.go b/internal/app/completion.go index 9bfdad10..d592ba8c 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -385,8 +385,13 @@ func (a *application) completeLabels() cobra.CompletionFunc { // completeMilestones completes the titles of open milestones. func (a *application) completeMilestones() cobra.CompletionFunc { + return a.completeMilestonesIn("open") +} + +// completeMilestonesIn completes the titles of the milestones in state: open, closed or all. +func (a *application) completeMilestonesIn(state string) cobra.CompletionFunc { return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) { - items, err := services.Milestones{Client: t.client, Repository: t.Repo}.List(ctx, "open", completionLimit, 1) + items, err := services.Milestones{Client: t.client, Repository: t.Repo}.List(ctx, state, completionLimit, 1) return describe(items, func(item services.Milestone) (string, string) { return item.Title, item.Description }), err }) } diff --git a/internal/app/label_export.go b/internal/app/label_export.go index 0d783255..282cb813 100644 --- a/internal/app/label_export.go +++ b/internal/app/label_export.go @@ -43,6 +43,10 @@ $ tea label export -R alice/old | tea label import - -R alice/new`, Args: argsEx } text.WriteString(row) } + if output == "" || output == "-" { + // Label names and descriptions are server text. + destination = terminal.SafeWriter(destination, a.deps.Streams.OutTTY, false) + } _, err = io.WriteString(destination, text.String()) return err }) diff --git a/internal/app/label_export_test.go b/internal/app/label_export_test.go index dea509bc..d448868a 100644 --- a/internal/app/label_export_test.go +++ b/internal/app/label_export_test.go @@ -86,3 +86,22 @@ func TestEmptyLabelJSONExportIsAnArray(t *testing.T) { require.JSONEq(t, `[]`, out.String()) require.Empty(t, diagnostics.String()) } + +func TestLabelExportStripsEscapesOnATerminalOnly(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "1" { + _, _ = w.Write([]byte(`[{"name":"bug\u001b[8m","color":"ff0000","description":""}]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + deps, out, _ := serverDeps(t, server.URL) + args := []string{"label", "export", "-R", "alice/project"} + require.NoError(t, Execute(context.Background(), deps, args)) + require.Contains(t, out.String(), "\x1b[8m") + out.Reset() + deps.Streams.OutTTY = true + require.NoError(t, Execute(context.Background(), deps, args)) + require.NotContains(t, out.String(), "\x1b") +} diff --git a/internal/app/label_options_test.go b/internal/app/label_options_test.go index d9cc51be..780abb3f 100644 --- a/internal/app/label_options_test.go +++ b/internal/app/label_options_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "strings" "testing" @@ -69,7 +70,7 @@ func TestLabelCreateRefusesDuplicateNames(t *testing.T) { } _, _ = w.Write([]byte(`[]`)) case "PATCH /api/v1/repos/alice/project/labels/11": - requirePayload(t, `{"color":"000000"}`, r.Body) + requirePayload(t, `{"color":"000000","is_archived":false}`, r.Body) changed = append(changed, r.Method+" "+r.URL.Path) _, _ = w.Write([]byte(`{"id":11,"name":"bug","color":"000000","description":"Broken"}`)) default: @@ -182,3 +183,28 @@ func TestLabelCreatePicksAColorAndPrompts(t *testing.T) { require.Equal(t, "From a prompt", created[1]["description"]) require.Equal(t, []string{"Label name", "Color (hex, e.g. d73a4a; empty = random):", "Description (optional):"}, prompt.asked) } + +func TestLabelEditAndForceKeepArchivedStateAndColor(t *testing.T) { + var patches []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeFirstPage(w, r, `[{"id":11,"name":"old","color":"ee0701","is_archived":true}]`) + case http.MethodPatch: + body, _ := io.ReadAll(r.Body) + patches = append(patches, string(body)) + _, _ = w.Write([]byte(`{"id":11,"name":"old","is_archived":true}`)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"label", "edit", "old", "-R", "alice/project", "-d", "retired"})) + require.NoError(t, Execute(context.Background(), deps, []string{"label", "create", "old", "-R", "alice/project", "-d", "kept", "--force", "--no-input"})) + require.Len(t, patches, 2) + for _, patch := range patches { + require.Contains(t, patch, `"is_archived":true`) + require.Contains(t, patch, `"color":null`) + } +} diff --git a/internal/app/labelfile_test.go b/internal/app/labelfile_test.go index 59a4c68e..eb577feb 100644 --- a/internal/app/labelfile_test.go +++ b/internal/app/labelfile_test.go @@ -88,3 +88,19 @@ func TestLabelImportAcceptsColorPrefixedRows(t *testing.T) { requirePayload(t, `{"name":"enhancement","color":"84b6eb"}`, strings.NewReader(created[1])) require.Empty(t, out.String()) } + +func TestLabelImportJSONIsAnArrayForOneRow(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`{"id":1,"name":"bug"}`)) + })) + defer server.Close() + deps, out, _ := serverDeps(t, server.URL) + file := filepath.Join(t.TempDir(), "labels.txt") + require.NoError(t, os.WriteFile(file, []byte("#ee0701 bug\n"), 0o600)) + require.NoError(t, Execute(context.Background(), deps, []string{"label", "import", file, "-R", "alice/project", "--json=id,name"})) + require.JSONEq(t, `[{"id":1,"name":"bug"}]`, out.String()) +} diff --git a/internal/app/labels.go b/internal/app/labels.go index 16ce1f33..4b58cb2b 100644 --- a/internal/app/labels.go +++ b/internal/app/labels.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math/rand/v2" + "slices" "strconv" "strings" @@ -166,10 +167,6 @@ $ tea label create bug -c ee0701 --force`, Args: usageArgs(cobra.MaximumNArgs(1) return err } } - if opts.Color == nil { - color := labelColors[rand.IntN(len(labelColors))] - opts.Color = &color - } s, err := scope(cmd) if err != nil { return err @@ -178,6 +175,11 @@ $ tea label create bug -c ee0701 --force`, Args: usageArgs(cobra.MaximumNArgs(1) if err != nil { return err } + // A random color is for new labels; --force keeps the color of an existing one. + if opts.Color == nil && !slices.ContainsFunc(existing, func(l services.Label) bool { return strings.EqualFold(l.Name, fields.name) }) { + color := labelColors[rand.IntN(len(labelColors))] + opts.Color = &color + } item, updated, err := s.CreateUnique(cmd.Context(), existing, opts, force) if err != nil { return forceHint(err) @@ -242,7 +244,7 @@ $ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.argu return err } if opts == (services.LabelOptions{}) { - return errNothingToEdit + return clierr.Wrap(clierr.Usage, errors.New("nothing to edit; pass a flag such as --name or --color (see --help)")) } s, err := scope(cmd) if err != nil { @@ -252,7 +254,7 @@ $ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.argu if err != nil { return err } - item, err := s.Edit(cmd.Context(), found[0].ID, opts) + item, err := s.Edit(cmd.Context(), found[0].ID, opts.KeepArchived(found[0])) if err != nil { return err } @@ -345,7 +347,11 @@ func (a *application) importLabels(cmd *cobra.Command, scope labelScope, file st }, func(item services.Label) { a.done(cmd, "%s %s", outcome[item.Name], labelName(s, item)) }) - return errors.Join(append(failures, writeBatchJSON(a, cmd, len(rows), results))...) + // The shape of the output must not depend on how many rows the file has. + if a.options.json != "" { + failures = append(failures, a.writeJSON(cmd, results)) + } + return errors.Join(failures...) } // completeScopedLabels completes the label names of the repository or, with --org, of the diff --git a/internal/app/metadata.go b/internal/app/metadata.go index 547d49b8..c73908be 100644 --- a/internal/app/metadata.go +++ b/internal/app/metadata.go @@ -45,11 +45,17 @@ $ tea milestone list --state all --json title,due_on,open_issues,closed_issues`, if err != nil { return err } - items, err := s.ListPage(cmd.Context(), state, limit, page) + // Gitea pages milestones by due date alone, so the order is applied to all of them first. + all, err := s.AllIn(cmd.Context(), state) if err != nil { return err } - slices.SortStableFunc(items.Items, compareMilestones) + slices.SortStableFunc(all, compareMilestones) + start := len(all) + if page-1 <= len(all)/limit { + start = min((page-1)*limit, len(all)) + } + items := services.Page[services.Milestone]{Items: all[start:min(start+limit, len(all))], More: start+limit < len(all), Total: len(all), Counted: true} what := "milestones in " + t.Repo if state != "all" { what = state + " " + what @@ -159,7 +165,7 @@ $ tea milestone view 3 --web`, Annotations: map[string]string{"tea.arguments": m }} cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID") cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the milestone in the browser") - cmd.ValidArgsFunction = firstArg(a.completeMilestones()) + cmd.ValidArgsFunction = firstArg(a.completeMilestonesIn("all")) return cmd } @@ -334,7 +340,7 @@ $ tea milestone edit 3 --editor`, Annotations: map[string]string{"tea.arguments" }} fields.register(cmd) cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID") - cmd.ValidArgsFunction = firstArg(a.completeMilestones()) + cmd.ValidArgsFunction = firstArg(a.completeMilestonesIn("all")) return cmd } @@ -361,7 +367,7 @@ $ tea milestone delete 7 --yes`, Annotations: map[string]string{"tea.confirm": " return a.jsonResult(cmd, item) }} cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID") - cmd.ValidArgsFunction = firstArg(a.completeMilestones()) + cmd.ValidArgsFunction = firstArg(a.completeMilestonesIn("all")) return cmd } diff --git a/internal/app/milestone_issues.go b/internal/app/milestone_issues.go index ae1550c4..603c12bd 100644 --- a/internal/app/milestone_issues.go +++ b/internal/app/milestone_issues.go @@ -24,11 +24,14 @@ $ tea milestone issue list v1.0 --state all --kind issues`, Annotations: map[str if err != nil { return err } - milestone, err := findMilestone(cmd, services.Milestones{Client: target.client, Repository: target.Repo}, args[0], byName) + milestones := services.Milestones{Client: target.client, Repository: target.Repo} + milestone, err := findMilestone(cmd, milestones, args[0], byName) if err != nil { return err } - opts.Milestones = strconv.FormatInt(milestone.ID, 10) + if opts.Milestones, err = milestones.FilterValue(cmd.Context(), milestone); err != nil { + return err + } page, err := (services.Issues{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), opts) if err != nil { return err @@ -40,7 +43,7 @@ $ tea milestone issue list v1.0 --state all --kind issues`, Annotations: map[str enumFlag(list, &opts.State, "state", "s", "open", "Filter by state", "open", "closed", "all") enumFlag(list, &opts.Kind, "kind", "", "all", "List issues, pull requests or both", "issues", "pulls", "all") list.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID") - list.ValidArgsFunction = firstArg(a.completeMilestones()) + list.ValidArgsFunction = firstArg(a.completeMilestonesIn("all")) group.AddCommand(list) for _, verb := range []string{"add", "remove"} { var byName bool @@ -77,7 +80,7 @@ $ tea milestone issue %[1]s v1.0 12 13 '#14'`, verb), Annotations: map[string]st return errors.Join(append(failures, writeBatchJSON(a, cmd, len(numbers), results))...) }} command.Flags().BoolVar(&byName, "by-name", false, "Match a numeric milestone argument as a title rather than an ID") - milestones, issues := a.completeMilestones(), a.completeIssues("open") + milestones, issues := a.completeMilestonesIn("all"), a.completeIssues("open") command.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { if len(args) == 0 { return milestones(cmd, args, toComplete) diff --git a/internal/app/milestone_names_test.go b/internal/app/milestone_names_test.go index 2e999c06..dd1a0ea5 100644 --- a/internal/app/milestone_names_test.go +++ b/internal/app/milestone_names_test.go @@ -97,7 +97,8 @@ func TestMilestoneIssueCommandsSelectByName(t *testing.T) { } _, _ = w.Write([]byte(`[]`)) case r.URL.Path == "/api/v1/repos/alice/project/issues" && r.Method == http.MethodGet: - require.Equal(t, "9", r.URL.Query().Get("milestones")) + // Gitea matches a filter value by title first, so the unique title is sent. + require.Equal(t, "Release one", r.URL.Query().Get("milestones")) _, _ = w.Write([]byte(`[]`)) case r.URL.Path == "/api/v1/repos/alice/project/issues/3" && r.Method == http.MethodPatch: var payload map[string]any @@ -180,3 +181,22 @@ func TestMilestonesAcceptTitlesOrIDs(t *testing.T) { } require.Len(t, calls, 5) } + +func TestMilestoneListSortsBeforePaging(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Gitea orders by due date alone, so undated milestones come first. + if r.URL.Query().Get("page") == "1" { + _, _ = w.Write([]byte(`[{"id":1,"title":"undated","state":"open"},{"id":2,"title":"dated","state":"open","due_on":"2028-01-15T00:00:00Z"}]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + defer server.Close() + deps, out, _ := serverDeps(t, server.URL) + args := []string{"milestone", "list", "-R", "alice/project", "--limit", "1", "--json=title"} + require.NoError(t, Execute(context.Background(), deps, args)) + require.JSONEq(t, `[{"title":"dated"}]`, out.String()) + out.Reset() + require.NoError(t, Execute(context.Background(), deps, append(args, "--page", "2"))) + require.JSONEq(t, `[{"title":"undated"}]`, out.String()) +} diff --git a/internal/app/milestone_state.go b/internal/app/milestone_state.go index 48a009cd..f2ffaae5 100644 --- a/internal/app/milestone_state.go +++ b/internal/app/milestone_state.go @@ -54,6 +54,7 @@ $ tea milestone %[1]s v1.0 v1.1`, verb), Annotations: map[string]string{"tea.arg return errors.Join(append(failures, writeBatchJSON(a, cmd, len(selected), results))...) }} cmd.Flags().BoolVar(&byName, "by-name", false, "Match numeric arguments as milestone titles rather than IDs") - cmd.ValidArgsFunction = a.completeMilestones() + // Only milestones that are not in the target state are worth completing. + cmd.ValidArgsFunction = a.completeMilestonesIn(map[string]string{"open": "closed", "closed": "open"}[state]) return cmd } diff --git a/internal/app/time.go b/internal/app/time.go index 7c3c2b3e..96a1bae1 100644 --- a/internal/app/time.go +++ b/internal/app/time.go @@ -34,6 +34,9 @@ func parseSpent(raw string) (int64, error) { // spent formats tracked seconds for people: "1h 30m", "15m", "45s". func spent(seconds int64) string { + if seconds < 0 { + return "-" + spent(-seconds) + } d := trackedDuration(seconds) var parts []string for _, unit := range []struct { @@ -123,7 +126,7 @@ $ tea time list --mine --since 168h`, Args: argsExact(0), RunE: func(cmd *cobra. // of the listed time. func (a *application) writeTimes(cmd *cobra.Command, page services.Page[services.TrackedTime], number int, spec listSpec[services.TrackedTime], total bool) error { d := a.display() - if a.options.json != "" || len(page.Items) == 0 || (!d.TTY && !total) { + if a.options.json != "" || (len(page.Items) == 0 && (d.TTY || !total)) || (!d.TTY && !total) { return writeTable(a, cmd, page.Items, spec, servicePage(page, number)) } var sum int64 @@ -136,7 +139,14 @@ func (a *application) writeTimes(cmd *cobra.Command, page services.Page[services if d.TTY { text = showing(len(page.Items), servicePage(page, number), spec.Context) + "\n\n" + text } - return a.writeHuman(cmd, text) + if err := a.writeHuman(cmd, text); err != nil { + return err + } + if d.TTY { + return nil + } + // A piped total covers the listed page only. + return a.morePagesHint(cmd, page.More, number) } // trackedTimeRow shows a record in repo; records of other repositories name theirs. @@ -200,12 +210,12 @@ $ tea time delete 12 8`, Annotations: map[string]string{"tea.text": "time delete } func (a *application) timeResetCommand() *cobra.Command { - return &cobra.Command{Use: "reset { | }", Short: "Delete all time logged on an issue", Example: "$ tea time reset 12 --yes", Annotations: map[string]string{"tea.text": "time reset does not return a resource", "tea.confirm": "true", "tea.arguments": issueArguments}, Args: argsExact(1), ValidArgsFunction: firstArg(a.completeIssues("all")), RunE: func(cmd *cobra.Command, args []string) error { + return &cobra.Command{Use: "reset { | }", Short: "Delete your time logged on an issue", Long: "Delete the time you logged on an issue or pull request. Gitea keeps the time other users logged.", Example: "$ tea time reset 12 --yes", Annotations: map[string]string{"tea.text": "time reset does not return a resource", "tea.confirm": "true", "tea.arguments": issueArguments}, Args: argsExact(1), ValidArgsFunction: firstArg(a.completeIssues("all")), RunE: func(cmd *cobra.Command, args []string) error { target, n, err := a.resolveRef(cmd, args[0], issueRef) if err != nil { return err } - subject := fmt.Sprintf("all time logged on %s#%d", target.Repo, n) + subject := fmt.Sprintf("your time logged on %s#%d", target.Repo, n) return a.deleteConfirmed(cmd, subject, func() error { return (services.Times{Client: target.client, Repository: target.Repo}).Reset(cmd.Context(), n) }) diff --git a/internal/app/time_test.go b/internal/app/time_test.go index c3a3f85f..20533c3a 100644 --- a/internal/app/time_test.go +++ b/internal/app/time_test.go @@ -95,7 +95,7 @@ func TestTimeTablesAndDoneLines(t *testing.T) { require.NoError(t, run("time", "delete", "1", "8", "--yes")) require.Equal(t, "✓ Deleted 1h 30m logged by alice on alice/project#1\n", diagnostics.String()) require.NoError(t, run("time", "reset", "1", "--yes")) - require.Equal(t, "✓ Deleted all time logged on alice/project#1\n", diagnostics.String()) + require.Equal(t, "✓ Deleted your time logged on alice/project#1\n", diagnostics.String()) require.ErrorContains(t, run("time", "delete", "1", "77", "--yes"), "tracked time 77 not found in alice/project#1") require.EqualError(t, run("time", "delete", "1", "8"), "confirmation required to delete 1h 30m logged by alice on alice/project#1; rerun with --yes") require.Equal(t, []string{ @@ -103,7 +103,28 @@ func TestTimeTablesAndDoneLines(t *testing.T) { "DELETE /api/v1/repos/alice/project/issues/1/times/8", "DELETE /api/v1/repos/alice/project/issues/1/times", }, changed) - for seconds, want := range map[int64]string{0: "0s", 45: "45s", 90: "1m 30s", 900: "15m", 3600: "1h", 5400: "1h 30m", 5410: "1h 30m"} { + for seconds, want := range map[int64]string{0: "0s", 45: "45s", 90: "1m 30s", 900: "15m", 3600: "1h", 5400: "1h 30m", 5410: "1h 30m", -90: "-1m 30s"} { require.Equal(t, want, spent(seconds)) } } + +func TestTimeListTotalPipedCoversEmptyAndPagedListings(t *testing.T) { + empty := true + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if empty { + _, _ = w.Write([]byte(`[]`)) + return + } + w.Header().Set("X-Total-Count", "2") + _, _ = w.Write([]byte(`[{"id":1,"time":60,"created":"2026-10-01T10:00:00Z","user_name":"alice","issue":{"number":3,"repository":{"full_name":"alice/project"}}}]`)) + })) + defer server.Close() + deps, out, diagnostics := serverDeps(t, server.URL) + require.NoError(t, Execute(context.Background(), deps, []string{"time", "list", "--mine", "--total"})) + require.Equal(t, "Total: 0s\n", out.String()) + out.Reset() + empty = false + require.NoError(t, Execute(context.Background(), deps, []string{"time", "list", "--mine", "--total", "--limit", "1"})) + require.Equal(t, "1\talice/project#3\talice\t1m\t2026-10-01T10:00:00Z\nTotal: 1m\n", out.String()) + require.Equal(t, "more results available; use --page 2\n", diagnostics.String()) +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 3b9dd8d3..31e11599 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -70,7 +70,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"issue", "list", "--page", "0"}, `"--page" flag: must be a positive integer`}, {[]string{"issue", "list", "--label", "bug, "}, "label names cannot be empty"}, {[]string{"label", "create", "--name", "bug", "--color", "wrong"}, "--color requires six hexadecimal digits"}, - {[]string{"label", "edit", "1"}, "nothing to edit; pass a flag such as --title (see --help)"}, + {[]string{"label", "edit", "1"}, "nothing to edit; pass a flag such as --name or --color (see --help)"}, {[]string{"label", "list", "--org", "team", "-R", "a/b"}, "--org and --repo are mutually exclusive"}, {[]string{"milestone", "close", " ", "--by-name"}, "milestone title or ID cannot be empty"}, {[]string{"milestone", "create", "--title", "v1", "--deadline", "invalid"}, "invalid --deadline"}, diff --git a/internal/services/errors.go b/internal/services/errors.go index ae0a2a28..1b120305 100644 --- a/internal/services/errors.go +++ b/internal/services/errors.go @@ -120,6 +120,8 @@ func (e *ConflictError) Unwrap() error { return e.Err } type AmbiguousError struct { Kind, Key, Scope string IDs []int64 + // Hint replaces the advice to pass an ID where IDs do not select the resource. + Hint string } func (e *AmbiguousError) Error() string { @@ -131,7 +133,7 @@ func (e *AmbiguousError) Error() string { if e.Scope != "" { text += " in " + e.Scope } - return text + " (IDs " + strings.Join(ids, ", ") + "); pass an ID" + return text + " (IDs " + strings.Join(ids, ", ") + "); " + cmp.Or(e.Hint, "pass an ID") } // refusals maps the messages Gitea gives for refused requests onto conflict reasons. Each entry diff --git a/internal/services/label_selection.go b/internal/services/label_selection.go index e09c4418..2cb8f1ec 100644 --- a/internal/services/label_selection.go +++ b/internal/services/label_selection.go @@ -57,7 +57,7 @@ func (s Labels) CreateUnique(ctx context.Context, existing []Label, opts LabelOp return Label{}, false, &ConflictError{Reason: ReasonExists, Kind: "label", Key: strconv.Quote(current.Name), Scope: s.Scope()} } opts.Name = nil - label, err := s.Edit(ctx, current.ID, opts) + label, err := s.Edit(ctx, current.ID, opts.KeepArchived(current)) return label, true, err } @@ -70,7 +70,7 @@ func selectLabelNames(scope string, names []string, matches map[string][]int64) return nil, NotFound("label", strconv.Quote(name), scope) } if len(candidates) > 1 { - return nil, &AmbiguousError{Kind: "label", Key: strconv.Quote(name), Scope: scope, IDs: candidates} + return nil, &AmbiguousError{Kind: "label", Key: strconv.Quote(name), Scope: scope, IDs: candidates, Hint: "prefix the name with repo: or org: to choose one"} } if !seen[candidates[0]] { ids = append(ids, candidates[0]) diff --git a/internal/services/label_selection_test.go b/internal/services/label_selection_test.go index 9f876e86..8a194210 100644 --- a/internal/services/label_selection_test.go +++ b/internal/services/label_selection_test.go @@ -66,7 +66,7 @@ func TestIssueLabelsCombineScopesWithoutSilentlyChoosingDuplicates(t *testing.T) })) service := Labels{Client: client, Repository: "company/project"} _, err := service.ResolveIssueNames(context.Background(), []string{"bug"}) - require.EqualError(t, err, `label "bug" is ambiguous in company/project (IDs 1, 2); pass an ID`) + require.EqualError(t, err, `label "bug" is ambiguous in company/project (IDs 1, 2); prefix the name with repo: or org: to choose one`) ids, err := service.ResolveIssueNames(context.Background(), []string{"org:bug", "repo:bug", "shared", "shared"}) require.NoError(t, err) require.Equal(t, []int64{2, 1, 3}, ids) diff --git a/internal/services/metadata.go b/internal/services/metadata.go index 177041ce..c4108134 100644 --- a/internal/services/metadata.go +++ b/internal/services/metadata.go @@ -35,6 +35,15 @@ type LabelOptions struct { Archived *bool `json:"is_archived,omitempty"` } +// KeepArchived returns opts with the archived state of current unless opts sets one: Gitea +// unarchives a label whose edit leaves is_archived out. +func (opts LabelOptions) KeepArchived(current Label) LabelOptions { + if opts.Archived == nil { + opts.Archived = ¤t.Archived + } + return opts +} + // Labels implements explicitly scoped label operations. type Labels struct { Client *transport.Client @@ -84,8 +93,14 @@ func (s Labels) Create(ctx context.Context, opts LabelOptions) (Label, error) { if s.Organization != "" && opts.Archived != nil && *opts.Archived { // TODO(sdk): CreateOrgLabelOption.IsArchived var result Label - err := sendJSON(ctx, s.Client, http.MethodPost, s.orgEndpoint(), opts, &result) - return result, err + if err := sendJSON(ctx, s.Client, http.MethodPost, s.orgEndpoint(), opts, &result); err != nil { + return result, err + } + if !result.Archived { + // Gitea ignores is_archived when it creates an organization label. + return s.Edit(ctx, result.ID, LabelOptions{Archived: opts.Archived}) + } + return result, nil } name, color, description := deref(opts.Name), deref(opts.Color), deref(opts.Description) exclusive, archived := deref(opts.Exclusive), deref(opts.Archived) diff --git a/internal/services/metadata_test.go b/internal/services/metadata_test.go index 64224552..597e6c44 100644 --- a/internal/services/metadata_test.go +++ b/internal/services/metadata_test.go @@ -75,3 +75,22 @@ func TestMilestoneRejectsInvalidDeadlineBeforeRequests(t *testing.T) { _, err = service.Edit(context.Background(), 1, MilestoneOptions{Deadline: &invalid}) require.ErrorContains(t, err, "invalid milestone deadline") } + +func TestOrganizationArchivedLabelCreateFollowsUpWithPatch(t *testing.T) { + var requests []string + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + requests = append(requests, r.Method+" "+r.URL.Path+" "+string(body)) + // Gitea ignores is_archived on create and honors it on edit. + archived := "false" + if r.Method == http.MethodPatch { + archived = "true" + } + _, _ = w.Write([]byte(`{"id":5,"name":"bug","is_archived":` + archived + `}`)) + })) + name, archived := "bug", true + label, err := (Labels{Client: client, Organization: "company"}).Create(context.Background(), LabelOptions{Name: &name, Archived: &archived}) + require.NoError(t, err) + require.True(t, label.Archived) + require.Equal(t, []string{`POST /api/v1/orgs/company/labels {"name":"bug","is_archived":true}`, `PATCH /api/v1/orgs/company/labels/5 {"is_archived":true}`}, requests) +} diff --git a/internal/services/milestone_selection.go b/internal/services/milestone_selection.go index d4159d4d..3286dd02 100644 --- a/internal/services/milestone_selection.go +++ b/internal/services/milestone_selection.go @@ -11,8 +11,11 @@ import ( ) // All returns the open and closed milestones of the repository. -func (s Milestones) All(ctx context.Context) ([]Milestone, error) { - return listAll(func(page int) ([]Milestone, error) { return s.List(ctx, "all", 100, page) }) +func (s Milestones) All(ctx context.Context) ([]Milestone, error) { return s.AllIn(ctx, "all") } + +// AllIn returns every milestone in state: open, closed or all. +func (s Milestones) AllIn(ctx context.Context, state string) ([]Milestone, error) { + return listAll(func(page int) ([]Milestone, error) { return s.List(ctx, state, 100, page) }) } // Find returns the milestones that raws address among the open and closed milestones, each by ID @@ -51,10 +54,6 @@ func (s Milestones) FilterIDs(ctx context.Context, values []string) ([]string, e if err != nil { return nil, err } - titles := make(map[string]int, len(items)) - for _, item := range items { - titles[item.Title]++ - } finder := NameOrID[Milestone]{Kind: "milestone", Scope: s.Repository, ID: func(m Milestone) int64 { return m.ID }, Name: func(m Milestone) string { return m.Title }} result := make([]string, len(values)) for i, raw := range values { @@ -62,15 +61,33 @@ func (s Milestones) FilterIDs(ctx context.Context, values []string) ([]string, e if err != nil { return nil, err } - id := strconv.FormatInt(item.ID, 10) - switch { - case titles[item.Title] == 1 && !strings.Contains(item.Title, ","): - result[i] = item.Title - case titles[id] == 0: - result[i] = id - default: - return nil, fmt.Errorf("milestone %q (ID %s) cannot filter issues: another milestone of %s is titled %q", item.Title, id, s.Repository, id) + if result[i], err = s.filterValue(items, item); err != nil { + return nil, err } } return result, nil } + +// FilterValue is the value that filters an issue listing by item, as FilterIDs resolves it. +func (s Milestones) FilterValue(ctx context.Context, item Milestone) (string, error) { + items, err := s.All(ctx) + if err != nil { + return "", err + } + return s.filterValue(items, item) +} + +func (s Milestones) filterValue(items []Milestone, item Milestone) (string, error) { + titles := make(map[string]int, len(items)) + for _, other := range items { + titles[other.Title]++ + } + id := strconv.FormatInt(item.ID, 10) + switch { + case titles[item.Title] == 1 && !strings.Contains(item.Title, ","): + return item.Title, nil + case titles[id] == 0: + return id, nil + } + return "", fmt.Errorf("milestone %q (ID %s) cannot filter issues: another milestone of %s is titled %q", item.Title, id, s.Repository, id) +}