diff --git a/docs/migration-1.0.md b/docs/migration-1.0.md index 23b3ff80..ddb94331 100644 --- a/docs/migration-1.0.md +++ b/docs/migration-1.0.md @@ -242,6 +242,8 @@ Renamed flags that appear on many commands: | `--referenced-version` / `-v` | `--ref` | | `--keyword` / `-k` | `--search` / `-S` | | `--from` / `-F` (issue list) | `--since` | +| `issues list` outside a repository, `--owner` / `--org` | `tea search issue [--owner OWNER]` | +| `issues edit` / `pulls edit --milestone ""` | `--remove-milestone` (or `--milestone 0`) | | `--states` / `-s`, `--types` / `-t` (notifications) | `--status`, `--type` | | `--page` / `-p`, `--limit` / `--lm` | `--page`, `--limit` / `-L` | | `--style` / `-s` (pr merge) | `--merge`, `--squash` or `--rebase`, or `--method` / `-s`; without one, the repository's default merge style | diff --git a/internal/app/alias_completion_test.go b/internal/app/alias_completion_test.go index 9d53d817..da8ce343 100644 --- a/internal/app/alias_completion_test.go +++ b/internal/app/alias_completion_test.go @@ -327,7 +327,7 @@ func TestInstanceCompletions(t *testing.T) { require.Equal(t, "issues", r.URL.Query().Get("type")) _, _ = w.Write([]byte(`[{"number":4,"title":"Crash when config is missing"}]`)) case "/api/v1/repos/alice/project/milestones": - _, _ = w.Write([]byte(`[{"id":1,"title":"v1.0","description":"First release"}]`)) + _, _ = w.Write([]byte(`[{"id":1,"title":"v1.0","description":"First release"},{"id":25,"title":"23"}]`)) case "/api/v1/repos/alice/project/branches": _, _ = w.Write([]byte(`[{"name":"main","protected":true},{"name":"docs/guide"}]`)) case "/api/v1/repos/alice/project/tags": @@ -370,7 +370,7 @@ func TestInstanceCompletions(t *testing.T) { {"teams start with organizations", a.completeTeams(), nil, "", []string{"acme/\tAcme Inc"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp}, {"teams of an organization", a.completeTeams(), nil, "acme/d", []string{"acme/developers\tDevelopers (ID 2)"}, cobra.ShellCompDirectiveNoFileComp}, {"issues", a.completeIssues("open"), nil, "", []string{"4\tCrash when config is missing"}, cobra.ShellCompDirectiveNoFileComp}, - {"milestones", a.completeMilestones(), nil, "", []string{"v1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp}, + {"milestones", a.completeMilestones(), nil, "", []string{"v1.0\tFirst release", "25\t23"}, cobra.ShellCompDirectiveNoFileComp}, {"branches", a.completeBranches(), nil, "", []string{"main\tprotected", "docs/guide"}, cobra.ShellCompDirectiveNoFileComp}, {"tags", a.completeTags(), nil, "v", []string{"v0.1.0"}, cobra.ShellCompDirectiveNoFileComp}, {"releases", a.completeReleases(), nil, "", []string{"v0.1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp}, diff --git a/internal/app/completion.go b/internal/app/completion.go index d592ba8c..499763fc 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -383,7 +383,8 @@ func (a *application) completeLabels() cobra.CompletionFunc { })) } -// completeMilestones completes the titles of open milestones. +// completeMilestones completes the titles of open milestones. A numeric title would be read as +// another milestone's ID, so such a milestone is completed by its own ID instead. func (a *application) completeMilestones() cobra.CompletionFunc { return a.completeMilestonesIn("open") } @@ -392,7 +393,13 @@ func (a *application) completeMilestones() cobra.CompletionFunc { 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, state, completionLimit, 1) - return describe(items, func(item services.Milestone) (string, string) { return item.Title, item.Description }), err + return describe(items, func(item services.Milestone) (string, string) { + // A title that reads as a number would be taken as an ID, so offer the ID instead. + if _, parse := strconv.ParseInt(strings.TrimSpace(item.Title), 10, 64); parse == nil { + return strconv.FormatInt(item.ID, 10), strings.TrimSpace(item.Title + " " + item.Description) + } + return item.Title, item.Description + }), err }) } diff --git a/internal/app/discussion_view.go b/internal/app/discussion_view.go index b5615057..9698d9ac 100644 --- a/internal/app/discussion_view.go +++ b/internal/app/discussion_view.go @@ -95,6 +95,7 @@ func dueText(now time.Time, raw string) string { if due.IsZero() { return raw } + // Keep the offset the server sent: Gitea stamps 23:59:59 in its own zone, and UTC would roll the date over. return due.Format("Jan 2, 2006") + " (" + terminal.RelativeTime(now, due) + ")" } diff --git a/internal/app/discussion_view_test.go b/internal/app/discussion_view_test.go index 0f3caa8f..27af16ca 100644 --- a/internal/app/discussion_view_test.go +++ b/internal/app/discussion_view_test.go @@ -195,3 +195,10 @@ func TestDueTextKeepsTheDayInTheServerOffset(t *testing.T) { require.Equal(t, "Dec 24, 2026 (in about 2 months)", dueText(now, "2026-12-24T23:59:59-05:00")) require.Equal(t, "Dec 24, 2026 (in about 2 months)", dueText(now, "2026-12-24T23:59:59Z")) } + +func TestDueTextKeepsTheServerCalendarDate(t *testing.T) { + now := time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) + require.Contains(t, dueText(now, "2026-12-24T23:59:59Z"), "Dec 24, 2026 (") + require.Contains(t, dueText(now, "2026-12-24T23:59:59-05:00"), "Dec 24, 2026 (") + require.Equal(t, "garbage", dueText(now, "garbage")) +} diff --git a/internal/app/issue_attachments.go b/internal/app/issue_attachments.go index 74609b86..244b4fbc 100644 --- a/internal/app/issue_attachments.go +++ b/internal/app/issue_attachments.go @@ -21,13 +21,19 @@ func attachmentName(t target, n int64, item services.IssueAttachment) string { return fmt.Sprintf("attachment %d (%s) on %s#%d", item.ID, item.Name, t.Repo, n) } -func attachmentRow(item services.IssueAttachment) []terminal.Cell { +// attachmentRow is one row of the attachment list; piped output carries exact byte counts and the URL. +func (a *application) attachmentRow(item services.IssueAttachment) []terminal.Cell { + size := terminal.Bytes(item.Size) + if !a.deps.Streams.OutTTY { + size = strconv.FormatInt(item.Size, 10) + } return []terminal.Cell{ {Text: strconv.FormatInt(item.ID, 10), Link: item.URL}, {Text: item.Name}, - {Text: terminal.Bytes(item.Size)}, + {Text: size}, {Text: strconv.FormatInt(item.DownloadCount, 10)}, {Time: terminal.ParseTime(item.CreatedAt)}, + {Text: item.URL}, } } @@ -39,7 +45,7 @@ func attachmentView(item services.IssueAttachment, d terminal.Display) terminal. {Label: "Downloads", Value: strconv.FormatInt(item.DownloadCount, 10)}, timeField("Created", item.CreatedAt, d), }, - Kind: "attachment", URL: item.URL, + Bodyless: true, Kind: "attachment", URL: item.URL, } } @@ -77,10 +83,10 @@ are addressed by the %s number and the attachment ID that attachment list shows. return err } where := fmt.Sprintf("on %s#%d", t.Repo, n) - return writeTable(a, cmd, items, listSpec[services.IssueAttachment]{ - Columns: []terminal.Column{{Header: "ID"}, {Header: "Name", Flex: true}, {Header: "Size"}, {Header: "Downloads"}, {Header: "Created"}}, - Row: attachmentRow, Empty: "no attachments " + where, Context: "attachments " + where, - }, listPage{Number: page}) + return writeTable(a, cmd, items.Items, listSpec[services.IssueAttachment]{ + Columns: []terminal.Column{{Header: "ID"}, {Header: "Name", Flex: true}, {Header: "Size"}, {Header: "Downloads"}, {Header: "Created"}, {Header: "URL", PipedOnly: true}}, + Row: a.attachmentRow, Empty: "no attachments " + where, Context: "attachments " + where, + }, servicePage(items, page)) }} pageFlags(list, &limit, &page) @@ -167,11 +173,10 @@ $ tea %[1]s attachment download %[2]s 9 -O - | less`, words.group, words.example if err != nil { return err } - item, body, err := (services.Issues{Client: t.client, Repository: t.Repo}).DownloadAttachment(cmd.Context(), n, id) + item, open, err := (services.Issues{Client: t.client, Repository: t.Repo}).DownloadAttachment(cmd.Context(), n, id) if err != nil { return err } - defer body.Close() path, skip, err := destination.destination(item.Name) switch { case err != nil: @@ -180,6 +185,11 @@ $ tea %[1]s attachment download %[2]s 9 -O - | less`, words.group, words.example a.warn(cmd, "Skipped %s: %s exists", attachmentName(t, n, item), path) return nil } + body, err := open() + if err != nil { + return err + } + defer body.Close() if err := a.download(cmd, destination, path, body); err != nil { return err } diff --git a/internal/app/issue_attachments_test.go b/internal/app/issue_attachments_test.go index 86c37c07..6a343966 100644 --- a/internal/app/issue_attachments_test.go +++ b/internal/app/issue_attachments_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "os" + "strings" "testing" "time" @@ -133,3 +134,53 @@ func TestAttachmentTablesDownloadsAndDoneLines(t *testing.T) { require.NoError(t, run(true, "issue", "attachment", "delete", "1", "9", "--yes")) require.Equal(t, "✓ Deleted attachment 9 (crash.log) on alice/project#1\n", diagnostics.String()) } + +func TestAttachmentListViewAndDownloadOrdering(t *testing.T) { + var fetched int + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/repos/alice/project/issues/1/assets": + _, _ = w.Write([]byte(`[{"id":9,"name":"crash.log","size":2048,"browser_download_url":"https://example.org/a/9"},{"id":10,"name":"b.log","size":1}]`)) + case "GET /api/v1/repos/alice/project/issues/1/assets/9": + _, _ = w.Write([]byte(`{"id":9,"name":"crash.log","size":5,"uuid":"abc","browser_download_url":"https://example.org/a/9"}`)) + case "GET /attachments/abc": + fetched++ + _, _ = w.Write([]byte("hello")) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + deps, out, _ := serverDeps(t, server.URL) + deps.Now = func() time.Time { return testNow } + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"}) + plainOutput(t, deps) + run := func(tty bool, args ...string) error { + out.Reset() + deps.Streams.OutTTY = tty + return Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input")) + } + // A limit that cuts the list says so. + require.NoError(t, run(true, "issue", "attachment", "list", "1", "--limit", "1")) + require.Equal(t, "Showing 1 of 2 attachments on alice/project#1 · next: --page 2\n", firstLine(out.String())) + // Piped output keeps exact sizes and the download URL. + require.NoError(t, run(false, "issue", "attachment", "list", "1", "--limit", "1")) + require.Equal(t, "9\tcrash.log\t2048\t0\t\thttps://example.org/a/9\n", out.String()) + // An attachment has no description. + require.NoError(t, run(true, "issue", "attachment", "view", "1", "9")) + require.NotContains(t, out.String(), "No description provided") + // Usage errors and skipped downloads never request the file. + t.Chdir(t.TempDir()) + require.ErrorContains(t, run(true, "issue", "attachment", "download", "1", "9", "--clobber", "--skip-existing"), "cannot be combined") + require.NoError(t, os.WriteFile("crash.log", []byte("old"), 0o600)) + require.NoError(t, run(true, "issue", "attachment", "download", "1", "9", "--skip-existing")) + require.ErrorContains(t, run(true, "issue", "attachment", "download", "1", "9"), "already exists") + require.Zero(t, fetched) + require.NoError(t, run(true, "issue", "attachment", "download", "1", "9", "--clobber")) + require.Equal(t, 1, fetched) +} + +func firstLine(s string) string { + line, _, _ := strings.Cut(s, "\n") + return line + "\n" +} diff --git a/internal/app/issue_bulk_edit_test.go b/internal/app/issue_bulk_edit_test.go index cb66b9a8..9ae5265f 100644 --- a/internal/app/issue_bulk_edit_test.go +++ b/internal/app/issue_bulk_edit_test.go @@ -139,3 +139,18 @@ func TestMissingItemsAreNamed(t *testing.T) { require.EqualError(t, Explain(context.Background(), deps, err), test.want, test.args) } } + +func TestReopeningAMergedPullRequestLeavesNoComment(t *testing.T) { + var writes []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writes = append(writes, r.Method+" "+r.URL.Path) + } + _, _ = w.Write([]byte(`{"number":14,"title":"Merged one","state":"closed","pull_request":{"merged":true}}`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + err := Execute(context.Background(), deps, []string{"issue", "reopen", "14", "-R", "alice/project", "--no-input", "--comment", "again"}) + require.ErrorContains(t, err, "pull request alice/project#14 (Merged one) is already merged; it cannot be reopened") + require.Empty(t, writes) +} diff --git a/internal/app/issues.go b/internal/app/issues.go index c8fe3f9b..32486bb0 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -235,6 +235,14 @@ $ tea issue create --template "Bug report"`, Args: argsExact(0), RunE: func(cmd return err } } + if web { + // The new-issue page is prefilled with the title and body only. + for _, name := range []string{"label", "assignee", "milestone", "deadline", "ref", "attachment", "editor"} { + if cmd.Flags().Changed(name) { + return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be combined with --web; the browser only receives the title and body", name)) + } + } + } if !localFlagsChanged(cmd) && a.canPrompt() && a.deps.IssueForm != nil { return a.createIssueInteractively(cmd) } @@ -421,6 +429,10 @@ $ tea issue %[1]s 12 13 --comment "Done in #14"`, verb), Annotations: map[string a.warn(cmd, "%s %s is already %s", titleCase(discussionNoun(current.Kind)), refLabel(target.Repo, n, current.Title), current.State) return current, nil } + if state == "open" && current.State == "merged" { + // Gitea refuses the state change, so fail before the comment is left behind. + return current, fmt.Errorf("pull request %s is already merged; it cannot be reopened", refLabel(target.Repo, n, current.Title)) + } if comment != "" { if _, err := comments.Create(cmd.Context(), n, comment); err != nil { return current, itemError(err, target.Repo) @@ -458,7 +470,11 @@ func milestoneFlag(cmd *cobra.Command, value string) (selected string, remove bo case remove: return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone and --remove-milestone cannot be combined")) case strings.TrimSpace(value) == "": - return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone needs a title or ID; use --remove-milestone to clear it")) + hint := "omit --milestone for none" + if cmd.Flags().Lookup("remove-milestone") != nil { + hint = "use --remove-milestone to clear it" + } + return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone needs a title or ID; "+hint)) case strings.TrimSpace(value) == "0": return "", true, nil } diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 31e11599..cae3ed72 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -50,7 +50,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"issue", "create", "--no-input"}, "missing required input: supply --title"}, {[]string{"issue", "create", "--title", "safe", "--json=unknown"}, "unknown JSON field \"unknown\""}, {[]string{"issue", "create", "--title", "safe", "--json", "--jq", "["}, "invalid --jq expression"}, - {[]string{"issue", "create", "--milestone="}, "--milestone needs a title or ID; use --remove-milestone to clear it"}, + {[]string{"issue", "create", "--milestone="}, "--milestone needs a title or ID; omit --milestone for none"}, + {[]string{"issue", "edit", "1", "--milestone="}, "--milestone needs a title or ID; use --remove-milestone to clear it"}, + {[]string{"issue", "create", "--web", "--label", "bug"}, "--label cannot be combined with --web"}, + {[]string{"issue", "create", "--web", "--deadline", "2026-12-24"}, "--deadline cannot be combined with --web"}, {[]string{"issue", "create", "--assignee", "alice, "}, "assignee names cannot be empty"}, {[]string{"issue", "create", "--template="}, "--template cannot be empty"}, {[]string{"issue", "create", "--title", "x", "--attachment", "."}, ". is not a regular file"}, diff --git a/internal/services/issue_attachments.go b/internal/services/issue_attachments.go index 93ac1dfe..19a19c1e 100644 --- a/internal/services/issue_attachments.go +++ b/internal/services/issue_attachments.go @@ -13,18 +13,21 @@ import ( ) // Attachments paginates the server's unpaginated attachment collection locally. -func (s Issues) Attachments(ctx context.Context, number int64, limit, page int) ([]IssueAttachment, error) { +func (s Issues) Attachments(ctx context.Context, number int64, limit, page int) (Page[IssueAttachment], error) { attachments, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Attachment, *gitea.Response, error) { return c.Issues.ListIssueAttachments(ctx, owner, repo, number) }) if err != nil { - return nil, err + return Page[IssueAttachment]{}, err } items, err := pageOf(attachments, limit, page) if err != nil { - return nil, err + return Page[IssueAttachment]{}, err } - return convertAll(items, func(item *gitea.Attachment) IssueAttachment { return IssueAttachment(assetFrom(item)) }), nil + return Page[IssueAttachment]{ + Items: convertAll(items, func(item *gitea.Attachment) IssueAttachment { return IssueAttachment(assetFrom(item)) }), + More: len(attachments) > page*limit, Total: len(attachments), Counted: true, + }, nil } // Attachment retrieves metadata for one attachment in its owning issue. @@ -73,13 +76,15 @@ func (s Issues) UploadAttachment(ctx context.Context, number int64, name string, return IssueAttachment(asset), err } -// DownloadAttachment opens an attachment stream scoped to its instance and issue, and returns the -// attachment's metadata. -func (s Issues) DownloadAttachment(ctx context.Context, number, id int64) (IssueAttachment, io.ReadCloser, error) { +// DownloadAttachment retrieves an attachment's metadata and returns a function that opens its +// content, scoped to its instance and issue. Nothing is downloaded until that function is called, +// so callers can refuse or skip the download first. +func (s Issues) DownloadAttachment(ctx context.Context, number, id int64) (IssueAttachment, func() (io.ReadCloser, error), error) { attachment, err := s.attachment(ctx, number, id) if err != nil { return IssueAttachment{}, nil, err } - body, err := downloadAttachment(ctx, s.Client, attachment) - return IssueAttachment(assetFrom(attachment)), body, err + return IssueAttachment(assetFrom(attachment)), func() (io.ReadCloser, error) { + return downloadAttachment(ctx, s.Client, attachment) + }, nil } diff --git a/internal/services/issue_labels_test.go b/internal/services/issue_labels_test.go index dad51340..849823bf 100644 --- a/internal/services/issue_labels_test.go +++ b/internal/services/issue_labels_test.go @@ -106,3 +106,31 @@ func assertIssueEdit(t *testing.T, want gitea.EditIssueOption, body []byte) { assert.Equal(t, want, got) } } + +func TestEditAddLabelReplacesTheExclusiveLabelOfTheSameScope(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/repos/alice/project/issues/7": + _, _ = w.Write([]byte(`{"number":7,"labels":[{"id":1,"name":"bug"},{"id":2,"name":"Priority/Low","exclusive":true}]}`)) + case "GET /api/v1/repos/alice/project/labels": + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`[{"id":1,"name":"bug"},{"id":2,"name":"Priority/Low","exclusive":true},{"id":3,"name":"Priority/High","exclusive":true},{"id":4,"name":"Kind/x","exclusive":true}]`)) + case "GET /api/v1/orgs/alice": + w.WriteHeader(http.StatusNotFound) + case "PUT /api/v1/repos/alice/project/issues/7/labels": + assert.JSONEq(t, `{"labels":[1,3]}`, string(body)) + _, _ = w.Write([]byte(`[]`)) + case "GET /api/v1/repos/alice/project/pulls/7": + _, _ = w.Write([]byte(`{"number":7}`)) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + _, err := (Issues{Client: client, Repository: "alice/project"}).Edit(context.Background(), 7, IssueEdit{Changes: &MetadataChanges{AddLabels: []int64{3}}}) + require.NoError(t, err) +} diff --git a/internal/services/issues.go b/internal/services/issues.go index 26569428..d0cd09cd 100644 --- a/internal/services/issues.go +++ b/internal/services/issues.go @@ -164,7 +164,8 @@ func (s Issues) Create(ctx context.Context, options IssueOptions) (Issue, error) Deadline: options.Deadline, Milestone: options.Milestone, Labels: options.Labels, }) }) - return item, Classify(err, "issue", "", s.Repository) + // A 404 here can only mean the repository is missing or invisible. + return item, Classify(err, "repository", s.Repository, "") } // SetState updates only the selected issue state. diff --git a/internal/services/issues_test.go b/internal/services/issues_test.go index a4d907cd..b2ccbd46 100644 --- a/internal/services/issues_test.go +++ b/internal/services/issues_test.go @@ -107,3 +107,12 @@ func TestIssueTemplatesAcceptCheckboxOptionObjects(t *testing.T) { var sdkTemplates []*gitea.IssueTemplate require.Error(t, json.Unmarshal([]byte(body), &sdkTemplates), "remove the raw request once the SDK decodes checkbox options") } + +func TestIssueCreateInMissingRepositoryReportsTheRepository(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + _, err := (Issues{Client: client, Repository: "alice/projetc"}).Create(context.Background(), IssueOptions{Title: "x"}) + require.EqualError(t, err, "repository alice/projetc not found") +} diff --git a/internal/services/metadata.go b/internal/services/metadata.go index c4108134..e043dfc6 100644 --- a/internal/services/metadata.go +++ b/internal/services/metadata.go @@ -388,8 +388,12 @@ func (s Issues) applyChanges(ctx context.Context, n int64, opts IssueEdit) (Issu return opts, err } if len(changes.AddLabels)+len(changes.RemoveLabels) > 0 { - ids := make([]int64, len(current.Labels)) - for i, label := range current.Labels { + kept, err := s.withoutReplacedExclusive(ctx, current.Labels, changes.AddLabels) + if err != nil { + return opts, err + } + ids := make([]int64, len(kept)) + for i, label := range kept { ids[i] = label.ID } ids = mergeUnique(ids, changes.AddLabels, changes.RemoveLabels, func(a, b int64) bool { return a == b }) @@ -402,6 +406,44 @@ func (s Issues) applyChanges(ctx context.Context, n int64, opts IssueEdit) (Issu return opts, nil } +// withoutReplacedExclusive drops the current labels that an added label of the same exclusive +// scope replaces. Gitea keeps one label per scope but picks it by name, not by recency, so +// sending both would keep whichever sorts last. +func (s Issues) withoutReplacedExclusive(ctx context.Context, current []Label, add []int64) ([]Label, error) { + if len(add) == 0 || !slices.ContainsFunc(current, func(l Label) bool { return l.Exclusive }) { + return current, nil + } + all, err := (Labels{Client: s.Client, Repository: s.Repository}).All(ctx) + if err != nil { + return nil, err + } + owner, _, _ := strings.Cut(s.Repository, "/") + if _, isOrg, err := organization(ctx, s.Client, owner); err != nil { + return nil, err + } else if isOrg { + shared, err := (Labels{Client: s.Client, Organization: owner}).All(ctx) + if err != nil { + return nil, err + } + all = append(all, shared...) + } + replaced := map[string]bool{} + for _, label := range all { + if scope := exclusiveScope(label); scope != "" && slices.Contains(add, label.ID) { + replaced[scope] = true + } + } + return slices.DeleteFunc(slices.Clone(current), func(l Label) bool { return replaced[exclusiveScope(l)] }), nil +} + +// exclusiveScope is the name of an exclusive label up to its last slash, or "" for any other label. +func exclusiveScope(label Label) string { + if i := strings.LastIndex(label.Name, "/"); label.Exclusive && i > 0 { + return label.Name[:i] + } + return "" +} + // mergeUnique applies additions and removals to current values, preserving order. func mergeUnique[T any](current, add, remove []T, equal func(T, T) bool) []T { result := []T{} diff --git a/main.go b/main.go index 439e9c19..41adcf15 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,7 @@ func run(ctx context.Context, deps app.Dependencies, args []string) int { fmt.Fprintln(deps.Streams.ErrOut, "Canceled.") reportDraft(deps, err) default: + // Errors quote names the server supplied, which may carry terminal escapes. fmt.Fprintf(deps.Streams.ErrOut, "Error: %s\n", terminal.SafeText(app.Explain(ctx, deps, err).Error())) } return code