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"`
}
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
}
if _, err := d.repoFor(ctx, in.Repo); 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
}
}
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": "/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": "/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
}
// 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)
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.
return ticketSetOut{}, nil
}
// 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
}