405 lines
13 KiB
Go
405 lines
13 KiB
Go
package delivery
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected
|
|
// head, its state and each merge commit), its asks (`merge.<group>.<heads hash>`, the router's ask whole) and owed
|
|
// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its
|
|
// answer under the ask's own key in its bucket answers.
|
|
const (
|
|
MergesBucket = GateModule + "_merges"
|
|
AsksBucket = GateModule + "_asks"
|
|
OwedBucket = GateModule + "_owed"
|
|
AnswersBucket = TellerModule + "_answers"
|
|
MergesHistory = 10
|
|
AsksHistory = 5
|
|
OwedHistory = 5
|
|
AnswersHistory = 5
|
|
)
|
|
|
|
// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key.
|
|
const AskKindMerge = "merge"
|
|
|
|
// The acts the gate owes the forge, the last part of an owed key.
|
|
const (
|
|
OwedStatus = "status"
|
|
OwedView = "view"
|
|
OwedNote = "note"
|
|
OwedComment = "comment"
|
|
)
|
|
|
|
// The states of a pull request in a merge.
|
|
type MergeState string
|
|
|
|
// The states a merge's pull request is in.
|
|
const (
|
|
MergeMerging MergeState = "merging"
|
|
MergeMerged MergeState = "merged"
|
|
MergeRefused MergeState = "refused"
|
|
)
|
|
|
|
// The states a status the gate owes the forge may carry.
|
|
var statusStates = []string{"pending", "success", "failure", "error", "warning"}
|
|
|
|
var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`)
|
|
|
|
// MergesKey is the key, in MergesBucket, of a group: `<group>`.
|
|
func MergesKey(group string) (string, error) {
|
|
if err := checkName("group", group); err != nil {
|
|
return "", err
|
|
}
|
|
return group, nil
|
|
}
|
|
|
|
// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge.<group>.<heads
|
|
// hash>`. The teller's answer has the same key in AnswersBucket (AnswerKey).
|
|
func MergeAskKey(group string, heads []Head) (string, error) {
|
|
if err := checkName("group", group); err != nil {
|
|
return "", err
|
|
}
|
|
hash, err := HeadsHash(heads)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return AskKindMerge + "." + group + "." + hash, nil
|
|
}
|
|
|
|
// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash.
|
|
func ParseAskKey(key string) (kind, group, hash string, err error) {
|
|
parts := strings.Split(key, ".")
|
|
if len(parts) != 3 || parts[0] != AskKindMerge {
|
|
return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge.<group>.<heads hash>", key)
|
|
}
|
|
if err := checkName("group", parts[1]); err != nil {
|
|
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err)
|
|
}
|
|
if !hashOK.MatchString(parts[2]) {
|
|
return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2])
|
|
}
|
|
return parts[0], parts[1], parts[2], nil
|
|
}
|
|
|
|
// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string.
|
|
func AnswerKey(askKey string) string { return askKey }
|
|
|
|
// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID),
|
|
// so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and
|
|
// the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back:
|
|
// the teller keeps the key beside it.
|
|
func AskID(askKey string) string {
|
|
const most = 64 // the router's bound on an id
|
|
const tail = 13 // "-" and 12 hex digits
|
|
readable := []byte(askKey)
|
|
for i, c := range readable {
|
|
switch {
|
|
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
|
case c == '.':
|
|
readable[i] = '-'
|
|
default: // a slash, an equals sign or any other byte the router's id refuses
|
|
readable[i] = '_'
|
|
}
|
|
}
|
|
if len(readable) > 0 && (readable[0] == '-' || readable[0] == '_') {
|
|
readable[0] = 'k' // an id starts with a letter or digit
|
|
}
|
|
if len(readable) > most-tail {
|
|
readable = readable[:most-tail]
|
|
}
|
|
sum := sha256.Sum256([]byte(askKey))
|
|
return string(readable) + "-" + hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
func checkOwedAct(act string) (kind, context string, err error) {
|
|
kind, context, hasContext := strings.Cut(act, "=")
|
|
switch kind {
|
|
case OwedStatus:
|
|
if !hasContext {
|
|
return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=<context>", act)
|
|
}
|
|
if err := checkName("status context", context); err != nil {
|
|
return "", "", err
|
|
}
|
|
return kind, context, nil
|
|
case OwedView, OwedNote, OwedComment:
|
|
if hasContext {
|
|
return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act)
|
|
}
|
|
return kind, "", nil
|
|
}
|
|
return "", "", fmt.Errorf("delivery: the owed act %q is not status=<context>, view, note or comment", act)
|
|
}
|
|
|
|
// ParseOwedAct splits an owed act into its kind and, for a status, its context.
|
|
func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) }
|
|
|
|
// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head:
|
|
// `<repository>.<number>.<head>.<act>`, the act being `status=<context>` (mesh/merge-gate), `view`, `note` or
|
|
// `comment`.
|
|
func OwedKey(repository string, number int, head, act string) (string, error) {
|
|
if err := checkRepository(repository); err != nil {
|
|
return "", err
|
|
}
|
|
if number <= 0 {
|
|
return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number)
|
|
}
|
|
if err := checkName("head", head); err != nil {
|
|
return "", err
|
|
}
|
|
if _, _, err := checkOwedAct(act); err != nil {
|
|
return "", err
|
|
}
|
|
return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil
|
|
}
|
|
|
|
// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=<context>` for a status.
|
|
func ParseOwedKey(key string) (repository string, number int, head, act string, err error) {
|
|
parts := strings.Split(key, ".")
|
|
if len(parts) != 4 {
|
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, <repository>.<number>.<head>.<act>", key)
|
|
}
|
|
number, convErr := strconv.Atoi(parts[1])
|
|
if convErr != nil {
|
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1])
|
|
}
|
|
if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil {
|
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
|
|
}
|
|
return parts[0], number, parts[2], parts[3], nil
|
|
}
|
|
|
|
// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects
|
|
// and how far its merge has come.
|
|
type Merge struct {
|
|
V int `json:"v"`
|
|
Group string `json:"group"`
|
|
Ask string `json:"ask"`
|
|
Pulls []MergePull `json:"pulls"`
|
|
}
|
|
|
|
// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the
|
|
// forge's words when refused.
|
|
type MergePull struct {
|
|
Head
|
|
State MergeState `json:"state"`
|
|
Commit string `json:"commit,omitempty"`
|
|
Words string `json:"words,omitempty"`
|
|
}
|
|
|
|
func (m Merge) check() error {
|
|
if err := checkName("group", m.Group); err != nil {
|
|
return err
|
|
}
|
|
if m.Ask == "" {
|
|
return fmt.Errorf("delivery: a merge names no ask")
|
|
}
|
|
if len(m.Pulls) == 0 {
|
|
return fmt.Errorf("delivery: a merge names no pull request")
|
|
}
|
|
for _, p := range m.Pulls {
|
|
who := fmt.Sprintf("%s#%d", p.Repository, p.Number)
|
|
switch p.State {
|
|
case MergeMerging:
|
|
case MergeMerged:
|
|
if p.Commit == "" {
|
|
return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who)
|
|
}
|
|
case MergeRefused:
|
|
if p.Words == "" {
|
|
return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who)
|
|
}
|
|
default:
|
|
return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it
|
|
// unchanged, and the group and heads it was composed for.
|
|
type Ask struct {
|
|
V int `json:"v"`
|
|
Group string `json:"group"`
|
|
Heads []Head `json:"heads"`
|
|
Ask asks.Ask `json:"ask"`
|
|
}
|
|
|
|
func (a Ask) check() error {
|
|
key, err := MergeAskKey(a.Group, a.Heads)
|
|
if err != nil {
|
|
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
|
|
}
|
|
if want := AskID(key); a.Ask.ID != want {
|
|
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want)
|
|
}
|
|
if len(a.Ask.Options) == 0 {
|
|
return fmt.Errorf("delivery: the ask has no option")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words.
|
|
type Answer struct {
|
|
V int `json:"v"`
|
|
Option string `json:"option"`
|
|
Warrant string `json:"warrant"`
|
|
Words string `json:"words"`
|
|
}
|
|
|
|
func (a Answer) check() error {
|
|
for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} {
|
|
if f.value == "" {
|
|
return fmt.Errorf("delivery: an answer has no %s", f.name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a
|
|
// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan).
|
|
type Owed struct {
|
|
V int `json:"v"`
|
|
Act string `json:"act"`
|
|
Context string `json:"context,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Ref string `json:"ref,omitempty"`
|
|
}
|
|
|
|
func (o Owed) check() error {
|
|
switch o.Act {
|
|
case OwedStatus:
|
|
if o.Context == "" {
|
|
return fmt.Errorf("delivery: an owed status has no context")
|
|
}
|
|
for _, s := range statusStates {
|
|
if o.State == s {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", "))
|
|
case OwedView, OwedComment:
|
|
if o.Text == "" {
|
|
return fmt.Errorf("delivery: an owed %s has no text", o.Act)
|
|
}
|
|
case OwedNote:
|
|
if o.Text == "" {
|
|
return fmt.Errorf("delivery: an owed note has no text")
|
|
}
|
|
if o.Ref == "" {
|
|
return fmt.Errorf("delivery: an owed note has no ref")
|
|
}
|
|
default:
|
|
return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one
|
|
// without the forge's words and a state outside the list.
|
|
func EncodeMerge(m Merge) ([]byte, error) {
|
|
if err := m.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
m.V = Version
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses.
|
|
func DecodeMerge(data []byte) (Merge, error) {
|
|
var m Merge
|
|
if err := decode(data, &m); err != nil {
|
|
return Merge{}, err
|
|
}
|
|
if err := m.check(); err != nil {
|
|
return Merge{}, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with
|
|
// no option.
|
|
func EncodeAsk(a Ask) ([]byte, error) {
|
|
if err := a.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
a.V = Version
|
|
return json.Marshal(a)
|
|
}
|
|
|
|
// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses.
|
|
func DecodeAsk(data []byte) (Ask, error) {
|
|
var a Ask
|
|
if err := decode(data, &a); err != nil {
|
|
return Ask{}, err
|
|
}
|
|
if err := a.check(); err != nil {
|
|
return Ask{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words.
|
|
func EncodeAnswer(a Answer) ([]byte, error) {
|
|
if err := a.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
a.V = Version
|
|
return json.Marshal(a)
|
|
}
|
|
|
|
// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses.
|
|
func DecodeAnswer(data []byte) (Answer, error) {
|
|
var a Answer
|
|
if err := decode(data, &a); err != nil {
|
|
return Answer{}, err
|
|
}
|
|
if err := a.check(); err != nil {
|
|
return Answer{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs.
|
|
func EncodeOwed(o Owed) ([]byte, error) {
|
|
if err := o.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
o.V = Version
|
|
return json.Marshal(o)
|
|
}
|
|
|
|
// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses.
|
|
func DecodeOwed(data []byte) (Owed, error) {
|
|
var o Owed
|
|
if err := decode(data, &o); err != nil {
|
|
return Owed{}, err
|
|
}
|
|
if err := o.check(); err != nil {
|
|
return Owed{}, err
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// SameMerge says whether two merges are the same value, so a writer writes only when they differ.
|
|
func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) }
|
|
|
|
// SameAsk says whether two asks are the same value.
|
|
func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) }
|
|
|
|
// SameAnswer says whether two answers are the same value.
|
|
func SameAnswer(a, b Answer) bool { return a == b }
|
|
|
|
// SameOwed says whether two owed acts are the same value.
|
|
func SameOwed(a, b Owed) bool { return a == b }
|