From 01ced23a4a729a7a3eca965a510e41547c1c1f9b Mon Sep 17 00:00:00 2001 From: bircni Date: Mon, 12 Oct 2026 00:12:08 +0200 Subject: [PATCH] fix(issue): name missing issues and attachments in errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tea issue close 999 printed "issue 999: not found (HTTP 404 GET …)" and issue attachment view 1 9 blamed issue #1 for a missing attachment. Batch commands (issue close, reopen, edit, milestone issue add and remove) now report "issue 999: not found in alice/x", and attachment commands "attachment 9 not found in alice/x#1". Signed-off-by: bircni --- internal/app/issue_bulk_edit_test.go | 25 +++++++++++++++++++++++++ internal/app/issue_edit.go | 8 +++++--- internal/app/issues.go | 22 +++++++++++++++++----- internal/app/milestone_issues.go | 8 +++++--- internal/services/issue_attachments.go | 16 ++++++++++++---- 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/internal/app/issue_bulk_edit_test.go b/internal/app/issue_bulk_edit_test.go index dceca910..cb66b9a8 100644 --- a/internal/app/issue_bulk_edit_test.go +++ b/internal/app/issue_bulk_edit_test.go @@ -114,3 +114,28 @@ func TestIssueMutationsReportDoneLines(t *testing.T) { run(true, "issue", "edit", "5", "--title", "Open one") require.Equal(t, "✓ Edited issue alice/project#5 (Open one)\n", diagnostics.String()) } + +func TestMissingItemsAreNamed(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/repos/alice/project" { + _, _ = w.Write([]byte(`{"full_name":"alice/project","has_issues":true,"has_pull_requests":true}`)) + return + } + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + for _, test := range []struct { + args []string + want string + }{ + {[]string{"issue", "close", "999"}, "issue 999: not found in alice/project"}, + {[]string{"issue", "edit", "999", "--title", "x"}, "issue 999: not found in alice/project"}, + {[]string{"issue", "attachment", "view", "1", "9"}, "attachment 9 not found in alice/project#1"}, + {[]string{"issue", "attachment", "delete", "1", "9", "--yes"}, "attachment 9 not found in alice/project#1"}, + } { + err := Execute(context.Background(), deps, append(test.args, "-R", "alice/project")) + require.EqualError(t, Explain(context.Background(), deps, err), test.want, test.args) + } +} diff --git a/internal/app/issue_edit.go b/internal/app/issue_edit.go index 2c456ef4..34eff637 100644 --- a/internal/app/issue_edit.go +++ b/internal/app/issue_edit.go @@ -109,7 +109,8 @@ $ tea issue edit 12 --body-file notes.md`, Annotations: map[string]string{"tea.a opts.Body = &draft.Body } return batchDone(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) { - return service.Edit(cmd.Context(), n, opts) + item, err := service.Edit(cmd.Context(), n, opts) + return item, itemError(err, target.Repo) }, editedIssue(target)) }} flags := cmd.Flags() @@ -147,7 +148,7 @@ func (a *application) editIssuesInteractively(cmd *cobra.Command, target target, return batchDone(a, cmd, numbers, issueLabel, func(n int64) (item services.Issue, err error) { current, err := service.View(cmd.Context(), n) if err != nil { - return item, err + return item, itemError(err, target.Repo) } choices := terminal.IssueEditFormChoices{ Heading: "Editing " + refLabel(target.Repo, n, current.Title), @@ -182,6 +183,7 @@ func (a *application) editIssuesInteractively(cmd *cobra.Command, target target, opts.RemoveDeadline = &values.ClearDeadline } } - return service.Edit(cmd.Context(), n, opts) + item, err = service.Edit(cmd.Context(), n, opts) + return item, itemError(err, target.Repo) }, editedIssue(target)) } diff --git a/internal/app/issues.go b/internal/app/issues.go index 069fbb15..0a6d6567 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "net/http" "net/url" "path/filepath" "strconv" @@ -17,6 +18,7 @@ import ( "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" + "gitea.dev/tea/internal/transport" "github.com/spf13/cobra" ) @@ -429,7 +431,7 @@ $ tea issue %[1]s 12 13 --comment "Done in #14"`, verb), Annotations: map[string results, failures := applyBatch(cmd, numbers, issueLabel, func(n int64) (services.Issue, error) { current, err := service.View(cmd.Context(), n) if err != nil { - return current, err + return current, itemError(err, target.Repo) } if current.State == state || (state == "closed" && current.State == "merged") { a.warn(cmd, "%s %s is already %s", titleCase(discussionNoun(current.Kind)), refLabel(target.Repo, n, current.Title), current.State) @@ -437,14 +439,15 @@ $ tea issue %[1]s 12 13 --comment "Done in #14"`, verb), Annotations: map[string } if comment != "" { if _, err := comments.Create(cmd.Context(), n, comment); err != nil { - return current, err + return current, itemError(err, target.Repo) } } item, err := service.SetState(cmd.Context(), n, state) - if err == nil { - a.done(cmd, "%s %s %s", past, discussionNoun(item.Kind), refLabel(target.Repo, n, item.Title)) + if err != nil { + return item, itemError(err, target.Repo) } - return item, err + a.done(cmd, "%s %s %s", past, discussionNoun(item.Kind), refLabel(target.Repo, n, item.Title)) + return item, nil }, func(services.Issue) {}) var err2 error if a.options.json != "" { @@ -523,6 +526,15 @@ func labelChoices(items []services.Label) []terminal.IssueLabelChoice { func issueLabel(n int64) string { return fmt.Sprintf("issue %d", n) } +// itemError words the failure of one item of a batch, which the batch prefixes with the item, such +// as "issue 9: not found in alice/x". +func itemError(err error, scope string) error { + if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound { + return explain(err, "not found in %s", scope) + } + return services.ClassifyRequest(err) +} + // nameFilter splits a comma-separated filter of kind, trimming entries and rejecting blanks. func nameFilter(kind, raw string) ([]string, error) { if raw == "" { diff --git a/internal/app/milestone_issues.go b/internal/app/milestone_issues.go index 8e27e377..182a055e 100644 --- a/internal/app/milestone_issues.go +++ b/internal/app/milestone_issues.go @@ -59,11 +59,13 @@ $ tea milestone issue %[1]s v1.0 12 13 '#14'`, verb), Annotations: map[string]st return err } service := services.Issues{Client: target.client, Repository: target.Repo} - results, failures := applyBatch(cmd, numbers, issueLabel, func(n int64) (services.Issue, error) { + results, failures := applyBatch(cmd, numbers, issueLabel, func(n int64) (item services.Issue, err error) { if verb == "remove" { - return service.RemoveMilestone(cmd.Context(), n, milestone) + item, err = service.RemoveMilestone(cmd.Context(), n, milestone) + } else { + item, err = service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &milestone.ID}) } - return service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &milestone.ID}) + return item, itemError(err, target.Repo) }, func(services.Issue) {}) if len(results) > 0 { changed := make([]string, len(results)) diff --git a/internal/services/issue_attachments.go b/internal/services/issue_attachments.go index 9b0acdf9..93ac1dfe 100644 --- a/internal/services/issue_attachments.go +++ b/internal/services/issue_attachments.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "strconv" gitea "gitea.dev/sdk" ) @@ -36,9 +37,16 @@ func (s Issues) Attachment(ctx context.Context, number, id int64) (IssueAttachme } func (s Issues) attachment(ctx context.Context, number, id int64) (*gitea.Attachment, error) { - return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Attachment, *gitea.Response, error) { + attachment, 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.GetIssueAttachment(ctx, owner, repo, number, id) }) + return attachment, s.attachmentError(err, number, id) +} + +// attachmentError names attachment id of issue number in a failure; Gitea answers 404 both for a +// missing issue and a missing attachment. +func (s Issues) attachmentError(err error, number, id int64) error { + return Classify(err, "attachment", strconv.FormatInt(id, 10), fmt.Sprintf("%s#%d", s.Repository, number)) } // RenameAttachment changes the filename without replacing its content. @@ -47,16 +55,16 @@ func (s Issues) RenameAttachment(ctx context.Context, number, id int64, name str return c.Issues.EditIssueAttachment(ctx, owner, repo, number, id, gitea.EditAttachmentOptions{Name: name}) }) if err != nil { - return IssueAttachment{}, err + return IssueAttachment{}, s.attachmentError(err, number, id) } return IssueAttachment(assetFrom(attachment)), nil } // DeleteAttachment removes one attachment from its owning issue. func (s Issues) DeleteAttachment(ctx context.Context, number, id int64) error { - return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { + return s.attachmentError(repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) { return c.Issues.DeleteIssueAttachment(ctx, owner, repo, number, id) - }) + }), number, id) } // UploadAttachment streams a file to its owning issue or pull request.