Artifact d6d0d911388e369252b90f6f0cb56b9e361eca015759da7c68a5e929b24f7292:
- File
internal/api/register.go
— part of check-in
[c754084dc7]
at
2026-08-17 15:52:12
on branch trunk
— web: the files page was unusably slow; fix the query, memoise, cap the list
Three separate faults, worst first.
Branches() used a correlated subquery — one full scan of checkin per row. On tcl (42,074 check-ins, 1,855 branches) that took 434 SECONDS, which is what made the page look hung rather than slow. One grouped scan with SQLite's bare-column-with-max rule, plus an index on (branch, date): 0.042s.
'ls --long' costs ~4ms per file because it reads each blob for size and the binary flag, so tcl's 2,198-path tip took 8.5s. Fossil never pays this — browse.c builds a directory listing from its own indexed tables and shows no size at all — but quarry reads through the CLI by design, so the answer is to compute once: a check-in's file set is IMMUTABLE, so listings are memoised per version, the tip is warmed after each ingest, and the tree op resolves 'trunk' to its hash so the page asks for the key that was warmed.
Only a hash may be cached. Caching 'trunk' would serve a stale tree forever after the next commit — a correctness bug dressed as a performance fix — so the memo refuses anything that can move, with a test pinning it.
The branch selector rendered every branch: 1,855 <option> elements, 90% of a 239KB page, and unusable as a control. Now the 25 most recently active, plus the branch being viewed even when it is older.
tcl's repo home: 8.5s -> 0.38s, 239KB -> 29KB. sqlite: 5.0s -> 0.32s. (user: dmestas, size: 60163) [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" "encoding/base64" "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 // Indexing reports whether this repo's first ingest is still // running. A large store takes minutes to fold, and during that // window its counts read as zero — which looks broken unless the // surface says so. nil means "never indexing" (test wiring). Indexing func() bool } // 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 } // WebhookPlane queues one activity event for third-party delivery // (Principle 6). Deliberately stated in primitives rather than the // webhook package's own type: the ops layer names what happened, and // the composition root owns how it is delivered. A nil Deps.Hooks // disables webhooks. type WebhookPlane interface { Raise(id, repo, kind, actor, summary, link string) 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. // // An ACTIVITY event is also the webhook surface's event — the same // thing happened, and Principle 6 asks that a third party be able to // see it without speaking NATS. Mentions are deliberately not // forwarded: they are addressed to one person, not to the repository. // Queueing is a publish, so it does not slow the op down; the delivery // worker does the HTTP. func (d Deps) fireNotify(e NotifyEvent) { if d.Hooks != nil && e.Workflow == "quarry-activity" { repo, _ := e.Payload["repo"].(string) actor, _ := e.Payload["actor"].(string) summary, _ := e.Payload["summary"].(string) link, _ := e.Payload["link"].(string) if err := d.Hooks.Raise(e.TxnID, repo, kindOf(e.TxnID), actor, summary, link); err != nil { log.Printf("webhook: queue %s: %v", e.TxnID, err) } } 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) } }() } // WriteTag records a tag artifact on a check-in through the lease // holder — a verdict is a write like any other, and two nodes must not // both record one. The index picks it up on the next ingest round. func (r *Repo) WriteTag(name, checkin, value string) error { if err := r.write(func() error { return r.Zit.TagAdd(name, checkin, value, false) }); err != nil { return err } return r.synced() } // 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 Hooks WebhookPlane // CI is the run record; nil when no engine is configured, which is // how a forge without CI simply has no CI pages. CI CIRuns } // kindOf recovers the event kind from an activity idempotency key, // which is built as "activity:<kind>:<hash>". Keeping one key format // means the webhook kind and the dedup key cannot disagree. func kindOf(txnID string) string { rest, ok := strings.CutPrefix(txnID, "activity:") if !ok { return "activity" } kind, _, _ := strings.Cut(rest, ":") return kind } // 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"` // Indexing is true while the first ingest is still folding, so a // zero count reads as "catching up", not "empty". Indexing bool `json:"indexing"` } 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"` Comment string `json:"comment"` // C card: the title, for the edit form 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"` // Mimetype is the N card. Empty omits it, which Fossil reads as // text/x-fossil-wiki — the same dispatch the read side applies. Mimetype string `json:"mimetype" validate:"max=64"` } 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"` Mimetype string `json:"mimetype" validate:"max=64"` } 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"` // Comment is the C card, which a timeline renders as the note's // title. It is write-once at creation: zit refuses to attach the // card later, so a note saved without one shows its event id // forever. Comment string `json:"comment" validate:"max=200"` // 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 attachmentGetIn struct { Repo string `json:"repo" validate:"required,max=64"` // Hash is the attachment's CONTENT artifact, not the A-card // artifact — the A card names what to serve. Hash string `json:"hash" validate:"required,min=4,max=64"` } type attachmentGetOut struct { Name string `json:"name"` Size int `json:"size"` // Content is base64: an attachment is arbitrary bytes and JSON has // no binary type. The HTML route decodes and streams it as a // download; the API answer stays a normal JSON document. Content string `json:"content"` } // CIRuns is the forge-side record of launched pipelines. quarry does // not model runs — dagnats owns them — this is only the mapping from a // run back to the check-in it was for. type CIRuns interface { CIRuns(repo string, limit int) ([]CIRun, error) CIRunsFor(repo, checkin string) ([]CIRun, error) } // CIRun is one launched pipeline, as the display needs it. type CIRun struct { RunID string `json:"runId"` Checkin string `json:"checkin"` Workflow string `json:"workflow"` Pipeline string `json:"pipeline"` Status string `json:"status"` Started string `json:"started"` } type ciListIn struct { Repo string `json:"repo" validate:"required,max=64"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` } type ciListOut struct { Runs []CIRun `json:"runs"` Count int `json:"count"` } type fileHistoryIn struct { Repo string `json:"repo" validate:"required,max=64"` Version string `json:"version" validate:"max=100"` Path string `json:"path" validate:"required,max=1024"` Limit int `json:"limit" validate:"min=0,max=200,default=50"` } type fileHistoryOut struct { Path string `json:"path"` Version string `json:"version"` Changes []zitcli.FileChange `json:"changes"` Count int `json:"count"` } type branchListIn struct { Repo string `json:"repo" validate:"required,max=64"` } type branchListOut struct { Branches []index.Branch `json:"branches"` Count int `json:"count"` } type treeIn struct { Repo string `json:"repo" validate:"required,max=64"` // Version is any name zit resolves — a branch, a tag, "trunk", or a // hash. Empty means the default branch's tip. Version string `json:"version" validate:"max=100"` // Path is the directory being listed; empty is the root. Path string `json:"path" validate:"max=1024"` } // treeEntry is one row of a directory listing. Dir entries are synthesised // from the flat path set zit returns — a store has no directory objects, // so a "directory" is a shared prefix and nothing more. type treeEntry struct { Name string `json:"name"` Path string `json:"path"` Dir bool `json:"dir"` Blob string `json:"blob,omitempty"` Files int `json:"files,omitempty"` // for a directory: what it contains, recursively Size int64 `json:"size,omitempty"` // Exec and Link come from the F card's permission (zit#96). A link's // blob content is its TARGET, so a viewer must not render it as file // content. Exec bool `json:"exec,omitempty"` Link bool `json:"link,omitempty"` Binary bool `json:"binary,omitempty"` } type treeOut struct { Version string `json:"version"` Path string `json:"path"` Entries []treeEntry `json:"entries"` Readme string `json:"readme,omitempty"` // rendered, when this directory has one ReadmeIn string `json:"readmeIn,omitempty"` // which file it came from } 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"` // CI is the recorded verdict, read from the store's tag artifacts // rather than the engine — so it answers even with dagnats down, // and it travels with a clone. CI string `json:"ci,omitempty"` 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"` } // ticketComment is one discussion entry with its body rendered. type ticketComment struct { Date string `json:"date"` Login string `json:"login"` Username string `json:"username"` HTML string `json:"html"` } type ticketGetOut struct { Ticket index.TicketState `json:"ticket"` // DescriptionHTML and Comments are the rendered forms. Fossil renders // a ticket's body and every comment through the wiki with the // mimetype recorded ALONGSIDE each one, so a plaintext comment on a // markdown tracker still reads as it was written. DescriptionHTML string `json:"descriptionHtml"` Comments []ticketComment `json:"comments"` // Attachments are the files currently attached to this ticket. // Fossil records them as their own artifacts targeting the ticket // id, so they are not part of TicketState's folded field set. Attachments []index.Attachment `json:"attachments"` } 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 r := d.Repos[name]; r != nil && r.Indexing != nil { info.Indexing = r.Indexing() } 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) } // Attachments key off the FULL ticket id, not the prefix the // caller passed. att, err := repo.Index.AttachmentsFor(t.ID) if err != nil { return ticketGetOut{}, err } out := ticketGetOut{Ticket: t, Attachments: att} if t.Description != "" { html, err := renderBody(repo.Index, in.Repo, t.DescriptionMimetype, []byte(t.Description)) if err != nil { return ticketGetOut{}, err } out.DescriptionHTML = html } for _, c := range t.Comments { html, err := renderBody(repo.Index, in.Repo, c.Mimetype, []byte(c.Body)) if err != nil { return ticketGetOut{}, err } out.Comments = append(out.Comments, ticketComment{ Date: c.Date, Login: c.Login, Username: c.Username, HTML: html, }) } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[attachmentGetIn, attachmentGetOut]{ Name: "attachment.get", Doc: "Fetch one attachment's content by its content-artifact hash.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/attachment/{hash}", Run: func(_ context.Context, in attachmentGetIn) (attachmentGetOut, error) { repo, err := d.repo(in.Repo) if err != nil { return attachmentGetOut{}, err } // The A card is the authority on what this blob is called and // whether it is still attached. Serving a blob the index does // not know as a live attachment would turn this route into a // general read-any-artifact endpoint. name, ok, err := repo.Index.AttachmentName(in.Hash) if err != nil { return attachmentGetOut{}, err } if !ok { return attachmentGetOut{}, fmt.Errorf("no attachment %q: %w", in.Hash, ops.ErrNotFound) } raw, err := repo.Zit.ArtifactRaw(in.Hash, maxAttachmentBytes) if err != nil { if errors.Is(err, zitcli.ErrNoEntry) { return attachmentGetOut{}, fmt.Errorf("attachment content unavailable: %w", ops.ErrNotFound) } return attachmentGetOut{}, err } return attachmentGetOut{Name: name, Size: len(raw), Content: base64.StdEncoding.EncodeToString(raw)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ciListIn, ciListOut]{ Name: "ci.list", Doc: "List a repository's CI runs, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ci", Run: func(_ context.Context, in ciListIn) (ciListOut, error) { if _, err := d.repo(in.Repo); err != nil { return ciListOut{}, err } if d.CI == nil { return ciListOut{}, nil // no engine: an empty list, not an error } runs, err := d.CI.CIRuns(in.Repo, in.Limit) if err != nil { return ciListOut{}, err } return ciListOut{Runs: runs, Count: len(runs)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[fileHistoryIn, fileHistoryOut]{ Name: "file.history", Doc: "List the check-ins that changed one file, newest first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/history/{version}/{path...}", Run: func(_ context.Context, in fileHistoryIn) (fileHistoryOut, error) { repo, err := d.repo(in.Repo) if err != nil { return fileHistoryOut{}, err } version := in.Version if version == "" { version = "trunk" } // zit answers this directly (zit#93); quarry has no index of // per-file history and building one would duplicate the DAG. changes, err := repo.Zit.FileHistory(version, in.Path, in.Limit) if err != nil { return fileHistoryOut{}, err } return fileHistoryOut{Path: in.Path, Version: version, Changes: changes, Count: len(changes)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[branchListIn, branchListOut]{ Name: "branch.list", Doc: "List branches with their tip check-in, most recently active first.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/branch", Run: func(_ context.Context, in branchListIn) (branchListOut, error) { repo, err := d.repo(in.Repo) if err != nil { return branchListOut{}, err } bs, err := repo.Index.Branches() if err != nil { return branchListOut{}, err } return branchListOut{Branches: bs, Count: len(bs)}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[treeIn, treeOut]{ Name: "tree.list", Doc: "List one directory of a check-in, with the directory's README rendered.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/tree/{version}/{path...}", Run: func(_ context.Context, in treeIn) (treeOut, error) { repo, err := d.repo(in.Repo) if err != nil { return treeOut{}, err } version := in.Version if version == "" { version = "trunk" } // Resolve a moving name to the check-in it currently means. // Two reasons: the listing is then cacheable (a hash cannot // move, "trunk" can), and the page's links carry the exact // check-in a reader was looking at rather than whatever is // newest by the time they click. if b, ok, err := repo.Index.TipOf(version); err == nil && ok { version = b.Tip } prefix := strings.Trim(in.Path, "/") // --under asks zit for this subtree only, so drawing one // directory no longer materialises every path in the // repository (zit#96). files, err := repo.Zit.LsUnder(version, prefix) if err != nil { return treeOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err) } if prefix != "" { prefix += "/" } // A store keeps no directories, only paths. One level of the // tree is therefore the distinct next segment of every path // under this prefix — files carry their blob, directories // carry how much lives beneath them. dirs := map[string]int{} var entries []treeEntry for _, f := range files { if !strings.HasPrefix(f.Name, prefix) { continue } rest := f.Name[len(prefix):] if i := strings.IndexByte(rest, '/'); i >= 0 { dirs[rest[:i]]++ continue } entries = append(entries, treeEntry{ Name: rest, Path: prefix + rest, Blob: f.Blob, Size: f.Size, Exec: f.Executable(), Link: f.Symlink(), Binary: f.Binary, }) } for name, n := range dirs { entries = append(entries, treeEntry{ Name: name, Path: prefix + name, Dir: true, Files: n, }) } // Directories first, then names — the ordering every file // browser uses, because a tree read alphabetically with files // interleaved is hard to scan. sort.Slice(entries, func(i, j int) bool { if entries[i].Dir != entries[j].Dir { return entries[i].Dir } return entries[i].Name < entries[j].Name }) out := treeOut{Version: version, Path: strings.Trim(in.Path, "/"), Entries: entries} // The README of THIS directory, rendered — what a forge shows // under the listing. for _, e := range entries { if e.Dir || !isReadme(e.Name) { continue } body, err := repo.Zit.Cat(e.Path, version) if err != nil { break // unreadable README is not a failed listing } html, err := renderBody(repo.Index, in.Repo, mimetypeOf(e.Name), body) if err != nil { break } out.Readme, out.ReadmeIn = html, e.Name break } return out, 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) } entries := []index.TimelineEntry{e} if err := repo.Index.AttachCI(entries); err != nil { return checkinGetOut{}, err } out := checkinGetOut{ Hash: e.Hash, CI: entries[0].CI, 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, Comment: n.Comment, 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 } // Same default as wiki.save and technote.add: quarry's own // forms are markdown, and an omitted N card would be read // back as text/x-fossil-wiki (fossil's rule), so a post // typed as markdown would render as wiki markup. mt := in.Mimetype if mt == "" { mt = "text/x-markdown" } var hash string if err := repo.write(func() (err error) { hash, err = repo.Zit.ForumPost(in.Title, author(ctx), mt, []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), replyMimetype(in.Mimetype), []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, in.Comment, []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 } } // replyMimetype applies the same N-card default the other authoring ops // use: an omitted card is text/x-fossil-wiki by fossil's rule, which is // not what quarry's markdown forms produce. func replyMimetype(in string) string { if in == "" { return "text/x-markdown" } return in } // maxAttachmentBytes caps a single attachment read. quarry buffers the // blob to answer through the op layer, so this is the memory an // anonymous request can ask for; larger attachments are refused rather // than truncated. const maxAttachmentBytes = 32 << 20 // isReadme matches the names a forge renders under a directory listing. // Case-insensitive because repositories are inconsistent about it and a // reader does not care. func isReadme(name string) bool { lower := strings.ToLower(name) for _, stem := range []string{"readme.md", "readme.markdown", "readme.txt", "readme"} { if lower == stem { return true } } return false } // mimetypeOf maps a filename to the markup dispatch renderBody expects. // A bare README with no extension is markdown by convention here, which // is what a forge assumes and what authors write. func mimetypeOf(name string) string { if strings.HasSuffix(strings.ToLower(name), ".txt") { return "text/plain" } return "text/x-markdown" }