v0.35.0 e86dbd6

ci_github.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"bytes"
7	"context"
8	"crypto/sha256"
9	"encoding/base64"
10	"encoding/binary"
11	"encoding/json"
12	"net/url"
13	"strconv"
14	"strings"
15	"time"
16
17	"github.com/bnema/zerowrap"
18
19	"bnema.dev/ermite/internal/domain"
20)
21
22// This file builds the "github" context of a push and the "job" keys the
23// server sends, to match what github.com gives (recordings of
24// ermite-conformance, normalised by its normalize.go).
25//
26// Value provenance, used in the comments below:
27//   - real: a value ermite has (repository name, visibility, description,
28//     dates, commits, default branch, configuration);
29//   - derived: computed from real values by a stable rule (ids, node ids,
30//     URLs);
31//   - constant: GitHub's value for a repository or account with no such
32//     feature, or a placeholder where ermite has no concept (no avatars,
33//     stars, forks, issues...).
34
35const (
36	// ghSecretSource is github.secret_source: where the secrets of the job
37	// come from. Constant: ermite has one source.
38	ghSecretSource = "Actions"
39	// ghCacheLimit is github.artifact_cache_size_limit, in GB, as recorded on
40	// github.com. Constant.
41	ghCacheLimit = "10"
42	// apiPath is where the GitHub-shaped API base of the payload lives under
43	// the public URL. github.api_url is that base, github.graphql_url is
44	// <base>/graphql and the *_url keys of the repository and the users are
45	// <base>/repos/... and <base>/users/..., as on github.com. ermite serves
46	// none of them yet: they are identifiers in the shape of GitHub's.
47	apiPath = "/-/github/api"
48)
49
50// pair is a key and a value of an ordered JSON object.
51type pair struct {
52	k string
53	v any
54}
55
56// ordered is a JSON object that keeps the order of its keys, as the github
57// context of GitHub does (toJSON(github) prints that order).
58type ordered []pair
59
60func (o ordered) MarshalJSON() ([]byte, error) {
61	var b bytes.Buffer
62	b.WriteByte('{')
63	for i, p := range o {
64		if i > 0 {
65			b.WriteByte(',')
66		}
67		k, err := json.Marshal(p.k)
68		if err != nil {
69			return nil, err
70		}
71		v, err := json.Marshal(p.v)
72		if err != nil {
73			return nil, err
74		}
75		b.Write(k)
76		b.WriteByte(':')
77		b.Write(v)
78	}
79	b.WriteByte('}')
80	return b.Bytes(), nil
81}
82
83// stableID is a positive number that depends only on the kind and the name:
84// the id of a user or of a repository, which ermite does not number. It fits
85// in a JavaScript number (GitHub's own ids are well below 2^53).
86func stableID(kind, name string) int64 {
87	sum := sha256.Sum256([]byte(kind + "\x00" + name))
88	return int64(binary.BigEndian.Uint64(sum[:8])>>20) + 1<<20
89}
90
91// nodeID is the legacy form of a GraphQL node id: base64 of "<n>:<Type><id>"
92// (GitHub: "04:User5212137" is "MDQ6VXNlcjUyMTIxMzc=").
93func nodeID(prefix, typ string, id int64) string {
94	return base64.StdEncoding.EncodeToString([]byte(prefix + ":" + typ + strconv.FormatInt(id, 10)))
95}
96
97// ghIdentity is the account a push is attributed to.
98type ghIdentity struct {
99	login string
100	id    int64
101}
102
103// identity maps the actor of a run to a login: "owner" and "root" (the
104// owner's session or the root token) are the owner of the forge, a token
105// label is itself.
106func (c *CI) identity(actor string) ghIdentity {
107	login := actor
108	if actor == "owner" || actor == "root" || actor == "" {
109		login = c.cfg.Owner
110	}
111	return ghIdentity{login: login, id: stableID("user", login)}
112}
113
114// ghURLs holds the URL bases of the payload of one repository.
115type ghURLs struct {
116	server string // github.server_url
117	api    string // github.api_url
118	host   string // host (and port) of the server, for git:// and ssh URLs
119	owner  string
120	name   string
121	full   string // <owner>/<repo>
122	repo   string // <server>/<owner>/<repo>, the page GitHub's html_url points at
123}
124
125func (c *CI) urls(repo domain.Repo) ghURLs {
126	u := ghURLs{server: c.cfg.PublicURL, owner: c.cfg.Owner, name: repo.Name.String()}
127	if p, err := url.Parse(c.cfg.PublicURL); err == nil {
128		u.host = p.Host
129	}
130	u.api = u.server + apiPath
131	u.full = u.owner + "/" + u.name
132	u.repo = u.server + "/" + u.full
133	return u
134}
135
136// userObj is the account object of an event (sender, repository owner).
137// Derived: ids from the login; URLs from the API base. Constant: type, no
138// avatar (ermite has none: "" is what GitHub gives for gravatar_id too), not
139// a site admin.
140func (u ghURLs) userObj(id ghIdentity, owner bool) map[string]any {
141	api := u.api + "/users/" + id.login
142	o := map[string]any{
143		"avatar_url": "", "events_url": api + "/events{/privacy}", "followers_url": api + "/followers",
144		"following_url": api + "/following{/other_user}", "gists_url": api + "/gists{/gist_id}", "gravatar_id": "",
145		"html_url": u.server + "/" + id.login, "id": id.id, "login": id.login,
146		"node_id":           nodeID("04", "User", id.id),
147		"organizations_url": api + "/orgs", "received_events_url": api + "/received_events", "repos_url": api + "/repos",
148		"site_admin": false, "starred_url": api + "/starred{/owner}{/repo}", "subscriptions_url": api + "/subscriptions",
149		"type": "User", "url": api, "user_view_type": "public",
150	}
151	if owner {
152		o["email"], o["name"] = "", id.login
153	}
154	return o
155}
156
157// repoLinks are the URL templates of the repository object, relative to
158// <api>/repos/<owner>/<repo>. Derived (constant templates, as GitHub's).
159var repoLinks = map[string]string{
160	"archive_url": "/{archive_format}{/ref}", "assignees_url": "/assignees{/user}", "blobs_url": "/git/blobs{/sha}",
161	"branches_url": "/branches{/branch}", "collaborators_url": "/collaborators{/collaborator}",
162	"comments_url": "/comments{/number}", "commits_url": "/commits{/sha}", "compare_url": "/compare/{base}...{head}",
163	"contents_url": "/contents/{+path}", "contributors_url": "/contributors", "deployments_url": "/deployments",
164	"downloads_url": "/downloads", "events_url": "/events", "forks_url": "/forks", "git_commits_url": "/git/commits{/sha}",
165	"git_refs_url": "/git/refs{/sha}", "git_tags_url": "/git/tags{/sha}", "hooks_url": "/hooks",
166	"issue_comment_url": "/issues/comments{/number}", "issue_events_url": "/issues/events{/number}",
167	"issues_url": "/issues{/number}", "keys_url": "/keys{/key_id}", "labels_url": "/labels{/name}",
168	"languages_url": "/languages", "merges_url": "/merges", "milestones_url": "/milestones{/number}",
169	"notifications_url": "/notifications{?since,all,participating}", "pulls_url": "/pulls{/number}",
170	"releases_url": "/releases{/id}", "stargazers_url": "/stargazers", "statuses_url": "/statuses/{sha}",
171	"subscribers_url": "/subscribers", "subscription_url": "/subscription", "tags_url": "/tags", "teams_url": "/teams",
172	"trees_url": "/git/trees{/sha}", "url": "",
173}
174
175// repoObj is event.repository: the 83 keys of GitHub's push payload.
176//
177//	real:     name, full_name, private, visibility, description, default_branch,
178//	          master_branch, created_at, pushed_at, updated_at
179//	derived:  id, node_id, owner, html_url, svn_url, clone_url, git_url,
180//	          ssh_url, url and the other *_url keys
181//	constant: the rest (no forks, stars, issues, pages..., the features of a
182//	          new GitHub repository; license, language, homepage and
183//	          mirror_url null; size 0)
184func (c *CI) repoObj(repo domain.Repo, u ghURLs, defaultBranch string, at time.Time) map[string]any {
185	owner := c.identity("owner")
186	id := stableID("repo", u.full)
187	pushed := repo.PushedAt
188	if pushed.IsZero() {
189		pushed = at
190	}
191	var desc any
192	if repo.Description != "" {
193		desc = repo.Description
194	}
195	r := map[string]any{
196		"allow_forking": true, "archived": false, "clone_url": u.repo + ".git",
197		"created_at": repo.CreatedAt.Unix(), "default_branch": defaultBranch, "description": desc,
198		"disabled": false, "fork": false, "forks": 0, "forks_count": 0, "full_name": u.full,
199		"git_url":         "git://" + u.host + "/" + u.full + ".git",
200		"has_discussions": false, "has_downloads": true, "has_issues": true, "has_pages": false, "has_projects": true,
201		"has_pull_requests": true, "has_wiki": true, "homepage": nil, "html_url": u.repo, "id": id,
202		"is_template": false, "language": nil, "license": nil, "master_branch": defaultBranch, "mirror_url": nil,
203		"name": u.name, "node_id": nodeID("010", "Repository", id), "open_issues": 0, "open_issues_count": 0,
204		"owner":   u.userObj(owner, true),
205		"private": repo.Visibility == domain.VisibilityPrivate, "pull_request_creation_policy": "all",
206		"pushed_at": pushed.Unix(), "size": 0, "ssh_url": "git@" + u.host + ":" + u.full + ".git",
207		"stargazers": 0, "stargazers_count": 0, "svn_url": u.repo, "topics": []string{},
208		"updated_at": pushed.UTC().Format(time.RFC3339), "visibility": string(repo.Visibility),
209		"watchers": 0, "watchers_count": 0, "web_commit_signoff_required": false,
210	}
211	for k, suffix := range repoLinks {
212		r[k] = u.api + "/repos/" + u.full + suffix
213	}
214	return r
215}
216
217// commitJSON is one commit of the push event: GitHub's push payload has
218// exactly these keys in the recordings (no added/removed/modified, no
219// username).
220func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, u ghURLs, cm domain.Commit) map[string]any {
221	tree := ""
222	if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
223		tree = id.String()
224	}
225	return map[string]any{
226		"id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message,
227		"timestamp": cm.Committer.Time.UTC().Format(time.RFC3339),
228		"url":       u.repo + "/commit/" + cm.ID.String(),
229		"author":    person(cm.Author.Name, cm.Author.Email), "committer": person(cm.Committer.Name, cm.Committer.Email),
230	}
231}
232
233// pushPayload is the part of the push event that is the same for every
234// workflow of one ref: the commits, each with its tree id (one Git call per
235// commit). It is built once per ref and shared by the probes and the runs.
236type pushPayload struct {
237	commits []any
238	head    map[string]any
239}
240
241// pushPayload builds the commit part of the push event of f.
242func (c *CI) pushPayload(ctx context.Context, repo domain.Repo, f pushFacts) *pushPayload {
243	u := c.urls(repo)
244	p := &pushPayload{commits: make([]any, 0, len(f.commits)), head: c.commitJSON(ctx, repo, u, f.head)}
245	for _, cm := range f.commits {
246		p.commits = append(p.commits, c.commitJSON(ctx, repo, u, cm))
247	}
248	return p
249}
250
251func person(name, email string) map[string]any { return map[string]any{"name": name, "email": email} }
252
253// githubJSON builds the "github" context of the run as GitHub does for a push
254// (the job-dependent keys, job and run_attempt, are filled in when a job is
255// planned). Keys the runner owns (token, workspace, action, event_path, path,
256// env, output, state, step_summary, artifacts...) are not here.
257//
258// The order of the keys is GitHub's, so that toJSON(github) prints the same.
259// Ids and counters are strings, so is retention_days; "compare" has
260// 12-character ids: the commit page for a created ref with one commit,
261// "<first>^...<after>" for a created ref with several, "<before>...<after>" for
262// an update.
263//
264// The commits of the event come from f.payload, built once per ref; without
265// one they are read from Git here.
266func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts) string {
267	if f.pull != nil {
268		return c.pullGithubJSON(repo, run, f)
269	}
270	if f.custom != nil {
271		return c.customGithubJSON(repo, run, f)
272	}
273	u := c.urls(repo)
274	id := c.identity(run.Actor)
275	refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
276	if f.isTag {
277		refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
278	}
279	before := f.update.Old
280	if f.created {
281		before = domain.ZeroOID
282	}
283	compare := u.repo + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
284	switch {
285	case f.created && len(f.commits) > 1:
286		compare = u.repo + "/compare/" + shortID(f.commits[0].ID.String()) + "^..." + shortID(f.update.New)
287	case f.created:
288		compare = u.repo + "/commit/" + shortID(f.update.New)
289	}
290	payload := f.payload
291	if payload == nil {
292		payload = c.pushPayload(ctx, repo, f)
293	}
294	event := map[string]any{
295		"ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
296		"base_ref": nil, "compare": compare, "commits": payload.commits, "head_commit": payload.head,
297		"pusher":     map[string]any{"name": id.login, "email": ""},
298		"repository": c.repoObj(repo, u, f.defaultBranch, run.CreatedAt),
299		"sender":     u.userObj(id, false),
300	}
301	return c.githubContext(repo, run, ghEvent{name: "push", refName: refName, refType: refType, payload: event})
302}
303
304// ghEvent is what differs between the github contexts of the events.
305type ghEvent struct {
306	name, refName, refType string
307	// headRef and baseRef are github.head_ref and github.base_ref: set for a
308	// pull_request only.
309	headRef, baseRef string
310	payload          map[string]any
311}
312
313// githubContext assembles the "github" context of a run in GitHub's key order
314// around the payload of the event.
315func (c *CI) githubContext(repo domain.Repo, run domain.CIRun, e ghEvent) string {
316	u := c.urls(repo)
317	id := c.identity(run.Actor)
318	owner := c.identity("owner")
319	repoID := strconv.FormatInt(stableID("repo", u.full), 10)
320	gh := ordered{
321		{"job", ""}, {"ref", run.Ref}, {"sha", run.Sha}, {"repository", u.full}, {"repository_owner", c.cfg.Owner},
322		{"repository_owner_id", strconv.FormatInt(owner.id, 10)}, {"repositoryUrl", "git://" + u.host + "/" + u.full + ".git"},
323		{"run_id", strconv.FormatInt(run.ID, 10)}, {"run_number", strconv.FormatInt(run.Number, 10)},
324		{"retention_days", strconv.Itoa(c.retentionDays())}, {"run_attempt", strconv.Itoa(int(max(run.Attempt, 1)))},
325		{"artifact_cache_size_limit", ghCacheLimit}, {"repository_visibility", string(repo.Visibility)},
326		{"actor_id", strconv.FormatInt(id.id, 10)}, {"actor", id.login}, {"workflow", run.WorkflowName},
327		{"head_ref", e.headRef}, {"base_ref", e.baseRef}, {"event_name", e.name}, {"server_url", u.server},
328		{"api_url", u.api}, {"graphql_url", u.api + "/graphql"}, {"ref_name", e.refName}, {"ref_protected", false},
329		{"ref_type", e.refType}, {"secret_source", ghSecretSource}, {"event", e.payload},
330		{"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref}, {"workflow_sha", run.Sha},
331		{"repository_id", repoID}, {"triggering_actor", id.login},
332	}
333	b, err := json.Marshal(gh)
334	if err != nil {
335		return "{}"
336	}
337	return string(b)
338}
339
340// Layout of job.check_run_id, 53 bits so that it is exact as a JavaScript
341// number (2^53 is the first integer that is not):
342//
343//	bits 0-8    combo:   matrix leg, 9 bits (0-511); a matrix has at most 256
344//	bits 9-18   index:   position of the job in the workflow file, 10 bits
345//	                     (0-1023); ermite has no limit of its own on the number
346//	                     of jobs
347//	bits 19-25  attempt: run attempt, 7 bits (0-127), so that every re-run
348//	                     gets new ids, as on GitHub
349//	bits 26-52  run:     run id, 27 bits (below 134 million)
350//
351// A field that overflows would wrap into another instance's id, so the whole
352// id is 0 then (see checkRunID).
353const (
354	checkComboBits   = 9
355	checkIndexBits   = 10
356	checkAttemptBits = 7
357	checkRunBits     = 53 - checkComboBits - checkIndexBits - checkAttemptBits
358)
359
360// checkRunID is the number github.com shows in job.check_run_id: derived from
361// the run, its attempt and the position of the job instance, as ermite's
362// checks are not numbered. It is unique and stable per attempt, job and matrix
363// leg, and fits in a JavaScript number.
364//
365// The key is informational, so it never fails a job: a value outside its field
366// (which would wrap and give two instances the same id) is logged at warn with
367// the run, attempt and job, and the id falls back to 0.
368func checkRunID(ctx context.Context, run domain.CIRun, index, combo int) int64 {
369	attempt := int64(run.Attempt)
370	switch {
371	case run.ID < 0 || run.ID >= 1<<checkRunBits,
372		attempt < 0 || attempt >= 1<<checkAttemptBits,
373		index < 0 || index >= 1<<checkIndexBits,
374		combo < 0 || combo >= 1<<checkComboBits:
375		log := zerowrap.FromCtx(ctx)
376		log.Warn().Int64("run_id", run.ID).Int32("attempt", run.Attempt).Int("job_index", index).Int("leg", combo).
377			Msg("ci check_run_id is out of range: the job context shows 0")
378		return 0
379	}
380	return run.ID<<(checkAttemptBits+checkIndexBits+checkComboBits) |
381		attempt<<(checkIndexBits+checkComboBits) |
382		int64(index)<<checkComboBits |
383		int64(combo)
384}
385
386// jobKeys are the keys of the "job" context that the server knows, in
387// GitHub's order (the runner adds "status"). The runner copies the "job" key
388// of the contexts it is given into the job context.
389func (c *CI) jobKeys(ctx context.Context, run domain.CIRun, index, combo int) ordered {
390	id := checkRunID(ctx, run, index, combo)
391	u := c.urls(domain.Repo{Name: run.Repo})
392	return ordered{
393		{"check_run_id", id},
394		{"workflow_ref", u.full + "/" + run.WorkflowPath + "@" + run.Ref},
395		{"workflow_sha", run.Sha}, {"workflow_repository", u.full}, {"workflow_file_path", run.WorkflowPath},
396	}
397}

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close