package gitcode
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
)
const (
ReleaseStatusUnset = 0
ReleaseStatusPreRelease = 1
ReleaseStatusLatest = 2
)
type ReleaseRequest struct {
Owner string
Repo string
Tag string
}
type ReleaseWriteRequest struct {
Owner string `json:"-"`
Repo string `json:"-"`
TagName string `json:"tag_name,omitempty"`
Ref string `json:"ref,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
ReleaseStatus int `json:"release_status"`
Links []ReleaseLink `json:"links,omitempty"`
Assets []ReleaseLink `json:"assets,omitempty"`
}
type ReleaseLink struct {
Name string `json:"name"`
URL string `json:"url"`
BrowserDownloadURL string `json:"browser_download_url,omitempty"`
AttachmentID string `json:"attachment_id,omitempty"`
Action string `json:"action,omitempty"`
Size int64 `json:"size,omitempty"`
}
type Release struct {
ID string `json:"id,omitempty"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Description string `json:"description"`
Body string `json:"body,omitempty"`
TargetCommitish string `json:"target_commitish,omitempty"`
Prerelease bool `json:"prerelease,omitempty"`
ReleaseStatus int `json:"release_status"`
Assets ReleaseAssets `json:"assets"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
func (r *Release) UnmarshalJSON(body []byte) error {
var wire struct {
ID *string `json:"id"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Description string `json:"description"`
Body string `json:"body"`
TargetCommitish string `json:"target_commitish"`
Prerelease bool `json:"prerelease"`
ReleaseStatus json.RawMessage `json:"release_status"`
Assets json.RawMessage `json:"assets"`
CreatedAt *time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at"`
}
if err := json.Unmarshal(body, &wire); err != nil {
return err
}
status, hasStatus, err := decodeReleaseStatus(wire.ReleaseStatus)
if err != nil {
return err
}
if !hasStatus {
if wire.Prerelease {
status = ReleaseStatusPreRelease
} else {
status = ReleaseStatusLatest
}
}
*r = Release{
TagName: wire.TagName,
Name: wire.Name,
Description: wire.Description,
Body: wire.Body,
TargetCommitish: wire.TargetCommitish,
Prerelease: wire.Prerelease,
ReleaseStatus: status,
CreatedAt: wire.CreatedAt,
UpdatedAt: wire.UpdatedAt,
}
if wire.ID != nil {
r.ID = *wire.ID
}
if len(wire.Assets) == 0 || string(wire.Assets) == "null" {
return nil
}
if bytes.HasPrefix(bytes.TrimSpace(wire.Assets), []byte("[")) {
var assets []ReleaseLink
if err := json.Unmarshal(wire.Assets, &assets); err != nil {
return err
}
r.Assets.Assets = assets
return nil
}
return json.Unmarshal(wire.Assets, &r.Assets)
}
type ReleaseAssets struct {
Assets []ReleaseLink `json:"assets,omitempty"`
Links []ReleaseLink `json:"links,omitempty"`
Sources []ReleaseLink `json:"sources,omitempty"`
}
func (c *HTTPClient) GetRelease(ctx context.Context, req ReleaseRequest) (Release, error) {
if err := validateReleaseRequest(req); err != nil {
return Release{}, err
}
endpoint := getReleaseEndpoint(req.Owner, req.Repo, req.Tag)
var release Release
body, _, err := c.getBytesWithOptions(ctx, endpoint, nil, requestOptions{})
if err != nil {
return Release{}, err
}
if err := decodeDataJSON(endpoint, body, &release); err != nil {
return Release{}, err
}
return normalizeRelease(release), nil
}
func (c *HTTPClient) CreateRelease(ctx context.Context, req ReleaseWriteRequest, opts WriteOptions) (WriteResult[Release], error) {
if err := validateReleaseWriteRequest(req, true); err != nil {
return WriteResult[Release]{}, err
}
payload := releaseCreatePayload(req)
return c.writeRelease(ctx, http.MethodPost, listReleasesEndpoint(req.Owner, req.Repo), "CreateRelease", req.Owner+"/"+req.Repo+"/releases/"+req.TagName, payload, req.TagName, opts)
}
func (c *HTTPClient) UpdateRelease(ctx context.Context, req ReleaseWriteRequest, opts WriteOptions) (WriteResult[Release], error) {
if err := validateReleaseWriteRequest(req, false); err != nil {
return WriteResult[Release]{}, err
}
payload := releaseUpdatePayload(req)
return c.writeRelease(ctx, http.MethodPatch, updateReleaseEndpoint(req.Owner, req.Repo, req.TagName), "UpdateRelease", req.Owner+"/"+req.Repo+"/releases/"+req.TagName, payload, req.TagName, opts)
}
func (c *HTTPClient) writeRelease(ctx context.Context, method, endpoint, operation, target string, payload any, tag string, opts WriteOptions) (WriteResult[Release], error) {
body, err := json.Marshal(payload)
if err != nil {
return WriteResult[Release]{}, err
}
key := opts.IdempotencyKey
if key == "" {
key = GenerateIdempotencyKey(operation, target, payload, opts)
opts.IdempotencyKey = key
}
respBody, headers, err := c.bytesWithOptions(ctx, method, endpoint, nil, body, requestOptions{idempotencyKey: key, localPayload: body})
if err != nil {
return WriteResult[Release]{}, err
}
var release Release
if err := decodeDataJSON(endpoint, respBody, &release); err != nil {
return WriteResult[Release]{}, err
}
release = normalizeRelease(release)
if strings.TrimSpace(release.TagName) == "" {
release.TagName = tag
}
if release.TagName != tag {
return WriteResult[Release]{}, ErrValidationFailed{Field: "release.tag_name", Message: "release write confirmation requires matching tag"}
}
hash := sha256.Sum256(respBody)
fingerprint := sha256.Sum256(RedactJSONBody(respBody, target))
status := headers.Get("Status")
if status == "" {
status = "2xx"
}
return WriteResult[Release]{
Record: release,
Confirmed: true,
Operation: operation,
Target: target,
ProviderStatus: status,
RemoteID: tag,
RemoteSlug: tag,
RemoteRevision: hex.EncodeToString(hash[:]),
IdempotencyKey: key,
ResponseHash: hex.EncodeToString(hash[:]),
ConfirmedAt: time.Now().UTC(),
ProviderPayloadFingerprint: hex.EncodeToString(fingerprint[:]),
}, nil
}
func releaseCreatePayload(req ReleaseWriteRequest) any {
return struct {
TagName string `json:"tag_name"`
TargetCommitish string `json:"target_commitish"`
Name string `json:"name"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
}{
TagName: req.TagName,
TargetCommitish: req.Ref,
Name: req.Name,
Body: req.Description,
Prerelease: req.ReleaseStatus == ReleaseStatusPreRelease,
}
}
func releaseUpdatePayload(req ReleaseWriteRequest) any {
return struct {
Name string `json:"name"`
Body string `json:"body"`
Prerelease bool `json:"prerelease"`
}{
Name: req.Name,
Body: req.Description,
Prerelease: req.ReleaseStatus == ReleaseStatusPreRelease,
}
}
func normalizeRelease(release Release) Release {
if strings.TrimSpace(release.Description) == "" {
release.Description = release.Body
}
for i := range release.Assets.Assets {
if release.Assets.Assets[i].URL == "" {
release.Assets.Assets[i].URL = release.Assets.Assets[i].BrowserDownloadURL
}
}
return release
}
func decodeReleaseStatus(raw json.RawMessage) (int, bool, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return 0, false, nil
}
var numeric int
if err := json.Unmarshal(raw, &numeric); err == nil {
return numeric, true, nil
}
var text string
if err := json.Unmarshal(raw, &text); err != nil {
return 0, true, err
}
switch strings.ToLower(strings.TrimSpace(text)) {
case "", "none", "unset":
return ReleaseStatusUnset, true, nil
case "latest":
return ReleaseStatusLatest, true, nil
case "pre", "prerelease", "pre-release":
return ReleaseStatusPreRelease, true, nil
default:
return ReleaseStatusUnset, true, nil
}
}
func decodeDataJSON(endpoint string, body []byte, out any) error {
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
dec := json.NewDecoder(bytes.NewReader(envelope.Data))
if err := dec.Decode(out); err != nil {
return ErrPartialResponse{Endpoint: endpoint, Got: int64(len(body)), Cause: err, Message: decodeMessage(err)}
}
return nil
}
return decodeJSON(endpoint, body, out)
}
func validateReleaseRequest(req ReleaseRequest) error {
if err := validateReadRepo(req.Owner, req.Repo); err != nil {
return err
}
if strings.TrimSpace(req.Tag) == "" {
return ErrValidationFailed{Field: "tag", Message: "release tag is required"}
}
return nil
}
func validateReleaseWriteRequest(req ReleaseWriteRequest, requireRef bool) error {
if err := validateReadRepo(req.Owner, req.Repo); err != nil {
return err
}
if strings.TrimSpace(req.TagName) == "" {
return ErrValidationFailed{Field: "tag", Message: "release tag is required"}
}
if requireRef && strings.TrimSpace(req.Ref) == "" {
return ErrValidationFailed{Field: "ref", Message: "release ref is required"}
}
if strings.TrimSpace(req.Name) == "" {
return ErrValidationFailed{Field: "name", Message: "release name is required"}
}
if strings.TrimSpace(req.Description) == "" {
return ErrValidationFailed{Field: "description", Message: "release description is required"}
}
for _, link := range req.Links {
if strings.TrimSpace(link.Name) == "" || strings.TrimSpace(link.URL) == "" {
return ErrValidationFailed{Field: "asset", Message: "release asset links require name and url"}
}
}
return nil
}