The gate's and the assigner's asks carry the router's ask as its JSON, so mesh-sdk names no type of a seat defined whole; the ask's id, options, digest and an option's level are read by the gate, the assigner and the teller through operatorchannelclient. ViewAnswerFits keeps the asking's check, from the key. The channel and intake benches are not defined whole: their shapes move to go/channel (ADR 0625 §9). Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
528 lines
19 KiB
Go
528 lines
19 KiB
Go
package delivery
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// 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
|
|
)
|
|
|
|
// The contexts of the statuses the mesh owes the forge: the gate's, the delivery's view of a head and of a group
|
|
// (design 47), and the checker's. From R4e the checker's mesh/check is the one status a repository's main requires,
|
|
// in place of mesh/merge-gate and mesh/repo-check (ADR 0503 clauses 3 and 5); a main that requires only
|
|
// mesh/repo-check (hq) keeps it, and the controller writes it for a repository the checker writes no status for.
|
|
const (
|
|
StatusContextMergeGate = "mesh/merge-gate"
|
|
StatusContextDelivery = "mesh/delivery"
|
|
StatusContextDeliveryGroup = "mesh/delivery-group"
|
|
StatusContextRepoCheck = "mesh/repo-check" // the gate's pending status of a recheck (mesh-delivery.recheck)
|
|
StatusContextCheck = "mesh/check"
|
|
)
|
|
|
|
var statusContexts = []string{StatusContextMergeGate, StatusContextDelivery, StatusContextDeliveryGroup, StatusContextRepoCheck, StatusContextCheck}
|
|
|
|
// 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) {
|
|
return groupKey(group)
|
|
}
|
|
|
|
// 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) {
|
|
part, err := groupKey(group)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
hash, err := HeadsHash(heads)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return AskKindMerge + "." + part + "." + hash, 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 gate's asks under MergeAskKey like a merge
|
|
// ask, so the teller carries it unchanged; its options bind the act AskVerb names for it. Git forbids a colon in a branch
|
|
// name, so no pull request's group (its branch) 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 of the gate 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 "mesh-delivery.settle"
|
|
}
|
|
return "mesh-delivery.merge"
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
group, err = parseGroupKey(parts[1])
|
|
if 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], group, 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 (its usable id),
|
|
// 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 (at most 64 bytes, ending in the 12 hex digits of the hash of that whole string) 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))
|
|
}
|
|
|
|
// checkStatusContext refuses a status context the mesh does not owe the forge.
|
|
func checkStatusContext(context string) error {
|
|
for _, c := range statusContexts {
|
|
if context == c {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("delivery: the status context %q is not one of %s", context, strings.Join(statusContexts, ", "))
|
|
}
|
|
|
|
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 := checkStatusContext(context); err != nil {
|
|
return "", "", err
|
|
}
|
|
return kind, context, nil
|
|
case OwedView, OwedNote:
|
|
if hasContext {
|
|
return "", "", fmt.Errorf("delivery: 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("delivery: 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("delivery: 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("delivery: a comment's sequence is 1 or more, not %d", seq)
|
|
}
|
|
return OwedComment + "." + strconv.Itoa(seq), nil
|
|
}
|
|
|
|
// ParseOwedAct splits an owed act into its kind and, for a status, its context, for a comment its sequence as
|
|
// digits.
|
|
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.<seq>`. 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("delivery: an owed key's pull request number is 0 (none) or more, not %d", number)
|
|
}
|
|
if err := checkName("head", head); err != nil {
|
|
return "", err
|
|
}
|
|
if _, _, err := checkOwedAct(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("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])
|
|
}
|
|
repository, err = unescapeRepository(parts[0])
|
|
if err != nil {
|
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
|
|
}
|
|
if _, err := OwedKey(repository, number, parts[2], parts[3]); err != nil {
|
|
return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err)
|
|
}
|
|
return repository, 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. Approved is the Approve's heads of every member of the group, ApprovedKey of
|
|
// the member to its full head sha: the forge's merge tool wants them for every sibling whichever member it is
|
|
// handed (ADR 0450 §3). A merge written before Approved existed has none and is not checked against it.
|
|
type Merge struct {
|
|
V int `json:"v"`
|
|
Group string `json:"group"`
|
|
Ask string `json:"ask"`
|
|
Approved map[string]string `json:"approved,omitempty"`
|
|
Pulls []MergePull `json:"pulls"`
|
|
}
|
|
|
|
// ApprovedKey is the key of a member in Merge.Approved: `<repository>@<number>`.
|
|
func ApprovedKey(repository string, number int) string {
|
|
return repository + "@" + strconv.Itoa(number)
|
|
}
|
|
|
|
// 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 m.Group == "" {
|
|
return fmt.Errorf("delivery: a merge names no group")
|
|
}
|
|
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 key, sha := range m.Approved {
|
|
if sha == "" {
|
|
return fmt.Errorf("delivery: the approved head of %s is empty", key)
|
|
}
|
|
}
|
|
for _, p := range m.Pulls {
|
|
who := fmt.Sprintf("%s#%d", p.Repository, p.Number)
|
|
if len(m.Approved) > 0 {
|
|
sha, ok := m.Approved[ApprovedKey(p.Repository, p.Number)]
|
|
if !ok {
|
|
return fmt.Errorf("delivery: the pull request %s is handed to the merge but is not among the approved heads", who)
|
|
}
|
|
if sha != p.Head.Head {
|
|
return fmt.Errorf("delivery: the pull request %s is handed at head %q but the Approve was for %q", who, p.Head.Head, sha)
|
|
}
|
|
}
|
|
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. The ask is the operator-channel seat's (mesh-contracts
|
|
// operatorchannel.Ask), carried here as its JSON: mesh-sdk names no seat defined whole (novox/hq ADR 0627 §4), so its
|
|
// id (AskingID of its key and asking), its options and its digest are checked by the gate and the teller through
|
|
// operator-channel's client.
|
|
type Ask struct {
|
|
V int `json:"v"`
|
|
Group string `json:"group"`
|
|
Heads []Head `json:"heads"`
|
|
Ask json.RawMessage `json:"ask"`
|
|
// Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th.
|
|
// The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id
|
|
// (AskingID); the first asking's id is unchanged and the field is omitted for it.
|
|
Asking int `json:"asking,omitempty"`
|
|
}
|
|
|
|
func (a Ask) check() error {
|
|
if a.Asking < 0 {
|
|
return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking)
|
|
}
|
|
if _, err := MergeAskKey(a.Group, a.Heads); err != nil {
|
|
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
|
|
}
|
|
return routerAsk(a.Ask)
|
|
}
|
|
|
|
// routerAsk refuses a value that carries no router's ask: none, null, or JSON that is no object. What the ask says
|
|
// is operator-channel's to check, through its client (novox/hq ADR 0627 §4).
|
|
func routerAsk(raw json.RawMessage) error {
|
|
var fields map[string]json.RawMessage
|
|
if len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil {
|
|
return fmt.Errorf("delivery: the value carries no router's ask")
|
|
}
|
|
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, State and Target (the
|
|
// address the status points at), 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"`
|
|
Target string `json:"target,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")
|
|
}
|
|
if err := checkStatusContext(o.Context); err != nil {
|
|
return err
|
|
}
|
|
if o.Target == "" {
|
|
return fmt.Errorf("delivery: an owed status has no target")
|
|
}
|
|
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 }
|