Files
杨成锴 22c5a858a1 fix(server): stop counting disabled conventions as completed work (#1152)
Merged after two codex review rounds (converged) on top of the community-loop supply-chain/static review. Review found two narrow follow-ups — the guest item-grant leg of the grouped terminal query keeps pre-PR over-matching semantics, and the standup/changelog display layer hardcodes `status` — both pre-existing edges, filed internally as follow-up work. Thanks @asjdf for a well-tested fix, and for honoring the per-collection terminal_options contract on both the CLI and server paths.
2026-08-18 10:44:50 -04:00

271 lines
10 KiB
Go

package models
import (
"encoding/json"
"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.
var DefaultTerminalStatuses = []string{
"done", "completed", "resolved", "cancelled", "rejected",
"wontfix", "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.
//
// A future task can make this per-collection configurable; for now it's a
// sensible global default (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,
"won't fix": true,
"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
// NegativeTerminals — the values that count as completed *work*.
func PositiveTerminalValuesForDoneField(
schema CollectionSchema,
settings CollectionSettings,
) (fieldKey string, values []string) {
fieldKey, terminals := TerminalValuesForDoneField(schema, settings)
values = make([]string, 0, len(terminals))
for _, v := range terminals {
if IsNegativeTerminal(v) {
continue
}
values = append(values, v)
}
return fieldKey, values
}
// 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 != "" {
_ = json.Unmarshal([]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
}