Artifact 60c2ce533d16cd038ec350693bdd944e4603e80f50dcd5716dfb128e1dddb0fc:
- File
internal/api/register.go
— part of check-in
[debc1f4c9f]
at
2026-08-21 19:23:18
on branch quarry-forum
— forum anchors: threads mentioning a check-in backlink from its page
Extracted at fold (hex refs with a digit), resolved only at render — the page's own hash is the honesty gate. schemaVersion 3→4 (rebuild). (user: dmestas, size: 156684) [annotate] [blame] [check-ins using]
// Package api owns RegisterAll — the one explicit wiring point where every // op joins the registry (0003 §2: no init() magic; the wiring stays // greppable) — plus the HTTP router that serves the registered ops. This // is the sanctioned place Run closures over the data planes are built; // the arch gate keeps every other consumer on the op side of the seam. package api import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "html" "log" "math" "net/url" "os" "path" "regexp" "sort" "strings" "sync" "time" "unicode/utf8" "craftdesign.group/quarry/internal/archive" "craftdesign.group/quarry/internal/chat" "craftdesign.group/quarry/internal/diff" "craftdesign.group/quarry/internal/errlog" "craftdesign.group/quarry/internal/highlight" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/ingest" "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) // DefaultBranch is the branch a nameless request means, WITH its // tip hash. zit's resolution of "trunk" is authoritative (fossil's // main-branch setting and tag propagation live there), and the tip // must come from the same authority: pinning the name against the // index serves a stale tree whenever the index lags the store — // which is exactly when it matters. Nil or empty falls back to the // index's recency heuristic. DefaultBranch func() (name, tip string) // 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) // Reindex is the full wipe-and-replay (0001 §5) run IN PROCESS, so // an operator repairs a store while the forge serves — the offline // CLI needed the server stopped, which priced a one-repo repair at // a whole-forge outage. nil disables the op (tests, static wiring). Reindex 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"` // Stalled means ingest has stopped retrying on its own schedule // after consecutive failures; RetryAt is the next attempt. The // store is intact and the cursor held — the index lags rather than // lies — but it stops advancing until the cause is fixed. Stalled bool `json:"stalled"` RetryAt string `json:"retryAt,omitempty"` } // 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. // // A stalled pipeline is not a failed WRITE: the artifact is already // durable in the store, and failing the request now would report loss // where there is only lag. The reader may not see their change until // the stall clears, which the fleet page says out loud. func (r *Repo) synced() error { if r.Sync == nil { return nil } _, err := r.Sync() if errors.Is(err, ingest.ErrStalled) { return nil } 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. // hotWindow bounds the ranked set: threads quiet longer than this keep // their recency order below the ranked ones, so ranking stays O(recent) // no matter how deep the archive grows. const hotWindow = 30 * 24 * time.Hour // hotRank is the forum's PUBLISHED ranking — Hacker News' legible decay, // (score + ½·replies) ÷ (age_hours + 2)^1.8 — over the votes we hold. // The forum page prints this formula; a change here changes it there. func hotRank(score, posts int, created string, now time.Time) float64 { t, err := time.Parse("2006-01-02T15:04:05", created[:min(19, len(created))]) if err != nil { return 0 } age := now.Sub(t).Hours() if age < 0 { age = 0 } points := float64(score) + 0.5*float64(posts-1) return points / math.Pow(age+2, 1.8) } // sortForumHot orders recently-active threads by hotRank, leaving the // dormant tail in the recency order it arrived in. func sortForumHot(threads []forumThreadOut, now time.Time) { recent := func(t forumThreadOut) bool { u, err := time.Parse("2006-01-02T15:04:05", t.Updated[:min(19, len(t.Updated))]) return err == nil && now.Sub(u) <= hotWindow } sort.SliceStable(threads, func(i, j int) bool { ri, rj := recent(threads[i]), recent(threads[j]) if ri != rj { return ri } if !ri { return false // dormant tail keeps its recency order } return hotRank(threads[i].Score, threads[i].Posts, threads[i].Created, now) > hotRank(threads[j].Score, threads[j].Posts, threads[j].Created, now) }) } // VoteStore is the forum-vote plane, satisfied by *oltp.DB. Scores are // derived from vote rows on every read — no counter exists to drift. type VoteStore interface { SetForumVote(repo, account, post, thread string, dir int) error ForumPostScores(repo string, posts []string) (map[string]int, error) ForumThreadScores(repo string, threads []string) (map[string]int, error) ForumAccountVotes(repo, account string, posts []string) (map[string]int, error) } 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 // Votes is forum vote state (forge-local, 0001 §8 OLTP — never part // of the DAG or sync). nil disables the vote op and score fields, // which is how a forge without an OLTP simply has no votes. Votes VoteStore // 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 // RepoMeta supplies the settings-held About fields per repository. // Like Descriptions, nil means "this node has no registry". RepoMeta func() map[string]RepoMeta // 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 // Errors is the server's error journal (errlog.Default in the real // wiring); nil reports no errors, which suits tests. Errors *errlog.Ring } // 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"` } // defaultBranchOf prefers zit's answer — name AND tip — falling back // to the index's recency heuristic when the seam is absent (tests, // torn-down repos). An empty tip means "resolve it yourself". func defaultBranchOf(repo *Repo) (name, tip string) { if repo.DefaultBranch != nil { if b, t := repo.DefaultBranch(); b != "" { return b, t } } if repo.Index != nil { name = repo.Index.DefaultBranch() if b, ok, err := repo.Index.TipOf(name); err == nil && ok { tip = b.Tip } return name, tip } return "trunk", "" } // RepoMeta is the About-panel settings a maintainer edits in place. type RepoMeta struct { Homepage string Topics []string } // 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, homepage, topics 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"` // Homepage and Topics are the settings-held About fields, for the // settings form to display and the panel to overlay. Homepage string `json:"homepage,omitempty"` Topics string `json:"topics,omitempty"` // 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"` } // DefaultTicketVocabulary is fossil's default ticket vocabulary // (tktsetup.c), one ordered word list per field. LIST ORDER IS SORT // ORDER — there is no separate weight anywhere; a word earlier in its // list outranks a later one, which is also how fossil's own choice // lists work. // // A repository overrides any of these lists with .quarry/tickets.json // at the tip of trunk (see the ticket.config op). Exported so the web // layer renders something sensible even when the op cannot be asked. var DefaultTicketVocabulary = map[string][]string{ "status": {"Open", "Verified", "Review", "Deferred", "Fixed", "Tested", "Closed"}, "type": {"Code_Defect", "Build_Problem", "Documentation", "Feature_Request", "Incident"}, "severity": {"Critical", "Severe", "Important", "Minor", "Cosmetic"}, "priority": {"Immediate", "High", "Medium", "Low", "Zero"}, "subsystem": {}, "resolution": {"Open", "Fixed", "Rejected", "Workaround", "Unable_To_Reproduce", "Works_As_Designed", "External_Bug", "Not_A_Bug", "Duplicate", "Overcome_By_Events", "Drive_By_Patch", "Misconfiguration"}, } // ticketConfigPath is where a repository keeps its forge configuration: // tracked files in the tree, like .github but for quarry. Versioned // like everything else — editing config is a commit, clones carry it, // and a wipe-and-reingest rebuilds it, so disposability holds. const ticketConfigPath = ".quarry/tickets.json" // repoConfigPath is .quarry's general repo configuration — the second // tenant beside tickets.json: homepage, topics, and the docs tab list. const repoConfigPath = ".quarry/repo.json" type aboutIn struct { Repo string `json:"repo" validate:"required,max=64"` // Doc selects a tab to render; empty renders the first tab. A path // outside the tab set is refused — the tab strip is curated, and // this op must not become a render-any-file endpoint. Doc string `json:"doc" validate:"max=512"` } // aboutDoc is one tab of the landing page's document panel. type aboutDoc struct { Path string `json:"path"` Label string `json:"label"` } type aboutOut struct { // The selected tab, rendered. DocPath is empty when the repo has no // documents at all. DocPath string `json:"docPath,omitempty"` DocLabel string `json:"docLabel,omitempty"` DocHTML string `json:"docHtml,omitempty"` Description string `json:"description,omitempty"` Homepage string `json:"homepage,omitempty"` Topics []string `json:"topics,omitempty"` // Docs is the tab set: .quarry/repo.json's docs list when present // (curation includes removal, so it REPLACES the convention), else // the root-level marker files in conventional order. Docs []aboutDoc `json:"docs,omitempty"` // LatestTag is the newest release tag; empty when the repo has none. LatestTag string `json:"latestTag,omitempty"` LatestTagTip string `json:"latestTagTip,omitempty"` LatestTagDate string `json:"latestTagDate,omitempty"` TagCount int `json:"tagCount,omitempty"` Contributors int `json:"contributors"` // LicenseName is the detected license ("MIT", "Apache-2.0"); empty // when the repo has no license document or the text is not one of // the well-known licenses. LicenseName string `json:"licenseName,omitempty"` Problem string `json:"problem,omitempty"` } // licenseSignatures maps a phrase that appears in exactly one // well-known license text to its SPDX-style name, checked in order. // GitHub's sidebar says "Apache-2.0 license" where a marker file alone // can only say "License"; the phrase test recovers that at zero cost // beyond the memoized file read. No match is not a failure — plenty of // real licenses (tcl's own terms) are bespoke. var licenseSignatures = []struct{ phrase, name string }{ {"Apache License", "Apache-2.0"}, {"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"}, {"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"}, {"GNU LIBRARY GENERAL PUBLIC LICENSE", "LGPL-2.0"}, {"GNU GENERAL PUBLIC LICENSE", "GPL"}, {"Mozilla Public License, v. 2.0", "MPL-2.0"}, {"Mozilla Public License Version 2.0", "MPL-2.0"}, {"Permission to use, copy, modify, and/or distribute this software", "ISC"}, {"Redistributions of source code must retain", "BSD"}, {"Permission is hereby granted, free of charge", "MIT"}, {"This is free and unencumbered software released into the public domain", "Unlicense"}, {"CC0 1.0 Universal", "CC0-1.0"}, } func detectLicense(text string) string { head := text if len(head) > 4096 { head = head[:4096] } for _, sig := range licenseSignatures { if strings.Contains(head, sig.phrase) { return sig.name } } return "" } // parseRepoConfig reads .quarry/repo.json. Unknown fields are ignored // (newer file, older quarry); wrong shapes are an error so a typo does // not silently become "no config". func parseRepoConfig(raw []byte) (homepage string, topics []string, docs []aboutDoc, err error) { var file struct { Homepage string `json:"homepage"` Topics []string `json:"topics"` Docs []aboutDoc `json:"docs"` } if err := json.Unmarshal(raw, &file); err != nil { return "", nil, nil, fmt.Errorf("not a config object: %w", err) } if file.Homepage != "" { u, uerr := url.Parse(file.Homepage) if uerr != nil || (u.Scheme != "http" && u.Scheme != "https") { return "", nil, nil, fmt.Errorf("homepage %q: want an http(s) URL", file.Homepage) } } if len(file.Topics) > 20 { return "", nil, nil, fmt.Errorf("%d topics; the limit is 20", len(file.Topics)) } for i, t := range file.Topics { file.Topics[i] = strings.TrimSpace(t) if len(file.Topics[i]) > 40 { return "", nil, nil, fmt.Errorf("topic %q is longer than 40 bytes", t) } } if len(file.Docs) > 12 { return "", nil, nil, fmt.Errorf("%d docs tabs; the limit is 12", len(file.Docs)) } for i, d := range file.Docs { if d.Path == "" { return "", nil, nil, fmt.Errorf("docs[%d]: a tab needs a path", i) } if file.Docs[i].Label == "" { base := path.Base(d.Path) file.Docs[i].Label = strings.TrimSuffix(base, path.Ext(base)) } } return file.Homepage, file.Topics, file.Docs, nil } // markerDocs finds the conventional root-level documents, in the order // GitHub taught everyone to expect them. Case-insensitive on the stem; // README always leads because it is the default tab. func markerDocs(files []zitcli.LsEntry) []aboutDoc { want := []struct{ stem, label string }{ {"readme", "README"}, {"contributing", "Contributing"}, {"license", "License"}, {"copying", "License"}, {"security", "Security"}, } var out []aboutDoc seen := map[string]bool{} for _, w := range want { if seen[w.label] { continue } for _, f := range files { if strings.Contains(f.Name, "/") { continue // convention discovers at the ROOT only } stem := strings.ToLower(strings.TrimSuffix(f.Name, path.Ext(f.Name))) if stem == w.stem { out = append(out, aboutDoc{Path: f.Name, Label: w.label}) seen[w.label] = true break } } } return out } type meFeedIn struct{} // feedEvent is one row of the signed-in dashboard's feeds: a timeline // entry that knows which repository it came from. type feedEvent struct { Repo string `json:"repo"` Kind string `json:"kind"` Ref string `json:"ref"` Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } type dashRepo struct { Name string `json:"name"` Description string `json:"description,omitempty"` LastActivity string `json:"lastActivity,omitempty"` } type meFeedOut struct { User string `json:"user"` Repos []dashRepo `json:"repos"` // Feed is the merged newest-first activity across the repos above. Feed []feedEvent `json:"feed"` // Mine is the caller's own recorded work. Forge logins and artifact // author names are different namespaces; v1 matches them by string // equality and shows nothing when they differ — honest, and the // mapping is a small follow-up decision. Mine []feedEvent `json:"mine"` } type reindexIn struct { Repo string `json:"repo" validate:"required,max=64"` } type reindexOut struct { Artifacts int `json:"artifacts"` } type ticketConfigIn struct { Repo string `json:"repo" validate:"required,max=64"` } // vocabField is one field's ordered word list. A list rather than a // map: the ops spec has no map kind, and a stable field order is what // a form iterating it wants anyway. type vocabField struct { Field string `json:"field"` Words []string `json:"words"` } // vocabFieldOrder is the order fields are listed in: the sortable axes // first, then the words a form offers but a board does not group by. var vocabFieldOrder = []string{"status", "priority", "severity", "type", "subsystem", "resolution"} type ticketConfigOut struct { // Vocab is the complete merged vocabulary: the file's lists over // the defaults. Always complete — a consumer never needs to know // which side a list came from to use it. Vocab []vocabField `json:"vocab"` // Source says where the words came from: "default", or the config // path when the file contributed. Source string `json:"source"` // Problem is set when a config file exists but could not be used — // the page renders defaults rather than an error, and this is how // an author finds out why their file did nothing. Problem string `json:"problem,omitempty"` } // parseTicketVocab reads a .quarry/tickets.json: an object of field // name to ordered word list. Unknown fields are ignored (a newer file // against an older quarry should not break), unknown SHAPES are an // error (a typo should not silently become "defaults"). Words are // trimmed; empties dropped; oversized lists refused rather than // truncated, because a silently shortened vocabulary would reorder the // tail of every sorted view. func parseTicketVocab(raw []byte) (map[string][]string, error) { var file map[string][]string if err := json.Unmarshal(raw, &file); err != nil { return nil, fmt.Errorf("not an object of field to word list: %w", err) } out := map[string][]string{} for field, words := range file { if _, known := DefaultTicketVocabulary[field]; !known { continue } if len(words) > 50 { return nil, fmt.Errorf("field %q: %d words; the limit is 50", field, len(words)) } var clean []string for _, w := range words { w = strings.TrimSpace(w) if w == "" { continue } if len(w) > 80 { return nil, fmt.Errorf("field %q: word %.20q… is longer than 80 bytes", field, w) } clean = append(clean, w) } out[field] = clean } return out, nil } 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 string `json:"type" validate:"max=40"` Severity string `json:"severity" validate:"max=40"` Priority string `json:"priority" validate:"max=40"` Subsystem string `json:"subsystem" validate:"max=80"` } 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"` // The rest of fossil's default triage vocabulary. Values are not // checked against a list: a tracker's vocabulary is its own, and // refusing a word this build has not heard of would make quarry // unable to edit trackers it can already read. Type string `json:"type" validate:"max=40"` Severity string `json:"severity" validate:"max=40"` Priority string `json:"priority" validate:"max=40"` Subsystem string `json:"subsystem" validate:"max=80"` } 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"` // Errors is the server-error journal: what has answered "internal // error" to a reader, deduplicated, newest first. ErrorTotal counts // occurrences; the rows carry per-signature counts. Errors []errlog.Entry `json:"errors,omitempty"` ErrorTotal int `json:"errorTotal"` } // 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 { // IndexBehind: the index has not folded the store's own tip, so // history-derived numbers and listings on the page may be // incomplete. Surfaces show it; they never silently omit. IndexBehind bool `json:"indexBehind,omitempty"` 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"` // Homepage must be http(s) or empty; Topics is space-separated, // at most twenty — the same rules .quarry/repo.json enforces. Homepage string `json:"homepage" validate:"max=300"` Topics string `json:"topics" validate:"max=600"` } type repoUpdateOut struct { Name string `json:"name"` Description string `json:"description"` Private bool `json:"private"` Homepage string `json:"homepage"` Topics string `json:"topics"` } // 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"` // IndexBehind reports that the index has not folded up to the // store's own default tip — a rebuild in progress. The entries are // real but the recent end is MISSING, and a reader must be told: // a timeline that silently stops three years ago reads as "this // project died", which is a worse lie than "we're rebuilding". IndexBehind bool `json:"indexBehind,omitempty"` } 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"` Deleted bool `json:"deleted,omitempty"` } 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"` // Sort is "hot" for ranked order; anything else is most-recent-first. Sort string `json:"sort" validate:"max=8"` } // forumThreadOut is one listed thread plus its forge-local vote score. // The score lives beside the index row rather than in it: the index is // rebuilt from the DAG and must not carry OLTP state. type forumThreadOut struct { index.ForumThread Score int `json:"score"` } type forumListOut struct { Threads []forumThreadOut `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"` // Score is the post's net vote; YourVote is the calling account's // own vote (-1, 0, 1), so the page can light the cast arrow. Score int `json:"score"` YourVote int `json:"yourVote"` } 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 forumVoteIn struct { Repo string `json:"repo" validate:"required,max=64"` Post string `json:"post" validate:"required,min=4,max=64"` // Dir is the vote: up, down, or none (retract). Dir string `json:"dir" validate:"oneof=up|down|none"` } type forumVoteOut struct { Post string `json:"post"` Score int `json:"score"` } 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"` // The newest check-in that touched this entry — GitHub's middle // column. Empty when the index has no filechange rows yet (a // repository indexed before that fold existed). LastHash string `json:"lastHash,omitempty"` LastDate string `json:"lastDate,omitempty"` LastComment string `json:"lastComment,omitempty"` } type treeOut struct { Version string `json:"version"` // Ref is the branch name this listing answered for — the request's // own, or the repository's default when the request named none. Ref string `json:"ref,omitempty"` 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 resolveIn struct { Repo string `json:"repo" validate:"required,max=64"` Prefix string `json:"prefix" validate:"required,min=4,max=64"` } type resolveOut struct { Kind string `json:"kind"` // ci | t | w | e | f Ref string `json:"ref"` // hash, ticket id, wiki title, event id, thread root } type diffGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` // File restricts Structured to one path. Offset and MaxLines pick a // window of structured lines (MaxLines 0 = to the end); what is cut // is reported per file as before/omitted and in total as // omittedLines, with nextOffset continuing the window. Unified is never // cut — a script reads that, a page reads Structured. File string `json:"file,omitempty" validate:"max=4096"` Offset int `json:"offset,omitempty" validate:"min=0"` MaxLines int `json:"maxLines,omitempty" validate:"min=0"` } type diffGetOut struct { From string `json:"from"` To string `json:"to"` Files []zitcli.DiffEntry `json:"files"` // Structured is the renderable model: files, hunks, numbered lines, // positional syntax tokens, intra-line marks. Unified remains — it // is what a script wants — but stops being the only shape // (Principle 5: the page and the API share one capability). Structured []diff.FileDiff `json:"structured,omitempty"` Unified string `json:"unified"` Note string `json:"note"` // set when the diff could not be fully computed // OmittedLines is how many structured lines follow the window; // NextOffset is the offset that continues from it. OmittedLines int `json:"omittedLines,omitempty"` NextOffset int `json:"nextOffset,omitempty"` TotalLines int `json:"totalLines,omitempty"` // structured lines in the whole (narrowed) diff LastOffset int `json:"lastOffset,omitempty"` // where the final window begins Offset int `json:"offset,omitempty"` // where this window begins (the request's offset, snapped to a hunk) } type checkinGetIn struct { Repo string `json:"repo" validate:"required,max=64"` Hash string `json:"hash" validate:"required,min=4,max=64"` // Files=0 skips the manifest read — the check-in PAGE renders the // diff, not the tree, and was paying a zit ls it threw away. Files string `json:"files" validate:"max=4"` } 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"` // DiscussedIn is the forum threads whose posts reference this // check-in — derived anchors, resolved against this hash at read. DiscussedIn []index.ForumThread `json:"discussedIn,omitempty"` } 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"` Created string `json:"created,omitempty"` Type string `json:"type,omitempty"` Severity string `json:"severity,omitempty"` Priority string `json:"priority,omitempty"` Subsystem string `json:"subsystem,omitempty"` Resolution string `json:"resolution,omitempty"` } 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} for k, v := range map[string]string{ "type": in.Type, "severity": in.Severity, "priority": in.Priority, "subsystem": in.Subsystem, } { if v != "" { fields[k] = v } } 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. The ALL resolver on purpose: // this op is token-gated, and it is the only way a private // ticket can ever be edited — including un-privating it. t, ok, err := repo.Index.TicketByPrefixAll(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, "type": in.Type, "severity": in.Severity, "priority": in.Priority, "subsystem": in.Subsystem, // 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)} if d.Errors != nil { out.Errors = d.Errors.Recent(25) out.ErrorTotal, _, _ = d.Errors.Stats() } 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.RepoMeta != nil { if meta, ok := d.RepoMeta()[in.Repo]; ok { info.Homepage = meta.Homepage info.Topics = strings.Join(meta.Topics, " ") } } 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 in.Homepage != "" && !strings.HasPrefix(in.Homepage, "http://") && !strings.HasPrefix(in.Homepage, "https://") { return repoUpdateOut{}, fmt.Errorf("homepage must be http(s): %w", ops.ErrInvalid) } topics := strings.Fields(in.Topics) if len(topics) > 20 { return repoUpdateOut{}, fmt.Errorf("at most 20 topics: %w", ops.ErrInvalid) } normTopics := strings.Join(topics, " ") if err := d.Registry.Update(in.Name, in.Description, visibilityOf(in.Private), in.Homepage, normTopics); err != nil { return repoUpdateOut{}, err } return repoUpdateOut{Name: in.Name, Description: in.Description, Private: in.Private, Homepage: in.Homepage, Topics: normTopics}, 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 } out := repoCountsOut{ Checkins: c.Checkins, WikiPages: c.WikiPages, Tickets: c.Tickets, Contributors: c.Contributors, Tags: c.Tags, ForumThreads: c.ForumThreads, Technotes: c.Technotes, } // THE staleness signal, computed once where every page // already looks: the store's authoritative tip (zit's) is // not in the index, so anything history-derived on the page // may be incomplete. The invariant is honesty, not // freshness — a page may lag, it may never lie about it. if _, ztip := defaultBranchOf(repo); ztip != "" { if _, _, ok, ierr := repo.Index.CheckinByPrefix(ztip); ierr == nil && !ok { out.IndexBehind = true } } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{ Name: "timeline.list", Doc: "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/timeline", Run: func(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 } behind := false if _, ztip := defaultBranchOf(repo); ztip != "" { if _, _, ok, ierr := repo.Index.CheckinByPrefix(ztip); ierr == nil && !ok { behind = true } } // 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), IndexBehind: behind} 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[reindexIn, reindexOut]{ Name: "repo.reindex", Doc: "Wipe this repository's derived index and replay it from the store, in process, while the forge serves. The sanctioned repair for any index that disagrees with its store.", Kind: ops.Command, Auth: ops.Admin, Route: "POST /api/v1/r/{repo}/reindex", Run: func(ctx context.Context, in reindexIn) (reindexOut, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return reindexOut{}, err } if repo.Reindex == nil { return reindexOut{}, fmt.Errorf("this deployment cannot reindex in process: %w", ops.ErrInvalid) } n, err := repo.Reindex() if err != nil { return reindexOut{}, err } return reindexOut{Artifacts: n}, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[meFeedIn, meFeedOut]{ Name: "me.feed", Doc: "The signed-in dashboard: the caller's granted repositories ordered by activity, the merged activity feed across them, and the caller's own recorded work.", Kind: ops.Query, Auth: ops.Token, Route: "GET /api/v1/me/feed", Run: func(ctx context.Context, _ meFeedIn) (meFeedOut, error) { user := ops.User(ctx) if user == "" || d.Accounts == nil { return meFeedOut{}, fmt.Errorf("no signed-in account: %w", ops.ErrNotFound) } grants, err := d.Accounts.GrantsFor(user) if err != nil { return meFeedOut{}, err } var names []string if _, site := grants[""]; site { // A site admin's forge IS the fleet. names = d.Repos.Names() } else { for repo := range grants { if repo != "" { names = append(names, repo) } } sort.Strings(names) } // A dashboard is a glance, not an audit: bounded per-repo // reads (K grants × one indexed page each), merged in memory. const perRepo, feedCap, mineCap, repoCap = 50, 50, 20, 25 if len(names) > repoCap { names = names[:repoCap] } var desc map[string]string if d.Descriptions != nil { desc = d.Descriptions() } out := meFeedOut{User: user} var all []feedEvent for _, name := range names { repo, ok := d.Repos.Get(name) if !ok || repo.Index == nil { continue } entries, _, terr := repo.Index.Timeline(perRepo, "", "", "") if terr != nil { continue // one broken index must not empty the page } dr := dashRepo{Name: name, Description: desc[name]} if len(entries) > 0 { dr.LastActivity = entries[0].Date } out.Repos = append(out.Repos, dr) for _, e := range entries { all = append(all, feedEvent{Repo: name, Kind: e.Kind, Ref: e.Ref, Hash: e.Hash, Date: e.Date, User: e.User, Comment: e.Comment}) } } sort.SliceStable(out.Repos, func(i, j int) bool { return out.Repos[i].LastActivity > out.Repos[j].LastActivity }) sort.SliceStable(all, func(i, j int) bool { return all[i].Date > all[j].Date }) for _, e := range all { if len(out.Feed) < feedCap { out.Feed = append(out.Feed, e) } if e.User == user && len(out.Mine) < mineCap { out.Mine = append(out.Mine, e) } } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[aboutIn, aboutOut]{ Name: "repo.about", Doc: "The repository's About aggregate: description, homepage and topics (.quarry/repo.json), the landing page's document tabs, the latest release tag, and the contributor count. One op so the panel costs one invoke and the JSON API sees the same panel.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/about", Run: func(ctx context.Context, in aboutIn) (aboutOut, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return aboutOut{}, err } out := aboutOut{} if d.Descriptions != nil { out.Description = d.Descriptions()[in.Repo] } version := "trunk" if repo.Index != nil { if _, tip := defaultBranchOf(repo); tip != "" { version = tip } if tags, terr := repo.Index.Tags(1); terr == nil && len(tags) > 0 { out.LatestTag, out.LatestTagDate = tags[0].Name, tags[0].Date out.LatestTagTip = tags[0].Tip if nn, nerr := repo.Index.TagCount(); nerr == nil { out.TagCount = nn } } if c, cerr := repo.Index.Counts(); cerr == nil { out.Contributors = c.Contributors } } var files []zitcli.LsEntry if repo.Zit != nil { // The memoized tip listing answers both the config read // and the marker scan. if ls, lerr := repo.Zit.Ls(ctx, version); lerr == nil { files = ls } raw, cerr := repo.Zit.Cat(ctx, repoConfigPath, version) switch { case errors.Is(cerr, zitcli.ErrNoEntry): // no file: convention alone case cerr != nil: // a read failure is not a config, and not fatal default: home, topics, docs, perr := parseRepoConfig(raw) if perr != nil { out.Problem = perr.Error() } else { out.Homepage, out.Topics = home, topics out.Docs = docs } } } // Settings are the maintainer's live edit and win over the // in-tree file when set — GitHub's model, with the file as // the versioned alternative for projects that prefer it. if d.RepoMeta != nil { if meta, ok := d.RepoMeta()[in.Repo]; ok { if meta.Homepage != "" { out.Homepage = meta.Homepage } if len(meta.Topics) > 0 { out.Topics = meta.Topics } } } if len(out.Docs) == 0 && out.Problem == "" { out.Docs = markerDocs(files) } if repo.Zit != nil { for _, dc := range out.Docs { if !strings.HasPrefix(strings.ToLower(path.Base(dc.Path)), "license") && !strings.HasPrefix(strings.ToLower(path.Base(dc.Path)), "copying") { continue } if body, berr := repo.Zit.Cat(ctx, dc.Path, version); berr == nil { out.LicenseName = detectLicense(string(body)) } break } } if len(out.Docs) > 0 && repo.Zit != nil { sel := out.Docs[0] if in.Doc != "" { found := false for _, dcc := range out.Docs { if dcc.Path == in.Doc { sel, found = dcc, true break } } if !found { return aboutOut{}, fmt.Errorf("no document tab %q: %w", in.Doc, ops.ErrNotFound) } } if body, berr := repo.Zit.Cat(ctx, sel.Path, version); berr == nil { if html, herr := renderBody(repo.Index, in.Repo, mimetypeOf(sel.Path), body); herr == nil { out.DocPath, out.DocLabel, out.DocHTML = sel.Path, sel.Label, html } } } return out, nil }, }); err != nil { return err } if err := ops.Register(r, ops.Op[ticketConfigIn, ticketConfigOut]{ Name: "ticket.config", Doc: "The repository's ticket vocabulary: .quarry/tickets.json at the tip of trunk, merged over fossil's defaults. List order is sort order.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/ticket-config", Run: func(ctx context.Context, in ticketConfigIn) (ticketConfigOut, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return ticketConfigOut{}, err } merged := map[string][]string{} for k, v := range DefaultTicketVocabulary { merged[k] = v } out := ticketConfigOut{Source: "default"} // Read at trunk's TIP HASH, not at the name "trunk": cat // memoizes immutable versions only, and this runs on every // tickets render — by name it was two zit subprocesses per // page. The index's tip is at worst one poll behind the // store, which for a vocabulary is a shorter delay than a // reader noticing their config commit. version := "trunk" if repo.Index != nil { if _, tip := defaultBranchOf(repo); tip != "" { version = tip } } var raw []byte err = zitcli.ErrNoEntry // no store client reads as "no file" if repo.Zit != nil { raw, err = repo.Zit.Cat(ctx, ticketConfigPath, version) } switch { case errors.Is(err, zitcli.ErrNoEntry): // No file: the defaults ARE the config. case err != nil: return ticketConfigOut{}, err default: if over, perr := parseTicketVocab(raw); perr != nil { // The page renders defaults rather than erroring; // this is how an author learns their file did // nothing. out.Problem = perr.Error() } else { for k, v := range over { merged[k] = v } out.Source = ticketConfigPath } } for _, f := range vocabFieldOrder { out.Vocab = append(out.Vocab, vocabField{Field: f, Words: merged[f]}) } return out, 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, Created: t.Created, Type: t.Type, Severity: t.Severity, Priority: t.Priority, Subsystem: t.Subsystem, Resolution: t.Resolution, }) } 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 } ref := in.Version var version string if ref == "" { // A nameless request means the repository's DEFAULT // branch — which is not always literally "trunk": a // repository that renamed its trunk (tk, tcl) keeps // the old name in the branch table forever. The tip // comes from zit with the name: pinning the name via // the index serves a stale tree whenever the index // lags the store. var tip string ref, tip = defaultBranchOf(repo) version = tip } if version == "" { version = d.resolveVersion(repo, ref) // Resolve a moving name to the check-in it currently // means: the listing is then cacheable (a hash cannot // move, a name can), and the page's links carry the // exact check-in a reader was looking at. 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 }) // The last change per entry: an exact-path indexed lookup per // row (directories have their own fold rows). Restricted to // the viewed branch when the version names one, GitHub's // semantics; a detached hash view goes unfiltered, which can // name a change newer than the viewed check-in — the honest // cost of not walking ancestry per row. if repo.Index != nil { branch := "" if _, ok, berr := repo.Index.TipOf(in.Version); berr == nil && ok { branch = in.Version } for i := range entries { e, ok, lerr := repo.Index.LastChange(entries[i].Path, branch) if lerr != nil || !ok { continue } entries[i].LastHash, entries[i].LastDate = e.Hash, e.Date entries[i].LastComment = e.Comment } } out := treeOut{Version: version, Ref: ref, 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[resolveIn, resolveOut]{ Name: "artifact.resolve", Doc: "Resolve an artifact hash prefix to its kind and canonical reference — fossil's /info dispatch.", Kind: ops.Query, Auth: ops.Anonymous, Route: "GET /api/v1/r/{repo}/info/{prefix}", Run: func(ctx context.Context, in resolveIn) (resolveOut, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return resolveOut{}, err } prefix := strings.ToLower(in.Prefix) for _, c := range prefix { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return resolveOut{}, fmt.Errorf("prefix %q is not hex: %w", in.Prefix, ops.ErrInvalid) } } if e, _, ok, err := repo.Index.CheckinByPrefix(prefix); err != nil { return resolveOut{}, err } else if ok { return resolveOut{Kind: "ci", Ref: e.Hash}, nil } // The reader-facing lookup: a private ticket resolves to // nothing, exactly as it would render as plain text. if t, ok, err := repo.Index.TicketByPrefix(prefix); err != nil { return resolveOut{}, err } else if ok { return resolveOut{Kind: "t", Ref: t.ID}, nil } if kind, ref, ok, err := repo.Index.EventByPrefix(prefix); err != nil { return resolveOut{}, err } else if ok { return resolveOut{Kind: kind, Ref: ref}, nil } return resolveOut{}, fmt.Errorf("no unique artifact for %q: %w", in.Prefix, ops.ErrNotFound) }, }); 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 out.Structured = diff.Parse(unified) if in.File != "" { var one []diff.FileDiff for _, f := range out.Structured { if f.Path == in.File || f.PrevPath == in.File { one = append(one, f) } } out.Structured = one } // Cut before enriching: highlighting and word-marking are // per line, and paying them for lines nobody sees is what // made a large merge a twenty-second page. out.LastOffset = diff.LastWindow(out.Structured, in.MaxLines) start, kept, after := diff.Window(out.Structured, in.Offset, in.MaxLines) out.Offset, out.OmittedLines, out.NextOffset = start, after, start+kept out.TotalLines = start + kept + after diff.Enrich(out.Structured) // 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, } if out.DiscussedIn, err = repo.Index.ThreadsMentioning(e.Hash); err != nil { return checkinGetOut{}, err } // 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 && in.Files != "0" { 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, // An empty NEWEST edit is fossil's deletion. The page // stays fetchable — its history is real — but a reader // must be told this is a tombstone, not a blank page. Deleted: in.Rev == "" && len(page.Body) == 0, }, 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 } out := forumListOut{Count: len(threads)} var scores map[string]int if d.Votes != nil && len(threads) > 0 { roots := make([]string, len(threads)) for i, t := range threads { roots[i] = t.Root } if scores, err = d.Votes.ForumThreadScores(in.Repo, roots); err != nil { return forumListOut{}, err } } for _, t := range threads { out.Threads = append(out.Threads, forumThreadOut{ForumThread: t, Score: scores[t.Root]}) } if in.Sort == "hot" { sortForumHot(out.Threads, time.Now().UTC()) } return out, 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} var scores, mine map[string]int if d.Votes != nil { hashes := make([]string, len(posts)) for i, p := range posts { hashes[i] = p.Hash } if scores, err = d.Votes.ForumPostScores(in.Repo, hashes); err != nil { return forumGetOut{}, err } if mine, err = d.Votes.ForumAccountVotes(in.Repo, ops.User(ctx), hashes); err != nil { return forumGetOut{}, err } } 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, Score: scores[p.Hash], YourVote: mine[p.Hash], }) } 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 } if d.Votes != nil { if err := ops.Register(r, ops.Op[forumVoteIn, forumVoteOut]{ Name: "forum.vote", Doc: "Vote on a forum post: up, down, or none to retract.", Kind: ops.Command, Auth: ops.Token, Route: "POST /api/v1/r/{repo}/forum/{post}/vote", Run: func(ctx context.Context, in forumVoteIn) (forumVoteOut, error) { repo, err := d.repoFor(ctx, in.Repo) if err != nil { return forumVoteOut{}, err } // A vote needs a real identity: the (account, post) key is // the one-vote-per-voter rule, and "anonymous" would pool // every stranger into a single shared vote. account := ops.User(ctx) if account == "" { return forumVoteOut{}, fmt.Errorf("voting needs a signed-in account: %w", ops.ErrInvalid) } post, thread, ok, err := repo.Index.ForumPostThread(in.Post) if err != nil { return forumVoteOut{}, err } if !ok { return forumVoteOut{}, fmt.Errorf("no forum post %q: %w", in.Post, ops.ErrNotFound) } dir := map[string]int{"up": 1, "down": -1, "none": 0}[in.Dir] if err := d.Votes.SetForumVote(in.Repo, account, post, thread, dir); err != nil { return forumVoteOut{}, err } scores, err := d.Votes.ForumPostScores(in.Repo, []string{post}) if err != nil { return forumVoteOut{}, err } return forumVoteOut{Post: post, Score: scores[post]}, 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: // A whole document rendered as plain text is a page, not a code // snippet: the class lets it sit on the page background instead // of in a code box. return `<pre class="plaindoc">` + 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 }