// repo.go stores which repositories the forge serves. 0001 §8 lists the
// repo registry as forge OLTP state alongside accounts and tokens; until
// now it lived only in argv, so adding a repository meant editing the
// server's command line and restarting every other repository with it.
package oltp
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"time"
)
const repoSchema = `
CREATE TABLE IF NOT EXISTS repo (
name TEXT PRIMARY KEY, -- the forge's name, as it appears in URLs
path TEXT NOT NULL, -- directory holding .zit, on this node
description TEXT NOT NULL DEFAULT '',
-- 'public' or 'private'. Public by DEFAULT and on migration: an
-- upgrade must never silently hide what was already being served,
-- and taking a repository private is the deliberate act.
visibility TEXT NOT NULL DEFAULT 'public',
homepage TEXT NOT NULL DEFAULT '',
topics TEXT NOT NULL DEFAULT '', -- space-separated
-- RFC 0009 §verdict-tags: '' follows the fleet default, 'on' and
-- 'off' are this repository's own choice.
ci_tags TEXT NOT NULL DEFAULT '',
-- An admin's answer to the CI tab's offer to help set CI up: the
-- account that said this repository does not use CI, empty while
-- nobody has said so. Here rather than in the repository itself
-- because it is the forge's business how the forge's own navigation
-- behaves, and a tracked file would clone to everyone who copied
-- the repository and mean nothing to them.
ci_declined_by TEXT NOT NULL DEFAULT '',
ci_declined_at TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL,
-- chat is whether this repository has a chat channel. On by default:
-- a forge that runs a chat plane offers it everywhere until a
-- maintainer says otherwise.
chat INTEGER NOT NULL DEFAULT 1,
-- uv is whether the repository's bar shows the UV tab (RFC 0020).
-- On by default; off hides only the tab, never the pages.
uv INTEGER NOT NULL DEFAULT 1,
-- mirrorable is whether OTHER PEOPLE may keep a copy that follows
-- this one — the mirror posture, elsewhere. Open by default, same
-- reasoning as visibility: a forge that never says otherwise should
-- not surprise anyone by refusing what looks like an ordinary
-- clone. A private repository is never mirrorable regardless of
-- this column — see MayMirror.
mirrorable INTEGER NOT NULL DEFAULT 1,
-- owner is the DEED, and it is always an account, never a team.
-- A person holds the repository; that is what makes delete,
-- transfer and succession expressible, and it is what a namespace
-- falls back to when a team is dissolved (RFC 0018 requires that
-- dissolving a team never puts anybody's code at risk).
--
-- Empty is a real state and means forge-held: a store this node
-- serves that no account has claimed. It is not the same as
-- unowned-and-deletable.
owner TEXT NOT NULL DEFAULT '',
-- namespace is where the repository APPEARS -- an account handle
-- or a team slug, drawn from the one shared pool. Equal to owner
-- for the ordinary personal case. Separate from owner because a
-- team holds an appearance, not a deed.
namespace TEXT NOT NULL DEFAULT '',
-- Who placed it in that namespace, and when. Provenance, not
-- authority: the capability that permitted it can later change
-- hands, so the row records who passed the check at the time.
namespace_by TEXT NOT NULL DEFAULT '',
namespace_at TEXT NOT NULL DEFAULT ''
);`
// repoMigrations are additive columns for registries written by an
// earlier schema. A duplicate-column error means it is already there.
var repoMigrations = []string{
`ALTER TABLE repo ADD COLUMN description TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'`,
`ALTER TABLE repo ADD COLUMN homepage TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN topics TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN ci_tags TEXT NOT NULL DEFAULT ''`,
// Which tier a new ticket lands in by default (66925a7a,
// docs/local-tickets.md). Empty follows the forge, which keeps
// tickets out of the artifact log until somebody promotes them.
`ALTER TABLE repo ADD COLUMN ticket_tier TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN ci_declined_by TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN ci_declined_at TEXT NOT NULL DEFAULT ''`,
// An import is minutes of network for a large repository. Before
// these, a cloning repository did not exist anywhere a reader could
// see it — no row, no state, no progress — because it was only
// registered once the clone returned. State makes the repository
// real from the first second, which is the whole point.
`ALTER TABLE repo ADD COLUMN state TEXT NOT NULL DEFAULT 'ready'`,
`ALTER TABLE repo ADD COLUMN import_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN import_error TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN chat INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE repo ADD COLUMN mirrorable INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE repo ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN namespace TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN namespace_by TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN namespace_at TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo ADD COLUMN uv INTEGER NOT NULL DEFAULT 1`,
// Adopt an owner for registries written before the column, but
// ONLY where the answer is unambiguous: exactly one account holds
// an explicit `a`.
//
// Deliberately not "the earliest grant". On this forge the agent
// account that imported every mirror holds `a` on all of them, so
// earliest-wins would have handed it the deed to repositories its
// human owner had merely been granted admin on afterwards. A rule
// that can silently pick the wrong owner is worse than one that
// declines and leaves the pick to a person.
//
// Guarded on owner = '' so it adopts only the unclaimed. A
// repository whose sole admin is its obvious owner is adopted the
// first time this runs and never reconsidered.
//
// Mirrors are excluded outright. A tracking store is something the
// forge serves, not something a person is hosting here, and its
// sole admin is typically the operator account that registered it
// — which is an artefact of who ran the import, not a claim on the
// code. Forge-held is the honest answer for a mirror, and anyone
// who actually wants one can claim it (repo.claim).
`UPDATE repo SET owner = (
SELECT c.account FROM repo_caps c
WHERE c.repo = repo.name AND instr(c.caps, 'a') > 0)
WHERE owner = ''
AND name NOT IN (SELECT repo FROM mirror)
AND (SELECT COUNT(*) FROM repo_caps c
WHERE c.repo = repo.name AND instr(c.caps, 'a') > 0) = 1`,
// The mirror release that used to sit here has been removed.
//
// It cleared an owner the first backfill had wrongly adopted onto
// a tracking store, and it identified those rows by namespace_by
// being empty. That guard is no longer safe to reuse: an empty
// namespace_by now also means "the segment fell back to the owner",
// which a repository leaving a team produces. A mirror somebody
// claimed, placed in a team and then removed would have been
// un-owned by a migration on the next restart.
//
// It is not needed either. The backfill above refuses to adopt a
// mirror at all, so no new forge can enter the state it repaired,
// and the one forge that was in it has been repaired.
// The namespace a repository appears under: its team when one
// holds it, otherwise its owner. Empty stays empty, because a
// forge-held store has no namespace to appear under yet.
`UPDATE repo SET namespace =
COALESCE((SELECT t.team FROM team_repo t WHERE t.repo = repo.name), owner)
WHERE namespace = ''`,
// Re-seat any path segment that disagrees with the team holding
// the repository. team_repo is the authority for that; namespace
// only says where the repository appears, so a disagreement is by
// definition the path gone stale and never the other way round.
//
// Needed because the two were written in different places until
// SetTeamRepo began updating both in one transaction: rows placed
// in a team before that still name whoever held them before.
// Unguarded on purpose, unlike the backfill above — this is not a
// guess to be made once, it is an invariant to restore.
`UPDATE repo SET namespace = (SELECT t.team FROM team_repo t WHERE t.repo = repo.name)
WHERE name IN (SELECT repo FROM team_repo)
AND namespace IS NOT (SELECT t.team FROM team_repo t WHERE t.repo = repo.name)`,
// The other half: a segment naming neither the team that holds the
// repository nor its owner.
//
// The statement above only looks at repositories still in a team,
// so it cannot see one whose team was dissolved -- exactly the row
// a dissolve used to leave behind. Catching it here means a forge
// carrying that damage repairs itself on the next start rather
// than needing the rows fixed by hand.
//
// The rule is simply the invariant: a namespace is the team holding
// the repository, or the owner's own handle, and nothing else. The
// first version of this only caught a segment naming NO live team
// and NO live account, to avoid mistaking a personal namespace for
// a dead team. That was too narrow and missed the case that
// actually occurred: pkgsrc changed hands and kept the PREVIOUS
// owner's handle, which is a live account, so nothing repaired it.
//
// Stating the invariant instead of enumerating the ways to break it
// catches that one and any other. A namespace equal to the owner is
// untouched, which is the ordinary personal case.
`UPDATE repo SET namespace = owner, namespace_by = '', namespace_at = ''
WHERE namespace <> ''
AND name NOT IN (SELECT repo FROM team_repo)
AND namespace <> owner`,
}
// Repository states. Ready is the ordinary one and the default, so
// every row written before importing existed reads correctly.
const (
Ready = "ready" // serving normally
Cloning = "cloning" // an import is running; the store is incomplete
Failed = "failed" // the import did not finish; the row is a record, not a repository
)
// Visibility values. Private means only an identity with a role on the
// repository may see it at all — including that it exists.
const (
Public = "public"
Private = "private"
)
// RepoName is the accepted repository name. Same rule the --repo flag
// enforces, kept here so a name that arrives through the API cannot be
// one the flag would have refused.
//
// It is deliberately narrow: the name appears in a URL path segment, so
// anything needing escaping to be safe there is better rejected than
// encoded. A dot only ever joins two non-empty parts: the name is also
// subject tokens wherever the repository's events travel, and "a." or
// "a..b" would make an empty token no exact filter can name.
var RepoName repoNameRule
type repoNameRule struct{}
var repoNameChars = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
// MatchString reports whether s is an accepted repository name.
func (repoNameRule) MatchString(s string) bool {
return repoNameChars.MatchString(s) && !strings.HasSuffix(s, ".") && !strings.Contains(s, "..")
}
// ReservedNames are the forge's own top-level routes: this is about
// routes and impersonation, not taste. A repository named "new" would
// sit under the same path the create form does, and one named "admin"
// would read, at a glance, as belonging to the operator. Kept short
// and closed — it grows only when a literal route needs the name.
var ReservedNames = map[string]bool{
"new": true, "fleet": true, "me": true, "inbox": true, "login": true,
"logout": true, "switch": true, "theme": true, "people": true,
"tickets": true, "assets": true, "api": true, "r": true,
"changelog": true, "invite": true, "search": true, "admin": true,
"settings": true, "static": true, "favicon.ico": true, "robots.txt": true,
"runner": true,
}
// IsReserved reports whether name collides with a route the forge keeps
// for itself. Case-insensitive: a repository named "New" would shadow
// /new exactly as one spelled "new" would.
func IsReserved(name string) bool {
return ReservedNames[strings.ToLower(name)]
}
// Repo is one registered repository.
type Repo struct {
Name string
Path string
// Visibility is Public or Private.
Visibility string
// Description is the one line that says what this repository is.
// Every forge has it and quarry had nowhere to put it, so the
// repository list could only ever show names and counts.
Description string
// Homepage and Topics are the About-panel settings a maintainer
// edits in place, GitHub-style; .quarry/repo.json remains the
// in-tree alternative and these override it when set.
Homepage string
Topics string // space-separated
Created string
// Chat is whether the repository has a chat channel (default on).
Chat bool
// UV is whether the bar shows the UV tab (default on).
UV bool
// Mirrorable is the STORED flag: whether other people may keep a
// copy that follows this one (default on). It is not the final
// answer for a private repository — see MayMirror — but the
// settings page shows this raw value so a choice made while public
// survives being taken private and back.
Mirrorable bool
// State is Ready, Cloning or Failed. ImportURL is what a Cloning or
// Failed row is (or was) copying; ImportError is why a Failed one
// stopped — kept rather than discarded, because an import that
// vanished without saying why is the thing this replaces.
State string
ImportURL string
ImportError string
}
// AddRepo registers a repository. The path is stored absolute: a
// relative one would resolve against whatever directory the server
// happened to start in, which is not a property anybody intends to
// depend on and changes silently between a shell and a service manager.
// AddImportingRepo registers a repository whose store is still being
// copied. It exists from this moment: listed, navigable, and honestly
// labelled as incomplete, rather than appearing only once the clone
// finishes minutes later.
func (d *DB) AddImportingRepo(name, path, description, visibility, url string) error {
if err := d.AddRepo(name, path, description, visibility); err != nil {
return err
}
_, err := d.w.Exec(`UPDATE repo SET state = ?, import_url = ? WHERE name = ?`, Cloning, url, name)
return err
}
// SetRepoState moves a repository between Ready, Cloning and Failed.
// The error text is stored only for Failed and cleared otherwise, so a
// repository that succeeds on a retry does not keep explaining a
// failure that no longer applies.
func (d *DB) SetRepoState(name, state, importErr string) error {
if state != Failed {
importErr = ""
}
_, err := d.w.Exec(`UPDATE repo SET state = ?, import_error = ? WHERE name = ?`, state, importErr, name)
return err
}
// ImportingRepos lists repositories whose state says an import was in
// flight. Used at startup: a clone runs as a child of the server, so a
// restart kills it and leaves the row claiming to be cloning forever.
func (d *DB) ImportingRepos() ([]string, error) {
rows, err := d.db.Query(`SELECT name FROM repo WHERE state = ?`, Cloning)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (d *DB) AddRepo(name, path, description, visibility string) error {
if !RepoName.MatchString(name) {
return fmt.Errorf("oltp: repository name %q: use letters, digits, dot, dash, underscore", name)
}
if path == "" {
return errors.New("oltp: repository needs a path")
}
if d.Tombstoned(name) {
return tombstoneRefusal(name)
}
abs, err := filepath.Abs(path)
if err != nil {
return err
}
if visibility != Private {
visibility = Public
}
_, err = d.w.Exec(`INSERT INTO repo (name, path, description, visibility, created)
VALUES (?, ?, ?, ?, ?)`,
name, abs, description, visibility, time.Now().UTC().Format(time.RFC3339))
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return fmt.Errorf("oltp: repository %q already registered", name)
}
if err != nil {
return err
}
// RFC 0004 §Private: the flag IS the two blank category strings, so
// a repository is private because nobody and anonymous grant
// nothing — not because a column says "private".
//
// SetRepoVisibility has always done this, so a repository turned
// private through the settings page was genuinely private. Created
// private, it was not: this INSERT wrote the column and left the
// categories to fall through to the fleet default, which is
// fossil's stock gjorzh2 — every read letter there is. CanRead then
// admitted ANY signed-in account to a repository whose owner had
// asked for private at the moment they created it.
//
// It went unseen over sync because MayMirror refused every caller
// on a private repository, including the owner (b112b205), so the
// lockout was hiding the leak. Fixing that gate without this would
// have opened the door it was accidentally holding shut.
if visibility == Private {
return d.SetVisibility(name, "Private")
}
return nil
}
// DeleteRepo unregisters a repository. It removes the registration only
// — the store on disk and its derived index are left alone, because
// "stop serving this" and "destroy this" are different intentions and
// only one of them is reversible.
func (d *DB) DeleteRepo(name string) error {
if _, err := d.w.Exec(`DELETE FROM repo_place_past WHERE repo = ?`, name); err != nil {
return err
}
_, err := d.w.Exec(`DELETE FROM repo WHERE name = ?`, name)
return err
}
// SetRepoChat turns a repository's chat channel on or off.
func (d *DB) SetRepoChat(name string, on bool) error {
_, err := d.w.Exec(`UPDATE repo SET chat = ? WHERE name = ?`, on, name)
return err
}
// SetRepoHomepage updates the project homepage.
//
// One column per setter, rather than the pair this used to write
// together: a settings write that names only the homepage must not
// carry the topics along with it. Writing both meant a caller who
// asked to change one had to supply the other, which it could only get
// by reading it first -- and anything read before a write is stale by
// the time it lands.
func (d *DB) SetRepoHomepage(name, homepage string) error {
_, err := d.w.Exec(`UPDATE repo SET homepage = ? WHERE name = ?`, homepage, name)
return err
}
// SetRepoTopics updates the space-separated topic list.
func (d *DB) SetRepoTopics(name, topics string) error {
_, err := d.w.Exec(`UPDATE repo SET topics = ? WHERE name = ?`, topics, name)
return err
}
// SetRepoDescription updates the one-line summary.
func (d *DB) SetRepoDescription(name, description string) error {
_, err := d.w.Exec(`UPDATE repo SET description = ? WHERE name = ?`, description, name)
return err
}
// SetRepoVisibility takes a repository public or private.
//
// It REFUSES a repository with no registry row rather than updating
// nothing and reporting success. That is not hypothetical: repositories
// named by --repo had no row, so taking one private silently did
// nothing and told the operator it had worked — a security control that
// reports success while doing nothing is worse than one that is absent.
func (d *DB) SetRepoVisibility(name, visibility string) error {
if visibility != Private {
visibility = Public
}
res, err := d.w.Exec(`UPDATE repo SET visibility = ? WHERE name = ?`, visibility, name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("no repository named %q", name)
}
return d.applyVisibilityCategories(name, visibility)
}
// applyVisibilityCategories is the half of a visibility change that is
// not the repo column.
//
// RFC 0004 §Private: the flag IS the two blank category strings.
// Private blanks them; public with both blank returns the repository to
// the fleet default rather than inventing strings.
//
// Split out so PatchRepo can reach it too. It writes a different table
// through its own statements, so it cannot join PatchRepo's
// transaction; it runs after the commit, which is the same order
// SetRepoVisibility has always used.
func (d *DB) applyVisibilityCategories(name, visibility string) error {
if visibility == Private {
return d.SetVisibility(name, "Private")
}
var err error
if d.CategoryCaps(name, "nobody") == "" && d.CategoryCaps(name, "anonymous") == "" {
_, err = d.w.Exec(`DELETE FROM repo_category_caps WHERE repo = ? AND category IN ('nobody', 'anonymous')`, name)
}
return err
}
// EnsureRepo records a repository that is being served but was never
// registered — the --repo bootstrap path. Existing rows are left alone,
// so a description or visibility set through the API survives a restart
// that also names the repository on the command line.
func (d *DB) EnsureRepo(name, path string) error {
abs, err := filepath.Abs(path)
if err != nil {
return err
}
_, err = d.w.Exec(`INSERT INTO repo (name, path, description, visibility, created)
VALUES (?, ?, '', 'public', ?)
ON CONFLICT (name) DO NOTHING`,
name, abs, time.Now().UTC().Format(time.RFC3339))
return err
}
// CITagsMode reads a repository's verdict-tag choice: ” follows the
// fleet default, 'on' and 'off' are its own (RFC 0009 §verdict-tags).
func (d *DB) CITagsMode(name string) string {
var m string
if err := d.db.QueryRow(`SELECT ci_tags FROM repo WHERE name = ?`, name).Scan(&m); err != nil {
return ""
}
return m
}
// SetCITagsMode writes it; anything but on/off means the fleet default.
func (d *DB) SetCITagsMode(name, mode string) error {
if mode != "on" && mode != "off" {
mode = ""
}
_, err := d.w.Exec(`UPDATE repo SET ci_tags = ? WHERE name = ?`, mode, name)
return err
}
// TicketTier reads where this repository's new tickets land by default:
// ” follows the forge (local, promoted deliberately), 'fossil' means a
// repository that keeps its tracker in the code history on purpose.
//
// A setting rather than a global because the two answers are both
// right: a repository dogfooding the store wants its tickets in the
// DAG, and a large public project wants them out of everybody's clone
// until somebody decides otherwise (66925a7a).
//
// It moves a DEFAULT and never a gate. Writing the artifact log still
// takes `a` whatever this says, and a reader filing without `a` still
// goes through intake — so a repository cannot use this to reopen the
// door ticket.new closed.
func (d *DB) TicketTier(name string) string {
var m string
if err := d.db.QueryRow(`SELECT ticket_tier FROM repo WHERE name = ?`, name).Scan(&m); err != nil {
return ""
}
return m
}
// SetTicketTier writes it; anything but 'fossil' means the forge default.
func (d *DB) SetTicketTier(name, tier string) error {
if tier != "fossil" {
tier = ""
}
_, err := d.w.Exec(`UPDATE repo SET ticket_tier = ? WHERE name = ?`, tier, name)
return err
}
// CIDeclined answers who said this repository does not use CI, and
// when. Empty means nobody has said so, which is NOT the same as "no
// CI": a repository with no CI and no answer is one the tab should
// still be offering to help.
func (d *DB) CIDeclined(name string) (who, at string) {
_ = d.db.QueryRow(`SELECT ci_declined_by, ci_declined_at FROM repo WHERE name = ?`,
name).Scan(&who, &at)
return who, at
}
// SetCIDeclined records the answer, or clears it when who is empty —
// the same control, both ways, so an admin who changes their mind is
// not looking for a different button somewhere else.
func (d *DB) SetCIDeclined(name, who string) error {
at := ""
if who != "" {
at = time.Now().UTC().Format(time.RFC3339)
}
_, err := d.w.Exec(`UPDATE repo SET ci_declined_by = ?, ci_declined_at = ? WHERE name = ?`,
who, at, name)
return err
}
// PrivateRepos returns the set of private repository names. A SET rather
// than a per-name query because the read gate runs on every request and
// the answer changes only when an admin changes it.
func (d *DB) PrivateRepos() (map[string]bool, error) {
rows, err := d.db.Query(`SELECT name FROM repo WHERE visibility = 'private'`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out[n] = true
}
return out, rows.Err()
}
// PurgeRepo removes a repository's registration and everything keyed to
// it: grants, team placement, credentials, tickets, chat indexes, CI
// records, webhooks, mirrors and the settings trail.
//
// One transaction and one place, because the failure mode of scattering
// this is silent: a deletion that misses a table leaves credentials or
// an outbound webhook pointing at a repository that no longer exists,
// and if a repository of the same name is later created it inherits
// them — reporter tokens that still authenticated, tickets and chat
// indexes that reappeared under a new owner (quarry#294). When a new
// repo-keyed table is added, it is added here; purgerepo_test.go fails
// until it is, or until it is named there as kept on purpose.
//
// The records that live outside this database — chat messages, worker
// tokens, attention events — are the registrar's to purge.
func (d *DB) PurgeRepo(name string) error {
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
`DELETE FROM sync_user WHERE repo = ?`,
`DELETE FROM mirror WHERE repo = ?`,
`DELETE FROM mirror_chatbridge WHERE repo = ?`,
`DELETE FROM mirror_chatbridge_msg WHERE repo = ?`,
`DELETE FROM webhook WHERE repo = ?`,
`DELETE FROM ci_run WHERE repo = ?`,
`DELETE FROM ci_watermark WHERE repo = ?`,
`DELETE FROM ci_tag_seen WHERE repo = ?`,
`DELETE FROM artifact_carrier WHERE repo = ?`,
`DELETE FROM forum_vote WHERE repo = ?`,
`DELETE FROM forum_thread_category WHERE repo = ?`,
`DELETE FROM poll_vote WHERE repo = ?`,
`DELETE FROM forum_moderation WHERE repo = ?`,
`DELETE FROM forum_staged WHERE repo = ?`,
`DELETE FROM forum_modlog WHERE repo = ?`,
`DELETE FROM forum_hidden WHERE repo = ?`,
`DELETE FROM forum_sub WHERE repo = ?`,
`DELETE FROM repo_caps WHERE repo = ?`,
`DELETE FROM branch_protection WHERE repo = ?`,
`DELETE FROM repo_category_caps WHERE repo = ?`,
`DELETE FROM team_repo WHERE repo = ?`,
`DELETE FROM team_repo_offer WHERE repo = ?`,
`DELETE FROM repo_place_past WHERE repo = ?`,
`DELETE FROM repo_companion WHERE repo = ? OR store = ?1`,
`DELETE FROM repo_settings_log WHERE repo = ?`,
`DELETE FROM forge_act WHERE repo = ?`,
`DELETE FROM avatar WHERE kind = 'repo' AND name = ?`,
`DELETE FROM bucket WHERE repo = ?`,
`DELETE FROM fossil_user WHERE repo = ?`,
`DELETE FROM mirror_pull WHERE repo = ?`,
`DELETE FROM webhook_delivery WHERE repo = ?`,
`DELETE FROM ci_reporter_token WHERE repo = ?`,
`DELETE FROM external_check WHERE repo = ?`,
`DELETE FROM branch_required_checks WHERE repo = ?`,
`DELETE FROM ci_deferred WHERE repo = ?`,
`DELETE FROM landing_auto WHERE repo = ?`,
`DELETE FROM forge_accept WHERE repo = ?`,
`DELETE FROM gate_watch WHERE repo = ?`,
`DELETE FROM gate_bypass WHERE repo = ?`,
`DELETE FROM local_ticket WHERE repo = ?`,
`DELETE FROM local_ticket_change WHERE repo = ?`,
`DELETE FROM local_attachment WHERE repo = ?`,
`DELETE FROM ticket_number WHERE repo = ?`,
`DELETE FROM upload_ticket WHERE repo = ?`,
`DELETE FROM attachment_hold WHERE repo = ?`,
`DELETE FROM chat_read WHERE repo = ?`,
`DELETE FROM chat_ref WHERE repo = ?`,
`DELETE FROM chat_reply WHERE repo = ?`,
`DELETE FROM chat_robot WHERE repo = ?`,
`DELETE FROM mention WHERE repo = ?`,
`DELETE FROM forum_read WHERE repo = ?`,
`DELETE FROM forum_read_mark WHERE repo = ?`,
`DELETE FROM stamp WHERE repo = ?`,
`DELETE FROM saved_view WHERE repo = ?`,
`DELETE FROM ci_secret WHERE repo = ?`,
`DELETE FROM ci_environment WHERE repo = ?`,
`DELETE FROM landing_number WHERE repo = ?`,
`DELETE FROM checkin_close WHERE repo = ?`,
`DELETE FROM checkin_close_watch WHERE repo = ?`,
`DELETE FROM unversioned WHERE repo = ?`,
`DELETE FROM unversioned_source WHERE repo = ?`,
`DELETE FROM repo WHERE name = ?`,
} {
if _, err := tx.Exec(q, name); err != nil {
return err
}
}
if err := unmuteRepo(tx, name); err != nil {
return err
}
// A remembered answer is scoped op|repo|author (api/idempotent.go);
// kept, a retried create would replay the old repository's ticket id
// into its successor.
esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(name)
if _, err := tx.Exec(`DELETE FROM idempotency WHERE scope LIKE ? ESCAPE '\'`,
"%|"+esc+"|%"); err != nil {
return err
}
return tx.Commit()
}
// unmuteRepo takes name out of every account's mute list, so a later
// repository of the name is not born muted for them. The list is the
// JSON array api/inbox.go writes, or the space-separated form it still
// reads; either way it is written back as the array.
func unmuteRepo(tx *sql.Tx, name string) error {
rows, err := tx.Query(`SELECT account, value FROM account_pref WHERE key = 'mute' AND instr(value, ?) > 0`, name)
if err != nil {
return err
}
edits := map[string]string{}
for rows.Next() {
var account, value string
if err := rows.Scan(&account, &value); err != nil {
rows.Close()
return err
}
var list []string
if v := strings.TrimSpace(value); strings.HasPrefix(v, "[") {
if json.Unmarshal([]byte(v), &list) != nil {
continue
}
} else {
list = strings.Fields(v)
}
kept := slices.DeleteFunc(slices.Clone(list), func(r string) bool { return r == name })
if len(kept) == len(list) {
continue
}
sort.Strings(kept)
b, err := json.Marshal(append([]string{}, kept...))
if err != nil {
rows.Close()
return err
}
edits[account] = string(b)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for account, value := range edits {
if _, err := tx.Exec(`UPDATE account_pref SET value = ? WHERE account = ? AND key = 'mute'`,
value, account); err != nil {
return err
}
}
return nil
}
// Repos lists registered repositories, name-ordered.
func (d *DB) Repos() ([]Repo, error) {
rows, err := d.db.Query(`SELECT name, path, description, visibility, homepage, topics, created,
state, import_url, import_error, chat, mirrorable, uv FROM repo ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Repo
for rows.Next() {
var r Repo
if err := rows.Scan(&r.Name, &r.Path, &r.Description, &r.Visibility, &r.Homepage, &r.Topics, &r.Created,
&r.State, &r.ImportURL, &r.ImportError, &r.Chat, &r.Mirrorable, &r.UV); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// RepoByName returns one registration.
func (d *DB) RepoByName(name string) (Repo, bool, error) {
var r Repo
err := d.db.QueryRow(`SELECT name, path, description, visibility, homepage, topics, created,
state, import_url, import_error, chat, mirrorable, uv FROM repo WHERE name = ?`, name).
Scan(&r.Name, &r.Path, &r.Description, &r.Visibility, &r.Homepage, &r.Topics, &r.Created,
&r.State, &r.ImportURL, &r.ImportError, &r.Chat, &r.Mirrorable, &r.UV)
if errors.Is(err, sql.ErrNoRows) {
return Repo{}, false, nil
}
return r, err == nil, err
}
// SetRepoMirrorable stores whether other people may keep a copy that
// follows this repository. It writes the raw flag only — a private
// repository's veto lives in MayMirror, not here, so an admin's choice
// survives a round trip through private and back.
func (d *DB) SetRepoMirrorable(name string, on bool) error {
_, err := d.w.Exec(`UPDATE repo SET mirrorable = ? WHERE name = ?`, boolInt(on), name)
return err
}
// MayMirror is the sync gate's question: may a clone or pull of this
// repository succeed at all. It is NOT the same as the stored flag —
// a private repository answers no here regardless of what the flag
// says, because mirroring is "may other people keep a copy", and a
// private repository already answered no to "may other people see
// this at all". A repository this forge does not know about answers
// no too, the same way an unknown name does everywhere else.
func (d *DB) MayMirror(name string) (bool, error) {
var visibility string
var flag int
err := d.db.QueryRow(`SELECT visibility, mirrorable FROM repo WHERE name = ?`, name).
Scan(&visibility, &flag)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return visibility != Private && flag != 0, nil
}
// OwnerOf is the account holding the repository's deed, or "" when the
// forge holds it and no account has claimed it.
func (d *DB) OwnerOf(name string) (string, error) {
var owner string
err := d.db.QueryRow(`SELECT owner FROM repo WHERE name = ?`, name).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return owner, err
}
// SetOwner moves the deed. It does NOT touch the namespace: a
// repository can change hands while staying in the team it appears
// under, and a repository can move between namespaces without changing
// hands. Callers that want both say both.
func (d *DB) SetOwner(name, account string) error {
_, err := d.w.Exec(`UPDATE repo SET owner = ? WHERE name = ?`, account, name)
return err
}
// NamespaceOf is where the repository appears: a team slug, or the
// owner's handle, or "" for a forge-held store with neither.
func (d *DB) NamespaceOf(name string) (string, error) {
var ns string
err := d.db.QueryRow(`SELECT namespace FROM repo WHERE name = ?`, name).Scan(&ns)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return ns, err
}
// SetNamespace places the repository under a namespace and records who
// did it. by and at are provenance: the capability that permitted the
// move can later change hands, so the row keeps who passed the check
// rather than implying the current holder did.
func (d *DB) SetNamespace(name, ns, by string, at time.Time) error {
return placeRepo(d.w, name, ns, by, at)
}
// NamespacePlacement is the provenance pair, for a settings page that
// wants to say who put a repository where it is.
func (d *DB) NamespacePlacement(name string) (by, at string, err error) {
err = d.db.QueryRow(`SELECT namespace_by, namespace_at FROM repo WHERE name = ?`, name).Scan(&by, &at)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
return by, at, err
}
// ReposOwnedBy lists the repositories whose deed an account holds, in
// name order. This is ownership, not reach: a repository the account
// merely administers is not here.
func (d *DB) ReposOwnedBy(account string) ([]string, error) {
if account == "" {
return nil, nil
}
rows, err := d.db.Query(`SELECT name FROM repo WHERE owner = ? ORDER BY name`, account)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}