From c97044f76cb5f9cacbd05da7e1f9ad46767b0770 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 18 Oct 2026 19:01:19 +0200 Subject: [PATCH] fix(release): report an unknown target clearly, hint to push, and add --verify-tag (follow gh) Signed-off-by: bircni --- internal/app/releases.go | 68 +++++++++++++++++++++++++++++- internal/app/releases_test.go | 30 +++++++++++++ internal/services/releases.go | 18 ++++++++ internal/services/releases_test.go | 27 ++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) diff --git a/internal/app/releases.go b/internal/app/releases.go index 00dd02a5..97a1026a 100644 --- a/internal/app/releases.go +++ b/internal/app/releases.go @@ -5,6 +5,7 @@ package app import ( "cmp" + "context" "errors" "fmt" "path/filepath" @@ -220,12 +221,16 @@ func (f *releaseFields) options(a *application, cmd *cobra.Command) (services.Re func (a *application) releaseCreateCommand() *cobra.Command { var fields releaseFields var assets []string + var verifyTag bool command := &cobra.Command{ Use: "create [] [...]", Aliases: []string{"new"}, Short: "Create a release", Long: `Create a release for a tag, and upload the files that follow the tag as its assets. Gitea creates -the tag at --target (the default branch) when it does not exist yet. The title defaults to the tag.`, +the tag at --target (the default branch) when it does not exist yet. The title defaults to the tag. + +tea never pushes: commits and tags that only exist in your clone are not on the server, so push them +first (git push ), or pass --verify-tag to fail when the tag is not on the server yet.`, Example: `# Create a release with notes from a file and two assets $ tea release create v1.0.0 dist/tea-linux dist/tea-darwin --notes-file CHANGELOG.md @@ -267,6 +272,9 @@ $ tea release create v1.1.0-rc1 --target release/1.1 --draft --prerelease --titl return err } service := services.Releases{Client: target.client, Repository: target.Repo} + if err := a.checkReleaseTag(cmd, target, service, fields.tag, opts.Target != nil, verifyTag); err != nil { + return err + } if fields.editor { if editorDraft, err = a.editDraft(cmd, ""); err != nil { return err @@ -279,6 +287,11 @@ $ tea release create v1.1.0-rc1 --target release/1.1 --draft --prerelease --titl } } item, err := service.Create(cmd.Context(), opts) + if notFound, ok := errors.AsType[*services.NotFoundError](err); ok && notFound.Kind == "target" { + if hint := a.pushHint(cmd.Context(), target, *opts.Target); hint != "" { + err = explain(err, "%s; %s", err, hint) + } + } if err != nil { return err } @@ -305,10 +318,63 @@ $ tea release create v1.1.0-rc1 --target release/1.1 --draft --prerelease --titl command.Flags().StringVar(&fields.tag, "tag", "", "Release tag") _ = command.Flags().MarkHidden("tag") command.Flags().StringSliceVar(&assets, "asset", nil, "File to upload as an asset (repeatable)") + command.Flags().BoolVar(&verifyTag, "verify-tag", false, "Fail if the tag does not exist on the server yet") command.ValidArgsFunction = pathArgs(1, true, completeFiles, a.completeTags()) return command } +// checkReleaseTag guards against a release whose tag Gitea would create from the default branch +// instead of from the local commit the tag names: --verify-tag refuses a tag the server lacks, and +// without it a tag that only exists locally is warned about. +func (a *application) checkReleaseTag(cmd *cobra.Command, t target, service services.Releases, tag string, hasTarget, verify bool) error { + local := a.localRefExists(cmd.Context(), "refs/tags/"+tag) + if !verify && (!local || hasTarget) { + return nil + } + exists, err := service.TagExists(cmd.Context(), tag) + if err != nil || exists { + return err + } + hint := "" + if local { + hint = "push it first: git push " + a.releaseRemote(t) + " " + tag + } + if verify { + missing := services.NotFound("tag", tag, t.Repo) + return explain(missing, "%s; %s", missing, cmp.Or(hint, "create it first or drop --verify-tag")) + } + a.warn(cmd, "Tag %s exists in your clone but not in %s; the release creates it at the default branch, not at your local commit; %s", tag, t.Repo, hint) + return nil +} + +// pushHint tells how to publish a ref that only the local clone has, or returns "". +func (a *application) pushHint(ctx context.Context, t target, ref string) string { + remote := a.releaseRemote(t) + for _, prefix := range []string{"refs/tags/", "refs/heads/"} { + if a.localRefExists(ctx, prefix+ref) { + return "push it first: git push " + remote + " " + ref + } + } + if ref != "" && a.localRefExists(ctx, ref+"^{commit}") { + return "push the branch that has this commit first: git push " + remote + } + return "" +} + +// localRefExists reports whether the working directory's checkout resolves ref. +func (a *application) localRefExists(ctx context.Context, ref string) bool { + _, err := a.gitProbe().Output(ctx, a.options.cwd, false, "rev-parse", "--verify", "--quiet", ref) + return err == nil +} + +// releaseRemote is the remote of the target repository, else origin. +func (a *application) releaseRemote(t target) string { + if remote, ok := strings.CutPrefix(t.RepoSource, "remote "); ok { + return remote + } + return cmp.Or(a.options.remote, "origin") +} + // rejectSwitchValue refuses the old tea syntax "--draft false": a switch reads no value, so the // word would be edited as one more release after the switch was applied as true. func rejectSwitchValue(cmd *cobra.Command, args []string, switches ...string) error { diff --git a/internal/app/releases_test.go b/internal/app/releases_test.go index 2f38b04e..03f1ea93 100644 --- a/internal/app/releases_test.go +++ b/internal/app/releases_test.go @@ -104,3 +104,33 @@ func TestReleaseCreateUploadsPositionalFiles(t *testing.T) { require.Equal(t, "https://gitea.test/alice/project/releases/tag/v1.1.0\n", out.String()) require.Equal(t, "āœ“ Created draft release v1.1.0 in alice/project\nāœ“ Uploaded tea.bin (2 B)\n", diagnostics.String()) } + +func TestReleaseCreateWithUnknownTargetNamesTheTargetAndHintsAtPushing(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"target not found"}`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + deps.Git = &fakeGit{results: map[string]string{"rev-parse --verify --quiet refs/tags/v2": "abc"}} + err := Execute(context.Background(), deps, []string{"release", "create", "v1", "--target", "v2", "-R", "alice/project"}) + require.EqualError(t, Explain(context.Background(), deps, err), `target "v2" not found in alice/project; push it first: git push origin v2`) + deps.Git = &fakeGit{} + err = Execute(context.Background(), deps, []string{"release", "create", "v1", "--target", "nowhere", "-R", "alice/project"}) + require.EqualError(t, Explain(context.Background(), deps, err), `target "nowhere" not found in alice/project`) +} + +func TestReleaseCreateVerifyTagRefusesATagTheServerLacks(t *testing.T) { + var requests []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.Path) + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"tag does not exist"}`)) + })) + defer server.Close() + deps, _, _ := serverDeps(t, server.URL) + deps.Git = &fakeGit{results: map[string]string{"rev-parse --verify --quiet refs/tags/v1": "abc"}} + err := Execute(context.Background(), deps, []string{"release", "create", "v1", "--verify-tag", "-R", "alice/project"}) + require.EqualError(t, err, "tag v1 not found in alice/project; push it first: git push origin v1") + require.Equal(t, []string{"GET /api/v1/repos/alice/project/tags/v1"}, requests) +} diff --git a/internal/services/releases.go b/internal/services/releases.go index ef828db3..ff2aa1fe 100644 --- a/internal/services/releases.go +++ b/internal/services/releases.go @@ -5,9 +5,12 @@ package services import ( "context" + "errors" "fmt" "io" "net/http" + "strconv" + "strings" gitea "gitea.dev/sdk" "gitea.dev/tea/internal/transport" @@ -103,9 +106,24 @@ func (s Releases) Create(ctx context.Context, opts ReleaseOptions) (Release, err IsDraft: deref(opts.Draft), IsPrerelease: deref(opts.Prerelease), }) }) + if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == http.StatusNotFound && strings.Contains(apiErr.Message, "target") { + // The release does not exist yet, so a 404 here names the unresolvable target, not the release. + return item, &NotFoundError{Kind: "target", Key: strconv.Quote(deref(opts.Target)), Scope: s.Repository, Err: err} + } return item, Classify(err, "release", deref(opts.Tag), s.Repository) } +// TagExists reports whether the repository on the server has the tag. +func (s Releases) TagExists(ctx context.Context, tag string) (bool, error) { + _, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Tag, *gitea.Response, error) { + return c.Repositories.GetTag(ctx, owner, repo, tag) + }) + if IsNotFound(err) { + return false, nil + } + return err == nil, err +} + // Edit changes only supplied release fields; Gitea ignores empty text fields. func (s Releases) Edit(ctx context.Context, id int64, opts ReleaseOptions) (Release, error) { return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) { diff --git a/internal/services/releases_test.go b/internal/services/releases_test.go index 4a3bb950..a61ddaf6 100644 --- a/internal/services/releases_test.go +++ b/internal/services/releases_test.go @@ -234,3 +234,30 @@ func TestReleaseAssetRenameAndDelete(t *testing.T) { require.Equal(t, ReleaseAsset{ID: 2, Name: "renamed.bin"}, asset) require.NoError(t, service.DeleteAsset(context.Background(), 1, 2)) } + +func TestReleaseCreateReportsAnUnresolvableTargetNotAMissingRelease(t *testing.T) { + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"target not found"}`)) + })) + target, tag := "deadbeef", "v1" + _, err := Releases{Client: client, Repository: "alice/project"}.Create(context.Background(), ReleaseOptions{Tag: &tag, Title: &tag, Target: &target}) + var notFound *NotFoundError + require.ErrorAs(t, err, ¬Found) + require.EqualError(t, err, `target "deadbeef" not found in alice/project`) +} + +func TestReleaseTagExistsDistinguishesMissingFromFailing(t *testing.T) { + status := http.StatusNotFound + client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"message":"x"}`)) + })) + service := Releases{Client: client, Repository: "alice/project"} + exists, err := service.TagExists(context.Background(), "v1") + require.NoError(t, err) + require.False(t, exists) + status = http.StatusInternalServerError + _, err = service.TagExists(context.Background(), "v1") + require.Error(t, err) +}