Files
mesh-delivery/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go
T
jschoubben 677f94e901
mesh/delivery-group group fix/1279-consumers-from-now rejected: a member's own check failed
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 677f94e9012b merged into trunk 1325c8b452c8 beside mesh-controller#400@a0c971ce — fail: its merge-check.sh failed: --- FAIL: TestBridg…
mesh/delivery superseded: a newer head of the same pull request
Say which pull requests are open and drop every other candidate, so the candidates hold only open pull requests (issue 1279, ADR 0443)
2026-10-19 21:56:42 +02:00

251 lines
10 KiB
Go

// Package bridge turns the forge module's pull request events into the messages the forge bridge writes on FORGE.
package bridge
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// ErrUnreadable is wrapped by every error that says an event cannot be turned into a message; it is never
// redelivered.
var ErrUnreadable = errors.New("unreadable event")
// ErrNotHandled says the event is one the bridge hears but writes nothing for (yet).
var ErrNotHandled = errors.New("event not handled")
// ErrRetry says the event is readable but the forge could not be asked what the message needs; the event is to be
// heard again later.
var ErrRetry = errors.New("the forge could not say")
// ErrNoModules says a merge into the trunk left a repository with no module.json anywhere: no merged message.
var ErrNoModules = errors.New("no module.json in the merge")
// Trunk is the branch a merged message is written for.
const Trunk = "main"
// Trees asks the forge for the trees of a commit: the root tree and the tree of every directory holding a module.json
// (the root directory spelled ""), as the forge's gitea_commit_trees answers (issue 1234/AC4).
type Trees func(owner, repo, sha string) (root string, trees map[string]string, err error)
// Message is a message for FORGE: the event name (forge.<owner>.<name>.<kind>) and its JSON body.
type Message struct {
Event string
Data []byte
}
// The subjects of the forge module's events the bridge writes pull messages for (mesh-catalog modules/gitea,
// published by the module as mesh.mod.<module>.event.<event>).
const (
UpdatedSubject = "mesh.mod.gitea.event.pull.updated"
ClosedSubject = "mesh.mod.gitea.event.pull.closed"
MergedSubject = "mesh.mod.gitea.event.pull.merged"
)
var sha40 = regexp.MustCompile(`^[0-9a-f]{40}$`)
// pull is the part of gitea.pull.merged (index.ts:186-207), gitea.pull.updated (mesh-catalog modules/gitea/trunk.ts:233-250) and gitea.pull.closed
// (index.ts:127-128) the bridge reads; head is the branch and head_sha the commit.
type pull struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Head string `json:"head"`
HeadSHA string `json:"head_sha"`
ModuleDirs []string `json:"module_dirs"`
ModuleDirsSaid bool `json:"module_dirs_said"`
Base string `json:"base"`
MergeSHA string `json:"merge_commit_sha"`
}
func unreadable(subject, why string, args ...any) error {
return fmt.Errorf("%w: %s: %s", ErrUnreadable, subject, fmt.Sprintf(why, args...))
}
// Convert turns one event of the forge module, by its bus subject and body, into a message. An event it cannot read
// is an ErrUnreadable error naming the subject and why; an event it writes nothing for is ErrNotHandled. A merge into
// the trunk asks trees once and becomes a merged message; a merge into any other branch is a closed pull message, as the
// pull request left its group. A forge that cannot say is ErrRetry; a merge with no module.json is ErrNoModules.
func Convert(subject string, data []byte, trees Trees) (Message, error) {
var action string
switch subject {
case UpdatedSubject:
action = delivery.PullUpdated
case ClosedSubject:
action = delivery.PullClosed
case MergedSubject:
action = delivery.PullClosed // unless it merged into the trunk, below
default:
return Message{}, fmt.Errorf("%w: %s", ErrNotHandled, subject)
}
var p pull
if err := json.Unmarshal(data, &p); err != nil {
return Message{}, unreadable(subject, "the body is not json: %v", err)
}
switch {
case p.Owner == "" || p.Repo == "":
return Message{}, unreadable(subject, "no owner or repo")
case p.Number <= 0:
return Message{}, unreadable(subject, "no pull request number")
case !sha40.MatchString(p.HeadSHA):
return Message{}, unreadable(subject, "the head %q is not 40 hex digits", p.HeadSHA)
case p.Head == "":
return Message{}, unreadable(subject, "no branch")
}
if subject == MergedSubject && p.Base == Trunk {
return merged(subject, p, trees)
}
f := delivery.Forge{Kind: delivery.ForgePull, Repository: p.Owner + "/" + p.Repo, Branch: p.Head, Number: p.Number,
Head: p.HeadSHA, Action: action}
// The directories are the forge's word only when it said it could read them (module_dirs_said).
if action == delivery.PullUpdated && p.ModuleDirsSaid {
f.Dirs, f.DirsKnown = p.ModuleDirs, true
}
event, err := delivery.ForgeEvent(f.Repository, delivery.ForgePull)
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
body, err := delivery.EncodeForge(f)
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
return Message{Event: event, Data: body}, nil
}
// merged is the merged message of a merge into the trunk: the trees of the merge commit, asked once.
func merged(subject string, p pull, trees Trees) (Message, error) {
if !sha40.MatchString(p.MergeSHA) {
return Message{}, unreadable(subject, "the merge commit %q is not 40 hex digits", p.MergeSHA)
}
repository := p.Owner + "/" + p.Repo
root, dirs, err := trees(p.Owner, p.Repo, p.MergeSHA)
if err != nil {
return Message{}, fmt.Errorf("%w: %s: the trees of %s at the merge commit %s: %v", ErrRetry, subject, repository, p.MergeSHA, err)
}
if len(dirs) == 0 {
return Message{}, fmt.Errorf("%w: %s: %s at the merge commit %s", ErrNoModules, subject, repository, p.MergeSHA)
}
event, err := delivery.ForgeEvent(repository, delivery.ForgeMerged)
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
body, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeMerged, Repository: repository, Branch: p.Head, Number: p.Number,
Head: p.HeadSHA, Merge: p.MergeSHA, Root: root, Trees: dirs})
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
return Message{Event: event, Data: body}, nil
}
// openPull is an entry of gitea_open_pulls's answer (mesh-catalog modules/gitea/client.ts:369-381 openPulls, with the
// branch as `head`, issue 1234/AC5), the part the bridge reads.
type openPull struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Base string `json:"base"`
Head string `json:"head"`
HeadSHA string `json:"head_sha"`
}
// OpenPulls turns the answer of gitea_open_pulls, {pulls: [...]}, into one pull message per open pull request into
// the trunk, action updated and its directories not known (the forge's answer does not say them). A pull request into
// another branch gets none. An entry that cannot be read is named in skipped and gets none; an answer that is not
// {pulls} is an error.
func OpenPulls(answer []byte) (msgs []Message, skipped []string, err error) {
var a struct {
Pulls *[]openPull `json:"pulls"`
}
if err := json.Unmarshal(answer, &a); err != nil || a.Pulls == nil {
return nil, nil, fmt.Errorf("the answer of gitea_open_pulls is not {pulls: [...]}: %v", err)
}
for _, p := range *a.Pulls {
if p.Base != Trunk {
continue
}
name := fmt.Sprintf("%s/%s#%d", p.Owner, p.Repo, p.Number)
if p.Owner == "" || p.Repo == "" || p.Number <= 0 || p.Head == "" || !sha40.MatchString(p.HeadSHA) {
skipped = append(skipped, name+": it names no owner, repo, number, branch or 40-hex head")
continue
}
f := delivery.Forge{Kind: delivery.ForgePull, Repository: p.Owner + "/" + p.Repo, Branch: p.Head, Number: p.Number,
Head: p.HeadSHA, Action: delivery.PullUpdated}
event, err := delivery.ForgeEvent(f.Repository, delivery.ForgePull)
var body []byte
if err == nil {
body, err = delivery.EncodeForge(f)
}
if err != nil {
skipped = append(skipped, name+": "+err.Error())
continue
}
msgs = append(msgs, Message{Event: event, Data: body})
}
return msgs, skipped, nil
}
// Held is the head FORGE last holds for each pull request, by `<owner>/<name>#<number>`: what the bridge compares an
// open pull request's head with, so it writes a pull message only for a head that differs (novox/hq ADR 0443 §5).
type Held map[string]string
func heldKey(repository string, number int) string { return fmt.Sprintf("%s#%d", repository, number) }
// Hear takes one message of FORGE into what is held: a pull message holds its head, a closed one or a merged message
// holds nothing for its pull request. Other kinds change nothing.
func (h Held) Hear(f delivery.Forge) {
key := heldKey(f.Repository, f.Number)
switch {
case f.Kind == delivery.ForgePull && f.Action != delivery.PullClosed:
h[key] = f.Head
case f.Kind == delivery.ForgePull || f.Kind == delivery.ForgeMerged:
delete(h, key)
}
}
// Differing keeps the pull messages whose head FORGE does not hold, in their order.
func (h Held) Differing(msgs []Message) []Message {
var out []Message
for _, m := range msgs {
if f, err := delivery.DecodeForge(m.Data); err == nil && h[heldKey(f.Repository, f.Number)] == f.Head {
continue
}
out = append(out, m)
}
return out
}
// OpenMessage turns the answer of gitea_open_pulls into the open message: every open pull request into the trunk, by
// repository and number, whether its head reads or not and whether FORGE holds it or not, in repository and number
// order. A pull request into another branch is not named. An answer that names none is a message too: nothing is open.
func OpenMessage(answer []byte) (Message, error) {
var a struct {
Pulls *[]openPull `json:"pulls"`
}
if err := json.Unmarshal(answer, &a); err != nil || a.Pulls == nil {
return Message{}, fmt.Errorf("the answer of gitea_open_pulls is not {pulls: [...]}: %v", err)
}
open := []delivery.OpenPull{}
for _, p := range *a.Pulls {
if p.Base != Trunk || p.Owner == "" || p.Repo == "" || p.Number <= 0 {
continue
}
open = append(open, delivery.OpenPull{Repository: p.Owner + "/" + p.Repo, Number: p.Number})
}
slices.SortFunc(open, func(a, b delivery.OpenPull) int {
if c := strings.Compare(a.Repository, b.Repository); c != 0 {
return c
}
return a.Number - b.Number
})
body, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeOpen, Open: open})
if err != nil {
return Message{}, fmt.Errorf("the open pull requests cannot be said: %w", err)
}
return Message{Event: delivery.OpenEvent(), Data: body}, nil
}