// Package evt is quarry's event vocabulary (RFC 0005): the names a
// pipeline, a webhook subscription or a notification rule may ask for.
//
// It is a leaf on purpose. The vocabulary is a PUBLISHED INTERFACE — a
// pipeline definition names an event in its `on:` clause, and that definition
// is a versioned artifact in someone else's store — so the names must be
// nameable from anywhere without dragging a transport in. This package
// imports nothing but the standard library, and nothing here knows that
// events travel over NATS.
package evt
import (
"fmt"
"strings"
)
// Subject is where this event publishes: quarry.evt.<repo>.<noun>.<action>.
//
// The subject IS the subscription interface (RFC 0005 §2.3.1). dagnats
// triggers take a NATS subject filter, so a wildcard does the selecting and
// quarry adds no syntax of its own: `zit.evt.*.landing.opened` is every
// landing opened anywhere, `zit.evt.quarry.>` is one repository entire.
//
// Which is the second reason noun-and-action was right, and one the RFC's
// motivation did not know: the vocabulary is a subject HIERARCHY. Gitea's
// issue_assign naming would flatten it into a string a wildcard cannot take
// apart. It also makes a rule of what was an accident — a noun and an action
// must be single tokens, because a dot is the subject separator.
//
// The root is `quarry.`, not `zit.`, for two reasons. The blunt one: the
// ingest pipeline's fold consumer already subscribes to `zit.evt.<repo>.>`,
// so an event published under that root would be handed to the folder and
// parsed as an artifact. The better one: `zit.` is zit's DAG transport, and
// half this vocabulary — ci, mirror, account, clone — names things zit has
// never heard of. These are the FORGE's events.
func (k Kind) Subject(repo string) string {
return "quarry.evt." + repo + "." + k.Noun() + "." + k.Action()
}
// Kind is one event: a noun and an action, `ticket.closed`.
//
// Noun-and-action rather than one name per pair (RFC 0005 §Motivation).
// Gitea mints a permanent name for every verb — issue_assign, issue_label,
// issue_milestone — so a subscriber wanting "any issue activity" must list
// them all and remember the next one. Here a subscription can name the noun
// and get every action on it, and adding an action later costs no name.
type Kind string
// The vocabulary. An action marked "reserved" below is not raised by any code
// path yet and exists so that raising it later is not a vocabulary change —
// which for a published interface is the expensive kind.
const (
CheckinCreated Kind = "checkin.created"
BranchCreated Kind = "branch.created"
BranchMoved Kind = "branch.moved"
BranchClosed Kind = "branch.closed" // reserved
TagCreated Kind = "tag.created"
TagRemoved Kind = "tag.removed" // reserved
TicketOpened Kind = "ticket.opened"
TicketChanged Kind = "ticket.changed"
TicketClosed Kind = "ticket.closed"
TicketReopened Kind = "ticket.reopened"
WikiCreated Kind = "wiki.created"
WikiEdited Kind = "wiki.edited"
WikiDeleted Kind = "wiki.deleted"
TechnoteCreated Kind = "technote.created"
TechnoteEdited Kind = "technote.edited"
ForumPosted Kind = "forum.posted"
ForumReplied Kind = "forum.replied"
ForumEdited Kind = "forum.edited" // reserved
ForumModerated Kind = "forum.moderated"
LandingOpened Kind = "landing.opened"
LandingCommented Kind = "landing.commented"
LandingAccepted Kind = "landing.accepted"
LandingRejected Kind = "landing.rejected"
CIStarted Kind = "ci.started"
CICompleted Kind = "ci.completed"
MirrorPulled Kind = "mirror.pulled"
MirrorFailed Kind = "mirror.failed"
// ErrorRaised is a distinct failure SIGNATURE seen for the first
// time — not every occurrence. A hot loop is one event with a
// count on the record, never a flood down a webhook; the journal
// already aggregates, and this fires off that aggregation.
ErrorRaised Kind = "error.raised"
RepoCreated Kind = "repo.created"
RepoRemoved Kind = "repo.removed"
RepoSettings Kind = "repo.settings"
AccountAdded Kind = "account.added"
AccountCaps Kind = "account.caps"
AccountRemoved Kind = "account.removed"
StampAdded Kind = "stamp.added"
StampRemoved Kind = "stamp.removed"
CloneCloned Kind = "clone.cloned"
CloneFetched Kind = "clone.fetched"
)
// Derived reports whether this event is a reading of artifacts the store
// holds, and so can be re-derived by walking the index after a missed
// delivery (RFC 0005 §2.1).
//
// The distinction is load-bearing rather than tidy. The bus is a ONE-HOUR
// window: a consumer outside it does not replay, it rebuilds. A derived event
// missed costs latency; an operational one missed is a fact that is simply
// gone — which is the difference between a mirror.failed an operator can page
// on and one that silently did not happen. Operational events are therefore
// recorded in OLTP as they are raised, and the bus carries notice of a row
// that already exists.
func (k Kind) Derived() bool {
switch k.Noun() {
case "checkin", "branch", "tag", "ticket", "wiki", "technote", "forum", "landing":
return true
}
return false
}
// Noun is the thing the event is about.
func (k Kind) Noun() string {
noun, _, _ := strings.Cut(string(k), ".")
return noun
}
// Action is what happened to it, empty when k names only a noun.
func (k Kind) Action() string {
_, action, _ := strings.Cut(string(k), ".")
return action
}
// Valid reports whether k is in the vocabulary. An unknown name is refused at
// the moment a subscription is written rather than silently never firing,
// because a pipeline that never runs and a pipeline that runs and fails look
// nothing alike to the person waiting for it.
func (k Kind) Valid() bool {
for _, known := range All {
if k == known {
return true
}
}
return false
}
// Matches reports whether a subscription pattern selects this event.
//
// A pattern is either `*` for the whole vocabulary, a full kind
// (`ticket.closed`), or a bare noun (`ticket`), which selects every
// action on it — including actions added after the subscription was
// written. That is the whole reason for the noun-and-action shape: a
// subscriber says what it cares about at the level it cares, and a
// vocabulary that grows does not silently narrow what it already asked
// for.
func (k Kind) Matches(pattern string) bool {
if pattern == "" {
return false
}
if pattern == "*" || pattern == string(k) {
return true
}
return !strings.Contains(pattern, ".") && pattern == k.Noun()
}
// All is the vocabulary, in the order RFC 0005 §1 lists it. Iterated by
// Valid, and by the API doc so the set is discoverable rather than folklore.
// MatchesAny reports whether any pattern in the list selects this event.
// An empty list selects nothing — a subscription that asked for no
// events gets none, rather than silently getting all of them.
func (k Kind) MatchesAny(patterns []string) bool {
for _, p := range patterns {
if k.Matches(p) {
return true
}
}
return false
}
var All = []Kind{
CheckinCreated,
BranchCreated, BranchMoved, BranchClosed,
TagCreated, TagRemoved,
TicketOpened, TicketChanged, TicketClosed, TicketReopened,
WikiCreated, WikiEdited, WikiDeleted,
TechnoteCreated, TechnoteEdited,
ForumPosted, ForumReplied, ForumEdited, ForumModerated,
LandingOpened, LandingCommented, LandingAccepted, LandingRejected,
CIStarted, CICompleted,
MirrorPulled, MirrorFailed,
RepoCreated, RepoRemoved, RepoSettings,
AccountAdded, AccountCaps, AccountRemoved,
StampAdded, StampRemoved,
CloneCloned, CloneFetched,
ErrorRaised,
}
// Nouns lists each noun once, in All's order.
func Nouns() []string {
var out []string
seen := map[string]bool{}
for _, k := range All {
if n := k.Noun(); !seen[n] {
seen[n] = true
out = append(out, n)
}
}
return out
}
// CheckPatterns refuses a selection no event in the vocabulary can
// match, at the moment it is set. A subscription that silently delivers
// nothing reads as a broken forge and its owner has no way to tell the
// difference — the webhook surface, the CLI and the activity robot all
// take the same syntax, so they ask the same question here rather than
// each keeping their own copy of the answer.
func CheckPatterns(patterns string) error {
for _, p := range strings.Fields(patterns) {
if p == "*" {
continue
}
matched := false
for _, k := range All {
if k.Matches(p) {
matched = true
break
}
}
if !matched {
return fmt.Errorf("no event matches %q — use *, a noun (%s), or an exact kind like ticket.closed",
p, strings.Join(Nouns(), ", "))
}
}
return nil
}