Files
releasekit/internal/gitea/backend.go

165 lines
5.3 KiB
Go

package gitea
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"strings"
"go.maximumdirect.net/releasekit/internal/forge"
)
// Backend adapts a configured Gitea destination to the hosted delivery
// operations. Its destination and client are private so callers cannot bypass
// Gitea's validation or replace opaque handles.
type Backend struct {
destination Destination
client Client
}
// NewBackend creates a Gitea backend with an optional HTTP client.
func NewBackend(destination Destination, client *http.Client) *Backend {
return &Backend{destination: destination, client: Client{HTTPClient: client}}
}
type releaseHandle struct {
id int64
}
type assetHandle struct {
id int64
downloadURL string
}
func (b *Backend) TagCommit(ctx context.Context, tag string) (string, error) {
if strings.TrimSpace(tag) == "" {
return "", &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
}
_, apiRoot, err := b.destinationAPI("get hosted tag")
if err != nil {
return "", err
}
var observed tagResponse
endpoint := apiRoot + "/tags/" + url.PathEscape(tag)
if err := b.client.getJSON(ctx, b.client.httpClient(), b.destination.Token, "get hosted tag", endpoint, &observed); err != nil {
return "", neutralFailure(err)
}
if observed.Commit.SHA == "" {
return "", &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("tag response does not identify a commit")}
}
return observed.Commit.SHA, nil
}
func (b *Backend) FindRelease(ctx context.Context, tag string) (forge.Release, error) {
if strings.TrimSpace(tag) == "" {
return forge.Release{}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("full tag is required")}
}
_, apiRoot, err := b.destinationAPI("find release")
if err != nil {
return forge.Release{}, err
}
endpoint := apiRoot + "/releases/tags/" + url.PathEscape(tag)
release, _, err := b.client.getRelease(ctx, b.client.httpClient(), b.destination.Token, endpoint)
if err != nil {
return forge.Release{}, neutralFailure(err)
}
return forge.Release{
URL: release.URL,
Tag: release.TagName,
Title: release.Title,
Body: release.Body,
Draft: release.Draft,
Prerelease: release.Prerelease,
Handle: releaseHandle{id: release.ID},
}, nil
}
func (b *Backend) ListAssets(ctx context.Context, handle any) ([]forge.Asset, error) {
release, ok := handle.(releaseHandle)
if !ok {
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")}
}
_, apiRoot, err := b.destinationAPI("list release assets")
if err != nil {
return nil, err
}
remote, err := b.client.listAssets(ctx, b.client.httpClient(), b.destination.Token, release.id, apiRoot)
if err != nil {
return nil, neutralFailure(err)
}
assets := make([]forge.Asset, 0, len(remote))
for _, item := range remote {
assets = append(assets, forge.Asset{
Filename: item.Filename,
Size: item.Size,
Handle: assetHandle{id: item.ID, downloadURL: item.DownloadURL},
})
}
return assets, nil
}
func (b *Backend) OpenAsset(ctx context.Context, handle any) (io.ReadCloser, error) {
asset, ok := handle.(assetHandle)
if !ok {
return nil, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset handle was not created by the Gitea backend")}
}
base, _, err := b.destinationAPI("download release asset")
if err != nil {
return nil, err
}
stream, err := b.client.openAsset(ctx, b.client.httpClient(), base, b.destination.Token, asset.downloadURL)
if err != nil {
return nil, neutralFailure(err)
}
return stream, nil
}
func (b *Backend) destinationAPI(operation string) (*url.URL, string, error) {
base, err := parseBaseURL(b.destination.BaseURL)
if err != nil {
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, err))
}
if strings.TrimSpace(b.destination.Token) == "" {
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea token is required")))
}
if strings.TrimSpace(b.destination.Owner) == "" || strings.ContainsAny(b.destination.Owner, "/\\") {
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea owner must be one path segment")))
}
if strings.TrimSpace(b.destination.Repository) == "" || strings.ContainsAny(b.destination.Repository, "/\\") {
return nil, "", neutralFailure(failure(FailureInvalid, operation, 0, errors.New("Gitea repository must be one path segment")))
}
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(b.destination.Owner) + "/" + url.PathEscape(b.destination.Repository)
return base, apiRoot, nil
}
func neutralFailure(err error) error {
var giteaFailure *Failure
if !errors.As(err, &giteaFailure) {
return err
}
kind := forge.FailureUnknown
switch giteaFailure.Kind {
case FailureInvalid:
kind = forge.FailureInvalid
case FailureNotFound:
kind = forge.FailureNotFound
case FailureUnauthorized:
kind = forge.FailureUnauthorized
case FailureConflict:
kind = forge.FailureConflict
case FailureMalformed:
kind = forge.FailureMalformed
case FailureUnverifiable:
kind = forge.FailureUnverifiable
case FailureTransport:
kind = forge.FailureTransport
case FailureRejected:
kind = forge.FailureRejected
case FailureUnknown:
kind = forge.FailureUnknown
}
return &forge.Failure{Kind: kind, Err: err}
}