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}