From 717d6d0d04f31e441944da4e88b922bf93915724 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 5 Oct 2026 19:46:27 +0000 Subject: [PATCH] Add Gitea release page transport --- README.md | 2 + docs/integrations/gitea.md | 33 +++ docs/internal/overview.md | 8 +- docs/roadmap/implementation.md | 4 +- docs/roadmap/releasekit.md | 9 +- internal/gitea/release.go | 367 +++++++++++++++++++++++++++++++++ internal/gitea/release_test.go | 341 ++++++++++++++++++++++++++++++ 7 files changed, 756 insertions(+), 8 deletions(-) create mode 100644 docs/integrations/gitea.md create mode 100644 internal/gitea/release.go create mode 100644 internal/gitea/release_test.go diff --git a/README.md b/README.md index f28fd28..c884cc5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ failure recovery. read their embedded version through `buildinfo`. - [Build guidance](docs/consumers/build.md): compile selected Go targets with an injected application version. +- [Gitea integration](docs/integrations/gitea.md): hosted release-page + operations and their failure behavior. - [Candidate check guidance](docs/consumers/check.md): local candidate validation through the Go API and CLI. - [Maintainer release procedure](docs/release.md): ReleaseKit's source-only diff --git a/docs/integrations/gitea.md b/docs/integrations/gitea.md new file mode 100644 index 0000000..897d797 --- /dev/null +++ b/docs/integrations/gitea.md @@ -0,0 +1,33 @@ +# Gitea release pages + +ReleaseKit's internal Gitea client verifies a hosted source tag and finds or +creates its stable release page. The public delivery facade and CLI workflow +are planned separately; this integration guide describes the implemented +internal operation. + +The caller supplies the Gitea base URL, owner, repository name, and token +explicitly. The client does not read ambient credentials. The token is sent in +the `Authorization: token …` request header and is not included in errors or +results. The base URL must use HTTP or HTTPS and cannot contain URL credentials, +a query, or a fragment. + +For version `vMAJOR.MINOR.PATCH`, the client first reads +`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit +SHA to equal the captured source commit. It then reads +`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page +must have that tag, the complete committed release note as its body, and stable +non-draft state. Its existing title is retained. ReleaseKit does not edit a +conflicting page. + +If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases` +with the selected tag and commit, the release note's H1 as title, the complete +note as body, and draft and prerelease disabled. A create conflict, interrupted +response, or malformed create response triggers one lookup. An exact page found +there confirms the existing content; a differing page is a conflict. If that +lookup cannot determine the result after a possibly successful write, the +client returns an unknown outcome. Other HTTP failures retain their structured +failure kind and status code. + +The integration tests use a local HTTP fixture and assert request paths, +payloads, authorization, and failure handling. They do not contact a live Gitea +service or use real credentials. diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 087aacc..2b666ed 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -16,6 +16,8 @@ Implemented internal packages: overlays. - `internal/build` compiles explicit Go targets with shared version injection, verifies native version output, and returns generated file digests. +- `internal/gitea` verifies hosted source tags and finds or creates matching + stable release pages through the Gitea REST API. - `internal/candidate` captures and validates one clean local candidate, checks its committed release note and local tag state, runs validation once, and rechecks mutable repository state. @@ -30,5 +32,7 @@ Implemented internal packages: The executable controller uses the runtime facade and the command-owned CLI adapter. The adapter uses the process runner and imports neither public facade. -The publication operation coordinates the candidate and Git packages. Build -and Gitea implementation packages will be added as those responsibilities land. +The publication operation coordinates the candidate and Git packages. The +internal Gitea client is documented in the +[Gitea integration guide](../integrations/gitea.md); delivery orchestration will +use it when that facade and CLI work lands. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7e645bc..cceb3e3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # ReleaseKit staged implementation plan -Status: In progress. Stages 1–11 are complete; later stages remain pending. +Status: In progress. Stages 1–12 are complete; later stages remain pending. Date: 2026-10-05 @@ -736,7 +736,7 @@ no publication or consumer pilot is needed to complete this stage. ## Stage 12: Find and create Gitea release pages -Status: Pending. Depends on Stage 11. +Status: Complete. Depends on Stage 11. 1. Implement the small internal REST transport using the served API operations, explicit destination/token inputs, context cancellation, and structured diff --git a/docs/roadmap/releasekit.md b/docs/roadmap/releasekit.md index dcdac8f..f924462 100644 --- a/docs/roadmap/releasekit.md +++ b/docs/roadmap/releasekit.md @@ -2,7 +2,8 @@ This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius, Narratio, and Scriptorium on 2026-09-29. The implementation plan records the -current implemented boundary; delivery and broader integrations remain +current implemented boundary; the internal Gitea page client is implemented, +while asset delivery, facade/CLI integration, and broader integrations remain planned. The public facade architecture is accepted in [ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version, candidate validation, and source publication. @@ -55,9 +56,9 @@ cleanup. The [architecture policy](../policy/architecture.md) owns the adopted responsibility and dependency rules, including the runtime version boundary. The root `releasekit` package provides the public facade for candidate -validation, source publication, and selected-target builds. Delivery remains -planned. The separate public `buildinfo` package provides lightweight runtime -version access. +validation, source publication, and selected-target builds. The internal +Gitea page client is implemented; delivery orchestration remains planned. The +separate public `buildinfo` package provides lightweight runtime version access. Each facade owns its public named types and documented contracts. Public operations, inputs, outcomes, errors, and callbacks express deliberately diff --git a/internal/gitea/release.go b/internal/gitea/release.go new file mode 100644 index 0000000..5fdaf27 --- /dev/null +++ b/internal/gitea/release.go @@ -0,0 +1,367 @@ +// Package gitea implements the internal Gitea REST operations used by delivery. +package gitea + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" +) + +type FailureKind string + +const ( + FailureInvalid FailureKind = "invalid_request" + FailureNotFound FailureKind = "not_found" + FailureUnauthorized FailureKind = "unauthorized" + FailureConflict FailureKind = "conflict" + FailureMalformed FailureKind = "malformed_response" + FailureTransport FailureKind = "transport" + FailureRejected FailureKind = "rejected" + FailureUnknown FailureKind = "unknown" +) + +// Failure describes how a Gitea operation failed. StatusCode is zero when no +// HTTP response was received. +type Failure struct { + Kind FailureKind + Operation string + StatusCode int + Err error +} + +func (f *Failure) Error() string { + if f.StatusCode != 0 { + return fmt.Sprintf("gitea %s: %s (HTTP %d)", f.Operation, f.Kind, f.StatusCode) + } + if f.Err != nil { + return fmt.Sprintf("gitea %s: %s: %v", f.Operation, f.Kind, f.Err) + } + return fmt.Sprintf("gitea %s: %s", f.Operation, f.Kind) +} + +func (f *Failure) Unwrap() error { return f.Err } + +type Destination struct { + BaseURL string + Owner string + Repository string + Token string +} + +type Request struct { + Destination Destination + Version string + Commit string + Note string +} + +type Release struct { + ID int64 + URL string + TagName string + Title string + Body string + Draft bool + Prerelease bool +} + +type Result struct { + Release Release + Created bool +} + +type Client struct { + HTTPClient *http.Client +} + +type tagResponse struct { + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` +} + +type releaseResponse struct { + ID int64 `json:"id"` + HTMLURL string `json:"html_url"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +type createReleaseRequest struct { + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +// EnsureRelease verifies the hosted tag and finds or creates its stable release +// page. It never edits a release that already exists. +func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, error) { + if err := validateRequest(request); err != nil { + return Result{}, err + } + base, err := parseBaseURL(request.Destination.BaseURL) + if err != nil { + return Result{}, failure(FailureInvalid, "validate destination", 0, err) + } + client := c.httpClient() + apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository) + tagURL := apiRoot + "/tags/" + url.PathEscape(request.Version) + + var tag tagResponse + if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil { + return Result{}, err + } + if tag.Commit.SHA != request.Commit { + return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Version, tag.Commit.SHA, request.Commit)) + } + + releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Version) + existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL) + if err == nil { + return acceptExisting(existing, request) + } + if status != http.StatusNotFound { + return Result{}, err + } + + title := noteTitle(request.Note) + body, err := json.Marshal(createReleaseRequest{ + TagName: request.Version, TargetCommitish: request.Commit, + Name: title, Body: request.Note, + }) + if err != nil { + return Result{}, failure(FailureInvalid, "encode release", 0, err) + } + created, postStatus, postErr := c.postRelease(ctx, client, request.Destination.Token, apiRoot+"/releases", body) + if postErr == nil { + return acceptCreated(created, request) + } + if !shouldReconcileCreate(postStatus, postErr) { + return Result{}, postErr + } + + // A conflict or interrupted server response may have followed a successful + // create. One lookup resolves only a page with the exact selected content. + retry, retryStatus, retryErr := c.getRelease(ctx, client, request.Destination.Token, releaseURL) + if retryErr == nil { + result, matchErr := acceptExisting(retry, request) + if matchErr == nil { + return result, nil + } + return Result{}, failure(FailureConflict, "resolve release creation", postStatus, matchErr) + } + if retryStatus == http.StatusNotFound { + if postStatus == http.StatusConflict { + return Result{}, failure(FailureConflict, "create release", postStatus, postErr) + } + return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, postErr) + } + return Result{}, failure(FailureUnknown, "resolve release creation", postStatus, errors.Join(postErr, retryErr)) +} + +func shouldReconcileCreate(status int, err error) bool { + if status == http.StatusConflict || status >= 500 || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + var failure *Failure + return errors.As(err, &failure) && (failure.Kind == FailureTransport || failure.Kind == FailureMalformed) +} + +func (c Client) getRelease(ctx context.Context, client *http.Client, token, endpoint string) (Release, int, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return Release{}, 0, failure(FailureInvalid, "find release", 0, err) + } + request.Header.Set("Authorization", "token "+token) + request.Header.Set("Accept", "application/json") + response, err := client.Do(request) + if err != nil { + return Release{}, 0, failure(FailureTransport, "find release", 0, err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusNotFound { + return Release{}, response.StatusCode, failure(FailureNotFound, "find release", response.StatusCode, nil) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return Release{}, response.StatusCode, responseFailure("find release", response.StatusCode) + } + var decoded releaseResponse + if err := decodeJSON(response.Body, &decoded); err != nil { + return Release{}, response.StatusCode, failure(FailureMalformed, "decode release", response.StatusCode, err) + } + return decoded.Release(), response.StatusCode, nil +} + +func (c Client) getJSON(ctx context.Context, client *http.Client, token, operation, endpoint string, destination any) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return failure(FailureInvalid, operation, 0, err) + } + request.Header.Set("Authorization", "token "+token) + request.Header.Set("Accept", "application/json") + response, err := client.Do(request) + if err != nil { + return failure(FailureTransport, operation, 0, err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return responseFailure(operation, response.StatusCode) + } + if err := decodeJSON(response.Body, destination); err != nil { + return failure(FailureMalformed, operation, response.StatusCode, err) + } + return nil +} + +func (c Client) postRelease(ctx context.Context, client *http.Client, token, endpoint string, body []byte) (Release, int, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return Release{}, 0, failure(FailureInvalid, "create release", 0, err) + } + request.Header.Set("Authorization", "token "+token) + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + response, err := client.Do(request) + if err != nil { + return Release{}, 0, failure(FailureTransport, "create release", 0, err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return Release{}, response.StatusCode, responseFailure("create release", response.StatusCode) + } + var decoded releaseResponse + if err := decodeJSON(response.Body, &decoded); err != nil { + return Release{}, response.StatusCode, failure(FailureMalformed, "decode created release", response.StatusCode, err) + } + return decoded.Release(), response.StatusCode, nil +} + +func (c Client) httpClient() *http.Client { + client := http.DefaultClient + if c.HTTPClient != nil { + clientCopy := *c.HTTPClient + clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &clientCopy + } + clientCopy := *client + clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &clientCopy +} + +func validateRequest(request Request) error { + if !versioncontract.ValidStableVersion(request.Version) { + return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required")) + } + if !validObjectID(request.Commit) { + return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID")) + } + if strings.TrimSpace(request.Destination.Owner) == "" || strings.ContainsAny(request.Destination.Owner, "/\\") { + return failure(FailureInvalid, "validate request", 0, errors.New("Gitea owner is required as one path segment")) + } + if strings.TrimSpace(request.Destination.Repository) == "" || strings.ContainsAny(request.Destination.Repository, "/\\") { + return failure(FailureInvalid, "validate request", 0, errors.New("Gitea repository is required as one path segment")) + } + if strings.TrimSpace(request.Destination.Token) == "" { + return failure(FailureInvalid, "validate request", 0, errors.New("Gitea token is required")) + } + if noteTitle(request.Note) == "" { + return failure(FailureInvalid, "validate request", 0, errors.New("release note must begin with a level-one heading")) + } + return nil +} + +func parseBaseURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(raw) + if err != nil { + return nil, err + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, errors.New("base URL must be an absolute HTTP(S) origin or path without credentials, query, or fragment") + } + return parsed, nil +} + +func acceptExisting(release Release, request Request) (Result, error) { + if release.TagName != request.Version || release.Body != request.Note || release.Draft || release.Prerelease { + return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag")) + } + return Result{Release: release}, nil +} + +func acceptCreated(release Release, request Request) (Result, error) { + if release.ID <= 0 || release.TagName != request.Version || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease { + return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release")) + } + return Result{Release: release, Created: true}, nil +} + +func (response releaseResponse) Release() Release { + return Release{ID: response.ID, URL: response.HTMLURL, TagName: response.TagName, Title: response.Name, Body: response.Body, Draft: response.Draft, Prerelease: response.Prerelease} +} + +func noteTitle(note string) string { + first := strings.SplitN(note, "\n", 2)[0] + if !strings.HasPrefix(first, "# ") { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(first, "# ")) +} + +func validObjectID(value string) bool { + if len(value) != 40 && len(value) != 64 { + return false + } + for _, r := range value { + if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') { + return false + } + } + return true +} + +func responseFailure(operation string, status int) error { + kind := FailureRejected + switch status { + case http.StatusUnauthorized, http.StatusForbidden: + kind = FailureUnauthorized + case http.StatusConflict: + kind = FailureConflict + case http.StatusNotFound: + kind = FailureNotFound + } + return failure(kind, operation, status, nil) +} + +func failure(kind FailureKind, operation string, status int, err error) error { + return &Failure{Kind: kind, Operation: operation, StatusCode: status, Err: err} +} + +func decodeJSON(reader io.Reader, destination any) error { + decoder := json.NewDecoder(reader) + if err := decoder.Decode(destination); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("response contains multiple JSON values") + } + return err + } + return nil +} diff --git a/internal/gitea/release_test.go b/internal/gitea/release_test.go new file mode 100644 index 0000000..18fb130 --- /dev/null +++ b/internal/gitea/release_test.go @@ -0,0 +1,341 @@ +package gitea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const testCommit = "0123456789012345678901234567890123456789" +const testNote = "# Example v1.2.3\n\nRelease details.\n" +const testToken = "fixture-secret-token" + +func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) { + server, _ := newReleaseServer(t, releaseFixture{title: "Historical title"}, false) + result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if err != nil { + t.Fatal(err) + } + if result.Created || result.Release.Title != "Historical title" { + t.Fatalf("result = %#v", result) + } +} + +func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) { + server, _ := newReleaseServer(t, releaseFixture{}, true) + result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if err != nil { + t.Fatal(err) + } + if !result.Created || result.Release.Title != "Example v1.2.3" || result.Release.Body != testNote { + t.Fatalf("result = %#v", result) + } +} + +func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) { + server, state := newReleaseServer(t, releaseFixture{title: "Keep this title", body: "Different body"}, false) + _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if failureKind(t, err) != FailureConflict { + t.Fatalf("error = %v", err) + } + if got := state.posts; got != 0 { + t.Fatalf("release creates = %d, want 0", got) + } +} + +func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if failureKind(t, err) != FailureUnauthorized { + t.Fatalf("error = %v", err) + } + if fmt.Sprint(err) == "" || contains(fmt.Sprint(err), testToken) { + t.Fatalf("error exposes unexpected content: %v", err) + } +} + +func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) { + posts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + posts++ + } + assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") + _, _ = w.Write([]byte("{")) + })) + defer server.Close() + _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if failureKind(t, err) != FailureMalformed || posts != 0 { + t.Fatalf("error = %v, posts = %d", err, posts) + } +} + +func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) { + posts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") + if r.Method == http.MethodPost { + posts++ + } + _, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7) + })) + defer server.Close() + _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if failureKind(t, err) != FailureConflict || posts != 0 { + t.Fatalf("error = %v, posts = %d", err, posts) + } +} + +func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) { + posts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") + if r.Method == http.MethodPost { + posts++ + } + http.NotFound(w, r) + })) + defer server.Close() + _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if failureKind(t, err) != FailureNotFound || posts != 0 { + t.Fatalf("error = %v, posts = %d", err, posts) + } +} + +func TestEnsureReleaseHonorsContextCancellation(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL)) + if !errors.Is(err, context.Canceled) || called { + t.Fatalf("error = %v, server called = %t", err, called) + } +} + +func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) { + created := false + lookups := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + switch r.URL.Path { + case "/api/v1/repos/owner/project/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + writeTag(t, w, testCommit) + case "/api/v1/repos/owner/project/releases/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + lookups++ + if !created { + http.NotFound(w, r) + return + } + writeRelease(t, w, releaseFixture{title: "Example v1.2.3"}) + case "/api/v1/repos/owner/project/releases": + assertRequest(t, r, http.MethodPost, r.URL.Path) + var payload createReleaseRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { + t.Fatalf("create payload = %#v", payload) + } + created = true + hijacker, ok := w.(http.Hijacker) + if !ok { + t.Fatal("HTTP writer does not support hijacking") + } + connection, _, err := hijacker.Hijack() + if err != nil { + t.Fatal(err) + } + _ = connection.Close() + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if err != nil { + t.Fatal(err) + } + if result.Created || lookups != 2 { + t.Fatalf("result = %#v, release lookups = %d", result, lookups) + } +} + +func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) { + lookups := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + switch r.URL.Path { + case "/api/v1/repos/owner/project/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + writeTag(t, w, testCommit) + case "/api/v1/repos/owner/project/releases/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + lookups++ + if lookups == 1 { + http.NotFound(w, r) + return + } + writeRelease(t, w, releaseFixture{title: "Created by another request"}) + case "/api/v1/repos/owner/project/releases": + assertRequest(t, r, http.MethodPost, r.URL.Path) + w.WriteHeader(http.StatusConflict) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if err != nil { + t.Fatal(err) + } + if result.Created || result.Release.Title != "Created by another request" || lookups != 2 { + t.Fatalf("result = %#v, release lookups = %d", result, lookups) + } +} + +func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) { + lookups := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + switch r.URL.Path { + case "/api/v1/repos/owner/project/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + writeTag(t, w, testCommit) + case "/api/v1/repos/owner/project/releases/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + lookups++ + if lookups == 1 { + http.NotFound(w, r) + return + } + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + case "/api/v1/repos/owner/project/releases": + assertRequest(t, r, http.MethodPost, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) + if failureKind(t, err) != FailureUnknown || lookups != 2 { + t.Fatalf("error = %v, release lookups = %d", err, lookups) + } +} + +type releaseFixture struct { + title string + body string + draft bool + pre bool +} + +type fixtureState struct{ posts int } + +func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) { + t.Helper() + state := &fixtureState{} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAuth(t, r) + switch r.URL.Path { + case "/api/v1/repos/owner/project/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + writeTag(t, w, testCommit) + case "/api/v1/repos/owner/project/releases/tags/v1.2.3": + assertRequest(t, r, http.MethodGet, r.URL.Path) + if existing.title == "" && existing.body == "" { + http.NotFound(w, r) + return + } + writeRelease(t, w, existing) + case "/api/v1/repos/owner/project/releases": + assertRequest(t, r, http.MethodPost, r.URL.Path) + state.posts++ + var payload createReleaseRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { + t.Fatalf("create payload = %#v", payload) + } + if !createMissing { + w.WriteHeader(http.StatusConflict) + return + } + writeRelease(t, w, releaseFixture{title: payload.Name}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + }) + server := httptest.NewServer(handler) + return server, state +} + +func writeTag(t *testing.T, w http.ResponseWriter, commit string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit) +} + +func writeRelease(t *testing.T, w http.ResponseWriter, fixture releaseFixture) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(releaseResponse{ID: 10, HTMLURL: "https://gitea.example/releases/10", TagName: "v1.2.3", Name: fixture.title, Body: fixture.bodyOrDefault(), Draft: fixture.draft, Prerelease: fixture.pre}) +} + +func (fixture releaseFixture) bodyOrDefault() string { + if fixture.body == "" { + return testNote + } + return fixture.body +} + +func assertRequest(t *testing.T, request *http.Request, method, path string) { + t.Helper() + if request.Method != method || request.URL.Path != path { + t.Fatalf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path) + } + assertAuth(t, request) +} + +func assertAuth(t *testing.T, request *http.Request) { + t.Helper() + if got := request.Header.Get("Authorization"); got != "token "+testToken { + t.Fatalf("Authorization = %q", got) + } +} + +func testClient(server *httptest.Server) Client { + return Client{HTTPClient: server.Client()} +} + +func testRequest(baseURL string) Request { + return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Version: "v1.2.3", Commit: testCommit, Note: testNote} +} + +func failureKind(t *testing.T, err error) FailureKind { + t.Helper() + var failure *Failure + if !errors.As(err, &failure) { + t.Fatalf("error type = %T (%v), want *Failure", err, err) + } + return failure.Kind +} + +func contains(value, substring string) bool { + return len(substring) > 0 && len(value) >= len(substring) && strings.Contains(value, substring) +}