Add Gitea release page transport

This commit is contained in:
2026-10-05 19:46:27 +00:00
parent e98ee0f7b9
commit 717d6d0d04
7 changed files with 756 additions and 8 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

367
internal/gitea/release.go Normal file
View File

@@ -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
}

View File

@@ -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)
}