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