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
}
// 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
}
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
},
})
}