Skip to content
quarry Artifact [1af8d952b6]
Login

Artifact [1af8d952b6]

Artifact 1af8d952b6107ddb49ad639187a67ad8865a5e971a0aaaa9c17bbc6a8ac6b74d:


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
}