package gitea import ( "context" "encoding/json" "errors" "io" "net/http" "net/url" "strconv" "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 } type repositoryResponse struct { ID int64 `json:"id"` Name string `json:"name"` FullName string `json:"full_name"` Owner struct { Login string `json:"login"` } `json:"owner"` } func (b *Backend) Preflight(ctx context.Context) error { if strings.TrimSpace(b.destination.Token) == "" { return &forge.PreflightFailure{Kind: forge.PreflightMissingCredential, Err: errors.New("Gitea token is required")} } base, apiRoot, err := b.destinationAPI("preflight repository access") if err != nil { return &forge.PreflightFailure{Kind: forge.PreflightInvalidDestination, Err: err} } client := b.client.httpClient() var resolved repositoryResponse endpoint := apiRoot if err := b.client.getJSON(ctx, client, b.destination.Token, "resolve repository", endpoint, &resolved); err != nil { return preflightFailure(err) } if !resolved.matches(b.destination) { return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("repository response does not identify the configured destination")} } var authenticated repositoryResponse apiPrefix := strings.TrimRight(base.String(), "/") + "/api/v1" endpoint = apiPrefix + "/repositories/" + strconv.FormatInt(resolved.ID, 10) if err := b.client.getJSON(ctx, client, b.destination.Token, "verify authenticated repository access", endpoint, &authenticated); err != nil { return preflightFailure(err) } if !authenticated.matches(b.destination) || authenticated.ID != resolved.ID { return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: errors.New("authenticated repository response does not match the resolved destination")} } // Gitea's permissions.push describes Git code pushes, not release or asset // writes. That metadata cannot establish a delivery-permission deficiency. return nil } func (b *Backend) CreateRelease(ctx context.Context, request forge.CreateReleaseRequest) (forge.Release, forge.WriteEvidence, error) { if strings.TrimSpace(request.Tag) == "" || !validObjectID(request.Commit) { return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{ Kind: forge.FailureInvalid, Err: errors.New("a full tag and lowercase Git commit ID are required"), } } _, apiRoot, err := b.destinationAPI("create release") if err != nil { return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err } body, err := json.Marshal(createReleaseRequest{ TagName: request.Tag, TargetCommitish: request.Commit, Name: request.Title, Body: request.Body, Draft: request.Draft, Prerelease: request.Prerelease, }) if err != nil { return forge.Release{}, forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: err} } created, status, err := b.client.postRelease(ctx, b.client.httpClient(), b.destination.Token, apiRoot+"/releases", body) if err == nil && created.ID <= 0 { err = failure(FailureMalformed, "verify created release", status, errors.New("release response does not contain a positive ID")) } evidence := writeEvidence(status, err) if err != nil { return forge.Release{}, evidence, neutralFailure(err) } return forge.Release{ URL: created.URL, Tag: created.TagName, Title: created.Title, Body: created.Body, Draft: created.Draft, Prerelease: created.Prerelease, Handle: releaseHandle{id: created.ID}, }, evidence, nil } func (b *Backend) UploadAsset(ctx context.Context, handle any, filename string, content io.Reader) (forge.WriteEvidence, error) { release, err := assetReleaseHandle(handle) if err != nil { return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err } if strings.TrimSpace(filename) == "" || content == nil { return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("asset filename and content reader are required")} } _, apiRoot, err := b.destinationAPI("upload release asset") if err != nil { return forge.WriteEvidence{Disposition: forge.WriteNotAttempted}, err } status, err := b.client.uploadAsset(ctx, b.client.httpClient(), b.destination.Token, apiRoot, release.id, filename, content) evidence := writeEvidence(status, err) if err != nil { return evidence, neutralFailure(err) } return evidence, nil } func writeEvidence(status int, err error) forge.WriteEvidence { evidence := forge.WriteEvidence{Attempted: status != 0, Disposition: forge.WriteNotAttempted} var backendFailure *Failure if err != nil && errors.As(err, &backendFailure) && backendFailure.Kind == FailureTransport { evidence.Attempted = true } if !evidence.Attempted { return evidence } if err == nil { evidence.Disposition = forge.WriteAccepted return evidence } if status == http.StatusConflict { evidence.Disposition = forge.WriteConflict return evidence } if status >= http.StatusInternalServerError || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { evidence.Disposition = forge.WriteUncertain return evidence } if errors.As(err, &backendFailure) && (backendFailure.Kind == FailureTransport || backendFailure.Kind == FailureMalformed) { evidence.Disposition = forge.WriteUncertain return evidence } evidence.Disposition = forge.WriteRejected return evidence } func (repository repositoryResponse) matches(destination Destination) bool { if repository.ID <= 0 || !strings.EqualFold(repository.Name, destination.Repository) { return false } if !strings.EqualFold(repository.FullName, destination.Owner+"/"+destination.Repository) { return false } return strings.EqualFold(repository.Owner.Login, destination.Owner) } func preflightFailure(err error) error { var backendFailure *Failure if !errors.As(err, &backendFailure) { return &forge.PreflightFailure{Kind: forge.PreflightUnavailable, Err: err} } kind := forge.PreflightUnavailable switch backendFailure.Kind { case FailureUnauthorized: if backendFailure.StatusCode == http.StatusUnauthorized { kind = forge.PreflightInvalidCredential } else { kind = forge.PreflightInaccessibleDestination } case FailureNotFound: kind = forge.PreflightInaccessibleDestination case FailureRejected: if backendFailure.StatusCode >= http.StatusInternalServerError { kind = forge.PreflightUnavailable } } return &forge.PreflightFailure{Kind: kind, Err: neutralFailure(err)} } 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, err := assetReleaseHandle(handle) if err != nil { return nil, err } _, 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 assetReleaseHandle(handle any) (releaseHandle, error) { release, ok := handle.(releaseHandle) if !ok { return releaseHandle{}, &forge.Failure{Kind: forge.FailureInvalid, Err: errors.New("release handle was not created by the Gitea backend")} } if release.id <= 0 { return releaseHandle{}, &forge.Failure{Kind: forge.FailureMalformed, Err: errors.New("release response does not contain a positive release ID")} } return release, 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} } var _ forge.Backend = (*Backend)(nil)