Skip to content
quarry Artifact [c087445013]
Login

Artifact [c087445013]

Artifact c0874450139fffd8a3e1f5a35b1e182a6fa2a87ed196716cd2a9f4a3ae7e572e:


// companion.go is RFC 0015 Phase 1: a repository's forum kept in a
// separate fossil-compatible store, bound to its parent by the forge.
//
// The fossil project is the shape this serves first: its source is one
// repository and its forum lives at fossil-scm.org/forum as another.
// Mirror both, bind them, and /r/fossil/forum reads the forum store
// while grants, notifications and search stay on fossil.
package api

import (
	"context"
	"fmt"

	"craftdesign.group/quarry/internal/ops"
)

// CompanionStore is the binding table (RFC 0015): which store holds a
// repository's forum. nil means this forge keeps no bindings, which is
// a forge without an OLTP.
type CompanionStore interface {
	Companion(repo, feature string) (store string, ok bool, err error)
	CompanionParent(store string) (repo, feature string, ok bool, err error)
	BindCompanion(repo, feature, store string) error
}

// featureForum is the one feature RFC 0015 defines a companion for.
const featureForum = "forum"

// forumCompanion answers which store holds repo's forum, or "" when the
// forum is in the repository itself.
func (d Deps) forumCompanion(repo string) string {
	if d.Companions == nil {
		return ""
	}
	store, ok, err := d.Companions.Companion(repo, featureForum)
	if err != nil || !ok {
		return ""
	}
	return store
}

// companionParent answers whose companion a store is, or "" when it is
// a repository in its own right.
func (d Deps) companionParent(store string) string {
	if d.Companions == nil {
		return ""
	}
	repo, _, ok, err := d.Companions.CompanionParent(store)
	if err != nil || !ok {
		return ""
	}
	return repo
}

// forumRepoFor is repoFor for the forum ops: the reader is checked
// against the PARENT — its visibility, its letters, its not-found
// answer for a name they may not see — and the store handed back is
// whichever one holds the forum. Every forum op resolves through here
// and nothing else does, so the companion's name never reaches a gate.
func (d Deps) forumRepoFor(ctx context.Context, name string) (*Repo, error) {
	parent, err := d.repoFor(ctx, name)
	if err != nil {
		return nil, err
	}
	store := d.forumCompanion(name)
	if store == "" {
		return parent, nil
	}
	repo, err := d.repo(store)
	if err != nil {
		// The binding names a store this node does not serve. That is
		// an operator's problem, worded as one, never as "no forum".
		return nil, fmt.Errorf("%s's forum is bound to %q, which this node does not serve: %w", name, store, err)
	}
	return repo, nil
}

type forumBindIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Store names the companion: an existing store with no check-ins,
	// bound to nobody. Nothing is inferred from names; an admin says.
	Store string `json:"store" validate:"required,max=64"`
}

type forumBindOut struct {
	Repo  string `json:"repo"`
	Store string `json:"store"`
}

// registerCompanions mounts forum.bind (RFC 0015 Phase 1). Split and
// fold are Phase 2.
func registerCompanions(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[forumBindIn, forumBindOut]{
		Name:  "forum.bind",
		Caps:  "a",
		Doc:   "Bind an existing store as this repository's forum (RFC 0015). The store must hold no history beyond fossil's initial empty check-in and be nobody's companion; from then on /r/{repo}/forum reads and writes there, while grants, notifications and search stay on the repository. Mirror a fossil project and its forum repository, then bind them, and the pair works as upstream serves it.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/r/{repo}/forum/bind",
		Run: func(ctx context.Context, in forumBindIn) (forumBindOut, error) {
			if d.Companions == nil {
				return forumBindOut{}, fmt.Errorf("this forge keeps no companion bindings: %w", ops.ErrInvalid)
			}
			if in.Store == in.Repo {
				return forumBindOut{}, fmt.Errorf("a repository cannot be its own forum: %w", ops.ErrInvalid)
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return forumBindOut{}, err
			}
			if d.companionParent(in.Repo) != "" {
				return forumBindOut{}, fmt.Errorf("%s is itself a companion and cannot have one: %w", in.Repo, ops.ErrInvalid)
			}
			if have := d.forumCompanion(in.Repo); have != "" {
				if have == in.Store {
					return forumBindOut{Repo: in.Repo, Store: in.Store}, nil
				}
				return forumBindOut{}, fmt.Errorf("%s's forum is already bound to %s: %w", in.Repo, have, ops.ErrInvalid)
			}
			store, err := d.repo(in.Store)
			if err != nil {
				return forumBindOut{}, err
			}
			if parent := d.companionParent(in.Store); parent != "" {
				return forumBindOut{}, fmt.Errorf("%s is already %s's forum: %w", in.Store, parent, ops.ErrInvalid)
			}
			if d.forumCompanion(in.Store) != "" {
				return forumBindOut{}, fmt.Errorf("%s has a forum of its own and cannot be one: %w", in.Store, ops.ErrInvalid)
			}
			// A store with history is a project, not a forum. Every
			// fossil repository is born with one "initial empty
			// check-in" — the forum at fossil-scm.org has exactly that
			// and 3,550 threads — so one check-in is not history and
			// two is. The index is the cheap way to ask; a store without
			// one cannot answer and is refused rather than assumed.
			if store.Index == nil {
				return forumBindOut{}, fmt.Errorf("%s is not indexed yet; bind it once it is: %w", in.Store, ops.ErrUnavailable)
			}
			n, err := store.Index.CheckinCount()
			if err != nil {
				return forumBindOut{}, err
			}
			if n > 1 {
				return forumBindOut{}, fmt.Errorf("%s holds %d check-ins; a forum store holds nothing beyond fossil's initial empty one: %w", in.Store, n, ops.ErrInvalid)
			}
			if err := d.Companions.BindCompanion(in.Repo, featureForum, in.Store); err != nil {
				return forumBindOut{}, err
			}
			return forumBindOut{Repo: in.Repo, Store: in.Store}, nil
		},
	})
}

// companionForumThreads counts the threads in a repository's companion,
// or zero when it has none.
//
// Best-effort and never an error: a count is decoration on a tab, and a
// binding to a store this node does not serve is an operator's problem
// that forumRepoFor words properly at the moment somebody opens the
// forum. Failing the whole counts op — which every repository page
// calls — would turn one misconfigured binding into a broken nav on a
// page that is otherwise fine.
func (d Deps) companionForumThreads(ctx context.Context, name string) int {
	store := d.forumCompanion(name)
	if store == "" {
		return 0
	}
	repo, err := d.repo(store)
	if err != nil || repo == nil || repo.Index == nil {
		return 0
	}
	c, err := repo.Index.Counts(seesPrivate(ctx))
	if err != nil {
		return 0
	}
	return c.ForumThreads
}