package main import ( "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "unicode/utf8" sdk "code.gitea.io/sdk/gitea" "git.noodles.cam/claude-skills/marketplace/cli/internal/config" "git.noodles.cam/claude-skills/marketplace/cli/internal/gitea" ) const ( // userAgent names this tool in the instance's log. An admin looking at a // burst of requests should be able to tell a release from a push. userAgent = "kettle-release" // requestTimeout bounds one call. Generous next to the transport's 30s // because one of these calls is an upload: a 20 MB binary over a domestic // connection is minutes, and a run that gives up half way through its // assets is exactly the mess this tool exists to avoid. requestTimeout = 10 * time.Minute // pageLimit and maxPages bound the two listings this makes. A repository // with a runaway number of releases must not turn one publish into an // unbounded read. pageLimit = 50 maxPages = 20 ) // spec is what was asked for: one release, and the files that belong on it. type spec struct { Tag string Title string Notes string Target string Draft bool Prerelease bool Files []string } // title defaults to the tag, because Gitea refuses a release without one and // "v1.2.3" is what a person would have typed anyway. func (s spec) title() string { if s.Title != "" { return s.Title } return s.Tag } // asset is one file that ended up on the release. type asset struct { Name string URL string // Replaced records that an attachment of this name was already there and // was removed to make room. Two assets with one name is the failure mode a // retried publish has, and it is silent: the download URL is by name. Replaced bool } // receipt is what happened, in the words the run will print. type receipt struct { Repo string Release *sdk.Release // State is "created", "updated" or "reused" — which of the three a re-run // hit is the whole question an operator has about idempotency. State string Assets []asset } // publish makes the tracker say what the spec says, and reports what it did. func publish(cfg *config.Resolved, s spec) (*receipt, error) { c, err := newClient(cfg) if err != nil { return nil, err } rel, state, err := c.releaseFor(s) if err != nil { return nil, err } assets, err := c.uploadAll(rel, s.Files) if err != nil { return nil, err } return &receipt{Repo: c.slug(), Release: rel, State: state, Assets: assets}, nil } // print writes the receipt: what happened to the release, every asset that // ended up on it, and the URL a person opens. func (r *receipt) print(w io.Writer) { fmt.Fprintf(w, "%-9s release %s in %s\n", r.State, r.Release.TagName, r.Repo) width := 0 for _, a := range r.Assets { if n := utf8.RuneCountInString(a.Name); n > width { width = n } } uploaded, replaced := 0, 0 for _, a := range r.Assets { verb := "uploaded" uploaded++ if a.Replaced { verb, replaced = "replaced", replaced+1 } fmt.Fprintf(w, "%-9s %-*s %s\n", verb, width, a.Name, a.URL) } if url := r.Release.HTMLURL; url != "" { fmt.Fprintf(w, "%-9s %s\n", "release", url) } fmt.Fprintf(w, "%d asset(s): %d uploaded, %d replaced — draft: %s, prerelease: %s\n", len(r.Assets), uploaded-replaced, replaced, yesNo(r.Release.IsDraft), yesNo(r.Release.IsPrerelease)) } func yesNo(b bool) string { if b { return "yes" } return "no" } // client is one repository on one instance. type client struct { api *sdk.Client owner, name string } func (c *client) slug() string { return c.owner + "/" + c.name } // newClient refuses a half-filled configuration before it dials, the same way // gitea.New does and for the same reason: building a client is itself a // request — the SDK asks the instance for its version before it hands one back // — and a missing token reported as a connection failure sends whoever is // reading it to the wrong place. func newClient(cfg *config.Resolved) (*client, error) { if cfg == nil { return nil, errors.New("no resolved configuration — call config.ResolveOutsideAProject first") } if err := cfg.Complete(); err != nil { return nil, err } base := strings.TrimRight(cfg.URL, "/") api, err := sdk.NewClient(base, sdk.SetToken(cfg.Token), sdk.SetHTTPClient(&http.Client{Timeout: requestTimeout}), sdk.SetUserAgent(userAgent)) if err != nil { if errors.Is(err, &sdk.ErrUnknownVersion{}) { return nil, fmt.Errorf("%s did not answer with a version this can read (%w)"+ " — check that %s points at a Gitea instance", base, err, config.EnvURL) } return nil, fmt.Errorf("cannot reach the Gitea instance at %s: %w", base, err) } return &client{api: api, owner: cfg.Owner, name: cfg.Repo}, nil } // releaseFor is the release this tag should have, created or brought into line. func (c *client) releaseFor(s spec) (*sdk.Release, string, error) { found, err := c.find(s.Tag) if err != nil { return nil, "", err } if found == nil { rel, err := c.create(s) return rel, "created", err } rel, changed, err := c.converge(found, s) if err != nil { return nil, "", err } if changed { return rel, "updated", nil } return rel, "reused", nil } // find is the release for this tag, or nil when the repository has none. // // A 404 is an answer here and not a failure — it is what "no release yet" // looks like, which is the ordinary case the first time a tag is published. // Anything else is reported, because "the instance refused us" and "there is // nothing there" must not both read as "create one". func (c *client) find(tag string) (*sdk.Release, error) { got, resp, err := c.api.GetReleaseByTag(c.owner, c.name, tag) if err == nil { return got, nil } if failed := gitea.Fail(resp, err); !gitea.StatusIs(failed, http.StatusNotFound) { return nil, fmt.Errorf("looking for a release on %s: %w", tag, failed) } // The by-tag route is a lookup through the tag, and a draft need not have // one — so a draft this tool created on an earlier run can answer 404 to // the question "is it already there?". Scanning the listing is what keeps a // retried `--draft` publish from filing a second release for one tag. return c.scan(tag) } // scan walks the release listing for this tag. func (c *client) scan(tag string) (*sdk.Release, error) { for page := 1; page <= maxPages; page++ { batch, resp, err := c.api.ListReleases(c.owner, c.name, sdk.ListReleasesOptions{ ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit}, }) if err := gitea.Fail(resp, err); err != nil { return nil, fmt.Errorf("listing releases: %w", err) } for _, rel := range batch { if rel.TagName == tag { return rel, nil } } if len(batch) < pageLimit { return nil, nil // a short page is the last one } } return nil, nil } func (c *client) create(s spec) (*sdk.Release, error) { got, resp, err := c.api.CreateRelease(c.owner, c.name, sdk.CreateReleaseOption{ TagName: s.Tag, Target: s.Target, Title: s.title(), Note: s.Notes, IsDraft: s.Draft, IsPrerelease: s.Prerelease, }) if err := gitea.Fail(resp, err); err != nil { return nil, fmt.Errorf("creating the release for %s: %w", s.Tag, err) } if got == nil || got.ID == 0 { return nil, fmt.Errorf("creating the release for %s: the tracker's answer carries no id", s.Tag) } return got, nil } // converge edits an existing release until it says what the spec says, and // reports whether anything had to change. // // Reuse alone would be enough to make a re-run not fail; it would not make it // CONVERGE. A second run with corrected notes has to leave the release holding // the corrected notes, or the retry that fixed the mistake published the // mistake again. // // Empty notes mean "leave what is there", not "clear them": `--notes-file` is // how notes are supplied, and a run that did not supply any is not a run asking // for the release to be emptied. func (c *client) converge(rel *sdk.Release, s spec) (*sdk.Release, bool, error) { note := s.Notes if note == "" { note = rel.Note } if rel.Title == s.title() && rel.Note == note && rel.IsDraft == s.Draft && rel.IsPrerelease == s.Prerelease { return rel, false, nil } draft, prerelease := s.Draft, s.Prerelease got, resp, err := c.api.EditRelease(c.owner, c.name, rel.ID, sdk.EditReleaseOption{ TagName: rel.TagName, Title: s.title(), Note: note, IsDraft: &draft, IsPrerelease: &prerelease, }) if err := gitea.Fail(resp, err); err != nil { return nil, false, fmt.Errorf("updating the release for %s: %w", rel.TagName, err) } if got == nil || got.ID == 0 { return nil, false, fmt.Errorf("updating the release for %s: the tracker's answer carries no id", rel.TagName) } return got, true, nil } // uploadAll puts every named file on the release, replacing an attachment that // already carries that name. // // The listing is read once, before the first upload, because the names that // matter are the ones that were there when the run started: the files being // uploaded are checked for duplicate basenames up front, so nothing this run // adds can collide with something else this run adds. func (c *client) uploadAll(rel *sdk.Release, files []string) ([]asset, error) { if len(files) == 0 { return nil, nil } existing, err := c.attachments(rel.ID) if err != nil { return nil, err } byName := map[string][]*sdk.Attachment{} for _, a := range existing { byName[a.Name] = append(byName[a.Name], a) } out := make([]asset, 0, len(files)) for _, path := range files { name := filepath.Base(path) replaced := false // Removed before the upload rather than after it. Gitea will happily // hold two attachments with one name, and the download URL names the // file — so the state to avoid at all costs is the ambiguous one, not // the momentarily absent one. for _, old := range byName[name] { if resp, err := c.api.DeleteReleaseAttachment(c.owner, c.name, rel.ID, old.ID); err != nil { return out, fmt.Errorf("removing the old %s: %w", name, gitea.Fail(resp, err)) } replaced = true } got, err := c.upload(rel.ID, path, name) if err != nil { return out, err } out = append(out, asset{Name: name, URL: got.DownloadURL, Replaced: replaced}) } return out, nil } func (c *client) attachments(release int64) ([]*sdk.Attachment, error) { var out []*sdk.Attachment for page := 1; page <= maxPages; page++ { batch, resp, err := c.api.ListReleaseAttachments(c.owner, c.name, release, sdk.ListReleaseAttachmentsOptions{ListOptions: sdk.ListOptions{Page: page, PageSize: pageLimit}}) if err := gitea.Fail(resp, err); err != nil { return nil, fmt.Errorf("listing the release's assets: %w", err) } out = append(out, batch...) if len(batch) < pageLimit { return out, nil } } return out, nil } func (c *client) upload(release int64, path, name string) (*sdk.Attachment, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("uploading %s: %w", name, err) } defer f.Close() got, resp, err := c.api.CreateReleaseAttachment(c.owner, c.name, release, f, name) if err := gitea.Fail(resp, err); err != nil { return nil, fmt.Errorf("uploading %s: %w", name, err) } if got == nil { return nil, fmt.Errorf("uploading %s: the tracker's answer carries no attachment", name) } return got, nil }