Skip to content
quarry Artifact [abfe932222]
Login

Artifact [abfe932222]

Artifact abfe9322224d2150b9e7b8a7b6f6a5e27b94ad0446dfbbc7fb810c57eae0c3bc:


package api

import (
	"context"
	"fmt"
	"log"
	"sort"
	"strconv"
	"sync"

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

// TicketNumberStore is the forge's ticket numbering (oltp/ticketnumber.go).
type TicketNumberStore interface {
	TicketNumbersAssign(repo string, ids []string) error
	TicketNumbers(repo string) (map[string]int, error)
	TicketNumbered(repo string, n int) (string, bool, error)
	TicketNumberRekey(repo, from, to string) error
	TicketNumberClaim(repo, ticket string, n int) (bool, error)
}

// numberLocks serializes numbering per repository, so two filings at
// once cannot both read the same "next number".
var numberLocks sync.Map

// numbers says whether this forge numbers a repository's tickets: one it
// owns, never a mirror — a mirror's tickets are numbered, if at all, by
// the forge that owns them (54c5175f).
func (d Deps) numbers(repo string) bool {
	if d.Numbers == nil {
		return false
	}
	m, _ := d.posture(repo)
	return !m.Tracking
}

// ticketNumbers is a repository's ticket numbers, id → number, after
// numbering anything that has none yet: the store's tickets and the
// local tier's, together, oldest first. So the first read after this
// ships backfills the tracker in creation order, and a ticket that
// arrives later — filed here, or pushed from a clone — takes the next
// number when it is first seen. Nil for a repository the forge does not
// number, or when the store cannot be read; a number is decoration on
// every page that shows one and must never fail the page.
func (d Deps) ticketNumbers(name string, repo *Repo, also ...string) map[string]int {
	if !d.numbers(name) {
		// A mirror numbers nothing, but shows what its upstream
		// recorded: a quarry forge writes each ticket's number into
		// the ticket, so a mirror of one can read it. A mirror of a
		// plain fossil repository has none, and stays unnumbered.
		if repo == nil || repo.Index == nil {
			return nil
		}
		rec, err := repo.Index.TicketRecordedNumbers()
		if err != nil || len(rec) == 0 {
			return nil
		}
		return rec
	}
	mu, _ := numberLocks.LoadOrStore(name, &sync.Mutex{})
	mu.(*sync.Mutex).Lock()
	defer mu.(*sync.Mutex).Unlock()
	return d.ticketNumbersLocked(name, repo, also...)
}

// ticketNumbersLocked is ticketNumbers with the repository's numbering
// lock already held.
func (d Deps) ticketNumbersLocked(name string, repo *Repo, also ...string) map[string]int {
	have, err := d.Numbers.TicketNumbers(name)
	if err != nil {
		log.Printf("tickets: %s: reading numbers: %v", name, err)
		return nil
	}
	type born struct{ id, at string }
	var missing []born
	seen := map[string]bool{}
	note := func(id, at string) {
		if id == "" || seen[id] {
			return
		}
		seen[id] = true
		if _, ok := have[id]; !ok {
			if len(at) > 19 {
				at = at[:19] // index and local tier write the same instant differently
			}
			missing = append(missing, born{id, at})
		}
	}
	if repo != nil && repo.Index != nil {
		if all, err := repo.Index.TicketCreation(); err == nil {
			for _, t := range all {
				note(t.ID, t.Created)
			}
		}
	}
	localIDs := map[string]bool{}
	if d.Local != nil {
		if locals, err := d.Local.LocalTickets(name); err == nil {
			for _, lt := range locals {
				localIDs[lt.ID] = true
				note(lt.ID, lt.Created)
			}
		}
	}
	// A ticket just filed that no reader has folded yet: last.
	for _, id := range also {
		note(id, "9999")
	}
	if len(missing) == 0 {
		return have
	}
	// On a repository's FIRST numbering pass — a fork just detached
	// from a quarry forge, or a store registered with numbers already
	// in it — a ticket that carries a recorded number keeps it, and the
	// rest continue after the highest: a fork answers to the names its
	// upstream gave instead of renumbering from 1 and colliding with
	// every one. Never after that: a ticket that arrives later by push
	// takes the next number here whatever it carries, or anyone who can
	// push could squat a number (number=500 jumps the sequence) by
	// writing it into a ticket of their own.
	if len(have) == 0 && repo != nil && repo.Index != nil {
		if rec, err := repo.Index.TicketRecordedNumbers(); err == nil && len(rec) > 0 {
			kept := missing[:0]
			for _, m := range missing {
				if n, ok := rec[m.id]; ok {
					if took, err := d.Numbers.TicketNumberClaim(name, m.id, n); err == nil && took {
						continue
					}
				}
				kept = append(kept, m)
			}
			missing = kept
		}
	}
	if len(missing) == 0 {
		have, _ = d.Numbers.TicketNumbers(name)
		return have
	}
	sort.SliceStable(missing, func(i, j int) bool { return missing[i].at < missing[j].at })
	first := len(have) == 0
	ids := make([]string, len(missing))
	for i, m := range missing {
		ids[i] = m.id
	}
	if err := d.Numbers.TicketNumbersAssign(name, ids); err != nil {
		log.Printf("tickets: %s: numbering %d: %v", name, len(ids), err)
		return have
	}
	if have, err = d.Numbers.TicketNumbers(name); err != nil {
		return nil
	}
	// A store ticket numbered here after the first pass arrived by push
	// — filed offline on a clone, or on a clone of this forge — and so
	// carries no number, or one this forge did not give. Its number is
	// written into the ticket, one change by the forge, so the clone it
	// came from learns the name on its next pull. In the background: a
	// read must not wait on a write. The first pass is the backfill of a
	// tracker that predates numbering, which ticket.numbers.record does
	// when an admin says so.
	if !first && repo != nil && repo.Index != nil && repo.Zit != nil {
		var todo []numberedTicket
		for _, m := range missing {
			if _, local := localIDs[m.id]; local {
				continue
			}
			if n := have[m.id]; n > 0 {
				todo = append(todo, numberedTicket{m.id, n})
			}
		}
		if len(todo) > 0 {
			go func() {
				if _, err := writeNumbers(repo, forgeAuthor, todo); err != nil {
					log.Printf("tickets: %s: recording %d pushed numbers: %v", name, len(todo), err)
				}
			}()
		}
	}
	return have
}

// ticketsNamed is every ticket a number names in a repository: the one
// this forge numbered, then any whose history records that number from
// another forge — a ticket numbered elsewhere and pushed here keeps the
// name it was given. More than one is a name two forges both recorded,
// which the reader has to choose between; guessing would be wrong half
// the time. Only tickets this reader may see are counted.
func (d Deps) ticketsNamed(ctx context.Context, name string, repo *Repo, n int) []string {
	var out []string
	seen := map[string]bool{}
	add := func(id string) {
		if id == "" || seen[id] {
			return
		}
		if repo.Index != nil {
			if _, ok, err := repo.Index.TicketByPrefixFor(id, seesPrivate(ctx)); err != nil || !ok {
				if d.Local == nil {
					return
				}
				if _, lok, _ := d.Local.LocalTicketByPrefix(name, id); !lok {
					return
				}
			}
		}
		seen[id] = true
		out = append(out, id)
	}
	if d.numbers(name) {
		d.ticketNumbers(name, repo)
		if id, ok, err := d.Numbers.TicketNumbered(name, n); err == nil && ok {
			add(id)
		}
	}
	if repo.Index != nil {
		if ids, err := repo.Index.TicketsRecordedAs(strconv.Itoa(n)); err == nil {
			for _, id := range ids {
				add(id)
			}
		}
	}
	return out
}

type ticketNamedIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Number int    `json:"number" validate:"required,min=1"`
}

type namedTicket struct {
	ID     string `json:"id"`
	Title  string `json:"title"`
	Status string `json:"status"`
	URL    string `json:"url"`
}

type ticketNamedOut struct {
	// Tickets are what #number names here: one, none, or — when two
	// forges both recorded it — several, this forge's own first.
	Tickets []namedTicket `json:"tickets"`
}

func (d Deps) registerTicketNamed(r *ops.Registry) error {
	return ops.Register(r, ops.Op[ticketNamedIn, ticketNamedOut]{
		Name:  "ticket.named",
		Caps:  "r",
		Doc:   "The tickets a number names in this repository: this forge's own, then any another forge recorded on a ticket pushed here. Several means a name two forges both gave; choose by id.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ticket/named/{number}",
		Run: func(ctx context.Context, in ticketNamedIn) (ticketNamedOut, error) {
			out := ticketNamedOut{Tickets: []namedTicket{}}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return out, err
			}
			for _, id := range d.ticketsNamed(ctx, in.Repo, repo, in.Number) {
				row := namedTicket{ID: id, URL: d.link("/r/" + in.Repo + "/ticket/" + id)}
				if repo.Index != nil {
					if t, ok, _ := repo.Index.TicketByPrefixFor(id, seesPrivate(ctx)); ok {
						row.Title, row.Status = ticketField(t, "title"), ticketField(t, "status")
					}
				}
				if row.Title == "" && d.Local != nil {
					if lt, ok, _ := d.Local.LocalTicketByPrefix(in.Repo, id); ok {
						row.Title, row.Status = lt.Fields["title"], lt.Fields["status"]
					}
				}
				out.Tickets = append(out.Tickets, row)
			}
			return out, nil
		},
	})
}

// ticketRef turns what a caller typed into a ticket id: an all-digit
// token names a NUMBER first (#12 is ticket 12, not whichever hex id
// starts with 12), and falls back to being an id prefix when this
// repository has no such number. Anything else is returned as given.
func (d Deps) ticketRef(name string, repo *Repo, ref string) string {
	n, err := strconv.Atoi(ref)
	if err != nil || n <= 0 || !d.numbers(name) {
		return ref
	}
	d.ticketNumbers(name, repo) // a number read before any list was shown still resolves
	if id, ok, err := d.Numbers.TicketNumbered(name, n); err == nil && ok {
		return id
	}
	return ref
}

// withNextNumber files a store ticket that carries its own number: it
// holds the numbering lock, numbers everything older first, and hands
// create the number the new ticket will get, so the number can be
// written into the ticket's first artifact rather than added by a
// second one. The id create returns is then recorded under it. A
// repository the forge does not number gets 0 and no record.
func (d Deps) withNextNumber(name string, repo *Repo, create func(n int) (string, error)) (string, int, error) {
	if !d.numbers(name) {
		id, err := create(0)
		return id, 0, err
	}
	mu, _ := numberLocks.LoadOrStore(name, &sync.Mutex{})
	mu.(*sync.Mutex).Lock()
	defer mu.(*sync.Mutex).Unlock()
	have := d.ticketNumbersLocked(name, repo)
	next := 1
	for _, n := range have {
		if n >= next {
			next = n + 1
		}
	}
	id, err := create(next)
	if err != nil {
		return id, 0, err
	}
	if err := d.Numbers.TicketNumbersAssign(name, []string{id}); err != nil {
		log.Printf("tickets: %s: recording #%d: %v", name, next, err)
		return id, 0, nil
	}
	return id, next, nil
}

type ticketNumbersRecordIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Write records the numbers; without it the op only reports what it
	// would write — each is a permanent ticket change in the history.
	Write bool `json:"write"`
}

type numberedTicket struct {
	ID     string `json:"id"`
	Number int    `json:"number"`
}

type ticketNumbersRecordOut struct {
	// Missing counts store tickets whose history does not yet carry the
	// number this forge gave them; Written, how many this call recorded.
	Missing int `json:"missing"`
	Written int `json:"written"`
	// Tickets lists them, oldest number first, up to 50.
	Tickets []numberedTicket `json:"tickets"`
}

func (d Deps) registerTicketNumbers(r *ops.Registry) error {
	return ops.Register(r, ops.Op[ticketNumbersRecordIn, ticketNumbersRecordOut]{
		Name: "ticket.numbers.record",
		Caps: "a",
		Doc: "Write each store ticket's number into the ticket itself (a number field, one ticket change each), so a clone knows the name without asking this forge. " +
			"Tickets filed or promoted here carry it already; this backfills the ones that predate numbering. Reports only, unless write=true: every change is permanent history.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ticket/numbers/record",
		Run: func(ctx context.Context, in ticketNumbersRecordIn) (ticketNumbersRecordOut, error) {
			out := ticketNumbersRecordOut{Tickets: []numberedTicket{}}
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return out, err
			}
			if !d.numbers(in.Repo) {
				return out, fmt.Errorf("%s is a mirror; its tickets are numbered by the forge that owns them: %w", in.Repo, ops.ErrInvalid)
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return out, err
			}
			if repo.Index == nil || repo.Zit == nil {
				return out, fmt.Errorf("repository %q has no store to write: %w", in.Repo, ops.ErrUnavailable)
			}
			nums := d.ticketNumbers(in.Repo, repo)
			all, err := repo.Index.TicketCreation()
			if err != nil {
				return out, err
			}
			var todo []numberedTicket
			for _, c := range all {
				n := nums[c.ID]
				if n == 0 {
					continue
				}
				t, ok, err := repo.Index.TicketByPrefixAll(c.ID)
				if err != nil || !ok || ticketField(t, "number") == strconv.Itoa(n) {
					continue
				}
				todo = append(todo, numberedTicket{c.ID, n})
			}
			sort.Slice(todo, func(i, j int) bool { return todo[i].Number < todo[j].Number })
			out.Missing = len(todo)
			for i, t := range todo {
				if i < 50 {
					out.Tickets = append(out.Tickets, t)
				}
			}
			if !in.Write || len(todo) == 0 {
				return out, nil
			}
			out.Written, err = writeNumbers(repo, author(ctx), todo)
			return out, err
		},
	})
}

// forgeAuthor is who a number the forge writes on its own is by.
const forgeAuthor = "quarry"

// writeNumbers records each ticket's number in the ticket itself, one
// change each, and re-reads the store once at the end.
func writeNumbers(repo *Repo, user string, todo []numberedTicket) (int, error) {
	written := 0
	for _, t := range todo {
		if err := repo.write(func(tok lease.Token) error {
			_, err := repo.Zit.TicketSet(tok, t.ID, user, map[string]string{"number": strconv.Itoa(t.Number)})
			return err
		}); err != nil {
			return written, fmt.Errorf("recording #%d on %.12s after %d written: %w", t.Number, t.ID, written, err)
		}
		written++
	}
	if written > 0 {
		if err := repo.synced(); err != nil {
			return written, err
		}
	}
	return written, nil
}