Files
pad/internal/models/terminal.go
T
xarmian c0e66c6700 fix(models): the fallback terminal lists cover every "won't fix" spelling (BUG-3546) (#2033)
A collection whose status option is the hyphenated wont-fix, with no
terminal_options declared, never closed those items: the fallback lists
spelled it only wontfix / won't fix. Seen in the APM workspace, where plans
needed --force to close over wont-fix children.

DefaultTerminalStatuses and NegativeTerminals now list wontfix, wont-fix,
won't fix and won't-fix: exact spellings rather than a normalizing compare,
because the list also feeds SQL IN (...) filters, which match exactly. The
web mirrors (types/index.ts) carry the same values, and
TestTerminalListsMatchWeb parses them and fails on any drift; until now a
comment asked for that. The CLI and board colour the new spellings as
negative terminals. Pad's own web Bug Tracker template, which uses wont-fix,
now declares terminal_options [fixed, wont-fix] and abandoned_options
[wont-fix].

childProgress.test.ts pinned DEFAULT_ABANDONED_STATUSES to the old literal;
its expected value now includes the new spellings.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 18:59:25 -04:00

404 lines
15 KiB
Go

package models
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// safeDoneFieldKey bounds what we'll interpolate into the JSON path of a
// SQL query (e.g. `json_extract(i.fields, '$.<key>')`). Collection schemas
// are persisted without backend-side key validation, so a user could in
// principle store a key containing quotes or path metacharacters; we
// refuse to resolve done-detection to anything outside this shape and
// fall back to "status". The pattern mirrors the convention already in
// use for search filters in internal/server/handlers_search.go.
var safeDoneFieldKey = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`)
// DefaultTerminalStatuses is the fallback list used when a collection's
// done field has no `terminal_options` declared on its schema. This is the
// union of all historically hardcoded terminal status values across the
// codebase.
//
// "Won't fix" is listed in each spelling a collection uses (BUG-3546): these
// values reach SQL `IN (…)` filters, which match exactly, so a normalizing
// comparison in Go alone would leave the SQL paths disagreeing with it. The
// web mirrors this list (DEFAULT_TERMINAL_STATUSES in
// web/src/lib/types/index.ts); TestTerminalListsMatchWeb keeps them equal.
var DefaultTerminalStatuses = []string{
"done", "completed", "resolved", "cancelled", "rejected",
"wontfix", "wont-fix", "won't fix", "won't-fix",
"fixed", "implemented", "archived", "disabled", "deprecated",
}
// DoneFieldKey resolves which field on a collection's schema represents
// "is this item done?". The resolution is:
//
// 1. If CollectionSettings.BoardGroupBy names a `select` field on the
// schema, use that. This lets a collection whose board is organized
// by e.g. "resolution" naturally drive done-detection from the same
// field.
//
// 2. Otherwise, fall back to the literal key "status". Every collection
// shipped today groups by status by default, so this preserves
// existing behavior for all pre-TASK-604 collections.
//
// Only `select` (single-value) is accepted as a done field. `multi_select`
// stores its values as a JSON array, and both the Go-side membership check
// and the SQL `IN (…)` filter assume a scalar string — naively accepting
// multi_select would cause done-detection to silently miss items whose
// terminal value is one of several in the array. If array semantics are
// ever needed, they belong in a follow-up task so the Go and SQL paths
// can be updated together with a clear "any terminal value → done" rule.
//
// The function does not assume the resolved key actually exists on the
// item — callers read `items.fields[key]` and a missing field just means
// the item is treated as not terminal, which is the safe default.
func DoneFieldKey(schema CollectionSchema, settings CollectionSettings) string {
candidate := strings.TrimSpace(settings.BoardGroupBy)
if candidate == "" {
return "status"
}
// Refuse to resolve to a key that would be unsafe to embed in a
// SQL JSON path. Callers pass the returned key to dialect-specific
// JSONExtractText builders that interpolate it as a string literal;
// schema keys are not validated on write, so we validate here.
if !safeDoneFieldKey.MatchString(candidate) {
return "status"
}
for _, f := range schema.Fields {
if f.Key == candidate && f.Type == "select" {
return candidate
}
}
return "status"
}
// TerminalValuesForDoneField returns the resolved done-field key and the
// list of terminal values for that field. If the resolved field has no
// terminal_options set on the schema, falls back to DefaultTerminalStatuses
// so existing collections without schema-declared terminals continue to
// work.
func TerminalValuesForDoneField(
schema CollectionSchema,
settings CollectionSettings,
) (fieldKey string, values []string) {
fieldKey = DoneFieldKey(schema, settings)
for _, f := range schema.Fields {
// Restricted to `select` — see DoneFieldKey for why multi_select
// is deliberately rejected.
if f.Key == fieldKey && f.Type == "select" {
if len(f.TerminalOptions) > 0 {
return fieldKey, f.TerminalOptions
}
break
}
}
return fieldKey, DefaultTerminalStatuses
}
// NegativeTerminals are terminal values that represent a NON-shipping close
// (the work didn't complete positively). Project report throughput and the
// standup/changelog "completed" lists exclude these so a rejected idea,
// cancelled task, or disabled convention is not counted as completed work.
// Matched case-insensitively.
//
// Since BUG-2347 a field's own `abandoned_options` REPLACES this list for
// that field; this remains the fallback for fields that declare none
// (PLAN-1628). "disabled" is included so collections
// whose only schema-declared terminal is "disabled" (stock Conventions) do
// not report turning a rule off as throughput (BUG-1049).
var NegativeTerminals = map[string]bool{
"rejected": true,
"cancelled": true,
"canceled": true,
"wontfix": true,
"wont-fix": true, // BUG-3546
"won't fix": true,
"won't-fix": true, // BUG-3546
"duplicate": true,
"declined": true,
"abandoned": true,
"disabled": true,
}
// IsNegativeTerminal reports whether value is a non-shipping terminal.
func IsNegativeTerminal(value string) bool {
return NegativeTerminals[strings.ToLower(strings.TrimSpace(value))]
}
// PositiveTerminalValuesForDoneField is TerminalValuesForDoneField minus the
// values that close WITHOUT delivering — the values that count as completed
// *work*. It is the ONE resolver for that question (BUG-2347): the changelog,
// standup, the CLI's changelog copy and report throughput all call it.
//
// Which values are "abandoned": the done field's own `abandoned_options` when
// it declares any; otherwise the global NegativeTerminals names. The global
// list is a guess about vocabulary and cannot know a collection's own words —
// "overturned" on plans, "withdrawn" on candidates — which is why a field
// that declares its own list is never second-guessed by it.
func PositiveTerminalValuesForDoneField(
schema CollectionSchema,
settings CollectionSettings,
) (fieldKey string, values []string) {
fieldKey, terminals := TerminalValuesForDoneField(schema, settings)
abandoned := declaredAbandoned(schema, fieldKey)
values = make([]string, 0, len(terminals))
for _, v := range terminals {
if abandoned != nil {
if abandoned[strings.ToLower(strings.TrimSpace(v))] {
continue
}
} else if IsNegativeTerminal(v) {
continue
}
values = append(values, v)
}
return fieldKey, values
}
// AbandonedValuesForDoneField is the complement of
// PositiveTerminalValuesForDoneField within the terminal set: the terminal
// values that close an item WITHOUT delivering it. Derived from that resolver
// rather than decided separately, so "shipped" and "abandoned" cannot disagree
// about a value.
//
// Progress uses it (BUG-3195): an abandoned child is not part of the work any
// more, so it leaves both the done count and the total.
func AbandonedValuesForDoneField(
schema CollectionSchema,
settings CollectionSettings,
) (fieldKey string, values []string) {
fieldKey, terminals := TerminalValuesForDoneField(schema, settings)
_, positives := PositiveTerminalValuesForDoneField(schema, settings)
return fieldKey, subtractValues(terminals, positives)
}
// DefaultAbandonedStatuses is AbandonedValuesForDoneField for the fallback
// case with no usable schema: the default terminal list minus its shipping
// values.
func DefaultAbandonedStatuses() []string {
_, values := AbandonedValuesForDoneField(CollectionSchema{}, CollectionSettings{})
return values
}
// IsAbandonedItem reports whether an item's done-field value is one of its
// collection's abandoned values. The IsTerminalItem shape, over the
// abandoned subset.
func IsAbandonedItem(
itemFields map[string]any,
schema CollectionSchema,
settings CollectionSettings,
) bool {
key, values := AbandonedValuesForDoneField(schema, settings)
s, ok := itemFields[key].(string)
if !ok {
return false
}
return containsFold(values, s)
}
// IsAbandonedStatusDefault is IsTerminalStatusDefault over the abandoned
// subset, for callers with no collection context.
func IsAbandonedStatusDefault(status string) bool {
return containsFold(DefaultAbandonedStatuses(), status)
}
func subtractValues(all, remove []string) []string {
out := make([]string, 0, len(all))
for _, v := range all {
if !containsFold(remove, v) {
out = append(out, v)
}
}
return out
}
// containsFold compares by strings.ToLower, the rule IsTerminalItem and the
// SQL LOWER(...) IN (...) filters use, so the abandoned check cannot match a
// value the terminal check does not.
func containsFold(values []string, s string) bool {
lower := strings.ToLower(s)
for _, v := range values {
if strings.ToLower(v) == lower {
return true
}
}
return false
}
// declaredAbandoned returns the done field's abandoned_options as a
// lowercased set, or nil when the field declares none (the fallback case).
func declaredAbandoned(schema CollectionSchema, fieldKey string) map[string]bool {
for _, f := range schema.Fields {
if f.Key != fieldKey || len(f.AbandonedOptions) == 0 {
continue
}
set := make(map[string]bool, len(f.AbandonedOptions))
for _, v := range f.AbandonedOptions {
set[strings.ToLower(strings.TrimSpace(v))] = true
}
return set
}
return nil
}
// ValidateAbandonedOptions refuses a schema whose abandoned_options name a
// value that is not one of the same field's terminal_options (BUG-2347). An
// abandoned value that is not terminal would never be reached by the
// completed-work resolver, so declaring it is a mistake the author should
// hear about rather than a silent no-op.
func ValidateAbandonedOptions(schema CollectionSchema) error {
for _, f := range schema.Fields {
if len(f.AbandonedOptions) == 0 {
continue
}
terminal := make(map[string]bool, len(f.TerminalOptions))
for _, t := range f.TerminalOptions {
terminal[strings.ToLower(strings.TrimSpace(t))] = true
}
for _, a := range f.AbandonedOptions {
if !terminal[strings.ToLower(strings.TrimSpace(a))] {
return fmt.Errorf("field %q: abandoned_options value %q is not one of its terminal_options", f.Key, a)
}
}
}
return nil
}
// CollectionCompletedWorkValues unmarshals a collection's persisted schema
// and settings JSON (best-effort; parse failures fall back the same way
// TerminalValuesForDoneField does) and returns the done-field key plus the
// positive terminal values that count as completed work.
func CollectionCompletedWorkValues(schemaJSON, settingsJSON string) (fieldKey string, values []string) {
var schema CollectionSchema
var settings CollectionSettings
if schemaJSON != "" {
_ = UnmarshalItemFieldSchema([]byte(schemaJSON), &schema)
}
if settingsJSON != "" {
_ = json.Unmarshal([]byte(settingsJSON), &settings)
}
return PositiveTerminalValuesForDoneField(schema, settings)
}
// TerminalPlaceholdersForDoneField is a SQL-layer convenience that returns
// the done-field key plus the placeholder + args pair needed for an IN
// clause. All values are lowercased to match the WHERE clause pattern used
// across the codebase (LOWER(json_extract(...)) IN (?, ?, ?)).
func TerminalPlaceholdersForDoneField(
schema CollectionSchema,
settings CollectionSettings,
) (fieldKey string, placeholders string, args []any) {
key, values := TerminalValuesForDoneField(schema, settings)
ph := make([]string, len(values))
ar := make([]any, len(values))
for i, v := range values {
ph[i] = "?"
ar[i] = strings.ToLower(v)
}
return key, strings.Join(ph, ","), ar
}
// IsTerminalItem reports whether an item's fields map indicates the item
// is in a terminal state for its collection. This is the canonical Go-side
// "is done" check when the caller has both the item's parsed fields and
// the collection's schema + settings in scope.
//
// The value at the resolved done-field key is expected to be a scalar
// string — DoneFieldKey only resolves to `select` fields, which round-
// trip through the fields JSON as a single string. Non-string values
// (e.g. an array from a misconfigured multi_select) return false rather
// than trying to infer semantics.
func IsTerminalItem(
itemFields map[string]any,
schema CollectionSchema,
settings CollectionSettings,
) bool {
key, values := TerminalValuesForDoneField(schema, settings)
raw, ok := itemFields[key]
if !ok {
return false
}
s, ok := raw.(string)
if !ok {
return false
}
lower := strings.ToLower(s)
for _, v := range values {
if strings.ToLower(v) == lower {
return true
}
}
return false
}
// ── Back-compat wrappers ────────────────────────────────────────────────
// The original API (status-only) stays in place so callers that don't yet
// have CollectionSettings in scope keep working unchanged. Internally each
// of these delegates to the new done-field-aware implementation with
// empty settings — which resolves the done field to "status", matching
// pre-TASK-604 behavior byte-for-byte.
// TerminalStatusesFromSchema extracts terminal status options from a
// CollectionSchema. If the `status` field has TerminalOptions set, returns
// those. Otherwise returns DefaultTerminalStatuses.
//
// Deprecated: prefer TerminalValuesForDoneField when settings are in
// scope. This wrapper forces done-field resolution to the literal
// "status" key; callers that want to honor the collection's configured
// board_group_by should migrate to the settings-aware API.
func TerminalStatusesFromSchema(schema CollectionSchema) []string {
_, values := TerminalValuesForDoneField(schema, CollectionSettings{})
return values
}
// IsTerminalStatus checks whether a status string is terminal given a
// schema. Like the status extract above, this is hardcoded to the `status`
// field — it takes a pre-extracted status string and checks membership
// against that field's terminal options. Use when you already know you're
// working with the status field specifically (e.g. link-payload joins).
func IsTerminalStatus(status string, schema CollectionSchema) bool {
lower := strings.ToLower(status)
for _, ts := range TerminalStatusesFromSchema(schema) {
if strings.ToLower(ts) == lower {
return true
}
}
return false
}
// IsTerminalStatusDefault checks using the default fallback list (for
// cases where no collection schema is available).
func IsTerminalStatusDefault(status string) bool {
lower := strings.ToLower(status)
for _, ts := range DefaultTerminalStatuses {
if ts == lower {
return true
}
}
return false
}
// TerminalStatusPlaceholders returns a comma-separated placeholder string
// and the corresponding args slice for use in SQL IN clauses. Like the
// *FromSchema variant it is hardcoded to the `status` field; prefer
// TerminalPlaceholdersForDoneField when settings are in scope.
func TerminalStatusPlaceholders(schema CollectionSchema) (string, []any) {
_, placeholders, args := TerminalPlaceholdersForDoneField(schema, CollectionSettings{})
return placeholders, args
}
// DefaultTerminalStatusPlaceholders returns placeholders and args for the
// default terminal statuses list.
func DefaultTerminalStatusPlaceholders() (string, []any) {
placeholders := make([]string, len(DefaultTerminalStatuses))
args := make([]any, len(DefaultTerminalStatuses))
for i, s := range DefaultTerminalStatuses {
placeholders[i] = "?"
args[i] = s
}
return strings.Join(placeholders, ","), args
}