Skip to content
quarry Artifact [4c5e726783]
Login

Artifact [4c5e726783]

Artifact 4c5e726783baaeed93a98198283f59a79a0178bb47fc8e8203d9afae3da4bc39:


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