bridge.go

  1// Package bridge makes a soft-serve forge look like a Tangled knot: a
  2// post-receive hook spools every push, and the bridge server turns spooled
  3// pushes into sh.tangled.git.refUpdate events on a knot-compatible /events
  4// stream (DESIGN.md ยง5). It reads no workflows; the runner does.
  5//
  6// The hook (ci bridge notify) runs as soft-serve with the bare repository at
  7// hand, so it computes everything from git there and writes one JSON file
  8// per push into the spool directory. That never blocks or fails a push, and
  9// a bridge that is down only delays events: the server ingests the spool when
 10// it starts, on every poke from a hook, and every few seconds.
 11package bridge
 12
 13import (
 14	"bufio"
 15	"context"
 16	"encoding/json"
 17	"fmt"
 18	"io"
 19	"os"
 20	"os/exec"
 21	"path/filepath"
 22	"sort"
 23	"strconv"
 24	"strings"
 25
 26	"git.kilimanjaro.io/ci/internal/lex"
 27	"git.kilimanjaro.io/ci/internal/tid"
 28)
 29
 30const zeroSha = "0000000000000000000000000000000000000000"
 31
 32// maxChangedFiles caps changedFiles; beyond it the list is left out, which
 33// upstream treats as unknown (a paths filter then matches).
 34const maxChangedFiles = 5000
 35
 36// Push is one spooled push: what the hook knew. DIDs are added by the server.
 37type Push struct {
 38	Repo     string             `json:"repo"`
 39	Username string             `json:"username,omitempty"`
 40	Updates  []lex.GitRefUpdate `json:"updates"`
 41}
 42
 43// RefLine is one line of post-receive input: "<old> <new> <ref>".
 44type RefLine struct{ Old, New, Ref string }
 45
 46// ParseHookInput reads post-receive's stdin.
 47func ParseHookInput(r io.Reader) ([]RefLine, error) {
 48	var out []RefLine
 49	sc := bufio.NewScanner(r)
 50	for sc.Scan() {
 51		f := strings.Fields(sc.Text())
 52		if len(f) == 0 {
 53			continue
 54		}
 55		if len(f) != 3 {
 56			return nil, fmt.Errorf("unexpected hook input %q", sc.Text())
 57		}
 58		out = append(out, RefLine{f[0], f[1], f[2]})
 59	}
 60	return out, sc.Err()
 61}
 62
 63// PushOptions returns git's push options from the hook environment
 64// (GIT_PUSH_OPTION_COUNT, GIT_PUSH_OPTION_<i>).
 65func PushOptions(getenv func(string) string) []string {
 66	n, _ := strconv.Atoi(getenv("GIT_PUSH_OPTION_COUNT"))
 67	var out []string
 68	for i := range n {
 69		out = append(out, getenv("GIT_PUSH_OPTION_"+strconv.Itoa(i)))
 70	}
 71	return out
 72}
 73
 74// BuildPush computes the ref updates of a push from the bare repository at
 75// gitDir (after the refs were updated, as in post-receive).
 76func BuildPush(ctx context.Context, gitDir, repo, username string, lines []RefLine, pushOptions []string) (Push, error) {
 77	p := Push{Repo: repo, Username: username}
 78	head, _ := git(ctx, gitDir, "symbolic-ref", "-q", "HEAD")
 79	head = strings.TrimSpace(head)
 80	for _, l := range lines {
 81		u := lex.GitRefUpdate{
 82			LexiconTypeID: lex.GitRefUpdateNSID,
 83			Ref:           l.Ref,
 84			OldSha:        l.Old,
 85			NewSha:        l.New,
 86			PushOptions:   pushOptions,
 87			Meta:          &lex.GitRefUpdateMeta{IsDefaultRef: l.Ref == head, CommitCount: &lex.GitRefUpdateCommitCount{}},
 88		}
 89		if l.New != zeroSha {
 90			rng := l.Old + ".." + l.New
 91			if l.Old == zeroSha {
 92				// a new ref: count what isn't on the default branch yet
 93				rng = l.New
 94				if head != "" && head != l.Ref {
 95					if _, err := git(ctx, gitDir, "rev-parse", "--verify", "-q", head); err == nil {
 96						rng = l.New + " ^" + head
 97					}
 98				}
 99			} else if out, err := git(ctx, gitDir, "diff", "--name-only", "--no-renames", l.Old, l.New); err == nil {
100				files := strings.Fields(out)
101				if len(files) <= maxChangedFiles {
102					u.ChangedFiles = files
103				}
104			}
105			if out, err := git(ctx, gitDir, append([]string{"log", "--format=%ae"}, strings.Fields(rng)...)...); err == nil {
106				u.Meta.CommitCount.ByEmail = countEmails(out)
107			}
108		}
109		p.Updates = append(p.Updates, u)
110	}
111	return p, nil
112}
113
114func countEmails(log string) []lex.GitRefUpdateEmailCommitCount {
115	n := map[string]int64{}
116	for _, e := range strings.Fields(log) {
117		n[e]++
118	}
119	out := make([]lex.GitRefUpdateEmailCommitCount, 0, len(n))
120	for e, c := range n {
121		out = append(out, lex.GitRefUpdateEmailCommitCount{Email: e, Count: c})
122	}
123	sort.Slice(out, func(i, j int) bool { return out[i].Email < out[j].Email })
124	return out
125}
126
127// Spool writes p into dir atomically as <tid>.json (sorted by time).
128func Spool(dir string, p Push) (string, error) {
129	if err := os.MkdirAll(dir, 0o775); err != nil {
130		return "", err
131	}
132	b, err := json.Marshal(p)
133	if err != nil {
134		return "", err
135	}
136	name := filepath.Join(dir, tid.New()+".json")
137	tmp := name + ".tmp"
138	if err := os.WriteFile(tmp, b, 0o664); err != nil {
139		return "", err
140	}
141	return name, os.Rename(tmp, name)
142}
143
144func git(ctx context.Context, dir string, args ...string) (string, error) {
145	cmd := exec.CommandContext(ctx, "git", append([]string{"--git-dir", dir}, args...)...)
146	out, err := cmd.Output()
147	return string(out), err
148}