324 lines
13 KiB
Go
324 lines
13 KiB
Go
// Package meshdeliveryclient holds the logic the holders and callers of the seat mesh-delivery share over its contract
|
|
// (novox/hq ADR 0625 §4, ADR 0634 §2, ADR 0635 §5): the keys of its states `merges`, `asks` and `owed` and the rules their
|
|
// values keep, the id the teller gives the router for an ask, and the act an ask's options bind. The gate writes them, the
|
|
// forge bridge, the teller and the assigner read them; the contract's decoding is strict by schema only, so these rules are
|
|
// written once here and no caller copies them.
|
|
package meshdeliveryclient
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
|
"git.novox.be/novox/mesh-sdk/go/keys"
|
|
)
|
|
|
|
// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key.
|
|
const AskKindMerge = "merge"
|
|
|
|
// VerbMerge is the act an ask for a group's merge binds in its options, `mesh-delivery.merge` (Approve).
|
|
const VerbMerge = "merge"
|
|
|
|
// Head is one pull request at one commit: the repository as `<owner>/<name>` and the full sha.
|
|
type Head struct {
|
|
Repository string `json:"repository"`
|
|
Number int `json:"number"`
|
|
Head string `json:"head"`
|
|
}
|
|
|
|
// HeadsHash is the first 12 hex digits of the sha256 over the lines `<repository>#<number>=<head>`, sorted and joined by a
|
|
// newline: the same for every order of heads, different when any head changes. An empty list is refused.
|
|
func HeadsHash(heads []Head) (string, error) {
|
|
if len(heads) == 0 {
|
|
return "", fmt.Errorf("meshdeliveryclient: a heads hash needs at least one pull request")
|
|
}
|
|
lines := make([]string, len(heads))
|
|
for i, h := range heads {
|
|
lines[i] = fmt.Sprintf("%s#%d=%s", h.Repository, h.Number, h.Head)
|
|
}
|
|
sort.Strings(lines)
|
|
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
|
return hex.EncodeToString(sum[:])[:12], nil
|
|
}
|
|
|
|
var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`)
|
|
|
|
func groupKey(group string) (string, error) {
|
|
if group == "" {
|
|
return "", fmt.Errorf("meshdeliveryclient: the group name is empty")
|
|
}
|
|
return keys.EscapeName(group), nil
|
|
}
|
|
|
|
// MergesKey is the key, in the state merges, of a group: `<group>`, escaped so a branch such as `fix/1.2` keys.
|
|
func MergesKey(group string) (string, error) { return groupKey(group) }
|
|
|
|
// MergeAskKey is the key, in the state asks, of the ask for merging a group at these heads: `merge.<group>.<heads hash>`.
|
|
// The teller's answer has the same key.
|
|
func MergeAskKey(group string, heads []Head) (string, error) {
|
|
part, err := groupKey(group)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
hash, err := HeadsHash(heads)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return AskKindMerge + "." + part + "." + 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("meshdeliveryclient: %q is not an ask key, merge.<group>.<heads hash>", key)
|
|
}
|
|
if parts[1] == "" {
|
|
return "", "", "", fmt.Errorf("meshdeliveryclient: %q is not an ask key: the group name is empty", key)
|
|
}
|
|
if group, err = keys.UnescapeName(parts[1]); err != nil {
|
|
return "", "", "", fmt.Errorf("meshdeliveryclient: %q is not an ask key: %w", key, err)
|
|
}
|
|
if !hashOK.MatchString(parts[2]) {
|
|
return "", "", "", fmt.Errorf("meshdeliveryclient: %q is not an ask key: %q is not 12 hex digits", key, parts[2])
|
|
}
|
|
return parts[0], group, parts[2], nil
|
|
}
|
|
|
|
// SettleGroupPrefix begins the group of a settle ask (novox/hq ADR 0556 clause 5): a person's ask to settle deliveries held
|
|
// because their merge reached the trunk without a passing check. It is kept in the asks under MergeAskKey like a merge ask,
|
|
// so the teller carries it unchanged. Git forbids a colon in a branch name, so no pull request's group ever begins with it.
|
|
const SettleGroupPrefix = "settle:"
|
|
|
|
// SettleGroup is the group of the settle ask of these delivery ids: the prefix and twelve hex digits of the sha256 over the
|
|
// ids, sorted and joined by a newline, so the same deliveries asked again keep one key.
|
|
func SettleGroup(ids []string) string {
|
|
sorted := append([]string(nil), ids...)
|
|
sort.Strings(sorted)
|
|
sum := sha256.Sum256([]byte(strings.Join(sorted, "\n")))
|
|
return SettleGroupPrefix + hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
// AskVerb is the act the options of an ask bind, by its group: a settle ask's `mesh-delivery.settle`, any other
|
|
// `mesh-delivery.merge`. The gate composes the act with it and the teller checks a warrant against it.
|
|
func AskVerb(group string) string {
|
|
if strings.HasPrefix(group, SettleGroupPrefix) {
|
|
return seat.Seat + "." + seat.VerbSettle
|
|
}
|
|
return seat.Seat + "." + VerbMerge
|
|
}
|
|
|
|
// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots, 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]
|
|
}
|
|
|
|
// AskingID is the id of the asking-th writing of the ask at askKey. Asking 0 and 1 give AskID(askKey), so the first asking's
|
|
// id is unchanged; asking n of 2 or more gives the AskID form of askKey + "." + n, so each asking has an id of its own and the
|
|
// router, which drops an ask under an id it keeps as a redelivery, takes each as new and an answer to an earlier asking is
|
|
// never taken for a later one.
|
|
func AskingID(askKey string, asking int) string {
|
|
if asking < 2 {
|
|
return AskID(askKey)
|
|
}
|
|
return AskID(askKey + "." + strconv.Itoa(asking))
|
|
}
|
|
|
|
// The acts the gate owes the forge, the last part of an owed key.
|
|
const (
|
|
OwedStatus = "status"
|
|
OwedView = "view"
|
|
OwedNote = "note"
|
|
OwedComment = "comment"
|
|
)
|
|
|
|
// The status contexts the mesh owes the forge.
|
|
const (
|
|
StatusContextMergeGate = "mesh/merge-gate"
|
|
StatusContextDelivery = "mesh/delivery"
|
|
StatusContextDeliveryGroup = "mesh/delivery-group"
|
|
StatusContextRepoCheck = "mesh/repo-check"
|
|
StatusContextCheck = "mesh/check"
|
|
)
|
|
|
|
var statusContexts = []string{StatusContextMergeGate, StatusContextDelivery, StatusContextDeliveryGroup, StatusContextRepoCheck, StatusContextCheck}
|
|
|
|
// StatusContexts is the contexts, in a new slice.
|
|
func StatusContexts() []string { return append([]string(nil), statusContexts...) }
|
|
|
|
// The states a status the gate owes the forge may carry.
|
|
var statusStates = []string{"pending", "success", "failure", "error", "warning"}
|
|
|
|
func checkStatusContext(context string) error {
|
|
for _, c := range statusContexts {
|
|
if context == c {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("meshdeliveryclient: the status context %q is not one of %s", context, strings.Join(statusContexts, ", "))
|
|
}
|
|
|
|
// ParseOwedAct splits an owed act into its kind and, for a status, its context, for a comment its sequence as digits. The act
|
|
// is `status=<context>`, `view`, `note` or `comment.<seq>`.
|
|
func ParseOwedAct(act string) (kind, context string, err error) {
|
|
kind, context, hasContext := strings.Cut(act, "=")
|
|
switch kind {
|
|
case OwedStatus:
|
|
if !hasContext {
|
|
return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q is a status without its context, status=<context>", act)
|
|
}
|
|
if err := checkStatusContext(context); err != nil {
|
|
return "", "", err
|
|
}
|
|
return kind, context, nil
|
|
case OwedView, OwedNote:
|
|
if hasContext {
|
|
return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q takes no context", act)
|
|
}
|
|
return kind, "", nil
|
|
}
|
|
if kind, seq, ok := strings.Cut(act, "."); ok && kind == OwedComment {
|
|
if n, err := strconv.Atoi(seq); err != nil || n < 1 || strconv.Itoa(n) != seq {
|
|
return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q is a comment without its sequence, comment.<seq> with seq of 1 or more", act)
|
|
}
|
|
return OwedComment, seq, nil
|
|
}
|
|
return "", "", fmt.Errorf("meshdeliveryclient: the owed act %q is not status=<context>, view, note or comment.<seq>", act)
|
|
}
|
|
|
|
// OwedCommentAct is the act of the seq-th comment on a head: `comment.<seq>`, seq of 1 or more, so a second comment has a key
|
|
// of its own and does not overwrite the first.
|
|
func OwedCommentAct(seq int) (string, error) {
|
|
if seq < 1 {
|
|
return "", fmt.Errorf("meshdeliveryclient: a comment's sequence is 1 or more, not %d", seq)
|
|
}
|
|
return OwedComment + "." + strconv.Itoa(seq), nil
|
|
}
|
|
|
|
func escapeRepository(repository string) (string, error) {
|
|
owner, name, ok := strings.Cut(repository, "/")
|
|
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
|
return "", fmt.Errorf("meshdeliveryclient: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
|
}
|
|
return keys.EscapeName(owner) + "/" + keys.EscapeName(name), nil
|
|
}
|
|
|
|
func unescapeRepository(part string) (string, error) {
|
|
owner, name, ok := strings.Cut(part, "/")
|
|
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
|
|
return "", fmt.Errorf("meshdeliveryclient: the repository %q is not <owner>/<name>, with exactly one slash", part)
|
|
}
|
|
o, err := keys.UnescapeName(owner)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
n, err := keys.UnescapeName(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return o + "/" + n, nil
|
|
}
|
|
|
|
// OwedKey is the key, in the state owed, of an act the gate owes the forge on a pull request at a head:
|
|
// `<repository>.<number>.<head>.<act>`. The number is 0 for a commit no pull request names, and never negative.
|
|
func OwedKey(repository string, number int, head, act string) (string, error) {
|
|
repo, err := escapeRepository(repository)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if number < 0 {
|
|
return "", fmt.Errorf("meshdeliveryclient: an owed key's pull request number is 0 (none) or more, not %d", number)
|
|
}
|
|
if err := keys.CheckName("head", head); err != nil {
|
|
return "", err
|
|
}
|
|
if _, _, err := ParseOwedAct(act); err != nil {
|
|
return "", err
|
|
}
|
|
return repo + "." + 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.SplitN(key, ".", 4) // a comment's act holds a dot
|
|
if len(parts) != 4 {
|
|
return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key, <repository>.<number>.<head>.<act>", key)
|
|
}
|
|
number, convErr := strconv.Atoi(parts[1])
|
|
if convErr != nil {
|
|
return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key: %q is not a pull request number", key, parts[1])
|
|
}
|
|
if repository, err = unescapeRepository(parts[0]); err != nil {
|
|
return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key: %w", key, err)
|
|
}
|
|
if _, err := OwedKey(repository, number, parts[2], parts[3]); err != nil {
|
|
return "", 0, "", "", fmt.Errorf("meshdeliveryclient: %q is not an owed key: %w", key, err)
|
|
}
|
|
return repository, number, parts[2], parts[3], nil
|
|
}
|
|
|
|
// CheckOwed refuses an owed value the forge bridge could not perform: a status needs its context (one the mesh owes), a
|
|
// state of the list and a target; a view and a comment need text; a note needs text and a ref.
|
|
func CheckOwed(o seat.OwedByRepositoryNumberHeadActValue) error {
|
|
const who = "meshdeliveryclient: "
|
|
switch o.Act {
|
|
case OwedStatus:
|
|
if o.Context == "" {
|
|
return fmt.Errorf(who + "an owed status has no context")
|
|
}
|
|
if err := checkStatusContext(o.Context); err != nil {
|
|
return err
|
|
}
|
|
if o.Target == "" {
|
|
return fmt.Errorf(who + "an owed status has no target")
|
|
}
|
|
for _, s := range statusStates {
|
|
if o.State == s {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf(who+"the owed status state %q is not %s", o.State, strings.Join(statusStates, ", "))
|
|
case OwedView, OwedComment:
|
|
if o.Text == "" {
|
|
return fmt.Errorf(who+"an owed %s has no text", o.Act)
|
|
}
|
|
case OwedNote:
|
|
if o.Text == "" {
|
|
return fmt.Errorf(who + "an owed note has no text")
|
|
}
|
|
if o.Ref == "" {
|
|
return fmt.Errorf(who + "an owed note has no ref")
|
|
}
|
|
default:
|
|
return fmt.Errorf(who+"the owed act %q is not status, view, note or comment", o.Act)
|
|
}
|
|
return nil
|
|
}
|