detached f67dc6a

feat(githubapi): GitHub-compatible REST API for releases and assets

commit
f67dc6a65ee597f5eb612c0f943afc21ef864279
author
brice · · 2026-10-21 15:13 UTC
committer
brice · · 2026-10-21 17:53 UTC
parent
eab40ee
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +2818 -2
M internal/adapters/githubapi/handler.go +18 -2
@@ -68,6 +68,10 @@ const (
6868 type Config struct {
6969 Auth ports.AuthService
7070 API ports.GitHubAPIService
71+ // Releases and ReleaseDownload enable the release REST routes. Nil services
72+ // leave their routes unavailable with GitHub's 404 JSON.
73+ Releases ports.GitHubReleaseAPI
74+ ReleaseDownload ports.ReleaseService
7175 // TrustedProxies are the networks allowed to set X-Forwarded-For.
7276 TrustedProxies []netip.Prefix
7377 // Limiter counts failed authentications; nil builds a default one.
@@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
124128 notFound(w)
125129 return
126130 }
127- if r.Method != http.MethodGet && r.Method != http.MethodHead {
128- w.Header().Set("Allow", "GET, HEAD")
131+ if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil {
132+ notFound(w)
133+ return
134+ }
135+ allowed := releaseMethods(kind)
136+ if !methodAllowed(r.Method, allowed) {
137+ w.Header().Set("Allow", allowed)
129138 jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
130139 return
131140 }
@@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148157 return
149158 }
150159
160+ if strings.HasPrefix(kind, "release") {
161+ h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr))
162+ return
163+ }
151164 switch kind {
152165 case "repo":
153166 body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
@@ -172,6 +185,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172185
173186 // route recognises the endpoints. ref is "" for /tarball.
174187 func route(rest string) (kind, owner, repo, ref string, ok bool) {
188+ if kind, owner, repo, ref, ok = releaseRoute(rest); ok {
189+ return
190+ }
175191 parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
176192 if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
177193 return "", "", "", "", false
@@ -68,6 +68,10 @@ const (
68 type Config struct {68 type Config struct {
69 Auth ports.AuthService69 Auth ports.AuthService
70 API ports.GitHubAPIService70 API ports.GitHubAPIService
71+ // Releases and ReleaseDownload enable the release REST routes. Nil services
72+ // leave their routes unavailable with GitHub's 404 JSON.
73+ Releases ports.GitHubReleaseAPI
74+ ReleaseDownload ports.ReleaseService
71 // TrustedProxies are the networks allowed to set X-Forwarded-For.75 // TrustedProxies are the networks allowed to set X-Forwarded-For.
72 TrustedProxies []netip.Prefix76 TrustedProxies []netip.Prefix
73 // Limiter counts failed authentications; nil builds a default one.77 // Limiter counts failed authentications; nil builds a default one.
@@ -124,8 +128,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
124 notFound(w)128 notFound(w)
125 return129 return
126 }130 }
127- if r.Method != http.MethodGet && r.Method != http.MethodHead {131+ if strings.HasPrefix(kind, "release") && h.cfg.Releases == nil {
128- w.Header().Set("Allow", "GET, HEAD")132+ notFound(w)
133+ return
134+ }
135+ allowed := releaseMethods(kind)
136+ if !methodAllowed(r.Method, allowed) {
137+ w.Header().Set("Allow", allowed)
129 jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")138 jsonError(w, http.StatusMethodNotAllowed, "Method Not Allowed")
130 return139 return
131 }140 }
@@ -148,6 +157,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
148 return157 return
149 }158 }
150 159
160+ if strings.HasPrefix(kind, "release") {
161+ h.releaseRequest(w, r, p, kind, owner, repo, ref, clientlimit.Key(addr))
162+ return
163+ }
151 switch kind {164 switch kind {
152 case "repo":165 case "repo":
153 body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)166 body, err := h.cfg.API.GetRepo(ctx, p, owner, repo)
@@ -172,6 +185,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
172 185
173 // route recognises the endpoints. ref is "" for /tarball.186 // route recognises the endpoints. ref is "" for /tarball.
174 func route(rest string) (kind, owner, repo, ref string, ok bool) {187 func route(rest string) (kind, owner, repo, ref string, ok bool) {
188+ if kind, owner, repo, ref, ok = releaseRoute(rest); ok {
189+ return
190+ }
175 parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")191 parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
176 if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {192 if len(parts) < 3 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" {
177 return "", "", "", "", false193 return "", "", "", "", false
A internal/adapters/githubapi/releases.go +444 -0
@@ -0,0 +1,444 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "mime"
11+ "net/http"
12+ "strconv"
13+ "strings"
14+ "unicode"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "github.com/bnema/zerowrap"
19+)
20+
21+func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) {
22+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
23+ upload := len(parts) > 0 && parts[0] == "uploads"
24+ if upload {
25+ parts = parts[1:]
26+ }
27+ if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" {
28+ return
29+ }
30+ owner, repo = parts[1], parts[2]
31+ if upload {
32+ if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" {
33+ return "release-upload", owner, repo, parts[4], true
34+ }
35+ return
36+ }
37+ switch {
38+ case len(parts) == 4:
39+ return "release-list", owner, repo, "", true
40+ case len(parts) == 5 && parts[4] == "latest":
41+ return "release-latest", owner, repo, "", true
42+ case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "":
43+ return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true
44+ case len(parts) == 5 && isNumber(parts[4]):
45+ return "release-id", owner, repo, parts[4], true
46+ case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets":
47+ return "release-assets", owner, repo, parts[4], true
48+ case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]):
49+ return "release-asset", owner, repo, parts[5], true
50+ }
51+ return
52+}
53+func releaseMethods(kind string) string {
54+ switch kind {
55+ case "release-list":
56+ return "GET, HEAD, POST"
57+ case "release-id", "release-asset":
58+ return "GET, HEAD, PATCH, DELETE"
59+ case "release-upload":
60+ return "POST"
61+ default:
62+ return "GET, HEAD"
63+ }
64+}
65+func methodAllowed(method, allow string) bool {
66+ for _, m := range strings.Split(allow, ", ") {
67+ if method == m {
68+ return true
69+ }
70+ }
71+ return false
72+}
73+func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) {
74+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
75+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
76+ w.WriteHeader(status)
77+ if r.Method != http.MethodHead {
78+ _, _ = w.Write(body)
79+ }
80+}
81+func releaseMessage(w http.ResponseWriter, status int, message string) {
82+ b, _ := json.Marshal(struct {
83+ Message string `json:"message"`
84+ }{message})
85+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
86+ w.WriteHeader(status)
87+ _, _ = w.Write(b)
88+}
89+func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
90+ b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
91+ if err != nil {
92+ var tooLarge *http.MaxBytesError
93+ if errors.As(err, &tooLarge) {
94+ releaseMessage(w, 422, "Body too large")
95+ } else {
96+ releaseMessage(w, 422, "Validation Failed")
97+ }
98+ return nil, false
99+ }
100+ return b, true
101+}
102+func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) {
103+ ctx := r.Context()
104+ api := h.cfg.Releases
105+ id, _ := strconv.ParseInt(ref, 10, 64)
106+ q := r.URL.Query()
107+ page, _ := strconv.Atoi(q.Get("page"))
108+ perPage, _ := strconv.Atoi(q.Get("per_page"))
109+ var body []byte
110+ var err error
111+ var link string
112+ status := http.StatusOK
113+ if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") {
114+ if h.cfg.ReleaseDownload == nil {
115+ notFound(w)
116+ return
117+ }
118+ h.downloadRelease(w, r, p, owner, repo, id, client)
119+ return
120+ }
121+ switch kind {
122+ case "release-list":
123+ if r.Method == http.MethodPost {
124+ b, ok := releaseBody(w, r)
125+ if !ok {
126+ return
127+ }
128+ body, err = api.CreateRelease(ctx, p, owner, repo, b)
129+ status = 201
130+ } else {
131+ body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage)
132+ }
133+ case "release-latest":
134+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true})
135+ case "release-tag":
136+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref})
137+ case "release-id":
138+ switch r.Method {
139+ case http.MethodPatch:
140+ b, ok := releaseBody(w, r)
141+ if !ok {
142+ return
143+ }
144+ body, err = api.UpdateRelease(ctx, p, owner, repo, id, b)
145+ case http.MethodDelete:
146+ err = api.DeleteRelease(ctx, p, owner, repo, id)
147+ status = 204
148+ default:
149+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id})
150+ }
151+ case "release-assets":
152+ body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage)
153+ case "release-upload":
154+ if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") {
155+ releaseMessage(w, 411, "Length Required")
156+ return
157+ }
158+ if q.Get("name") == "" {
159+ h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name")
160+ return
161+ }
162+ ct := r.Header.Get("Content-Type")
163+ if ct == "" {
164+ ct = "application/octet-stream"
165+ }
166+ body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength))
167+ status = 201
168+ case "release-asset":
169+ switch r.Method {
170+ case http.MethodPatch:
171+ b, ok := releaseBody(w, r)
172+ if !ok {
173+ return
174+ }
175+ body, err = api.UpdateAsset(ctx, p, owner, repo, id, b)
176+ case http.MethodDelete:
177+ err = api.DeleteAsset(ctx, p, owner, repo, id)
178+ status = 204
179+ default:
180+ body, err = api.GetAsset(ctx, p, owner, repo, id)
181+ }
182+ }
183+ if err != nil {
184+ h.failRelease(ctx, w, p, err, kind, "")
185+ return
186+ }
187+ if link != "" {
188+ w.Header().Set("Link", link)
189+ }
190+ if status == 204 {
191+ w.WriteHeader(status)
192+ return
193+ }
194+ releaseJSON(w, r, status, body)
195+}
196+
197+// failRelease translates use-case failures to GitHub's status and error JSON;
198+// authorization and limits have already been decided by ReleaseService.
199+//
200+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here
201+func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) {
202+ switch {
203+ case errors.Is(err, domain.ErrNotFound):
204+ notFound(w)
205+ case errors.Is(err, domain.ErrUnauthenticated):
206+ releaseMessage(w, 401, "Bad credentials")
207+ case errors.Is(err, domain.ErrForbidden):
208+ msg := "Forbidden"
209+ if p.ViaJobToken() {
210+ msg = "Resource not accessible by integration"
211+ }
212+ releaseMessage(w, 403, msg)
213+ case errors.Is(err, domain.ErrResourceExhausted):
214+ if strings.Contains(err.Error(), "too many uploads in progress") {
215+ w.Header().Set("Retry-After", retryAfter)
216+ releaseMessage(w, 429, err.Error())
217+ } else {
218+ releaseMessage(w, 422, err.Error())
219+ }
220+ case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed):
221+ writeReleaseValidation(w, err, kind, field)
222+ case errors.Is(err, context.Canceled):
223+ default:
224+ log := zerowrap.FromCtx(ctx)
225+ log.Error().Err(err).Msg("github release request failed")
226+ releaseMessage(w, 500, "Server Error")
227+ }
228+}
229+
230+type releaseValidationError struct {
231+ Resource string `json:"resource"`
232+ Code string `json:"code"`
233+ Field string `json:"field,omitempty"`
234+}
235+
236+func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) {
237+ resource, code, doc := "Release", "invalid", ""
238+ if errors.Is(err, domain.ErrAlreadyExists) {
239+ code = "already_exists"
240+ field = "tag_name"
241+ if kind == "release-upload" || kind == "release-asset" {
242+ resource = "ReleaseAsset"
243+ field = "name"
244+ }
245+ if kind == "release-upload" {
246+ doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset"
247+ }
248+ } else if errors.Is(err, domain.ErrPreconditionFailed) {
249+ field = "target_commitish"
250+ } else if field == "" {
251+ field = releaseErrorField(err.Error())
252+ }
253+ b, _ := json.Marshal(struct {
254+ Message string `json:"message"`
255+ Errors []releaseValidationError `json:"errors"`
256+ DocumentationURL string `json:"documentation_url,omitempty"`
257+ }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc})
258+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
259+ w.WriteHeader(422)
260+ _, _ = w.Write(b)
261+}
262+
263+// releaseErrorField recognises the wire fields and domain validation vocabulary.
264+// Errors without a named field omit it rather than guessing.
265+func releaseErrorField(text string) string {
266+ for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} {
267+ if strings.Contains(text, f) {
268+ return f
269+ }
270+ }
271+ if strings.Contains(text, "tag name") {
272+ return "tag_name"
273+ }
274+ if strings.Contains(text, "media type") {
275+ return "content_type"
276+ }
277+ if strings.Contains(text, "name") {
278+ return "name"
279+ }
280+ return ""
281+}
282+
283+// assetRange returns a single byte range. Multiple ranges and unknown units are
284+// ignored. Unsatisfiable or malformed byte ranges return false.
285+func assetRange(header string, size int64) (offset, length int64, partial, ok bool) {
286+ if header == "" || !strings.HasPrefix(header, "bytes=") || strings.Contains(header, ",") {
287+ return 0, size, false, true
288+ }
289+ spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes="))
290+ a, b, found := strings.Cut(spec, "-")
291+ if !found || size <= 0 {
292+ return 0, 0, false, false
293+ }
294+ if a == "" {
295+ n, err := strconv.ParseInt(b, 10, 64)
296+ if err != nil || n <= 0 {
297+ return 0, 0, false, false
298+ }
299+ n = min(n, size)
300+ return size - n, n, true, true
301+ }
302+ start, err := strconv.ParseInt(a, 10, 64)
303+ if err != nil || start < 0 || start >= size {
304+ return 0, 0, false, false
305+ }
306+ end := size - 1
307+ if b != "" {
308+ end, err = strconv.ParseInt(b, 10, 64)
309+ if err != nil || end < start {
310+ return 0, 0, false, false
311+ }
312+ end = min(end, size-1)
313+ }
314+ return start, end - start + 1, true, true
315+}
316+func releaseContentDisposition(name string) string {
317+ name = strings.Map(func(r rune) rune {
318+ if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar {
319+ return -1
320+ }
321+ return r
322+ }, name)
323+ if name == "" || name == "." || name == ".." {
324+ name = "asset"
325+ }
326+ ascii := true
327+ fallback := strings.Map(func(r rune) rune {
328+ if r > unicode.MaxASCII {
329+ ascii = false
330+ return '_'
331+ }
332+ return r
333+ }, name)
334+ // Quote even token-safe names, keeping a consistent RFC 6266 representation.
335+ cd := "attachment; filename=" + strconv.Quote(fallback)
336+ if !ascii {
337+ const digits = "0123456789ABCDEF"
338+ var b strings.Builder
339+ for i := 0; i < len(name); i++ {
340+ c := name[i]
341+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
342+ b.WriteByte(c)
343+ } else {
344+ b.WriteByte('%')
345+ b.WriteByte(digits[c>>4])
346+ b.WriteByte(digits[c&15])
347+ }
348+ }
349+ cd += "; filename*=UTF-8''" + b.String()
350+ }
351+ return cd
352+}
353+func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string {
354+ ct := a.ContentType
355+ media, _, err := mime.ParseMediaType(ct)
356+ media = strings.ToLower(media)
357+ if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" {
358+ ct = "application/octet-stream"
359+ }
360+ w.Header().Set("Content-Type", ct)
361+ w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name))
362+ w.Header().Set("X-Content-Type-Options", "nosniff")
363+ w.Header().Set("Content-Security-Policy", "sandbox")
364+ w.Header().Set("Accept-Ranges", "bytes")
365+ etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"`
366+ w.Header().Set("ETag", etag)
367+ w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat))
368+ return etag
369+}
370+func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) {
371+ ctx := r.Context()
372+ a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id)
373+ if err != nil {
374+ h.failRelease(ctx, w, p, err, "release-asset", "")
375+ return
376+ }
377+ if r.Method != http.MethodHead {
378+ release, ok := h.acquire(w, client)
379+ if !ok {
380+ return
381+ }
382+ defer release()
383+ }
384+ etag := setReleaseDownloadHeaders(w, a)
385+ if r.Header.Get("If-None-Match") == etag {
386+ w.WriteHeader(304)
387+ return
388+ }
389+ offset, length, partial, ok := assetRange(r.Header.Get("Range"), a.Size)
390+ if !ok {
391+ w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
392+ w.WriteHeader(416)
393+ return
394+ }
395+ status := 200
396+ if partial {
397+ status = 206
398+ w.Header().Set("Content-Range", "bytes "+strconv.FormatInt(offset, 10)+"-"+strconv.FormatInt(offset+length-1, 10)+"/"+strconv.FormatInt(a.Size, 10))
399+ }
400+ w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
401+ if r.Method == http.MethodHead {
402+ w.WriteHeader(status)
403+ return
404+ }
405+ // Slot failures and pre-stream failures are JSON, not a binary response.
406+ clearHeaders := func() {
407+ for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} {
408+ w.Header().Del(k)
409+ }
410+ }
411+ writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle}
412+ started := false
413+ err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error {
414+ if len(chunk.Data) == 0 {
415+ return nil
416+ }
417+ if !started {
418+ w.WriteHeader(status)
419+ started = true
420+ }
421+ _, e := writer.Write(chunk.Data)
422+ return e
423+ })
424+ // BlobStore.Open reports offset >= size as out of range, including the
425+ // valid empty object at offset zero. It has still checked existence.
426+ if err == nil || (a.Size == 0 && errors.Is(err, domain.ErrOutOfRange)) {
427+ if !started {
428+ w.WriteHeader(status)
429+ }
430+ return
431+ }
432+ if started {
433+ log := zerowrap.FromCtx(ctx)
434+ log.Warn().Err(err).Msg("github release download cut short")
435+ panic(http.ErrAbortHandler)
436+ }
437+ clearHeaders()
438+ if errors.Is(err, domain.ErrOutOfRange) {
439+ w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
440+ w.WriteHeader(416)
441+ return
442+ }
443+ h.failRelease(ctx, w, p, err, "release-asset", "")
444+}
@@ -0,0 +1,444 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "io"
10+ "mime"
11+ "net/http"
12+ "strconv"
13+ "strings"
14+ "unicode"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "github.com/bnema/zerowrap"
19+)
20+
21+func releaseRoute(rest string) (kind, owner, repo, ref string, ok bool) {
22+ parts := strings.Split(strings.TrimPrefix(rest, "/"), "/")
23+ upload := len(parts) > 0 && parts[0] == "uploads"
24+ if upload {
25+ parts = parts[1:]
26+ }
27+ if len(parts) < 4 || parts[0] != "repos" || parts[1] == "" || parts[2] == "" || parts[3] != "releases" {
28+ return
29+ }
30+ owner, repo = parts[1], parts[2]
31+ if upload {
32+ if len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets" {
33+ return "release-upload", owner, repo, parts[4], true
34+ }
35+ return
36+ }
37+ switch {
38+ case len(parts) == 4:
39+ return "release-list", owner, repo, "", true
40+ case len(parts) == 5 && parts[4] == "latest":
41+ return "release-latest", owner, repo, "", true
42+ case len(parts) >= 6 && parts[4] == "tags" && strings.Join(parts[5:], "/") != "":
43+ return "release-tag", owner, repo, strings.Join(parts[5:], "/"), true
44+ case len(parts) == 5 && isNumber(parts[4]):
45+ return "release-id", owner, repo, parts[4], true
46+ case len(parts) == 6 && isNumber(parts[4]) && parts[5] == "assets":
47+ return "release-assets", owner, repo, parts[4], true
48+ case len(parts) == 6 && parts[4] == "assets" && isNumber(parts[5]):
49+ return "release-asset", owner, repo, parts[5], true
50+ }
51+ return
52+}
53+func releaseMethods(kind string) string {
54+ switch kind {
55+ case "release-list":
56+ return "GET, HEAD, POST"
57+ case "release-id", "release-asset":
58+ return "GET, HEAD, PATCH, DELETE"
59+ case "release-upload":
60+ return "POST"
61+ default:
62+ return "GET, HEAD"
63+ }
64+}
65+func methodAllowed(method, allow string) bool {
66+ for _, m := range strings.Split(allow, ", ") {
67+ if method == m {
68+ return true
69+ }
70+ }
71+ return false
72+}
73+func releaseJSON(w http.ResponseWriter, r *http.Request, status int, body []byte) {
74+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
75+ w.Header().Set("Content-Length", strconv.Itoa(len(body)))
76+ w.WriteHeader(status)
77+ if r.Method != http.MethodHead {
78+ _, _ = w.Write(body)
79+ }
80+}
81+func releaseMessage(w http.ResponseWriter, status int, message string) {
82+ b, _ := json.Marshal(struct {
83+ Message string `json:"message"`
84+ }{message})
85+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
86+ w.WriteHeader(status)
87+ _, _ = w.Write(b)
88+}
89+func releaseBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
90+ b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
91+ if err != nil {
92+ var tooLarge *http.MaxBytesError
93+ if errors.As(err, &tooLarge) {
94+ releaseMessage(w, 422, "Body too large")
95+ } else {
96+ releaseMessage(w, 422, "Validation Failed")
97+ }
98+ return nil, false
99+ }
100+ return b, true
101+}
102+func (h *Handler) releaseRequest(w http.ResponseWriter, r *http.Request, p domain.Principal, kind, owner, repo, ref, client string) {
103+ ctx := r.Context()
104+ api := h.cfg.Releases
105+ id, _ := strconv.ParseInt(ref, 10, 64)
106+ q := r.URL.Query()
107+ page, _ := strconv.Atoi(q.Get("page"))
108+ perPage, _ := strconv.Atoi(q.Get("per_page"))
109+ var body []byte
110+ var err error
111+ var link string
112+ status := http.StatusOK
113+ if kind == "release-asset" && (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/octet-stream") {
114+ if h.cfg.ReleaseDownload == nil {
115+ notFound(w)
116+ return
117+ }
118+ h.downloadRelease(w, r, p, owner, repo, id, client)
119+ return
120+ }
121+ switch kind {
122+ case "release-list":
123+ if r.Method == http.MethodPost {
124+ b, ok := releaseBody(w, r)
125+ if !ok {
126+ return
127+ }
128+ body, err = api.CreateRelease(ctx, p, owner, repo, b)
129+ status = 201
130+ } else {
131+ body, link, err = api.ListReleases(ctx, p, owner, repo, page, perPage)
132+ }
133+ case "release-latest":
134+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Latest: true})
135+ case "release-tag":
136+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{Tag: ref})
137+ case "release-id":
138+ switch r.Method {
139+ case http.MethodPatch:
140+ b, ok := releaseBody(w, r)
141+ if !ok {
142+ return
143+ }
144+ body, err = api.UpdateRelease(ctx, p, owner, repo, id, b)
145+ case http.MethodDelete:
146+ err = api.DeleteRelease(ctx, p, owner, repo, id)
147+ status = 204
148+ default:
149+ body, err = api.GetRelease(ctx, p, owner, repo, ports.ReleaseRef{ID: id})
150+ }
151+ case "release-assets":
152+ body, link, err = api.ListAssets(ctx, p, owner, repo, id, page, perPage)
153+ case "release-upload":
154+ if r.ContentLength < 0 || (r.ContentLength == 0 && r.Header.Get("Content-Length") == "") {
155+ releaseMessage(w, 411, "Length Required")
156+ return
157+ }
158+ if q.Get("name") == "" {
159+ h.failRelease(ctx, w, p, domain.ErrInvalid, "release-upload", "name")
160+ return
161+ }
162+ ct := r.Header.Get("Content-Type")
163+ if ct == "" {
164+ ct = "application/octet-stream"
165+ }
166+ body, err = api.UploadAsset(ctx, p, owner, repo, id, q.Get("name"), q.Get("label"), ct, r.ContentLength, io.LimitReader(r.Body, r.ContentLength))
167+ status = 201
168+ case "release-asset":
169+ switch r.Method {
170+ case http.MethodPatch:
171+ b, ok := releaseBody(w, r)
172+ if !ok {
173+ return
174+ }
175+ body, err = api.UpdateAsset(ctx, p, owner, repo, id, b)
176+ case http.MethodDelete:
177+ err = api.DeleteAsset(ctx, p, owner, repo, id)
178+ status = 204
179+ default:
180+ body, err = api.GetAsset(ctx, p, owner, repo, id)
181+ }
182+ }
183+ if err != nil {
184+ h.failRelease(ctx, w, p, err, kind, "")
185+ return
186+ }
187+ if link != "" {
188+ w.Header().Set("Link", link)
189+ }
190+ if status == 204 {
191+ w.WriteHeader(status)
192+ return
193+ }
194+ releaseJSON(w, r, status, body)
195+}
196+
197+// failRelease translates use-case failures to GitHub's status and error JSON;
198+// authorization and limits have already been decided by ReleaseService.
199+//
200+//hexcheck:ignore suspicious-business-logic-in-adapter maps domain failures and the principal kind to the GitHub HTTP error contract; no access or quota decision is made here
201+func (h *Handler) failRelease(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error, kind, field string) {
202+ switch {
203+ case errors.Is(err, domain.ErrNotFound):
204+ notFound(w)
205+ case errors.Is(err, domain.ErrUnauthenticated):
206+ releaseMessage(w, 401, "Bad credentials")
207+ case errors.Is(err, domain.ErrForbidden):
208+ msg := "Forbidden"
209+ if p.ViaJobToken() {
210+ msg = "Resource not accessible by integration"
211+ }
212+ releaseMessage(w, 403, msg)
213+ case errors.Is(err, domain.ErrResourceExhausted):
214+ if strings.Contains(err.Error(), "too many uploads in progress") {
215+ w.Header().Set("Retry-After", retryAfter)
216+ releaseMessage(w, 429, err.Error())
217+ } else {
218+ releaseMessage(w, 422, err.Error())
219+ }
220+ case errors.Is(err, domain.ErrAlreadyExists), domain.IsInvalid(err), errors.Is(err, domain.ErrPreconditionFailed):
221+ writeReleaseValidation(w, err, kind, field)
222+ case errors.Is(err, context.Canceled):
223+ default:
224+ log := zerowrap.FromCtx(ctx)
225+ log.Error().Err(err).Msg("github release request failed")
226+ releaseMessage(w, 500, "Server Error")
227+ }
228+}
229+
230+type releaseValidationError struct {
231+ Resource string `json:"resource"`
232+ Code string `json:"code"`
233+ Field string `json:"field,omitempty"`
234+}
235+
236+func writeReleaseValidation(w http.ResponseWriter, err error, kind, field string) {
237+ resource, code, doc := "Release", "invalid", ""
238+ if errors.Is(err, domain.ErrAlreadyExists) {
239+ code = "already_exists"
240+ field = "tag_name"
241+ if kind == "release-upload" || kind == "release-asset" {
242+ resource = "ReleaseAsset"
243+ field = "name"
244+ }
245+ if kind == "release-upload" {
246+ doc = "https://docs.github.com/rest/releases/assets#upload-a-release-asset"
247+ }
248+ } else if errors.Is(err, domain.ErrPreconditionFailed) {
249+ field = "target_commitish"
250+ } else if field == "" {
251+ field = releaseErrorField(err.Error())
252+ }
253+ b, _ := json.Marshal(struct {
254+ Message string `json:"message"`
255+ Errors []releaseValidationError `json:"errors"`
256+ DocumentationURL string `json:"documentation_url,omitempty"`
257+ }{"Validation Failed", []releaseValidationError{{resource, code, field}}, doc})
258+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
259+ w.WriteHeader(422)
260+ _, _ = w.Write(b)
261+}
262+
263+// releaseErrorField recognises the wire fields and domain validation vocabulary.
264+// Errors without a named field omit it rather than guessing.
265+func releaseErrorField(text string) string {
266+ for _, f := range []string{"target_commitish", "make_latest", "tag_name", "prerelease", "draft", "content_type", "label", "body"} {
267+ if strings.Contains(text, f) {
268+ return f
269+ }
270+ }
271+ if strings.Contains(text, "tag name") {
272+ return "tag_name"
273+ }
274+ if strings.Contains(text, "media type") {
275+ return "content_type"
276+ }
277+ if strings.Contains(text, "name") {
278+ return "name"
279+ }
280+ return ""
281+}
282+
283+// assetRange returns a single byte range. Multiple ranges and unknown units are
284+// ignored. Unsatisfiable or malformed byte ranges return false.
285+func assetRange(header string, size int64) (offset, length int64, partial, ok bool) {
286+ if header == "" || !strings.HasPrefix(header, "bytes=") || strings.Contains(header, ",") {
287+ return 0, size, false, true
288+ }
289+ spec := strings.TrimSpace(strings.TrimPrefix(header, "bytes="))
290+ a, b, found := strings.Cut(spec, "-")
291+ if !found || size <= 0 {
292+ return 0, 0, false, false
293+ }
294+ if a == "" {
295+ n, err := strconv.ParseInt(b, 10, 64)
296+ if err != nil || n <= 0 {
297+ return 0, 0, false, false
298+ }
299+ n = min(n, size)
300+ return size - n, n, true, true
301+ }
302+ start, err := strconv.ParseInt(a, 10, 64)
303+ if err != nil || start < 0 || start >= size {
304+ return 0, 0, false, false
305+ }
306+ end := size - 1
307+ if b != "" {
308+ end, err = strconv.ParseInt(b, 10, 64)
309+ if err != nil || end < start {
310+ return 0, 0, false, false
311+ }
312+ end = min(end, size-1)
313+ }
314+ return start, end - start + 1, true, true
315+}
316+func releaseContentDisposition(name string) string {
317+ name = strings.Map(func(r rune) rune {
318+ if unicode.IsControl(r) || r == '"' || r == '\\' || r == '/' || r == unicode.ReplacementChar {
319+ return -1
320+ }
321+ return r
322+ }, name)
323+ if name == "" || name == "." || name == ".." {
324+ name = "asset"
325+ }
326+ ascii := true
327+ fallback := strings.Map(func(r rune) rune {
328+ if r > unicode.MaxASCII {
329+ ascii = false
330+ return '_'
331+ }
332+ return r
333+ }, name)
334+ // Quote even token-safe names, keeping a consistent RFC 6266 representation.
335+ cd := "attachment; filename=" + strconv.Quote(fallback)
336+ if !ascii {
337+ const digits = "0123456789ABCDEF"
338+ var b strings.Builder
339+ for i := 0; i < len(name); i++ {
340+ c := name[i]
341+ if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.IndexByte("!#$&+-.^_`|~", c) >= 0 {
342+ b.WriteByte(c)
343+ } else {
344+ b.WriteByte('%')
345+ b.WriteByte(digits[c>>4])
346+ b.WriteByte(digits[c&15])
347+ }
348+ }
349+ cd += "; filename*=UTF-8''" + b.String()
350+ }
351+ return cd
352+}
353+func setReleaseDownloadHeaders(w http.ResponseWriter, a domain.ReleaseAsset) string {
354+ ct := a.ContentType
355+ media, _, err := mime.ParseMediaType(ct)
356+ media = strings.ToLower(media)
357+ if err != nil || strings.HasPrefix(media, "text/html") || media == "application/javascript" || media == "image/svg+xml" {
358+ ct = "application/octet-stream"
359+ }
360+ w.Header().Set("Content-Type", ct)
361+ w.Header().Set("Content-Disposition", releaseContentDisposition(a.Name))
362+ w.Header().Set("X-Content-Type-Options", "nosniff")
363+ w.Header().Set("Content-Security-Policy", "sandbox")
364+ w.Header().Set("Accept-Ranges", "bytes")
365+ etag := `"sha256-` + strings.TrimPrefix(a.Digest, "sha256:") + `"`
366+ w.Header().Set("ETag", etag)
367+ w.Header().Set("Last-Modified", a.UpdatedAt.UTC().Format(http.TimeFormat))
368+ return etag
369+}
370+func (h *Handler) downloadRelease(w http.ResponseWriter, r *http.Request, p domain.Principal, owner, repo string, id int64, client string) {
371+ ctx := r.Context()
372+ a, err := h.cfg.Releases.AssetByID(ctx, p, owner, repo, id)
373+ if err != nil {
374+ h.failRelease(ctx, w, p, err, "release-asset", "")
375+ return
376+ }
377+ if r.Method != http.MethodHead {
378+ release, ok := h.acquire(w, client)
379+ if !ok {
380+ return
381+ }
382+ defer release()
383+ }
384+ etag := setReleaseDownloadHeaders(w, a)
385+ if r.Header.Get("If-None-Match") == etag {
386+ w.WriteHeader(304)
387+ return
388+ }
389+ offset, length, partial, ok := assetRange(r.Header.Get("Range"), a.Size)
390+ if !ok {
391+ w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
392+ w.WriteHeader(416)
393+ return
394+ }
395+ status := 200
396+ if partial {
397+ status = 206
398+ w.Header().Set("Content-Range", "bytes "+strconv.FormatInt(offset, 10)+"-"+strconv.FormatInt(offset+length-1, 10)+"/"+strconv.FormatInt(a.Size, 10))
399+ }
400+ w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
401+ if r.Method == http.MethodHead {
402+ w.WriteHeader(status)
403+ return
404+ }
405+ // Slot failures and pre-stream failures are JSON, not a binary response.
406+ clearHeaders := func() {
407+ for _, k := range []string{"Content-Length", "Content-Range", "Content-Disposition", "ETag", "Last-Modified", "Accept-Ranges", "Content-Security-Policy"} {
408+ w.Header().Del(k)
409+ }
410+ }
411+ writer := &idleWriter{w: w, rc: http.NewResponseController(w), idle: h.idle}
412+ started := false
413+ err = h.cfg.ReleaseDownload.DownloadAsset(ctx, p, a.Repo, ports.AssetRef{ID: id}, offset, length, func(chunk ports.AssetChunk) error {
414+ if len(chunk.Data) == 0 {
415+ return nil
416+ }
417+ if !started {
418+ w.WriteHeader(status)
419+ started = true
420+ }
421+ _, e := writer.Write(chunk.Data)
422+ return e
423+ })
424+ // BlobStore.Open reports offset >= size as out of range, including the
425+ // valid empty object at offset zero. It has still checked existence.
426+ if err == nil || (a.Size == 0 && errors.Is(err, domain.ErrOutOfRange)) {
427+ if !started {
428+ w.WriteHeader(status)
429+ }
430+ return
431+ }
432+ if started {
433+ log := zerowrap.FromCtx(ctx)
434+ log.Warn().Err(err).Msg("github release download cut short")
435+ panic(http.ErrAbortHandler)
436+ }
437+ clearHeaders()
438+ if errors.Is(err, domain.ErrOutOfRange) {
439+ w.Header().Set("Content-Range", "bytes */"+strconv.FormatInt(a.Size, 10))
440+ w.WriteHeader(416)
441+ return
442+ }
443+ h.failRelease(ctx, w, p, err, "release-asset", "")
444+}
A internal/adapters/githubapi/releases_test.go +408 -0
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "net/url"
14+ "os"
15+ "strings"
16+ "testing"
17+ "time"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
24+)
25+
26+const releasePath = "/repos/acme/proj/releases"
27+const releaseAssetPath = releasePath + "/assets/7"
28+const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets"
29+
30+func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
31+ t.Helper()
32+ req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body))
33+ for k, v := range headers {
34+ req.Header.Set(k, v)
35+ }
36+ w := httptest.NewRecorder()
37+ h.ServeHTTP(w, req)
38+ return w
39+}
40+func TestReleaseRoutes(t *testing.T) {
41+ cases := []struct{ path, kind, ref, allow string }{
42+ {releasePath, "release-list", "", "GET, HEAD, POST"},
43+ {releasePath + "/latest", "release-latest", "", "GET, HEAD"},
44+ {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"},
45+ {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"},
46+ {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"},
47+ {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"},
48+ {releaseUploadPath, "release-upload", "4", "POST"},
49+ }
50+ for _, tc := range cases {
51+ t.Run(tc.kind, func(t *testing.T) {
52+ kind, owner, repo, ref, ok := route(tc.path)
53+ require.True(t, ok)
54+ require.Equal(t, tc.kind, kind)
55+ require.Equal(t, "acme", owner)
56+ require.Equal(t, "proj", repo)
57+ require.Equal(t, tc.ref, ref)
58+ api := mocks.NewGitHubReleaseAPI(t)
59+ h := New(Config{Releases: api})
60+ for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} {
61+ if methodAllowed(method, tc.allow) {
62+ continue
63+ }
64+ w := releaseRequest(t, h, method, tc.path, "", nil)
65+ require.Equal(t, 405, w.Code)
66+ require.Equal(t, tc.allow, w.Header().Get("Allow"))
67+ }
68+ w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil)
69+ require.Equal(t, 404, w.Code)
70+ require.Equal(t, notFoundBody, w.Body.String())
71+ })
72+ }
73+ for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} {
74+ _, _, _, _, ok := route(path)
75+ require.False(t, ok, path)
76+ }
77+}
78+func TestReleaseDispatch(t *testing.T) {
79+ for _, method := range []string{"GET", "HEAD"} {
80+ t.Run(method, func(t *testing.T) {
81+ api := mocks.NewGitHubReleaseAPI(t)
82+ h := New(Config{Releases: api})
83+ api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once()
84+ w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil)
85+ require.Equal(t, 200, w.Code)
86+ require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link"))
87+ require.Equal(t, "2", w.Header().Get("Content-Length"))
88+ if method == "HEAD" {
89+ require.Empty(t, w.Body.String())
90+ } else {
91+ require.Equal(t, "[]", w.Body.String())
92+ }
93+ for _, tc := range []struct {
94+ path string
95+ ref ports.ReleaseRef
96+ }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} {
97+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once()
98+ w = releaseRequest(t, h, method, tc.path, "", nil)
99+ require.Equal(t, 200, w.Code)
100+ }
101+ api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once()
102+ w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil)
103+ require.Equal(t, 200, w.Code)
104+ require.Equal(t, "asset-link", w.Header().Get("Link"))
105+ api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once()
106+ w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"})
107+ require.Equal(t, 200, w.Code)
108+ })
109+ }
110+ api := mocks.NewGitHubReleaseAPI(t)
111+ h := New(Config{Releases: api})
112+ b := []byte(`{"tag_name":"v1"}`)
113+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once()
114+ w := releaseRequest(t, h, "POST", releasePath, string(b), nil)
115+ require.Equal(t, 201, w.Code)
116+ require.Equal(t, `{"id":4}`, w.Body.String())
117+ api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once()
118+ w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil)
119+ require.Equal(t, 200, w.Code)
120+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once()
121+ w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil)
122+ require.Equal(t, 200, w.Code)
123+ api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once()
124+ w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil)
125+ require.Equal(t, 204, w.Code)
126+ require.Empty(t, w.Body.String())
127+ api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once()
128+ w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil)
129+ require.Equal(t, 204, w.Code)
130+ require.Empty(t, w.Body.String())
131+}
132+func TestReleaseUpload(t *testing.T) {
133+ api := mocks.NewGitHubReleaseAPI(t)
134+ h := New(Config{Releases: api})
135+ req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc"))
136+ req.ContentLength = -1
137+ w := httptest.NewRecorder()
138+ h.ServeHTTP(w, req)
139+ require.Equal(t, 411, w.Code)
140+ require.Equal(t, `{"message":"Length Required"}`, w.Body.String())
141+ // HTTP distinguishes an omitted length from an explicitly empty asset.
142+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil)
143+ require.Equal(t, 411, w.Code)
144+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once()
145+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"})
146+ require.Equal(t, 201, w.Code)
147+ w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil)
148+ require.Equal(t, 422, w.Code)
149+ for _, ct := range []string{"", "text/plain"} {
150+ expectedType := ct
151+ if ct == "" {
152+ expectedType = "application/octet-stream"
153+ }
154+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) {
155+ got, err := io.ReadAll(r)
156+ require.NoError(t, err)
157+ require.Equal(t, "abc", string(got))
158+ return []byte(`{"id":7}`), nil
159+ }).Once()
160+ req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef"))
161+ req.ContentLength = 3
162+ req.Header.Set("Content-Type", ct)
163+ w = httptest.NewRecorder()
164+ h.ServeHTTP(w, req)
165+ require.Equal(t, 201, w.Code)
166+ require.Equal(t, `{"id":7}`, w.Body.String())
167+ }
168+ // The real response template expands to precisely the upload route used above.
169+ b, err := os.ReadFile("testdata/releases/release.json")
170+ require.NoError(t, err)
171+ var object struct {
172+ UploadURL string `json:"upload_url"`
173+ }
174+ require.NoError(t, json.Unmarshal(b, &object))
175+ expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz")
176+ u, err := url.Parse(expanded)
177+ require.NoError(t, err)
178+ kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix))
179+ require.True(t, ok)
180+ require.Equal(t, "release-upload", kind)
181+ require.Equal(t, "acme", owner)
182+ require.Equal(t, "releases", repo)
183+ require.Equal(t, "1", ref)
184+}
185+func TestReleaseBodyLimit(t *testing.T) {
186+ h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)})
187+ for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} {
188+ w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil)
189+ require.Equal(t, 422, w.Code)
190+ require.Equal(t, `{"message":"Body too large"}`, w.Body.String())
191+ }
192+}
193+func TestReleaseErrors(t *testing.T) {
194+ rn, err := domain.ParseRepoName("proj")
195+ require.NoError(t, err)
196+ job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
197+ cases := []struct {
198+ name, kind string
199+ p domain.Principal
200+ err error
201+ status int
202+ body string
203+ }{
204+ {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody},
205+ {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`},
206+ {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`},
207+ {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`},
208+ {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`},
209+ {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`},
210+ {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`},
211+ {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`},
212+ {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`},
213+ {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`},
214+ {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`},
215+ {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`},
216+ {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`},
217+ {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`},
218+ }
219+ for _, tc := range cases {
220+ t.Run(tc.name, func(t *testing.T) {
221+ w := httptest.NewRecorder()
222+ New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "")
223+ require.Equal(t, tc.status, w.Code)
224+ require.Equal(t, tc.body, w.Body.String())
225+ if tc.status == 429 {
226+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
227+ }
228+ })
229+ }
230+ // Verify the route actually forwards a use-case visibility failure unchanged.
231+ api := mocks.NewGitHubReleaseAPI(t)
232+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once()
233+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil)
234+ require.Equal(t, 404, w.Code)
235+ require.Equal(t, notFoundBody, w.Body.String())
236+}
237+func downloadAsset(t *testing.T) domain.ReleaseAsset {
238+ t.Helper()
239+ rn, err := domain.ParseRepoName("proj")
240+ require.NoError(t, err)
241+ return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)}
242+}
243+func TestReleaseDownload(t *testing.T) {
244+ cases := []struct {
245+ name, method, rang, etag string
246+ status int
247+ offset, length int64
248+ body, contentRange string
249+ }{
250+ {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""},
251+ {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"},
252+ {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"},
253+ {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"},
254+ {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"},
255+ {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"},
256+ {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""},
257+ {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""},
258+ {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"},
259+ {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"},
260+ {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"},
261+ {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"},
262+ {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""},
263+ {"head", "HEAD", "", "", 200, 0, 10, "", ""},
264+ {"head range", "HEAD", "bytes=2-5", "", 206, 2, 4, "", "bytes 2-5/10"},
265+ }
266+ for _, tc := range cases {
267+ t.Run(tc.name, func(t *testing.T) {
268+ api := mocks.NewGitHubReleaseAPI(t)
269+ dl := mocks.NewReleaseService(t)
270+ a := downloadAsset(t)
271+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
272+ if tc.method == "GET" && (tc.status == 200 || tc.status == 206) {
273+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
274+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
275+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
276+ }).Once()
277+ }
278+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag})
279+ require.Equal(t, tc.status, w.Code)
280+ require.Equal(t, tc.body, w.Body.String())
281+ require.Equal(t, tc.contentRange, w.Header().Get("Content-Range"))
282+ require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
283+ require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition"))
284+ require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
285+ require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy"))
286+ require.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
287+ require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag"))
288+ require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified"))
289+ if tc.status == 200 || tc.status == 206 {
290+ require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length"))
291+ }
292+ })
293+ }
294+}
295+func TestReleaseDownloadSafety(t *testing.T) {
296+ for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} {
297+ a := downloadAsset(t)
298+ a.ContentType = ct
299+ a.Name = "é space.txt"
300+ w := httptest.NewRecorder()
301+ setReleaseDownloadHeaders(w, a)
302+ require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
303+ require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition"))
304+ }
305+ require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt"))
306+}
307+func TestReleaseDownloadFailures(t *testing.T) {
308+ for _, started := range []bool{false, true} {
309+ t.Run(fmt.Sprint(started), func(t *testing.T) {
310+ api := mocks.NewGitHubReleaseAPI(t)
311+ dl := mocks.NewReleaseService(t)
312+ a := downloadAsset(t)
313+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
314+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
315+ if started {
316+ require.NoError(t, send(ports.AssetChunk{Data: []byte("x")}))
317+ }
318+ return domain.ErrNotFound
319+ }).Once()
320+ h := New(Config{Releases: api, ReleaseDownload: dl})
321+ run := func() {
322+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
323+ require.Equal(t, 404, w.Code)
324+ require.Equal(t, notFoundBody, w.Body.String())
325+ require.Empty(t, w.Header().Get("Content-Length"))
326+ require.Empty(t, w.Header().Get("Content-Range"))
327+ }
328+ if started {
329+ require.PanicsWithValue(t, http.ErrAbortHandler, run)
330+ } else {
331+ run()
332+ }
333+ require.Empty(t, h.archives)
334+ require.Empty(t, h.clients)
335+ })
336+ }
337+}
338+func TestReleaseDownloadSlots(t *testing.T) {
339+ api := mocks.NewGitHubReleaseAPI(t)
340+ a := downloadAsset(t)
341+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2)
342+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1})
343+ release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1")
344+ require.True(t, ok)
345+ defer release()
346+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
347+ require.Equal(t, 429, w.Code)
348+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
349+ require.Empty(t, w.Header().Get("Content-Length"))
350+ require.Contains(t, w.Body.String(), "Too many archives")
351+ // A HEAD uses metadata, even when every download slot is taken.
352+ w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
353+ require.Equal(t, 200, w.Code)
354+}
355+
356+func TestReleaseEmptyAndMissingDownload(t *testing.T) {
357+ for _, tc := range []struct {
358+ name string
359+ size int64
360+ err error
361+ want int
362+ }{
363+ {"empty", 0, domain.ErrOutOfRange, 200},
364+ {"missing empty", 0, domain.ErrNotFound, 404},
365+ {"store range", 10, domain.ErrOutOfRange, 416},
366+ } {
367+ t.Run(tc.name, func(t *testing.T) {
368+ api := mocks.NewGitHubReleaseAPI(t)
369+ dl := mocks.NewReleaseService(t)
370+ a := downloadAsset(t)
371+ a.Size = tc.size
372+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
373+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once()
374+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
375+ require.Equal(t, tc.want, w.Code)
376+ switch tc.want {
377+ case 200:
378+ require.Equal(t, "0", w.Header().Get("Content-Length"))
379+ require.Empty(t, w.Body.String())
380+ case 416:
381+ require.Equal(t, "bytes */10", w.Header().Get("Content-Range"))
382+ case 404:
383+ require.Equal(t, notFoundBody, w.Body.String())
384+ }
385+ })
386+ }
387+ // Missing the stream service leaves JSON available but bytes disabled.
388+ api := mocks.NewGitHubReleaseAPI(t)
389+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
390+ require.Equal(t, 404, w.Code)
391+ require.Equal(t, notFoundBody, w.Body.String())
392+}
393+
394+func TestReleaseDownloadGlobalSlot(t *testing.T) {
395+ api := mocks.NewGitHubReleaseAPI(t)
396+ a := downloadAsset(t)
397+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
398+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1})
399+ release, ok := h.acquire(httptest.NewRecorder(), "another-client")
400+ require.True(t, ok)
401+ defer release()
402+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
403+ require.Equal(t, 503, w.Code)
404+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
405+ require.Empty(t, w.Header().Get("Content-Length"))
406+ require.Contains(t, w.Body.String(), "Server busy")
407+ require.NotContains(t, h.clients, "192.0.2.1")
408+}
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githubapi
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "net/http"
12+ "net/http/httptest"
13+ "net/url"
14+ "os"
15+ "strings"
16+ "testing"
17+ "time"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+ "github.com/stretchr/testify/mock"
23+ "github.com/stretchr/testify/require"
24+)
25+
26+const releasePath = "/repos/acme/proj/releases"
27+const releaseAssetPath = releasePath + "/assets/7"
28+const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets"
29+
30+func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
31+ t.Helper()
32+ req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body))
33+ for k, v := range headers {
34+ req.Header.Set(k, v)
35+ }
36+ w := httptest.NewRecorder()
37+ h.ServeHTTP(w, req)
38+ return w
39+}
40+func TestReleaseRoutes(t *testing.T) {
41+ cases := []struct{ path, kind, ref, allow string }{
42+ {releasePath, "release-list", "", "GET, HEAD, POST"},
43+ {releasePath + "/latest", "release-latest", "", "GET, HEAD"},
44+ {releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"},
45+ {releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"},
46+ {releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"},
47+ {releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"},
48+ {releaseUploadPath, "release-upload", "4", "POST"},
49+ }
50+ for _, tc := range cases {
51+ t.Run(tc.kind, func(t *testing.T) {
52+ kind, owner, repo, ref, ok := route(tc.path)
53+ require.True(t, ok)
54+ require.Equal(t, tc.kind, kind)
55+ require.Equal(t, "acme", owner)
56+ require.Equal(t, "proj", repo)
57+ require.Equal(t, tc.ref, ref)
58+ api := mocks.NewGitHubReleaseAPI(t)
59+ h := New(Config{Releases: api})
60+ for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} {
61+ if methodAllowed(method, tc.allow) {
62+ continue
63+ }
64+ w := releaseRequest(t, h, method, tc.path, "", nil)
65+ require.Equal(t, 405, w.Code)
66+ require.Equal(t, tc.allow, w.Header().Get("Allow"))
67+ }
68+ w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil)
69+ require.Equal(t, 404, w.Code)
70+ require.Equal(t, notFoundBody, w.Body.String())
71+ })
72+ }
73+ for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} {
74+ _, _, _, _, ok := route(path)
75+ require.False(t, ok, path)
76+ }
77+}
78+func TestReleaseDispatch(t *testing.T) {
79+ for _, method := range []string{"GET", "HEAD"} {
80+ t.Run(method, func(t *testing.T) {
81+ api := mocks.NewGitHubReleaseAPI(t)
82+ h := New(Config{Releases: api})
83+ api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once()
84+ w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil)
85+ require.Equal(t, 200, w.Code)
86+ require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link"))
87+ require.Equal(t, "2", w.Header().Get("Content-Length"))
88+ if method == "HEAD" {
89+ require.Empty(t, w.Body.String())
90+ } else {
91+ require.Equal(t, "[]", w.Body.String())
92+ }
93+ for _, tc := range []struct {
94+ path string
95+ ref ports.ReleaseRef
96+ }{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} {
97+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once()
98+ w = releaseRequest(t, h, method, tc.path, "", nil)
99+ require.Equal(t, 200, w.Code)
100+ }
101+ api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once()
102+ w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil)
103+ require.Equal(t, 200, w.Code)
104+ require.Equal(t, "asset-link", w.Header().Get("Link"))
105+ api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once()
106+ w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"})
107+ require.Equal(t, 200, w.Code)
108+ })
109+ }
110+ api := mocks.NewGitHubReleaseAPI(t)
111+ h := New(Config{Releases: api})
112+ b := []byte(`{"tag_name":"v1"}`)
113+ api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once()
114+ w := releaseRequest(t, h, "POST", releasePath, string(b), nil)
115+ require.Equal(t, 201, w.Code)
116+ require.Equal(t, `{"id":4}`, w.Body.String())
117+ api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once()
118+ w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil)
119+ require.Equal(t, 200, w.Code)
120+ api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once()
121+ w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil)
122+ require.Equal(t, 200, w.Code)
123+ api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once()
124+ w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil)
125+ require.Equal(t, 204, w.Code)
126+ require.Empty(t, w.Body.String())
127+ api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once()
128+ w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil)
129+ require.Equal(t, 204, w.Code)
130+ require.Empty(t, w.Body.String())
131+}
132+func TestReleaseUpload(t *testing.T) {
133+ api := mocks.NewGitHubReleaseAPI(t)
134+ h := New(Config{Releases: api})
135+ req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc"))
136+ req.ContentLength = -1
137+ w := httptest.NewRecorder()
138+ h.ServeHTTP(w, req)
139+ require.Equal(t, 411, w.Code)
140+ require.Equal(t, `{"message":"Length Required"}`, w.Body.String())
141+ // HTTP distinguishes an omitted length from an explicitly empty asset.
142+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil)
143+ require.Equal(t, 411, w.Code)
144+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once()
145+ w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"})
146+ require.Equal(t, 201, w.Code)
147+ w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil)
148+ require.Equal(t, 422, w.Code)
149+ for _, ct := range []string{"", "text/plain"} {
150+ expectedType := ct
151+ if ct == "" {
152+ expectedType = "application/octet-stream"
153+ }
154+ api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) {
155+ got, err := io.ReadAll(r)
156+ require.NoError(t, err)
157+ require.Equal(t, "abc", string(got))
158+ return []byte(`{"id":7}`), nil
159+ }).Once()
160+ req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef"))
161+ req.ContentLength = 3
162+ req.Header.Set("Content-Type", ct)
163+ w = httptest.NewRecorder()
164+ h.ServeHTTP(w, req)
165+ require.Equal(t, 201, w.Code)
166+ require.Equal(t, `{"id":7}`, w.Body.String())
167+ }
168+ // The real response template expands to precisely the upload route used above.
169+ b, err := os.ReadFile("testdata/releases/release.json")
170+ require.NoError(t, err)
171+ var object struct {
172+ UploadURL string `json:"upload_url"`
173+ }
174+ require.NoError(t, json.Unmarshal(b, &object))
175+ expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz")
176+ u, err := url.Parse(expanded)
177+ require.NoError(t, err)
178+ kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix))
179+ require.True(t, ok)
180+ require.Equal(t, "release-upload", kind)
181+ require.Equal(t, "acme", owner)
182+ require.Equal(t, "releases", repo)
183+ require.Equal(t, "1", ref)
184+}
185+func TestReleaseBodyLimit(t *testing.T) {
186+ h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)})
187+ for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} {
188+ w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil)
189+ require.Equal(t, 422, w.Code)
190+ require.Equal(t, `{"message":"Body too large"}`, w.Body.String())
191+ }
192+}
193+func TestReleaseErrors(t *testing.T) {
194+ rn, err := domain.ParseRepoName("proj")
195+ require.NoError(t, err)
196+ job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
197+ cases := []struct {
198+ name, kind string
199+ p domain.Principal
200+ err error
201+ status int
202+ body string
203+ }{
204+ {"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody},
205+ {"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`},
206+ {"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`},
207+ {"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`},
208+ {"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`},
209+ {"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`},
210+ {"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`},
211+ {"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`},
212+ {"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`},
213+ {"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`},
214+ {"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`},
215+ {"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`},
216+ {"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`},
217+ {"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`},
218+ }
219+ for _, tc := range cases {
220+ t.Run(tc.name, func(t *testing.T) {
221+ w := httptest.NewRecorder()
222+ New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "")
223+ require.Equal(t, tc.status, w.Code)
224+ require.Equal(t, tc.body, w.Body.String())
225+ if tc.status == 429 {
226+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
227+ }
228+ })
229+ }
230+ // Verify the route actually forwards a use-case visibility failure unchanged.
231+ api := mocks.NewGitHubReleaseAPI(t)
232+ api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once()
233+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil)
234+ require.Equal(t, 404, w.Code)
235+ require.Equal(t, notFoundBody, w.Body.String())
236+}
237+func downloadAsset(t *testing.T) domain.ReleaseAsset {
238+ t.Helper()
239+ rn, err := domain.ParseRepoName("proj")
240+ require.NoError(t, err)
241+ return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)}
242+}
243+func TestReleaseDownload(t *testing.T) {
244+ cases := []struct {
245+ name, method, rang, etag string
246+ status int
247+ offset, length int64
248+ body, contentRange string
249+ }{
250+ {"whole", "GET", "", "", 200, 0, 10, "0123456789", ""},
251+ {"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"},
252+ {"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"},
253+ {"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"},
254+ {"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"},
255+ {"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"},
256+ {"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""},
257+ {"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""},
258+ {"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"},
259+ {"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"},
260+ {"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"},
261+ {"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"},
262+ {"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""},
263+ {"head", "HEAD", "", "", 200, 0, 10, "", ""},
264+ {"head range", "HEAD", "bytes=2-5", "", 206, 2, 4, "", "bytes 2-5/10"},
265+ }
266+ for _, tc := range cases {
267+ t.Run(tc.name, func(t *testing.T) {
268+ api := mocks.NewGitHubReleaseAPI(t)
269+ dl := mocks.NewReleaseService(t)
270+ a := downloadAsset(t)
271+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
272+ if tc.method == "GET" && (tc.status == 200 || tc.status == 206) {
273+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
274+ require.NoError(t, send(ports.AssetChunk{Asset: &a}))
275+ return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
276+ }).Once()
277+ }
278+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag})
279+ require.Equal(t, tc.status, w.Code)
280+ require.Equal(t, tc.body, w.Body.String())
281+ require.Equal(t, tc.contentRange, w.Header().Get("Content-Range"))
282+ require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
283+ require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition"))
284+ require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
285+ require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy"))
286+ require.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
287+ require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag"))
288+ require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified"))
289+ if tc.status == 200 || tc.status == 206 {
290+ require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length"))
291+ }
292+ })
293+ }
294+}
295+func TestReleaseDownloadSafety(t *testing.T) {
296+ for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} {
297+ a := downloadAsset(t)
298+ a.ContentType = ct
299+ a.Name = "é space.txt"
300+ w := httptest.NewRecorder()
301+ setReleaseDownloadHeaders(w, a)
302+ require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
303+ require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition"))
304+ }
305+ require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt"))
306+}
307+func TestReleaseDownloadFailures(t *testing.T) {
308+ for _, started := range []bool{false, true} {
309+ t.Run(fmt.Sprint(started), func(t *testing.T) {
310+ api := mocks.NewGitHubReleaseAPI(t)
311+ dl := mocks.NewReleaseService(t)
312+ a := downloadAsset(t)
313+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
314+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
315+ if started {
316+ require.NoError(t, send(ports.AssetChunk{Data: []byte("x")}))
317+ }
318+ return domain.ErrNotFound
319+ }).Once()
320+ h := New(Config{Releases: api, ReleaseDownload: dl})
321+ run := func() {
322+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
323+ require.Equal(t, 404, w.Code)
324+ require.Equal(t, notFoundBody, w.Body.String())
325+ require.Empty(t, w.Header().Get("Content-Length"))
326+ require.Empty(t, w.Header().Get("Content-Range"))
327+ }
328+ if started {
329+ require.PanicsWithValue(t, http.ErrAbortHandler, run)
330+ } else {
331+ run()
332+ }
333+ require.Empty(t, h.archives)
334+ require.Empty(t, h.clients)
335+ })
336+ }
337+}
338+func TestReleaseDownloadSlots(t *testing.T) {
339+ api := mocks.NewGitHubReleaseAPI(t)
340+ a := downloadAsset(t)
341+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2)
342+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1})
343+ release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1")
344+ require.True(t, ok)
345+ defer release()
346+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
347+ require.Equal(t, 429, w.Code)
348+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
349+ require.Empty(t, w.Header().Get("Content-Length"))
350+ require.Contains(t, w.Body.String(), "Too many archives")
351+ // A HEAD uses metadata, even when every download slot is taken.
352+ w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
353+ require.Equal(t, 200, w.Code)
354+}
355+
356+func TestReleaseEmptyAndMissingDownload(t *testing.T) {
357+ for _, tc := range []struct {
358+ name string
359+ size int64
360+ err error
361+ want int
362+ }{
363+ {"empty", 0, domain.ErrOutOfRange, 200},
364+ {"missing empty", 0, domain.ErrNotFound, 404},
365+ {"store range", 10, domain.ErrOutOfRange, 416},
366+ } {
367+ t.Run(tc.name, func(t *testing.T) {
368+ api := mocks.NewGitHubReleaseAPI(t)
369+ dl := mocks.NewReleaseService(t)
370+ a := downloadAsset(t)
371+ a.Size = tc.size
372+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
373+ dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once()
374+ w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
375+ require.Equal(t, tc.want, w.Code)
376+ switch tc.want {
377+ case 200:
378+ require.Equal(t, "0", w.Header().Get("Content-Length"))
379+ require.Empty(t, w.Body.String())
380+ case 416:
381+ require.Equal(t, "bytes */10", w.Header().Get("Content-Range"))
382+ case 404:
383+ require.Equal(t, notFoundBody, w.Body.String())
384+ }
385+ })
386+ }
387+ // Missing the stream service leaves JSON available but bytes disabled.
388+ api := mocks.NewGitHubReleaseAPI(t)
389+ w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
390+ require.Equal(t, 404, w.Code)
391+ require.Equal(t, notFoundBody, w.Body.String())
392+}
393+
394+func TestReleaseDownloadGlobalSlot(t *testing.T) {
395+ api := mocks.NewGitHubReleaseAPI(t)
396+ a := downloadAsset(t)
397+ api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
398+ h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1})
399+ release, ok := h.acquire(httptest.NewRecorder(), "another-client")
400+ require.True(t, ok)
401+ defer release()
402+ w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
403+ require.Equal(t, 503, w.Code)
404+ require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
405+ require.Empty(t, w.Header().Get("Content-Length"))
406+ require.Contains(t, w.Body.String(), "Server busy")
407+ require.NotContains(t, h.clients, "192.0.2.1")
408+}
A internal/adapters/githubapi/testdata/releases/draft.json +40 -0
@@ -0,0 +1,40 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/next",
6+ "id": 4,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "next",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": true,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": null,
36+ "assets": [],
37+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next",
38+ "zipball_url": null,
39+ "body": ""
40+}
@@ -0,0 +1,40 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/4/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/4/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/next",
6+ "id": 4,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "next",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": true,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": null,
36+ "assets": [],
37+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/next",
38+ "zipball_url": null,
39+ "body": ""
40+}
A internal/adapters/githubapi/testdata/releases/release.json +113 -0
@@ -0,0 +1,113 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/v1",
6+ "id": 1,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "v1",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": false,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": "2026-10-10T08:00:00Z",
36+ "assets": [
37+ {
38+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3",
39+ "id": 3,
40+ "node_id": "",
41+ "name": "checksums.txt",
42+ "label": "",
43+ "uploader": {
44+ "avatar_url": "",
45+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
46+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
47+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
48+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
49+ "gravatar_id": "",
50+ "html_url": "http://localhost:7341/acme",
51+ "id": 13112345534056,
52+ "login": "acme",
53+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
54+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
55+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
56+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
57+ "site_admin": false,
58+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
59+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
60+ "type": "User",
61+ "url": "http://localhost:7341/-/github/api/users/acme",
62+ "user_view_type": "public"
63+ },
64+ "content_type": "application/octet-stream",
65+ "state": "uploaded",
66+ "size": 3,
67+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
68+ "download_count": 0,
69+ "created_at": "2026-10-10T08:00:00Z",
70+ "updated_at": "2026-10-10T08:00:00Z",
71+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt"
72+ },
73+ {
74+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2",
75+ "id": 2,
76+ "node_id": "",
77+ "name": "ermite.tar.gz",
78+ "label": "",
79+ "uploader": {
80+ "avatar_url": "",
81+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
82+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
83+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
84+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
85+ "gravatar_id": "",
86+ "html_url": "http://localhost:7341/acme",
87+ "id": 13112345534056,
88+ "login": "acme",
89+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
90+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
91+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
92+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
93+ "site_admin": false,
94+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
95+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
96+ "type": "User",
97+ "url": "http://localhost:7341/-/github/api/users/acme",
98+ "user_view_type": "public"
99+ },
100+ "content_type": "application/octet-stream",
101+ "state": "uploaded",
102+ "size": 3,
103+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
104+ "download_count": 0,
105+ "created_at": "2026-10-10T08:00:00Z",
106+ "updated_at": "2026-10-10T08:00:00Z",
107+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz"
108+ }
109+ ],
110+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1",
111+ "zipball_url": null,
112+ "body": ""
113+}
@@ -0,0 +1,113 @@
1+{
2+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1",
3+ "assets_url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/1/assets",
4+ "upload_url": "http://localhost:7341/-/github/api/uploads/repos/acme/releases/releases/1/assets{?name,label}",
5+ "html_url": "http://localhost:7341/releases/releases/tag/v1",
6+ "id": 1,
7+ "author": {
8+ "avatar_url": "",
9+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
10+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
11+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
12+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
13+ "gravatar_id": "",
14+ "html_url": "http://localhost:7341/acme",
15+ "id": 13112345534056,
16+ "login": "acme",
17+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
18+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
19+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
20+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
21+ "site_admin": false,
22+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
23+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
24+ "type": "User",
25+ "url": "http://localhost:7341/-/github/api/users/acme",
26+ "user_view_type": "public"
27+ },
28+ "node_id": "",
29+ "tag_name": "v1",
30+ "target_commitish": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
31+ "name": "",
32+ "draft": false,
33+ "prerelease": false,
34+ "created_at": "2026-10-10T08:00:00Z",
35+ "published_at": "2026-10-10T08:00:00Z",
36+ "assets": [
37+ {
38+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/3",
39+ "id": 3,
40+ "node_id": "",
41+ "name": "checksums.txt",
42+ "label": "",
43+ "uploader": {
44+ "avatar_url": "",
45+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
46+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
47+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
48+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
49+ "gravatar_id": "",
50+ "html_url": "http://localhost:7341/acme",
51+ "id": 13112345534056,
52+ "login": "acme",
53+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
54+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
55+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
56+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
57+ "site_admin": false,
58+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
59+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
60+ "type": "User",
61+ "url": "http://localhost:7341/-/github/api/users/acme",
62+ "user_view_type": "public"
63+ },
64+ "content_type": "application/octet-stream",
65+ "state": "uploaded",
66+ "size": 3,
67+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
68+ "download_count": 0,
69+ "created_at": "2026-10-10T08:00:00Z",
70+ "updated_at": "2026-10-10T08:00:00Z",
71+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/checksums.txt"
72+ },
73+ {
74+ "url": "http://localhost:7341/-/github/api/repos/acme/releases/releases/assets/2",
75+ "id": 2,
76+ "node_id": "",
77+ "name": "ermite.tar.gz",
78+ "label": "",
79+ "uploader": {
80+ "avatar_url": "",
81+ "events_url": "http://localhost:7341/-/github/api/users/acme/events{/privacy}",
82+ "followers_url": "http://localhost:7341/-/github/api/users/acme/followers",
83+ "following_url": "http://localhost:7341/-/github/api/users/acme/following{/other_user}",
84+ "gists_url": "http://localhost:7341/-/github/api/users/acme/gists{/gist_id}",
85+ "gravatar_id": "",
86+ "html_url": "http://localhost:7341/acme",
87+ "id": 13112345534056,
88+ "login": "acme",
89+ "node_id": "MDQ6VXNlcjEzMTEyMzQ1NTM0MDU2",
90+ "organizations_url": "http://localhost:7341/-/github/api/users/acme/orgs",
91+ "received_events_url": "http://localhost:7341/-/github/api/users/acme/received_events",
92+ "repos_url": "http://localhost:7341/-/github/api/users/acme/repos",
93+ "site_admin": false,
94+ "starred_url": "http://localhost:7341/-/github/api/users/acme/starred{/owner}{/repo}",
95+ "subscriptions_url": "http://localhost:7341/-/github/api/users/acme/subscriptions",
96+ "type": "User",
97+ "url": "http://localhost:7341/-/github/api/users/acme",
98+ "user_view_type": "public"
99+ },
100+ "content_type": "application/octet-stream",
101+ "state": "uploaded",
102+ "size": 3,
103+ "digest": "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
104+ "download_count": 0,
105+ "created_at": "2026-10-10T08:00:00Z",
106+ "updated_at": "2026-10-10T08:00:00Z",
107+ "browser_download_url": "http://localhost:7341/releases/releases/download/v1/ermite.tar.gz"
108+ }
109+ ],
110+ "tarball_url": "http://localhost:7341/-/github/api/repos/acme/releases/tarball/v1",
111+ "zipball_url": null,
112+ "body": ""
113+}
A internal/ports/mocks/GitHubReleaseAPI.go +1044 -0
@@ -0,0 +1,1044 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewGitHubReleaseAPI creates a new instance of GitHubReleaseAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewGitHubReleaseAPI(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *GitHubReleaseAPI {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &GitHubReleaseAPI{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// GitHubReleaseAPI is an autogenerated mock type for the GitHubReleaseAPI type
40+type GitHubReleaseAPI struct {
41+ mock.Mock
42+}
43+
44+type GitHubReleaseAPI_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *GitHubReleaseAPI) EXPECT() *GitHubReleaseAPI_Expecter {
49+ return &GitHubReleaseAPI_Expecter{mock: &_m.Mock}
50+}
51+
52+// AssetByID provides a mock function for the type GitHubReleaseAPI
53+func (_mock *GitHubReleaseAPI) AssetByID(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error) {
54+ ret := _mock.Called(ctx, p, owner, repo, assetID)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for AssetByID")
58+ }
59+
60+ var r0 domain.ReleaseAsset
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) (domain.ReleaseAsset, error)); ok {
63+ return returnFunc(ctx, p, owner, repo, assetID)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) domain.ReleaseAsset); ok {
66+ r0 = returnFunc(ctx, p, owner, repo, assetID)
67+ } else {
68+ r0 = ret.Get(0).(domain.ReleaseAsset)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok {
71+ r1 = returnFunc(ctx, p, owner, repo, assetID)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// GitHubReleaseAPI_AssetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetByID'
79+type GitHubReleaseAPI_AssetByID_Call struct {
80+ *mock.Call
81+}
82+
83+// AssetByID is a helper method to define mock.On call
84+// - ctx context.Context
85+// - p domain.Principal
86+// - owner string
87+// - repo string
88+// - assetID int64
89+func (_e *GitHubReleaseAPI_Expecter) AssetByID(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_AssetByID_Call {
90+ return &GitHubReleaseAPI_AssetByID_Call{Call: _e.mock.On("AssetByID", ctx, p, owner, repo, assetID)}
91+}
92+
93+func (_c *GitHubReleaseAPI_AssetByID_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_AssetByID_Call {
94+ _c.Call.Run(func(args mock.Arguments) {
95+ var arg0 context.Context
96+ if args[0] != nil {
97+ arg0 = args[0].(context.Context)
98+ }
99+ var arg1 domain.Principal
100+ if args[1] != nil {
101+ arg1 = args[1].(domain.Principal)
102+ }
103+ var arg2 string
104+ if args[2] != nil {
105+ arg2 = args[2].(string)
106+ }
107+ var arg3 string
108+ if args[3] != nil {
109+ arg3 = args[3].(string)
110+ }
111+ var arg4 int64
112+ if args[4] != nil {
113+ arg4 = args[4].(int64)
114+ }
115+ run(
116+ arg0,
117+ arg1,
118+ arg2,
119+ arg3,
120+ arg4,
121+ )
122+ })
123+ return _c
124+}
125+
126+func (_c *GitHubReleaseAPI_AssetByID_Call) Return(releaseAsset domain.ReleaseAsset, err error) *GitHubReleaseAPI_AssetByID_Call {
127+ _c.Call.Return(releaseAsset, err)
128+ return _c
129+}
130+
131+func (_c *GitHubReleaseAPI_AssetByID_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error)) *GitHubReleaseAPI_AssetByID_Call {
132+ _c.Call.Return(run)
133+ return _c
134+}
135+
136+// CreateRelease provides a mock function for the type GitHubReleaseAPI
137+func (_mock *GitHubReleaseAPI) CreateRelease(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error) {
138+ ret := _mock.Called(ctx, p, owner, repo, body)
139+
140+ if len(ret) == 0 {
141+ panic("no return value specified for CreateRelease")
142+ }
143+
144+ var r0 []byte
145+ var r1 error
146+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) ([]byte, error)); ok {
147+ return returnFunc(ctx, p, owner, repo, body)
148+ }
149+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) []byte); ok {
150+ r0 = returnFunc(ctx, p, owner, repo, body)
151+ } else {
152+ if ret.Get(0) != nil {
153+ r0 = ret.Get(0).([]byte)
154+ }
155+ }
156+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, []byte) error); ok {
157+ r1 = returnFunc(ctx, p, owner, repo, body)
158+ } else {
159+ r1 = ret.Error(1)
160+ }
161+ return r0, r1
162+}
163+
164+// GitHubReleaseAPI_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease'
165+type GitHubReleaseAPI_CreateRelease_Call struct {
166+ *mock.Call
167+}
168+
169+// CreateRelease is a helper method to define mock.On call
170+// - ctx context.Context
171+// - p domain.Principal
172+// - owner string
173+// - repo string
174+// - body []byte
175+func (_e *GitHubReleaseAPI_Expecter) CreateRelease(ctx any, p any, owner any, repo any, body any) *GitHubReleaseAPI_CreateRelease_Call {
176+ return &GitHubReleaseAPI_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, owner, repo, body)}
177+}
178+
179+func (_c *GitHubReleaseAPI_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte)) *GitHubReleaseAPI_CreateRelease_Call {
180+ _c.Call.Run(func(args mock.Arguments) {
181+ var arg0 context.Context
182+ if args[0] != nil {
183+ arg0 = args[0].(context.Context)
184+ }
185+ var arg1 domain.Principal
186+ if args[1] != nil {
187+ arg1 = args[1].(domain.Principal)
188+ }
189+ var arg2 string
190+ if args[2] != nil {
191+ arg2 = args[2].(string)
192+ }
193+ var arg3 string
194+ if args[3] != nil {
195+ arg3 = args[3].(string)
196+ }
197+ var arg4 []byte
198+ if args[4] != nil {
199+ arg4 = args[4].([]byte)
200+ }
201+ run(
202+ arg0,
203+ arg1,
204+ arg2,
205+ arg3,
206+ arg4,
207+ )
208+ })
209+ return _c
210+}
211+
212+func (_c *GitHubReleaseAPI_CreateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_CreateRelease_Call {
213+ _c.Call.Return(bytes, err)
214+ return _c
215+}
216+
217+func (_c *GitHubReleaseAPI_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error)) *GitHubReleaseAPI_CreateRelease_Call {
218+ _c.Call.Return(run)
219+ return _c
220+}
221+
222+// DeleteAsset provides a mock function for the type GitHubReleaseAPI
223+func (_mock *GitHubReleaseAPI) DeleteAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error {
224+ ret := _mock.Called(ctx, p, owner, repo, assetID)
225+
226+ if len(ret) == 0 {
227+ panic("no return value specified for DeleteAsset")
228+ }
229+
230+ var r0 error
231+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok {
232+ r0 = returnFunc(ctx, p, owner, repo, assetID)
233+ } else {
234+ r0 = ret.Error(0)
235+ }
236+ return r0
237+}
238+
239+// GitHubReleaseAPI_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset'
240+type GitHubReleaseAPI_DeleteAsset_Call struct {
241+ *mock.Call
242+}
243+
244+// DeleteAsset is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - owner string
248+// - repo string
249+// - assetID int64
250+func (_e *GitHubReleaseAPI_Expecter) DeleteAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_DeleteAsset_Call {
251+ return &GitHubReleaseAPI_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, owner, repo, assetID)}
252+}
253+
254+func (_c *GitHubReleaseAPI_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_DeleteAsset_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 string
265+ if args[2] != nil {
266+ arg2 = args[2].(string)
267+ }
268+ var arg3 string
269+ if args[3] != nil {
270+ arg3 = args[3].(string)
271+ }
272+ var arg4 int64
273+ if args[4] != nil {
274+ arg4 = args[4].(int64)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *GitHubReleaseAPI_DeleteAsset_Call) Return(err error) *GitHubReleaseAPI_DeleteAsset_Call {
288+ _c.Call.Return(err)
289+ return _c
290+}
291+
292+func (_c *GitHubReleaseAPI_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error) *GitHubReleaseAPI_DeleteAsset_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
297+// DeleteRelease provides a mock function for the type GitHubReleaseAPI
298+func (_mock *GitHubReleaseAPI) DeleteRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error {
299+ ret := _mock.Called(ctx, p, owner, repo, id)
300+
301+ if len(ret) == 0 {
302+ panic("no return value specified for DeleteRelease")
303+ }
304+
305+ var r0 error
306+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok {
307+ r0 = returnFunc(ctx, p, owner, repo, id)
308+ } else {
309+ r0 = ret.Error(0)
310+ }
311+ return r0
312+}
313+
314+// GitHubReleaseAPI_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease'
315+type GitHubReleaseAPI_DeleteRelease_Call struct {
316+ *mock.Call
317+}
318+
319+// DeleteRelease is a helper method to define mock.On call
320+// - ctx context.Context
321+// - p domain.Principal
322+// - owner string
323+// - repo string
324+// - id int64
325+func (_e *GitHubReleaseAPI_Expecter) DeleteRelease(ctx any, p any, owner any, repo any, id any) *GitHubReleaseAPI_DeleteRelease_Call {
326+ return &GitHubReleaseAPI_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, owner, repo, id)}
327+}
328+
329+func (_c *GitHubReleaseAPI_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64)) *GitHubReleaseAPI_DeleteRelease_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ var arg0 context.Context
332+ if args[0] != nil {
333+ arg0 = args[0].(context.Context)
334+ }
335+ var arg1 domain.Principal
336+ if args[1] != nil {
337+ arg1 = args[1].(domain.Principal)
338+ }
339+ var arg2 string
340+ if args[2] != nil {
341+ arg2 = args[2].(string)
342+ }
343+ var arg3 string
344+ if args[3] != nil {
345+ arg3 = args[3].(string)
346+ }
347+ var arg4 int64
348+ if args[4] != nil {
349+ arg4 = args[4].(int64)
350+ }
351+ run(
352+ arg0,
353+ arg1,
354+ arg2,
355+ arg3,
356+ arg4,
357+ )
358+ })
359+ return _c
360+}
361+
362+func (_c *GitHubReleaseAPI_DeleteRelease_Call) Return(err error) *GitHubReleaseAPI_DeleteRelease_Call {
363+ _c.Call.Return(err)
364+ return _c
365+}
366+
367+func (_c *GitHubReleaseAPI_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error) *GitHubReleaseAPI_DeleteRelease_Call {
368+ _c.Call.Return(run)
369+ return _c
370+}
371+
372+// GetAsset provides a mock function for the type GitHubReleaseAPI
373+func (_mock *GitHubReleaseAPI) GetAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error) {
374+ ret := _mock.Called(ctx, p, owner, repo, assetID)
375+
376+ if len(ret) == 0 {
377+ panic("no return value specified for GetAsset")
378+ }
379+
380+ var r0 []byte
381+ var r1 error
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) ([]byte, error)); ok {
383+ return returnFunc(ctx, p, owner, repo, assetID)
384+ }
385+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) []byte); ok {
386+ r0 = returnFunc(ctx, p, owner, repo, assetID)
387+ } else {
388+ if ret.Get(0) != nil {
389+ r0 = ret.Get(0).([]byte)
390+ }
391+ }
392+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok {
393+ r1 = returnFunc(ctx, p, owner, repo, assetID)
394+ } else {
395+ r1 = ret.Error(1)
396+ }
397+ return r0, r1
398+}
399+
400+// GitHubReleaseAPI_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset'
401+type GitHubReleaseAPI_GetAsset_Call struct {
402+ *mock.Call
403+}
404+
405+// GetAsset is a helper method to define mock.On call
406+// - ctx context.Context
407+// - p domain.Principal
408+// - owner string
409+// - repo string
410+// - assetID int64
411+func (_e *GitHubReleaseAPI_Expecter) GetAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_GetAsset_Call {
412+ return &GitHubReleaseAPI_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, owner, repo, assetID)}
413+}
414+
415+func (_c *GitHubReleaseAPI_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_GetAsset_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 context.Context
418+ if args[0] != nil {
419+ arg0 = args[0].(context.Context)
420+ }
421+ var arg1 domain.Principal
422+ if args[1] != nil {
423+ arg1 = args[1].(domain.Principal)
424+ }
425+ var arg2 string
426+ if args[2] != nil {
427+ arg2 = args[2].(string)
428+ }
429+ var arg3 string
430+ if args[3] != nil {
431+ arg3 = args[3].(string)
432+ }
433+ var arg4 int64
434+ if args[4] != nil {
435+ arg4 = args[4].(int64)
436+ }
437+ run(
438+ arg0,
439+ arg1,
440+ arg2,
441+ arg3,
442+ arg4,
443+ )
444+ })
445+ return _c
446+}
447+
448+func (_c *GitHubReleaseAPI_GetAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetAsset_Call {
449+ _c.Call.Return(bytes, err)
450+ return _c
451+}
452+
453+func (_c *GitHubReleaseAPI_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error)) *GitHubReleaseAPI_GetAsset_Call {
454+ _c.Call.Return(run)
455+ return _c
456+}
457+
458+// GetRelease provides a mock function for the type GitHubReleaseAPI
459+func (_mock *GitHubReleaseAPI) GetRelease(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error) {
460+ ret := _mock.Called(ctx, p, owner, repo, ref)
461+
462+ if len(ret) == 0 {
463+ panic("no return value specified for GetRelease")
464+ }
465+
466+ var r0 []byte
467+ var r1 error
468+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) ([]byte, error)); ok {
469+ return returnFunc(ctx, p, owner, repo, ref)
470+ }
471+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) []byte); ok {
472+ r0 = returnFunc(ctx, p, owner, repo, ref)
473+ } else {
474+ if ret.Get(0) != nil {
475+ r0 = ret.Get(0).([]byte)
476+ }
477+ }
478+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) error); ok {
479+ r1 = returnFunc(ctx, p, owner, repo, ref)
480+ } else {
481+ r1 = ret.Error(1)
482+ }
483+ return r0, r1
484+}
485+
486+// GitHubReleaseAPI_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease'
487+type GitHubReleaseAPI_GetRelease_Call struct {
488+ *mock.Call
489+}
490+
491+// GetRelease is a helper method to define mock.On call
492+// - ctx context.Context
493+// - p domain.Principal
494+// - owner string
495+// - repo string
496+// - ref ports.ReleaseRef
497+func (_e *GitHubReleaseAPI_Expecter) GetRelease(ctx any, p any, owner any, repo any, ref any) *GitHubReleaseAPI_GetRelease_Call {
498+ return &GitHubReleaseAPI_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, owner, repo, ref)}
499+}
500+
501+func (_c *GitHubReleaseAPI_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef)) *GitHubReleaseAPI_GetRelease_Call {
502+ _c.Call.Run(func(args mock.Arguments) {
503+ var arg0 context.Context
504+ if args[0] != nil {
505+ arg0 = args[0].(context.Context)
506+ }
507+ var arg1 domain.Principal
508+ if args[1] != nil {
509+ arg1 = args[1].(domain.Principal)
510+ }
511+ var arg2 string
512+ if args[2] != nil {
513+ arg2 = args[2].(string)
514+ }
515+ var arg3 string
516+ if args[3] != nil {
517+ arg3 = args[3].(string)
518+ }
519+ var arg4 ports.ReleaseRef
520+ if args[4] != nil {
521+ arg4 = args[4].(ports.ReleaseRef)
522+ }
523+ run(
524+ arg0,
525+ arg1,
526+ arg2,
527+ arg3,
528+ arg4,
529+ )
530+ })
531+ return _c
532+}
533+
534+func (_c *GitHubReleaseAPI_GetRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetRelease_Call {
535+ _c.Call.Return(bytes, err)
536+ return _c
537+}
538+
539+func (_c *GitHubReleaseAPI_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error)) *GitHubReleaseAPI_GetRelease_Call {
540+ _c.Call.Return(run)
541+ return _c
542+}
543+
544+// ListAssets provides a mock function for the type GitHubReleaseAPI
545+func (_mock *GitHubReleaseAPI) ListAssets(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error) {
546+ ret := _mock.Called(ctx, p, owner, repo, id, page, perPage)
547+
548+ if len(ret) == 0 {
549+ panic("no return value specified for ListAssets")
550+ }
551+
552+ var r0 []byte
553+ var r1 string
554+ var r2 error
555+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) ([]byte, string, error)); ok {
556+ return returnFunc(ctx, p, owner, repo, id, page, perPage)
557+ }
558+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) []byte); ok {
559+ r0 = returnFunc(ctx, p, owner, repo, id, page, perPage)
560+ } else {
561+ if ret.Get(0) != nil {
562+ r0 = ret.Get(0).([]byte)
563+ }
564+ }
565+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, int, int) string); ok {
566+ r1 = returnFunc(ctx, p, owner, repo, id, page, perPage)
567+ } else {
568+ r1 = ret.Get(1).(string)
569+ }
570+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int64, int, int) error); ok {
571+ r2 = returnFunc(ctx, p, owner, repo, id, page, perPage)
572+ } else {
573+ r2 = ret.Error(2)
574+ }
575+ return r0, r1, r2
576+}
577+
578+// GitHubReleaseAPI_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets'
579+type GitHubReleaseAPI_ListAssets_Call struct {
580+ *mock.Call
581+}
582+
583+// ListAssets is a helper method to define mock.On call
584+// - ctx context.Context
585+// - p domain.Principal
586+// - owner string
587+// - repo string
588+// - id int64
589+// - page int
590+// - perPage int
591+func (_e *GitHubReleaseAPI_Expecter) ListAssets(ctx any, p any, owner any, repo any, id any, page any, perPage any) *GitHubReleaseAPI_ListAssets_Call {
592+ return &GitHubReleaseAPI_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, owner, repo, id, page, perPage)}
593+}
594+
595+func (_c *GitHubReleaseAPI_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int)) *GitHubReleaseAPI_ListAssets_Call {
596+ _c.Call.Run(func(args mock.Arguments) {
597+ var arg0 context.Context
598+ if args[0] != nil {
599+ arg0 = args[0].(context.Context)
600+ }
601+ var arg1 domain.Principal
602+ if args[1] != nil {
603+ arg1 = args[1].(domain.Principal)
604+ }
605+ var arg2 string
606+ if args[2] != nil {
607+ arg2 = args[2].(string)
608+ }
609+ var arg3 string
610+ if args[3] != nil {
611+ arg3 = args[3].(string)
612+ }
613+ var arg4 int64
614+ if args[4] != nil {
615+ arg4 = args[4].(int64)
616+ }
617+ var arg5 int
618+ if args[5] != nil {
619+ arg5 = args[5].(int)
620+ }
621+ var arg6 int
622+ if args[6] != nil {
623+ arg6 = args[6].(int)
624+ }
625+ run(
626+ arg0,
627+ arg1,
628+ arg2,
629+ arg3,
630+ arg4,
631+ arg5,
632+ arg6,
633+ )
634+ })
635+ return _c
636+}
637+
638+func (_c *GitHubReleaseAPI_ListAssets_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListAssets_Call {
639+ _c.Call.Return(body, link, err)
640+ return _c
641+}
642+
643+func (_c *GitHubReleaseAPI_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListAssets_Call {
644+ _c.Call.Return(run)
645+ return _c
646+}
647+
648+// ListReleases provides a mock function for the type GitHubReleaseAPI
649+func (_mock *GitHubReleaseAPI) ListReleases(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error) {
650+ ret := _mock.Called(ctx, p, owner, repo, page, perPage)
651+
652+ if len(ret) == 0 {
653+ panic("no return value specified for ListReleases")
654+ }
655+
656+ var r0 []byte
657+ var r1 string
658+ var r2 error
659+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) ([]byte, string, error)); ok {
660+ return returnFunc(ctx, p, owner, repo, page, perPage)
661+ }
662+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) []byte); ok {
663+ r0 = returnFunc(ctx, p, owner, repo, page, perPage)
664+ } else {
665+ if ret.Get(0) != nil {
666+ r0 = ret.Get(0).([]byte)
667+ }
668+ }
669+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int, int) string); ok {
670+ r1 = returnFunc(ctx, p, owner, repo, page, perPage)
671+ } else {
672+ r1 = ret.Get(1).(string)
673+ }
674+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int, int) error); ok {
675+ r2 = returnFunc(ctx, p, owner, repo, page, perPage)
676+ } else {
677+ r2 = ret.Error(2)
678+ }
679+ return r0, r1, r2
680+}
681+
682+// GitHubReleaseAPI_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases'
683+type GitHubReleaseAPI_ListReleases_Call struct {
684+ *mock.Call
685+}
686+
687+// ListReleases is a helper method to define mock.On call
688+// - ctx context.Context
689+// - p domain.Principal
690+// - owner string
691+// - repo string
692+// - page int
693+// - perPage int
694+func (_e *GitHubReleaseAPI_Expecter) ListReleases(ctx any, p any, owner any, repo any, page any, perPage any) *GitHubReleaseAPI_ListReleases_Call {
695+ return &GitHubReleaseAPI_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, owner, repo, page, perPage)}
696+}
697+
698+func (_c *GitHubReleaseAPI_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int)) *GitHubReleaseAPI_ListReleases_Call {
699+ _c.Call.Run(func(args mock.Arguments) {
700+ var arg0 context.Context
701+ if args[0] != nil {
702+ arg0 = args[0].(context.Context)
703+ }
704+ var arg1 domain.Principal
705+ if args[1] != nil {
706+ arg1 = args[1].(domain.Principal)
707+ }
708+ var arg2 string
709+ if args[2] != nil {
710+ arg2 = args[2].(string)
711+ }
712+ var arg3 string
713+ if args[3] != nil {
714+ arg3 = args[3].(string)
715+ }
716+ var arg4 int
717+ if args[4] != nil {
718+ arg4 = args[4].(int)
719+ }
720+ var arg5 int
721+ if args[5] != nil {
722+ arg5 = args[5].(int)
723+ }
724+ run(
725+ arg0,
726+ arg1,
727+ arg2,
728+ arg3,
729+ arg4,
730+ arg5,
731+ )
732+ })
733+ return _c
734+}
735+
736+func (_c *GitHubReleaseAPI_ListReleases_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListReleases_Call {
737+ _c.Call.Return(body, link, err)
738+ return _c
739+}
740+
741+func (_c *GitHubReleaseAPI_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListReleases_Call {
742+ _c.Call.Return(run)
743+ return _c
744+}
745+
746+// UpdateAsset provides a mock function for the type GitHubReleaseAPI
747+func (_mock *GitHubReleaseAPI) UpdateAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error) {
748+ ret := _mock.Called(ctx, p, owner, repo, assetID, body)
749+
750+ if len(ret) == 0 {
751+ panic("no return value specified for UpdateAsset")
752+ }
753+
754+ var r0 []byte
755+ var r1 error
756+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok {
757+ return returnFunc(ctx, p, owner, repo, assetID, body)
758+ }
759+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok {
760+ r0 = returnFunc(ctx, p, owner, repo, assetID, body)
761+ } else {
762+ if ret.Get(0) != nil {
763+ r0 = ret.Get(0).([]byte)
764+ }
765+ }
766+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok {
767+ r1 = returnFunc(ctx, p, owner, repo, assetID, body)
768+ } else {
769+ r1 = ret.Error(1)
770+ }
771+ return r0, r1
772+}
773+
774+// GitHubReleaseAPI_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset'
775+type GitHubReleaseAPI_UpdateAsset_Call struct {
776+ *mock.Call
777+}
778+
779+// UpdateAsset is a helper method to define mock.On call
780+// - ctx context.Context
781+// - p domain.Principal
782+// - owner string
783+// - repo string
784+// - assetID int64
785+// - body []byte
786+func (_e *GitHubReleaseAPI_Expecter) UpdateAsset(ctx any, p any, owner any, repo any, assetID any, body any) *GitHubReleaseAPI_UpdateAsset_Call {
787+ return &GitHubReleaseAPI_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, owner, repo, assetID, body)}
788+}
789+
790+func (_c *GitHubReleaseAPI_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte)) *GitHubReleaseAPI_UpdateAsset_Call {
791+ _c.Call.Run(func(args mock.Arguments) {
792+ var arg0 context.Context
793+ if args[0] != nil {
794+ arg0 = args[0].(context.Context)
795+ }
796+ var arg1 domain.Principal
797+ if args[1] != nil {
798+ arg1 = args[1].(domain.Principal)
799+ }
800+ var arg2 string
801+ if args[2] != nil {
802+ arg2 = args[2].(string)
803+ }
804+ var arg3 string
805+ if args[3] != nil {
806+ arg3 = args[3].(string)
807+ }
808+ var arg4 int64
809+ if args[4] != nil {
810+ arg4 = args[4].(int64)
811+ }
812+ var arg5 []byte
813+ if args[5] != nil {
814+ arg5 = args[5].([]byte)
815+ }
816+ run(
817+ arg0,
818+ arg1,
819+ arg2,
820+ arg3,
821+ arg4,
822+ arg5,
823+ )
824+ })
825+ return _c
826+}
827+
828+func (_c *GitHubReleaseAPI_UpdateAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateAsset_Call {
829+ _c.Call.Return(bytes, err)
830+ return _c
831+}
832+
833+func (_c *GitHubReleaseAPI_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateAsset_Call {
834+ _c.Call.Return(run)
835+ return _c
836+}
837+
838+// UpdateRelease provides a mock function for the type GitHubReleaseAPI
839+func (_mock *GitHubReleaseAPI) UpdateRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error) {
840+ ret := _mock.Called(ctx, p, owner, repo, id, body)
841+
842+ if len(ret) == 0 {
843+ panic("no return value specified for UpdateRelease")
844+ }
845+
846+ var r0 []byte
847+ var r1 error
848+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok {
849+ return returnFunc(ctx, p, owner, repo, id, body)
850+ }
851+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok {
852+ r0 = returnFunc(ctx, p, owner, repo, id, body)
853+ } else {
854+ if ret.Get(0) != nil {
855+ r0 = ret.Get(0).([]byte)
856+ }
857+ }
858+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok {
859+ r1 = returnFunc(ctx, p, owner, repo, id, body)
860+ } else {
861+ r1 = ret.Error(1)
862+ }
863+ return r0, r1
864+}
865+
866+// GitHubReleaseAPI_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease'
867+type GitHubReleaseAPI_UpdateRelease_Call struct {
868+ *mock.Call
869+}
870+
871+// UpdateRelease is a helper method to define mock.On call
872+// - ctx context.Context
873+// - p domain.Principal
874+// - owner string
875+// - repo string
876+// - id int64
877+// - body []byte
878+func (_e *GitHubReleaseAPI_Expecter) UpdateRelease(ctx any, p any, owner any, repo any, id any, body any) *GitHubReleaseAPI_UpdateRelease_Call {
879+ return &GitHubReleaseAPI_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, owner, repo, id, body)}
880+}
881+
882+func (_c *GitHubReleaseAPI_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte)) *GitHubReleaseAPI_UpdateRelease_Call {
883+ _c.Call.Run(func(args mock.Arguments) {
884+ var arg0 context.Context
885+ if args[0] != nil {
886+ arg0 = args[0].(context.Context)
887+ }
888+ var arg1 domain.Principal
889+ if args[1] != nil {
890+ arg1 = args[1].(domain.Principal)
891+ }
892+ var arg2 string
893+ if args[2] != nil {
894+ arg2 = args[2].(string)
895+ }
896+ var arg3 string
897+ if args[3] != nil {
898+ arg3 = args[3].(string)
899+ }
900+ var arg4 int64
901+ if args[4] != nil {
902+ arg4 = args[4].(int64)
903+ }
904+ var arg5 []byte
905+ if args[5] != nil {
906+ arg5 = args[5].([]byte)
907+ }
908+ run(
909+ arg0,
910+ arg1,
911+ arg2,
912+ arg3,
913+ arg4,
914+ arg5,
915+ )
916+ })
917+ return _c
918+}
919+
920+func (_c *GitHubReleaseAPI_UpdateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateRelease_Call {
921+ _c.Call.Return(bytes, err)
922+ return _c
923+}
924+
925+func (_c *GitHubReleaseAPI_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateRelease_Call {
926+ _c.Call.Return(run)
927+ return _c
928+}
929+
930+// UploadAsset provides a mock function for the type GitHubReleaseAPI
931+func (_mock *GitHubReleaseAPI) UploadAsset(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error) {
932+ ret := _mock.Called(ctx, p, owner, repo, id, name, label, contentType, size, r)
933+
934+ if len(ret) == 0 {
935+ panic("no return value specified for UploadAsset")
936+ }
937+
938+ var r0 []byte
939+ var r1 error
940+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) ([]byte, error)); ok {
941+ return returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
942+ }
943+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) []byte); ok {
944+ r0 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
945+ } else {
946+ if ret.Get(0) != nil {
947+ r0 = ret.Get(0).([]byte)
948+ }
949+ }
950+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) error); ok {
951+ r1 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
952+ } else {
953+ r1 = ret.Error(1)
954+ }
955+ return r0, r1
956+}
957+
958+// GitHubReleaseAPI_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset'
959+type GitHubReleaseAPI_UploadAsset_Call struct {
960+ *mock.Call
961+}
962+
963+// UploadAsset is a helper method to define mock.On call
964+// - ctx context.Context
965+// - p domain.Principal
966+// - owner string
967+// - repo string
968+// - id int64
969+// - name string
970+// - label string
971+// - contentType string
972+// - size int64
973+// - r io.Reader
974+func (_e *GitHubReleaseAPI_Expecter) UploadAsset(ctx any, p any, owner any, repo any, id any, name any, label any, contentType any, size any, r any) *GitHubReleaseAPI_UploadAsset_Call {
975+ return &GitHubReleaseAPI_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, owner, repo, id, name, label, contentType, size, r)}
976+}
977+
978+func (_c *GitHubReleaseAPI_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader)) *GitHubReleaseAPI_UploadAsset_Call {
979+ _c.Call.Run(func(args mock.Arguments) {
980+ var arg0 context.Context
981+ if args[0] != nil {
982+ arg0 = args[0].(context.Context)
983+ }
984+ var arg1 domain.Principal
985+ if args[1] != nil {
986+ arg1 = args[1].(domain.Principal)
987+ }
988+ var arg2 string
989+ if args[2] != nil {
990+ arg2 = args[2].(string)
991+ }
992+ var arg3 string
993+ if args[3] != nil {
994+ arg3 = args[3].(string)
995+ }
996+ var arg4 int64
997+ if args[4] != nil {
998+ arg4 = args[4].(int64)
999+ }
1000+ var arg5 string
1001+ if args[5] != nil {
1002+ arg5 = args[5].(string)
1003+ }
1004+ var arg6 string
1005+ if args[6] != nil {
1006+ arg6 = args[6].(string)
1007+ }
1008+ var arg7 string
1009+ if args[7] != nil {
1010+ arg7 = args[7].(string)
1011+ }
1012+ var arg8 int64
1013+ if args[8] != nil {
1014+ arg8 = args[8].(int64)
1015+ }
1016+ var arg9 io.Reader
1017+ if args[9] != nil {
1018+ arg9 = args[9].(io.Reader)
1019+ }
1020+ run(
1021+ arg0,
1022+ arg1,
1023+ arg2,
1024+ arg3,
1025+ arg4,
1026+ arg5,
1027+ arg6,
1028+ arg7,
1029+ arg8,
1030+ arg9,
1031+ )
1032+ })
1033+ return _c
1034+}
1035+
1036+func (_c *GitHubReleaseAPI_UploadAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UploadAsset_Call {
1037+ _c.Call.Return(bytes, err)
1038+ return _c
1039+}
1040+
1041+func (_c *GitHubReleaseAPI_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error)) *GitHubReleaseAPI_UploadAsset_Call {
1042+ _c.Call.Return(run)
1043+ return _c
1044+}
@@ -0,0 +1,1044 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewGitHubReleaseAPI creates a new instance of GitHubReleaseAPI. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewGitHubReleaseAPI(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *GitHubReleaseAPI {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &GitHubReleaseAPI{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// GitHubReleaseAPI is an autogenerated mock type for the GitHubReleaseAPI type
40+type GitHubReleaseAPI struct {
41+ mock.Mock
42+}
43+
44+type GitHubReleaseAPI_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *GitHubReleaseAPI) EXPECT() *GitHubReleaseAPI_Expecter {
49+ return &GitHubReleaseAPI_Expecter{mock: &_m.Mock}
50+}
51+
52+// AssetByID provides a mock function for the type GitHubReleaseAPI
53+func (_mock *GitHubReleaseAPI) AssetByID(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error) {
54+ ret := _mock.Called(ctx, p, owner, repo, assetID)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for AssetByID")
58+ }
59+
60+ var r0 domain.ReleaseAsset
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) (domain.ReleaseAsset, error)); ok {
63+ return returnFunc(ctx, p, owner, repo, assetID)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) domain.ReleaseAsset); ok {
66+ r0 = returnFunc(ctx, p, owner, repo, assetID)
67+ } else {
68+ r0 = ret.Get(0).(domain.ReleaseAsset)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok {
71+ r1 = returnFunc(ctx, p, owner, repo, assetID)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// GitHubReleaseAPI_AssetByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetByID'
79+type GitHubReleaseAPI_AssetByID_Call struct {
80+ *mock.Call
81+}
82+
83+// AssetByID is a helper method to define mock.On call
84+// - ctx context.Context
85+// - p domain.Principal
86+// - owner string
87+// - repo string
88+// - assetID int64
89+func (_e *GitHubReleaseAPI_Expecter) AssetByID(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_AssetByID_Call {
90+ return &GitHubReleaseAPI_AssetByID_Call{Call: _e.mock.On("AssetByID", ctx, p, owner, repo, assetID)}
91+}
92+
93+func (_c *GitHubReleaseAPI_AssetByID_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_AssetByID_Call {
94+ _c.Call.Run(func(args mock.Arguments) {
95+ var arg0 context.Context
96+ if args[0] != nil {
97+ arg0 = args[0].(context.Context)
98+ }
99+ var arg1 domain.Principal
100+ if args[1] != nil {
101+ arg1 = args[1].(domain.Principal)
102+ }
103+ var arg2 string
104+ if args[2] != nil {
105+ arg2 = args[2].(string)
106+ }
107+ var arg3 string
108+ if args[3] != nil {
109+ arg3 = args[3].(string)
110+ }
111+ var arg4 int64
112+ if args[4] != nil {
113+ arg4 = args[4].(int64)
114+ }
115+ run(
116+ arg0,
117+ arg1,
118+ arg2,
119+ arg3,
120+ arg4,
121+ )
122+ })
123+ return _c
124+}
125+
126+func (_c *GitHubReleaseAPI_AssetByID_Call) Return(releaseAsset domain.ReleaseAsset, err error) *GitHubReleaseAPI_AssetByID_Call {
127+ _c.Call.Return(releaseAsset, err)
128+ return _c
129+}
130+
131+func (_c *GitHubReleaseAPI_AssetByID_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) (domain.ReleaseAsset, error)) *GitHubReleaseAPI_AssetByID_Call {
132+ _c.Call.Return(run)
133+ return _c
134+}
135+
136+// CreateRelease provides a mock function for the type GitHubReleaseAPI
137+func (_mock *GitHubReleaseAPI) CreateRelease(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error) {
138+ ret := _mock.Called(ctx, p, owner, repo, body)
139+
140+ if len(ret) == 0 {
141+ panic("no return value specified for CreateRelease")
142+ }
143+
144+ var r0 []byte
145+ var r1 error
146+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) ([]byte, error)); ok {
147+ return returnFunc(ctx, p, owner, repo, body)
148+ }
149+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, []byte) []byte); ok {
150+ r0 = returnFunc(ctx, p, owner, repo, body)
151+ } else {
152+ if ret.Get(0) != nil {
153+ r0 = ret.Get(0).([]byte)
154+ }
155+ }
156+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, []byte) error); ok {
157+ r1 = returnFunc(ctx, p, owner, repo, body)
158+ } else {
159+ r1 = ret.Error(1)
160+ }
161+ return r0, r1
162+}
163+
164+// GitHubReleaseAPI_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease'
165+type GitHubReleaseAPI_CreateRelease_Call struct {
166+ *mock.Call
167+}
168+
169+// CreateRelease is a helper method to define mock.On call
170+// - ctx context.Context
171+// - p domain.Principal
172+// - owner string
173+// - repo string
174+// - body []byte
175+func (_e *GitHubReleaseAPI_Expecter) CreateRelease(ctx any, p any, owner any, repo any, body any) *GitHubReleaseAPI_CreateRelease_Call {
176+ return &GitHubReleaseAPI_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, p, owner, repo, body)}
177+}
178+
179+func (_c *GitHubReleaseAPI_CreateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte)) *GitHubReleaseAPI_CreateRelease_Call {
180+ _c.Call.Run(func(args mock.Arguments) {
181+ var arg0 context.Context
182+ if args[0] != nil {
183+ arg0 = args[0].(context.Context)
184+ }
185+ var arg1 domain.Principal
186+ if args[1] != nil {
187+ arg1 = args[1].(domain.Principal)
188+ }
189+ var arg2 string
190+ if args[2] != nil {
191+ arg2 = args[2].(string)
192+ }
193+ var arg3 string
194+ if args[3] != nil {
195+ arg3 = args[3].(string)
196+ }
197+ var arg4 []byte
198+ if args[4] != nil {
199+ arg4 = args[4].([]byte)
200+ }
201+ run(
202+ arg0,
203+ arg1,
204+ arg2,
205+ arg3,
206+ arg4,
207+ )
208+ })
209+ return _c
210+}
211+
212+func (_c *GitHubReleaseAPI_CreateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_CreateRelease_Call {
213+ _c.Call.Return(bytes, err)
214+ return _c
215+}
216+
217+func (_c *GitHubReleaseAPI_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, body []byte) ([]byte, error)) *GitHubReleaseAPI_CreateRelease_Call {
218+ _c.Call.Return(run)
219+ return _c
220+}
221+
222+// DeleteAsset provides a mock function for the type GitHubReleaseAPI
223+func (_mock *GitHubReleaseAPI) DeleteAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error {
224+ ret := _mock.Called(ctx, p, owner, repo, assetID)
225+
226+ if len(ret) == 0 {
227+ panic("no return value specified for DeleteAsset")
228+ }
229+
230+ var r0 error
231+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok {
232+ r0 = returnFunc(ctx, p, owner, repo, assetID)
233+ } else {
234+ r0 = ret.Error(0)
235+ }
236+ return r0
237+}
238+
239+// GitHubReleaseAPI_DeleteAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAsset'
240+type GitHubReleaseAPI_DeleteAsset_Call struct {
241+ *mock.Call
242+}
243+
244+// DeleteAsset is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - owner string
248+// - repo string
249+// - assetID int64
250+func (_e *GitHubReleaseAPI_Expecter) DeleteAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_DeleteAsset_Call {
251+ return &GitHubReleaseAPI_DeleteAsset_Call{Call: _e.mock.On("DeleteAsset", ctx, p, owner, repo, assetID)}
252+}
253+
254+func (_c *GitHubReleaseAPI_DeleteAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_DeleteAsset_Call {
255+ _c.Call.Run(func(args mock.Arguments) {
256+ var arg0 context.Context
257+ if args[0] != nil {
258+ arg0 = args[0].(context.Context)
259+ }
260+ var arg1 domain.Principal
261+ if args[1] != nil {
262+ arg1 = args[1].(domain.Principal)
263+ }
264+ var arg2 string
265+ if args[2] != nil {
266+ arg2 = args[2].(string)
267+ }
268+ var arg3 string
269+ if args[3] != nil {
270+ arg3 = args[3].(string)
271+ }
272+ var arg4 int64
273+ if args[4] != nil {
274+ arg4 = args[4].(int64)
275+ }
276+ run(
277+ arg0,
278+ arg1,
279+ arg2,
280+ arg3,
281+ arg4,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *GitHubReleaseAPI_DeleteAsset_Call) Return(err error) *GitHubReleaseAPI_DeleteAsset_Call {
288+ _c.Call.Return(err)
289+ return _c
290+}
291+
292+func (_c *GitHubReleaseAPI_DeleteAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) error) *GitHubReleaseAPI_DeleteAsset_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
297+// DeleteRelease provides a mock function for the type GitHubReleaseAPI
298+func (_mock *GitHubReleaseAPI) DeleteRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error {
299+ ret := _mock.Called(ctx, p, owner, repo, id)
300+
301+ if len(ret) == 0 {
302+ panic("no return value specified for DeleteRelease")
303+ }
304+
305+ var r0 error
306+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) error); ok {
307+ r0 = returnFunc(ctx, p, owner, repo, id)
308+ } else {
309+ r0 = ret.Error(0)
310+ }
311+ return r0
312+}
313+
314+// GitHubReleaseAPI_DeleteRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRelease'
315+type GitHubReleaseAPI_DeleteRelease_Call struct {
316+ *mock.Call
317+}
318+
319+// DeleteRelease is a helper method to define mock.On call
320+// - ctx context.Context
321+// - p domain.Principal
322+// - owner string
323+// - repo string
324+// - id int64
325+func (_e *GitHubReleaseAPI_Expecter) DeleteRelease(ctx any, p any, owner any, repo any, id any) *GitHubReleaseAPI_DeleteRelease_Call {
326+ return &GitHubReleaseAPI_DeleteRelease_Call{Call: _e.mock.On("DeleteRelease", ctx, p, owner, repo, id)}
327+}
328+
329+func (_c *GitHubReleaseAPI_DeleteRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64)) *GitHubReleaseAPI_DeleteRelease_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ var arg0 context.Context
332+ if args[0] != nil {
333+ arg0 = args[0].(context.Context)
334+ }
335+ var arg1 domain.Principal
336+ if args[1] != nil {
337+ arg1 = args[1].(domain.Principal)
338+ }
339+ var arg2 string
340+ if args[2] != nil {
341+ arg2 = args[2].(string)
342+ }
343+ var arg3 string
344+ if args[3] != nil {
345+ arg3 = args[3].(string)
346+ }
347+ var arg4 int64
348+ if args[4] != nil {
349+ arg4 = args[4].(int64)
350+ }
351+ run(
352+ arg0,
353+ arg1,
354+ arg2,
355+ arg3,
356+ arg4,
357+ )
358+ })
359+ return _c
360+}
361+
362+func (_c *GitHubReleaseAPI_DeleteRelease_Call) Return(err error) *GitHubReleaseAPI_DeleteRelease_Call {
363+ _c.Call.Return(err)
364+ return _c
365+}
366+
367+func (_c *GitHubReleaseAPI_DeleteRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64) error) *GitHubReleaseAPI_DeleteRelease_Call {
368+ _c.Call.Return(run)
369+ return _c
370+}
371+
372+// GetAsset provides a mock function for the type GitHubReleaseAPI
373+func (_mock *GitHubReleaseAPI) GetAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error) {
374+ ret := _mock.Called(ctx, p, owner, repo, assetID)
375+
376+ if len(ret) == 0 {
377+ panic("no return value specified for GetAsset")
378+ }
379+
380+ var r0 []byte
381+ var r1 error
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) ([]byte, error)); ok {
383+ return returnFunc(ctx, p, owner, repo, assetID)
384+ }
385+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64) []byte); ok {
386+ r0 = returnFunc(ctx, p, owner, repo, assetID)
387+ } else {
388+ if ret.Get(0) != nil {
389+ r0 = ret.Get(0).([]byte)
390+ }
391+ }
392+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64) error); ok {
393+ r1 = returnFunc(ctx, p, owner, repo, assetID)
394+ } else {
395+ r1 = ret.Error(1)
396+ }
397+ return r0, r1
398+}
399+
400+// GitHubReleaseAPI_GetAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAsset'
401+type GitHubReleaseAPI_GetAsset_Call struct {
402+ *mock.Call
403+}
404+
405+// GetAsset is a helper method to define mock.On call
406+// - ctx context.Context
407+// - p domain.Principal
408+// - owner string
409+// - repo string
410+// - assetID int64
411+func (_e *GitHubReleaseAPI_Expecter) GetAsset(ctx any, p any, owner any, repo any, assetID any) *GitHubReleaseAPI_GetAsset_Call {
412+ return &GitHubReleaseAPI_GetAsset_Call{Call: _e.mock.On("GetAsset", ctx, p, owner, repo, assetID)}
413+}
414+
415+func (_c *GitHubReleaseAPI_GetAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64)) *GitHubReleaseAPI_GetAsset_Call {
416+ _c.Call.Run(func(args mock.Arguments) {
417+ var arg0 context.Context
418+ if args[0] != nil {
419+ arg0 = args[0].(context.Context)
420+ }
421+ var arg1 domain.Principal
422+ if args[1] != nil {
423+ arg1 = args[1].(domain.Principal)
424+ }
425+ var arg2 string
426+ if args[2] != nil {
427+ arg2 = args[2].(string)
428+ }
429+ var arg3 string
430+ if args[3] != nil {
431+ arg3 = args[3].(string)
432+ }
433+ var arg4 int64
434+ if args[4] != nil {
435+ arg4 = args[4].(int64)
436+ }
437+ run(
438+ arg0,
439+ arg1,
440+ arg2,
441+ arg3,
442+ arg4,
443+ )
444+ })
445+ return _c
446+}
447+
448+func (_c *GitHubReleaseAPI_GetAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetAsset_Call {
449+ _c.Call.Return(bytes, err)
450+ return _c
451+}
452+
453+func (_c *GitHubReleaseAPI_GetAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64) ([]byte, error)) *GitHubReleaseAPI_GetAsset_Call {
454+ _c.Call.Return(run)
455+ return _c
456+}
457+
458+// GetRelease provides a mock function for the type GitHubReleaseAPI
459+func (_mock *GitHubReleaseAPI) GetRelease(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error) {
460+ ret := _mock.Called(ctx, p, owner, repo, ref)
461+
462+ if len(ret) == 0 {
463+ panic("no return value specified for GetRelease")
464+ }
465+
466+ var r0 []byte
467+ var r1 error
468+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) ([]byte, error)); ok {
469+ return returnFunc(ctx, p, owner, repo, ref)
470+ }
471+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) []byte); ok {
472+ r0 = returnFunc(ctx, p, owner, repo, ref)
473+ } else {
474+ if ret.Get(0) != nil {
475+ r0 = ret.Get(0).([]byte)
476+ }
477+ }
478+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, ports.ReleaseRef) error); ok {
479+ r1 = returnFunc(ctx, p, owner, repo, ref)
480+ } else {
481+ r1 = ret.Error(1)
482+ }
483+ return r0, r1
484+}
485+
486+// GitHubReleaseAPI_GetRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRelease'
487+type GitHubReleaseAPI_GetRelease_Call struct {
488+ *mock.Call
489+}
490+
491+// GetRelease is a helper method to define mock.On call
492+// - ctx context.Context
493+// - p domain.Principal
494+// - owner string
495+// - repo string
496+// - ref ports.ReleaseRef
497+func (_e *GitHubReleaseAPI_Expecter) GetRelease(ctx any, p any, owner any, repo any, ref any) *GitHubReleaseAPI_GetRelease_Call {
498+ return &GitHubReleaseAPI_GetRelease_Call{Call: _e.mock.On("GetRelease", ctx, p, owner, repo, ref)}
499+}
500+
501+func (_c *GitHubReleaseAPI_GetRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef)) *GitHubReleaseAPI_GetRelease_Call {
502+ _c.Call.Run(func(args mock.Arguments) {
503+ var arg0 context.Context
504+ if args[0] != nil {
505+ arg0 = args[0].(context.Context)
506+ }
507+ var arg1 domain.Principal
508+ if args[1] != nil {
509+ arg1 = args[1].(domain.Principal)
510+ }
511+ var arg2 string
512+ if args[2] != nil {
513+ arg2 = args[2].(string)
514+ }
515+ var arg3 string
516+ if args[3] != nil {
517+ arg3 = args[3].(string)
518+ }
519+ var arg4 ports.ReleaseRef
520+ if args[4] != nil {
521+ arg4 = args[4].(ports.ReleaseRef)
522+ }
523+ run(
524+ arg0,
525+ arg1,
526+ arg2,
527+ arg3,
528+ arg4,
529+ )
530+ })
531+ return _c
532+}
533+
534+func (_c *GitHubReleaseAPI_GetRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_GetRelease_Call {
535+ _c.Call.Return(bytes, err)
536+ return _c
537+}
538+
539+func (_c *GitHubReleaseAPI_GetRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, ref ports.ReleaseRef) ([]byte, error)) *GitHubReleaseAPI_GetRelease_Call {
540+ _c.Call.Return(run)
541+ return _c
542+}
543+
544+// ListAssets provides a mock function for the type GitHubReleaseAPI
545+func (_mock *GitHubReleaseAPI) ListAssets(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error) {
546+ ret := _mock.Called(ctx, p, owner, repo, id, page, perPage)
547+
548+ if len(ret) == 0 {
549+ panic("no return value specified for ListAssets")
550+ }
551+
552+ var r0 []byte
553+ var r1 string
554+ var r2 error
555+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) ([]byte, string, error)); ok {
556+ return returnFunc(ctx, p, owner, repo, id, page, perPage)
557+ }
558+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, int, int) []byte); ok {
559+ r0 = returnFunc(ctx, p, owner, repo, id, page, perPage)
560+ } else {
561+ if ret.Get(0) != nil {
562+ r0 = ret.Get(0).([]byte)
563+ }
564+ }
565+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, int, int) string); ok {
566+ r1 = returnFunc(ctx, p, owner, repo, id, page, perPage)
567+ } else {
568+ r1 = ret.Get(1).(string)
569+ }
570+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int64, int, int) error); ok {
571+ r2 = returnFunc(ctx, p, owner, repo, id, page, perPage)
572+ } else {
573+ r2 = ret.Error(2)
574+ }
575+ return r0, r1, r2
576+}
577+
578+// GitHubReleaseAPI_ListAssets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAssets'
579+type GitHubReleaseAPI_ListAssets_Call struct {
580+ *mock.Call
581+}
582+
583+// ListAssets is a helper method to define mock.On call
584+// - ctx context.Context
585+// - p domain.Principal
586+// - owner string
587+// - repo string
588+// - id int64
589+// - page int
590+// - perPage int
591+func (_e *GitHubReleaseAPI_Expecter) ListAssets(ctx any, p any, owner any, repo any, id any, page any, perPage any) *GitHubReleaseAPI_ListAssets_Call {
592+ return &GitHubReleaseAPI_ListAssets_Call{Call: _e.mock.On("ListAssets", ctx, p, owner, repo, id, page, perPage)}
593+}
594+
595+func (_c *GitHubReleaseAPI_ListAssets_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int)) *GitHubReleaseAPI_ListAssets_Call {
596+ _c.Call.Run(func(args mock.Arguments) {
597+ var arg0 context.Context
598+ if args[0] != nil {
599+ arg0 = args[0].(context.Context)
600+ }
601+ var arg1 domain.Principal
602+ if args[1] != nil {
603+ arg1 = args[1].(domain.Principal)
604+ }
605+ var arg2 string
606+ if args[2] != nil {
607+ arg2 = args[2].(string)
608+ }
609+ var arg3 string
610+ if args[3] != nil {
611+ arg3 = args[3].(string)
612+ }
613+ var arg4 int64
614+ if args[4] != nil {
615+ arg4 = args[4].(int64)
616+ }
617+ var arg5 int
618+ if args[5] != nil {
619+ arg5 = args[5].(int)
620+ }
621+ var arg6 int
622+ if args[6] != nil {
623+ arg6 = args[6].(int)
624+ }
625+ run(
626+ arg0,
627+ arg1,
628+ arg2,
629+ arg3,
630+ arg4,
631+ arg5,
632+ arg6,
633+ )
634+ })
635+ return _c
636+}
637+
638+func (_c *GitHubReleaseAPI_ListAssets_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListAssets_Call {
639+ _c.Call.Return(body, link, err)
640+ return _c
641+}
642+
643+func (_c *GitHubReleaseAPI_ListAssets_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListAssets_Call {
644+ _c.Call.Return(run)
645+ return _c
646+}
647+
648+// ListReleases provides a mock function for the type GitHubReleaseAPI
649+func (_mock *GitHubReleaseAPI) ListReleases(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error) {
650+ ret := _mock.Called(ctx, p, owner, repo, page, perPage)
651+
652+ if len(ret) == 0 {
653+ panic("no return value specified for ListReleases")
654+ }
655+
656+ var r0 []byte
657+ var r1 string
658+ var r2 error
659+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) ([]byte, string, error)); ok {
660+ return returnFunc(ctx, p, owner, repo, page, perPage)
661+ }
662+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int, int) []byte); ok {
663+ r0 = returnFunc(ctx, p, owner, repo, page, perPage)
664+ } else {
665+ if ret.Get(0) != nil {
666+ r0 = ret.Get(0).([]byte)
667+ }
668+ }
669+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int, int) string); ok {
670+ r1 = returnFunc(ctx, p, owner, repo, page, perPage)
671+ } else {
672+ r1 = ret.Get(1).(string)
673+ }
674+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, string, string, int, int) error); ok {
675+ r2 = returnFunc(ctx, p, owner, repo, page, perPage)
676+ } else {
677+ r2 = ret.Error(2)
678+ }
679+ return r0, r1, r2
680+}
681+
682+// GitHubReleaseAPI_ListReleases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReleases'
683+type GitHubReleaseAPI_ListReleases_Call struct {
684+ *mock.Call
685+}
686+
687+// ListReleases is a helper method to define mock.On call
688+// - ctx context.Context
689+// - p domain.Principal
690+// - owner string
691+// - repo string
692+// - page int
693+// - perPage int
694+func (_e *GitHubReleaseAPI_Expecter) ListReleases(ctx any, p any, owner any, repo any, page any, perPage any) *GitHubReleaseAPI_ListReleases_Call {
695+ return &GitHubReleaseAPI_ListReleases_Call{Call: _e.mock.On("ListReleases", ctx, p, owner, repo, page, perPage)}
696+}
697+
698+func (_c *GitHubReleaseAPI_ListReleases_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int)) *GitHubReleaseAPI_ListReleases_Call {
699+ _c.Call.Run(func(args mock.Arguments) {
700+ var arg0 context.Context
701+ if args[0] != nil {
702+ arg0 = args[0].(context.Context)
703+ }
704+ var arg1 domain.Principal
705+ if args[1] != nil {
706+ arg1 = args[1].(domain.Principal)
707+ }
708+ var arg2 string
709+ if args[2] != nil {
710+ arg2 = args[2].(string)
711+ }
712+ var arg3 string
713+ if args[3] != nil {
714+ arg3 = args[3].(string)
715+ }
716+ var arg4 int
717+ if args[4] != nil {
718+ arg4 = args[4].(int)
719+ }
720+ var arg5 int
721+ if args[5] != nil {
722+ arg5 = args[5].(int)
723+ }
724+ run(
725+ arg0,
726+ arg1,
727+ arg2,
728+ arg3,
729+ arg4,
730+ arg5,
731+ )
732+ })
733+ return _c
734+}
735+
736+func (_c *GitHubReleaseAPI_ListReleases_Call) Return(body []byte, link string, err error) *GitHubReleaseAPI_ListReleases_Call {
737+ _c.Call.Return(body, link, err)
738+ return _c
739+}
740+
741+func (_c *GitHubReleaseAPI_ListReleases_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, page int, perPage int) ([]byte, string, error)) *GitHubReleaseAPI_ListReleases_Call {
742+ _c.Call.Return(run)
743+ return _c
744+}
745+
746+// UpdateAsset provides a mock function for the type GitHubReleaseAPI
747+func (_mock *GitHubReleaseAPI) UpdateAsset(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error) {
748+ ret := _mock.Called(ctx, p, owner, repo, assetID, body)
749+
750+ if len(ret) == 0 {
751+ panic("no return value specified for UpdateAsset")
752+ }
753+
754+ var r0 []byte
755+ var r1 error
756+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok {
757+ return returnFunc(ctx, p, owner, repo, assetID, body)
758+ }
759+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok {
760+ r0 = returnFunc(ctx, p, owner, repo, assetID, body)
761+ } else {
762+ if ret.Get(0) != nil {
763+ r0 = ret.Get(0).([]byte)
764+ }
765+ }
766+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok {
767+ r1 = returnFunc(ctx, p, owner, repo, assetID, body)
768+ } else {
769+ r1 = ret.Error(1)
770+ }
771+ return r0, r1
772+}
773+
774+// GitHubReleaseAPI_UpdateAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAsset'
775+type GitHubReleaseAPI_UpdateAsset_Call struct {
776+ *mock.Call
777+}
778+
779+// UpdateAsset is a helper method to define mock.On call
780+// - ctx context.Context
781+// - p domain.Principal
782+// - owner string
783+// - repo string
784+// - assetID int64
785+// - body []byte
786+func (_e *GitHubReleaseAPI_Expecter) UpdateAsset(ctx any, p any, owner any, repo any, assetID any, body any) *GitHubReleaseAPI_UpdateAsset_Call {
787+ return &GitHubReleaseAPI_UpdateAsset_Call{Call: _e.mock.On("UpdateAsset", ctx, p, owner, repo, assetID, body)}
788+}
789+
790+func (_c *GitHubReleaseAPI_UpdateAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte)) *GitHubReleaseAPI_UpdateAsset_Call {
791+ _c.Call.Run(func(args mock.Arguments) {
792+ var arg0 context.Context
793+ if args[0] != nil {
794+ arg0 = args[0].(context.Context)
795+ }
796+ var arg1 domain.Principal
797+ if args[1] != nil {
798+ arg1 = args[1].(domain.Principal)
799+ }
800+ var arg2 string
801+ if args[2] != nil {
802+ arg2 = args[2].(string)
803+ }
804+ var arg3 string
805+ if args[3] != nil {
806+ arg3 = args[3].(string)
807+ }
808+ var arg4 int64
809+ if args[4] != nil {
810+ arg4 = args[4].(int64)
811+ }
812+ var arg5 []byte
813+ if args[5] != nil {
814+ arg5 = args[5].([]byte)
815+ }
816+ run(
817+ arg0,
818+ arg1,
819+ arg2,
820+ arg3,
821+ arg4,
822+ arg5,
823+ )
824+ })
825+ return _c
826+}
827+
828+func (_c *GitHubReleaseAPI_UpdateAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateAsset_Call {
829+ _c.Call.Return(bytes, err)
830+ return _c
831+}
832+
833+func (_c *GitHubReleaseAPI_UpdateAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, assetID int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateAsset_Call {
834+ _c.Call.Return(run)
835+ return _c
836+}
837+
838+// UpdateRelease provides a mock function for the type GitHubReleaseAPI
839+func (_mock *GitHubReleaseAPI) UpdateRelease(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error) {
840+ ret := _mock.Called(ctx, p, owner, repo, id, body)
841+
842+ if len(ret) == 0 {
843+ panic("no return value specified for UpdateRelease")
844+ }
845+
846+ var r0 []byte
847+ var r1 error
848+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) ([]byte, error)); ok {
849+ return returnFunc(ctx, p, owner, repo, id, body)
850+ }
851+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, []byte) []byte); ok {
852+ r0 = returnFunc(ctx, p, owner, repo, id, body)
853+ } else {
854+ if ret.Get(0) != nil {
855+ r0 = ret.Get(0).([]byte)
856+ }
857+ }
858+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, []byte) error); ok {
859+ r1 = returnFunc(ctx, p, owner, repo, id, body)
860+ } else {
861+ r1 = ret.Error(1)
862+ }
863+ return r0, r1
864+}
865+
866+// GitHubReleaseAPI_UpdateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRelease'
867+type GitHubReleaseAPI_UpdateRelease_Call struct {
868+ *mock.Call
869+}
870+
871+// UpdateRelease is a helper method to define mock.On call
872+// - ctx context.Context
873+// - p domain.Principal
874+// - owner string
875+// - repo string
876+// - id int64
877+// - body []byte
878+func (_e *GitHubReleaseAPI_Expecter) UpdateRelease(ctx any, p any, owner any, repo any, id any, body any) *GitHubReleaseAPI_UpdateRelease_Call {
879+ return &GitHubReleaseAPI_UpdateRelease_Call{Call: _e.mock.On("UpdateRelease", ctx, p, owner, repo, id, body)}
880+}
881+
882+func (_c *GitHubReleaseAPI_UpdateRelease_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte)) *GitHubReleaseAPI_UpdateRelease_Call {
883+ _c.Call.Run(func(args mock.Arguments) {
884+ var arg0 context.Context
885+ if args[0] != nil {
886+ arg0 = args[0].(context.Context)
887+ }
888+ var arg1 domain.Principal
889+ if args[1] != nil {
890+ arg1 = args[1].(domain.Principal)
891+ }
892+ var arg2 string
893+ if args[2] != nil {
894+ arg2 = args[2].(string)
895+ }
896+ var arg3 string
897+ if args[3] != nil {
898+ arg3 = args[3].(string)
899+ }
900+ var arg4 int64
901+ if args[4] != nil {
902+ arg4 = args[4].(int64)
903+ }
904+ var arg5 []byte
905+ if args[5] != nil {
906+ arg5 = args[5].([]byte)
907+ }
908+ run(
909+ arg0,
910+ arg1,
911+ arg2,
912+ arg3,
913+ arg4,
914+ arg5,
915+ )
916+ })
917+ return _c
918+}
919+
920+func (_c *GitHubReleaseAPI_UpdateRelease_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UpdateRelease_Call {
921+ _c.Call.Return(bytes, err)
922+ return _c
923+}
924+
925+func (_c *GitHubReleaseAPI_UpdateRelease_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, body []byte) ([]byte, error)) *GitHubReleaseAPI_UpdateRelease_Call {
926+ _c.Call.Return(run)
927+ return _c
928+}
929+
930+// UploadAsset provides a mock function for the type GitHubReleaseAPI
931+func (_mock *GitHubReleaseAPI) UploadAsset(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error) {
932+ ret := _mock.Called(ctx, p, owner, repo, id, name, label, contentType, size, r)
933+
934+ if len(ret) == 0 {
935+ panic("no return value specified for UploadAsset")
936+ }
937+
938+ var r0 []byte
939+ var r1 error
940+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) ([]byte, error)); ok {
941+ return returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
942+ }
943+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) []byte); ok {
944+ r0 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
945+ } else {
946+ if ret.Get(0) != nil {
947+ r0 = ret.Get(0).([]byte)
948+ }
949+ }
950+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, string, string, int64, string, string, string, int64, io.Reader) error); ok {
951+ r1 = returnFunc(ctx, p, owner, repo, id, name, label, contentType, size, r)
952+ } else {
953+ r1 = ret.Error(1)
954+ }
955+ return r0, r1
956+}
957+
958+// GitHubReleaseAPI_UploadAsset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UploadAsset'
959+type GitHubReleaseAPI_UploadAsset_Call struct {
960+ *mock.Call
961+}
962+
963+// UploadAsset is a helper method to define mock.On call
964+// - ctx context.Context
965+// - p domain.Principal
966+// - owner string
967+// - repo string
968+// - id int64
969+// - name string
970+// - label string
971+// - contentType string
972+// - size int64
973+// - r io.Reader
974+func (_e *GitHubReleaseAPI_Expecter) UploadAsset(ctx any, p any, owner any, repo any, id any, name any, label any, contentType any, size any, r any) *GitHubReleaseAPI_UploadAsset_Call {
975+ return &GitHubReleaseAPI_UploadAsset_Call{Call: _e.mock.On("UploadAsset", ctx, p, owner, repo, id, name, label, contentType, size, r)}
976+}
977+
978+func (_c *GitHubReleaseAPI_UploadAsset_Call) Run(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader)) *GitHubReleaseAPI_UploadAsset_Call {
979+ _c.Call.Run(func(args mock.Arguments) {
980+ var arg0 context.Context
981+ if args[0] != nil {
982+ arg0 = args[0].(context.Context)
983+ }
984+ var arg1 domain.Principal
985+ if args[1] != nil {
986+ arg1 = args[1].(domain.Principal)
987+ }
988+ var arg2 string
989+ if args[2] != nil {
990+ arg2 = args[2].(string)
991+ }
992+ var arg3 string
993+ if args[3] != nil {
994+ arg3 = args[3].(string)
995+ }
996+ var arg4 int64
997+ if args[4] != nil {
998+ arg4 = args[4].(int64)
999+ }
1000+ var arg5 string
1001+ if args[5] != nil {
1002+ arg5 = args[5].(string)
1003+ }
1004+ var arg6 string
1005+ if args[6] != nil {
1006+ arg6 = args[6].(string)
1007+ }
1008+ var arg7 string
1009+ if args[7] != nil {
1010+ arg7 = args[7].(string)
1011+ }
1012+ var arg8 int64
1013+ if args[8] != nil {
1014+ arg8 = args[8].(int64)
1015+ }
1016+ var arg9 io.Reader
1017+ if args[9] != nil {
1018+ arg9 = args[9].(io.Reader)
1019+ }
1020+ run(
1021+ arg0,
1022+ arg1,
1023+ arg2,
1024+ arg3,
1025+ arg4,
1026+ arg5,
1027+ arg6,
1028+ arg7,
1029+ arg8,
1030+ arg9,
1031+ )
1032+ })
1033+ return _c
1034+}
1035+
1036+func (_c *GitHubReleaseAPI_UploadAsset_Call) Return(bytes []byte, err error) *GitHubReleaseAPI_UploadAsset_Call {
1037+ _c.Call.Return(bytes, err)
1038+ return _c
1039+}
1040+
1041+func (_c *GitHubReleaseAPI_UploadAsset_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, owner string, repo string, id int64, name string, label string, contentType string, size int64, r io.Reader) ([]byte, error)) *GitHubReleaseAPI_UploadAsset_Call {
1042+ _c.Call.Return(run)
1043+ return _c
1044+}
M internal/ports/services.go +17 -0
@@ -390,3 +390,20 @@ type EnvironmentService interface {
390390 ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error)
391391 DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error
392392 }
393+
394+// GitHubReleaseAPI is the Releases part of the REST subset. owner/repo follow
395+// GitHubAPIService; bodies are GitHub JSON. *usecase.ReleasesGitHub implements it.
396+type GitHubReleaseAPI interface {
397+ CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error)
398+ GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ReleaseRef) ([]byte, error)
399+ ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) (body []byte, link string, err error)
400+ UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error)
401+ DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error
402+ ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) (body []byte, link string, err error)
403+ UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, r io.Reader) ([]byte, error)
404+ GetAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) ([]byte, error)
405+ UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64, body []byte) ([]byte, error)
406+ DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) error
407+ // AssetByID resolves metadata for a download through ReleaseService.
408+ AssetByID(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) (domain.ReleaseAsset, error)
409+}
@@ -390,3 +390,20 @@ type EnvironmentService interface {
390 ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error)390 ListEnvironments(ctx context.Context, p domain.Principal, repo domain.RepoName, size int, token string) (EnvironmentPage, error)
391 DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error391 DeleteEnvironment(ctx context.Context, p domain.Principal, repo domain.RepoName, name string) error
392 }392 }
393+
394+// GitHubReleaseAPI is the Releases part of the REST subset. owner/repo follow
395+// GitHubAPIService; bodies are GitHub JSON. *usecase.ReleasesGitHub implements it.
396+type GitHubReleaseAPI interface {
397+ CreateRelease(ctx context.Context, p domain.Principal, owner, repo string, body []byte) ([]byte, error)
398+ GetRelease(ctx context.Context, p domain.Principal, owner, repo string, ref ReleaseRef) ([]byte, error)
399+ ListReleases(ctx context.Context, p domain.Principal, owner, repo string, page, perPage int) (body []byte, link string, err error)
400+ UpdateRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64, body []byte) ([]byte, error)
401+ DeleteRelease(ctx context.Context, p domain.Principal, owner, repo string, id int64) error
402+ ListAssets(ctx context.Context, p domain.Principal, owner, repo string, id int64, page, perPage int) (body []byte, link string, err error)
403+ UploadAsset(ctx context.Context, p domain.Principal, owner, repo string, id int64, name, label, contentType string, size int64, r io.Reader) ([]byte, error)
404+ GetAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) ([]byte, error)
405+ UpdateAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64, body []byte) ([]byte, error)
406+ DeleteAsset(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) error
407+ // AssetByID resolves metadata for a download through ReleaseService.
408+ AssetByID(ctx context.Context, p domain.Principal, owner, repo string, assetID int64) (domain.ReleaseAsset, error)
409+}
A internal/usecase/releases_github.go +387 -0
@@ -0,0 +1,387 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
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.
21+type ReleasesGitHub struct {
22+ rel *Releases
23+ owner, publicURL string
24+}
25+
26+var _ 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.
30+func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub {
31+ return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")}
32+}
33+
34+func (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+}
44+func (g *ReleasesGitHub) base(repo domain.RepoName) string {
45+ return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String())
46+}
47+func (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.
56+func 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+}
63+func (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/" + escapeReleaseTag(tag) + "/" + url.PathEscape(a.Name)},
71+ }
72+}
73+func (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+}
97+func (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+}
104+func 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+}
111+func 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+}
122+func 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+}
159+func 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.
167+func (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.
188+func (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.
201+func (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.
219+func (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+}
230+func 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+}
239+func 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.
249+func (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.
289+func (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+}
311+func (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.
320+func (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.
333+func (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.
342+func (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.
351+func (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.
377+func (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+}
@@ -0,0 +1,387 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
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.
21+type ReleasesGitHub struct {
22+ rel *Releases
23+ owner, publicURL string
24+}
25+
26+var _ 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.
30+func NewReleasesGitHub(rel *Releases, owner, publicURL string) *ReleasesGitHub {
31+ return &ReleasesGitHub{rel: rel, owner: owner, publicURL: strings.TrimRight(publicURL, "/")}
32+}
33+
34+func (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+}
44+func (g *ReleasesGitHub) base(repo domain.RepoName) string {
45+ return g.publicURL + apiPath + "/repos/" + url.PathEscape(g.owner) + "/" + url.PathEscape(repo.String())
46+}
47+func (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.
56+func 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+}
63+func (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/" + escapeReleaseTag(tag) + "/" + url.PathEscape(a.Name)},
71+ }
72+}
73+func (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+}
97+func (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+}
104+func 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+}
111+func 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+}
122+func 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+}
159+func 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.
167+func (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.
188+func (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.
201+func (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.
219+func (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+}
230+func 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+}
239+func 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.
249+func (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.
289+func (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+}
311+func (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.
320+func (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.
333+func (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.
342+func (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.
351+func (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.
377+func (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+}
A internal/usecase/releases_github_test.go +347 -0
@@ -0,0 +1,347 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "math"
11+ "net/http"
12+ "net/http/httptest"
13+ "os"
14+ "path/filepath"
15+ "strconv"
16+ "strings"
17+ "testing"
18+
19+ "bnema.dev/ermite/internal/adapters/githubapi"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+ "bnema.dev/ermite/internal/usecase"
24+ "github.com/stretchr/testify/mock"
25+ "github.com/stretchr/testify/require"
26+)
27+
28+func releaseGitHub(t *testing.T, r *releaseRig) *usecase.ReleasesGitHub {
29+ t.Helper()
30+ return usecase.NewReleasesGitHub(r.svc, "acme", "http://localhost:7341/")
31+}
32+func releaseGolden(t *testing.T, file string, body []byte) {
33+ t.Helper()
34+ var pretty bytes.Buffer
35+ require.NoError(t, json.Indent(&pretty, body, "", " "))
36+ pretty.WriteByte('\n')
37+ path := filepath.Join("..", "adapters", "githubapi", "testdata", "releases", file+".json")
38+ if os.Getenv("UPDATE_RELEASE_GOLDENS") == "1" {
39+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
40+ require.NoError(t, os.WriteFile(path, pretty.Bytes(), 0644))
41+ }
42+ want, err := os.ReadFile(path)
43+ require.NoError(t, err)
44+ require.Equal(t, string(want), pretty.String())
45+}
46+func TestGitHubReleaseGoldens(t *testing.T) {
47+ r := newReleaseRig(t, ports.ReleaseLimits{})
48+ g := releaseGitHub(t, r)
49+ rel := r.create(t, "v1", false)
50+ for _, name := range []string{"ermite.tar.gz", "checksums.txt"} {
51+ _, err := g.UploadAsset(r.ctx, domain.Root(), "acme", r.repo.String(), rel.ID, name, "", "application/octet-stream", 3, strings.NewReader("abc"))
52+ require.NoError(t, err)
53+ }
54+ body, err := g.GetRelease(r.ctx, domain.Anonymous(), "ACME", "RELEASES", ports.ReleaseRef{Tag: "v1"})
55+ require.NoError(t, err)
56+ releaseGolden(t, "release", body)
57+ draft := r.create(t, "next", true)
58+ body, err = g.GetRelease(r.ctx, domain.Root(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
59+ require.NoError(t, err)
60+ releaseGolden(t, "draft", body)
61+ _, err = g.GetRelease(r.ctx, domain.Anonymous(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
62+ require.ErrorIs(t, err, domain.ErrNotFound)
63+}
64+func TestGitHubReleaseRequests(t *testing.T) {
65+ r := newReleaseRig(t, ports.ReleaseLimits{})
66+ g := releaseGitHub(t, r)
67+ repo := r.repo.String()
68+ p := domain.Root()
69+ r.resolve(t, "v1", nil)
70+ body, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1","name":"Title","body":"Notes","draft":true,"prerelease":true,"discussion_category_name":"ignored","make_latest":"legacy"}`))
71+ require.NoError(t, err)
72+ var rel struct {
73+ ID int64 `json:"id"`
74+ }
75+ require.NoError(t, json.Unmarshal(body, &rel))
76+ body, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"name":"","draft":false,"prerelease":false,"make_latest":"true","unknown":1}`))
77+ require.NoError(t, err)
78+ var got map[string]any
79+ require.NoError(t, json.Unmarshal(body, &got))
80+ require.Equal(t, "", got["name"])
81+ require.Equal(t, "Notes", got["body"])
82+ require.Equal(t, false, got["draft"])
83+ require.Equal(t, false, got["prerelease"])
84+ require.NotContains(t, got, "make_latest")
85+ stored, err := r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
86+ require.NoError(t, err)
87+ require.True(t, stored.MakeLatest)
88+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"legacy"}`))
89+ require.NoError(t, err)
90+ stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
91+ require.NoError(t, err)
92+ require.True(t, stored.MakeLatest)
93+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"false"}`))
94+ require.NoError(t, err)
95+ stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
96+ require.NoError(t, err)
97+ require.False(t, stored.MakeLatest)
98+ for _, bad := range []string{`{}`, `null`, `[]`, `{`, `{"tag_name":2}`, `{"tag_name":"v1","draft":"true"}`, `{"tag_name":"v1","name":null}`, `{"tag_name":"v1","make_latest":true}`, `{"tag_name":"v1","make_latest":"yes"}`} {
99+ _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(bad))
100+ require.ErrorIs(t, err, domain.ErrInvalid, bad)
101+ }
102+ _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1"}`))
103+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
104+ _, err = g.GetRelease(r.ctx, p, "other", repo, ports.ReleaseRef{ID: rel.ID})
105+ require.ErrorIs(t, err, domain.ErrNotFound)
106+ _, err = g.GetRelease(r.ctx, p, "acme", "bad/name", ports.ReleaseRef{ID: rel.ID})
107+ require.ErrorIs(t, err, domain.ErrNotFound)
108+}
109+func TestGitHubReleasePagination(t *testing.T) {
110+ r := newReleaseRig(t, ports.ReleaseLimits{})
111+ g := releaseGitHub(t, r)
112+ for i := 0; i < 5; i++ {
113+ r.create(t, "v"+strconv.Itoa(i), i == 4)
114+ }
115+ for page := 1; page <= 3; page++ {
116+ b, link, err := g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
117+ require.NoError(t, err)
118+ var rows []struct {
119+ ID int64 `json:"id"`
120+ }
121+ require.NoError(t, json.Unmarshal(b, &rows))
122+ require.Len(t, rows, min(2, 5-(page-1)*2))
123+ require.Equal(t, int64(5-(page-1)*2), rows[0].ID)
124+ base := "http://localhost:7341/-/github/api/repos/acme/releases/releases?per_page=2&page="
125+ switch page {
126+ case 1:
127+ require.Equal(t, `<`+base+`2>; rel="next", <`+base+`3>; rel="last"`, link)
128+ case 2:
129+ require.Equal(t, `<`+base+`3>; rel="next", <`+base+`3>; rel="last", <`+base+`1>; rel="first", <`+base+`1>; rel="prev"`, link)
130+ case 3:
131+ require.Equal(t, `<`+base+`1>; rel="first", <`+base+`2>; rel="prev"`, link)
132+ }
133+ }
134+ b, _, err := g.ListReleases(r.ctx, domain.Anonymous(), "acme", r.repo.String(), 0, 0)
135+ require.NoError(t, err)
136+ var rows []any
137+ require.NoError(t, json.Unmarshal(b, &rows))
138+ require.Len(t, rows, 4)
139+ for _, page := range []int{4, math.MaxInt} {
140+ b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
141+ require.NoError(t, err)
142+ require.Equal(t, "[]", string(b))
143+ }
144+ b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), 1, 1000)
145+ require.NoError(t, err)
146+ require.NoError(t, json.Unmarshal(b, &rows))
147+ require.Len(t, rows, 5)
148+}
149+func TestGitHubReleaseAssetsAndRetry(t *testing.T) {
150+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10})
151+ g := releaseGitHub(t, r)
152+ p := domain.Root()
153+ repo := r.repo.String()
154+ rel := r.create(t, "v1/slash", false)
155+ upload := func(name string) ([]byte, error) {
156+ return g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, name, "label", "text/plain", 3, strings.NewReader("abc"))
157+ }
158+ b, err := upload("é #?.txt")
159+ require.NoError(t, err)
160+ var asset struct {
161+ ID int64 `json:"id"`
162+ URL string `json:"browser_download_url"`
163+ }
164+ require.NoError(t, json.Unmarshal(b, &asset))
165+ require.Equal(t, "http://localhost:7341/releases/releases/download/v1/slash/%C3%A9%20%23%3F.txt", asset.URL)
166+ _, err = upload("é #?.txt")
167+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
168+ b, err = g.UpdateAsset(r.ctx, p, "acme", repo, asset.ID, []byte(`{"label":""}`))
169+ require.NoError(t, err)
170+ require.Contains(t, string(b), `"label":""`)
171+ require.Contains(t, string(b), `"name":"é #?.txt"`)
172+ b, err = g.GetAsset(r.ctx, domain.Anonymous(), "acme", repo, asset.ID)
173+ require.NoError(t, err)
174+ require.Contains(t, string(b), `"state":"uploaded"`)
175+ for i := 0; i < 2; i++ {
176+ _, err = upload(fmt.Sprint(i))
177+ require.NoError(t, err)
178+ }
179+ b, link, err := g.ListAssets(r.ctx, p, "acme", repo, rel.ID, 1, 2)
180+ require.NoError(t, err)
181+ require.Contains(t, link, `rel="next"`)
182+ var assets []any
183+ require.NoError(t, json.Unmarshal(b, &assets))
184+ require.Len(t, assets, 2)
185+ require.NoError(t, g.DeleteAsset(r.ctx, p, "acme", repo, asset.ID))
186+ _, err = g.AssetByID(r.ctx, p, "acme", repo, asset.ID)
187+ require.ErrorIs(t, err, domain.ErrNotFound)
188+ _, err = upload("é #?.txt")
189+ require.NoError(t, err)
190+ _, err = g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, "huge", "", "", 11, strings.NewReader("ignored"))
191+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
192+ require.NoError(t, g.DeleteRelease(r.ctx, p, "acme", repo, rel.ID))
193+ _, err = g.GetRelease(r.ctx, p, "acme", repo, ports.ReleaseRef{ID: rel.ID})
194+ require.ErrorIs(t, err, domain.ErrNotFound)
195+}
196+
197+func TestGitHubReleaseAuthorizationAndLatest(t *testing.T) {
198+ r := newReleaseRig(t, ports.ReleaseLimits{})
199+ g := releaseGitHub(t, r)
200+ repo := r.repo.String()
201+ stable := r.create(t, "v1", false)
202+ r.create(t, "draft", true)
203+ b, err := g.GetRelease(r.ctx, domain.Anonymous(), "acme", repo, ports.ReleaseRef{Latest: true})
204+ require.NoError(t, err)
205+ var got struct {
206+ ID int64 `json:"id"`
207+ }
208+ require.NoError(t, json.Unmarshal(b, &got))
209+ require.Equal(t, stable.ID, got.ID)
210+ writer := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionWrite}})
211+ reader := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
212+ branched := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, BranchPrefixes: []string{"feature/"}})
213+ for _, tc := range []struct {
214+ p domain.Principal
215+ tag string
216+ want error
217+ }{
218+ {domain.Anonymous(), "anonymous", domain.ErrUnauthenticated},
219+ {reader, "reader", domain.ErrForbidden},
220+ {branched, "branch", domain.ErrForbidden},
221+ {writer, "job", nil},
222+ } {
223+ if tc.want == nil {
224+ r.resolve(t, tc.tag, nil)
225+ }
226+ b, err = g.CreateRelease(r.ctx, tc.p, "acme", repo, []byte(`{"tag_name":"`+tc.tag+`"}`))
227+ if tc.want != nil {
228+ require.ErrorIs(t, err, tc.want)
229+ continue
230+ }
231+ require.NoError(t, err)
232+ require.NoError(t, json.Unmarshal(b, &got))
233+ _, err = g.UploadAsset(r.ctx, tc.p, "acme", repo, got.ID, "ci.tar.gz", "", "", 3, strings.NewReader("abc"))
234+ require.NoError(t, err)
235+ _, err = g.UploadAsset(r.ctx, reader, "acme", repo, got.ID, "read.tar.gz", "", "", 3, strings.NewReader("abc"))
236+ require.ErrorIs(t, err, domain.ErrForbidden)
237+ }
238+ other, err := domain.ParseRepoName("other")
239+ require.NoError(t, err)
240+ require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now}))
241+ b, _, err = g.ListAssets(r.ctx, domain.Root(), "acme", other.String(), stable.ID, 1, 30)
242+ require.ErrorIs(t, err, domain.ErrNotFound)
243+ require.Nil(t, b)
244+}
245+
246+func TestGitHubReleaseTargetAndMissingTag(t *testing.T) {
247+ r := newReleaseRig(t, ports.ReleaseLimits{})
248+ g := releaseGitHub(t, r)
249+ p := domain.Root()
250+ repo := r.repo.String()
251+ r.resolve(t, "missing", domain.ErrNotFound)
252+ _, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing"}`))
253+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
254+ b, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing","draft":true}`))
255+ require.NoError(t, err)
256+ var got struct {
257+ ID int64 `json:"id"`
258+ Target string `json:"target_commitish"`
259+ }
260+ require.NoError(t, json.Unmarshal(b, &got))
261+ require.Empty(t, got.Target)
262+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false}`))
263+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
264+ r.resolve(t, "new", domain.ErrNotFound)
265+ v, err := domain.ParseRevision("main")
266+ require.NoError(t, err)
267+ oid, err := domain.ParseObjectID(oidA)
268+ require.NoError(t, err)
269+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
270+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
271+ r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
272+ b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"new","target_commitish":"main"}`))
273+ require.NoError(t, err)
274+ require.NoError(t, json.Unmarshal(b, &got))
275+ require.Equal(t, oidA, got.Target)
276+}
277+
278+// A real HTTP round trip through the facade, metadata store and disk blob
279+// adapter protects the goreleaser/softprops create, retry, overwrite, publish
280+// sequence, not just each side's mocked contract.
281+func TestGitHubReleaseHTTPWorkflow(t *testing.T) {
282+ r := newReleaseRig(t, ports.ReleaseLimits{})
283+ g := releaseGitHub(t, r)
284+ auth := mocks.NewAuthService(t)
285+ auth.EXPECT().Authenticate(mock.Anything, "dev-token").Return(domain.Root(), nil).Maybe()
286+ srv := httptest.NewServer(githubapi.New(githubapi.Config{Auth: auth, Releases: g, ReleaseDownload: r.svc}))
287+ defer srv.Close()
288+ do := func(method, path, body string, headers map[string]string) (*http.Response, string) {
289+ req, err := http.NewRequestWithContext(r.ctx, method, srv.URL+githubapi.Prefix+path, strings.NewReader(body))
290+ require.NoError(t, err)
291+ req.Header.Set("Authorization", "Bearer dev-token")
292+ for k, v := range headers {
293+ req.Header.Set(k, v)
294+ }
295+ resp, err := srv.Client().Do(req)
296+ require.NoError(t, err)
297+ defer resp.Body.Close()
298+ b, err := io.ReadAll(resp.Body)
299+ require.NoError(t, err)
300+ return resp, string(b)
301+ }
302+ base := "/repos/acme/" + r.repo.String() + "/releases"
303+ resp, _ := do("GET", base+"/tags/v1", "", nil)
304+ require.Equal(t, 404, resp.StatusCode)
305+ r.resolve(t, "v1", nil)
306+ resp, b := do("POST", base, `{"tag_name":"v1","draft":true}`, nil)
307+ require.Equal(t, 201, resp.StatusCode)
308+ var rel struct {
309+ ID int64 `json:"id"`
310+ UploadURL string `json:"upload_url"`
311+ }
312+ require.NoError(t, json.Unmarshal([]byte(b), &rel))
313+ upload := strings.TrimPrefix(strings.Split(rel.UploadURL, "{")[0], "http://localhost:7341"+githubapi.Prefix) + "?name=archive.tar.gz"
314+ resp, b = do("POST", upload, "0123456789", map[string]string{"Content-Type": "application/gzip"})
315+ require.Equal(t, 201, resp.StatusCode)
316+ var a struct {
317+ ID int64 `json:"id"`
318+ }
319+ require.NoError(t, json.Unmarshal([]byte(b), &a))
320+ resp, b = do("POST", upload, "replacement", nil)
321+ require.Equal(t, 422, resp.StatusCode)
322+ require.Contains(t, b, `"code":"already_exists"`)
323+ resp, b = do("GET", base+"/"+strconv.FormatInt(rel.ID, 10)+"/assets", "", nil)
324+ require.Equal(t, 200, resp.StatusCode)
325+ require.Contains(t, b, `"name":"archive.tar.gz"`)
326+ assetURL := base + "/assets/" + strconv.FormatInt(a.ID, 10)
327+ resp, _ = do("DELETE", assetURL, "", nil)
328+ require.Equal(t, 204, resp.StatusCode)
329+ resp, b = do("POST", upload, "abcdefghij", nil)
330+ require.Equal(t, 201, resp.StatusCode)
331+ require.NoError(t, json.Unmarshal([]byte(b), &a))
332+ resp, _ = do("PATCH", base+"/"+strconv.FormatInt(rel.ID, 10), `{"draft":false}`, nil)
333+ require.Equal(t, 200, resp.StatusCode)
334+ assetURL = base + "/assets/" + strconv.FormatInt(a.ID, 10)
335+ resp, b = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5"})
336+ require.Equal(t, 206, resp.StatusCode)
337+ require.Equal(t, "cdef", b)
338+ require.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range"))
339+ require.Equal(t, "4", resp.Header.Get("Content-Length"))
340+ require.Empty(t, resp.Header.Get("Location"))
341+ resp, b = do("HEAD", assetURL, "", map[string]string{"Accept": "application/octet-stream"})
342+ require.Equal(t, 200, resp.StatusCode)
343+ require.Empty(t, b)
344+ require.Equal(t, "10", resp.Header.Get("Content-Length"))
345+ resp, _ = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "If-None-Match": resp.Header.Get("ETag")})
346+ require.Equal(t, 304, resp.StatusCode)
347+}
@@ -0,0 +1,347 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "math"
11+ "net/http"
12+ "net/http/httptest"
13+ "os"
14+ "path/filepath"
15+ "strconv"
16+ "strings"
17+ "testing"
18+
19+ "bnema.dev/ermite/internal/adapters/githubapi"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/ports/mocks"
23+ "bnema.dev/ermite/internal/usecase"
24+ "github.com/stretchr/testify/mock"
25+ "github.com/stretchr/testify/require"
26+)
27+
28+func releaseGitHub(t *testing.T, r *releaseRig) *usecase.ReleasesGitHub {
29+ t.Helper()
30+ return usecase.NewReleasesGitHub(r.svc, "acme", "http://localhost:7341/")
31+}
32+func releaseGolden(t *testing.T, file string, body []byte) {
33+ t.Helper()
34+ var pretty bytes.Buffer
35+ require.NoError(t, json.Indent(&pretty, body, "", " "))
36+ pretty.WriteByte('\n')
37+ path := filepath.Join("..", "adapters", "githubapi", "testdata", "releases", file+".json")
38+ if os.Getenv("UPDATE_RELEASE_GOLDENS") == "1" {
39+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
40+ require.NoError(t, os.WriteFile(path, pretty.Bytes(), 0644))
41+ }
42+ want, err := os.ReadFile(path)
43+ require.NoError(t, err)
44+ require.Equal(t, string(want), pretty.String())
45+}
46+func TestGitHubReleaseGoldens(t *testing.T) {
47+ r := newReleaseRig(t, ports.ReleaseLimits{})
48+ g := releaseGitHub(t, r)
49+ rel := r.create(t, "v1", false)
50+ for _, name := range []string{"ermite.tar.gz", "checksums.txt"} {
51+ _, err := g.UploadAsset(r.ctx, domain.Root(), "acme", r.repo.String(), rel.ID, name, "", "application/octet-stream", 3, strings.NewReader("abc"))
52+ require.NoError(t, err)
53+ }
54+ body, err := g.GetRelease(r.ctx, domain.Anonymous(), "ACME", "RELEASES", ports.ReleaseRef{Tag: "v1"})
55+ require.NoError(t, err)
56+ releaseGolden(t, "release", body)
57+ draft := r.create(t, "next", true)
58+ body, err = g.GetRelease(r.ctx, domain.Root(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
59+ require.NoError(t, err)
60+ releaseGolden(t, "draft", body)
61+ _, err = g.GetRelease(r.ctx, domain.Anonymous(), "acme", r.repo.String(), ports.ReleaseRef{ID: draft.ID})
62+ require.ErrorIs(t, err, domain.ErrNotFound)
63+}
64+func TestGitHubReleaseRequests(t *testing.T) {
65+ r := newReleaseRig(t, ports.ReleaseLimits{})
66+ g := releaseGitHub(t, r)
67+ repo := r.repo.String()
68+ p := domain.Root()
69+ r.resolve(t, "v1", nil)
70+ body, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1","name":"Title","body":"Notes","draft":true,"prerelease":true,"discussion_category_name":"ignored","make_latest":"legacy"}`))
71+ require.NoError(t, err)
72+ var rel struct {
73+ ID int64 `json:"id"`
74+ }
75+ require.NoError(t, json.Unmarshal(body, &rel))
76+ body, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"name":"","draft":false,"prerelease":false,"make_latest":"true","unknown":1}`))
77+ require.NoError(t, err)
78+ var got map[string]any
79+ require.NoError(t, json.Unmarshal(body, &got))
80+ require.Equal(t, "", got["name"])
81+ require.Equal(t, "Notes", got["body"])
82+ require.Equal(t, false, got["draft"])
83+ require.Equal(t, false, got["prerelease"])
84+ require.NotContains(t, got, "make_latest")
85+ stored, err := r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
86+ require.NoError(t, err)
87+ require.True(t, stored.MakeLatest)
88+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"legacy"}`))
89+ require.NoError(t, err)
90+ stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
91+ require.NoError(t, err)
92+ require.True(t, stored.MakeLatest)
93+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, rel.ID, []byte(`{"make_latest":"false"}`))
94+ require.NoError(t, err)
95+ stored, err = r.svc.GetRelease(r.ctx, p, r.repo, ports.ReleaseRef{ID: rel.ID})
96+ require.NoError(t, err)
97+ require.False(t, stored.MakeLatest)
98+ for _, bad := range []string{`{}`, `null`, `[]`, `{`, `{"tag_name":2}`, `{"tag_name":"v1","draft":"true"}`, `{"tag_name":"v1","name":null}`, `{"tag_name":"v1","make_latest":true}`, `{"tag_name":"v1","make_latest":"yes"}`} {
99+ _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(bad))
100+ require.ErrorIs(t, err, domain.ErrInvalid, bad)
101+ }
102+ _, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"v1"}`))
103+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
104+ _, err = g.GetRelease(r.ctx, p, "other", repo, ports.ReleaseRef{ID: rel.ID})
105+ require.ErrorIs(t, err, domain.ErrNotFound)
106+ _, err = g.GetRelease(r.ctx, p, "acme", "bad/name", ports.ReleaseRef{ID: rel.ID})
107+ require.ErrorIs(t, err, domain.ErrNotFound)
108+}
109+func TestGitHubReleasePagination(t *testing.T) {
110+ r := newReleaseRig(t, ports.ReleaseLimits{})
111+ g := releaseGitHub(t, r)
112+ for i := 0; i < 5; i++ {
113+ r.create(t, "v"+strconv.Itoa(i), i == 4)
114+ }
115+ for page := 1; page <= 3; page++ {
116+ b, link, err := g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
117+ require.NoError(t, err)
118+ var rows []struct {
119+ ID int64 `json:"id"`
120+ }
121+ require.NoError(t, json.Unmarshal(b, &rows))
122+ require.Len(t, rows, min(2, 5-(page-1)*2))
123+ require.Equal(t, int64(5-(page-1)*2), rows[0].ID)
124+ base := "http://localhost:7341/-/github/api/repos/acme/releases/releases?per_page=2&page="
125+ switch page {
126+ case 1:
127+ require.Equal(t, `<`+base+`2>; rel="next", <`+base+`3>; rel="last"`, link)
128+ case 2:
129+ require.Equal(t, `<`+base+`3>; rel="next", <`+base+`3>; rel="last", <`+base+`1>; rel="first", <`+base+`1>; rel="prev"`, link)
130+ case 3:
131+ require.Equal(t, `<`+base+`1>; rel="first", <`+base+`2>; rel="prev"`, link)
132+ }
133+ }
134+ b, _, err := g.ListReleases(r.ctx, domain.Anonymous(), "acme", r.repo.String(), 0, 0)
135+ require.NoError(t, err)
136+ var rows []any
137+ require.NoError(t, json.Unmarshal(b, &rows))
138+ require.Len(t, rows, 4)
139+ for _, page := range []int{4, math.MaxInt} {
140+ b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), page, 2)
141+ require.NoError(t, err)
142+ require.Equal(t, "[]", string(b))
143+ }
144+ b, _, err = g.ListReleases(r.ctx, domain.Root(), "acme", r.repo.String(), 1, 1000)
145+ require.NoError(t, err)
146+ require.NoError(t, json.Unmarshal(b, &rows))
147+ require.Len(t, rows, 5)
148+}
149+func TestGitHubReleaseAssetsAndRetry(t *testing.T) {
150+ r := newReleaseRig(t, ports.ReleaseLimits{AssetMax: 10})
151+ g := releaseGitHub(t, r)
152+ p := domain.Root()
153+ repo := r.repo.String()
154+ rel := r.create(t, "v1/slash", false)
155+ upload := func(name string) ([]byte, error) {
156+ return g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, name, "label", "text/plain", 3, strings.NewReader("abc"))
157+ }
158+ b, err := upload("é #?.txt")
159+ require.NoError(t, err)
160+ var asset struct {
161+ ID int64 `json:"id"`
162+ URL string `json:"browser_download_url"`
163+ }
164+ require.NoError(t, json.Unmarshal(b, &asset))
165+ require.Equal(t, "http://localhost:7341/releases/releases/download/v1/slash/%C3%A9%20%23%3F.txt", asset.URL)
166+ _, err = upload("é #?.txt")
167+ require.ErrorIs(t, err, domain.ErrAlreadyExists)
168+ b, err = g.UpdateAsset(r.ctx, p, "acme", repo, asset.ID, []byte(`{"label":""}`))
169+ require.NoError(t, err)
170+ require.Contains(t, string(b), `"label":""`)
171+ require.Contains(t, string(b), `"name":"é #?.txt"`)
172+ b, err = g.GetAsset(r.ctx, domain.Anonymous(), "acme", repo, asset.ID)
173+ require.NoError(t, err)
174+ require.Contains(t, string(b), `"state":"uploaded"`)
175+ for i := 0; i < 2; i++ {
176+ _, err = upload(fmt.Sprint(i))
177+ require.NoError(t, err)
178+ }
179+ b, link, err := g.ListAssets(r.ctx, p, "acme", repo, rel.ID, 1, 2)
180+ require.NoError(t, err)
181+ require.Contains(t, link, `rel="next"`)
182+ var assets []any
183+ require.NoError(t, json.Unmarshal(b, &assets))
184+ require.Len(t, assets, 2)
185+ require.NoError(t, g.DeleteAsset(r.ctx, p, "acme", repo, asset.ID))
186+ _, err = g.AssetByID(r.ctx, p, "acme", repo, asset.ID)
187+ require.ErrorIs(t, err, domain.ErrNotFound)
188+ _, err = upload("é #?.txt")
189+ require.NoError(t, err)
190+ _, err = g.UploadAsset(r.ctx, p, "acme", repo, rel.ID, "huge", "", "", 11, strings.NewReader("ignored"))
191+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
192+ require.NoError(t, g.DeleteRelease(r.ctx, p, "acme", repo, rel.ID))
193+ _, err = g.GetRelease(r.ctx, p, "acme", repo, ports.ReleaseRef{ID: rel.ID})
194+ require.ErrorIs(t, err, domain.ErrNotFound)
195+}
196+
197+func TestGitHubReleaseAuthorizationAndLatest(t *testing.T) {
198+ r := newReleaseRig(t, ports.ReleaseLimits{})
199+ g := releaseGitHub(t, r)
200+ repo := r.repo.String()
201+ stable := r.create(t, "v1", false)
202+ r.create(t, "draft", true)
203+ b, err := g.GetRelease(r.ctx, domain.Anonymous(), "acme", repo, ports.ReleaseRef{Latest: true})
204+ require.NoError(t, err)
205+ var got struct {
206+ ID int64 `json:"id"`
207+ }
208+ require.NoError(t, json.Unmarshal(b, &got))
209+ require.Equal(t, stable.ID, got.ID)
210+ writer := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionWrite}})
211+ reader := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: r.repo, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
212+ branched := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeReleaseWrite}, BranchPrefixes: []string{"feature/"}})
213+ for _, tc := range []struct {
214+ p domain.Principal
215+ tag string
216+ want error
217+ }{
218+ {domain.Anonymous(), "anonymous", domain.ErrUnauthenticated},
219+ {reader, "reader", domain.ErrForbidden},
220+ {branched, "branch", domain.ErrForbidden},
221+ {writer, "job", nil},
222+ } {
223+ if tc.want == nil {
224+ r.resolve(t, tc.tag, nil)
225+ }
226+ b, err = g.CreateRelease(r.ctx, tc.p, "acme", repo, []byte(`{"tag_name":"`+tc.tag+`"}`))
227+ if tc.want != nil {
228+ require.ErrorIs(t, err, tc.want)
229+ continue
230+ }
231+ require.NoError(t, err)
232+ require.NoError(t, json.Unmarshal(b, &got))
233+ _, err = g.UploadAsset(r.ctx, tc.p, "acme", repo, got.ID, "ci.tar.gz", "", "", 3, strings.NewReader("abc"))
234+ require.NoError(t, err)
235+ _, err = g.UploadAsset(r.ctx, reader, "acme", repo, got.ID, "read.tar.gz", "", "", 3, strings.NewReader("abc"))
236+ require.ErrorIs(t, err, domain.ErrForbidden)
237+ }
238+ other, err := domain.ParseRepoName("other")
239+ require.NoError(t, err)
240+ require.NoError(t, r.store.Create(r.ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: now}))
241+ b, _, err = g.ListAssets(r.ctx, domain.Root(), "acme", other.String(), stable.ID, 1, 30)
242+ require.ErrorIs(t, err, domain.ErrNotFound)
243+ require.Nil(t, b)
244+}
245+
246+func TestGitHubReleaseTargetAndMissingTag(t *testing.T) {
247+ r := newReleaseRig(t, ports.ReleaseLimits{})
248+ g := releaseGitHub(t, r)
249+ p := domain.Root()
250+ repo := r.repo.String()
251+ r.resolve(t, "missing", domain.ErrNotFound)
252+ _, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing"}`))
253+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
254+ b, err := g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"missing","draft":true}`))
255+ require.NoError(t, err)
256+ var got struct {
257+ ID int64 `json:"id"`
258+ Target string `json:"target_commitish"`
259+ }
260+ require.NoError(t, json.Unmarshal(b, &got))
261+ require.Empty(t, got.Target)
262+ _, err = g.UpdateRelease(r.ctx, p, "acme", repo, got.ID, []byte(`{"draft":false}`))
263+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
264+ r.resolve(t, "new", domain.ErrNotFound)
265+ v, err := domain.ParseRevision("main")
266+ require.NoError(t, err)
267+ oid, err := domain.ParseObjectID(oidA)
268+ require.NoError(t, err)
269+ r.reader.EXPECT().ResolveRevision(r.ctx, r.repo, v).Return(domain.ResolvedRevision{CommitID: oid}, nil).Once()
270+ r.tags.EXPECT().CreateTag(r.ctx, r.repo, "new", oid).Return(nil).Once()
271+ r.pushes.EXPECT().Record(r.ctx, p, r.repo, []domain.RefUpdate{{Ref: "refs/tags/new", Old: domain.ZeroOID, New: oidA}}).Return(nil).Once()
272+ b, err = g.CreateRelease(r.ctx, p, "acme", repo, []byte(`{"tag_name":"new","target_commitish":"main"}`))
273+ require.NoError(t, err)
274+ require.NoError(t, json.Unmarshal(b, &got))
275+ require.Equal(t, oidA, got.Target)
276+}
277+
278+// A real HTTP round trip through the facade, metadata store and disk blob
279+// adapter protects the goreleaser/softprops create, retry, overwrite, publish
280+// sequence, not just each side's mocked contract.
281+func TestGitHubReleaseHTTPWorkflow(t *testing.T) {
282+ r := newReleaseRig(t, ports.ReleaseLimits{})
283+ g := releaseGitHub(t, r)
284+ auth := mocks.NewAuthService(t)
285+ auth.EXPECT().Authenticate(mock.Anything, "dev-token").Return(domain.Root(), nil).Maybe()
286+ srv := httptest.NewServer(githubapi.New(githubapi.Config{Auth: auth, Releases: g, ReleaseDownload: r.svc}))
287+ defer srv.Close()
288+ do := func(method, path, body string, headers map[string]string) (*http.Response, string) {
289+ req, err := http.NewRequestWithContext(r.ctx, method, srv.URL+githubapi.Prefix+path, strings.NewReader(body))
290+ require.NoError(t, err)
291+ req.Header.Set("Authorization", "Bearer dev-token")
292+ for k, v := range headers {
293+ req.Header.Set(k, v)
294+ }
295+ resp, err := srv.Client().Do(req)
296+ require.NoError(t, err)
297+ defer resp.Body.Close()
298+ b, err := io.ReadAll(resp.Body)
299+ require.NoError(t, err)
300+ return resp, string(b)
301+ }
302+ base := "/repos/acme/" + r.repo.String() + "/releases"
303+ resp, _ := do("GET", base+"/tags/v1", "", nil)
304+ require.Equal(t, 404, resp.StatusCode)
305+ r.resolve(t, "v1", nil)
306+ resp, b := do("POST", base, `{"tag_name":"v1","draft":true}`, nil)
307+ require.Equal(t, 201, resp.StatusCode)
308+ var rel struct {
309+ ID int64 `json:"id"`
310+ UploadURL string `json:"upload_url"`
311+ }
312+ require.NoError(t, json.Unmarshal([]byte(b), &rel))
313+ upload := strings.TrimPrefix(strings.Split(rel.UploadURL, "{")[0], "http://localhost:7341"+githubapi.Prefix) + "?name=archive.tar.gz"
314+ resp, b = do("POST", upload, "0123456789", map[string]string{"Content-Type": "application/gzip"})
315+ require.Equal(t, 201, resp.StatusCode)
316+ var a struct {
317+ ID int64 `json:"id"`
318+ }
319+ require.NoError(t, json.Unmarshal([]byte(b), &a))
320+ resp, b = do("POST", upload, "replacement", nil)
321+ require.Equal(t, 422, resp.StatusCode)
322+ require.Contains(t, b, `"code":"already_exists"`)
323+ resp, b = do("GET", base+"/"+strconv.FormatInt(rel.ID, 10)+"/assets", "", nil)
324+ require.Equal(t, 200, resp.StatusCode)
325+ require.Contains(t, b, `"name":"archive.tar.gz"`)
326+ assetURL := base + "/assets/" + strconv.FormatInt(a.ID, 10)
327+ resp, _ = do("DELETE", assetURL, "", nil)
328+ require.Equal(t, 204, resp.StatusCode)
329+ resp, b = do("POST", upload, "abcdefghij", nil)
330+ require.Equal(t, 201, resp.StatusCode)
331+ require.NoError(t, json.Unmarshal([]byte(b), &a))
332+ resp, _ = do("PATCH", base+"/"+strconv.FormatInt(rel.ID, 10), `{"draft":false}`, nil)
333+ require.Equal(t, 200, resp.StatusCode)
334+ assetURL = base + "/assets/" + strconv.FormatInt(a.ID, 10)
335+ resp, b = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5"})
336+ require.Equal(t, 206, resp.StatusCode)
337+ require.Equal(t, "cdef", b)
338+ require.Equal(t, "bytes 2-5/10", resp.Header.Get("Content-Range"))
339+ require.Equal(t, "4", resp.Header.Get("Content-Length"))
340+ require.Empty(t, resp.Header.Get("Location"))
341+ resp, b = do("HEAD", assetURL, "", map[string]string{"Accept": "application/octet-stream"})
342+ require.Equal(t, 200, resp.StatusCode)
343+ require.Empty(t, b)
344+ require.Equal(t, "10", resp.Header.Get("Content-Length"))
345+ resp, _ = do("GET", assetURL, "", map[string]string{"Accept": "application/octet-stream", "If-None-Match": resp.Header.Get("ETag")})
346+ require.Equal(t, 304, resp.StatusCode)
347+}

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 41

↑↓ moveenter openesc close