// 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"
"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)
// 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.
func author(ctx context.Context) string {
if u := ops.User(ctx); u != "" {
return u
}
return "anonymous"
}
// Deps carries what op implementations close over. Repos may be empty
// (`quarry spec` runs storeless); ops answer unknown repos with
// not-found, and the spec stays complete either way.
type Deps struct {
Version string
Repos *Repos
Chat ChatPlane
Notify NotifyPlane
Hooks WebhookPlane
// CI is the run record; nil when no engine is configured, which is
// how a forge without CI simply has no CI pages.
CI CIRuns
// CanRead gates reading a repository at all. nil means every
// repository is public, which is v1's behaviour and what tests want.
//
// It takes a context because the answer depends on WHO is asking,
// and the identity rides there. Returning false must make the
// repository indistinguishable from one that does not exist — see
// Deps.repoFor.
CanRead func(ctx context.Context, repo string) bool
// Accounts is forge identity: who exists and what they may do. nil
// disables the account ops, which is how a forge with no accounts
// configured simply has no account surface.
Accounts AccountStore
// Private reports which repositories are private, for the listing's
// badge. Separate from CanRead, which answers a different question:
// this is "is it marked private", that is "may you see it".
Private func() map[string]bool
// MirrorFreshness reports each mirrored repository's last successful
// pull; absent means not mirrored.
MirrorFreshness func() map[string]string
// Deletable reports which repositories this node created and may
// therefore destroy.
Deletable func(repo string) bool
// Descriptions supplies each repository's one-line summary for the
// listing; nil simply renders none.
Descriptions func() map[string]string
// 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"`
}
// 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 {
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"`
}
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"`
}
type forumListOut struct {
Threads []index.ForumThread `json:"threads"`
Count int `json:"count"`
}
type forumGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Root string `json:"root" validate:"required,min=4,max=64"`
}
type forumRendered struct {
Hash string `json:"hash"`
Parent string `json:"parent"`
Date string `json:"date"`
User string `json:"user"`
HTML string `json:"html"`
}
type forumGetOut struct {
Root string `json:"root"`
Title string `json:"title"`
Posts []forumRendered `json:"posts"`
}
type forumPostIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Title string `json:"title" validate:"required,max=200"`
Body string `json:"body" validate:"required,max=1048576"`
// Mimetype is the N card. Empty omits it, which Fossil reads as
// text/x-fossil-wiki — the same dispatch the read side applies.
Mimetype string `json:"mimetype" validate:"max=64"`
}
type forumPostOut struct {
Hash string `json:"hash"`
}
type forumReplyIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Root names the thread being answered — the same path parameter
// forum.get takes, so the two share one consistent template.
Root string `json:"root" validate:"required,min=4,max=64"`
Body string `json:"body" validate:"required,max=1048576"`
Mimetype string `json:"mimetype" validate:"max=64"`
}
type forumReplyOut struct {
Hash string `json:"hash"`
}
type technoteAddIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Body string `json:"body" validate:"required,max=1048576"`
Mimetype string `json:"mimetype" validate:"max=64"`
// Comment is the C card, which a timeline renders as the note's
// title. It is write-once at creation: zit refuses to attach the
// card later, so a note saved without one shows its event id
// forever.
Comment string `json:"comment" validate:"max=200"`
// Event reuses an existing technote's id to record an edit; empty
// mints a new technote.
Event string `json:"event" validate:"max=64"`
}
type technoteAddOut struct {
Event string `json:"event"`
Hash string `json:"hash"`
}
type chatHistoryIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Limit int `json:"limit" validate:"min=0,max=500,default=100"`
}
type chatHistoryOut struct {
Messages []chat.Message `json:"messages"`
Count int `json:"count"`
}
type chatPostIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Text string `json:"text" validate:"required,max=4096"`
}
type chatPostOut struct {
OK bool `json:"ok"`
}
type wikiHistoryOut struct {
Title string `json:"title"`
Edits []index.WikiRev `json:"edits"`
Count int `json:"count"`
}
type fileGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Hash string `json:"hash" validate:"required,min=4,max=64"`
Path string `json:"path" validate:"required,max=512"`
}
type fileGetOut struct {
Hash string `json:"hash"`
Path string `json:"path"`
Size int `json:"size"`
Binary bool `json:"binary"`
Content string `json:"content"`
HTML string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched
}
type attachmentGetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Hash is the attachment's CONTENT artifact, not the A-card
// artifact — the A card names what to serve.
Hash string `json:"hash" validate:"required,min=4,max=64"`
}
type attachmentGetOut struct {
Name string `json:"name"`
Size int `json:"size"`
// Content is base64: an attachment is arbitrary bytes and JSON has
// no binary type. The HTML route decodes and streams it as a
// download; the API answer stays a normal JSON document.
Content string `json:"content"`
}
// CIRuns is the forge-side record of launched pipelines. quarry does
// not model runs — dagnats owns them — this is only the mapping from a
// run back to the check-in it was for.
type CIRuns interface {
CIRuns(repo string, limit int) ([]CIRun, error)
CIRunsFor(repo, checkin string) ([]CIRun, error)
}
// CIRun is one launched pipeline, as the display needs it.
type CIRun struct {
RunID string `json:"runId"`
Checkin string `json:"checkin"`
Workflow string `json:"workflow"`
Pipeline string `json:"pipeline"`
Status string `json:"status"`
Started string `json:"started"`
}
type ciListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
}
type ciListOut struct {
Runs []CIRun `json:"runs"`
Count int `json:"count"`
}
type fileHistoryIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Version string `json:"version" validate:"max=100"`
Path string `json:"path" validate:"required,max=1024"`
Limit int `json:"limit" validate:"min=0,max=200,default=50"`
}
type fileHistoryOut struct {
Path string `json:"path"`
Version string `json:"version"`
Changes []zitcli.FileChange `json:"changes"`
Count int `json:"count"`
}
type archiveIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Version string `json:"version" validate:"max=100"`
// Format is "tar" (gzip tar, the default) or "zip". zit builds both
// with identical selection and the same reproducibility guarantee,
// so which container a reader gets is purely their preference.
Format string `json:"format" validate:"oneof=tar|zip"`
}
type archiveOut struct {
Name string `json:"name"`
Size int `json:"size"`
// Version is the check-in this archive was cut from, always a full
// hash even when the request named a branch. It is what makes the
// bytes addressable: the same value always names the same archive,
// which is what the caching downstream of this relies on.
Version string `json:"version"`
// Content is base64: an archive is bytes and JSON has no binary
// type. The HTML route decodes and offers it as a download.
Content string `json:"content"`
}
type branchListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type branchListOut struct {
Branches []index.Branch `json:"branches"`
Count int `json:"count"`
}
type treeIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Version is any name zit resolves — a branch, a tag, "trunk", or a
// hash. Empty means the default branch's tip.
Version string `json:"version" validate:"max=100"`
// Path is the directory being listed; empty is the root.
Path string `json:"path" validate:"max=1024"`
}
// treeEntry is one row of a directory listing. Dir entries are synthesised
// from the flat path set zit returns — a store has no directory objects,
// so a "directory" is a shared prefix and nothing more.
type treeEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Dir bool `json:"dir"`
Blob string `json:"blob,omitempty"`
Files int `json:"files,omitempty"` // for a directory: what it contains, recursively
Size int64 `json:"size,omitempty"`
// Exec and Link come from the F card's permission (zit#96). A link's
// blob content is its TARGET, so a viewer must not render it as file
// content.
Exec bool `json:"exec,omitempty"`
Link bool `json:"link,omitempty"`
Binary bool `json:"binary,omitempty"`
// 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"`
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"`
}
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
}
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"`
}
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
}
return repoCountsOut{
Checkins: c.Checkins, WikiPages: c.WikiPages, Tickets: c.Tickets,
Contributors: c.Contributors, Tags: c.Tags,
ForumThreads: c.ForumThreads, Technotes: c.Technotes,
}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{
Name: "timeline.list",
Doc: "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/timeline",
Run: func(ctx context.Context, in timelineIn) (timelineOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return timelineOut{}, err
}
entries, more, err := repo.Index.Timeline(in.Limit, in.Before, in.Branch, in.Y)
if err != nil {
return timelineOut{}, err
}
// Leaf status comes from zit, not from a query over the
// folded rows. quarry's own predicate answered "no child of
// any kind", which the repo-state RFC §7.2 names explicitly
// as NOT the canonical test — the real one is "no child on
// the same branch", and open leaves additionally exclude an
// active closed tag. On fossil the difference is 490 marked
// against a true 1432, and a different set. Asking is ~10ms
// against zit's own derivation cache; reproducing it is
// several RFC sections and a standing risk of drifting into
// showing false data about a store that is entirely correct.
if repo.Leaves == nil {
// No store handle (tests, and any caller that wires an
// index without one): leave every entry unmarked rather
// than marked by a rule zit does not use.
for i := range entries {
entries[i].IsLeaf = false
}
} else if leaves, lerr := repo.Leaves(); lerr == nil {
for i := range entries {
entries[i].IsLeaf = entries[i].Kind == "ci" && leaves[entries[i].Hash]
}
} else {
log.Printf("timeline %s: leaves: %v", in.Repo, lerr)
}
out := timelineOut{Entries: entries, Count: len(entries)}
if more && len(entries) > 0 {
last := entries[len(entries)-1]
out.Next = last.Date + "~" + last.Hash
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{
Name: "wiki.list",
Doc: "List a repository's wiki pages with their latest edit.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/wiki",
Run: func(ctx context.Context, in wikiListIn) (wikiListOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return wikiListOut{}, err
}
pages, err := repo.Index.WikiTitles()
if err != nil {
return wikiListOut{}, err
}
return wikiListOut{Pages: pages, Count: len(pages)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[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, ok, terr := repo.Index.TipOf("trunk"); terr == nil && ok {
version = tip.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, ok, terr := repo.Index.TipOf("trunk"); terr == nil && ok {
version = tip.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
}
version := d.resolveVersion(repo, in.Version)
// Resolve a moving name to the check-in it currently means.
// Two reasons: the listing is then cacheable (a hash cannot
// move, "trunk" can), and the page's links carry the exact
// check-in a reader was looking at rather than whatever is
// newest by the time they click.
if b, ok, err := repo.Index.TipOf(version); err == nil && ok {
version = b.Tip
}
prefix := strings.Trim(in.Path, "/")
// --under asks zit for this subtree only, so drawing one
// directory no longer materialises every path in the
// repository (zit#96).
files, err := repo.Zit.LsUnder(ctx, version, prefix)
if err != nil {
return treeOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err)
}
if prefix != "" {
prefix += "/"
}
// A store keeps no directories, only paths. One level of the
// tree is therefore the distinct next segment of every path
// under this prefix — files carry their blob, directories
// carry how much lives beneath them.
dirs := map[string]int{}
var entries []treeEntry
for _, f := range files {
if !strings.HasPrefix(f.Name, prefix) {
continue
}
rest := f.Name[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
dirs[rest[:i]]++
continue
}
entries = append(entries, treeEntry{
Name: rest, Path: prefix + rest, Blob: f.Blob,
Size: f.Size, Exec: f.Executable(), Link: f.Symlink(),
Binary: f.Binary,
})
}
for name, n := range dirs {
entries = append(entries, treeEntry{
Name: name, Path: prefix + name, Dir: true, Files: n,
})
}
// Directories first, then names — the ordering every file
// browser uses, because a tree read alphabetically with files
// interleaved is hard to scan.
sort.Slice(entries, func(i, j int) bool {
if entries[i].Dir != entries[j].Dir {
return entries[i].Dir
}
return entries[i].Name < entries[j].Name
})
// 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, 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)
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,
}
// 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
}
return forumListOut{Threads: threads, Count: len(threads)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[forumGetIn, forumGetOut]{
Name: "forum.get",
Doc: "Fetch one forum thread: every post, rendered, in reading order.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/forum/{root}",
Run: func(ctx context.Context, in forumGetIn) (forumGetOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return forumGetOut{}, err
}
title, posts, ok, err := repo.Index.ForumThreadPosts(in.Root)
if err != nil {
return forumGetOut{}, err
}
if !ok {
return forumGetOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound)
}
out := forumGetOut{Root: in.Root, Title: title}
for _, p := range posts {
// Dispatch on the post's own N card (absent = Fossil wiki,
// matching fossil's forum_render).
html, err := renderBody(repo.Index, in.Repo, p.Mimetype, []byte(p.Body))
if err != nil {
return forumGetOut{}, err
}
out.Posts = append(out.Posts, forumRendered{
Hash: p.Hash, Parent: p.Parent, Date: p.Date, User: p.User, HTML: html,
})
}
return out, nil
},
}); err != nil {
return err
}
if err := registerAuthoring(r, d); err != nil {
return err
}
if d.Chat != nil {
return registerChat(r, d)
}
return nil
}
// registerAuthoring wires the forum and technote write ops (v2 write
// plane, on zit#76's verbs). Fossil has the same surfaces — technoteedit
// and forumedit — so these are the web faces of record types the format
// already carries, not new inventions.
func registerAuthoring(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[forumPostIn, forumPostOut]{
Name: "forum.post",
Doc: "Open a new forum thread.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/forum",
Run: func(ctx context.Context, in forumPostIn) (forumPostOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return forumPostOut{}, err
}
// Same default as wiki.save and technote.add: quarry's own
// forms are markdown, and an omitted N card would be read
// back as text/x-fossil-wiki (fossil's rule), so a post
// typed as markdown would render as wiki markup.
mt := in.Mimetype
if mt == "" {
mt = authoredMimetype
}
var hash string
if err := repo.write(func() (err error) {
hash, err = repo.Zit.ForumPost(in.Title, author(ctx), mt, []byte(in.Body))
return err
}); err != nil {
return forumPostOut{}, err
}
if err := repo.synced(); err != nil {
return forumPostOut{}, err
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-activity", To: "repo:" + in.Repo,
Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
"summary": "opened forum thread " + in.Title,
"link": "/r/" + in.Repo + "/forum/" + hash[:min(16, len(hash))]},
TxnID: "activity:forum-post:" + hash,
})
return forumPostOut{Hash: hash}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[forumReplyIn, forumReplyOut]{
Name: "forum.reply",
Doc: "Reply to a forum post within its thread.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/forum/{root}",
Run: func(ctx context.Context, in forumReplyIn) (forumReplyOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return forumReplyOut{}, err
}
// The route carries the prefix pages link with; zit wants a
// post hash it can resolve, so widen it through the index.
post := in.Root
if _, posts, ok, perr := repo.Index.ForumThreadPosts(in.Root); perr == nil && ok && len(posts) > 0 {
post = posts[0].Hash // replying to a thread answers its root
}
var hash string
if err := repo.write(func() (err error) {
hash, err = repo.Zit.ForumReply(post, author(ctx), replyMimetype(in.Mimetype), []byte(in.Body))
return err
}); err != nil {
if errors.Is(err, zitcli.ErrNoEntry) {
return forumReplyOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound)
}
return forumReplyOut{}, err
}
if err := repo.synced(); err != nil {
return forumReplyOut{}, err
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-activity", To: "repo:" + in.Repo,
Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
"summary": "replied in a forum thread",
"link": "/r/" + in.Repo + "/forum/" + in.Root},
TxnID: "activity:forum-reply:" + hash,
})
return forumReplyOut{Hash: hash}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[technoteAddIn, technoteAddOut]{
Name: "technote.add",
Doc: "Record a technote; passing an existing event id records an edit.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/technote",
Run: func(ctx context.Context, in technoteAddIn) (technoteAddOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return technoteAddOut{}, err
}
mt := in.Mimetype
if mt == "" {
mt = authoredMimetype
}
// An edit names an existing technote. Pages link event-id
// prefixes, so widen to the full id — passing a prefix would
// mint a NEW technote under that truncated id instead.
event := in.Event
if event != "" {
n, ok, err := repo.Index.TechnoteByEvent(event)
if err != nil {
return technoteAddOut{}, err
}
if !ok {
return technoteAddOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound)
}
event = n.Event
}
var id, hash string
if err := repo.write(func() (err error) {
id, hash, err = repo.Zit.TechnoteAdd(author(ctx), event, mt, in.Comment, []byte(in.Body))
return err
}); err != nil {
return technoteAddOut{}, err
}
if err := repo.synced(); err != nil {
return technoteAddOut{}, err
}
what := "added technote"
if in.Event != "" {
what = "edited technote"
}
d.fireNotify(NotifyEvent{
Workflow: "quarry-activity", To: "repo:" + in.Repo,
Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
"summary": what,
"link": "/r/" + in.Repo + "/technote/" + id[:min(16, len(id))]},
TxnID: "activity:technote:" + hash,
})
return technoteAddOut{Event: id, Hash: hash}, nil
},
})
}
// registerChat wires the per-repo live channel ops (0001 §5). Called
// from RegisterAll only when a chat plane is configured.
func registerChat(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[chatHistoryIn, chatHistoryOut]{
Name: "chat.history",
Doc: "Recent messages in a repository's chat channel, oldest first.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/chat",
Run: func(ctx context.Context, in chatHistoryIn) (chatHistoryOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return chatHistoryOut{}, err
}
limit := in.Limit
if limit <= 0 {
limit = 100
}
msgs, err := d.Chat.History(repo.Code, limit)
if err != nil {
return chatHistoryOut{}, err
}
return chatHistoryOut{Messages: msgs, Count: len(msgs)}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[chatPostIn, chatPostOut]{
Name: "chat.post",
Doc: "Post a message to a repository's chat channel.",
Kind: ops.Command,
// A chat post mutates no store (0001 §5: transport is NATS, not
// the DAG), so it is not lease-gated and stays anonymous-postable
// in v1; the author is the authenticated user when present.
Auth: ops.Anonymous,
Route: "POST /api/v1/r/{repo}/chat",
Run: func(ctx context.Context, in chatPostIn) (chatPostOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return chatPostOut{}, err
}
from := author(ctx)
if err := d.Chat.Post(repo.Code, chat.Message{
Repo: in.Repo, User: from, Text: in.Text,
}); err != nil {
return chatPostOut{}, err
}
// Mentions route to notify (0001 §5), one trigger per handle.
for _, who := range mentions(in.Text) {
d.fireNotify(NotifyEvent{
Workflow: "quarry-mention", To: who,
Payload: map[string]any{
"repo": in.Repo, "from": from, "text": in.Text,
"link": "/r/" + in.Repo + "/chat",
},
TxnID: "mention:" + in.Repo + ":" + who + ":" + from + ":" + in.Text,
})
}
return chatPostOut{OK: true}, nil
},
})
}
// renderBody renders an artifact body: markdown/Fossil-wiki through the
// sanitizer-gated markup pipeline (so [[wiki]] and hash refs resolve),
// anything else as escaped text.
func renderBody(ix *index.Index, repo, mimetype string, body []byte) (string, error) {
// Dispatch exactly as fossil's wiki_render_by_mimetype does: an
// ABSENT mimetype means Fossil wiki markup, not markdown. Treating
// it as markdown silently mangles half of every fossil-born corpus
// (libfossil: 217 of 443 wiki artifacts carry no N card).
res := resolver{ix: ix, repo: repo}
switch mimetype {
case "text/x-markdown", "text/markdown":
rendered, err := markup.Render(body, res)
if err != nil {
return "", err
}
return string(rendered), nil
case "", "text/x-fossil-wiki":
rendered, err := markup.RenderFossilWiki(body, res)
if err != nil {
return "", err
}
return string(rendered), nil
case "text/x-pikchr":
rendered, err := markup.RenderPikchr(body)
if err != nil {
// A diagram that will not compile shows its source and the
// compiler's complaint, never a broken page.
return "<pre>" + html.EscapeString(string(body)) + "</pre>" +
"<p><small>pikchr: " + html.EscapeString(err.Error()) + "</small></p>", nil
}
return string(rendered), nil
default:
// 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
}