diff --git a/gitea/releases.go b/gitea/releases.go
index 167ed15..7db5b36 100644
--- a/gitea/releases.go
+++ b/gitea/releases.go
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"code.gitea.io/sdk/gitea"
)
@@ -24,6 +25,7 @@ func (f *giteaForge) Releases() forge.ReleaseService {
func convertGiteaRelease(r *gitea.Release) forge.Release {
result := forge.Release{
+ ID: r.ID,
TagName: r.TagName,
Title: r.Title,
Body: r.Note,
@@ -88,10 +90,31 @@ func (s *giteaReleaseService) List(ctx context.Context, owner, repo string, opts
return all, nil
}
-func (s *giteaReleaseService) Get(ctx context.Context, owner, repo, tag string) (*forge.Release, error) {
+// getReleaseByTagOrID resolves a release by tag, falling back to a numeric
+// release ID when the tag lookup returns 404.
+func (s *giteaReleaseService) getReleaseByTagOrID(owner, repo, tag, op string) (*gitea.Release, error) {
r, resp, err := s.client.GetReleaseByTag(owner, repo, tag)
+ if err == nil {
+ return r, nil
+ }
+ if resp == nil || resp.StatusCode != http.StatusNotFound {
+ return nil, wrapErr(op, resp, err)
+ }
+ id, parseErr := strconv.ParseInt(tag, 10, 64)
+ if parseErr != nil {
+ return nil, forge.ErrNotFound
+ }
+ r, resp, err = s.client.GetRelease(owner, repo, id)
+ if err != nil {
+ return nil, wrapErr(op, resp, err)
+ }
+ return r, nil
+}
+
+func (s *giteaReleaseService) Get(ctx context.Context, owner, repo, tag string) (*forge.Release, error) {
+ r, err := s.getReleaseByTagOrID(owner, repo, tag, "get release")
if err != nil {
- return nil, wrapErr("get release", resp, err)
+ return nil, err
}
result := convertGiteaRelease(r)
return &result, nil
@@ -142,9 +165,9 @@ func (s *giteaReleaseService) Create(ctx context.Context, owner, repo string, op
}
func (s *giteaReleaseService) Update(ctx context.Context, owner, repo, tag string, opts forge.UpdateReleaseOpts) (*forge.Release, error) {
- existing, resp, err := s.client.GetReleaseByTag(owner, repo, tag)
+ existing, err := s.getReleaseByTagOrID(owner, repo, tag, "update release")
if err != nil {
- return nil, wrapErr("update release", resp, err)
+ return nil, err
}
gOpts := gitea.EditReleaseOption{}
@@ -189,10 +212,11 @@ func (s *giteaReleaseService) Update(ctx context.Context, owner, repo, tag strin
}
func (s *giteaReleaseService) Delete(ctx context.Context, owner, repo, tag string) error {
- existing, resp, err := s.client.GetReleaseByTag(owner, repo, tag)
+ existing, err := s.getReleaseByTagOrID(owner, repo, tag, "delete release")
if err != nil {
- return wrapErr("delete release", resp, err)
+ return err
}
+ var resp *gitea.Response
resp, err = s.client.DeleteRelease(owner, repo, existing.ID)
if err != nil {
@@ -202,10 +226,11 @@ func (s *giteaReleaseService) Delete(ctx context.Context, owner, repo, tag strin
}
func (s *giteaReleaseService) UploadAsset(ctx context.Context, owner, repo, tag string, file *os.File) (*forge.ReleaseAsset, error) {
- existing, resp, err := s.client.GetReleaseByTag(owner, repo, tag)
+ existing, err := s.getReleaseByTagOrID(owner, repo, tag, "upload release asset")
if err != nil {
- return nil, wrapErr("upload release asset", resp, err)
+ return nil, err
}
+ var resp *gitea.Response
name := filepath.Base(file.Name())
a, resp, err := s.client.CreateReleaseAttachment(owner, repo, existing.ID, file, name)
diff --git a/gitea/releases_test.go b/gitea/releases_test.go
index 9cb35f2..b4b4d1e 100644
--- a/gitea/releases_test.go
+++ b/gitea/releases_test.go
@@ -6,8 +6,12 @@ import (
forge "github.com/git-pkgs/forge"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
+
+ "code.gitea.io/sdk/gitea"
)
func TestCreateReleaseAlreadyExists(t *testing.T) {
@@ -124,3 +128,181 @@ func TestCreateReleaseNotFound(t *testing.T) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
+
+func TestConvertGiteaReleasePopulatesID(t *testing.T) {
+ rel := convertGiteaRelease(&gitea.Release{
+ ID: 777,
+ TagName: "v7.7.7",
+ Title: "Release 7.7.7",
+ IsDraft: true,
+ })
+ if rel.ID != 777 {
+ t.Errorf("ID: want 777, got %d", rel.ID)
+ }
+ if rel.TagName != "v7.7.7" {
+ t.Errorf("TagName: want %q, got %q", "v7.7.7", rel.TagName)
+ }
+}
+
+func TestGiteaReleaseGetByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/version", giteaVersionHandler)
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message":"release not found"}`))
+ })
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":9,"tag_name":"v0.9.0-draft","name":"Draft 9","draft":true}`))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ f := New(srv.URL, "", nil)
+ rel, err := f.Releases().Get(context.Background(), "o", "r", "9")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rel.ID != 9 {
+ t.Errorf("ID: want 9, got %d", rel.ID)
+ }
+ if rel.TagName != "v0.9.0-draft" {
+ t.Errorf("TagName: want %q, got %q", "v0.9.0-draft", rel.TagName)
+ }
+ if !rel.Draft {
+ t.Errorf("Draft: want true, got false")
+ }
+}
+
+func TestGiteaReleaseUpdateByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/version", giteaVersionHandler)
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message":"release not found"}`))
+ })
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":9,"tag_name":"v0.9.0-draft","name":"Draft 9","draft":true}`))
+ })
+ mux.HandleFunc("PATCH /api/v1/repos/o/r/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":9,"tag_name":"v0.9.0-draft","name":"Draft 9 renamed","draft":true}`))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ f := New(srv.URL, "", nil)
+ title := "Draft 9 renamed"
+ rel, err := f.Releases().Update(context.Background(), "o", "r", "9", forge.UpdateReleaseOpts{
+ Title: &title,
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if rel.ID != 9 {
+ t.Errorf("ID: want 9, got %d", rel.ID)
+ }
+ if rel.Title != "Draft 9 renamed" {
+ t.Errorf("Title: want %q, got %q", "Draft 9 renamed", rel.Title)
+ }
+}
+
+func TestGiteaReleaseDeleteByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/version", giteaVersionHandler)
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message":"release not found"}`))
+ })
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":9,"tag_name":"v0.9.0-draft","draft":true}`))
+ })
+ mux.HandleFunc("DELETE /api/v1/repos/o/r/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ f := New(srv.URL, "", nil)
+ if err := f.Releases().Delete(context.Background(), "o", "r", "9"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestGiteaReleaseUploadAssetByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/version", giteaVersionHandler)
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message":"release not found"}`))
+ })
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":9,"tag_name":"v0.9.0-draft","draft":true}`))
+ })
+ mux.HandleFunc("POST /api/v1/repos/o/r/releases/9/assets", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"id":100,"name":"notes.txt","size":11,"browser_download_url":"https://example.com/o/r/releases/download/9/notes.txt"}`))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ tmp := filepath.Join(t.TempDir(), "notes.txt")
+ if err := os.WriteFile(tmp, []byte("hello world"), 0o600); err != nil {
+ t.Fatalf("writing temp file: %v", err)
+ }
+ file, err := os.Open(tmp)
+ if err != nil {
+ t.Fatalf("opening temp file: %v", err)
+ }
+ defer func() { _ = file.Close() }()
+
+ f := New(srv.URL, "", nil)
+ asset, err := f.Releases().UploadAsset(context.Background(), "o", "r", "9", file)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if asset.Name != "notes.txt" {
+ t.Errorf("Name: want %q, got %q", "notes.txt", asset.Name)
+ }
+ if asset.Size != 11 {
+ t.Errorf("Size: want 11, got %d", asset.Size)
+ }
+}
+
+func TestGiteaReleaseGetMissingTagNotFound(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/version", giteaVersionHandler)
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/tags/nope", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message":"release not found"}`))
+ })
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/tags/4242", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message":"release not found"}`))
+ })
+ mux.HandleFunc("GET /api/v1/repos/o/r/releases/4242", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message":"release not found"}`))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ f := New(srv.URL, "", nil)
+ if _, err := f.Releases().Get(context.Background(), "o", "r", "nope"); !errors.Is(err, forge.ErrNotFound) {
+ t.Errorf("non-numeric missing tag: expected forge.ErrNotFound, got %v", err)
+ }
+ if _, err := f.Releases().Get(context.Background(), "o", "r", "4242"); !errors.Is(err, forge.ErrNotFound) {
+ t.Errorf("numeric missing tag: expected forge.ErrNotFound, got %v", err)
+ }
+}
diff --git a/github/helpers_test.go b/github/helpers_test.go
index bc9e063..f1e7928 100644
--- a/github/helpers_test.go
+++ b/github/helpers_test.go
@@ -36,6 +36,13 @@ func assertEqualInt(t *testing.T, field string, want, got int) {
}
}
+func assertEqualInt64(t *testing.T, field string, want, got int64) {
+ t.Helper()
+ if want != got {
+ t.Errorf("%s: want %d, got %d", field, want, got)
+ }
+}
+
func assertSliceEqual(t *testing.T, field string, want, got []string) {
t.Helper()
if len(want) != len(got) {
diff --git a/github/releases.go b/github/releases.go
index 81373d4..f906ec8 100644
--- a/github/releases.go
+++ b/github/releases.go
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "strconv"
"github.com/google/go-github/v82/github"
)
@@ -21,6 +22,7 @@ func (f *gitHubForge) Releases() forge.ReleaseService {
func convertGitHubRelease(r *github.RepositoryRelease) forge.Release {
result := forge.Release{
+ ID: r.GetID(),
TagName: r.GetTagName(),
Title: r.GetName(),
Body: r.GetBody(),
@@ -100,14 +102,36 @@ func (s *gitHubReleaseService) List(ctx context.Context, owner, repo string, opt
return all, nil
}
-func (s *gitHubReleaseService) Get(ctx context.Context, owner, repo, tag string) (*forge.Release, error) {
+// Draft releases 404 by tag on GitHub, so fall back to treating tag as a release ID.
+func (s *gitHubReleaseService) getReleaseByTagOrID(ctx context.Context, owner, repo, tag string) (*github.RepositoryRelease, error) {
r, resp, err := s.client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
+ if err == nil {
+ return r, nil
+ }
+ if resp == nil || resp.StatusCode != http.StatusNotFound {
+ return nil, err
+ }
+
+ id, parseErr := strconv.ParseInt(tag, 10, 64)
+ if parseErr != nil {
+ return nil, forge.ErrNotFound
+ }
+
+ r, resp, err = s.client.Repositories.GetRelease(ctx, owner, repo, id)
if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound {
return nil, forge.ErrNotFound
}
return nil, err
}
+ return r, nil
+}
+
+func (s *gitHubReleaseService) Get(ctx context.Context, owner, repo, tag string) (*forge.Release, error) {
+ r, err := s.getReleaseByTagOrID(ctx, owner, repo, tag)
+ if err != nil {
+ return nil, err
+ }
result := convertGitHubRelease(r)
return &result, nil
}
@@ -152,11 +176,8 @@ func (s *gitHubReleaseService) Create(ctx context.Context, owner, repo string, o
func (s *gitHubReleaseService) Update(ctx context.Context, owner, repo, tag string, opts forge.UpdateReleaseOpts) (*forge.Release, error) {
// Get existing release to find its ID
- existing, resp, err := s.client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
+ existing, err := s.getReleaseByTagOrID(ctx, owner, repo, tag)
if err != nil {
- if resp != nil && resp.StatusCode == http.StatusNotFound {
- return nil, forge.ErrNotFound
- }
return nil, err
}
@@ -205,15 +226,12 @@ func (s *gitHubReleaseService) Update(ctx context.Context, owner, repo, tag stri
}
func (s *gitHubReleaseService) Delete(ctx context.Context, owner, repo, tag string) error {
- existing, resp, err := s.client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
+ existing, err := s.getReleaseByTagOrID(ctx, owner, repo, tag)
if err != nil {
- if resp != nil && resp.StatusCode == http.StatusNotFound {
- return forge.ErrNotFound
- }
return err
}
- resp, err = s.client.Repositories.DeleteRelease(ctx, owner, repo, existing.GetID())
+ resp, err := s.client.Repositories.DeleteRelease(ctx, owner, repo, existing.GetID())
if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound {
return forge.ErrNotFound
@@ -224,11 +242,8 @@ func (s *gitHubReleaseService) Delete(ctx context.Context, owner, repo, tag stri
}
func (s *gitHubReleaseService) UploadAsset(ctx context.Context, owner, repo, tag string, file *os.File) (*forge.ReleaseAsset, error) {
- existing, resp, err := s.client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
+ existing, err := s.getReleaseByTagOrID(ctx, owner, repo, tag)
if err != nil {
- if resp != nil && resp.StatusCode == http.StatusNotFound {
- return nil, forge.ErrNotFound
- }
return nil, err
}
diff --git a/github/releases_id_test.go b/github/releases_id_test.go
new file mode 100644
index 0000000..bd0ee12
--- /dev/null
+++ b/github/releases_id_test.go
@@ -0,0 +1,181 @@
+package github
+
+import (
+ "context"
+ "fmt"
+ forge "github.com/git-pkgs/forge"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/google/go-github/v82/github"
+)
+
+func TestGitHubReleaseGetByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = fmt.Fprint(w, `{"message": "Not Found"}`)
+ })
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprint(w, `{
+ "id": 9,
+ "tag_name": "v0.9.0-draft",
+ "name": "Draft 9",
+ "body": "unpublished notes",
+ "draft": true,
+ "prerelease": false
+ }`)
+ })
+
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ svc := newTestGitHubReleaseService(srv)
+ rel, err := svc.Get(context.Background(), "octocat", "hello", "9")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ assertEqual(t, "TagName", "v0.9.0-draft", rel.TagName)
+ assertEqualBool(t, "Draft", true, rel.Draft)
+ assertEqualInt64(t, "ID", 9, rel.ID)
+}
+
+func TestGitHubReleaseUploadAssetByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = fmt.Fprint(w, `{"message": "Not Found"}`)
+ })
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprint(w, `{"id": 9, "tag_name": "v0.9.0-draft", "draft": true}`)
+ })
+ // go-github posts uploads to /api/uploads/... on enterprise URLs, so the stub must match that path.
+ mux.HandleFunc("POST /api/v3/api/uploads/repos/octocat/hello/releases/9/assets", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _, _ = fmt.Fprint(w, `{
+ "id": 100,
+ "name": "notes.txt",
+ "size": 11,
+ "download_count": 0,
+ "browser_download_url": "https://github.com/octocat/hello/releases/download/9/notes.txt"
+ }`)
+ })
+
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ tmp := filepath.Join(t.TempDir(), "notes.txt")
+ if err := os.WriteFile(tmp, []byte("hello world"), 0o600); err != nil {
+ t.Fatalf("writing temp file: %v", err)
+ }
+ f, err := os.Open(tmp)
+ if err != nil {
+ t.Fatalf("opening temp file: %v", err)
+ }
+ defer func() { _ = f.Close() }()
+
+ svc := newTestGitHubReleaseService(srv)
+ asset, err := svc.UploadAsset(context.Background(), "octocat", "hello", "9", f)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ assertEqual(t, "Asset.Name", "notes.txt", asset.Name)
+ assertEqualInt(t, "Asset.Size", 11, asset.Size)
+}
+
+func TestGitHubReleaseUpdateByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = fmt.Fprint(w, `{"message": "Not Found"}`)
+ })
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprint(w, `{"id": 9, "tag_name": "v0.9.0-draft", "draft": true}`)
+ })
+ mux.HandleFunc("PATCH /api/v3/repos/octocat/hello/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprint(w, `{"id": 9, "tag_name": "v0.9.0-draft", "name": "Draft 9 renamed", "draft": true}`)
+ })
+
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ svc := newTestGitHubReleaseService(srv)
+ rel, err := svc.Update(context.Background(), "octocat", "hello", "9", forge.UpdateReleaseOpts{
+ Title: ptr("Draft 9 renamed"),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ assertEqualInt64(t, "ID", 9, rel.ID)
+ assertEqual(t, "Title", "Draft 9 renamed", rel.Title)
+}
+
+func TestGitHubReleaseDeleteByIDFallback(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/tags/9", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = fmt.Fprint(w, `{"message": "Not Found"}`)
+ })
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprint(w, `{"id": 9, "tag_name": "v0.9.0-draft", "draft": true}`)
+ })
+ mux.HandleFunc("DELETE /api/v3/repos/octocat/hello/releases/9", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ svc := newTestGitHubReleaseService(srv)
+ if err := svc.Delete(context.Background(), "octocat", "hello", "9"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestGitHubReleaseGetMissingTagNotFound(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/tags/nope", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = fmt.Fprint(w, `{"message": "Not Found"}`)
+ })
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/tags/4242", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = fmt.Fprint(w, `{"message": "Not Found"}`)
+ })
+ mux.HandleFunc("GET /api/v3/repos/octocat/hello/releases/4242", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = fmt.Fprint(w, `{"message": "Not Found"}`)
+ })
+
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ svc := newTestGitHubReleaseService(srv)
+ if _, err := svc.Get(context.Background(), "octocat", "hello", "nope"); err != forge.ErrNotFound {
+ t.Errorf("non-numeric missing tag: expected forge.ErrNotFound, got %v", err)
+ }
+ if _, err := svc.Get(context.Background(), "octocat", "hello", "4242"); err != forge.ErrNotFound {
+ t.Errorf("numeric missing tag: expected forge.ErrNotFound, got %v", err)
+ }
+}
+
+func TestConvertGitHubReleasePopulatesID(t *testing.T) {
+ rel := convertGitHubRelease(&github.RepositoryRelease{
+ ID: ptrInt64(12345),
+ TagName: ptr("v9.9.9"),
+ Name: ptr("Draft 9"),
+ Draft: ptrBool(true),
+ })
+ assertEqualInt64(t, "ID", 12345, rel.ID)
+ assertEqual(t, "TagName", "v9.9.9", rel.TagName)
+ assertEqualBool(t, "Draft", true, rel.Draft)
+}
diff --git a/internal/cli/release.go b/internal/cli/release.go
index c10e245..4155818 100644
--- a/internal/cli/release.go
+++ b/internal/cli/release.go
@@ -94,6 +94,7 @@ func releaseViewCmd() *cobra.Command {
return &cobra.Command{
Use: "view ",
Short: "View a release",
+ Long: "View a release. Pass the release ID instead of the tag to target a draft release, since drafts cannot be looked up by tag.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
tag := args[0]
@@ -225,6 +226,7 @@ func releaseEditCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "edit ",
Short: "Edit a release",
+ Long: "Edit a release. Pass the release ID instead of the tag to target a draft release, since drafts cannot be looked up by tag.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
tag := args[0]
@@ -284,6 +286,7 @@ func releaseDeleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "delete ",
Short: "Delete a release",
+ Long: "Delete a release. Pass the release ID instead of the tag to target a draft release, since drafts cannot be looked up by tag.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
tag := args[0]
@@ -338,6 +341,7 @@ func releaseUploadCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "upload ( | --tag )",
Short: "Upload an asset to a release",
+ Long: "Upload an asset to a release. Pass the release ID instead of the tag to target a draft release, since drafts cannot be looked up by tag.",
Args: func(cmd *cobra.Command, args []string) error {
_, _, err := uploadTagAndFile(flagTag, cmd.Flags().Changed("tag"), args)
return err
@@ -387,7 +391,7 @@ func releaseDownloadCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "download ",
Short: "Download a release asset",
- Long: "Downloads a release asset by first listing assets to find the matching name, then downloading by ID.",
+ Long: "Downloads a release asset by first listing assets to find the matching name, then downloading by ID. Pass the release ID instead of the tag to target a draft release, since drafts cannot be looked up by tag.",
Args: cobra.ExactArgs(releaseAssetArgs),
RunE: func(cmd *cobra.Command, args []string) error {
tag := args[0]
diff --git a/types.go b/types.go
index 2220bb9..b81e905 100644
--- a/types.go
+++ b/types.go
@@ -372,6 +372,7 @@ type UpdateMilestoneOpts struct {
// Release holds normalized metadata about a release.
type Release struct {
+ ID int64 `json:"id"`
TagName string `json:"tag_name"`
Title string `json:"title"`
Body string `json:"body,omitempty"`