Skip to content
quarry Artifact [1d268c95e5]
Login

Artifact [1d268c95e5]

Artifact 1d268c95e55182242e1094acc6db558dd3cae074ab030690042601681ecdb6ec:


package api

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

	"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
}

// 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) {
		return nil
	}
	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)
			}
		}
	}
	if d.Local != nil {
		if locals, err := d.Local.LocalTickets(name); err == nil {
			for _, lt := range locals {
				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
	}
	sort.SliceStable(missing, func(i, j int) bool { return missing[i].at < missing[j].at })
	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
	}
	return have
}

// 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
			}
			for _, t := range todo {
				if err := repo.write(func() error {
					_, err := repo.Zit.TicketSet(t.ID, author(ctx), map[string]string{"number": strconv.Itoa(t.Number)})
					return err
				}); err != nil {
					return out, fmt.Errorf("recording #%d on %.12s after %d written: %w", t.Number, t.ID, out.Written, err)
				}
				out.Written++
			}
			if err := repo.synced(); err != nil {
				return out, err
			}
			return out, nil
		},
	})
}