Artifact bbd4a4675c8f5a0a19d9d843ec0a73c4ac4660e478977e4f35222b90e84dfef5:
- File
internal/api/register.go
— part of check-in
[1d680372e5]
at
2026-08-16 13:44:53
on branch trunk
— markup: map fossil site-relative links; theme-neutral pikchr ink
Two bugs found by browsing real content on a phone.
Imported wiki/technote/forum bodies are full of fossil's own routes (libfossil's wiki alone: 500 /finfo, 211 /dir, 105 /timeline, 23 /doc). Quarry passed those hrefs through verbatim, so libfossil's home page offered a README.md link that went nowhere. Resolver grows ResolvePath: /doc/<ver>/<path> maps to quarry's file route, /info to the artifact, /timeline /wiki /forum /technote to their pages; a verb quarry has no page for drops the link and keeps the label, since a dead link is worse than plain text. file.get now lets zit resolve symbolic versions (trunk, tip, tags) instead of demanding a hash — that is what /doc links use, and zit is the authority on names. The href rewrite runs BEFORE reference resolution so the two passes never see each other's output.
Pikchr hardcodes rgb(0,0,0), which is invisible on quarry's dark theme — the DOM assertions all passed while the diagram was unreadable, and only a screenshot caught it. Its default ink now becomes currentColor so a diagram follows the page theme; author-specified colours are untouched. Fossil solves this with a server-side --dark-mode flag, which cannot work for a CSS-themed page. (user: dmestas, size: 43359) [annotate] [blame] [check-ins using]
// Package api owns RegisterAll — the one explicit wiring point where every // op joins the registry (0003 §2: no init() magic; the wiring stays // greppable) — plus the HTTP router that serves the registered ops. This // is the sanctioned place Run closures over the data planes are built; // the arch gate keeps every other consumer on the op side of the seam. package api import ( "bytes" "context" "errors" "fmt" "html" "log" "net/url" "regexp" "sort" "strings" "time" "unicode/utf8" "craftdesign.group/quarry/internal/chat" "craftdesign.group/quarry/internal/highlight" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/markup" "craftdesign.group/quarry/internal/ops" "craftdesign.group/quarry/internal/zitcli" ) // Repo is one registered store's data-plane pair. type Repo struct { Index *index.Index Zit *zitcli.Client // Sync runs one ingest round; write ops call it so a successful // response means the index already reflects the write (read-your- // writes). nil skips (the background poll converges instead). Sync func() (int, error) // Guard runs a write under the store's single-writer lease (0001 // §3): it refuses when another node holds the lease or the // coordinator is unreachable. nil runs the write directly // (single-node default). Guard func(func() error) error // Code is the store's project code — the chat channel key (0001 // §5), stable across nodes and repo renames. Code string } // ChatPlane is the per-repo live channel (0001 §5). A nil Deps.Chat // disables chat (no event plane). type ChatPlane interface { Post(code string, m chat.Message) error History(code string, limit int) ([]chat.Message, error) } // NotifyEvent is one notification trigger quarry raises for the notify // companion to route (0001 §5): quarry names the workflow and the // recipient and hands over a payload; notify owns delivery. type NotifyEvent struct { Workflow string // notify workflow name (quarry-mention, quarry-activity) To string // subscriber id — a user, or "repo:<name>" topic Payload map[string]any // repo, actor, summary, link TxnID string // idempotency key (dedups replays) } // NotifyPlane fires triggers best-effort. A nil Deps.Notify disables // notifications. type NotifyPlane interface { Trigger(ctx context.Context, e NotifyEvent) error } // mentionRE matches an @handle mention — the token notify routes to a // subscriber. The @ must start a word (not follow a name char), so an // email address's @ is not a mention. var mentionRE = regexp.MustCompile(`(^|[^a-zA-Z0-9._-])@([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})`) // mentions returns the distinct @handles named in text, without the @. func mentions(text string) []string { seen := map[string]bool{} var out []string for _, m := range mentionRE.FindAllStringSubmatch(text, -1) { if !seen[m[2]] { seen[m[2]] = true out = append(out, m[2]) } } return out } // fireNotify raises an event best-effort: never blocks the op, never // fails it. A disabled plane (nil) is a no-op. func (d Deps) fireNotify(e NotifyEvent) { if d.Notify == nil { return } go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := d.Notify.Trigger(ctx, e); err != nil { log.Printf("notify: %s: %v", e.Workflow, err) } }() } // synced runs the repo's ingest round after a successful write. func (r *Repo) synced() error { if r.Sync == nil { return nil } _, err := r.Sync() return err } // write runs fn under the repo's write lease (or directly if unleased). func (r *Repo) write(fn func() error) error { if r.Guard == nil { return fn() } return r.Guard(fn) } // hasControl reports whether s carries an ASCII control byte (below // 0x20, or DEL) — such bytes have no place in a wiki name or ticket id // and would otherwise reach the zit subprocess and fault. func hasControl(s string) bool { for i := 0; i < len(s); i++ { if s[i] < 0x20 || s[i] == 0x7f { return true } } return false } // author resolves the recorded author: the authenticated user, or // "anonymous" on an ungated repo. func author(ctx context.Context) string { if u := ops.User(ctx); u != "" { return u } return "anonymous" } // Deps carries what op implementations close over. Repos may be empty // (`quarry spec` runs storeless); ops answer unknown repos with // not-found, and the spec stays complete either way. type Deps struct { Version string Repos map[string]*Repo Chat ChatPlane Notify NotifyPlane } // repo resolves one registered store; unknown names are not-found. func (d Deps) repo(name string) (*Repo, error) { if r, ok := d.Repos[name]; ok { return r, nil } return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound) } type versionIn struct{} type versionOut struct { Version string `json:"version"` } type repoListIn struct{} type repoInfo struct { Name string `json:"name"` Checkins int `json:"checkins"` TipDate string `json:"tipDate"` TipComment string `json:"tipComment"` } type wikiSaveIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` Body string `json:"body" validate:"required,max=1048576"` Mimetype string `json:"mimetype" validate:"max=64"` } type wikiSaveOut struct { Hash string `json:"hash"` } type ticketNewIn struct { Repo string `json:"repo" validate:"required,max=64"` Title string `json:"title" validate:"required,max=200"` Status string `json:"status" validate:"max=40"` Comment string `json:"comment" validate:"max=65536"` } type ticketNewOut struct { ID string `json:"id"` Hash string `json:"hash"` } type ticketSetIn struct { Repo string `json:"repo" validate:"required,max=64"` ID string `json:"id" validate:"required,min=4,max=40"` Title string `json:"title" validate:"max=200"` Status string `json:"status" validate:"max=40"` Resolution string `json:"resolution" validate:"max=40"` Comment string `json:"comment" validate:"max=65536"` } type ticketSetOut struct { Hash string `json:"hash"` } type docsIn struct{} type docsOut struct { Markdown string `json:"markdown"` HTML string `json:"html"` } type repoListOut struct { Repos []repoInfo `json:"repos"` Count int `json:"count"` } type timelineIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` Before string `json:"before" validate:"max=96"` Branch string `json:"branch" validate:"max=64"` Y string `json:"y" validate:"oneof=all|ci|w|t|e|f"` } type timelineOut struct { Entries []index.TimelineEntry `json:"entries"` Count int `json:"count"` // Next is the `before` cursor for the following (older) page; // empty when this page is the last. Next string `json:"next"` } type wikiListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type wikiListOut struct { Pages []index.WikiTitle `json:"pages"` Count int `json:"count"` } type wikiGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` Rev string `json:"rev" validate:"max=64"` // artifact hash prefix; empty = latest } type wikiGetOut struct { Hash string `json:"hash"` Title string `json:"title"` Date string `json:"date"` User string `json:"user"` Mimetype string `json:"mimetype"` Source string `json:"source"` HTML string `json:"html"` } type wikiHistoryIn struct { Repo string `json:"repo" validate:"required,max=64"` Name string `json:"name" validate:"required,max=100"` } type technoteListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type technoteListOut struct { Notes []index.Technote `json:"notes"` Count int `json:"count"` } type technoteGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Event string `json:"event" validate:"required,max=64"` } type technoteGetOut struct { Event string `json:"event"` Date string `json:"date"` User string `json:"user"` Body string `json:"body"` // raw source, for the edit form HTML string `json:"html"` } type forumListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type forumListOut struct { Threads []index.ForumThread `json:"threads"` Count int `json:"count"` } type forumGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Root string `json:"root" validate:"required,min=4,max=64"` } type forumRendered struct { Hash string `json:"hash"` Parent string `json:"parent"` Date string `json:"date"` User string `json:"user"` HTML string `json:"html"` } type forumGetOut struct { Root string `json:"root"` Title string `json:"title"` Posts []forumRendered `json:"posts"` } type forumPostIn struct { Repo string `json:"repo" validate:"required,max=64"` Title string `json:"title" validate:"required,max=200"` Body string `json:"body" validate:"required,max=1048576"` } type forumPostOut struct { Hash string `json:"hash"` } type forumReplyIn struct { Repo string `json:"repo" validate:"required,max=64"` // Root names the thread being answered — the same path parameter // forum.get takes, so the two share one consistent template. Root string `json:"root" validate:"required,min=4,max=64"` Body string `json:"body" validate:"required,max=1048576"` } type forumReplyOut struct { Hash string `json:"hash"` } type technoteAddIn struct { Repo string `json:"repo" validate:"required,max=64"` Body string `json:"body" validate:"required,max=1048576"` Mimetype string `json:"mimetype" validate:"max=64"` // Event reuses an existing technote's id to record an edit; empty // mints a new technote. Event string `json:"event" validate:"max=64"` } type technoteAddOut struct { Event string `json:"event"` Hash string `json:"hash"` } type chatHistoryIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=500,default=100"` } type chatHistoryOut struct { Messages []chat.Message `json:"messages"` Count int `json:"count"` } type chatPostIn struct { Repo string `json:"repo" validate:"required,max=64"` Text string `json:"text" validate:"required,max=4096"` } type chatPostOut struct { OK bool `json:"ok"` } type wikiHistoryOut struct { Title string `json:"title"` Edits []index.WikiRev `json:"edits"` Count int `json:"count"` } type fileGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` Path string `json:"path" validate:"required,max=512"` } type fileGetOut struct { Hash string `json:"hash"` Path string `json:"path"` Size int `json:"size"` Binary bool `json:"binary"` Content string `json:"content"` HTML string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched } type searchIn struct { Repo string `json:"repo" validate:"required,max=64"` Q string `json:"q" validate:"required,max=200"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` } type searchOut struct { Results []index.SearchResult `json:"results"` Count int `json:"count"` } type diffGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` } type diffGetOut struct { From string `json:"from"` To string `json:"to"` Files []zitcli.DiffEntry `json:"files"` Unified string `json:"unified"` Note string `json:"note"` // set when the diff could not be fully computed } type checkinGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` } type checkinGetOut struct { Hash string `json:"hash"` Branch string `json:"branch"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` Parents []string `json:"parents"` Files []zitcli.LsEntry `json:"files"` } type ticketListIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` Before string `json:"before" validate:"max=96"` } // ticketSummary is the list row: id plus the two fields every tracker // listing needs, pulled out of the folded state. type ticketSummary struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Updated string `json:"updated"` } type ticketListOut struct { Tickets []ticketSummary `json:"tickets"` Count int `json:"count"` // Next is the `before` cursor for the following (older-updated) // page; empty when this page is the last. Next string `json:"next"` } type ticketGetIn struct { Repo string `json:"repo" validate:"required,max=64"` ID string `json:"id" validate:"required,min=4,max=40"` } type ticketGetOut struct { Ticket index.TicketState `json:"ticket"` } func fieldValue(fields []index.TicketFieldSet, name string) string { for _, f := range fields { if f.Name == name { return f.Value } } return "" } // resolver implements markup.Resolver over one repo's index. Wiki names // resolve to existing pages; hash prefixes resolve to uniquely matching // check-ins or tickets. An unresolvable or ambiguous reference renders // as plain text, never as a dead link. type resolver struct { ix *index.Index repo string } func (r resolver) ResolveHash(prefix string) string { if r.ix == nil { return "" } if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok { return "/r/" + r.repo + "/checkin/" + e.Hash } if t, ok, err := r.ix.TicketByPrefix(prefix); err == nil && ok { return "/r/" + r.repo + "/ticket/" + t.ID } return "" } func (r resolver) ResolveWiki(name string) string { if r.ix == nil { return "" } if _, ok, err := r.ix.WikiPage(name); err == nil && ok { return "/r/" + r.repo + "/wiki/" + url.PathEscape(name) } return "" } // fossilRoute splits a Fossil site-relative URL into its verb and the // rest ("/doc/trunk/README.md" -> "doc", "trunk/README.md"). var fossilRoute = regexp.MustCompile(`^/([a-z]+)(?:[/?](.*))?$`) // ResolvePath maps Fossil's own site-relative URLs — which imported // wiki, technote and forum content is full of — onto quarry routes. // Fossil verbs quarry has no page for return "" so the caller drops the // link instead of leaving a 404 behind (libfossil's wiki alone carries // 500 /finfo, 211 /dir and 105 /timeline references). func (r resolver) ResolvePath(p string) string { m := fossilRoute.FindStringSubmatch(p) if m == nil { return "" } verb, rest := m[1], m[2] base := "/r/" + r.repo switch verb { case "doc": // /doc/<version>/<path> — embedded documentation at a check-in. // quarry serves the same bytes from its file route, and zit // resolves the symbolic version. if ver, path, ok := strings.Cut(rest, "/"); ok && path != "" { return base + "/file/" + ver + "/" + path } case "info", "artifact", "whatis": // /info/<hash> resolves to whatever the artifact is. if h, _, _ := strings.Cut(rest, "?"); h != "" { if u := r.ResolveHash(h); u != "" { return u } } case "timeline": return base + "/timeline" case "wiki": // /wiki?name=Page if v, err := url.ParseQuery(rest); err == nil { if n := v.Get("name"); n != "" { if u := r.ResolveWiki(n); u != "" { return u } } } case "forum", "forumpost", "forumthread": return base + "/forum" case "technote", "event": return base + "/technote" } // finfo, dir, uv, leaves, brlist, … — real Fossil pages quarry does // not have yet. Dropping the link beats sending a reader to a 404. return "" } // RegisterAll wires every op. func RegisterAll(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[versionIn, versionOut]{ Name: "meta.version", Doc: "Report the quarry build version.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/version", Run: func(_ context.Context, _ versionIn) (versionOut, error) { return versionOut{Version: d.Version}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiSaveIn, wikiSaveOut]{ Name: "wiki.save", Doc: "Record a wiki page edit (creates the page on first save).", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/wiki/{name}", Run: func(ctx context.Context, in wikiSaveIn) (wikiSaveOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiSaveOut{}, err } if hasControl(in.Name) { return wikiSaveOut{}, fmt.Errorf("wiki name has control characters: %w", ops.ErrInvalid) } mt := in.Mimetype if mt == "" { mt = "text/x-markdown" } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.WikiCommit(in.Name, author(ctx), mt, []byte(in.Body)) return err }); err != nil { return wikiSaveOut{}, err } if err := repo.synced(); err != nil { return wikiSaveOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "edited wiki page " + in.Name, "link": "/r/" + in.Repo + "/wiki/" + in.Name}, TxnID: "activity:wiki:" + hash, }) return wikiSaveOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketNewIn, ticketNewOut]{ Name: "ticket.new", Doc: "Open a new ticket.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/ticket", Run: func(ctx context.Context, in ticketNewIn) (ticketNewOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketNewOut{}, err } status := in.Status if status == "" { status = "Open" } fields := map[string]string{"title": in.Title, "status": status} if in.Comment != "" { fields["comment"] = in.Comment } var id, hash string if err := repo.write(func() (err error) { id, hash, err = repo.Zit.TicketNew(author(ctx), fields) return err }); err != nil { return ticketNewOut{}, err } if err := repo.synced(); err != nil { return ticketNewOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "opened ticket " + in.Title, "link": "/r/" + in.Repo + "/ticket/" + id}, TxnID: "activity:ticket-new:" + hash, }) return ticketNewOut{ID: id, Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketSetIn, ticketSetOut]{ Name: "ticket.set", Doc: "Record a ticket change; empty fields are left untouched.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/ticket/{id}", Run: func(ctx context.Context, in ticketSetIn) (ticketSetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketSetOut{}, err } // The route accepts the id prefixes pages link with; the zit // verb wants the full stored id. t, ok, err := repo.Index.TicketByPrefix(in.ID) if err != nil { return ticketSetOut{}, err } if !ok { return ticketSetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound) } fields := map[string]string{} for k, v := range map[string]string{ "title": in.Title, "status": in.Status, "resolution": in.Resolution, "comment": in.Comment, } { if v != "" { fields[k] = v } } if len(fields) == 0 { return ticketSetOut{}, fmt.Errorf("no fields to set: %w", ops.ErrInvalid) } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.TicketSet(t.ID, author(ctx), fields) return err }); err != nil { return ticketSetOut{}, err } if err := repo.synced(); err != nil { return ticketSetOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "updated ticket " + t.ID[:min(10, len(t.ID))], "link": "/r/" + in.Repo + "/ticket/" + t.ID[:min(10, len(t.ID))]}, TxnID: "activity:ticket-set:" + hash, }) return ticketSetOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[docsIn, docsOut]{ Name: "meta.docs", Doc: "The API reference, generated from this registry and rendered.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/docs", Run: func(_ context.Context, _ docsIn) (docsOut, error) { // r is complete by the time any op runs; the reference // documents the same registry that serves it. md := ops.Docs(r, "quarry") rendered, err := markup.Render(md, resolver{}) if err != nil { return docsOut{}, err } return docsOut{Markdown: string(md), HTML: string(rendered)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[repoListIn, repoListOut]{ Name: "repo.list", Doc: "List the served repositories with their tip check-in.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/repos", Run: func(_ context.Context, _ repoListIn) (repoListOut, error) { names := make([]string, 0, len(d.Repos)) for name := range d.Repos { names = append(names, name) } sort.Strings(names) out := repoListOut{Count: len(names)} for _, name := range names { info := repoInfo{Name: name} if ix := d.Repos[name].Index; ix != nil { info.Checkins, _ = ix.CheckinCount() if tip, _, err := ix.Timeline(1, "", "", "ci"); err == nil && len(tip) == 1 { info.TipDate, info.TipComment = tip[0].Date, tip[0].Comment } } out.Repos = append(out.Repos, info) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{ Name: "timeline.list", Doc: "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/timeline", Run: func(_ context.Context, in timelineIn) (timelineOut, error) { repo, err := d.repo(in.Repo) if err != nil { return timelineOut{}, err } entries, more, err := repo.Index.Timeline(in.Limit, in.Before, in.Branch, in.Y) if err != nil { return timelineOut{}, err } out := timelineOut{Entries: entries, Count: len(entries)} if more && len(entries) > 0 { last := entries[len(entries)-1] out.Next = last.Date + "~" + last.Hash } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{ Name: "wiki.list", Doc: "List a repository's wiki pages with their latest edit.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki", Run: func(_ context.Context, in wikiListIn) (wikiListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiListOut{}, err } pages, err := repo.Index.WikiTitles() if err != nil { return wikiListOut{}, err } return wikiListOut{Pages: pages, Count: len(pages)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{ Name: "ticket.list", Doc: "List a repository's tickets with their folded current state.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ticket", Run: func(_ context.Context, in ticketListIn) (ticketListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketListOut{}, err } all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before) if err != nil { return ticketListOut{}, err } out := ticketListOut{Count: len(all)} for _, t := range all { out.Tickets = append(out.Tickets, ticketSummary{ ID: t.ID, Title: t.Title, Status: t.Status, Updated: t.Updated, }) } if more && len(all) > 0 { last := all[len(all)-1] out.Next = last.Updated + "~" + last.ID } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{ Name: "ticket.get", Doc: "Fetch one ticket: folded state plus change history.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ticket/{id}", Run: func(_ context.Context, in ticketGetIn) (ticketGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return ticketGetOut{}, err } t, ok, err := repo.Index.TicketByPrefix(in.ID) if err != nil { return ticketGetOut{}, err } if !ok { return ticketGetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound) } return ticketGetOut{Ticket: t}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[searchIn, searchOut]{ Name: "search.query", Doc: "Full-text search over a repository's check-ins, wiki, and tickets.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/search", Run: func(_ context.Context, in searchIn) (searchOut, error) { repo, err := d.repo(in.Repo) if err != nil { return searchOut{}, err } results, err := repo.Index.Search(in.Q, in.Limit) if err != nil { return searchOut{}, err } return searchOut{Results: results, Count: len(results)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{ Name: "checkin.diff", Doc: "Diff one check-in against its primary parent.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/checkin/{hash}/diff", Run: func(_ context.Context, in diffGetIn) (diffGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return diffGetOut{}, err } e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash) if err != nil { return diffGetOut{}, err } if !ok { return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } out := diffGetOut{To: e.Hash} if len(parents) == 0 { // A root check-in has nothing to diff against; its // whole file set is the change. files, err := repo.Zit.Ls(e.Hash) if err != nil { return diffGetOut{}, err } for _, f := range files { out.Files = append(out.Files, zitcli.DiffEntry{Status: "A", Name: f.Name}) } return out, nil } out.From = parents[0] unified, brief, err := repo.Zit.Diff(out.From, e.Hash) if err != nil { return diffGetOut{}, err } out.Unified, out.Files = unified, brief // zit exits 0 with empty output when changed content is // unavailable (shunned) — flag it rather than serving what // looks like a legitimate no-op diff. if out.Unified == "" && len(out.Files) > 0 { out.Note = "diff content unavailable — the changed file content may have been shunned from the store" } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{ Name: "file.get", Doc: "Fetch one file's content at a check-in.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/file/{hash}/{path...}", Run: func(_ context.Context, in fileGetIn) (fileGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return fileGetOut{}, err } // A hash prefix resolves through the index; anything else — // "trunk", "tip", a tag — is a name only zit resolves, and // zit is the authority on names (0001 §2). Fossil's /doc // links use exactly those symbolic versions. version := in.Hash if e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash); err != nil { return fileGetOut{}, err } else if ok { version = e.Hash } raw, err := repo.Zit.Cat(in.Path, version) if errors.Is(err, zitcli.ErrNoEntry) { // Clean public message; the zit detail stays behind. return fileGetOut{}, fmt.Errorf("no file %q at this check-in: %w", in.Path, ops.ErrNotFound) } if err != nil { return fileGetOut{}, err } out := fileGetOut{Hash: version, Path: in.Path, Size: len(raw)} // Binary content stays out of the JSON surface; the page // says "binary" and the artifact read verb serves bytes. if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) { out.Content = string(raw) // Best-effort highlighting; sanitized like all rendered // HTML even though quarry generated it. if hl, ok := highlight.Code(raw, "", in.Path); ok { out.HTML = markup.Sanitize(hl) } } else { out.Binary = true } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{ Name: "checkin.get", Doc: "Describe one check-in: metadata, parents, and its file set.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/checkin/{hash}", Run: func(_ context.Context, in checkinGetIn) (checkinGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return checkinGetOut{}, err } e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash) if err != nil { return checkinGetOut{}, err } if !ok { return checkinGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound) } out := checkinGetOut{ Hash: e.Hash, Branch: e.Branch, Date: e.Date, User: e.User, Comment: e.Comment, Parents: parents, } // File set is an on-demand zit read (0001 §2), not index // state — the index answers "which check-in", the store // answers "what does it hold". if repo.Zit != nil { files, err := repo.Zit.Ls(e.Hash) if err != nil { return checkinGetOut{}, err } out.Files = files } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiHistoryIn, wikiHistoryOut]{ Name: "wiki.history", Doc: "List every recorded edit of one wiki page, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki/{name}/history", Run: func(_ context.Context, in wikiHistoryIn) (wikiHistoryOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiHistoryOut{}, err } edits, err := repo.Index.WikiHistory(in.Name) if err != nil { return wikiHistoryOut{}, err } if len(edits) == 0 { return wikiHistoryOut{}, fmt.Errorf("no wiki page %q: %w", in.Name, ops.ErrNotFound) } return wikiHistoryOut{Title: in.Name, Edits: edits, Count: len(edits)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{ Name: "wiki.get", Doc: "Fetch one wiki page, rendered — latest, or the revision named by rev.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/wiki/{name}", Run: func(_ context.Context, in wikiGetIn) (wikiGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return wikiGetOut{}, err } page, ok, err := repo.Index.WikiRevision(in.Name, in.Rev) if err != nil { return wikiGetOut{}, err } if !ok { return wikiGetOut{}, fmt.Errorf("no wiki page %q rev %q: %w", in.Name, in.Rev, ops.ErrNotFound) } html, err := renderBody(repo.Index, in.Repo, page.Mimetype, page.Body) if err != nil { return wikiGetOut{}, err } return wikiGetOut{ Hash: page.Hash, Title: page.Title, Date: page.Date, User: page.User, Mimetype: page.Mimetype, Source: string(page.Body), HTML: html, }, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[technoteListIn, technoteListOut]{ Name: "technote.list", Doc: "List a repository's technotes at their latest edit, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/technote", Run: func(_ context.Context, in technoteListIn) (technoteListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return technoteListOut{}, err } notes, err := repo.Index.Technotes() if err != nil { return technoteListOut{}, err } return technoteListOut{Notes: notes, Count: len(notes)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[technoteGetIn, technoteGetOut]{ Name: "technote.get", Doc: "Fetch one technote, rendered, by event-id prefix.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/technote/{event}", Run: func(_ context.Context, in technoteGetIn) (technoteGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return technoteGetOut{}, err } n, ok, err := repo.Index.TechnoteByEvent(in.Event) if err != nil { return technoteGetOut{}, err } if !ok { return technoteGetOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound) } html, err := renderBody(repo.Index, in.Repo, n.Mimetype, []byte(n.Body)) if err != nil { return technoteGetOut{}, err } return technoteGetOut{Event: n.Event, Date: n.Date, User: n.User, Body: n.Body, HTML: html}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[forumListIn, forumListOut]{ Name: "forum.list", Doc: "List a repository's forum threads, most-recently-active first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/forum", Run: func(_ context.Context, in forumListIn) (forumListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumListOut{}, err } threads, err := repo.Index.ForumThreads() if err != nil { return forumListOut{}, err } return forumListOut{Threads: threads, Count: len(threads)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[forumGetIn, forumGetOut]{ Name: "forum.get", Doc: "Fetch one forum thread: every post, rendered, in reading order.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/forum/{root}", Run: func(_ context.Context, in forumGetIn) (forumGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumGetOut{}, err } title, posts, ok, err := repo.Index.ForumThreadPosts(in.Root) if err != nil { return forumGetOut{}, err } if !ok { return forumGetOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound) } out := forumGetOut{Root: in.Root, Title: title} for _, p := range posts { // Dispatch on the post's own N card (absent = Fossil wiki, // matching fossil's forum_render). html, err := renderBody(repo.Index, in.Repo, p.Mimetype, []byte(p.Body)) if err != nil { return forumGetOut{}, err } out.Posts = append(out.Posts, forumRendered{ Hash: p.Hash, Parent: p.Parent, Date: p.Date, User: p.User, HTML: html, }) } return out, nil }, }); err != nil { return err } if err := registerAuthoring(r, d); err != nil { return err } if d.Chat != nil { return registerChat(r, d) } return nil } // registerAuthoring wires the forum and technote write ops (v2 write // plane, on zit#76's verbs). Fossil has the same surfaces — technoteedit // and forumedit — so these are the web faces of record types the format // already carries, not new inventions. func registerAuthoring(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[forumPostIn, forumPostOut]{ Name: "forum.post", Doc: "Open a new forum thread.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/forum", Run: func(ctx context.Context, in forumPostIn) (forumPostOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumPostOut{}, err } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.ForumPost(in.Title, author(ctx), []byte(in.Body)) return err }); err != nil { return forumPostOut{}, err } if err := repo.synced(); err != nil { return forumPostOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "opened forum thread " + in.Title, "link": "/r/" + in.Repo + "/forum/" + hash[:min(16, len(hash))]}, TxnID: "activity:forum-post:" + hash, }) return forumPostOut{Hash: hash}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[forumReplyIn, forumReplyOut]{ Name: "forum.reply", Doc: "Reply to a forum post within its thread.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/forum/{root}", Run: func(ctx context.Context, in forumReplyIn) (forumReplyOut, error) { repo, err := d.repo(in.Repo) if err != nil { return forumReplyOut{}, err } // The route carries the prefix pages link with; zit wants a // post hash it can resolve, so widen it through the index. post := in.Root if _, posts, ok, perr := repo.Index.ForumThreadPosts(in.Root); perr == nil && ok && len(posts) > 0 { post = posts[0].Hash // replying to a thread answers its root } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.ForumReply(post, author(ctx), []byte(in.Body)) return err }); err != nil { if errors.Is(err, zitcli.ErrNoEntry) { return forumReplyOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound) } return forumReplyOut{}, err } if err := repo.synced(); err != nil { return forumReplyOut{}, err } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": "replied in a forum thread", "link": "/r/" + in.Repo + "/forum/" + in.Root}, TxnID: "activity:forum-reply:" + hash, }) return forumReplyOut{Hash: hash}, nil }, }); err != nil { return err } return ops.Register(r, ops.Op[technoteAddIn, technoteAddOut]{ Name: "technote.add", Doc: "Record a technote; passing an existing event id records an edit.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/technote", Run: func(ctx context.Context, in technoteAddIn) (technoteAddOut, error) { repo, err := d.repo(in.Repo) if err != nil { return technoteAddOut{}, err } mt := in.Mimetype if mt == "" { mt = "text/x-markdown" } // An edit names an existing technote. Pages link event-id // prefixes, so widen to the full id — passing a prefix would // mint a NEW technote under that truncated id instead. event := in.Event if event != "" { n, ok, err := repo.Index.TechnoteByEvent(event) if err != nil { return technoteAddOut{}, err } if !ok { return technoteAddOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound) } event = n.Event } var id, hash string if err := repo.write(func() (err error) { id, hash, err = repo.Zit.TechnoteAdd(author(ctx), event, mt, []byte(in.Body)) return err }); err != nil { return technoteAddOut{}, err } if err := repo.synced(); err != nil { return technoteAddOut{}, err } what := "added technote" if in.Event != "" { what = "edited technote" } d.fireNotify(NotifyEvent{ Workflow: "quarry-activity", To: "repo:" + in.Repo, Payload: map[string]any{"repo": in.Repo, "actor": author(ctx), "summary": what, "link": "/r/" + in.Repo + "/technote/" + id[:min(16, len(id))]}, TxnID: "activity:technote:" + hash, }) return technoteAddOut{Event: id, Hash: hash}, nil }, }) } // registerChat wires the per-repo live channel ops (0001 §5). Called // from RegisterAll only when a chat plane is configured. func registerChat(r *ops.Registry, d Deps) error { if err := ops.Register(r, ops.Op[chatHistoryIn, chatHistoryOut]{ Name: "chat.history", Doc: "Recent messages in a repository's chat channel, oldest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/chat", Run: func(_ context.Context, in chatHistoryIn) (chatHistoryOut, error) { repo, err := d.repo(in.Repo) if err != nil { return chatHistoryOut{}, err } limit := in.Limit if limit <= 0 { limit = 100 } msgs, err := d.Chat.History(repo.Code, limit) if err != nil { return chatHistoryOut{}, err } return chatHistoryOut{Messages: msgs, Count: len(msgs)}, nil }, }); err != nil { return err } return ops.Register(r, ops.Op[chatPostIn, chatPostOut]{ Name: "chat.post", Doc: "Post a message to a repository's chat channel.", Kind: ops.Command, // A chat post mutates no store (0001 §5: transport is NATS, not // the DAG), so it is not lease-gated and stays anonymous-postable // in v1; the author is the authenticated user when present. Auth: ops.Anonymous, Route: "POST /api/v1/r/{repo}/chat", Run: func(ctx context.Context, in chatPostIn) (chatPostOut, error) { repo, err := d.repo(in.Repo) if err != nil { return chatPostOut{}, err } from := author(ctx) if err := d.Chat.Post(repo.Code, chat.Message{ Repo: in.Repo, User: from, Text: in.Text, }); err != nil { return chatPostOut{}, err } // Mentions route to notify (0001 §5), one trigger per handle. for _, who := range mentions(in.Text) { d.fireNotify(NotifyEvent{ Workflow: "quarry-mention", To: who, Payload: map[string]any{ "repo": in.Repo, "from": from, "text": in.Text, "link": "/r/" + in.Repo + "/chat", }, TxnID: "mention:" + in.Repo + ":" + who + ":" + from + ":" + in.Text, }) } return chatPostOut{OK: true}, nil }, }) } // renderBody renders an artifact body: markdown/Fossil-wiki through the // sanitizer-gated markup pipeline (so [[wiki]] and hash refs resolve), // anything else as escaped text. func renderBody(ix *index.Index, repo, mimetype string, body []byte) (string, error) { // Dispatch exactly as fossil's wiki_render_by_mimetype does: an // ABSENT mimetype means Fossil wiki markup, not markdown. Treating // it as markdown silently mangles half of every fossil-born corpus // (libfossil: 217 of 443 wiki artifacts carry no N card). res := resolver{ix: ix, repo: repo} switch mimetype { case "text/x-markdown", "text/markdown": rendered, err := markup.Render(body, res) if err != nil { return "", err } return string(rendered), nil case "", "text/x-fossil-wiki": rendered, err := markup.RenderFossilWiki(body, res) if err != nil { return "", err } return string(rendered), nil case "text/x-pikchr": rendered, err := markup.RenderPikchr(body) if err != nil { // A diagram that will not compile shows its source and the // compiler's complaint, never a broken page. return "<pre>" + html.EscapeString(string(body)) + "</pre>" + "<p><small>pikchr: " + html.EscapeString(err.Error()) + "</small></p>", nil } return string(rendered), nil default: return "<pre>" + html.EscapeString(string(body)) + "</pre>", nil } }