mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
fix: address verified review findings (labels-milestones-time-1)
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
21 files changed
+261
-46
No files matched your search
@@ -210,6 +210,7 @@ Subcommands:
|
||||
| `labels update --id ID` | `label edit <name-or-id>` |
|
||||
| `labels delete --id ID` | `label delete <name-or-id>...` |
|
||||
| `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 <name>` | `milestone issue list <milestone>` (title or ID) |
|
||||
| `milestones issues add\|remove` | `milestone issue add\|remove <milestone> <number>...` |
|
||||
| `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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,9 +93,15 @@ 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)
|
||||
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)
|
||||
label, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Label, *gitea.Response, error) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user