internal/control/release.go

487 lines · 15874 bytes

16 symbols in this file
  1package control
  2
  3import (
  4	"crypto/sha256"
  5	"encoding/hex"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"os"
 10	"path/filepath"
 11	"regexp"
 12	"strconv"
 13	"strings"
 14
 15	"gitbay.org/gitbay/internal/gitutil"
 16	"gitbay.org/gitbay/internal/policy"
 17	"gitbay.org/gitbay/internal/protocol"
 18	"gitbay.org/gitbay/internal/store"
 19)
 20
 21func init() {
 22	register(Command{Path: []string{"release", "create"},
 23		Summary: "create a release on a tag",
 24		Usage:   "release create <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
 25		Flags: []Flag{
 26			{"--title", "<t>", "the release's title", "the tag"},
 27			{"--notes", "<n>", "the release notes", ""},
 28			{"--file", "-", "read the release notes from stdin", ""},
 29			{"--format", "md|org", "the notes' markup", "md"},
 30		},
 31		Examples: []string{
 32			`release create krz/gitbay v1.31.0 --title "v1.31.0" --notes "flag help"`,
 33			"release create krz/gitbay v1.31.0 --file - < notes.md",
 34		},
 35		ReadsStdin: true, Run: runReleaseCreate})
 36	register(Command{Path: []string{"release", "edit"},
 37		Summary: "update a release's title and notes",
 38		Usage:   "release edit <owner/name> <tag> [--title <t>] [--notes <n> | --file -] [--format md|org]",
 39		Flags: []Flag{
 40			{"--title", "<t>", "the release's new title", ""},
 41			{"--notes", "<n>", "the release's new notes", ""},
 42			{"--file", "-", "read the new release notes from stdin", ""},
 43			{"--format", "md|org", "the notes' markup", ""},
 44		},
 45		Examples:   []string{`release edit krz/gitbay v1.31.0 --title "v1.31.0"`},
 46		ReadsStdin: true, Run: runReleaseEdit})
 47	register(Command{Path: []string{"release", "list"},
 48		Summary: "list releases",
 49		Usage:   "release list <owner/name> [--limit <n>] [--cursor <c>]",
 50		Flags: []Flag{
 51			{"--limit", "<n>", "rows per page", ""},
 52			{"--cursor", "<c>", "continue from the previous page", ""},
 53		},
 54		Examples: []string{"release list krz/gitbay --limit 10"},
 55		ReadOnly: true, Run: runReleaseList})
 56	register(Command{Path: []string{"release", "show"},
 57		Summary:  "show a release with assets",
 58		Usage:    "release show <owner/name> <tag>",
 59		Examples: []string{"release show krz/gitbay v1.30.0"},
 60		ReadOnly: true, Run: runReleaseShow})
 61	register(Command{Path: []string{"release", "delete"},
 62		Summary: "delete a release and its assets",
 63		Usage:   "release delete <owner/name> <tag> --yes",
 64		Flags: []Flag{
 65			{"--yes", "", "confirm the permanent delete", ""},
 66		},
 67		Examples: []string{"release delete krz/gitbay v1.30.0 --yes"},
 68		Run:      runReleaseDelete})
 69	register(Command{Path: []string{"release", "asset", "add"},
 70		Summary:    "upload an asset from stdin",
 71		Usage:      "release asset add <owner/name> <tag> <filename> < file",
 72		Examples:   []string{"release asset add krz/gitbay v1.30.0 gitbay-darwin-arm64 < gitbay-darwin-arm64"},
 73		ReadsStdin: true, Run: runAssetAdd})
 74	register(Command{Path: []string{"release", "asset", "get"},
 75		Summary:  "write an asset to stdout",
 76		Usage:    "release asset get <owner/name> <tag> <filename> > file",
 77		Examples: []string{"release asset get krz/gitbay v1.30.0 gitbay-darwin-arm64 > gitbay-darwin-arm64"},
 78		ReadOnly: true, Run: runAssetGet})
 79	register(Command{Path: []string{"release", "asset", "remove"},
 80		Summary:  "remove an asset",
 81		Usage:    "release asset remove <owner/name> <tag> <filename>",
 82		Examples: []string{"release asset remove krz/gitbay v1.30.0 gitbay-darwin-arm64"},
 83		Run:      runAssetRemove})
 84}
 85
 86var assetNamePat = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$`)
 87
 88// assetDir holds a release's uploaded files inside the bare repo directory,
 89// so backup, transfer, and delete all carry them automatically.
 90func assetDir(root string, repo store.Repo, releaseID int64) string {
 91	return filepath.Join(RepoDir(root, repo.OwnerName, repo.Name), "gitbay-releases", strconv.FormatInt(releaseID, 10))
 92}
 93
 94// releaseRef loads a release for "<owner/name> <tag>" with the permission.
 95func releaseRef(c *Ctx, args []string, perm func(store.User, store.Repo, string) bool) (store.Repo, store.Release, int) {
 96	if len(args) < 2 {
 97		return store.Repo{}, store.Release{}, c.usageWith("expected <owner/name> <tag>")
 98	}
 99	repo, code := resolveRepo(c, args[0], perm)
100	if code >= 0 {
101		return repo, store.Release{}, code
102	}
103	rel, err := c.Store.ReleaseByTag(repo.ID, args[1])
104	if errors.Is(err, store.ErrNotFound) {
105		return repo, rel, c.fail(protocol.ExitNotFound, "no release for tag %q in %s", args[1], repo.Path())
106	}
107	if err != nil {
108		return repo, rel, c.fail(protocol.ExitFailure, "%v", err)
109	}
110	return repo, rel, -1
111}
112
113func runReleaseCreate(c *Ctx, args []string) int {
114	f, err := c.parseArgs(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
115	if err != nil {
116		return c.fail(protocol.ExitUsage, "%v", err)
117	}
118	path, tag := f.pos(0), f.pos(1)
119	title, notes, file, format := f.Value("--title"), f.Value("--notes"), f.Value("--file"), f.Value("--format")
120	if path == "" || tag == "" {
121		return c.usage()
122	}
123	fmtName, err := markupFormat(format)
124	if err != nil {
125		return c.failInput(err)
126	}
127	if fmtName == "" {
128		fmtName = "md"
129	}
130	repo, code := resolveRepo(c, path, policy.CanWrite)
131	if code >= 0 {
132		return code
133	}
134	if code := refuseArchived(c, repo); code >= 0 {
135		return code
136	}
137	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
138	if _, err := gitutil.ResolveRef(dir, "refs/tags/"+tag); err != nil {
139		return c.fail(protocol.ExitNotFound, "no tag %q in %s — push the tag first", tag, repo.Path())
140	}
141	body, err := bodyFrom(c, notes, file)
142	if err != nil {
143		return c.failInput(err)
144	}
145	if title == "" {
146		title = tag
147	}
148	if _, err := c.Store.CreateRelease(repo.ID, tag, title, body, c.User.ID, fmtName); err != nil {
149		return c.failErr(err)
150	}
151	c.Store.RecordEvent(repo.ID, c.User.ID, "release.created", fmt.Sprintf(`{"tag":%q}`, tag))
152	return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
153		fmt.Fprintf(w, "created release %s on %s\n", tag, repo.Path())
154		if c.Term.Cols > 0 {
155			fmt.Fprintln(w, c.siteURL(repo.Path(), "releases"))
156		}
157	})
158}
159
160type assetOut struct {
161	Name   string `json:"name"`
162	Size   int64  `json:"size"`
163	SHA256 string `json:"sha256"`
164}
165
166type releaseOut struct {
167	Tag         string     `json:"tag"`
168	Title       string     `json:"title"`
169	Notes       string     `json:"notes,omitempty"`
170	NotesFormat string     `json:"notes_format,omitempty"`
171	Author      string     `json:"author,omitempty"`
172	CreatedAt   string     `json:"created_at"`
173	Assets      []assetOut `json:"assets,omitempty"`
174}
175
176func releaseToOut(r store.Release, withNotes bool) releaseOut {
177	o := releaseOut{Tag: r.Tag, Title: r.Title, Author: r.Author, CreatedAt: r.CreatedAt}
178	if withNotes {
179		o.Notes = r.Notes
180		o.NotesFormat = r.NotesFormat
181	}
182	for _, a := range r.Assets {
183		o.Assets = append(o.Assets, assetOut{a.Name, a.Size, a.SHA256})
184	}
185	return o
186}
187
188func runReleaseEdit(c *Ctx, args []string) int {
189	f, err := c.parseArgs(args, flagSpec{Values: []string{"--title", "--notes", "--file", "--format"}, MaxPos: 2, Usage: c.Cmd.Usage})
190	if err != nil {
191		return c.fail(protocol.ExitUsage, "%v", err)
192	}
193	path, tag := f.pos(0), f.pos(1)
194	title, notes, file, format := f.Value("--title"), f.Value("--notes"), f.Value("--file"), f.Value("--format")
195	setTitle, setNotes := f.Has("--title"), f.Has("--notes") || f.Has("--file")
196	fmtName, err := markupFormat(format)
197	if err != nil {
198		return c.failInput(err)
199	}
200	if path == "" || tag == "" || (!setTitle && !setNotes && fmtName == "") {
201		return c.usage()
202	}
203	repo, code := resolveRepo(c, path, policy.CanWrite)
204	if code >= 0 {
205		return code
206	}
207	if code := refuseArchived(c, repo); code >= 0 {
208		return code
209	}
210	rel, err := c.Store.ReleaseByTag(repo.ID, tag)
211	if err != nil {
212		return c.fail(protocol.ExitNotFound, "no release %q in %s", tag, repo.Path())
213	}
214	// Absent flags keep what the release already says.
215	if !setTitle {
216		title = rel.Title
217	} else if title == "" {
218		title = tag
219	}
220	body := rel.Notes
221	if setNotes {
222		if body, err = bodyFrom(c, notes, file); err != nil {
223			return c.failInput(err)
224		}
225	}
226	if fmtName == "" {
227		fmtName = rel.NotesFormat
228	}
229	if err := c.Store.UpdateRelease(repo.ID, tag, title, body, fmtName); err != nil {
230		return c.fail(protocol.ExitFailure, "%v", err)
231	}
232	return c.emit(map[string]string{"tag": tag, "title": title}, func(w io.Writer) {
233		fmt.Fprintf(w, "updated release %s\n", tag)
234	})
235}
236
237// splitReleaseCursor pulls "<created_at>|<id>" apart. The id is what a
238// deleted release loses, so the created_at half carries the sort
239// position even when the row the cursor names is gone.
240func splitReleaseCursor(key string) (created string, id int64, ok bool) {
241	i := strings.LastIndex(key, "|")
242	if i < 0 {
243		return "", 0, false
244	}
245	created = key[:i]
246	n, err := strconv.ParseInt(key[i+1:], 10, 64)
247	if err != nil || created == "" {
248		return "", 0, false
249	}
250	return created, n, true
251}
252
253func runReleaseList(c *Ctx, args []string) int {
254	rest, p, code := parsePageFlags(c, args, "release", false)
255	if code >= 0 {
256		return code
257	}
258	if len(rest) != 1 {
259		return c.usage()
260	}
261	repo, code := resolveRepo(c, rest[0], policy.CanRead)
262	if code >= 0 {
263		return code
264	}
265	var afterCreated string
266	var afterID int64
267	if p.key != "" {
268		var ok bool
269		afterCreated, afterID, ok = splitReleaseCursor(p.key)
270		if !ok {
271			return c.fail(protocol.ExitUsage, "bad cursor")
272		}
273	}
274	rels, err := c.Store.ListReleasesPage(repo.ID, p.queryLimit(), afterCreated, afterID)
275	if err != nil {
276		return c.fail(protocol.ExitFailure, "%v", err)
277	}
278	rels, next := trimPage(p, rels, "release", func(r store.Release) string {
279		return r.CreatedAt + "|" + strconv.FormatInt(r.ID, 10)
280	})
281	var ds []releaseOut
282	for _, r := range rels {
283		ds = append(ds, releaseToOut(r, false))
284	}
285	return c.emitPageView(p, ds, next, func(w io.Writer) {
286		tb := c.table(w, "TAG", "TITLE", "ASSETS")
287		for _, d := range ds {
288			title := d.Title
289			if title == d.Tag {
290				title = ""
291			}
292			tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets))))
293		}
294		tb.flush()
295	}, func() screen {
296		rows := make([]row, len(ds))
297		for i, d := range ds {
298			// "v1.2.0 — the forge speaks first" reads as its subtitle
299			// beside the tag.
300			title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ")
301			assets := ""
302			switch n := len(d.Assets); n {
303			case 0:
304			case 1:
305				assets = "1 asset"
306			default:
307				assets = fmt.Sprintf("%d assets", n)
308			}
309			rows[i] = rowOf(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cMeta(assets, relAge(d.CreatedAt, termNow())))
310		}
311		s := listScreen("Releases", rows)
312		if len(ds) > 0 {
313			s.actions = []action{{"Read", []string{"release", "show", repo.Path(), ds[0].Tag}}}
314		}
315		return s
316	})
317}
318
319func runReleaseShow(c *Ctx, args []string) int {
320	repo, rel, code := releaseRef(c, args, policy.CanRead)
321	if code >= 0 {
322		return code
323	}
324	d := releaseToOut(rel, true)
325	return c.emitView(d, func(w io.Writer) {
326		title := d.Title
327		if title == d.Tag {
328			title = ""
329		}
330		v := c.view(w)
331		v.title(d.Tag, title, "")
332		v.fields(
333			"author", d.Author+", "+c.when(d.CreatedAt),
334		)
335		v.body(d.Notes, d.NotesFormat)
336		if len(d.Assets) > 0 {
337			v.section("assets")
338			tb := c.table(w, "NAME", "SIZE", "SHA256")
339			for _, a := range d.Assets {
340				tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256))
341			}
342			tb.flush()
343		}
344	}, func() screen {
345		assets := section{title: "Assets", n: len(d.Assets)}
346		for _, a := range d.Assets {
347			assets.rows = append(assets.rows, rowOf(cRef(a.Name), cSize(a.Size), cMeta(a.SHA256[:min(12, len(a.SHA256))])))
348		}
349		s := screen{body: d.Notes, format: d.NotesFormat, sections: []section{assets}, fields: []field{
350			{"Release", []cell{cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cText(d.Title)}},
351			{"Author", []cell{cText(d.Author), cAge(d.CreatedAt)}},
352		}}
353		if len(d.Assets) > 0 {
354			s.actions = append(s.actions, action{"Get", []string{"release", "asset", "get", repo.Path(), d.Tag, d.Assets[0].Name}})
355		}
356		s.actions = append(s.actions, action{"Edit", []string{"release", "edit", repo.Path(), d.Tag, "--title", "<title>"}})
357		return s
358	})
359}
360
361func runReleaseDelete(c *Ctx, args []string) int {
362	var rest []string
363	var yes bool
364	for _, a := range args {
365		if a == "--yes" {
366			yes = true
367		} else {
368			rest = append(rest, a)
369		}
370	}
371	repo, rel, code := releaseRef(c, rest, policy.CanAdmin)
372	if code >= 0 {
373		return code
374	}
375	if !yes {
376		return c.fail(protocol.ExitUsage, "release delete is permanent (assets included); re-run with --yes")
377	}
378	if err := c.Store.DeleteRelease(rel.ID); err != nil {
379		return c.fail(protocol.ExitFailure, "%v", err)
380	}
381	os.RemoveAll(assetDir(c.Cfg.Server.Root, repo, rel.ID))
382	c.Store.RecordEvent(repo.ID, c.User.ID, "release.deleted", fmt.Sprintf(`{"tag":%q}`, rel.Tag))
383	return c.emit(map[string]string{"deleted": rel.Tag}, func(w io.Writer) {
384		fmt.Fprintf(w, "deleted release %s\n", rel.Tag)
385	})
386}
387
388func runAssetAdd(c *Ctx, args []string) int {
389	if len(args) != 3 {
390		return c.usage()
391	}
392	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
393	if code >= 0 {
394		return code
395	}
396	if code := refuseArchived(c, repo); code >= 0 {
397		return code
398	}
399	name := args[2]
400	if !assetNamePat.MatchString(name) {
401		return c.fail(protocol.ExitUsage, "invalid asset name %q: letters, digits, '._+-'; must not start with '.'", name)
402	}
403	dir := assetDir(c.Cfg.Server.Root, repo, rel.ID)
404	if err := os.MkdirAll(dir, 0o750); err != nil {
405		return c.fail(protocol.ExitFailure, "%v", err)
406	}
407	tmp, err := os.CreateTemp(dir, ".upload-*")
408	if err != nil {
409		return c.fail(protocol.ExitFailure, "%v", err)
410	}
411	defer os.Remove(tmp.Name())
412	h := sha256.New()
413	limit := c.Cfg.Limits.MaxAssetBytes
414	n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(c.Stdin, limit+1))
415	if err != nil {
416		return c.fail(protocol.ExitFailure, "reading asset: %v", err)
417	}
418	if n > limit {
419		return c.fail(protocol.ExitUsage, "asset exceeds max_asset_bytes (%d)", limit)
420	}
421	if n == 0 {
422		return c.fail(protocol.ExitUsage, "empty asset: pipe the file on stdin")
423	}
424	if err := tmp.Close(); err != nil {
425		return c.fail(protocol.ExitFailure, "%v", err)
426	}
427	sum := hex.EncodeToString(h.Sum(nil))
428	if err := c.Store.AddReleaseAsset(rel.ID, name, n, sum); err != nil {
429		return c.failErr(err)
430	}
431	if err := os.Rename(tmp.Name(), filepath.Join(dir, name)); err != nil {
432		c.Store.RemoveReleaseAsset(rel.ID, name)
433		return c.fail(protocol.ExitFailure, "%v", err)
434	}
435	return c.emit(assetOut{name, n, sum}, func(w io.Writer) {
436		fmt.Fprintf(w, "uploaded %s (%d bytes, sha256 %s)\n", name, n, sum)
437	})
438}
439
440func runAssetGet(c *Ctx, args []string) int {
441	if len(args) != 3 {
442		return c.usage()
443	}
444	repo, rel, code := releaseRef(c, args[:2], policy.CanRead)
445	if code >= 0 {
446		return code
447	}
448	name := args[2]
449	if !assetNamePat.MatchString(name) {
450		return c.fail(protocol.ExitNotFound, "no asset %q", name)
451	}
452	f, err := os.Open(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
453	if err != nil {
454		return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
455	}
456	defer f.Close()
457	if _, err := io.Copy(c.Stdout, f); err != nil {
458		return protocol.ExitFailure
459	}
460	return protocol.ExitOK
461}
462
463func runAssetRemove(c *Ctx, args []string) int {
464	if len(args) != 3 {
465		return c.usage()
466	}
467	repo, rel, code := releaseRef(c, args[:2], policy.CanWrite)
468	if code >= 0 {
469		return code
470	}
471	if code := refuseArchived(c, repo); code >= 0 {
472		return code
473	}
474	name := args[2]
475	if err := c.Store.RemoveReleaseAsset(rel.ID, name); err != nil {
476		if errors.Is(err, store.ErrNotFound) {
477			return c.fail(protocol.ExitNotFound, "no asset %q on release %s", name, rel.Tag)
478		}
479		return c.fail(protocol.ExitFailure, "%v", err)
480	}
481	if assetNamePat.MatchString(name) {
482		os.Remove(filepath.Join(assetDir(c.Cfg.Server.Root, repo, rel.ID), name))
483	}
484	return c.emit(map[string]string{"removed": name}, func(w io.Writer) {
485		fmt.Fprintf(w, "removed %s\n", name)
486	})
487}