Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 36 additions & 38 deletions cmd/pad/cmd_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,40 @@ func nextCmd() *cobra.Command {
}
}

// listCompletedWorkSince fetches items that reached a *positive* terminal
// since cutoff. Terminal values are resolved per collection via
// models.CollectionCompletedWorkValues — KEEP IN SYNC with
// server.listTerminalItemsSince (BUG-1049). Best-effort per collection
// and status: a list error skips that query rather than failing the
// whole report.
func listCompletedWorkSince(client *cli.Client, ws string, cutoff time.Time, limit int) []models.Item {
colls, err := client.ListCollections(ws)
if err != nil {
return nil
}
var out []models.Item
for _, c := range colls {
field, values := models.CollectionCompletedWorkValues(c.Schema, c.Settings)
for _, status := range values {
params := url.Values{
field: {status},
"sort": {"updated_at:desc"},
"limit": {strconv.Itoa(limit)},
}
items, err := client.ListCollectionItems(ws, c.Slug, params)
if err != nil {
continue
}
for _, item := range items {
if item.UpdatedAt.After(cutoff) {
out = append(out, item)
}
}
}
}
return out
}

// --- standup ---

func standupCmd() *cobra.Command {
Expand Down Expand Up @@ -315,26 +349,8 @@ func standupCmd() *cobra.Command {
return fmt.Errorf("parsing dashboard: %w", err)
}

// Fetch recently completed items (terminal statuses)
doneStatuses := models.DefaultTerminalStatuses
var completedItems []models.Item
cutoff := time.Now().AddDate(0, 0, -days)

for _, status := range doneStatuses {
items, err := client.ListItems(ws, url.Values{
"status": {status},
"sort": {"updated_at:desc"},
"limit": {"20"},
})
if err != nil {
continue
}
for _, item := range items {
if item.UpdatedAt.After(cutoff) {
completedItems = append(completedItems, item)
}
}
}
completedItems := listCompletedWorkSince(client, ws, cutoff, 20)

// Fetch in-progress items
inProgressItems, err := client.ListItems(ws, url.Values{
Expand Down Expand Up @@ -689,25 +705,7 @@ func changelogCmd() *cobra.Command {
cutoff = time.Now().AddDate(0, 0, -days)
}

// Fetch completed items across all terminal statuses
doneStatuses := models.DefaultTerminalStatuses
var allItems []models.Item

for _, status := range doneStatuses {
items, err := client.ListItems(ws, url.Values{
"status": {status},
"sort": {"updated_at:desc"},
"limit": {"100"},
})
if err != nil {
continue
}
for _, item := range items {
if item.UpdatedAt.After(cutoff) {
allItems = append(allItems, item)
}
}
}
allItems := listCompletedWorkSince(client, ws, cutoff, 100)

// Filter by parent if specified
filterParent := parentRef
Expand Down
61 changes: 61 additions & 0 deletions internal/models/terminal.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package models

import (
"encoding/json"
"regexp"
"strings"
)
Expand Down Expand Up @@ -89,6 +90,66 @@ func TerminalValuesForDoneField(
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
Expand Down
65 changes: 65 additions & 0 deletions internal/models/terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,68 @@ func TestIsTerminalStatusDefault(t *testing.T) {
t.Fatal("expected 'in-progress' to NOT be default-terminal")
}
}

func TestIsNegativeTerminal(t *testing.T) {
if !IsNegativeTerminal("disabled") {
t.Fatal("expected 'disabled' to be a negative terminal (BUG-1049)")
}
if !IsNegativeTerminal("Rejected") {
t.Fatal("expected case-insensitive negative-terminal match")
}
if IsNegativeTerminal("done") {
t.Fatal("expected 'done' to remain a positive terminal")
}
}

func TestPositiveTerminalValuesForDoneField_DropsNegatives(t *testing.T) {
// Stock Conventions: the only schema-declared terminal is "disabled",
// which is a non-shipping close. Completed-work queries must see an
// empty positive set rather than treating the disabled rule as work.
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "status",
Type: "select",
Options: []string{"active", "draft", "disabled"},
TerminalOptions: []string{"disabled"},
},
},
}
key, values := PositiveTerminalValuesForDoneField(schema, CollectionSettings{})
if key != "status" {
t.Fatalf("expected key 'status', got %q", key)
}
if len(values) != 0 {
t.Fatalf("expected no positive terminals for Conventions, got %v", values)
}
}

func TestPositiveTerminalValuesForDoneField_KeepsDone(t *testing.T) {
schema := CollectionSchema{
Fields: []FieldDef{
{
Key: "status",
Type: "select",
Options: []string{"open", "done", "cancelled"},
TerminalOptions: []string{"done", "cancelled"},
},
},
}
_, values := PositiveTerminalValuesForDoneField(schema, CollectionSettings{})
if !reflect.DeepEqual(values, []string{"done"}) {
t.Fatalf("expected only 'done' (cancelled is negative), got %v", values)
}
}

func TestCollectionCompletedWorkValues_ParsesJSON(t *testing.T) {
key, values := CollectionCompletedWorkValues(
`{"fields":[{"key":"status","type":"select","options":["open","shipped"],"terminal_options":["shipped"]}]}`,
`{}`,
)
if key != "status" {
t.Fatalf("expected key 'status', got %q", key)
}
if !reflect.DeepEqual(values, []string{"shipped"}) {
t.Fatalf("expected [shipped], got %v", values)
}
}
80 changes: 69 additions & 11 deletions internal/server/handlers_project_intel.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,23 +122,81 @@ func (s *Server) projectIntelVisibility(r *http.Request, workspaceID string) (co
return collIDs, itemIDs, visibleIDs, nil
}

// listTerminalItemsSince fetches items in each of models.DefaultTerminalStatuses
// (one ListItems call per status — mirrors the CLI's loop rather than OR-ing
// statuses into a single query), keeping only items updated after cutoff, up
// to limit items considered per status. A store error for one status is
// swallowed and the loop continues — matches the CLI's best-effort
// semantics: a transient failure on one status must not blank out the whole
// report. The MCP transport inherits this by proxying here (TASK-1916)
// rather than replicating the loop.
// listTerminalItemsSince fetches items that reached a *positive* terminal
// since cutoff. Terminal values are resolved per collection via
// models.CollectionCompletedWorkValues (schema terminal_options, minus
// models.NegativeTerminals) rather than iterating the global
// DefaultTerminalStatuses union — so a collection that only declares
// "shipped" is not scanned for "done", and a disabled convention is not
// counted as completed work (BUG-1049).
//
// One ListItems call per (done-field, value) pair, scoped to the
// collections that share that pair (mirrors the CLI's per-status
// best-effort loop rather than OR-ing values into a single query). A
// store error for one pair is swallowed and the loop continues: a
// transient failure on one status must not blank out the whole report.
// The MCP transport inherits this by proxying here (TASK-1916) rather
// than replicating the loop.
//
// KEEP IN SYNC with cmd/pad/cmd_project.go's listCompletedWorkSince.
func (s *Server) listTerminalItemsSince(
workspaceID string, collIDs, itemIDs []string, cutoff time.Time, limit int,
) []models.Item {
colls, err := s.store.ListCollections(workspaceID)
if err != nil {
return nil
}

// Resolve terminals for EVERY collection, including ones the caller
// only has an item-level grant on. ListItems treats CollectionIDs OR
// ItemIDs as the visibility filter; if we skipped those collections
// here we would never query their done-field values and a granted
// child in an item-grant-only collection would vanish from changelog
// (see TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection).
allowed := map[string]bool{}
restrict := collIDs != nil
if restrict {
for _, id := range collIDs {
allowed[id] = true
}
}

type queryKey struct {
field string
value string
}
groups := map[queryKey][]string{}
var order []queryKey
for _, c := range colls {
field, values := models.CollectionCompletedWorkValues(c.Schema, c.Settings)
for _, value := range values {
k := queryKey{field: field, value: value}
if _, exists := groups[k]; !exists {
order = append(order, k)
}
groups[k] = append(groups[k], c.ID)
}
}

var out []models.Item
for _, status := range models.DefaultTerminalStatuses {
for _, k := range order {
var queryCollIDs []string
if restrict {
for _, id := range groups[k] {
if allowed[id] {
queryCollIDs = append(queryCollIDs, id)
}
}
if len(queryCollIDs) == 0 && len(itemIDs) == 0 {
continue
}
} else {
queryCollIDs = groups[k]
}
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
CollectionIDs: collIDs,
CollectionIDs: queryCollIDs,
ItemIDs: itemIDs,
Fields: map[string]string{"status": status},
Fields: map[string]string{k.field: k.value},
Sort: "updated_at:desc",
Limit: limit,
})
Expand Down
Loading