Skip to content
quarry Artifact [e52b2af765]
Login

Artifact [e52b2af765]

Artifact e52b2af765c68f30630ac99e655b8db7af35d9a92b4e15b38001adce90c4cb9a:


package api

import (
	"context"
	"fmt"
	"sort"
	"strings"

	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// The local-ticket tier (docs/local-tickets.md): tickets that live in
// the forge's own database and enter the artifact log only when an
// admin promotes them. Same fields, same views, one extra property the
// fossil can never offer — a discard that leaves nothing behind.

// LocalTicketStore is what the tier needs from oltp.
type LocalTicketStore interface {
	LocalTicketNew(repo, user, addr string, fields map[string]string) (string, error)
	LocalTicketSet(repo, id, user string, fields map[string]string) error
	LocalTicketByPrefix(repo, prefix string) (oltp.LocalTicket, bool, error)
	LocalTickets(repo string) ([]oltp.LocalTicket, error)
	LocalTicketDiscard(repo, id string) error
	LocalTicketPromoted(repo, id, fossilID string) error
	LocalTicketRedirect(repo, prefix string) (string, bool)
}

// ticketIntakeIn is ticket.new's shape pointed at the local tier. A
// separate op rather than a flag on ticket.new: the two doors are gated
// differently, and a caller must always know which log it writes.
type ticketIntakeIn struct {
	Repo      string `json:"repo" validate:"required,max=64"`
	Title     string `json:"title" validate:"required,max=200"`
	Status    string `json:"status" validate:"max=40"`
	Comment   string `json:"comment" validate:"max=65536"`
	Type      string `json:"type" validate:"max=40"`
	Severity  string `json:"severity" validate:"max=40"`
	Priority  string `json:"priority" validate:"max=40"`
	Subsystem string `json:"subsystem" validate:"max=80"`
	FoundIn   string `json:"foundin" validate:"max=80"`
	// Fields carries the tracker's other columns, gated against the
	// artifact tier's vocabulary — see localTicketSet for why a local
	// ticket may not invent one.
	Fields map[string]string `json:"fields" validate:"max=24"`
}

type ticketIntakeOut struct {
	ID string `json:"id"`
}

type ticketPromoteIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	ID   string `json:"id" validate:"required,min=4,max=40"`
}

type ticketPromoteOut struct {
	// ID is the FOSSIL ticket the local one became.
	ID   string `json:"id"`
	Hash string `json:"hash"`
}

type ticketDiscardIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	ID   string `json:"id" validate:"required,min=4,max=40"`
}

type ticketDiscardOut struct {
	OK bool `json:"ok"`
}

func (d Deps) registerLocalTickets(r *ops.Registry) error {
	if err := ops.Register(r, ops.Op[ticketIntakeIn, ticketIntakeOut]{
		Name:  "ticket.intake",
		Caps:  "n",
		Doc:   "File a local ticket (docs/local-tickets.md): stored on this forge, promotable into the artifact log by an admin, deletable until then.",
		Kind:  ops.Command,
		Auth:  ops.Anonymous,
		Route: "POST /api/v1/r/{repo}/ticket/intake",
		Run: func(ctx context.Context, in ticketIntakeIn) (ticketIntakeOut, error) {
			if d.Local == nil {
				return ticketIntakeOut{}, fmt.Errorf("this forge keeps no local tickets: %w", ops.ErrUnavailable)
			}
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return ticketIntakeOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ticketIntakeOut{}, err
			}
			extra, err := extraTicketFields(authorableFields(repo), in.Fields)
			if err != nil {
				return ticketIntakeOut{}, err
			}
			status := in.Status
			if status == "" {
				status = "Open"
			}
			fields := map[string]string{"title": in.Title, "status": status}
			for k, v := range map[string]string{
				"type": in.Type, "severity": in.Severity,
				"priority": in.Priority, "subsystem": in.Subsystem,
				"foundin": in.FoundIn,
			} {
				if v != "" {
					fields[k] = v
				}
			}
			for k, v := range extra {
				fields[k] = v
			}
			if in.Comment != "" {
				fields["comment"] = in.Comment
				fields["cmimetype"] = authoredMimetype
			}
			id, err := d.Local.LocalTicketNew(in.Repo, author(ctx), "", fields)
			if err != nil {
				return ticketIntakeOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "filed local ticket " + in.Title,
					"link":    d.link("/r/" + in.Repo + "/ticket/" + id)},
				TxnID: "activity:ticket-intake:" + id,
			})
			return ticketIntakeOut{ID: id}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketPromoteIn, ticketPromoteOut]{
		Name:  "ticket.promote",
		Caps:  "a",
		Doc:   "Promote a local ticket into the artifact log: one ticket artifact with the folded state and provenance, one more collating any discussion. The local id becomes a redirect.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ticket/{id}/promote",
		Run: func(ctx context.Context, in ticketPromoteIn) (ticketPromoteOut, error) {
			if d.Local == nil {
				return ticketPromoteOut{}, fmt.Errorf("this forge keeps no local tickets: %w", ops.ErrUnavailable)
			}
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return ticketPromoteOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ticketPromoteOut{}, err
			}
			// A promoted id folds to nothing (the tombstone keeps no
			// changes), so the redirect is the probe that can still
			// tell "promoted" from "never existed".
			if to, ok := d.Local.LocalTicketRedirect(in.Repo, in.ID); ok {
				return ticketPromoteOut{}, fmt.Errorf("already promoted to %.12s: %w", to, ops.ErrInvalid)
			}
			lt, ok, err := d.Local.LocalTicketByPrefix(in.Repo, in.ID)
			if err != nil {
				return ticketPromoteOut{}, err
			}
			if !ok {
				return ticketPromoteOut{}, fmt.Errorf("no local ticket %q: %w", in.ID, ops.ErrNotFound)
			}
			if repo.Zit == nil {
				return ticketPromoteOut{}, fmt.Errorf("this repository cannot write tickets: %w", ops.ErrUnavailable)
			}
			// Distillation, not replay (docs/local-tickets.md): the
			// folded state and description enter the log; the local
			// change-by-change history is working noise and stays out.
			fields := map[string]string{}
			for k, v := range lt.Fields {
				if k == "comment" || k == "cmimetype" || v == "" {
					continue
				}
				fields[k] = v
			}
			prov := fmt.Sprintf("reported by %s on %s; promoted from local ticket %.12s by %s",
				lt.Author, lt.Created, lt.ID, author(ctx))
			if desc := lt.Fields["comment"]; desc != "" {
				fields["comment"] = desc + "\n\n---\n" + prov
			} else {
				fields["comment"] = prov
			}
			if cm := lt.Fields["cmimetype"]; cm != "" {
				fields["cmimetype"] = cm
			} else {
				fields["cmimetype"] = authoredMimetype
			}
			var id, hash string
			if err := repo.write(func() (err error) {
				id, hash, err = repo.Zit.TicketNew(author(ctx), fields)
				return err
			}); err != nil {
				return ticketPromoteOut{}, err
			}
			// Tombstone BEFORE the discussion artifact: if the second
			// write fails, the fossil ticket merely lacks its collated
			// comments — where the reverse order could promote twice.
			if err := d.Local.LocalTicketPromoted(in.Repo, lt.ID, id); err != nil {
				return ticketPromoteOut{}, err
			}
			if len(lt.Comments) > 0 {
				var b strings.Builder
				for i, c := range lt.Comments {
					if i > 0 {
						b.WriteString("\n\n---\n\n")
					}
					fmt.Fprintf(&b, "%s on %s:\n\n%s", c.Login, c.Date, c.Body)
				}
				if err := repo.write(func() (err error) {
					_, err = repo.Zit.TicketSet(id, author(ctx),
						map[string]string{"icomment": b.String(), "mimetype": authoredMimetype})
					return err
				}); err != nil {
					return ticketPromoteOut{}, err
				}
			}
			if err := repo.synced(); err != nil {
				return ticketPromoteOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "promoted ticket " + lt.Fields["title"],
					"link":    d.link("/r/" + in.Repo + "/ticket/" + id)},
				TxnID: "activity:ticket-promote:" + id,
			})
			return ticketPromoteOut{ID: id, Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[ticketDiscardIn, ticketDiscardOut]{
		Name:  "ticket.discard",
		Caps:  "a",
		Doc:   "Delete a local ticket outright — the ability the artifact log can never offer, and the whole answer to tracker spam.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ticket/{id}/discard",
		Run: func(ctx context.Context, in ticketDiscardIn) (ticketDiscardOut, error) {
			if d.Local == nil {
				return ticketDiscardOut{}, fmt.Errorf("this forge keeps no local tickets: %w", ops.ErrUnavailable)
			}
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return ticketDiscardOut{}, err
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return ticketDiscardOut{}, err
			}
			if _, ok := d.Local.LocalTicketRedirect(in.Repo, in.ID); ok {
				return ticketDiscardOut{}, fmt.Errorf("promoted; the redirect stays: %w", ops.ErrInvalid)
			}
			lt, ok, err := d.Local.LocalTicketByPrefix(in.Repo, in.ID)
			if err != nil {
				return ticketDiscardOut{}, err
			}
			if !ok {
				return ticketDiscardOut{}, fmt.Errorf("no local ticket %q: %w", in.ID, ops.ErrNotFound)
			}
			if err := d.Local.LocalTicketDiscard(in.Repo, lt.ID); err != nil {
				return ticketDiscardOut{}, err
			}
			return ticketDiscardOut{OK: true}, nil
		},
	})
}

// localSummary shapes one local ticket as a listing row.
func localSummary(lt oltp.LocalTicket) ticketSummary {
	f := lt.Fields
	row := ticketSummary{ID: lt.ID, Title: f["title"], Status: f["status"],
		Updated: lt.Updated, Created: lt.Created, Type: f["type"],
		Severity: f["severity"], Priority: f["priority"], Subsystem: f["subsystem"],
		Resolution: f["resolution"], Author: lt.Author,
		Comments: len(lt.Comments), Local: true}
	if n := len(lt.Changes); n > 0 {
		row.LastUser = lt.Changes[n-1].User
	}
	return row
}

// localClosed mirrors the index's done-words fold for rows the index
// never sees.
func localClosed(status string) bool {
	s := strings.ToLower(status)
	for _, w := range index.ClosedStatuses {
		if s == w {
			return true
		}
	}
	return false
}

// localOpenTickets counts the repository's OPEN local tickets, for the
// Tickets tab badge.
//
// The badge counts open tickets (index.go's Counts), and the tracker
// page lists both tiers — so a badge drawn from the artifact index
// alone undercounts every repository with an intake queue. quarry's own
// read 42 beside a page saying 62 open. A number that disagrees with
// the page it labels is worse than no number, which is the same reason
// the forum badge folds in its companion's threads.
//
// Open is localClosed's fold, not a second opinion about the done
// words, and LocalTickets already drops promoted rows so a ticket that
// has become an artifact is counted once, by the index, not twice.
//
// Best-effort and never an error, like companionForumThreads: a count
// is decoration on a tab, and failing the counts op every repository
// page calls would turn a sick oltp store into a broken nav.
func (d Deps) localOpenTickets(repo string) int {
	if d.Local == nil {
		return 0
	}
	ls, err := d.Local.LocalTickets(repo)
	if err != nil {
		return 0
	}
	n := 0
	for _, lt := range ls {
		if !localClosed(lt.Fields["status"]) {
			n++
		}
	}
	return n
}

// localMatches applies ticket.list's report axes to a local row — the
// narrowing the index does in SQL, done in Go, because the intake queue
// is dozens of rows where the archive is thousands.
func localMatches(t ticketSummary, in ticketListIn) bool {
	closed := localClosed(t.Status)
	if in.State == "closed" && !closed {
		return false
	}
	if (in.State == "" || in.State == "open") && closed {
		return false
	}
	for _, p := range [][2]string{{in.Type, t.Type}, {in.Severity, t.Severity},
		{in.Priority, t.Priority}, {in.Subsystem, t.Subsystem},
		{in.Status, t.Status}, {in.Author, t.Author}} {
		if p[0] != "" && !strings.EqualFold(p[0], p[1]) {
			return false
		}
	}
	if in.Title != "" && !strings.Contains(strings.ToLower(t.Title), strings.ToLower(in.Title)) {
		return false
	}
	return true
}

// localFieldSet orders a change's fields for display — maps iterate
// randomly and a field table must not.
func localFieldSet(fields map[string]string, skipDescription bool) []index.TicketFieldSet {
	names := make([]string, 0, len(fields))
	for k := range fields {
		if localPerChangeField(k) || (skipDescription && (k == "comment" || k == "cmimetype")) {
			continue
		}
		names = append(names, k)
	}
	sort.Strings(names)
	out := make([]index.TicketFieldSet, 0, len(names))
	for _, k := range names {
		out = append(out, index.TicketFieldSet{Name: k, Value: fields[k]})
	}
	return out
}

func localPerChangeField(name string) bool {
	switch name {
	case "icomment", "mimetype", "login", "username":
		return true
	}
	return false
}

// localTicketGet answers ticket.get from the local tier when the
// artifact log has no such id. A promoted id answers with PromotedTo
// set and nothing else: the caller followed an old link, and the page
// it wants is the fossil ticket's.
func (d Deps) localTicketGet(repo *Repo, name, prefix string) (ticketGetOut, bool, error) {
	if d.Local == nil {
		return ticketGetOut{}, false, nil
	}
	if to, ok := d.Local.LocalTicketRedirect(name, prefix); ok {
		return ticketGetOut{PromotedTo: to}, true, nil
	}
	lt, ok, err := d.Local.LocalTicketByPrefix(name, prefix)
	if err != nil || !ok {
		return ticketGetOut{}, false, err
	}
	st := index.TicketState{ID: lt.ID, Created: lt.Created, Updated: lt.Updated,
		Description: lt.Fields["comment"], DescriptionMimetype: lt.Fields["cmimetype"],
		Fields: localFieldSet(lt.Fields, true)}
	for _, c := range lt.Comments {
		st.Comments = append(st.Comments, index.TicketComment{
			Date: c.Date, Login: c.Login, Mimetype: c.Mimetype, Body: c.Body})
	}
	for _, ch := range lt.Changes {
		st.Changes = append(st.Changes, index.TicketChange{
			Date: ch.Date, User: ch.User, Fields: localFieldSet(ch.Fields, false)})
	}
	out := ticketGetOut{Ticket: st, Local: true}
	if st.Description != "" {
		html, err := renderBody(repo.Index, name, st.DescriptionMimetype, []byte(st.Description))
		if err != nil {
			return ticketGetOut{}, false, err
		}
		out.DescriptionHTML = html
	}
	for _, c := range st.Comments {
		html, err := renderBody(repo.Index, name, c.Mimetype, []byte(c.Body))
		if err != nil {
			return ticketGetOut{}, false, err
		}
		out.Comments = append(out.Comments, ticketComment{
			Date: c.Date, Login: c.Login, Username: c.Username, HTML: html,
		})
	}
	return out, true, nil
}

// localTicketSet records a ticket.set change against the local tier,
// with the same field semantics as the artifact path.
func (d Deps) localTicketSet(ctx context.Context, in ticketSetIn, lt oltp.LocalTicket) (ticketSetOut, error) {
	fields := ticketSetFields(in)
	// The local tier has no schema of its own — a promoted ticket lands
	// in the same tracker — so the same vocabulary gates it. Asking the
	// artifact tier's index for the names is the whole point: a local
	// ticket must not be able to invent a column the tracker it will be
	// promoted into does not have.
	if repo, err := d.repoFor(ctx, in.Repo); err == nil {
		extra, ferr := extraTicketFields(authorableFields(repo), in.Fields)
		if ferr != nil {
			return ticketSetOut{}, ferr
		}
		for k, v := range extra {
			fields[k] = v
		}
	}
	if len(fields) == 0 {
		return ticketSetOut{}, fmt.Errorf("no fields to set: %w", ops.ErrInvalid)
	}
	if err := d.Local.LocalTicketSet(in.Repo, lt.ID, author(ctx), fields); err != nil {
		return ticketSetOut{}, err
	}
	// No artifact was written, so there is no hash to return — the
	// honest empty string, not an invented one. Nor an A card: a
	// local ticket cannot carry files, which the answer says.
	out := ticketSetOut{}
	if len(in.Attach) > 0 {
		out.AttachErrors = []string{"a local ticket cannot carry attachments until it is promoted"}
	}
	return out, nil
}

// attachRefusedLocally says why files did not travel with a ticket that
// stayed on the forge. A local ticket has no A card to hang them on,
// which the answer says rather than dropping them silently.
func attachRefusedLocally(attach []string) []string {
	if len(attach) == 0 {
		return nil
	}
	return []string{"a ticket on this forge's own tier cannot carry attachments until it is promoted"}
}

// ticketSetFields translates ticket.set's input into fossil's field
// vocabulary, shared by both tiers. A ticket CHANGE carries its own
// text under icomment with a plain "mimetype"; the description's is
// "comment" with "cmimetype". Two field names for the same question,
// which is fossil's schema and not ours to rename — and writing a
// reply into "comment" does not append it, it REPLACES the description.
func ticketSetFields(in ticketSetIn) map[string]string {
	fields := map[string]string{}
	for k, v := range map[string]string{
		"title": in.Title, "status": in.Status,
		"resolution": in.Resolution, "type": in.Type,
		"severity": in.Severity, "priority": in.Priority,
		"subsystem": in.Subsystem, "foundin": in.FoundIn,
		"icomment": in.Comment,
	} {
		if v != "" {
			fields[k] = v
		}
	}
	if in.Comment != "" {
		fields["mimetype"] = authoredMimetype
	}
	if in.Description != "" {
		fields["comment"] = in.Description
		fields["cmimetype"] = authoredMimetype
	}
	return fields
}