// ticketfields.go lets quarry author the fields a tracker actually
// defines, not only the ten fossil ships with.
//
// A fossil tracker's schema is its own: a repository may define any
// columns it likes, and quarry already READS all of them. It could
// write only fossil's defaults, which made quarry a second-class
// client of its own stores — tcl's tracker carries fourteen fields
// including assignee, and a ticket there could be read here and only
// edited in fossil.
//
// The authorable set is the union of what the tracker's own tickets
// carry (index.TicketFieldNames) and what this build knows how to
// offer, less two groups: the fields with their own input, because two
// sources for one value is a bug waiting to happen, and the fields
// fossil deliberately withholds, because reproducing a tracker
// faithfully must not mean authoring what it hides.
package api
import (
"fmt"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// dedicatedFields have their own named input on every write op. A
// generic map that also carried them would give one value two doors,
// and the two would disagree the first time a client set both.
var dedicatedFields = map[string]bool{
"title": true, "status": true, "resolution": true,
"comment": true, "cmimetype": true, "type": true,
"severity": true, "priority": true, "subsystem": true,
"foundin": true, "icomment": true,
}
// withheldFields are the ones fossil does not show and quarry does not
// author. private_contact is labelled "Not publicly visible" in
// fossil's own template and gated behind capability e; writing it
// through a forge form would put an address into a synchronized,
// permanent record from a page that never showed it.
var withheldFields = map[string]bool{"private_contact": true, "contact": true}
// maxExtraFields bounds one change. A tracker with more than this many
// columns exists, but a single write that sets fifty of them is a
// client bug, not a triage step.
const maxExtraFields = 24
// extraFieldValue is the longest value the generic door accepts. The
// named inputs carry their own limits; this is the one that applies to
// a column this build has never heard of, and it is deliberately short
// — a tracker column is a label, and a body belongs in the comment.
const extraFieldValue = 200
// extraTicketFields validates a generic field map against the
// tracker's own vocabulary and returns what may be written.
//
// known is the set of field names the tracker carries. A name outside
// it is REFUSED rather than written: fossil creates a column on demand,
// so an unchecked map would turn every ticket write into a
// schema-migration endpoint, and one typo would leave a tracker with an
// "assigne" column forever.
func extraTicketFields(known map[string]bool, in map[string]string) (map[string]string, error) {
if len(in) == 0 {
return nil, nil
}
if len(in) > maxExtraFields {
return nil, fmt.Errorf("%d fields in one change; the limit is %d: %w",
len(in), maxExtraFields, ops.ErrInvalid)
}
out := map[string]string{}
for name, value := range in {
name = strings.ToLower(strings.TrimSpace(name))
if err := checkFieldName(name, known); err != nil {
return nil, err
}
if len(value) > extraFieldValue {
return nil, fmt.Errorf("field %q: %d characters; the limit is %d: %w",
name, len(value), extraFieldValue, ops.ErrInvalid)
}
// An empty value CLEARS the field, which is the only way to
// unset one — fossil's J card with an empty value does exactly
// that, and a form that could set but never clear would trap a
// wrong assignee on a ticket forever.
out[name] = value
}
return out, nil
}
// checkFieldName is the one place a name is judged.
func checkFieldName(name string, known map[string]bool) error {
switch {
case name == "number":
// The forge writes a ticket's number; a caller naming one
// could squat a number or shadow another ticket's (54c5175f).
return fmt.Errorf("a ticket's number is given by the forge, not set: %w", ops.ErrInvalid)
case name == "":
return fmt.Errorf("a field with no name: %w", ops.ErrInvalid)
case dedicatedFields[name]:
return fmt.Errorf("field %q has its own input; set it there: %w", name, ops.ErrInvalid)
case withheldFields[name]:
return fmt.Errorf("field %q is withheld by the tracker and not authored here: %w",
name, ops.ErrInvalid)
case !plainFieldName(name):
return fmt.Errorf("field %q is not a field name: %w", name, ops.ErrInvalid)
case !known[name]:
return fmt.Errorf("this tracker defines no field %q: %w", name, ops.ErrNotFound)
}
return nil
}
// plainFieldName holds the shape of a fossil ticket column: lowercase
// letters, digits and underscore, starting with a letter. Anything else
// would have to be quoted in the tracker's own SQL, which is a column
// no tracker has.
func plainFieldName(s string) bool {
if len(s) == 0 || len(s) > 40 {
return false
}
for i, c := range s {
switch {
case c >= 'a' && c <= 'z':
case c == '_' || (c >= '0' && c <= '9'):
if i == 0 {
return false
}
default:
return false
}
}
return true
}
// authorableFields is the set a write may name: every field the
// tracker's tickets carry, less the dedicated and the withheld.
//
// A tracker with no tickets yet authors nothing through this door, and
// that is the honest answer — there is no schema to read, and inventing
// columns on a fresh tracker is exactly what the check exists to stop.
func authorableFields(repo *Repo) map[string]bool {
if repo == nil || repo.Index == nil {
return nil
}
uses, err := repo.Index.TicketFieldNames()
if err != nil {
return nil
}
out := map[string]bool{}
for _, u := range uses {
if dedicatedFields[u.Name] || withheldFields[u.Name] {
continue
}
out[u.Name] = true
}
return out
}