package ingest
import (
"strings"
"craftdesign.group/quarry/internal/artifact"
"craftdesign.group/quarry/internal/evt"
"craftdesign.group/quarry/internal/index"
)
// raised is one vocabulary event a folded artifact produced.
type raised struct {
Kind evt.Kind
Payload map[string]any
}
// symPrefix marks a branch name among the tags. Fossil keeps the T
// card's operator on the name — `*` propagates, `+` applies to one
// artifact, `-` cancels — so the operator is read off the front rather
// than tracked separately.
const symPrefix = "sym-"
// derive names the vocabulary events one folded artifact produces
// (RFC 0005 §2.1: these are the DERIVED events, the ones zit's DAG
// creates rather than a forge op). It reads only the artifact, so it
// cannot lie about state it did not look at — anything needing index
// state is deliberately absent, see branchKind below.
func derive(hash string, a *artifact.Artifact, prior index.Prior) []raised {
if a == nil {
return nil
}
base := func() map[string]any {
return map[string]any{"hash": hash, "actor": a.User, "date": a.Date}
}
var out []raised
switch a.Type {
case artifact.Manifest:
p := base()
p["summary"] = firstLine(a.Comment)
if b := branches(a.Tags); len(b) > 0 {
p["branch"] = b[0]
}
out = append(out, raised{evt.CheckinCreated, p})
case artifact.Control:
// A control artifact carries nothing but tags; the check-in it
// targets is the subject of every event it produces.
case artifact.Wiki:
// An empty W card is fossil's deletion of a page.
p := base()
p["page"] = a.WikiTitle
switch {
case len(a.Body) == 0:
return []raised{{evt.WikiDeleted, p}}
case prior.WikiKnown[a.WikiTitle]:
return []raised{{evt.WikiEdited, p}}
default:
return []raised{{evt.WikiCreated, p}}
}
case artifact.Technote:
// The E-card id is stable across edits, so a second artifact
// under an id the index already holds is an edit of that note.
p := base()
p["note"] = a.EventID
p["summary"] = firstLine(a.Comment)
if prior.NoteKnown[a.EventID] {
return []raised{{evt.TechnoteEdited, p}}
}
return []raised{{evt.TechnoteCreated, p}}
case artifact.Forum:
// The artifact says this one outright: a thread opener has no
// G card, so it roots its own thread.
p := base()
p["summary"] = a.ThreadTitle
body := string(a.Body)
if a.ThreadRoot == "" {
p["thread"] = hash
// A landing IS a forum thread. Calling its opening post
// forum.posted would be true and useless — nothing wanting
// landings could select them, which is the whole reason the
// subject carries the noun.
if index.IsLandingBody(body) {
return []raised{{evt.LandingOpened, p}}
}
return []raised{{evt.ForumPosted, p}}
}
p["thread"] = a.ThreadRoot
if !prior.LandingThreads[a.ThreadRoot] {
return []raised{{evt.ForumReplied, p}}
}
if index.IsLandingStateBody(body) {
// A verdict. Which verdict is not in the reply — a post can
// claim anything, and the state is what the fold computes
// from the whole thread. Named after the fold instead, in
// raiseLandingVerdicts.
return nil
}
return []raised{{evt.LandingCommented, p}}
case artifact.Attachment:
// The A card names a file on a target, and one with NO source is
// fossil's deletion of that name. Both are news; which one it is
// is in the artifact, so no index state is needed.
at := a.Attachment
if at == nil {
return nil
}
p := base()
p["target"] = at.Target
p["name"] = at.Name
if at.Source == "" {
return []raised{{evt.AttachmentRemoved, p}}
}
p["src"] = at.Source
return []raised{{evt.AttachmentAdded, p}}
case artifact.Ticket:
// Handled per ticket rather than per artifact: a batch may carry
// several changes to one ticket, and the transition that matters
// is the folded state's, not each change's. See ticketEvents.
return nil
default:
return nil
}
for _, t := range a.Tags {
if len(t.Name) < 2 {
continue // no operator, no name: nothing to say about it
}
op, name := t.Name[:1], t.Name[1:]
if !strings.HasPrefix(name, symPrefix) {
// A plain tag. `-` cancels one, anything else applies it.
k := evt.TagCreated
if op == "-" {
k = evt.TagRemoved
}
p := base()
p["tag"] = name
p["target"] = target(t, hash)
out = append(out, raised{k, p})
continue
}
if op == "-" {
// Cancelling a branch tag is not closing the branch — the
// `closed` tag does that, and it is a plain tag handled
// above. Nothing in the vocabulary names this, so nothing
// is raised rather than something approximate.
continue
}
// A `sym-` tag on a check-in is that branch coming into
// existence. branch.moved for the SAME check-in comes from the
// pipeline once branches are recomputed (see branchKind), so
// raising it here as well would double it.
if a.Type == artifact.Control {
p := base()
p["branch"] = strings.TrimPrefix(name, symPrefix)
p["target"] = target(t, hash)
out = append(out, raised{evt.BranchCreated, p})
}
}
return out
}
// branchKind is deliberately not a function here, because a check-in's
// branch is not in the check-in. Only a branch's FIRST check-in wears a
// `sym-` tag; the hundredth commit to trunk says nothing about trunk, so
// deriving branch.moved from tags alone would fire it on branch creation
// and never again — the opposite of what the name promises. The index
// computes the branch during the fold, and the pipeline raises
// branch.moved from that answer once it exists.
// target is the artifact a tag applies to: a control artifact names it,
// a manifest tags itself.
func target(t artifact.Tag, self string) string {
if t.Target != "" && t.Target != "*" {
return t.Target
}
return self
}
// branches returns the propagating branch names among a check-in's tags.
func branches(tags []artifact.Tag) []string {
var out []string
for _, t := range tags {
if len(t.Name) > 1 && strings.HasPrefix(t.Name[1:], symPrefix) && t.Name[:1] != "-" {
out = append(out, strings.TrimPrefix(t.Name[1:], symPrefix))
}
}
return out
}
// ticketEvents names the transition a batch made to one ticket, from the
// folded status before and after.
//
// This is the op's own logic, moved to where a pushed artifact can reach
// it: the op compared the status it was replacing with the one it was
// writing, and before/after around the fold is the same comparison
// asked of the index. A ticket the index had never seen is opened; the
// rest is whether the closed-ness changed.
func ticketEvents(id, before, after string, seen bool, p map[string]any) []raised {
p["ticket"] = id
p["status"] = after
switch {
case !seen:
return []raised{{evt.TicketOpened, p}}
case !index.IsClosedStatus(before) && index.IsClosedStatus(after):
return []raised{{evt.TicketClosed, p}}
case index.IsClosedStatus(before) && !index.IsClosedStatus(after):
return []raised{{evt.TicketReopened, p}}
default:
return []raised{{evt.TicketChanged, p}}
}
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}