fix: address verified review findings (labels-milestones-time-1)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 17:40:59 +02:00
1 parent be3b8a1ced
commit e28bb6e039
21 files changed
+262 -47

No files matched your search

+22
View File
@@ -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)
}
+6 -1
View File
@@ -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
})
}
+4
View File
@@ -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
})
+19
View File
@@ -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")
}
+27 -1
View File
@@ -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`)
}
}
+16
View File
@@ -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())
}
+13 -7
View File
@@ -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
+11 -5
View File
@@ -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
}
+7 -4
View File
@@ -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)
+21 -1
View File
@@ -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())
}
+2 -1
View File
@@ -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
}
+14 -4
View File
@@ -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 {<number> | <url>}", 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 {<number> | <url>}", 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)
})
+23 -2
View File
@@ -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())
}
+1 -1
View File
@@ -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"},
+3 -1
View File
@@ -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
+2 -2
View File
@@ -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])
+1 -1
View File
@@ -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)
+17 -2
View File
@@ -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 = &current.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)
+19
View File
@@ -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)
}
+31 -14
View File
@@ -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)
}