detached 1beb9db

fix(ci): match GitHub's push payload in the github context

Checked against the ermite-conformance recordings: repository.default_branch
and master_branch are the default branch, event.compare uses 12-character ids
(the commit page for a created ref, before...after for an update), the commits
carry their tree id, and retention_days is a string taken from the configured
retention.
commit
1beb9dbf29973085c746ab03ca24d00534a1a80c
author
brice · · 2026-10-12 19:17 UTC
parent
5e9332c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +79 -13
M internal/app/ci_flow_test.go +37 -0
@@ -4,6 +4,7 @@ package app
44
55 import (
66 "context"
7+ "encoding/json"
78 "fmt"
89 "net/http/httptest"
910 "os"
@@ -647,3 +648,39 @@ func TestCIAcknowledgedAssignmentIsNotRepeated(t *testing.T) {
647648 require.NoError(t, err)
648649 assert.Nil(t, again, "the acknowledged assignment is not handed out again")
649650 }
651+
652+// The push payload follows GitHub's (checked against the recordings of
653+// ermite-conformance): 12-character ids in "compare", the commit page for a
654+// created ref and a compare range for an update, the default branch of the
655+// repository, a tree id, and string counters.
656+func TestCIGithubContextOfAPush(t *testing.T) {
657+ c := newCIHarness(t, nil)
658+ runner := c.registerRunner("alpha", "ubuntu-latest")
659+ oneJobWF := strings.Split(ciWorkflow, " test:")[0]
660+ first := c.push("main", map[string]string{".github/workflows/ci.yml": oneJobWF}, "")
661+ ev := func() (map[string]any, map[string]any) {
662+ job := runner.await()
663+ var ctxs map[string]map[string]any
664+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
665+ gh := ctxs["github"]
666+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
667+ return gh, gh["event"].(map[string]any)
668+ }
669+
670+ gh, event := ev()
671+ assert.Equal(t, publicOrigin+"/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")
672+ assert.Equal(t, true, event["created"])
673+ assert.Equal(t, domain.ZeroOID, event["before"])
674+ repo := event["repository"].(map[string]any)
675+ assert.Equal(t, "main", repo["default_branch"])
676+ assert.Equal(t, "main", repo["master_branch"])
677+ assert.Equal(t, "90", gh["retention_days"], "a string, from the configured retention")
678+ head := event["head_commit"].(map[string]any)
679+ tree := strings.TrimSpace(c.bareGit("proj", "rev-parse", first+"^{tree}"))
680+ assert.Equal(t, tree, head["tree_id"])
681+
682+ second := c.push("main", map[string]string{"a.txt": "a\n"}, "")
683+ _, event = ev()
684+ assert.Equal(t, publicOrigin+"/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])
685+ assert.Equal(t, false, event["created"])
686+}
@@ -4,6 +4,7 @@ package app
4 4
5 import (5 import (
6 "context"6 "context"
7+ "encoding/json"
7 "fmt"8 "fmt"
8 "net/http/httptest"9 "net/http/httptest"
9 "os"10 "os"
@@ -647,3 +648,39 @@ func TestCIAcknowledgedAssignmentIsNotRepeated(t *testing.T) {
647 require.NoError(t, err)648 require.NoError(t, err)
648 assert.Nil(t, again, "the acknowledged assignment is not handed out again")649 assert.Nil(t, again, "the acknowledged assignment is not handed out again")
649 }650 }
651+
652+// The push payload follows GitHub's (checked against the recordings of
653+// ermite-conformance): 12-character ids in "compare", the commit page for a
654+// created ref and a compare range for an update, the default branch of the
655+// repository, a tree id, and string counters.
656+func TestCIGithubContextOfAPush(t *testing.T) {
657+ c := newCIHarness(t, nil)
658+ runner := c.registerRunner("alpha", "ubuntu-latest")
659+ oneJobWF := strings.Split(ciWorkflow, " test:")[0]
660+ first := c.push("main", map[string]string{".github/workflows/ci.yml": oneJobWF}, "")
661+ ev := func() (map[string]any, map[string]any) {
662+ job := runner.await()
663+ var ctxs map[string]map[string]any
664+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
665+ gh := ctxs["github"]
666+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
667+ return gh, gh["event"].(map[string]any)
668+ }
669+
670+ gh, event := ev()
671+ assert.Equal(t, publicOrigin+"/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")
672+ assert.Equal(t, true, event["created"])
673+ assert.Equal(t, domain.ZeroOID, event["before"])
674+ repo := event["repository"].(map[string]any)
675+ assert.Equal(t, "main", repo["default_branch"])
676+ assert.Equal(t, "main", repo["master_branch"])
677+ assert.Equal(t, "90", gh["retention_days"], "a string, from the configured retention")
678+ head := event["head_commit"].(map[string]any)
679+ tree := strings.TrimSpace(c.bareGit("proj", "rev-parse", first+"^{tree}"))
680+ assert.Equal(t, tree, head["tree_id"])
681+
682+ second := c.push("main", map[string]string{"a.txt": "a\n"}, "")
683+ _, event = ev()
684+ assert.Equal(t, publicOrigin+"/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])
685+ assert.Equal(t, false, event["created"])
686+}
M internal/usecase/ci_trigger.go +42 -13
@@ -130,6 +130,8 @@ type pushFacts struct {
130130 filesTruncated bool
131131 tagCount int
132132 isTag bool
133+ // defaultBranch is the default branch of the repository, "" when unknown.
134+ defaultBranch string
133135 }
134136
135137 func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error {
@@ -179,6 +181,9 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
179181 return pushFacts{}, err
180182 }
181183 f := pushFacts{update: u, created: u.IsCreate(), isTag: isTag, tagCount: tagCount}
184+ if def, _, err := c.cfg.Repos.DefaultBranch(ctx, repo); err == nil {
185+ f.defaultBranch = def
186+ }
182187 res, err := c.cfg.Reader.ResolveRevision(ctx, repo, domain.RevisionOf(newID))
183188 if err != nil {
184189 return f, fmt.Errorf("resolve %s: %w", u.New, err)
@@ -381,7 +386,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
381386 Status: domain.CIQueued, CreatedAt: now,
382387 }
383388 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
384- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(repo, run, f, wf),
389+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf),
385390 }}
386391 for i, j := range wf.Jobs {
387392 job := domain.CIJob{
@@ -413,14 +418,33 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
413418
414419 // --- the github context ---
415420
421+// shortID is the 12-character form of an object id that GitHub puts in the
422+// compare URL of a push.
423+func shortID(id string) string {
424+ if len(id) > 12 {
425+ return id[:12]
426+ }
427+ return id
428+}
429+
430+// retentionDays is the retention in days, as github.retention_days shows it
431+// (at least 1).
432+func (c *CI) retentionDays() int {
433+ return max(int(c.cfg.Retention/(24*time.Hour)), 1)
434+}
435+
416436 type obj = map[string]any
417437
418438 func person(name, email string) obj { return obj{"name": name, "email": email} }
419439
420-func (c *CI) commitJSON(repo domain.Repo, cm domain.Commit) obj {
440+func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj {
421441 ts := cm.Committer.Time.UTC().Format(time.RFC3339)
442+ tree := ""
443+ if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
444+ tree = id.String()
445+ }
422446 return obj{
423- "id": cm.ID.String(), "tree_id": "", "distinct": true, "message": cm.Message, "timestamp": ts,
447+ "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts,
424448 "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(),
425449 "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""},
426450 "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""},
@@ -432,10 +456,15 @@ func (c *CI) commitJSON(repo domain.Repo, cm domain.Commit) obj {
432456 // (the job-dependent keys are added when a job is planned). Values that
433457 // GitHub's documentation does not settle are marked for the recordings.
434458 //
435-// TODO(verify with recording): the exact types of run_id, run_number,
436-// run_attempt (strings) and retention_days (a number), the empty tree_id and
437-// the empty added/removed/modified lists of the push commits.
438-func (c *CI) githubJSON(repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string {
459+// Checked against the recordings of ermite-conformance (push-branch-create,
460+// push-tag, push-force, ctx-github-names): ids and counters are strings, so is
461+// retention_days; "compare" has 12-character ids, the commit page for a created
462+// ref and "<before>...<after>" for an update; repository.default_branch and
463+// master_branch are the default branch.
464+//
465+// TODO(verify with recording): the empty added/removed/modified lists of the
466+// push commits.
467+func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string {
439468 full := c.cfg.Owner + "/" + repo.Name.String()
440469 refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
441470 if f.isTag {
@@ -446,22 +475,22 @@ func (c *CI) githubJSON(repo domain.Repo, run domain.CIRun, f pushFacts, wf *wor
446475 if f.created {
447476 before = domain.ZeroOID
448477 }
449- compare := repoURL + "/compare/" + before + "..." + f.update.New
478+ compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
450479 if f.created {
451- compare = repoURL + "/commit/" + f.update.New
480+ compare = repoURL + "/commit/" + shortID(f.update.New)
452481 }
453482 commits := make([]any, 0, len(f.commits))
454483 for _, cm := range f.commits {
455- commits = append(commits, c.commitJSON(repo, cm))
484+ commits = append(commits, c.commitJSON(ctx, repo, cm))
456485 }
457486 event := obj{
458487 "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
459- "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(repo, f.head),
488+ "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head),
460489 "pusher": person(run.Actor, ""),
461490 "repository": obj{
462491 "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate,
463492 "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git",
464- "default_branch": "", "url": repoURL,
493+ "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL,
465494 },
466495 "sender": obj{"login": run.Actor, "type": "User"},
467496 }
@@ -472,7 +501,7 @@ func (c *CI) githubJSON(repo domain.Repo, run domain.CIRun, f pushFacts, wf *wor
472501 "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef,
473502 "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10),
474503 "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3",
475- "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": domain.CIRetentionDays,
504+ "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()),
476505 "head_ref": "", "base_ref": "",
477506 }
478507 b, err := json.Marshal(gh)
@@ -130,6 +130,8 @@ type pushFacts struct {
130 filesTruncated bool130 filesTruncated bool
131 tagCount int131 tagCount int
132 isTag bool132 isTag bool
133+ // defaultBranch is the default branch of the repository, "" when unknown.
134+ defaultBranch string
133 }135 }
134 136
135 func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error {137 func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error {
@@ -179,6 +181,9 @@ func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpda
179 return pushFacts{}, err181 return pushFacts{}, err
180 }182 }
181 f := pushFacts{update: u, created: u.IsCreate(), isTag: isTag, tagCount: tagCount}183 f := pushFacts{update: u, created: u.IsCreate(), isTag: isTag, tagCount: tagCount}
184+ if def, _, err := c.cfg.Repos.DefaultBranch(ctx, repo); err == nil {
185+ f.defaultBranch = def
186+ }
182 res, err := c.cfg.Reader.ResolveRevision(ctx, repo, domain.RevisionOf(newID))187 res, err := c.cfg.Reader.ResolveRevision(ctx, repo, domain.RevisionOf(newID))
183 if err != nil {188 if err != nil {
184 return f, fmt.Errorf("resolve %s: %w", u.New, err)189 return f, fmt.Errorf("resolve %s: %w", u.New, err)
@@ -381,7 +386,7 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
381 Status: domain.CIQueued, CreatedAt: now,386 Status: domain.CIQueued, CreatedAt: now,
382 }387 }
383 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{388 d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
384- Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(repo, run, f, wf),389+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf),
385 }}390 }}
386 for i, j := range wf.Jobs {391 for i, j := range wf.Jobs {
387 job := domain.CIJob{392 job := domain.CIJob{
@@ -413,14 +418,33 @@ func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f push
413 418
414 // --- the github context ---419 // --- the github context ---
415 420
421+// shortID is the 12-character form of an object id that GitHub puts in the
422+// compare URL of a push.
423+func shortID(id string) string {
424+ if len(id) > 12 {
425+ return id[:12]
426+ }
427+ return id
428+}
429+
430+// retentionDays is the retention in days, as github.retention_days shows it
431+// (at least 1).
432+func (c *CI) retentionDays() int {
433+ return max(int(c.cfg.Retention/(24*time.Hour)), 1)
434+}
435+
416 type obj = map[string]any436 type obj = map[string]any
417 437
418 func person(name, email string) obj { return obj{"name": name, "email": email} }438 func person(name, email string) obj { return obj{"name": name, "email": email} }
419 439
420-func (c *CI) commitJSON(repo domain.Repo, cm domain.Commit) obj {440+func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj {
421 ts := cm.Committer.Time.UTC().Format(time.RFC3339)441 ts := cm.Committer.Time.UTC().Format(time.RFC3339)
442+ tree := ""
443+ if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
444+ tree = id.String()
445+ }
422 return obj{446 return obj{
423- "id": cm.ID.String(), "tree_id": "", "distinct": true, "message": cm.Message, "timestamp": ts,447+ "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts,
424 "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(),448 "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(),
425 "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""},449 "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""},
426 "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""},450 "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""},
@@ -432,10 +456,15 @@ func (c *CI) commitJSON(repo domain.Repo, cm domain.Commit) obj {
432 // (the job-dependent keys are added when a job is planned). Values that456 // (the job-dependent keys are added when a job is planned). Values that
433 // GitHub's documentation does not settle are marked for the recordings.457 // GitHub's documentation does not settle are marked for the recordings.
434 //458 //
435-// TODO(verify with recording): the exact types of run_id, run_number,459+// Checked against the recordings of ermite-conformance (push-branch-create,
436-// run_attempt (strings) and retention_days (a number), the empty tree_id and460+// push-tag, push-force, ctx-github-names): ids and counters are strings, so is
437-// the empty added/removed/modified lists of the push commits.461+// retention_days; "compare" has 12-character ids, the commit page for a created
438-func (c *CI) githubJSON(repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string {462+// ref and "<before>...<after>" for an update; repository.default_branch and
463+// master_branch are the default branch.
464+//
465+// TODO(verify with recording): the empty added/removed/modified lists of the
466+// push commits.
467+func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string {
439 full := c.cfg.Owner + "/" + repo.Name.String()468 full := c.cfg.Owner + "/" + repo.Name.String()
440 refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"469 refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
441 if f.isTag {470 if f.isTag {
@@ -446,22 +475,22 @@ func (c *CI) githubJSON(repo domain.Repo, run domain.CIRun, f pushFacts, wf *wor
446 if f.created {475 if f.created {
447 before = domain.ZeroOID476 before = domain.ZeroOID
448 }477 }
449- compare := repoURL + "/compare/" + before + "..." + f.update.New478+ compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
450 if f.created {479 if f.created {
451- compare = repoURL + "/commit/" + f.update.New480+ compare = repoURL + "/commit/" + shortID(f.update.New)
452 }481 }
453 commits := make([]any, 0, len(f.commits))482 commits := make([]any, 0, len(f.commits))
454 for _, cm := range f.commits {483 for _, cm := range f.commits {
455- commits = append(commits, c.commitJSON(repo, cm))484+ commits = append(commits, c.commitJSON(ctx, repo, cm))
456 }485 }
457 event := obj{486 event := obj{
458 "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,487 "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
459- "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(repo, f.head),488+ "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head),
460 "pusher": person(run.Actor, ""),489 "pusher": person(run.Actor, ""),
461 "repository": obj{490 "repository": obj{
462 "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate,491 "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate,
463 "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git",492 "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git",
464- "default_branch": "", "url": repoURL,493+ "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL,
465 },494 },
466 "sender": obj{"login": run.Actor, "type": "User"},495 "sender": obj{"login": run.Actor, "type": "User"},
467 }496 }
@@ -472,7 +501,7 @@ func (c *CI) githubJSON(repo domain.Repo, run domain.CIRun, f pushFacts, wf *wor
472 "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef,501 "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef,
473 "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10),502 "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10),
474 "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3",503 "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3",
475- "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": domain.CIRetentionDays,504+ "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()),
476 "head_ref": "", "base_ref": "",505 "head_ref": "", "base_ref": "",
477 }506 }
478 b, err := json.Marshal(gh)507 b, err := json.Marshal(gh)

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