// 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"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"craftdesign.group/quarry/internal/archive"
"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/work"
"craftdesign.group/quarry/internal/zitcli"
)
// Repo is one registered store's data-plane pair.
type Repo struct {
Index *index.Index
Zit *zitcli.Client
// Leaves answers which check-ins are open leaves, from zit rather
// than from a query over the index: repo-state is zit's to define
// and it already caches the answer. nil leaves every entry unmarked
// rather than marked WRONGLY, which is the safer failure.
Leaves func() (map[string]bool, error)
// FossilClone reports that this store holds no sub-manifest
// artifact and can therefore be cloned by stock fossil. Probed once
// at registration (the answer changes only when an S-bearing
// artifact first arrives) because the probe costs ~250ms.
//
// Defaults to FALSE, which is the safe direction: offering a fossil
// clone that cannot work wastes somebody's time and makes quarry
// look wrong, where withholding one they could have used costs them
// a copied command.
FossilClone bool
// 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
// Health reports this store's ingest state for the fleet admin
// surface. Stated as a func returning a local type rather than
// exposing the pipeline, for the same reason Sync is: the ops layer
// names what it needs and the composition root owns the wiring.
// nil means "no health available" (test wiring).
Health func() RepoHealth
// Mirror pulls from an upstream into this store and reports whether
// anything arrived. It must run under the same single-writer lease
// Guard enforces — a pull WRITES artifacts, so two nodes mirroring
// the same store concurrently is the exact race the lease exists to
// prevent. nil means this store cannot be mirrored.
Mirror func(url string) (bool, error)
}
// RepoHealth is one store's ingest state, as the fleet admin page needs
// it. Durations are seconds and times are RFC3339 so the shape survives
// the JSON surface unchanged.
type RepoHealth struct {
// LastOK is when a round last succeeded, empty if none ever has.
LastOK string `json:"lastOk"`
// RoundSeconds is how long the most recent round took.
RoundSeconds float64 `json:"roundSeconds"`
// LastCount is artifacts published in the last round, Total since
// this process started.
LastCount int `json:"lastCount"`
Total int `json:"total"`
// Fallbacks counts artifacts that would not parse and were indexed
// as opaque content.
Fallbacks int `json:"fallbacks"`
// Gaps counts DAG edges pointing at a check-in the index does not
// hold: artifacts lost from the bounded event window that a rebuild
// has not recovered. Distinct from a failing round — this one says
// the index is serving an incomplete history as if it were whole.
Gaps int `json:"gaps"`
// LastErr is the most recent round's failure, empty when healthy.
// Failures counts CONSECUTIVE failures: 1 is a blip, 900 is wedged.
LastErr string `json:"lastErr"`
LastErrAt string `json:"lastErrAt"`
Failures int `json:"failures"`
}
// 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 *Repos
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
// CanRead gates reading a repository at all. nil means every
// repository is public, which is v1's behaviour and what tests want.
//
// It takes a context because the answer depends on WHO is asking,
// and the identity rides there. Returning false must make the
// repository indistinguishable from one that does not exist — see
// Deps.repoFor.
CanRead func(ctx context.Context, repo string) bool
// Accounts is forge identity: who exists and what they may do. nil
// disables the account ops, which is how a forge with no accounts
// configured simply has no account surface.
Accounts AccountStore
// Private reports which repositories are private, for the listing's
// badge. Separate from CanRead, which answers a different question:
// this is "is it marked private", that is "may you see it".
Private func() map[string]bool
// MirrorFreshness reports each mirrored repository's last successful
// pull; absent means not mirrored.
MirrorFreshness func() map[string]string
// Deletable reports which repositories this node created and may
// therefore destroy.
Deletable func(repo string) bool
// Descriptions supplies each repository's one-line summary for the
// listing; nil simply renders none.
Descriptions func() map[string]string
// Registry adds and removes served repositories at runtime; nil
// leaves the fleet fixed at whatever --repo named, which is the old
// behaviour and what tests want.
Registry RepoRegistry
// Bulk bounds operations expensive in MEMORY as well as time.
// archive buffers up to 256 MiB per request on an ANONYMOUS route,
// so ten concurrent requests would be 2.5 GiB resident and ten
// subprocesses. That is latent rather than live today — archive 503s
// on zit#97, so nothing is buffered yet — which is exactly why the
// bound belongs here now: the endpoint should be born bounded rather
// than acquire a limit after someone finds it.
//
// nil runs unbounded, which is the old behaviour and suits tests.
Bulk *work.Lane
// Archives caches built archives on disk. An archive costs seconds
// of compression on an anonymous route and the bytes are
// reproducible, so the same check-in must not be rebuilt per
// reader. nil builds every time, which is correct but slow and is
// what tests use.
Archives *archive.Cache
// Mirrors is the upstream-tracking store; nil disables the mirror
// ops entirely, which is how a forge that mirrors nothing simply has
// no mirror surface.
Mirrors MirrorStore
}
// AccountStore is forge identity. Stated in this package's own types so
// the ops layer does not depend on the OLTP package's, matching how
// CIRuns and MirrorStore are stated.
//
// The password VERIFIER never appears here in any direction. Passwords
// go in; nothing that could verify one comes back.
type AccountStore interface {
Accounts() ([]Account, error)
AddAccount(name, password string, admin bool) error
SetPassword(name, password string) error
DeleteAccount(name string) error
Grant(account, repo, role string) error
Revoke(account, repo string) error
GrantsFor(account string) (map[string]string, error)
}
// Account is one login as the admin surface sees it: never the verifier.
type Account struct {
Name string `json:"name"`
Admin bool `json:"admin"`
Created string `json:"created"`
}
// RepoRegistry is the seam for changing which repositories are served.
// Stated here rather than as the composition root's concrete type for
// the same reason MirrorStore is: the op layer names what it needs, and
// main owns how a repository is actually opened and torn down.
//
// Add must be atomic in effect — a repository is serving and persisted,
// or neither. A registration that starts serving but is not recorded
// disappears on the next restart, which is the worst of both.
type RepoRegistry interface {
// Add returns where the repository was placed, which the caller may
// have left to the server.
Add(spec RepoSpec) (path string, err error)
Remove(name string) error
Delete(name, confirm string) error
Update(name, description, visibility string) error
}
// RepoSpec says which repository to serve and where it comes from.
// Exactly one origin applies: an existing store at Path, a new empty one
// (Create), or a clone of Clone. A struct rather than positional
// arguments because this list has grown twice already.
type RepoSpec struct {
Name string
// Path may be empty, in which case the node puts the repository
// under its repositories root. Nobody picks a filesystem path when
// they create a repository on a forge, and requiring one made the
// API unusable by anyone who was not also the server's operator.
Path string
// Description is the one line the repository list shows.
Description string
// Visibility is "public" or "private"; empty means public.
Visibility string
// Create runs `zit init` at Path first — the "host my project here"
// case, which quarry could not do at all.
Create bool
// Readme, License and Ignore seed the first check-in. Only meaningful
// with Create: a cloned repository already has a history.
Readme bool
License string
Ignore bool
// Author records the check-in's user.
Author string
// Clone copies the repository served at this URL into Path. quarry
// also records a mirror for it unless Mirror is false: adopting a
// remote repository and then not tracking it is almost never what
// anyone meant.
Clone string
Mirror bool
// MirrorEvery is the pull interval in seconds; zero takes the
// default.
MirrorEvery int
}
// MirrorStore is the mirror configuration seam. Stated in this package's
// own types so the ops layer does not depend on the OLTP package's,
// matching how CIRuns is stated.
type MirrorStore interface {
Mirrors() ([]Mirror, error)
MirrorFor(repo string) (Mirror, bool, error)
SetMirror(repo, url string, everySeconds int) error
DeleteMirror(repo string) error
// RecordMirrorRun stamps an attempt; err nil means it succeeded.
RecordMirrorRun(repo string, err error) error
}
// Mirror is one store's upstream configuration and recent history.
type Mirror struct {
Repo string `json:"repo"`
URL string `json:"url"`
Every int `json:"every"` // seconds
Enabled bool `json:"enabled"`
// LastAt is the last ATTEMPT and LastOK the last success, kept apart
// so a store that has been failing for a week still reports when it
// was last actually current.
LastAt string `json:"lastAt"`
LastOK string `json:"lastOk"`
LastErr string `json:"lastErr"`
Failures int `json:"failures"`
}
// 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
}
// PullMirror runs a single mirror: pull, stamp the attempt, and re-ingest
// only if artifacts actually arrived. It is shared by the mirror.run op
// and the background scheduler (via the composition root) so a manual pull and a scheduled one
// cannot drift in behaviour.
//
// The attempt is recorded BEFORE the ingest round, and the ingest error
// is deliberately not folded into the mirror's error: a pull that
// succeeded and an index that then failed to fold are different
// failures with different fixes, and reporting the second as the first
// sends an operator to look at the network.
func (d Deps) PullMirror(m Mirror) (bool, error) {
repo, err := d.repo(m.Repo)
if err != nil {
return false, err
}
if repo.Mirror == nil {
return false, fmt.Errorf("repository %q cannot be mirrored: %w", m.Repo, ops.ErrUnavailable)
}
changed, pullErr := repo.Mirror(m.URL)
if err := d.Mirrors.RecordMirrorRun(m.Repo, pullErr); err != nil {
log.Printf("mirror: %s: recording run: %v", m.Repo, err)
}
if pullErr != nil {
return false, pullErr
}
if changed {
if err := repo.synced(); err != nil {
return true, fmt.Errorf("pulled, but indexing failed: %w", err)
}
}
return changed, nil
}
// visibilityOf turns the API's boolean into the stored word.
func visibilityOf(private bool) string {
if private {
return "private"
}
return "public"
}
func errNoAccounts() error {
return fmt.Errorf("this node has no account store: %w", ops.ErrUnavailable)
}
// scrub removes a secret from an error's text before it travels.
//
// The store's errors quote what they were given, and for these two ops
// what they were given is a password. An error is the one value that
// reliably reaches a log, an HTTP body and a terminal, so it is the one
// place a secret must not be.
func scrub(err error, secret string) string {
msg := err.Error()
if secret == "" {
return msg
}
return strings.ReplaceAll(msg, secret, "[redacted]")
}
// resolveVersion turns a hash PREFIX into a full check-in hash, and
// passes every other reference through untouched.
//
// It deliberately does not resolve names. 0001 §2 makes zit the
// authority on them, and an earlier version of this function ignored
// that: it fell back to the index's branch table, so "trunk" resolved to
// whatever check-in sat on a branch literally named trunk. On tk — which
// renamed its trunk to main — that served a README from 2020 while zit
// resolved the same word to the current tip on main. Names are aliases,
// defaults and tags with rules that live in zit; a second implementation
// here is a slower way to be wrong.
//
// The consequence is that a symbolic version is not cacheable, because
// what it names can change under it. Making file links carry the
// RESOLVED hash is how that gets fixed, and that resolution has to come
// from zit.
func (d Deps) resolveVersion(repo *Repo, ref string) string {
if ref == "" {
return "trunk"
}
if repo.Index == nil {
return ref
}
if e, _, ok, err := repo.Index.CheckinByPrefix(ref); err == nil && ok {
return e.Hash
}
return ref
}
// repo resolves one registered store without a visibility check. Use it
// only where the caller has already been gated, or where no reader is
// involved (the mirror scheduler, CI).
func (d Deps) repo(name string) (*Repo, error) {
if r, ok := d.Repos.Get(name); ok {
return r, nil
}
return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
}
// repoFor resolves a store for a READER, and is what every request-path
// op should use.
//
// A repository the caller may not see reports NOT FOUND, never
// forbidden. A 403 confirms the repository exists, which is precisely
// what a private repository must not confirm: "no such repository" and
// "you may not see this repository" have to be one answer, or the
// existence of a name leaks to anyone who can spell it.
func (d Deps) repoFor(ctx context.Context, name string) (*Repo, error) {
r, err := d.repo(name)
if err != nil {
return nil, err
}
if d.CanRead != nil && !d.CanRead(ctx, name) {
return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
}
return r, nil
}
type versionIn struct{}
type versionOut struct {
Version string `json:"version"`
}
// repoListIn filters and orders the listing. A forge's front page is a
// listing, and an unfiltered, unsorted, unbounded one is the shape that
// made the timeline unusable — render everything, sort nothing.
type repoListIn struct {
// Q filters by name substring, case-insensitive.
Q string `json:"q" validate:"max=64"`
// Sort is active (most recent event first), name, or size.
Sort string `json:"sort" validate:"oneof=active|name|size"`
// Limit caps the page; 0 means no limit, which is what internal
// callers and a small fleet want.
Limit int `json:"limit" validate:"min=0,max=500"`
}
type repoGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type repoInfo struct {
Name string `json:"name"`
// Contributors is how many distinct people have authored anything
// here, and MirroredAt when this node last successfully pulled from
// upstream — empty when the repository is not mirrored.
//
// Mirror freshness is on the listing because mirroring created an
// ambiguity: a repository quiet upstream and a mirror that has been
// failing for a week look identical if all a reader sees is "last
// activity".
Contributors int `json:"contributors"`
MirroredAt string `json:"mirroredAt"`
// Deletable is whether this node created the store and may destroy
// it, so a surface can offer deletion only where it would work.
Deletable bool `json:"deletable"`
// FossilClone reports that stock fossil can clone this store — it
// holds no sub-manifest artifact. zit and fossil share the sync
// protocol, so a repository that never emitted one is readable by
// either tool, and a reader who already has fossil should be told
// so rather than made to install something.
FossilClone bool `json:"fossilClone"`
// LastActive is the newest event's date, empty when the repository
// has none. It is what "sort by activity" orders on and what a
// reader actually scans a forge listing for.
LastActive string `json:"lastActive"`
Description string `json:"description"`
Private bool `json:"private"`
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 is how many this page holds; Total how many matched the
// filter before the limit. Both, because "23 of 140" is the thing a
// reader needs and one number cannot say it.
Count int `json:"count"`
Total int `json:"total"`
// Served is how many repositories the caller may read BEFORE the
// filter. Total alone made a filtered listing say "1 of 1", which
// is true and tells a reader nothing: what they want to know when
// filtering is how many of the fleet matched.
Served int `json:"served"`
}
type fleetStatusIn struct{}
// fleetRepo is one row of the fleet admin surface: identity, size, and
// ingest health together. Health is admin-only because LastErr carries
// operational detail (store paths, NATS subjects) that the anonymous
// repo.list has no business leaking.
type fleetRepo struct {
Name string `json:"name"`
Code string `json:"code"`
Checkins int `json:"checkins"`
Indexing bool `json:"indexing"`
Health RepoHealth `json:"health"`
}
type fleetStatusOut struct {
Repos []fleetRepo `json:"repos"`
Count int `json:"count"`
// Unhealthy counts stores whose last round failed — the number an
// operator actually scans for.
Unhealthy int `json:"unhealthy"`
}
// fleetSyncIn names one repository, or all of them when Repo is empty.
type fleetSyncIn struct {
Repo string `json:"repo" validate:"max=64"`
}
type fleetSyncResult struct {
Repo string `json:"repo"`
Artifacts int `json:"artifacts"`
Err string `json:"err,omitempty"`
}
type repoCountsIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
// repoCountsOut is the repository nav's content census. It is its own op
// rather than part of repo.list because it is per-repository and read on
// every page render; the index memoizes it per fold.
type repoCountsOut struct {
Checkins int `json:"checkins"`
WikiPages int `json:"wikiPages"`
Tickets int `json:"tickets"`
ForumThreads int `json:"forumThreads"`
Technotes int `json:"technotes"`
Contributors int `json:"contributors"`
// Tags is release tags, memoized with the rest: the ref switcher
// shows a page of them and the stats strip needs the true total,
// which is not the same number.
Tags int `json:"tags"`
}
type accountListIn struct{}
// accountGrant is one repository grant, for the listing.
type accountGrant struct {
Repo string `json:"repo"`
Role string `json:"role"`
}
type accountRow struct {
Account
Grants []accountGrant `json:"grants"`
}
type accountListOut struct {
Accounts []accountRow `json:"accounts"`
Count int `json:"count"`
}
// accountAddIn carries a plaintext password, which makes this one of the
// two ops that must never be logged with its input. The ops layer's
// error path logs context; these must not hand it one.
type accountAddIn struct {
Name string `json:"name" validate:"required,max=64"`
Password string `json:"password" validate:"required,max=1024"`
Admin bool `json:"admin"`
}
type accountAddOut struct {
Name string `json:"name"`
Admin bool `json:"admin"`
}
type accountPasswdIn struct {
Name string `json:"name" validate:"required,max=64"`
Password string `json:"password" validate:"required,max=1024"`
}
type accountDeleteIn struct {
Name string `json:"name" validate:"required,max=64"`
}
type accountNameOut struct {
Name string `json:"name"`
}
type accountGrantIn struct {
Name string `json:"name" validate:"required,max=64"`
Repo string `json:"repo" validate:"required,max=64"`
Role string `json:"role" validate:"required,oneof=read|write|admin"`
}
type accountGrantOut struct {
Name string `json:"name"`
Repo string `json:"repo"`
Role string `json:"role"`
}
type accountRevokeIn struct {
Name string `json:"name" validate:"required,max=64"`
Repo string `json:"repo" validate:"required,max=64"`
}
type repoAddIn struct {
Name string `json:"name" validate:"required,max=64"`
// Path is optional: omitted, the server places the repository under
// its repositories root.
Path string `json:"path" validate:"max=4096"`
Description string `json:"description" validate:"max=300"`
// Private creates the repository unreadable to anyone without a role
// on it. Absent means public — the safe default is the one that does
// not surprise, and hiding something by accident is recoverable
// while publishing it by accident is not... but a forge whose repos
// silently vanish is worse, so public it is, and taking a repository
// private stays the deliberate act.
Private bool `json:"private"`
// Create makes an empty repository at path; Clone copies one from a
// URL. Both are optional and mutually exclusive; with neither, path
// must already hold a repository.
Create bool `json:"create"`
Clone string `json:"clone" validate:"max=2048"`
// Readme seeds a first check-in, so the repository has a born trunk
// rather than an unborn one with nothing to show. License is "mit",
// "bsd-2" or empty; Ignore adds a starter .zitignore.
Readme bool `json:"readme"`
License string `json:"license" validate:"max=16"`
Ignore bool `json:"ignore"`
// NoMirror suppresses the mirror a clone would otherwise record.
// Negative because tracking what you just adopted is the default.
NoMirror bool `json:"noMirror"`
MirrorEvery int `json:"mirrorEvery" validate:"min=0,max=604800"`
}
type repoAddOut struct {
Name string `json:"name"`
// Path is where the server actually placed it, which the caller may
// not have chosen.
Path string `json:"path"`
// Mirrored reports whether a mirror was recorded, so the caller does
// not have to infer it from the flags it sent.
Mirrored bool `json:"mirrored"`
}
// repoUpdateIn is the repository's settings, applied WHOLE. A settings
// form submits every field it manages, so partial-update semantics
// would only add a way for the two to disagree about what "absent"
// means.
type repoUpdateIn struct {
Name string `json:"name" validate:"required,max=64"`
Description string `json:"description" validate:"max=300"`
Private bool `json:"private"`
}
type repoUpdateOut struct {
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
}
// repoDeleteIn destroys a repository. Confirm must equal Name: the
// typed-name pattern is used because a yes/no prompt is answered
// reflexively and this operation has no undo.
type repoDeleteIn struct {
Name string `json:"name" validate:"required,max=64"`
Confirm string `json:"confirm" validate:"required,max=64"`
}
type repoRemoveIn struct {
Name string `json:"name" validate:"required,max=64"`
}
type repoRemoveOut struct {
Name string `json:"name"`
}
type mirrorListIn struct{}
type mirrorListOut struct {
Mirrors []Mirror `json:"mirrors"`
Count int `json:"count"`
}
type mirrorSetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
URL string `json:"url" validate:"required,max=2048"`
// Every is the pull interval in seconds. The store floors it — these
// upstreams are other people's servers.
Every int `json:"every" validate:"min=0,max=604800,default=3600"`
}
type mirrorSetOut struct {
Mirror Mirror `json:"mirror"`
}
type mirrorDeleteIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type mirrorDeleteOut struct {
Repo string `json:"repo"`
}
type mirrorRunIn struct {
// Repo names one store, or every configured mirror when empty.
Repo string `json:"repo" validate:"max=64"`
}
type mirrorRunResult struct {
Repo string `json:"repo"`
// Changed reports whether the pull actually brought anything in.
// Almost every scheduled pull is a no-op, so this is what separates
// signal from noise in the log and on the page.
Changed bool `json:"changed"`
Err string `json:"err,omitempty"`
}
type mirrorRunOut struct {
Results []mirrorRunResult `json:"results"`
Failed int `json:"failed"`
Changed int `json:"changed"`
}
type fleetSyncOut struct {
Results []fleetSyncResult `json:"results"`
// Failed counts results carrying an error. A bulk sync reports every
// store's outcome rather than aborting on the first failure: with
// eleven repositories, "one of them is broken" must not hide the
// other ten's state.
Failed int `json:"failed"`
}
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 archiveIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Version string `json:"version" validate:"max=100"`
// Format is "tar" (gzip tar, the default) or "zip". zit builds both
// with identical selection and the same reproducibility guarantee,
// so which container a reader gets is purely their preference.
Format string `json:"format" validate:"oneof=tar|zip"`
}
type archiveOut struct {
Name string `json:"name"`
Size int `json:"size"`
// Version is the check-in this archive was cut from, always a full
// hash even when the request named a branch. It is what makes the
// bytes addressable: the same value always names the same archive,
// which is what the caching downstream of this relies on.
Version string `json:"version"`
// Content is base64: an archive is bytes and JSON has no binary
// type. The HTML route decodes and offers it as a download.
Content string `json:"content"`
}
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"`
// Kind restricts the search to one class of document. Empty
// searches everything, which is the default and what a reader
// arriving from a link expects.
Kind string `json:"kind" validate:"oneof=checkin|wiki|ticket|forum|technote"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
}
type searchOut struct {
Results []index.SearchResult `json:"results"`
Count int `json:"count"`
}
type searchAllIn struct {
Q string `json:"q" validate:"required,max=200"`
// Kind restricts to one class of document, as on the per-repo op.
Kind string `json:"kind" validate:"oneof=checkin|wiki|ticket|forum|technote"`
// Repo restricts to one repository — the "repo:" filter, expressed
// as a parameter rather than as syntax inside the query so it
// cannot collide with a term a reader meant literally.
Repo string `json:"repo" validate:"max=64"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
}
// searchAllHit is one result with the repository it came from. The
// repository is part of the result rather than a grouping key because a
// reader scanning fleet-wide results reads them as one ranked list.
type searchAllHit struct {
Repo string `json:"repo"`
index.SearchResult
}
type searchAllOut struct {
Results []searchAllHit `json:"results"`
Count int `json:"count"`
// Searched is how many repositories actually answered, which is not
// the fleet size: private repositories the caller may not read are
// never consulted, and a repository whose index errors is skipped.
Searched int `json:"searched"`
// ByRepo is the true match count per repository, so a reader can see
// that a repository matched even when none of its hits reached the
// first page — and by how much, which is what tells them whether it
// is worth narrowing to.
ByRepo []repoCount `json:"by_repo"`
}
type repoCount struct {
Repo string `json:"repo"`
Count int `json:"count"`
}
type peopleListIn struct {
// Repo narrows to one repository; empty is the whole fleet.
Repo string `json:"repo" validate:"max=64"`
Q string `json:"q" validate:"max=100"`
// Offset pages the listing. It does NOT reduce the work: the merge
// across repositories happens first and is the cost. Paging bounds
// what is rendered, not what is computed — the fleet rollup that
// fixes the second is ticketed separately.
Offset int `json:"offset" validate:"min=0,max=1000000"`
Limit int `json:"limit" validate:"min=0,max=500,default=100"`
}
// person is one contributor's activity, summed across the repositories
// the caller may read. Repos names those repositories, because "who is
// this" on a forge is largely "where do they work".
type person struct {
User string `json:"user"`
Events int `json:"events"`
First string `json:"first"`
Last string `json:"last"`
Repos []string `json:"repos"`
}
type peopleListOut struct {
People []person `json:"people"`
Count int `json:"count"`
Total int `json:"total"`
}
type tagListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Limit int `json:"limit" validate:"min=0,max=500,default=100"`
}
type tagListOut struct {
Tags []index.Tag `json:"tags"`
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"`
// Closed opts INTO finished tickets. The default is the open ones:
// a tracker list is a working surface, and across this corpus half
// of every tracker is closed.
Closed bool `json:"closed"`
}
// 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(ctx 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.repoFor(ctx, 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 = authoredMimetype
}
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.repoFor(ctx, 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
// Say what the text IS. An absent mimetype means fossil
// wiki (see renderBody), so a description written in the
// Markdown box and stored without this renders with its
// headings, fences and emphasis as literal characters —
// correct dispatch on a card that lied by omission.
fields["cmimetype"] = authoredMimetype
}
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.repoFor(ctx, 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,
// A ticket CHANGE carries its own text under icomment
// with a plain "mimetype"; the description's is
// "comment" with "cmimetype". Two field names for the
// same question, which is fossil's schema and not ours
// to rename — and writing a reply into "comment" does
// not append it, it REPLACES the ticket's description.
"icomment": in.Comment,
} {
if v != "" {
fields[k] = v
}
}
if in.Comment != "" {
fields["mimetype"] = authoredMimetype
}
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(ctx 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(ctx context.Context, in repoListIn) (repoListOut, error) {
// Filtered by visibility: a listing that names a private
// repository has leaked it, whatever the page then refuses
// to show. Name leakage is the usual way this is got wrong.
needle := strings.ToLower(in.Q)
var names []string
served := 0
for _, name := range d.Repos.Names() {
if d.CanRead != nil && !d.CanRead(ctx, name) {
continue
}
// Counted before the filter but AFTER the visibility
// check: the denominator is what this caller may see,
// never the whole fleet, or it leaks how many private
// repositories exist.
served++
if needle != "" && !strings.Contains(strings.ToLower(name), needle) {
continue
}
names = append(names, name)
}
var desc map[string]string
if d.Descriptions != nil {
desc = d.Descriptions()
}
var priv map[string]bool
if d.Private != nil {
priv = d.Private()
}
var fresh map[string]string
if d.MirrorFreshness != nil {
fresh = d.MirrorFreshness()
}
rows := make([]repoInfo, 0, len(names))
for _, name := range names {
r, ok := d.Repos.Get(name)
if !ok {
continue
}
info := repoInfo{Name: name, Description: desc[name], Private: priv[name]}
if d.Deletable != nil {
info.Deletable = d.Deletable(name)
}
if r.Indexing != nil {
info.Indexing = r.Indexing()
}
info.MirroredAt = fresh[name]
if ix := r.Index; ix != nil {
if c, err := ix.Counts(); err == nil {
info.Checkins, info.Contributors = c.Checkins, c.Contributors
}
if last, ok, err := ix.LastEvent(); err == nil && ok {
info.LastActive = last
}
}
rows = append(rows, info)
}
// Sorted BEFORE the limit, or a page is an arbitrary subset
// in the right order — which reads as correct and is not.
switch in.Sort {
case "size":
sort.SliceStable(rows, func(i, j int) bool { return rows[i].Checkins > rows[j].Checkins })
case "active":
sort.SliceStable(rows, func(i, j int) bool { return rows[i].LastActive > rows[j].LastActive })
}
out := repoListOut{Total: len(rows), Served: served}
if in.Limit > 0 && len(rows) > in.Limit {
rows = rows[:in.Limit]
}
out.Repos, out.Count = rows, len(rows)
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[fleetStatusIn, fleetStatusOut]{
Name: "fleet.status",
Doc: "Report every served store's ingest health.",
Kind: ops.Query,
Auth: ops.Admin,
Route: "GET /api/v1/fleet",
Run: func(ctx context.Context, _ fleetStatusIn) (fleetStatusOut, error) {
names := d.Repos.Names()
out := fleetStatusOut{Count: len(names)}
for _, name := range names {
repo, ok := d.Repos.Get(name)
if !ok || repo == nil {
continue
}
row := fleetRepo{Name: name, Code: repo.Code}
if repo.Indexing != nil {
row.Indexing = repo.Indexing()
}
if repo.Health != nil {
row.Health = repo.Health()
}
if repo.Index != nil {
row.Checkins, _ = repo.Index.CheckinCount()
}
if row.Health.Failures > 0 {
out.Unhealthy++
}
out.Repos = append(out.Repos, row)
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[fleetSyncIn, fleetSyncOut]{
Name: "fleet.sync",
Doc: "Run an ingest round now — one store, or every store when `repo` is empty.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/fleet/sync",
Run: func(ctx context.Context, in fleetSyncIn) (fleetSyncOut, error) {
var names []string
if in.Repo != "" {
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return fleetSyncOut{}, err
}
names = append(names, in.Repo)
} else {
names = d.Repos.Names()
}
var out fleetSyncOut
for _, name := range names {
repo, ok := d.Repos.Get(name)
if !ok || repo == nil || repo.Sync == nil {
continue
}
res := fleetSyncResult{Repo: name}
n, err := repo.Sync()
res.Artifacts = n
if err != nil {
res.Err = err.Error()
out.Failed++
}
out.Results = append(out.Results, res)
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[mirrorListIn, mirrorListOut]{
Name: "mirror.list",
Doc: "List configured upstream mirrors and their recent history.",
Kind: ops.Query,
Auth: ops.Admin,
Route: "GET /api/v1/mirrors",
Run: func(ctx context.Context, _ mirrorListIn) (mirrorListOut, error) {
if d.Mirrors == nil {
return mirrorListOut{}, nil // mirroring unconfigured: an empty list, not an error
}
ms, err := d.Mirrors.Mirrors()
if err != nil {
return mirrorListOut{}, err
}
return mirrorListOut{Mirrors: ms, Count: len(ms)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[mirrorSetIn, mirrorSetOut]{
Name: "mirror.set",
Doc: "Configure a store's upstream and pull interval.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/mirrors",
Run: func(ctx context.Context, in mirrorSetIn) (mirrorSetOut, error) {
if d.Mirrors == nil {
return mirrorSetOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
}
// The store must be one quarry serves. Configuring a
// mirror for a name that resolves to nothing produces a
// row the scheduler will skip forever, which reads as
// "mirroring is broken" rather than "that repo is a typo".
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return mirrorSetOut{}, err
}
if err := d.Mirrors.SetMirror(in.Repo, in.URL, in.Every); err != nil {
return mirrorSetOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
m, _, err := d.Mirrors.MirrorFor(in.Repo)
if err != nil {
return mirrorSetOut{}, err
}
return mirrorSetOut{Mirror: m}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[mirrorDeleteIn, mirrorDeleteOut]{
Name: "mirror.delete",
Doc: "Stop mirroring a store and forget its upstream.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/mirrors/delete",
Run: func(ctx context.Context, in mirrorDeleteIn) (mirrorDeleteOut, error) {
if d.Mirrors == nil {
return mirrorDeleteOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
}
if err := d.Mirrors.DeleteMirror(in.Repo); err != nil {
return mirrorDeleteOut{}, err
}
return mirrorDeleteOut{Repo: in.Repo}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[mirrorRunIn, mirrorRunOut]{
Name: "mirror.run",
Doc: "Pull from upstream now — one store, or every configured mirror when `repo` is empty.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/mirrors/run",
Run: func(ctx context.Context, in mirrorRunIn) (mirrorRunOut, error) {
if d.Mirrors == nil {
return mirrorRunOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
}
ms, err := d.Mirrors.Mirrors()
if err != nil {
return mirrorRunOut{}, err
}
var out mirrorRunOut
for _, m := range ms {
if in.Repo != "" && m.Repo != in.Repo {
continue
}
res := mirrorRunResult{Repo: m.Repo}
changed, err := d.PullMirror(m)
res.Changed = changed
if err != nil {
res.Err = err.Error()
out.Failed++
} else if changed {
out.Changed++
}
out.Results = append(out.Results, res)
}
if in.Repo != "" && len(out.Results) == 0 {
return out, fmt.Errorf("no mirror configured for %q: %w", in.Repo, ops.ErrNotFound)
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[accountListIn, accountListOut]{
Name: "account.list",
Doc: "List forge accounts and their per-repository grants.",
Kind: ops.Query,
Auth: ops.Admin,
Route: "GET /api/v1/accounts",
Run: func(ctx context.Context, _ accountListIn) (accountListOut, error) {
if d.Accounts == nil {
return accountListOut{}, errNoAccounts()
}
accts, err := d.Accounts.Accounts()
if err != nil {
return accountListOut{}, err
}
out := accountListOut{Count: len(accts)}
for _, a := range accts {
row := accountRow{Account: a}
grants, err := d.Accounts.GrantsFor(a.Name)
if err != nil {
return accountListOut{}, err
}
repos := make([]string, 0, len(grants))
for repo := range grants {
repos = append(repos, repo)
}
sort.Strings(repos)
for _, repo := range repos {
row.Grants = append(row.Grants, accountGrant{Repo: repo, Role: grants[repo]})
}
out.Accounts = append(out.Accounts, row)
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[accountAddIn, accountAddOut]{
Name: "account.add",
Doc: "Create a forge account. The password is hashed with argon2id and never stored or returned in plaintext.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/accounts",
Run: func(ctx context.Context, in accountAddIn) (accountAddOut, error) {
if d.Accounts == nil {
return accountAddOut{}, errNoAccounts()
}
if err := d.Accounts.AddAccount(in.Name, in.Password, in.Admin); err != nil {
// Scrubbed deliberately: the store's error can quote what
// it was given, and what it was given is a password.
return accountAddOut{}, fmt.Errorf("%w: %s", ops.ErrInvalid, scrub(err, in.Password))
}
return accountAddOut{Name: in.Name, Admin: in.Admin}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[accountPasswdIn, accountNameOut]{
Name: "account.passwd",
Doc: "Set an account's password.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/accounts/passwd",
Run: func(ctx context.Context, in accountPasswdIn) (accountNameOut, error) {
if d.Accounts == nil {
return accountNameOut{}, errNoAccounts()
}
if err := d.Accounts.SetPassword(in.Name, in.Password); err != nil {
return accountNameOut{}, fmt.Errorf("%w: %s", ops.ErrInvalid, scrub(err, in.Password))
}
return accountNameOut{Name: in.Name}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[accountDeleteIn, accountNameOut]{
Name: "account.delete",
Doc: "Delete an account and all of its grants.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/accounts/delete",
Run: func(ctx context.Context, in accountDeleteIn) (accountNameOut, error) {
if d.Accounts == nil {
return accountNameOut{}, errNoAccounts()
}
if err := d.Accounts.DeleteAccount(in.Name); err != nil {
return accountNameOut{}, err
}
return accountNameOut{Name: in.Name}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[accountGrantIn, accountGrantOut]{
Name: "account.grant",
Doc: "Grant an account read, write or admin on one repository.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/accounts/grant",
Run: func(ctx context.Context, in accountGrantIn) (accountGrantOut, error) {
if d.Accounts == nil {
return accountGrantOut{}, errNoAccounts()
}
// The repository must be one quarry serves. Granting against
// a name that resolves to nothing produces a row that looks
// like access and grants none.
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return accountGrantOut{}, err
}
if err := d.Accounts.Grant(in.Name, in.Repo, in.Role); err != nil {
return accountGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
return accountGrantOut{Name: in.Name, Repo: in.Repo, Role: in.Role}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[accountRevokeIn, accountGrantOut]{
Name: "account.revoke",
Doc: "Remove an account's grant on one repository.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/accounts/revoke",
Run: func(ctx context.Context, in accountRevokeIn) (accountGrantOut, error) {
if d.Accounts == nil {
return accountGrantOut{}, errNoAccounts()
}
if err := d.Accounts.Revoke(in.Name, in.Repo); err != nil {
return accountGrantOut{}, err
}
return accountGrantOut{Name: in.Name, Repo: in.Repo}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[repoGetIn, repoInfo]{
Name: "repo.get",
Doc: "Describe one repository: its settings and headline counts.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/info",
Run: func(ctx context.Context, in repoGetIn) (repoInfo, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return repoInfo{}, err
}
info := repoInfo{Name: in.Repo, FossilClone: repo.FossilClone}
if d.Descriptions != nil {
info.Description = d.Descriptions()[in.Repo]
}
if d.Private != nil {
info.Private = d.Private()[in.Repo]
}
if d.Deletable != nil {
info.Deletable = d.Deletable(in.Repo)
}
if repo.Indexing != nil {
info.Indexing = repo.Indexing()
}
if ix := repo.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
}
if last, ok, err := ix.LastEvent(); err == nil && ok {
info.LastActive = last
}
}
return info, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[repoAddIn, repoAddOut]{
Name: "repo.add",
Doc: "Serve a repository: an existing store at `path`, a new empty one (`create`), or a clone of `clone`. Cloning also records a mirror unless `noMirror`.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/repos",
Run: func(ctx context.Context, in repoAddIn) (repoAddOut, error) {
if d.Registry == nil {
return repoAddOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
}
if in.Create && in.Clone != "" {
return repoAddOut{}, fmt.Errorf(
"create and clone are alternatives, not both: %w", ops.ErrInvalid)
}
if in.Clone != "" && (in.Readme || in.License != "" || in.Ignore) {
return repoAddOut{}, fmt.Errorf(
"a cloned repository already has a history to seed: %w", ops.ErrInvalid)
}
spec := RepoSpec{
Name: in.Name, Path: in.Path, Description: in.Description,
Visibility: visibilityOf(in.Private),
Create: in.Create, Clone: in.Clone,
Readme: in.Readme, License: in.License, Ignore: in.Ignore,
Author: ops.User(ctx),
Mirror: in.Clone != "" && !in.NoMirror, MirrorEvery: in.MirrorEvery,
}
path, err := d.Registry.Add(spec)
if err != nil {
return repoAddOut{}, err
}
return repoAddOut{Name: in.Name, Path: path, Mirrored: spec.Mirror}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[repoUpdateIn, repoUpdateOut]{
Name: "repo.update",
Doc: "Set a repository's settings: description and visibility. Fields are applied whole.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/repos/update",
Run: func(ctx context.Context, in repoUpdateIn) (repoUpdateOut, error) {
if d.Registry == nil {
return repoUpdateOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
}
if _, err := d.repoFor(ctx, in.Name); err != nil {
return repoUpdateOut{}, err
}
if err := d.Registry.Update(in.Name, in.Description, visibilityOf(in.Private)); err != nil {
return repoUpdateOut{}, err
}
return repoUpdateOut{Name: in.Name, Description: in.Description, Private: in.Private}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[repoDeleteIn, repoRemoveOut]{
Name: "repo.delete",
Doc: "Destroy a repository: its store, its index and everything keyed to it. `confirm` must equal `name`. Only repositories quarry created can be deleted.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/repos/delete",
Run: func(ctx context.Context, in repoDeleteIn) (repoRemoveOut, error) {
if d.Registry == nil {
return repoRemoveOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
}
if _, err := d.repoFor(ctx, in.Name); err != nil {
return repoRemoveOut{}, err
}
if err := d.Registry.Delete(in.Name, in.Confirm); err != nil {
return repoRemoveOut{}, err
}
return repoRemoveOut{Name: in.Name}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[repoRemoveIn, repoRemoveOut]{
Name: "repo.remove",
Doc: "Stop serving a repository. The store on disk is left untouched.",
Kind: ops.Command,
Auth: ops.Admin,
Route: "POST /api/v1/repos/remove",
Run: func(ctx context.Context, in repoRemoveIn) (repoRemoveOut, error) {
if d.Registry == nil {
return repoRemoveOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
}
if err := d.Registry.Remove(in.Name); err != nil {
return repoRemoveOut{}, err
}
return repoRemoveOut{Name: in.Name}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[repoCountsIn, repoCountsOut]{
Name: "repo.counts",
Doc: "Count a repository's content by section — what the navigation shows.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/counts",
Run: func(ctx context.Context, in repoCountsIn) (repoCountsOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return repoCountsOut{}, err
}
c, err := repo.Index.Counts()
if err != nil {
return repoCountsOut{}, err
}
return repoCountsOut{
Checkins: c.Checkins, WikiPages: c.WikiPages, Tickets: c.Tickets,
Contributors: c.Contributors, Tags: c.Tags,
ForumThreads: c.ForumThreads, Technotes: c.Technotes,
}, 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(ctx context.Context, in timelineIn) (timelineOut, error) {
repo, err := d.repoFor(ctx, 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
}
// Leaf status comes from zit, not from a query over the
// folded rows. quarry's own predicate answered "no child of
// any kind", which the repo-state RFC §7.2 names explicitly
// as NOT the canonical test — the real one is "no child on
// the same branch", and open leaves additionally exclude an
// active closed tag. On fossil the difference is 490 marked
// against a true 1432, and a different set. Asking is ~10ms
// against zit's own derivation cache; reproducing it is
// several RFC sections and a standing risk of drifting into
// showing false data about a store that is entirely correct.
if repo.Leaves == nil {
// No store handle (tests, and any caller that wires an
// index without one): leave every entry unmarked rather
// than marked by a rule zit does not use.
for i := range entries {
entries[i].IsLeaf = false
}
} else if leaves, lerr := repo.Leaves(); lerr == nil {
for i := range entries {
entries[i].IsLeaf = entries[i].Kind == "ci" && leaves[entries[i].Hash]
}
} else {
log.Printf("timeline %s: leaves: %v", in.Repo, lerr)
}
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(ctx context.Context, in wikiListIn) (wikiListOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in ticketListIn) (ticketListOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return ticketListOut{}, err
}
all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before, in.Closed)
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(ctx context.Context, in ticketGetIn) (ticketGetOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in attachmentGetIn) (attachmentGetOut, error) {
repo, err := d.repoFor(ctx, 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(ctx, 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(ctx context.Context, in ciListIn) (ciListOut, error) {
if _, err := d.repoFor(ctx, 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(ctx context.Context, in fileHistoryIn) (fileHistoryOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return fileHistoryOut{}, err
}
version := d.resolveVersion(repo, in.Version)
// 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(ctx, 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[archiveIn, archiveOut]{
Name: "archive.get",
Doc: "Download a check-in's files as a reproducible archive (tar.gz or zip).",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/archive/{version}",
Run: func(ctx context.Context, in archiveIn) (archiveOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return archiveOut{}, err
}
format := in.Format
if format == "" {
format = "tar"
}
version := d.resolveVersion(repo, in.Version)
// Resolve to the check-in a name currently means, so the
// archive's directory and filename say WHICH commit this is
// rather than "trunk", which will mean something else
// tomorrow. It is also the cache key: a branch name is not
// one, because what it points at changes.
short := version
if b, ok, err := repo.Index.TipOf(version); err == nil && ok {
short = b.Tip[:min(12, len(b.Tip))]
version = b.Tip
} else if len(short) > 12 {
short = short[:12]
}
name := in.Repo + "-" + short
suffix := ".tar.gz"
if format == "zip" {
suffix = ".zip"
}
build := func(dst string) error {
return d.Bulk.Do(ctx, func() error {
return repo.Zit.ArchiveTo(ctx, format, version, name, dst, maxArchiveBytes)
})
}
var path string
if d.Archives != nil {
// An archive is a pure function of a resolved check-in
// and a container, so the key needs nothing else and an
// entry is never stale.
path, err = d.Archives.Get(in.Repo+"/"+version+"."+format, build)
} else {
path, err = tempArchive(build)
if path != "" {
defer os.Remove(path)
}
}
if errors.Is(err, work.ErrBusy) {
return archiveOut{}, fmt.Errorf("archive queue is full, retry shortly: %w", ops.ErrUnavailable)
}
if err != nil {
log.Printf("archive %s@%s (%s): %v", in.Repo, version, format, err)
return archiveOut{}, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
}
raw, err := os.ReadFile(path)
if err != nil {
return archiveOut{}, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
}
return archiveOut{Name: name + suffix, Size: len(raw), Version: version,
Content: base64.StdEncoding.EncodeToString(raw)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[tagListIn, tagListOut]{
Name: "tag.list",
Doc: "List release tags — symbolic names that are not branches.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/tag",
Run: func(ctx context.Context, in tagListIn) (tagListOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return tagListOut{}, err
}
tags, err := repo.Index.Tags(in.Limit)
if err != nil {
return tagListOut{}, err
}
return tagListOut{Tags: tags, Count: len(tags)}, 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(ctx context.Context, in branchListIn) (branchListOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in treeIn) (treeOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return treeOut{}, err
}
version := d.resolveVersion(repo, in.Version)
// 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(ctx, 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(ctx, 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[peopleListIn, peopleListOut]{
Name: "people.list",
Doc: "List contributors across every repository the caller may read.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/people",
Run: func(ctx context.Context, in peopleListIn) (peopleListOut, error) {
names := d.Repos.Names()
if in.Repo != "" {
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return peopleListOut{}, err
}
names = []string{in.Repo}
}
// These are the names on artifacts a reader can already
// read, so this exposes nothing new — but a repository they
// may NOT read must not contribute a name or a count, or
// the listing leaks that it exists.
merged := map[string]*person{}
for _, name := range names {
if d.CanRead != nil && !d.CanRead(ctx, name) {
continue
}
repo, err := d.repo(name)
if err != nil {
continue
}
people, err := repo.Index.Contributors(0)
if err != nil {
log.Printf("people.list %s: %v", name, err)
continue
}
for _, c := range people {
p := merged[c.User]
if p == nil {
p = &person{User: c.User, First: c.First, Last: c.Last}
merged[c.User] = p
}
p.Events += c.Events
p.Repos = append(p.Repos, name)
if c.First < p.First {
p.First = c.First
}
if c.Last > p.Last {
p.Last = c.Last
}
}
}
needle := strings.ToLower(in.Q)
out := peopleListOut{}
for _, p := range merged {
if needle != "" && !strings.Contains(strings.ToLower(p.User), needle) {
continue
}
sort.Strings(p.Repos)
out.People = append(out.People, *p)
}
out.Total = len(out.People)
sort.Slice(out.People, func(i, j int) bool {
if out.People[i].Events != out.People[j].Events {
return out.People[i].Events > out.People[j].Events
}
return out.People[i].User < out.People[j].User
})
if in.Offset > 0 {
if in.Offset >= len(out.People) {
out.People = nil
} else {
out.People = out.People[in.Offset:]
}
}
if in.Limit > 0 && len(out.People) > in.Limit {
out.People = out.People[:in.Limit]
}
out.Count = len(out.People)
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[searchAllIn, searchAllOut]{
Name: "search.all",
Doc: "Full-text search across every repository the caller may read.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/search",
Run: func(ctx context.Context, in searchAllIn) (searchAllOut, error) {
names := d.Repos.Names()
if in.Repo != "" {
// A named repository still goes through repoFor, so an
// unreadable one reports NOT FOUND rather than being
// silently dropped to an empty result.
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return searchAllOut{}, err
}
names = []string{in.Repo}
}
// Fan out concurrently. Each index is its own SQLite file
// and every query is a read, so there is nothing to
// serialize; the bound is only to stop a fleet of thousands
// opening thousands of queries at once.
type answer struct {
repo string
hits []index.SearchResult
total int
}
sem := make(chan struct{}, searchFanout)
results := make([]answer, len(names))
var wg sync.WaitGroup
for i, name := range names {
if d.CanRead != nil && !d.CanRead(ctx, name) {
// Never consulted, never counted: a fleet-wide
// search must not reveal that a private repository
// exists, and a hit count that moves with it would.
continue
}
repo, err := d.repo(name)
if err != nil {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
// Each repo is asked for the full limit: the top
// results overall may all come from one repository,
// so trimming per repo would lose them.
hits, err := repo.Index.Search(in.Q, in.Kind, in.Limit)
if err != nil {
log.Printf("search.all %s: %v", name, err)
return
}
// The true count, not len(hits): a facet capped at
// the page size reads the same for every repository
// and so says nothing about where to look.
total, err := repo.Index.SearchCount(in.Q, in.Kind)
if err != nil {
log.Printf("search.all count %s: %v", name, err)
total = len(hits)
}
results[i] = answer{repo: name, hits: hits, total: total}
}()
}
wg.Wait()
var out searchAllOut
for _, a := range results {
if a.repo == "" {
continue
}
out.Searched++
if a.total > 0 {
out.ByRepo = append(out.ByRepo, repoCount{Repo: a.repo, Count: a.total})
}
for _, h := range a.hits {
out.Results = append(out.Results, searchAllHit{Repo: a.repo, SearchResult: h})
}
}
// Most matches first, so the facet list leads with where the
// answer probably is; name breaks ties so it is stable.
sort.Slice(out.ByRepo, func(i, j int) bool {
if out.ByRepo[i].Count != out.ByRepo[j].Count {
return out.ByRepo[i].Count > out.ByRepo[j].Count
}
return out.ByRepo[i].Repo < out.ByRepo[j].Repo
})
// Each index ranks against its OWN corpus statistics, so
// bm25 scores from different repositories are comparable
// only approximately. Rank is already baked into each
// repository's ordering, so the merge preserves it and
// breaks ties on repo and ref to stay deterministic —
// honest about being an interleave rather than a true
// global ranking, which would need fleet-wide term
// frequencies this deliberately does not keep.
sort.SliceStable(out.Results, func(i, j int) bool {
a, b := out.Results[i], out.Results[j]
if a.Rank != b.Rank {
return a.Rank < b.Rank
}
if a.Repo != b.Repo {
return a.Repo < b.Repo
}
return a.Ref < b.Ref
})
if in.Limit > 0 && len(out.Results) > in.Limit {
out.Results = out.Results[:in.Limit]
}
out.Count = len(out.Results)
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(ctx context.Context, in searchIn) (searchOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return searchOut{}, err
}
results, err := repo.Index.Search(in.Q, in.Kind, 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(ctx context.Context, in diffGetIn) (diffGetOut, error) {
repo, err := d.repoFor(ctx, 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(ctx, 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(ctx, 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(ctx context.Context, in fileGetIn) (fileGetOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return fileGetOut{}, err
}
version := d.resolveVersion(repo, in.Hash)
raw, err := repo.Zit.Cat(ctx, 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(ctx context.Context, in checkinGetIn) (checkinGetOut, error) {
repo, err := d.repoFor(ctx, 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(ctx, 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(ctx context.Context, in wikiHistoryIn) (wikiHistoryOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in wikiGetIn) (wikiGetOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in technoteListIn) (technoteListOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in technoteGetIn) (technoteGetOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in forumListIn) (forumListOut, error) {
repo, err := d.repoFor(ctx, 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(ctx context.Context, in forumGetIn) (forumGetOut, error) {
repo, err := d.repoFor(ctx, 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.repoFor(ctx, 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 = authoredMimetype
}
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.repoFor(ctx, 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.repoFor(ctx, in.Repo)
if err != nil {
return technoteAddOut{}, err
}
mt := in.Mimetype
if mt == "" {
mt = authoredMimetype
}
// 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(ctx context.Context, in chatHistoryIn) (chatHistoryOut, error) {
repo, err := d.repoFor(ctx, 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.repoFor(ctx, 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 authoredMimetype
}
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)
// The set fossil's browse.c recognizes, minus readme.html — rendering
// repository HTML is its own decision, tracked separately.
for _, stem := range []string{"readme.md", "readme.markdown", "readme.wiki", "readme.txt", "readme"} {
if lower == stem {
return true
}
}
return false
}
// mimetypeOf maps a filename to the markup dispatch renderBody expects.
// Markup is chosen by EXTENSION, and a name without a recognized one is
// plain text. An earlier version of this guessed markdown for a bare
// README "by convention", which is a GitHub-era convention that predates
// nothing in a Fossil corpus: libspatialite's README is an autotools
// plain-text file whose shell commands are prefixed with the root prompt
// `#`, and markdown reads `# sudo make install` as a heading. Rendering
// it as markdown turned its build instructions into a dozen <h2>s and
// collapsed every hard line break in the command listings.
//
// This is fossil's rule (wiki_render_by_mimetype): known markup types
// render, everything else falls through to escaped <pre class=textPlain>
// with its line structure intact. GitHub does the same — a bare README
// is served as plain text there too.
func mimetypeOf(name string) string {
switch {
case hasExt(name, ".md", ".markdown"):
return "text/x-markdown"
case hasExt(name, ".wiki"):
return "text/x-fossil-wiki"
case hasExt(name, ".pikchr"):
return "text/x-pikchr"
default:
return "text/plain"
}
}
func hasExt(name string, exts ...string) bool {
lower := strings.ToLower(name)
for _, e := range exts {
if strings.HasSuffix(lower, e) {
return true
}
}
return false
}
// maxArchiveBytes caps an archive quarry will build. zit writes the
// tarball to a file, so quarry buffers it to answer — this is the memory
// one anonymous request can ask for. A tree beyond it is refused rather
// than half-served.
// searchFanout bounds how many repository indexes a fleet-wide search
// queries at once. Each is a read against its own SQLite file, so the
// limit is about file handles and scheduler pressure rather than
// contention: at 32 repositories the whole fan-out is tens of
// milliseconds and this never engages.
// authoredMimetype is what quarry records for text a person typed into
// one of its own forms. Every authoring box in the web UI is labelled
// Markdown and the API documents the same, so the artifact must say so:
// fossil dispatches on this field and defaults to its own wiki markup
// when it is absent.
const authoredMimetype = "text/x-markdown"
const searchFanout = 16
const maxArchiveBytes = 256 << 20
// tempArchive builds into a throwaway file for the no-cache case, so the
// build function has one shape whether or not a cache is configured.
func tempArchive(build func(dst string) error) (string, error) {
f, err := os.CreateTemp("", "quarry-archive-*")
if err != nil {
return "", err
}
path := f.Name()
f.Close()
if err := build(path); err != nil {
os.Remove(path)
return "", err
}
return path, nil
}