// 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" ) type FailureKind string const ( FailureInvalid FailureKind = "invalid_request" FailureNotFound FailureKind = "not_found" FailureUnauthorized FailureKind = "unauthorized" FailureConflict FailureKind = "conflict" FailureMalformed FailureKind = "malformed_response" FailureUnverifiable FailureKind = "unverifiable" 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 Tag 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 WriteAttempted bool // a create request was issued, including reconciled creates. } 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.Tag) 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.Tag, tag.Commit.SHA, request.Commit)) } releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag) 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.Tag, 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 { result, verifyErr := acceptCreated(created, request) if verifyErr == nil { result.WriteAttempted = true return result, nil } postErr = verifyErr } if !shouldReconcileWrite(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 { result.WriteAttempted = true 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 shouldReconcileWrite(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 strings.TrimSpace(request.Tag) == "" { return failure(FailureInvalid, "validate request", 0, errors.New("full release tag 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.Tag || 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.Tag || 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 }