From 1df580185c9600851fbdbaf3b087da2f924d5979 Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:42:59 -0700 Subject: [PATCH 1/2] Allow targeting draft releases by release ID on GitHub GitHub's GetReleaseByTag endpoint returns 404 for draft releases, so release view, edit, delete, and upload failed with not found on drafts. Add an ID field to forge.Release, populated by the GitHub and Gitea converters (GitLab releases carry no ID and Bitbucket does not support releases), and add a getReleaseByTagOrID helper that falls back to a numeric release ID when the tag lookup 404s. The ID path only fires after a tag 404, so existing numeric tags keep resolving by tag. The release view, edit, delete, upload, and download help text now notes that a release ID may be given instead of a tag. Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> --- gitea/releases.go | 1 + gitea/releases_test.go | 17 ++++ github/helpers_test.go | 7 ++ github/releases.go | 47 ++++++--- github/releases_id_test.go | 200 +++++++++++++++++++++++++++++++++++++ internal/cli/release.go | 6 +- types.go | 1 + 7 files changed, 264 insertions(+), 15 deletions(-) create mode 100644 github/releases_id_test.go diff --git a/gitea/releases.go b/gitea/releases.go index 167ed15..9a1881f 100644 --- a/gitea/releases.go +++ b/gitea/releases.go @@ -24,6 +24,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, diff --git a/gitea/releases_test.go b/gitea/releases_test.go index 9cb35f2..bda1a28 100644 --- a/gitea/releases_test.go +++ b/gitea/releases_test.go @@ -8,6 +8,8 @@ import ( "net/http/httptest" "strings" "testing" + + "code.gitea.io/sdk/gitea" ) func TestCreateReleaseAlreadyExists(t *testing.T) { @@ -124,3 +126,18 @@ 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) + } +} 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..00273cf 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,40 @@ 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) { +// getReleaseByTagOrID resolves a release by tag, falling back to a numeric +// release ID when the tag lookup returns 404. Draft releases cannot be +// reached by tag on GitHub, so passing the release ID lets callers target +// drafts. The ID path only fires after a tag 404, so existing numeric tags +// keep resolving by tag exactly as before. +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 +180,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 +230,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 +246,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..398ec3a --- /dev/null +++ b/github/releases_id_test.go @@ -0,0 +1,200 @@ +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" +) + +// Draft releases cannot be resolved by tag: GitHub's +// GET /repos/{owner}/{repo}/releases/tags/{tag} 404s for drafts. +// These tests pin the fallback to the numeric release ID. + +// TestGitHubReleaseGetByIDFallback stubs a draft release that 404s by tag +// but resolves by ID, then asserts Get returns it with its ID populated. +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) +} + +// TestGitHubReleaseUploadAssetByIDFallback covers the reported bug directly: +// `release upload ` must reach a draft release even though the +// tag lookup 404s. +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}`) + }) + // Note: go-github posts asset uploads to /api/uploads/... on + // enterprise URL configurations, 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) +} + +// TestGitHubReleaseUpdateByIDFallback pins that Update also resolves drafts +// through the ID path. +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) +} + +// TestGitHubReleaseDeleteByIDFallback pins that Delete also resolves drafts +// through the ID path. +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) + } +} + +// TestGitHubReleaseGetMissingTagNotFound pins that a tag that is neither a +// real tag nor a resolvable release ID still returns forge.ErrNotFound, both +// for non-numeric and for numeric-but-absent values. +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) + } +} + +// TestConvertGitHubReleasePopulatesID pins that the converter carries the +// release ID into forge.Release so callers can see it in list/view output. +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"` From fe5b725fffdf0a463112ef2b8736dab3b48e39e5 Mon Sep 17 00:00:00 2001 From: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:34:51 -0700 Subject: [PATCH 2/2] Add Gitea release ID fallback and trim review-requested comments Mirror the GitHub 404-then-ID fallback in the Gitea provider so an ID copied from release list --json resolves in get, update, delete, and upload. Add fallback tests for all four paths plus the missing-tag case. Trim the getReleaseByTagOrID doc comment to one line and drop name-repeating comments in releases_id_test.go, keeping the uploads-path note as one line. Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com> --- gitea/releases.go | 40 +++++++-- gitea/releases_test.go | 165 +++++++++++++++++++++++++++++++++++++ github/releases.go | 6 +- github/releases_id_test.go | 21 +---- 4 files changed, 199 insertions(+), 33 deletions(-) diff --git a/gitea/releases.go b/gitea/releases.go index 9a1881f..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" ) @@ -89,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 @@ -143,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{} @@ -190,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 { @@ -203,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 bda1a28..b4b4d1e 100644 --- a/gitea/releases_test.go +++ b/gitea/releases_test.go @@ -6,6 +6,8 @@ import ( forge "github.com/git-pkgs/forge" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -141,3 +143,166 @@ func TestConvertGiteaReleasePopulatesID(t *testing.T) { 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/releases.go b/github/releases.go index 00273cf..f906ec8 100644 --- a/github/releases.go +++ b/github/releases.go @@ -102,11 +102,7 @@ func (s *gitHubReleaseService) List(ctx context.Context, owner, repo string, opt return all, nil } -// getReleaseByTagOrID resolves a release by tag, falling back to a numeric -// release ID when the tag lookup returns 404. Draft releases cannot be -// reached by tag on GitHub, so passing the release ID lets callers target -// drafts. The ID path only fires after a tag 404, so existing numeric tags -// keep resolving by tag exactly as before. +// 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 { diff --git a/github/releases_id_test.go b/github/releases_id_test.go index 398ec3a..bd0ee12 100644 --- a/github/releases_id_test.go +++ b/github/releases_id_test.go @@ -13,12 +13,6 @@ import ( "github.com/google/go-github/v82/github" ) -// Draft releases cannot be resolved by tag: GitHub's -// GET /repos/{owner}/{repo}/releases/tags/{tag} 404s for drafts. -// These tests pin the fallback to the numeric release ID. - -// TestGitHubReleaseGetByIDFallback stubs a draft release that 404s by tag -// but resolves by ID, then asserts Get returns it with its ID populated. 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) { @@ -50,9 +44,6 @@ func TestGitHubReleaseGetByIDFallback(t *testing.T) { assertEqualInt64(t, "ID", 9, rel.ID) } -// TestGitHubReleaseUploadAssetByIDFallback covers the reported bug directly: -// `release upload ` must reach a draft release even though the -// tag lookup 404s. 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) { @@ -63,8 +54,7 @@ func TestGitHubReleaseUploadAssetByIDFallback(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `{"id": 9, "tag_name": "v0.9.0-draft", "draft": true}`) }) - // Note: go-github posts asset uploads to /api/uploads/... on - // enterprise URL configurations, so the stub must match that path. + // 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) @@ -99,8 +89,6 @@ func TestGitHubReleaseUploadAssetByIDFallback(t *testing.T) { assertEqualInt(t, "Asset.Size", 11, asset.Size) } -// TestGitHubReleaseUpdateByIDFallback pins that Update also resolves drafts -// through the ID path. 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) { @@ -130,8 +118,6 @@ func TestGitHubReleaseUpdateByIDFallback(t *testing.T) { assertEqual(t, "Title", "Draft 9 renamed", rel.Title) } -// TestGitHubReleaseDeleteByIDFallback pins that Delete also resolves drafts -// through the ID path. 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) { @@ -155,9 +141,6 @@ func TestGitHubReleaseDeleteByIDFallback(t *testing.T) { } } -// TestGitHubReleaseGetMissingTagNotFound pins that a tag that is neither a -// real tag nor a resolvable release ID still returns forge.ErrNotFound, both -// for non-numeric and for numeric-but-absent values. 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) { @@ -185,8 +168,6 @@ func TestGitHubReleaseGetMissingTagNotFound(t *testing.T) { } } -// TestConvertGitHubReleasePopulatesID pins that the converter carries the -// release ID into forge.Release so callers can see it in list/view output. func TestConvertGitHubReleasePopulatesID(t *testing.T) { rel := convertGitHubRelease(&github.RepositoryRelease{ ID: ptrInt64(12345),