1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "io"
10 "strings"
11
12 "bnema.dev/ermite/internal/domain"
13 "bnema.dev/ermite/internal/ports"
14)
15
16// The REST subset (ADR 0025, CI-2): what the actions that run on ermite call
17// on github.api_url. The access rule is the forge's: a repository is read by
18// anyone when public, and with repo:read when private, which the principal of a
19// job repository token holds on the repository of its run (and the archive
20// tokens on theirs). The holder of an archive token reads none of it but its
21// own archive.
22
23var _ ports.GitHubAPIService = (*CI)(nil)
24
25// githubRepo looks the repository up as GitHub names it, owner/repo. Another
26// owner, a name that is not one and a repository the caller cannot read are all
27// not found.
28func (c *CI) githubRepo(ctx context.Context, p domain.Principal, owner, name string) (domain.Repo, error) {
29 if !domain.OwnerMatches(c.cfg.Owner, owner) {
30 return domain.Repo{}, domain.ErrNotFound
31 }
32 rn, err := domain.ParseRepoName(strings.ToLower(name))
33 if err != nil {
34 return domain.Repo{}, domain.ErrNotFound
35 }
36 return c.readableRepo(ctx, p, rn)
37}
38
39// GetRepo returns GET /repos/{owner}/{repo}.
40//
41// The object is the repository of the push event (repoObj: real values where
42// ermite has them, GitHub's for a repository without the feature elsewhere)
43// with the REST differences: dates are ISO 8601 strings, and the permissions of
44// the caller are given, as GitHub does for an authenticated request.
45func (c *CI) GetRepo(ctx context.Context, p domain.Principal, owner, name string) ([]byte, error) {
46 repo, err := c.githubRepo(ctx, p, owner, name)
47 if err != nil {
48 return nil, err
49 }
50 branch, err := c.cfg.Repos.HeadBranch(ctx, repo.Name)
51 if errors.Is(err, domain.ErrNotFound) {
52 return nil, domain.ErrNotFound
53 }
54 if err != nil {
55 return nil, err
56 }
57 if branch == "" {
58 branch = "main"
59 }
60 u := c.urls(repo)
61 created, pushed := repo.CreatedAt, repo.PushedAt
62 obj := c.repoObj(repo, u, branch, created)
63 obj["created_at"] = created.UTC().Format(timeLayout)
64 if pushed.IsZero() {
65 pushed = created
66 }
67 obj["pushed_at"] = pushed.UTC().Format(timeLayout)
68 obj["updated_at"] = pushed.UTC().Format(timeLayout)
69 delete(obj, "master_branch") // push event only
70 delete(obj, "stargazers")
71 delete(obj, "watchers")
72 delete(obj, "forks")
73 delete(obj, "open_issues")
74 for k, v := range map[string]any{
75 "network_count": 0, "subscribers_count": 0, "allow_squash_merge": repo.Settings.Allows(domain.MergeModeSquash),
76 "allow_merge_commit": false, "allow_rebase_merge": repo.Settings.Allows(domain.MergeModeFastForward),
77 "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false,
78 "use_squash_pr_title_as_default": false, "has_pull_requests": true,
79 } {
80 obj[k] = v
81 }
82 if !p.IsAnonymous() {
83 write := canWrite(p, repo.Name)
84 obj["permissions"] = map[string]bool{"admin": canAdmin(p, repo.Name), "maintain": write, "push": write, "triage": write, "pull": true}
85 }
86 return json.Marshal(obj)
87}
88
89// timeLayout is the ISO 8601 form of GitHub's REST dates.
90const timeLayout = "2006-01-02T15:04:05Z"
91
92// HashAlgorithm answers GET /repos/{owner}/{repo}/hash-algorithm: checkout asks
93// it before it runs git init. ermite repositories are SHA-1.
94func (c *CI) HashAlgorithm(ctx context.Context, p domain.Principal, owner, name string) (string, error) {
95 if _, err := c.githubRepo(ctx, p, owner, name); err != nil {
96 return "", err
97 }
98 return "sha1", nil
99}
100
101// archiveTarget finds the repository and the commit of an archive request and
102// checks the access to it. A reader of the repository (the job's own token, any
103// principal with repo:read, anyone for a public one) gets any ref. The holder of
104// an archive token (domain.RepoTokenPrincipal) gets its commit of its
105// repository and nothing else: the ref may be anything that resolves to exactly
106// that commit, and every other answer is not found.
107func (c *CI) archiveTarget(ctx context.Context, p domain.Principal, owner, name, ref string) (domain.Repo, domain.ObjectID, error) {
108 var repo domain.Repo
109 grantRepo, grantSha, granted := p.ArchiveGrant()
110 if granted {
111 if !domain.OwnerMatches(c.cfg.Owner, owner) {
112 return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
113 }
114 rn, err := domain.ParseRepoName(strings.ToLower(name))
115 if err != nil || rn != grantRepo {
116 return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
117 }
118 if repo, err = lookupRepo(ctx, c.cfg.Meta, rn); err != nil {
119 return domain.Repo{}, domain.ObjectID{}, err
120 }
121 } else {
122 var err error
123 if repo, err = c.githubRepo(ctx, p, owner, name); err != nil {
124 return domain.Repo{}, domain.ObjectID{}, err
125 }
126 }
127 rev, err := domain.ParseRevision(ref)
128 if err != nil {
129 return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
130 }
131 res, err := c.cfg.Reader.ResolveRevision(ctx, repo.Name, rev)
132 if err != nil {
133 return domain.Repo{}, domain.ObjectID{}, err
134 }
135 if granted && res.CommitID.String() != grantSha {
136 return domain.Repo{}, domain.ObjectID{}, domain.ErrNotFound
137 }
138 return repo, res.CommitID, nil
139}
140
141func (c *CI) archiveDir(repo domain.Repo, commit domain.ObjectID) string {
142 return c.cfg.Owner + "-" + repo.Name.String() + "-" + commit.String()[:7]
143}
144
145// TarballName answers a HEAD of the archive: the access and the ref are
146// checked as for the archive, and the name of the file is returned, without
147// running git archive.
148func (c *CI) TarballName(ctx context.Context, p domain.Principal, owner, name, ref string) (string, error) {
149 repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
150 if err != nil {
151 return "", err
152 }
153 return c.archiveDir(repo, commit) + ".tar.gz", nil
154}
155
156// Tarball streams the archive of a ref as `git archive` makes it, in one
157// response (GitHub answers 302 to codeload: ermite serves it itself). The files
158// are in a directory <owner>-<repo>-<7 first characters of the commit>, as
159// GitHub names it.
160func (c *CI) Tarball(ctx context.Context, p domain.Principal, owner, name, ref string, begin func(string) io.Writer) error {
161 repo, commit, err := c.archiveTarget(ctx, p, owner, name, ref)
162 if err != nil {
163 return err
164 }
165 dir := c.archiveDir(repo, commit)
166 return c.cfg.Archive.Archive(ctx, repo.Name, commit, dir, &lazyWriter{begin: func() io.Writer { return begin(dir + ".tar.gz") }})
167}
168
169// lazyWriter calls begin at the first write, so that nothing is sent before git
170// produced a byte.
171type lazyWriter struct {
172 begin func() io.Writer
173 w io.Writer
174}
175
176func (l *lazyWriter) Write(b []byte) (int, error) {
177 if l.w == nil {
178 l.w = l.begin()
179 }
180 return l.w.Write(b)
181}