Add Gitea release page transport
This commit is contained in:
367
internal/gitea/release.go
Normal file
367
internal/gitea/release.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user