fix(issue): name missing issues and attachments in errors

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 <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:17:08 +02:00
1 parent 56c6ffc80b
commit 01ced23a4a
5 files changed
+64 -15

No files matched your search

+25
View File
@@ -114,3 +114,28 @@ func TestIssueMutationsReportDoneLines(t *testing.T) {
run(true, "issue", "edit", "5", "--title", "Open one") run(true, "issue", "edit", "5", "--title", "Open one")
require.Equal(t, "✓ Edited issue alice/project#5 (Open one)\n", diagnostics.String()) 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)
}
}
+5 -3
View File
@@ -109,7 +109,8 @@ $ tea issue edit 12 --body-file notes.md`, Annotations: map[string]string{"tea.a
opts.Body = &draft.Body opts.Body = &draft.Body
} }
return batchDone(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) { 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)) }, editedIssue(target))
}} }}
flags := cmd.Flags() 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) { return batchDone(a, cmd, numbers, issueLabel, func(n int64) (item services.Issue, err error) {
current, err := service.View(cmd.Context(), n) current, err := service.View(cmd.Context(), n)
if err != nil { if err != nil {
return item, err return item, itemError(err, target.Repo)
} }
choices := terminal.IssueEditFormChoices{ choices := terminal.IssueEditFormChoices{
Heading: "Editing " + refLabel(target.Repo, n, current.Title), 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 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)) }, editedIssue(target))
} }
+17 -5
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -17,6 +18,7 @@ import (
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"github.com/spf13/cobra" "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) { results, failures := applyBatch(cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
current, err := service.View(cmd.Context(), n) current, err := service.View(cmd.Context(), n)
if err != nil { if err != nil {
return current, err return current, itemError(err, target.Repo)
} }
if current.State == state || (state == "closed" && current.State == "merged") { 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) 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 comment != "" {
if _, err := comments.Create(cmd.Context(), n, comment); err != nil { 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) item, err := service.SetState(cmd.Context(), n, state)
if err == nil { if err != nil {
a.done(cmd, "%s %s %s", past, discussionNoun(item.Kind), refLabel(target.Repo, n, item.Title)) 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) {}) }, func(services.Issue) {})
var err2 error var err2 error
if a.options.json != "" { 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) } 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. // nameFilter splits a comma-separated filter of kind, trimming entries and rejecting blanks.
func nameFilter(kind, raw string) ([]string, error) { func nameFilter(kind, raw string) ([]string, error) {
if raw == "" { if raw == "" {
+5 -3
View File
@@ -59,11 +59,13 @@ $ tea milestone issue %[1]s v1.0 12 13 '#14'`, verb), Annotations: map[string]st
return err return err
} }
service := services.Issues{Client: target.client, Repository: target.Repo} 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" { 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) {}) }, func(services.Issue) {})
if len(results) > 0 { if len(results) > 0 {
changed := make([]string, len(results)) changed := make([]string, len(results))
+12 -4
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"strconv"
gitea "gitea.dev/sdk" 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) { 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 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. // 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}) return c.Issues.EditIssueAttachment(ctx, owner, repo, number, id, gitea.EditAttachmentOptions{Name: name})
}) })
if err != nil { if err != nil {
return IssueAttachment{}, err return IssueAttachment{}, s.attachmentError(err, number, id)
} }
return IssueAttachment(assetFrom(attachment)), nil return IssueAttachment(assetFrom(attachment)), nil
} }
// DeleteAttachment removes one attachment from its owning issue. // DeleteAttachment removes one attachment from its owning issue.
func (s Issues) DeleteAttachment(ctx context.Context, number, id int64) error { 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) return c.Issues.DeleteIssueAttachment(ctx, owner, repo, number, id)
}) }), number, id)
} }
// UploadAttachment streams a file to its owning issue or pull request. // UploadAttachment streams a file to its owning issue or pull request.