Artifact d7f9a8e34dfc7c18b706ae8f45102dd06a3b519c6fcfcbf788b28af5da8f306f:
- File
internal/ingest/derive.go
— part of check-in
[06942dcc5d]
at
2026-08-24 16:57:41
on branch trunk
— Close the push gap: derived events all come from the fold
The index holds the state an op would have read, so a pushed artifact gets the same event a forge op would have raised. Ticket transitions and landing verdicts compare folded state before and after; forum, wiki and technote need only the artifact or one lookup.
Op sites stop raising derived events, so 2.1's split is now enforced rather than documented. (user: dmestas, size: 6683) [annotate] [blame] [check-ins using]
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.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 }