mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
Squashed history of PR #4892 (pr-4784-fix). Makes spaces the primary navigation and working surface, on the "associations bind at creation time only" model. Model - Issue <-> space is the only enforced ownership (per-space numbering). Parent/child and project<->space associations only seed defaults at creation; cross-space/child and project-association validations are removed. - Moving an issue renumbers it and records the old identifier in issue_identifier_alias; API/CLI lookups and GitHub branch/PR auto-linking fall back to the alias, so old identifiers resolve forever. - Membership drives only the sidebar and personal defaults — never access. Anyone can configure any space's member set wholesale (PUT /api/spaces/{id}/members); saving an empty set archives the space behind a confirm. - Per-user space order (workspace_space_member.sort_order, fractional): drag-sorted sidebar, "my first space" is the personal issue-creation default; the workspace default space backs headless creation (agents/CLI/Slack) and system placement. Surfaces - Sidebar: joined-spaces section (drag reorder, row -> space page, per-group persisted collapse), Workspace group with a More menu, Settings demoted to a footer icon. - /space/:key/{issues,projects,autopilots,settings} — space surfaces reuse shared page components; a routed /space/new create page (replacing the earlier create-space modal), reserved key "NEW" so it can never collide with a real space's /space/:key detail page. - Issue detail moves to /issue/:id (identifier-first, Linear-style; old /issues/:id redirects); create dialogs lead with a required space pill. - Agent runtime brief now carries Space context (id/key/name) through the daemon claim -> TaskContextForEnv -> prompt pipeline, with a "## Space Context" section and --space on issue create/update in both brief renderers, matching Project's existing treatment. - zh-Hans: Space translated to 空间 across locales and conventions.zh.mdx. Fixes along the way - Cache membership judgment gains the space dimension + space_changed WS flag. - Silent skip on default-space lookup during invite acceptance is now a hard failure (no space-less members). - Backfilled space_id into ~28 raw-SQL Go test fixtures across internal/handler and cmd/server that predated migration 132's NOT NULL cutover. - Fixed a resolve-loop bug in the IssueDetail identifier wrapper (mount/ unmount cycle on resolution failure) and gave it its own loading skeleton instead of a blank screen while resolving. - reserved-slugs generator's stale hardcoded doc-comment example synced back to /create-space. Verification - go build ./..., go vet ./..., go test ./... all clean. - pnpm typecheck (core/views/web/desktop) clean. - packages/views: 162 files / 1656 tests passing. - pnpm generate:reserved-slugs produces no diff. Follow-ups tracked in docs/follow-ups/space-rollout.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
252 lines
8.3 KiB
Go
252 lines
8.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestListGroupedIssuesAssigneePaginatesPerGroup(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
suffix := time.Now().UnixNano()
|
|
var assigneeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO "user" (name, email)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, "Grouped Issues Test User", fmt.Sprintf("grouped-%d@multica.ai", suffix)).Scan(&assigneeID); err != nil {
|
|
t.Fatalf("create assignee user: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, assigneeID)
|
|
})
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role)
|
|
VALUES ($1, $2, 'member')
|
|
`, testWorkspaceID, assigneeID); err != nil {
|
|
t.Fatalf("create assignee member: %v", err)
|
|
}
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks, owner_id
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, "Grouped Issues Test Agent", testRuntimeID, testUserID).Scan(&agentID); err != nil {
|
|
t.Fatalf("create agent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
|
|
})
|
|
|
|
createIssue := func(title, assigneeType, assigneeID string, position float64) string {
|
|
t.Helper()
|
|
var number int32
|
|
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)
|
|
}
|
|
|
|
var id string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, title, description, status, priority,
|
|
assignee_type, assignee_id, creator_type, creator_id,
|
|
position, number, space_id
|
|
)
|
|
VALUES ($1, $2, NULL, 'todo', 'none', $3, $4, 'member', $5, $6, $7,
|
|
(SELECT id FROM workspace_space WHERE workspace_id = $1 LIMIT 1))
|
|
RETURNING id
|
|
`, testWorkspaceID, title, assigneeType, assigneeID, testUserID, position, number).Scan(&id); err != nil {
|
|
t.Fatalf("create issue %q: %v", title, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, id)
|
|
})
|
|
return id
|
|
}
|
|
|
|
createIssue("Grouped member one", "member", assigneeID, 1)
|
|
createIssue("Grouped member two", "member", assigneeID, 2)
|
|
createIssue("Grouped member three", "member", assigneeID, 3)
|
|
createIssue("Grouped agent one", "agent", agentID, 1)
|
|
|
|
path := fmt.Sprintf(
|
|
"/api/issues/grouped?workspace_id=%s&group_by=assignee&statuses=todo&limit=2&assignee_filters=member:%s,agent:%s",
|
|
testWorkspaceID,
|
|
assigneeID,
|
|
agentID,
|
|
)
|
|
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 resp GroupedIssuesResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode grouped response: %v", err)
|
|
}
|
|
|
|
memberGroupID := "assignee:member:" + assigneeID
|
|
agentGroupID := "assignee:agent:" + agentID
|
|
groups := map[string]IssueAssigneeGroupResponse{}
|
|
for _, group := range resp.Groups {
|
|
groups[group.ID] = group
|
|
}
|
|
|
|
memberGroup, ok := groups[memberGroupID]
|
|
if !ok {
|
|
t.Fatalf("missing member group %s in %#v", memberGroupID, resp.Groups)
|
|
}
|
|
if memberGroup.Total != 3 || len(memberGroup.Issues) != 2 {
|
|
t.Fatalf("member group total/page mismatch: total=%d len=%d", memberGroup.Total, len(memberGroup.Issues))
|
|
}
|
|
if memberGroup.Issues[0].Title != "Grouped member one" || memberGroup.Issues[1].Title != "Grouped member two" {
|
|
t.Fatalf("member group order mismatch: %#v", memberGroup.Issues)
|
|
}
|
|
|
|
agentGroup, ok := groups[agentGroupID]
|
|
if !ok {
|
|
t.Fatalf("missing agent group %s in %#v", agentGroupID, resp.Groups)
|
|
}
|
|
if agentGroup.Total != 1 || len(agentGroup.Issues) != 1 {
|
|
t.Fatalf("agent group total/page mismatch: total=%d len=%d", agentGroup.Total, len(agentGroup.Issues))
|
|
}
|
|
|
|
nextPath := fmt.Sprintf(
|
|
"/api/issues/grouped?workspace_id=%s&group_by=assignee&statuses=todo&limit=2&offset=2&group_assignee_type=member&group_assignee_id=%s",
|
|
testWorkspaceID,
|
|
assigneeID,
|
|
)
|
|
next := httptest.NewRecorder()
|
|
testHandler.ListGroupedIssues(next, newRequest("GET", nextPath, nil))
|
|
if next.Code != http.StatusOK {
|
|
t.Fatalf("ListGroupedIssues next page: expected 200, got %d: %s", next.Code, next.Body.String())
|
|
}
|
|
|
|
var nextResp GroupedIssuesResponse
|
|
if err := json.NewDecoder(next.Body).Decode(&nextResp); err != nil {
|
|
t.Fatalf("decode next grouped response: %v", err)
|
|
}
|
|
if len(nextResp.Groups) != 1 {
|
|
t.Fatalf("expected one next-page group, got %#v", nextResp.Groups)
|
|
}
|
|
if nextResp.Groups[0].ID != memberGroupID || nextResp.Groups[0].Total != 3 || len(nextResp.Groups[0].Issues) != 1 {
|
|
t.Fatalf("unexpected next-page group: %#v", nextResp.Groups[0])
|
|
}
|
|
if nextResp.Groups[0].Issues[0].Title != "Grouped member three" {
|
|
t.Fatalf("unexpected next-page issue: %#v", nextResp.Groups[0].Issues[0])
|
|
}
|
|
}
|
|
|
|
func TestListGroupedIssuesFiltersBySpace(t *testing.T) {
|
|
ctx := context.Background()
|
|
suffix := time.Now().UnixNano() % 1_000_000
|
|
keyA := fmt.Sprintf("A%06d", suffix)
|
|
keyB := fmt.Sprintf("B%06d", suffix)
|
|
|
|
var workspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, '', $3)
|
|
RETURNING id
|
|
`, "Grouped Space Filter", fmt.Sprintf("grouped-space-filter-%d", suffix), keyA).Scan(&workspaceID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, workspaceID)
|
|
})
|
|
|
|
var spaceA, spaceB string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace_space (workspace_id, name, key)
|
|
VALUES ($1, 'Space A', $2)
|
|
RETURNING id
|
|
`, workspaceID, keyA).Scan(&spaceA); err != nil {
|
|
t.Fatalf("create space A: %v", err)
|
|
}
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace_space (workspace_id, name, key)
|
|
VALUES ($1, 'Space B', $2)
|
|
RETURNING id
|
|
`, workspaceID, keyB).Scan(&spaceB); err != nil {
|
|
t.Fatalf("create space B: %v", err)
|
|
}
|
|
|
|
createIssue := func(title, spaceID string, position float64) {
|
|
t.Helper()
|
|
var number int32
|
|
if err := testPool.QueryRow(ctx, `
|
|
UPDATE workspace
|
|
SET issue_counter = issue_counter + 1
|
|
WHERE id = $1
|
|
RETURNING issue_counter
|
|
`, workspaceID).Scan(&number); err != nil {
|
|
t.Fatalf("next issue number: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, space_id, title, description, status, priority,
|
|
assignee_type, assignee_id, creator_type, creator_id,
|
|
position, number
|
|
)
|
|
VALUES ($1, $2, $3, NULL, 'todo', 'none', 'member', $4, 'member', $4, $5, $6)
|
|
`, workspaceID, spaceID, title, testUserID, position, number); err != nil {
|
|
t.Fatalf("create issue %q: %v", title, err)
|
|
}
|
|
}
|
|
|
|
createIssue("Space A first", spaceA, 1)
|
|
createIssue("Space A second", spaceA, 2)
|
|
createIssue("Space B hidden", spaceB, 3)
|
|
|
|
path := fmt.Sprintf(
|
|
"/api/issues/grouped?workspace_id=%s&group_by=assignee&statuses=todo&space_id=%s&limit=10",
|
|
workspaceID,
|
|
spaceA,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", path, nil)
|
|
req.Header.Set("X-Workspace-ID", workspaceID)
|
|
testHandler.ListGroupedIssues(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListGroupedIssues: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp GroupedIssuesResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode grouped response: %v", err)
|
|
}
|
|
if len(resp.Groups) != 1 {
|
|
t.Fatalf("expected one assignee group, got %#v", resp.Groups)
|
|
}
|
|
if resp.Groups[0].Total != 2 || len(resp.Groups[0].Issues) != 2 {
|
|
t.Fatalf("expected only Space A issues, total=%d len=%d", resp.Groups[0].Total, len(resp.Groups[0].Issues))
|
|
}
|
|
for _, issue := range resp.Groups[0].Issues {
|
|
if issue.SpaceID == nil || *issue.SpaceID != spaceA {
|
|
t.Fatalf("grouped space filter leaked wrong space issue: %#v", issue)
|
|
}
|
|
if issue.Title == "Space B hidden" {
|
|
t.Fatalf("grouped space filter leaked Space B issue: %#v", issue)
|
|
}
|
|
}
|
|
}
|