mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
fix(release): report an unknown target clearly, hint to push, and add --verify-tag (follow gh)
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
4 files changed
+142
-1
No files matched your search
@@ -5,6 +5,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -220,12 +221,16 @@ func (f *releaseFields) options(a *application, cmd *cobra.Command) (services.Re
|
|||||||
func (a *application) releaseCreateCommand() *cobra.Command {
|
func (a *application) releaseCreateCommand() *cobra.Command {
|
||||||
var fields releaseFields
|
var fields releaseFields
|
||||||
var assets []string
|
var assets []string
|
||||||
|
var verifyTag bool
|
||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "create [<tag>] [<file>...]",
|
Use: "create [<tag>] [<file>...]",
|
||||||
Aliases: []string{"new"},
|
Aliases: []string{"new"},
|
||||||
Short: "Create a release",
|
Short: "Create a release",
|
||||||
Long: `Create a release for a tag, and upload the files that follow the tag as its assets. Gitea creates
|
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 <remote> <tag>), 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
|
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
|
$ 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
|
return err
|
||||||
}
|
}
|
||||||
service := services.Releases{Client: target.client, Repository: target.Repo}
|
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 fields.editor {
|
||||||
if editorDraft, err = a.editDraft(cmd, ""); err != nil {
|
if editorDraft, err = a.editDraft(cmd, ""); err != nil {
|
||||||
return err
|
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)
|
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 {
|
if err != nil {
|
||||||
return err
|
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().StringVar(&fields.tag, "tag", "", "Release tag")
|
||||||
_ = command.Flags().MarkHidden("tag")
|
_ = command.Flags().MarkHidden("tag")
|
||||||
command.Flags().StringSliceVar(&assets, "asset", nil, "File to upload as an asset (repeatable)")
|
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())
|
command.ValidArgsFunction = pathArgs(1, true, completeFiles, a.completeTags())
|
||||||
return command
|
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
|
// 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.
|
// 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 {
|
func rejectSwitchValue(cmd *cobra.Command, args []string, switches ...string) error {
|
||||||
|
|||||||
@@ -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, "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())
|
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)
|
||||||
|
}
|
||||||
@@ -5,9 +5,12 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
gitea "gitea.dev/sdk"
|
gitea "gitea.dev/sdk"
|
||||||
"gitea.dev/tea/internal/transport"
|
"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),
|
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)
|
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.
|
// Edit changes only supplied release fields; Gitea ignores empty text fields.
|
||||||
func (s Releases) Edit(ctx context.Context, id int64, opts ReleaseOptions) (Release, error) {
|
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) {
|
return repoConvert(ctx, s.Client, s.Repository, releaseFrom, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Release, *gitea.Response, error) {
|
||||||
|
|||||||
@@ -234,3 +234,30 @@ func TestReleaseAssetRenameAndDelete(t *testing.T) {
|
|||||||
require.Equal(t, ReleaseAsset{ID: 2, Name: "renamed.bin"}, asset)
|
require.Equal(t, ReleaseAsset{ID: 2, Name: "renamed.bin"}, asset)
|
||||||
require.NoError(t, service.DeleteAsset(context.Background(), 1, 2))
|
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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user