v0.21.0 ef38ad0

ci_githubapi.go RawHistoryFolder

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}

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 44

↑↓ moveenter openesc close