mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
c0e66c6700
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
404 lines
15 KiB
Go
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
|
|
}
|