Files
Bohan Jiang ff60b2f25a MUL-7379 fix: restore per-status sort order and the delegated handoff signal (#8435)
Fixes three regressions left by MUL-7240's collapse from seven stored issue
lifecycle categories to four. All three share one shape — a comparison of an
Effective() result against one of the five NON-TERMINAL built-in keys, which
stopped matching custom statuses once nonterminal custom keys were left raw.
Terminal projection survived, which is why no done/cancelled check regressed.

Principle applied: behavior logic aggregates on category, display logic resolves
on status.

1. Status sort collapsed from seven ranks to four (server and client, affects
   every workspace). Backlog tied with Todo and In Progress/In Review/Blocked
   all tied, leaving the created_at tiebreak to order a list the user sorted by
   status. Both sides now rank on the concrete key in catalog board order,
   reusing issueTableStatusOrder so sorting and grouping share one source of
   truth. statusOrderExpression itself is unchanged — its other caller ranks
   the four category buckets of a status_category grouping, which is correct.

2. Delegated subscribers stopped being told about handoffs. Their allowlist was
   {in_review, done, cancelled, blocked}; a workspace's "Awaiting Response" or
   "Code Review" used to match via its category and no longer did, so the human
   whose agent filed the issue was never told it was waiting on them. Both
   hand-kept allowlists (which had already drifted) are replaced by one
   predicate: done/closed, or started excluding the fixed in_progress key.
   Delivery fails open, stale task_failed dismissal fails closed.

3. Autopilot webhook crash-compensation stopped repairing the lost create_issue
   task for any workspace with a custom status, leaving the run in
   issue_created with no task while the delivery recorded success.

Also corrects four comments that still justified their behavior with the
retired "a custom status resolves to the canonical status it inherits"
mechanism — the exact failure mode MUL-7364's postmortem named.

Tests: the existing sort regression test used one issue per category, so four
buckets and seven produced identical output; the new one puts several built-ins
in one category, wedges a custom Started status between two of them by position,
and orders insertion so created_at DESC is the reverse of the expected result.
Verified to fail on the pre-fix expression.
2026-09-15 13:30:37 +08:00

311 lines
10 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestListIssuesStatusSortCountsCustomStatuses(t *testing.T) {
const key = "sort_custom_started"
createTestCustomStatus(t, key, "started")
mustCreateIssue(t, "Custom sorting first", key)
mustCreateIssue(t, "Custom sorting second", key)
w := httptest.NewRecorder()
testHandler.ListIssues(w, newRequest(http.MethodGet, "/api/issues?status="+key+"&sort=status&limit=1", nil))
if w.Code != http.StatusOK {
t.Fatalf("status sort: %d %s", w.Code, w.Body.String())
}
var response struct {
Issues []IssueResponse
Total int
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Total != 2 || len(response.Issues) != 1 {
t.Fatalf("total/rows = %d/%d", response.Total, len(response.Issues))
}
}
func TestListIssuesSortsByStatusAndUpdatedAt(t *testing.T) {
ctx := context.Background()
suffix := time.Now().UnixNano()
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title) VALUES ($1, $2) RETURNING id
`, testWorkspaceID, fmt.Sprintf("Issue table sort %d", suffix)).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
type fixture struct {
title string
status string
updatedAt time.Time
activityAt time.Time
}
fixtures := []fixture{
{"sort-done", "done", time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)},
{"sort-backlog", "backlog", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 2, 3, 0, 0, 0, 0, time.UTC)},
{"sort-progress", "in_progress", time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC)},
}
for index, item := range fixtures {
var number int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
WHERE id = $1 RETURNING issue_counter
`, testWorkspaceID).Scan(&number); err != nil {
t.Fatalf("next issue number: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id, created_at, updated_at, last_activity_at
)
VALUES ($1, $2, $3, 'none', 'member', $4, $5, $6, $7, $8, $8, $9)
`, testWorkspaceID, item.title, item.status, testUserID, index, number, projectID, item.updatedAt, item.activityAt); err != nil {
t.Fatalf("create issue %q: %v", item.title, err)
}
}
listTitles := func(sort, direction string) []string {
t.Helper()
path := fmt.Sprintf(
"/api/issues?workspace_id=%s&project_id=%s&limit=50&sort=%s",
testWorkspaceID,
projectID,
sort,
)
if direction != "" {
path += "&direction=" + direction
}
w := httptest.NewRecorder()
testHandler.ListIssues(w, newRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var response struct {
Issues []IssueResponse `json:"issues"`
}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
titles := make([]string, 0, len(response.Issues))
for _, issue := range response.Issues {
titles = append(titles, issue.Title)
}
return titles
}
groupedTitles := func(sort, direction string) []string {
t.Helper()
path := fmt.Sprintf(
"/api/issues/grouped?workspace_id=%s&project_id=%s&limit=50&sort=%s",
testWorkspaceID,
projectID,
sort,
)
if direction != "" {
path += "&direction=" + direction
}
w := httptest.NewRecorder()
testHandler.ListGroupedIssues(w, newRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Fatalf("ListGroupedIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var response GroupedIssuesResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode grouped response: %v", err)
}
if len(response.Groups) != 1 {
t.Fatalf("ListGroupedIssues groups = %d, want 1", len(response.Groups))
}
titles := make([]string, 0, len(response.Groups[0].Issues))
for _, issue := range response.Groups[0].Issues {
titles = append(titles, issue.Title)
}
return titles
}
assertTitles := func(got, want []string) {
t.Helper()
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("order = %v, want %v", got, want)
}
}
assertTitles(listTitles("status", "asc"), []string{
"sort-backlog",
"sort-progress",
"sort-done",
})
assertTitles(listTitles("status", "desc"), []string{
"sort-done",
"sort-progress",
"sort-backlog",
})
assertTitles(listTitles("updated_at", "asc"), []string{
"sort-backlog",
"sort-progress",
"sort-done",
})
assertTitles(listTitles("updated_at", "desc"), []string{
"sort-done",
"sort-progress",
"sort-backlog",
})
assertTitles(listTitles("last_activity", "asc"), []string{
"sort-done",
"sort-progress",
"sort-backlog",
})
assertTitles(listTitles("last_activity", "desc"), []string{
"sort-backlog",
"sort-progress",
"sort-done",
})
assertTitles(listTitles("last_activity", ""), []string{
"sort-backlog",
"sort-progress",
"sort-done",
})
assertTitles(groupedTitles("last_activity", "asc"), []string{
"sort-done",
"sort-progress",
"sort-backlog",
})
assertTitles(groupedTitles("last_activity", ""), []string{
"sort-backlog",
"sort-progress",
"sort-done",
})
}
// TestListIssuesStatusSortFollowsCatalogOrder pins that `sort=status` ranks by
// the CONCRETE status key in catalog order, not by lifecycle category
// (MUL-7379).
//
// Sibling test TestListIssuesSortsByStatusAndUpdatedAt cannot catch the
// regression this guards: it uses one issue per lifecycle category, so a
// four-bucket ranking and a per-status ranking produce the same output. Here
// the unstarted group holds backlog AND todo, and the started group holds
// in_progress, in_review and blocked — collapsing to categories ties every one
// of those and lets the created_at tiebreak decide.
//
// The custom Started status is wedged BETWEEN two built-ins by position, which
// is what separates "ranks by the catalog" from "ranks by a hardcoded list of
// the seven built-ins": no static ordering of the built-ins can produce it.
func TestListIssuesStatusSortFollowsCatalogOrder(t *testing.T) {
ctx := context.Background()
suffix := time.Now().UnixNano()
customKey := fmt.Sprintf("sort_gate_%d", suffix%1_000_000)
createTestCustomStatus(t, customKey, "started")
// Built-ins all seed at position 0 and tie-break on their fixed rank, so the
// custom status can only land before or after the whole group until the
// Started positions are spread out. Restore them afterwards: the workspace
// catalog is shared with every other test in this package.
setPosition := func(key string, position float64) {
t.Helper()
if _, err := testPool.Exec(ctx,
`UPDATE issue_status SET position = $3 WHERE workspace_id = $1 AND key = $2`,
testWorkspaceID, key, position,
); err != nil {
t.Fatalf("set position for %q: %v", key, err)
}
}
for _, key := range []string{"in_progress", "in_review", "blocked"} {
t.Cleanup(func() { setPosition(key, 0) })
}
setPosition("in_progress", 10)
setPosition("in_review", 20)
setPosition(customKey, 30)
setPosition("blocked", 40)
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title) VALUES ($1, $2) RETURNING id
`, testWorkspaceID, fmt.Sprintf("Status sort order %d", suffix)).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
// Inserted newest-first so created_at DESC — the tiebreak a category
// ranking would fall through to — is the REVERSE of the expected order.
// A four-bucket ranking therefore cannot accidentally pass this.
boardOrder := []string{
"backlog", "todo", "in_progress", "in_review", customKey, "blocked", "done", "cancelled",
}
titles := make([]string, 0, len(boardOrder))
for i, status := range boardOrder {
title := fmt.Sprintf("order-%02d-%s", i, status)
titles = append(titles, title)
var number int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
WHERE id = $1 RETURNING issue_counter
`, testWorkspaceID).Scan(&number); err != nil {
t.Fatalf("next issue number: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id, created_at, updated_at, last_activity_at
)
VALUES ($1, $2, $3, 'none', 'member', $4, $5, $6, $7, $8, $8, $8)
`, testWorkspaceID, title, status, testUserID, i, number, projectID,
time.Now().Add(-time.Duration(i)*time.Minute),
); err != nil {
t.Fatalf("create issue %q: %v", title, err)
}
}
got := func(direction string) []string {
t.Helper()
path := fmt.Sprintf(
"/api/issues?workspace_id=%s&project_id=%s&limit=50&sort=status&direction=%s",
testWorkspaceID, projectID, direction,
)
w := httptest.NewRecorder()
testHandler.ListIssues(w, newRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var response struct {
Issues []IssueResponse `json:"issues"`
}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
out := make([]string, 0, len(response.Issues))
for _, issue := range response.Issues {
out = append(out, issue.Title)
}
return out
}
if asc := got("asc"); fmt.Sprint(asc) != fmt.Sprint(titles) {
t.Fatalf("sort=status asc\n got %v\n want %v", asc, titles)
}
reversed := make([]string, len(titles))
for i, title := range titles {
reversed[len(titles)-1-i] = title
}
if desc := got("desc"); fmt.Sprint(desc) != fmt.Sprint(reversed) {
t.Fatalf("sort=status desc\n got %v\n want %v", desc, reversed)
}
}