Skip to content
quarry Artifact [f600f5d086]
Login

Artifact [f600f5d086]

Artifact f600f5d086b8c0abb3cf0a78a6491645110909d748c28ff60c2ae1fe70646faf:


// follow.go — an agent's reach (RFC 0021, Reach and following).
//
// An agent with site reach follows the person it is assigned to into
// every repository that person owns, at that person's string there.
// The grants it makes are ordinary repo_caps rows carrying a followed
// mark, so a reconcile only ever touches rows it wrote: a grant an
// admin gave the agent by hand is theirs, not the reconcile's.
//
// A removal sticks. An admin taking a followed grant away writes a
// follow_skip row (through SetCapsGuarded), and the reconcile leaves
// that repository alone until somebody grants the agent there by hand.
package oltp

import (
	"database/sql"
	"fmt"
	"log"
)

const followSchema = `
CREATE TABLE IF NOT EXISTS follow_skip (
	agent TEXT NOT NULL,
	repo  TEXT NOT NULL,
	PRIMARY KEY (agent, repo)
);`

var followMigrations = []string{
	// reach is '' (repo: follows nobody) or 'site'. Team reach waits on
	// team-owned agents (RFC 0021 rung 8).
	`ALTER TABLE account ADD COLUMN reach TEXT NOT NULL DEFAULT ''`,
	`ALTER TABLE repo_caps ADD COLUMN followed INTEGER NOT NULL DEFAULT 0`,
}

// ReachSite is the one reach a store accepts today besides the default.
const ReachSite = "site"

// Reach is an account's reach: "" for a repo agent and for a person.
func (d *DB) Reach(name string) string {
	var r string
	_ = d.db.QueryRow(`SELECT reach FROM account WHERE name = ?`, name).Scan(&r)
	return r
}

// SetReach gives an agent a reach and applies it at once. Only an
// agent has one, and a person has at most one site agent: a second
// would make "your site agent" two answers.
func (d *DB) SetReach(agent, reach string) error {
	if reach != "" && reach != ReachSite {
		return Refusal{fmt.Sprintf("%q is not a reach: use site, or nothing for an agent that works only where it is granted", reach)}
	}
	a, ok := d.AccountByName(agent)
	if !ok || !a.Agent() {
		return Refusal{fmt.Sprintf("%s is not an agent; only an agent has a reach", agent)}
	}
	if reach == ReachSite {
		who := assignedTo(a)
		if who == "" {
			return Refusal{fmt.Sprintf("%s is assigned to nobody, so there is nobody for it to follow", agent)}
		}
		if other := d.SiteAgentOf(who); other != "" && other != agent {
			return Refusal{fmt.Sprintf("%s already has a site agent, %s; take its reach away first", who, other)}
		}
	}
	if _, err := d.w.Exec(`UPDATE account SET reach = ? WHERE name = ?`, reach, agent); err != nil {
		return err
	}
	return d.ReconcileFollowing(agent)
}

// assignedTo is who an agent follows when its reach is site: its
// operator for a person's agent, the answerable site admin for a forge
// agent.
func assignedTo(a Account) string {
	if a.Operator == forgeOperator {
		return a.Answerable
	}
	return a.Operator
}

// SiteAgentOf names the person's site agent, or "".
func (d *DB) SiteAgentOf(person string) string {
	rows, err := d.db.Query(`SELECT name FROM account WHERE reach = ? AND operator != ''`, ReachSite)
	if err != nil {
		return ""
	}
	var names []string
	for rows.Next() {
		var n string
		if rows.Scan(&n) == nil {
			names = append(names, n)
		}
	}
	rows.Close()
	for _, n := range names {
		if a, ok := d.AccountByName(n); ok && assignedTo(a) == person {
			return n
		}
	}
	return ""
}

// Follows names who an agent follows, or "" when it follows nobody.
func (d *DB) Follows(agent string) string {
	if d.Reach(agent) != ReachSite {
		return ""
	}
	a, ok := d.AccountByName(agent)
	if !ok || a.Disabled != "" {
		return ""
	}
	return assignedTo(a)
}

// FollowedOn reports whether the agent's grant on repo is one the
// reconcile made.
func (d *DB) FollowedOn(agent, repo string) bool {
	var f int
	_ = d.db.QueryRow(`SELECT followed FROM repo_caps WHERE account = ? AND repo = ?`, agent, repo).Scan(&f)
	return f != 0
}

// ReconcileFollowing makes an agent's followed grants match what it
// should hold: its person's string on each repository the person owns,
// except where a removal was recorded. It adds, changes and removes
// only rows carrying the followed mark, so running it again changes
// nothing.
func (d *DB) ReconcileFollowing(agent string) error {
	want := map[string]string{}
	if who := d.Follows(agent); who != "" {
		rows, err := d.db.Query(`SELECT r.name, coalesce(c.caps, '') FROM repo r
			LEFT JOIN repo_caps c ON c.repo = r.name AND c.account = ?
			WHERE r.owner = ? AND r.name NOT IN (SELECT repo FROM follow_skip WHERE agent = ?)`,
			who, who, agent)
		if err != nil {
			return err
		}
		for rows.Next() {
			var repo, caps string
			if err := rows.Scan(&repo, &caps); err != nil {
				rows.Close()
				return err
			}
			if caps = noSetup(caps); caps != "" {
				want[repo] = caps
			}
		}
		rows.Close()
		if err := rows.Err(); err != nil {
			return err
		}
	}
	have := map[string]string{}
	followed := map[string]bool{}
	rows, err := d.db.Query(`SELECT repo, caps, followed FROM repo_caps WHERE account = ?`, agent)
	if err != nil {
		return err
	}
	for rows.Next() {
		var repo, caps string
		var f int
		if err := rows.Scan(&repo, &caps, &f); err != nil {
			rows.Close()
			return err
		}
		have[repo], followed[repo] = caps, f != 0
	}
	rows.Close()
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	for repo, caps := range want {
		cur, exists := have[repo]
		switch {
		case !exists:
			_, err = tx.Exec(`INSERT INTO repo_caps (account, repo, caps, followed) VALUES (?, ?, ?, 1)`, agent, repo, caps)
		case followed[repo] && cur != caps:
			_, err = tx.Exec(`UPDATE repo_caps SET caps = ? WHERE account = ? AND repo = ?`, caps, agent, repo)
		}
		if err != nil {
			return err
		}
	}
	for repo := range have {
		if _, keep := want[repo]; keep || !followed[repo] {
			continue
		}
		if _, err := tx.Exec(`DELETE FROM repo_caps WHERE account = ? AND repo = ? AND followed = 1`, agent, repo); err != nil {
			return err
		}
	}
	return tx.Commit()
}

// reconcileFollowersOf brings every agent following person up to date.
// Called after a write that can change what they should hold: a deed
// changing hands, a person's string changing on a repository they own.
func (d *DB) reconcileFollowersOf(person string) {
	if person == "" {
		return
	}
	if a := d.SiteAgentOf(person); a != "" {
		if err := d.ReconcileFollowing(a); err != nil {
			log.Printf("oltp: follow: %s for %s: %v", a, person, err)
		}
	}
}

// reconcileFollowersOn is reconcileFollowersOf for a repository's owner.
func (d *DB) reconcileFollowersOn(repo string) {
	var owner string
	if err := d.db.QueryRow(`SELECT owner FROM repo WHERE name = ?`, repo).Scan(&owner); err != nil {
		return
	}
	d.reconcileFollowersOf(owner)
}

// ReconcileAllFollowing runs every agent with a reach once, so nothing
// drifts across a restart or a write made outside these paths.
func (d *DB) ReconcileAllFollowing() error {
	rows, err := d.db.Query(`SELECT name FROM account WHERE reach != ''`)
	if err != nil {
		return err
	}
	var names []string
	for rows.Next() {
		var n string
		if rows.Scan(&n) == nil {
			names = append(names, n)
		}
	}
	rows.Close()
	for _, n := range names {
		if err := d.ReconcileFollowing(n); err != nil {
			return fmt.Errorf("oltp: follow: %s: %w", n, err)
		}
	}
	return nil
}

// handEdit is setCaps for a change a person makes, inside the guarded
// transaction: it takes the row out of the reconcile's hands. Taking a
// followed grant away records the removal, so it sticks; granting by
// hand clears any recorded removal and makes the row the admin's.
func handEdit(tx *sql.Tx, account, repo, caps string) error {
	if noSetup(caps) == "" {
		var f int
		if err := tx.QueryRow(`SELECT followed FROM repo_caps WHERE account = ? AND repo = ?`, account, repo).Scan(&f); err == nil && f != 0 {
			if _, err := tx.Exec(`INSERT OR IGNORE INTO follow_skip (agent, repo) VALUES (?, ?)`, account, repo); err != nil {
				return err
			}
		}
	} else if _, err := tx.Exec(`DELETE FROM follow_skip WHERE agent = ? AND repo = ?`, account, repo); err != nil {
		return err
	}
	if err := setCaps(tx, account, repo, caps); err != nil {
		return err
	}
	_, err := tx.Exec(`UPDATE repo_caps SET followed = 0 WHERE account = ? AND repo = ?`, account, repo)
	return err
}