detached fda3c2b

releases_github.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"encoding/json"
8	"fmt"
9	"io"
10	"net/url"
11	"slices"
12	"strconv"
13	"strings"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19// ReleasesGitHub builds the GitHub REST representations of releases. Authorization
20// and all mutations remain in Releases; it owns no metadata or contents.
21type ReleasesGitHub struct {
22	rel              *Releases
23	owner, publicURL string
24}
25
26var _ ports.GitHubReleaseAPI = (*ReleasesGitHub)(nil)
27
28// NewReleasesGitHub wraps the shared release service. publicURL is the external
29// server URL (without the API prefix), used in upload and browser download URLs.
30func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub {
31	return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")}
32}
33
34func (g *ReleasesGitHub) repo(owner, name string) (domain.RepoName, error) {
35	if !domain.OwnerMatches(g.owner, owner) {
36		return domain.RepoName{}, domain.ErrNotFound
37	}
38	rn, err := domain.ParseRepoName(strings.ToLower(name))
39	if err != nil {
40		return domain.RepoName{}, domain.ErrNotFound
41	}
42	return rn, nil
43}
44func (g *ReleasesGitHub) base(repo domain.RepoName) string {
45	return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String())
46}
47func (g *ReleasesGitHub) user(label string) map[string]any {
48	if label == "" || label == "root" || label == "owner" {
49		label = g.owner
50	}
51	u := ghURLs{server: g.publicURL, api: g.publicURL + apiPath}
52	return u.userObj(ghIdentity{login: label, id: stableID("user", label)}, false)
53}
54
55// escapeReleaseTag preserves tag separators: the web routes join the tag path.
56func escapeReleaseTag(tag string) string {
57	parts := strings.Split(tag, "/")
58	for i := range parts {
59		parts[i] = url.PathEscape(parts[i])
60	}
61	return strings.Join(parts, "/")
62}
63func (g *ReleasesGitHub) assetObj(a domain.ReleaseAsset, tag string) ordered {
64	return ordered{
65		{"url", g.base(a.Repo) + "/releases/assets/" + strconv.FormatInt(a.ID, 10)},
66		{"id", a.ID}, {"node_id", ""}, {"name", a.Name}, {"label", a.Label},
67		{"uploader", g.user(a.Uploader)}, {"content_type", a.ContentType}, {"state", "uploaded"},
68		{"size", a.Size}, {"digest", a.Digest}, {"download_count", 0},
69		{"created_at", a.CreatedAt.UTC().Format(timeLayout)}, {"updated_at", a.UpdatedAt.UTC().Format(timeLayout)},
70		{"browser_download_url", g.publicURL + "/" + a.Repo.String() + "/releases/download/" + url.PathEscape(tag) + "/" + url.PathEscape(a.Name)},
71	}
72}
73func (g *ReleasesGitHub) releaseObj(ctx context.Context, p domain.Principal, r domain.Release) (ordered, error) {
74	assets, err := g.rel.ListAssets(ctx, p, r.Repo, r.ID)
75	if err != nil {
76		return nil, err
77	}
78	objects := make([]ordered, 0, len(assets))
79	for _, a := range assets {
80		objects = append(objects, g.assetObj(a, r.Tag))
81	}
82	base := g.base(r.Repo) + "/releases/" + strconv.FormatInt(r.ID, 10)
83	var published any
84	if !r.Draft && !r.PublishedAt.IsZero() {
85		published = r.PublishedAt.UTC().Format(timeLayout)
86	}
87	return ordered{
88		{"url", base}, {"assets_url", base + "/assets"},
89		{"upload_url", g.publicURL + apiPath + "/uploads/repos/" + url.PathEscape(g.owner) + "/" + r.Repo.String() + "/releases/" + strconv.FormatInt(r.ID, 10) + "/assets{?name,label}"},
90		{"html_url", g.publicURL + "/" + r.Repo.String() + "/releases/tag/" + escapeReleaseTag(r.Tag)},
91		{"id", r.ID}, {"author", g.user(r.Author)}, {"node_id", ""}, {"tag_name", r.Tag}, {"target_commitish", r.TargetCommitish},
92		{"name", r.Title}, {"draft", r.Draft}, {"prerelease", r.Prerelease}, {"created_at", r.CreatedAt.UTC().Format(timeLayout)},
93		{"published_at", published}, {"assets", objects}, {"tarball_url", g.base(r.Repo) + "/tarball/" + escapeReleaseTag(r.Tag)},
94		{"zipball_url", nil}, {"body", r.Notes},
95	}, nil
96}
97func (g *ReleasesGitHub) marshalRelease(ctx context.Context, p domain.Principal, r domain.Release) ([]byte, error) {
98	o, err := g.releaseObj(ctx, p, r)
99	if err != nil {
100		return nil, err
101	}
102	return json.Marshal(o)
103}
104func releaseFields(body []byte) (map[string]json.RawMessage, error) {
105	var fields map[string]json.RawMessage
106	if err := json.Unmarshal(body, &fields); err != nil || fields == nil {
107		return nil, fmt.Errorf("%w: JSON object required", domain.ErrInvalid)
108	}
109	return fields, nil
110}
111func releaseField[T any](fields map[string]json.RawMessage, key string) (*T, error) {
112	b, ok := fields[key]
113	if !ok {
114		return nil, nil
115	}
116	var v T
117	if string(b) == "null" || json.Unmarshal(b, &v) != nil {
118		return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, key)
119	}
120	return &v, nil
121}
122func releaseParams(body []byte) (ports.UpdateReleaseParams, error) {
123	var p ports.UpdateReleaseParams
124	f, err := releaseFields(body)
125	if err != nil {
126		return p, err
127	}
128	for key, dst := range map[string]**string{"tag_name": &p.Tag, "target_commitish": &p.TargetCommitish, "name": &p.Title, "body": &p.Notes} {
129		*dst, err = releaseField[string](f, key)
130		if err != nil {
131			return p, err
132		}
133	}
134	for key, dst := range map[string]**bool{"draft": &p.Draft, "prerelease": &p.Prerelease} {
135		*dst, err = releaseField[bool](f, key)
136		if err != nil {
137			return p, err
138		}
139	}
140	latest, err := releaseField[string](f, "make_latest")
141	if err != nil {
142		return p, err
143	}
144	if latest != nil {
145		switch *latest {
146		case "true":
147			v := true
148			p.MakeLatest = &v
149		case "false":
150			v := false
151			p.MakeLatest = &v
152		case "legacy":
153		default:
154			return p, fmt.Errorf("%w: make_latest", domain.ErrInvalid)
155		}
156	}
157	return p, nil
158}
159func value[T any](p *T) (v T) {
160	if p != nil {
161		return *p
162	}
163	return v
164}
165
166// CreateRelease accepts GitHub's create body and returns its representation.
167func (g *ReleasesGitHub) CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error) {
168	rn, err := g.repo(owner, repo)
169	if err != nil {
170		return nil, err
171	}
172	params, err := releaseParams(body)
173	if err != nil {
174		return nil, err
175	}
176	if params.Tag == nil || *params.Tag == "" {
177		return nil, fmt.Errorf("%w: tag_name", domain.ErrInvalid)
178	}
179	r, err := g.rel.CreateRelease(ctx, p, rn, ports.CreateReleaseParams{Tag: *params.Tag, TargetCommitish: value(params.TargetCommitish), Title: value(params.Title), Notes: value(params.Notes), Draft: value(params.Draft), Prerelease: value(params.Prerelease), MakeLatest: value(params.MakeLatest)})
180	if err != nil {
181		return nil, err
182	}
183	ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", r.ID).Msg("github release created")
184	return g.marshalRelease(ctx, p, r)
185}
186
187// GetRelease resolves an id, tag or latest selector.
188func (g *ReleasesGitHub) GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ports.ReleaseRef) ([]byte, error) {
189	rn, err := g.repo(owner, repo)
190	if err != nil {
191		return nil, err
192	}
193	r, err := g.rel.GetRelease(ctx, p, rn, ref)
194	if err != nil {
195		return nil, err
196	}
197	return g.marshalRelease(ctx, p, r)
198}
199
200// UpdateRelease changes only present fields, including explicit false and empty strings.
201func (g *ReleasesGitHub) UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) {
202	rn, err := g.repo(owner, repo)
203	if err != nil {
204		return nil, err
205	}
206	params, err := releaseParams(body)
207	if err != nil {
208		return nil, err
209	}
210	r, err := g.rel.UpdateRelease(ctx, p, rn, id, params)
211	if err != nil {
212		return nil, err
213	}
214	ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release updated")
215	return g.marshalRelease(ctx, p, r)
216}
217
218// DeleteRelease removes a release and all its assets.
219func (g *ReleasesGitHub) DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error {
220	rn, err := g.repo(owner, repo)
221	if err != nil {
222		return err
223	}
224	if err := g.rel.DeleteRelease(ctx, p, rn, id); err != nil {
225		return err
226	}
227	ctxLog(ctx).Info().Str("repo", rn.String()).Int64("release", id).Msg("github release deleted")
228	return nil
229}
230func releasePagination(page, perPage int) (int, int) {
231	if page < 1 {
232		page = 1
233	}
234	if perPage < 1 {
235		perPage = 30
236	}
237	return page, min(perPage, 100)
238}
239func releasePageBounds(page, perPage, total int) (int, int) {
240	// Avoid overflow on arbitrary query parameters.
241	if page-1 > total/perPage {
242		return total, total
243	}
244	start := min((page-1)*perPage, total)
245	return start, min(start+perPage, total)
246}
247
248// ListReleases returns visible releases newest first and GitHub Link pagination.
249func (g *ReleasesGitHub) ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) ([]byte, string, error) {
250	rn, err := g.repo(owner, repo)
251	if err != nil {
252		return nil, "", err
253	}
254	if _, err = g.rel.readable(ctx, p, rn); err != nil {
255		return nil, "", err
256	}
257	all, err := g.rel.store.ListReleases(ctx, rn)
258	if err != nil {
259		return nil, "", err
260	}
261	all = slices.DeleteFunc(all, func(r domain.Release) bool { return r.Draft && !releaseWriter(p, rn) })
262	slices.SortFunc(all, func(a, b domain.Release) int {
263		if c := b.CreatedAt.Compare(a.CreatedAt); c != 0 {
264			return c
265		}
266		if a.ID > b.ID {
267			return -1
268		}
269		if a.ID < b.ID {
270			return 1
271		}
272		return 0
273	})
274	page, perPage = releasePagination(page, perPage)
275	start, end := releasePageBounds(page, perPage, len(all))
276	objects := make([]ordered, 0, end-start)
277	for _, r := range all[start:end] {
278		o, e := g.releaseObj(ctx, p, r)
279		if e != nil {
280			return nil, "", e
281		}
282		objects = append(objects, o)
283	}
284	b, err := json.Marshal(objects)
285	return b, pullFilesLink(g.base(rn)+"/releases", page, perPage, len(all)), err
286}
287
288// ListAssets lists uploaded assets with the same pagination conventions.
289func (g *ReleasesGitHub) ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) ([]byte, string, error) {
290	rn, err := g.repo(owner, repo)
291	if err != nil {
292		return nil, "", err
293	}
294	r, err := g.rel.GetRelease(ctx, p, rn, ports.ReleaseRef{ID: id})
295	if err != nil {
296		return nil, "", err
297	}
298	all, err := g.rel.ListAssets(ctx, p, rn, id)
299	if err != nil {
300		return nil, "", err
301	}
302	page, perPage = releasePagination(page, perPage)
303	start, end := releasePageBounds(page, perPage, len(all))
304	objects := make([]ordered, 0, end-start)
305	for _, a := range all[start:end] {
306		objects = append(objects, g.assetObj(a, r.Tag))
307	}
308	b, err := json.Marshal(objects)
309	return b, pullFilesLink(g.base(rn)+"/releases/"+strconv.FormatInt(id, 10)+"/assets", page, perPage, len(all)), err
310}
311func (g *ReleasesGitHub) marshalAsset(ctx context.Context, p domain.Principal, a domain.ReleaseAsset) ([]byte, error) {
312	r, err := g.rel.GetRelease(ctx, p, a.Repo, ports.ReleaseRef{ID: a.ReleaseID})
313	if err != nil {
314		return nil, err
315	}
316	return json.Marshal(g.assetObj(a, r.Tag))
317}
318
319// UploadAsset streams an exact-sized raw body; duplicate names are not replaced.
320func (g *ReleasesGitHub) UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, body io.Reader) ([]byte, error) {
321	rn, err := g.repo(owner, repo)
322	if err != nil {
323		return nil, err
324	}
325	a, err := g.rel.UploadAsset(ctx, p, rn, ports.UploadAssetParams{ReleaseID: id, Name: name, Label: label, ContentType: contentType, Size: size}, body)
326	if err != nil {
327		return nil, err
328	}
329	return g.marshalAsset(ctx, p, a)
330}
331
332// AssetByID resolves a visible asset, including its repository ownership.
333func (g *ReleasesGitHub) AssetByID(ctx context.Context, p domain.Principal, owner, repo string, id int64) (domain.ReleaseAsset, error) {
334	rn, err := g.repo(owner, repo)
335	if err != nil {
336		return domain.ReleaseAsset{}, err
337	}
338	return g.rel.GetAsset(ctx, p, rn, ports.AssetRef{ID: id})
339}
340
341// GetAsset returns the JSON representation, not the contents.
342func (g *ReleasesGitHub) GetAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) ([]byte, error) {
343	a, err := g.AssetByID(ctx, p, owner, repo, id)
344	if err != nil {
345		return nil, err
346	}
347	return g.marshalAsset(ctx, p, a)
348}
349
350// UpdateAsset changes the name and label fields that are present.
351func (g *ReleasesGitHub) UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error) {
352	rn, err := g.repo(owner, repo)
353	if err != nil {
354		return nil, err
355	}
356	f, err := releaseFields(body)
357	if err != nil {
358		return nil, err
359	}
360	name, err := releaseField[string](f, "name")
361	if err != nil {
362		return nil, err
363	}
364	label, err := releaseField[string](f, "label")
365	if err != nil {
366		return nil, err
367	}
368	a, err := g.rel.UpdateAsset(ctx, p, rn, id, ports.UpdateAssetParams{Name: name, Label: label})
369	if err != nil {
370		return nil, err
371	}
372	ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset updated")
373	return g.marshalAsset(ctx, p, a)
374}
375
376// DeleteAsset removes the asset record and its blob.
377func (g *ReleasesGitHub) DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64) error {
378	rn, err := g.repo(owner, repo)
379	if err != nil {
380		return err
381	}
382	if err := g.rel.DeleteAsset(ctx, p, rn, id); err != nil {
383		return err
384	}
385	ctxLog(ctx).Info().Str("repo", rn.String()).Int64("asset", id).Msg("github release asset deleted")
386	return nil
387}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close