mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
fix(server): an ordinary open backlog fills Up next and pad project ready (BUG-3453) (#1854)
A moved-in backlog of open tasks and bugs with no priority set, bugs in their initial status `new`, answered "No ready items found": only the literal status `open` counted, and an open item outside an active plan needed a high/critical priority (BUG-1082's "without flooding", though the list is capped at three). Such items now fill the slots the existing tiers leave empty, from a fallback tier below every item admitted before, so a workspace that filled three slots gets the same list. The tier admits items whose collection is not a system collection and declares `priority` or `severity` (lead-ruled; across every built-in template that is Tasks, Features, Backlog and Bugs), whose done field is not terminal, that are not in progress and not blocked. It ranks by priority, else severity, then age. `ready`'s help and the MCP `ready` description said it returns the full actionable backlog; it returns the same capped top three as `next`, and now says so. The empty state says why and points at `pad project stale`. ListCollectionsMinimal also returns is_system. ToolSurfaceVersion 0.70 (behavior bump on the v0.49 grounds). Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
@@ -390,7 +390,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
|
||||
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
|
||||
the per-user clients (including Codex) and skip it.
|
||||
|
||||
**Tool catalog (v0.69)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
**Tool catalog (v0.70)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
|
||||
| Tool | Actions |
|
||||
|---|---|
|
||||
@@ -414,7 +414,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
|
||||
and `pad://_meta/version`.
|
||||
|
||||
**Stability contract.** The catalog is versioned
|
||||
(`tool_surface_version: "0.69"`) and advertised in the initialize handshake and at
|
||||
(`tool_surface_version: "0.70"`) and advertised in the initialize handshake and at
|
||||
`pad://_meta/version`, so external agents can pin against it; the per-version
|
||||
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
|
||||
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
|
||||
|
||||
+11
-5
@@ -32,11 +32,14 @@ func readyCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "ready",
|
||||
Short: "Show actionable next items for an agent",
|
||||
Long: `List the items that Pad currently considers ready to work on.
|
||||
Long: `List up to three items Pad suggests working on next, best first.
|
||||
|
||||
This is the broader query-oriented counterpart to 'pad project next'. It reuses the
|
||||
dashboard's suggested-next logic and returns the current actionable backlog
|
||||
for active plans.`,
|
||||
It is the same ranked list as 'pad project next' and the dashboard's Up next:
|
||||
fired reminders, then overdue work, in-progress work, open items in active
|
||||
plans, and high or critical priority items. Any slot left over is filled with
|
||||
other open items from collections that track priority or severity (Tasks,
|
||||
Bugs, Features, Backlog), ranked by priority (else severity), then age.
|
||||
Blocked items are never listed; 'pad project stale' shows them.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
@@ -55,7 +58,10 @@ for active plans.`,
|
||||
}
|
||||
|
||||
if len(suggestions) == 0 {
|
||||
fmt.Println("No ready items found.")
|
||||
// With the fallback tier (BUG-3453) an empty answer means there
|
||||
// is genuinely nothing open and unblocked to work on.
|
||||
fmt.Println("Nothing ready: no open, unblocked items in a collection that tracks priority or severity.")
|
||||
fmt.Println("Run 'pad project stale' for blocked or stalled work.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -556,7 +556,7 @@
|
||||
"idempotentHint": true,
|
||||
"openWorldHint": false
|
||||
},
|
||||
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Actionable backlog: items Pad considers ready to work on now.\n The query-oriented counterpart to next (reuses the same\n suggested-next logic, returns the full list).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
|
||||
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Up to three items to work on next, best first: the same\n ranked list as next. Reminders, overdue, in-progress, active-\n plan and high/critical items lead; leftover slots are filled\n with other open items from collections that track priority\n or severity. Blocked items are excluded (see stale).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"action": {
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -556,7 +556,7 @@
|
||||
"idempotentHint": true,
|
||||
"openWorldHint": false
|
||||
},
|
||||
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Actionable backlog: items Pad considers ready to work on now.\n The query-oriented counterpart to next (reuses the same\n suggested-next logic, returns the full list).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
|
||||
"description": "Project intelligence — computed views over workspace state.\n\nActions:\n dashboard — Project overview: collections, active items, plans, attention,\n blockers, suggested next.\n Required: workspace.\n next — Recommended next item to work on (uses dashboard's suggestion logic).\n Required: workspace.\n ready — Up to three items to work on next, best first: the same\n ranked list as next. Reminders, overdue, in-progress, active-\n plan and high/critical items lead; leftover slots are filled\n with other open items from collections that track priority\n or severity. Blocked items are excluded (see stale).\n Required: workspace.\n stale — Items needing attention — stalled, blocked, overdue, or\n otherwise falling out of the active workflow.\n Required: workspace.\n standup — Daily standup report (recent done, in-progress, blockers).\n Required: workspace.\n Optional: days (default 1).\n changelog — Changelog of completed items.\n Required: workspace.\n Optional: days (default 7), since (ISO date), parent (item ref to scope).\n Use parent=PLAN-N to generate a release-notes view for one plan.\n report — Windowed project report: created-vs-completed throughput, net\n flow, completed-by-collection, current status distribution.\n Required: workspace.\n Optional: window (day|week|2wk|month, default week),\n collections (comma-separated slugs, default all visible).\n activity — Recent workspace activity feed (non-streaming): what agents and\n users changed, with item refs, titles, and field-level change\n details. Use to catch up on what other agents did since you last\n worked.\n Required: workspace.\n Optional: limit (default 20), actor (user | agent),\n since (ISO date).\n\nUse pad_project when an agent needs to summarize progress, plan next work,\ncatch up on recent activity, or generate retro / standup / changelog reports.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"action": {
|
||||
|
||||
@@ -70,9 +70,11 @@ Actions:
|
||||
Required: workspace.
|
||||
next — Recommended next item to work on (uses dashboard's suggestion logic).
|
||||
Required: workspace.
|
||||
ready — Actionable backlog: items Pad considers ready to work on now.
|
||||
The query-oriented counterpart to next (reuses the same
|
||||
suggested-next logic, returns the full list).
|
||||
ready — Up to three items to work on next, best first: the same
|
||||
ranked list as next. Reminders, overdue, in-progress, active-
|
||||
plan and high/critical items lead; leftover slots are filled
|
||||
with other open items from collections that track priority
|
||||
or severity. Blocked items are excluded (see stale).
|
||||
Required: workspace.
|
||||
stale — Items needing attention — stalled, blocked, overdue, or
|
||||
otherwise falling out of the active workflow.
|
||||
|
||||
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
|
||||
|
||||
If the user is asking general code questions with no project-management thread, you don't need this server.
|
||||
|
||||
## Tool surface (v0.69)
|
||||
## Tool surface (v0.70)
|
||||
|
||||
Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total.
|
||||
|
||||
@@ -37,7 +37,7 @@ In `relation_targets`, a `multi_relation` key carries a JSON **ARRAY** of the sa
|
||||
|
||||
- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.
|
||||
- `pad_collection` — Collections: list / create / update / delete.
|
||||
- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the actionable backlog and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).
|
||||
- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).
|
||||
- `pad_role` — Agent roles: list / create / update / delete.
|
||||
- `pad_search` — Full-text search across items: query.
|
||||
- `pad_playbook` — Invokable procedures: list / get / run / match. Use `run` to bind args against a playbook's declared spec and get the rendered body back; side-effect-free. `run` refuses a playbook whose status isn't `active` (a draft still being authored) with a `playbook_not_active` error — pass `allow_draft: true` to override. Both `run` and `get` echo the playbook's `status`. Both may also carry `content_state` (`"applied_pending_flush"`, or `"superseded_set_aside"` as under `pad_item`), meaning the BODY you are about to execute is behind the item's live collaborative document — an editor holds unflushed edits, so the steps may be superseded. It is a reason to re-read or to ask, not a reason to refuse to run; the body is still the best the server has. `match` finds a playbook from text.
|
||||
|
||||
+15
-1
@@ -1738,6 +1738,20 @@ const CmdhelpVersion = "0.1"
|
||||
// caller cannot read embeds nothing; a failed fetch stays non-fatal,
|
||||
// as on stdio. Stdio is unchanged.
|
||||
//
|
||||
// 0.70 — BUG-3453 (lead ruling). BEHAVIOR bump on the v0.49 grounds
|
||||
// (a default call's content changed, with no name, enum or param
|
||||
// moved): `pad_project.action=next` and `ready`, which return the
|
||||
// dashboard's suggested-next list, now fill any slot the existing tiers
|
||||
// leave empty with ordinary open items, from collections that are not
|
||||
// system collections and declare `priority` or `severity`. Open means
|
||||
// the collection's done field is not terminal and the item is not in
|
||||
// progress, so an initial status like `new` counts. They rank by
|
||||
// priority (else severity), then age, below every item admitted before,
|
||||
// so a workspace that filled three slots gets the same list; one that
|
||||
// got "nothing ready" for a moved-in backlog now gets it. The `ready`
|
||||
// description is corrected: it is the same capped top three as `next`,
|
||||
// not the full backlog.
|
||||
//
|
||||
// 0.69 — TASK-2314. BEHAVIOR bump on the v0.42 / v0.45 grounds (an
|
||||
// error CODE a caller receives changed, with no name, enum or param
|
||||
// moved): on the REMOTE transport, every route that needs a workspace
|
||||
@@ -1828,7 +1842,7 @@ const CmdhelpVersion = "0.1"
|
||||
// this surface can receive it; the entry exists so a future action does
|
||||
// not collapse it to permission_denied. When an action that can reach
|
||||
// it is added, that addition is the contract change and owns the bump.
|
||||
const ToolSurfaceVersion = "0.69"
|
||||
const ToolSurfaceVersion = "0.70"
|
||||
|
||||
// MetaVersionURI is the canonical URI of the queryable version document.
|
||||
// Lives outside the pad://workspace/{ws}/... namespace because it's a
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BUG-3453: a moved-in backlog of ordinary open tasks and bugs, with no
|
||||
// priority set and bugs in their initial status `new`, answered "No ready
|
||||
// items found" from `pad project ready` and an empty Up next. Only the literal
|
||||
// status `open` counted, and an open item outside an active plan needed a
|
||||
// high/critical priority. Such items now fill the slots the tiers above leave
|
||||
// empty; they never displace an item those tiers admit.
|
||||
|
||||
const bug3453BugsSchema = `{"fields":[` +
|
||||
`{"key":"status","type":"select","options":["new","fixing","fixed"],"terminal_options":["fixed"],"default":"new"},` +
|
||||
`{"key":"severity","type":"select","options":["low","medium","high","critical"]}]}`
|
||||
|
||||
func bug3453Bugs(t *testing.T, srv *Server, slug string) {
|
||||
t.Helper()
|
||||
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections", map[string]any{
|
||||
"name": "Bugs", "slug": "bugs", "prefix": "BUG", "schema": bug3453BugsSchema,
|
||||
}); rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create bugs: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func suggestionTitles(resp DashboardResponse) []string {
|
||||
out := make([]string, 0, len(resp.SuggestedNext))
|
||||
for _, s := range resp.SuggestedNext {
|
||||
out = append(out, s.ItemTitle)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBUG3453_MovedInBacklogIsReady(t *testing.T) {
|
||||
bothBackends(t, func(t *testing.T, srv *Server) {
|
||||
slug := createWSWithCollections(t, srv)
|
||||
bug3453Bugs(t, srv, slug)
|
||||
// The repro's backlog: a task given no priority (the Tasks schema
|
||||
// defaults it to medium) and bugs in their initial status `new` with
|
||||
// no severity, written down in this order.
|
||||
createItem(t, srv, slug, "bugs", map[string]any{"title": "Bug one", "fields": `{"status":"new"}`})
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "Task one", "fields": `{"status":"open"}`})
|
||||
createItem(t, srv, slug, "bugs", map[string]any{"title": "Bug two", "fields": `{"status":"new"}`})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
got := suggestionTitles(resp)
|
||||
// The medium-priority task first; the unranked bugs oldest first.
|
||||
want := []string{"Task one", "Bug one", "Bug two"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("suggested_next = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("suggested_next = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
for i, r := range []string{"Open task (medium priority)", "Open item", "Open item"} {
|
||||
if resp.SuggestedNext[i].Reason != r {
|
||||
t.Errorf("reason[%d] = %q, want %q", i, resp.SuggestedNext[i].Reason, r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBUG3453_FallbackRanksByPriorityOrSeverityThenAge(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
bug3453Bugs(t, srv, slug)
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "Old low task", "fields": `{"status":"open","priority":"low"}`})
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "Medium task", "fields": `{"status":"open","priority":"medium"}`})
|
||||
createItem(t, srv, slug, "bugs", map[string]any{"title": "High severity bug", "fields": `{"status":"new","severity":"high"}`})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
got := suggestionTitles(resp)
|
||||
// One rank (priority, else severity), then age: the high-severity bug,
|
||||
// though newest, leads; the old low-priority task trails.
|
||||
want := []string{"High severity bug", "Medium task", "Old low task"}
|
||||
if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
||||
t.Fatalf("suggested_next = %v, want %v", got, want)
|
||||
}
|
||||
if r := resp.SuggestedNext[0].Reason; r != "Open item (high severity)" {
|
||||
t.Errorf("reason = %q, want %q", r, "Open item (high severity)")
|
||||
}
|
||||
}
|
||||
|
||||
// The fallback admits only WORKABLE collections (not system, and the schema
|
||||
// declares priority or severity), and never a done or blocked item.
|
||||
func TestBUG3453_FallbackExclusions(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
bug3453Bugs(t, srv, slug)
|
||||
createItem(t, srv, slug, "ideas", map[string]any{"title": "An idea", "fields": `{"status":"new"}`})
|
||||
createItem(t, srv, slug, "docs", map[string]any{"title": "A doc", "fields": `{"status":"draft"}`})
|
||||
createItem(t, srv, slug, "conventions", map[string]any{"title": "A convention", "fields": `{"status":"active","priority":"must"}`})
|
||||
createItem(t, srv, slug, "bugs", map[string]any{"title": "Fixed bug", "fields": `{"status":"fixed"}`})
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "Done task", "fields": `{"status":"done"}`})
|
||||
blocker := createItem(t, srv, slug, "tasks", map[string]any{"title": "Blocker", "fields": `{"status":"open"}`})
|
||||
blocked := createItem(t, srv, slug, "tasks", map[string]any{"title": "Blocked task", "fields": `{"status":"open"}`})
|
||||
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+blocker.Slug+"/links", map[string]any{
|
||||
"target_id": blocked.ID, "link_type": "blocks",
|
||||
}); rr.Code != http.StatusCreated {
|
||||
t.Fatalf("block: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
got := suggestionTitles(getDashboard(t, srv, slug))
|
||||
// Only the blocker itself is open, unblocked and workable.
|
||||
if len(got) != 1 || got[0] != "Blocker" {
|
||||
t.Fatalf("suggested_next = %v, want [Blocker]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace whose tiers already fill the three slots gets the same list:
|
||||
// the fallback never displaces. The fallback-eligible items are created
|
||||
// FIRST, so oldest-first ordering would put them on top if it reached them.
|
||||
//
|
||||
// It passes on the code before BUG-3453 too, by design: it is the invariant,
|
||||
// not the fix. What it catches is a fallback that is not sorted last. With
|
||||
// that comparator removed, the old critical bug takes a slot
|
||||
// ([In flight, Old critical bug, Critical]) and this fails.
|
||||
func TestBUG3453_FullListUnchanged(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
bug3453Bugs(t, srv, slug)
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "Old plain task", "fields": `{"status":"open"}`})
|
||||
createItem(t, srv, slug, "bugs", map[string]any{"title": "Old critical bug", "fields": `{"status":"new","severity":"critical"}`})
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "In flight", "fields": `{"status":"in-progress","priority":"low"}`})
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "Critical", "fields": `{"status":"open","priority":"critical"}`})
|
||||
createItem(t, srv, slug, "tasks", map[string]any{"title": "High", "fields": `{"status":"open","priority":"high"}`})
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
got := suggestionTitles(resp)
|
||||
want := []string{"In flight", "Critical", "High"}
|
||||
if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
||||
t.Fatalf("suggested_next = %v, want %v", got, want)
|
||||
}
|
||||
wantReasons := []string{"In-progress task (low priority)", "Open task (critical priority)", "Open task (high priority)"}
|
||||
for i, s := range resp.SuggestedNext {
|
||||
if s.Reason != wantReasons[i] {
|
||||
t.Errorf("reason[%d] = %q, want %q", i, s.Reason, wantReasons[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,14 @@ func priorityRank(priority string) int {
|
||||
|
||||
// isActiveStatus returns true if the status indicates work actively in progress.
|
||||
// It excludes both initial/queued states and terminal/completed states.
|
||||
// itemNumber is the item's workspace-sequential number, 0 when unset.
|
||||
func itemNumber(it models.Item) int {
|
||||
if it.ItemNumber == nil {
|
||||
return 0
|
||||
}
|
||||
return *it.ItemNumber
|
||||
}
|
||||
|
||||
func isActiveStatus(status string) bool {
|
||||
s := strings.ToLower(strings.ReplaceAll(status, "-", "_"))
|
||||
switch s {
|
||||
@@ -1006,9 +1014,31 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
|
||||
// entry and the suggestion would drift.
|
||||
overdue bool
|
||||
overdueReason string
|
||||
// fallback marks the BUG-3453 tier: an ordinary open item outside any
|
||||
// active plan, admitted only to fill slots the tiers above leave empty.
|
||||
fallback bool
|
||||
}
|
||||
var candidates []suggestion
|
||||
|
||||
// workable: the collections whose open items may fill the fallback tier
|
||||
// (BUG-3453, lead-ruled): not a system collection, and the schema
|
||||
// declares a `priority` or `severity` field. Across every built-in
|
||||
// template that is Tasks, Features, Backlog and Bugs; Conventions (a
|
||||
// system collection with a priority field), Ideas, Docs, Plans and the
|
||||
// people templates' collections stay out. No collection is named.
|
||||
workable := make(map[string]bool, len(collections))
|
||||
for _, c := range collections {
|
||||
if c.IsSystem {
|
||||
continue
|
||||
}
|
||||
for _, f := range ctxMap[c.ID].schema.Fields {
|
||||
if f.Key == "priority" || f.Key == "severity" {
|
||||
workable[c.ID] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, dp := range resp.ActivePlans {
|
||||
// Reuse the children batched once above (BUG-2002) instead of
|
||||
// re-resolving the plan slug and re-querying its children per plan.
|
||||
@@ -1098,11 +1128,41 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
|
||||
taskStatus := extractFieldValue(item.Fields, "status")
|
||||
isInProgress := isActiveStatus(taskStatus)
|
||||
isOpen := taskStatus == "open"
|
||||
if !isInProgress && !isOpen {
|
||||
continue
|
||||
}
|
||||
pri := extractFieldValue(item.Fields, "priority")
|
||||
odField, odValue, isOverdue := itemOverdue(item.Fields, todayStr)
|
||||
// The tiers below admit only the literal status `open` and, for open
|
||||
// items, a high/critical priority or a passed deadline. An item they
|
||||
// turn away can still FILL AN EMPTY SLOT (BUG-3453): a moved-in
|
||||
// backlog of ordinary open tasks and bugs, with no priority set and
|
||||
// an initial status like `new`, used to answer "nothing ready".
|
||||
// It is open when its collection's done field is not terminal and it
|
||||
// is not in progress; the sort keeps every fallback item below every
|
||||
// item admitted above, so a workspace that fills its slots today gets
|
||||
// the same list.
|
||||
admitted := isInProgress || (isOpen && (isOverdue || pri == "high" || pri == "critical"))
|
||||
if !admitted {
|
||||
if !workable[item.CollectionID] || isItemDone(item.Fields, item.CollectionID, ctxMap) {
|
||||
continue
|
||||
}
|
||||
if _, blocked := firstActiveBlocker[item.ID]; blocked {
|
||||
continue
|
||||
}
|
||||
// One rank on the low..critical scale: the priority when set,
|
||||
// else the severity. A Bugs collection declares only severity,
|
||||
// so a high-severity bug ranks with high-priority tasks rather
|
||||
// than below every task that has any priority at all.
|
||||
rank := priorityRank(pri)
|
||||
if pri == "" {
|
||||
rank = priorityRank(extractFieldValue(item.Fields, "severity"))
|
||||
}
|
||||
candidates = append(candidates, suggestion{
|
||||
item: item,
|
||||
status: taskStatus,
|
||||
priority: rank,
|
||||
fallback: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Open orphans must be high or critical to surface — open
|
||||
// in-progress items always do (continuing-work signal beats
|
||||
// priority gating).
|
||||
@@ -1113,9 +1173,6 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
|
||||
// where the deadline would quietly stop: a low-priority orphan three
|
||||
// weeks late would be reported by `stale` and never suggested by
|
||||
// `next`, which is the exact split GitHub #1010 is about.
|
||||
if !isInProgress && !isOverdue && pri != "high" && pri != "critical" {
|
||||
continue
|
||||
}
|
||||
if _, blocked := firstActiveBlocker[item.ID]; blocked {
|
||||
continue
|
||||
}
|
||||
@@ -1135,6 +1192,24 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
|
||||
// "active-plan continuation" suggestion stays at the top when
|
||||
// both are present. Lower rank = higher priority.
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
// The fallback tier (BUG-3453) sits below everything: it only fills
|
||||
// slots, never displaces. Within it: the rank (priority, else
|
||||
// severity), then the oldest first, so a moved-in backlog reads in
|
||||
// the order it was written down.
|
||||
if candidates[i].fallback != candidates[j].fallback {
|
||||
return candidates[j].fallback
|
||||
}
|
||||
if candidates[i].fallback {
|
||||
if candidates[i].priority != candidates[j].priority {
|
||||
return candidates[i].priority < candidates[j].priority
|
||||
}
|
||||
if !candidates[i].item.CreatedAt.Equal(candidates[j].item.CreatedAt) {
|
||||
return candidates[i].item.CreatedAt.Before(candidates[j].item.CreatedAt)
|
||||
}
|
||||
// created_at is stored to the second; the item number is the
|
||||
// workspace's creation sequence and breaks the tie.
|
||||
return itemNumber(candidates[i].item) < itemNumber(candidates[j].item)
|
||||
}
|
||||
// OVERDUE FIRST, above in-progress (IDEA-2641). The list is capped at
|
||||
// three, so a rank below in-progress would not merely order the
|
||||
// deadline lower — on any workspace with three things in flight it
|
||||
@@ -1189,6 +1264,10 @@ func (s *Server) buildDashboardResponse(workspaceID string, r *http.Request) (*D
|
||||
}
|
||||
if pri != "" {
|
||||
reason += " (" + pri + " priority)"
|
||||
} else if sev := extractFieldValue(c.item.Fields, "severity"); c.fallback && sev != "" {
|
||||
// Fallback items only: an item the tiers above admit reads exactly
|
||||
// as it did before BUG-3453.
|
||||
reason += " (" + sev + " severity)"
|
||||
}
|
||||
if c.overdue {
|
||||
// Prefixed rather than appended: the deadline is why this is at
|
||||
|
||||
@@ -891,10 +891,10 @@ func TestDashboardSuggestedNextFromPlannedPlan(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDashboardSuggestedNextOrphan_InProgressBeatsPriority pins
|
||||
// BUG-1082's gating rule for the orphan branch: in-progress items
|
||||
// surface regardless of priority, but open items must be high or
|
||||
// critical to surface (avoids flooding suggestions with low-priority
|
||||
// open work). The "continue what's in flight" signal beats priority.
|
||||
// BUG-1082's ranking for the orphan branch: in-progress items surface
|
||||
// regardless of priority, and rank above open work. A low-priority open
|
||||
// orphan used to be excluded outright; since BUG-3453 it fills an empty slot
|
||||
// BELOW the in-progress one, so "continue what's in flight" still leads.
|
||||
func TestDashboardSuggestedNextOrphan_InProgressBeatsPriority(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := testServer(t)
|
||||
@@ -905,7 +905,7 @@ func TestDashboardSuggestedNextOrphan_InProgressBeatsPriority(t *testing.T) {
|
||||
"title": "Low-Pri In-Progress Orphan",
|
||||
"fields": `{"status":"in-progress","priority":"low"}`,
|
||||
})
|
||||
// Low-priority OPEN orphan should NOT surface.
|
||||
// Low-priority OPEN orphan fills the next slot, below it (BUG-3453).
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Low-Pri Open Orphan",
|
||||
"fields": `{"status":"open","priority":"low"}`,
|
||||
@@ -913,12 +913,15 @@ func TestDashboardSuggestedNextOrphan_InProgressBeatsPriority(t *testing.T) {
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
|
||||
if len(resp.SuggestedNext) != 1 {
|
||||
t.Fatalf("expected exactly 1 suggested_next (in-progress only), got %d: %+v",
|
||||
if len(resp.SuggestedNext) != 2 {
|
||||
t.Fatalf("expected 2 suggested_next (in-progress, then the open fallback), got %d: %+v",
|
||||
len(resp.SuggestedNext), resp.SuggestedNext)
|
||||
}
|
||||
if got := resp.SuggestedNext[0].ItemTitle; got != "Low-Pri In-Progress Orphan" {
|
||||
t.Errorf("expected in-progress orphan, got %q", got)
|
||||
t.Errorf("expected in-progress orphan first, got %q", got)
|
||||
}
|
||||
if got := resp.SuggestedNext[1].ItemTitle; got != "Low-Pri Open Orphan" {
|
||||
t.Errorf("expected the open orphan second, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,16 @@ func TestFutureDeadlineIsNotOverdue(t *testing.T) {
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": "Plenty of time", "fields": `{"status":"open","priority":"low","due_date":"2099-12-31"}`,
|
||||
})
|
||||
// Three high-priority tasks fill every slot. Since BUG-3453 an ordinary
|
||||
// open item fills an EMPTY slot from a fallback tier below them, so the
|
||||
// item's absence only discriminates when no slot is empty: an
|
||||
// unconditional bypass would rank it FIRST (overdue leads the list),
|
||||
// the fallback cannot reach it.
|
||||
for _, title := range []string{"High one", "High two", "High three"} {
|
||||
createItem(t, srv, slug, "tasks", map[string]interface{}{
|
||||
"title": title, "fields": `{"status":"open","priority":"high"}`,
|
||||
})
|
||||
}
|
||||
|
||||
resp := getDashboard(t, srv, slug)
|
||||
if got := len(filterAttention(resp.Attention, "overdue")); got != 0 {
|
||||
|
||||
@@ -349,14 +349,15 @@ func (s *Store) GetCollectionBySlug(workspaceID, slug string) (*models.Collectio
|
||||
|
||||
// ListCollectionsMinimal returns collection rows populated with just the
|
||||
// fields needed for done-detection context and slug lookups: ID, Slug,
|
||||
// Schema, Settings. Skips the per-collection COUNT queries that
|
||||
// Schema, Settings, IsSystem (the dashboard's ready fallback keeps system
|
||||
// collections out, BUG-3453). Skips the per-collection COUNT queries that
|
||||
// ListCollections runs, which matters on hot paths that only need those
|
||||
// fields (e.g. handlers that build a ctxMap for isItemDone or an ID→slug
|
||||
// map). Includes soft-deleted collections so items still attached to them
|
||||
// can be evaluated.
|
||||
func (s *Store) ListCollectionsMinimal(workspaceID string) ([]models.Collection, error) {
|
||||
rows, err := s.db.Query(
|
||||
s.q(`SELECT id, slug, schema, settings FROM collections WHERE workspace_id = ?`),
|
||||
s.q(`SELECT id, slug, schema, settings, is_system FROM collections WHERE workspace_id = ?`),
|
||||
workspaceID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -366,7 +367,7 @@ func (s *Store) ListCollectionsMinimal(workspaceID string) ([]models.Collection,
|
||||
var result []models.Collection
|
||||
for rows.Next() {
|
||||
var c models.Collection
|
||||
if err := rows.Scan(&c.ID, &c.Slug, &c.Schema, &c.Settings); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Slug, &c.Schema, &c.Settings, &c.IsSystem); err != nil {
|
||||
return nil, fmt.Errorf("scan collection minimal: %w", err)
|
||||
}
|
||||
result = append(result, c)
|
||||
|
||||
Reference in New Issue
Block a user