mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +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>
288 lines
8.4 KiB
Go
288 lines
8.4 KiB
Go
package slack
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/slack-go/slack"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// ---- fakes ----
|
|
|
|
type fakeSlashQueries struct {
|
|
inst db.ChannelInstallation
|
|
instErr error
|
|
binding db.ChannelUserBinding
|
|
bindErr error
|
|
memberErr error
|
|
gotAppID string
|
|
}
|
|
|
|
func (f *fakeSlashQueries) GetChannelInstallationByAppID(_ context.Context, arg db.GetChannelInstallationByAppIDParams) (db.ChannelInstallation, error) {
|
|
f.gotAppID = arg.AppID
|
|
return f.inst, f.instErr
|
|
}
|
|
|
|
func (f *fakeSlashQueries) GetChannelUserBindingByUserID(_ context.Context, _ db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error) {
|
|
return f.binding, f.bindErr
|
|
}
|
|
|
|
func (f *fakeSlashQueries) GetMemberByUserAndWorkspace(_ context.Context, _ db.GetMemberByUserAndWorkspaceParams) (db.Member, error) {
|
|
return db.Member{}, f.memberErr
|
|
}
|
|
|
|
// fakeQuickCreate records the last EnqueueQuickCreateTask call so tests can
|
|
// assert the prompt is passed through verbatim and attributed correctly.
|
|
type fakeQuickCreate struct {
|
|
task db.AgentTaskQueue
|
|
err error
|
|
calls int
|
|
|
|
workspaceID pgtype.UUID
|
|
requesterID pgtype.UUID
|
|
agentID pgtype.UUID
|
|
squadID pgtype.UUID
|
|
prompt string
|
|
}
|
|
|
|
func (f *fakeQuickCreate) EnqueueQuickCreateTask(_ context.Context, workspaceID, requesterID, agentID, squadID pgtype.UUID, prompt string, _, _, _ pgtype.UUID, _ []pgtype.UUID) (db.AgentTaskQueue, error) {
|
|
f.calls++
|
|
f.workspaceID = workspaceID
|
|
f.requesterID = requesterID
|
|
f.agentID = agentID
|
|
f.squadID = squadID
|
|
f.prompt = prompt
|
|
return f.task, f.err
|
|
}
|
|
|
|
func slashTestUUID(b byte) pgtype.UUID {
|
|
var u pgtype.UUID
|
|
for i := range u.Bytes {
|
|
u.Bytes[i] = b
|
|
}
|
|
u.Valid = true
|
|
return u
|
|
}
|
|
|
|
// newTestSlashProcessor builds a processor over fakes and returns it plus a
|
|
// pointer to the last ephemeral reply text and the reply count.
|
|
func newTestSlashProcessor(q slashQueries, tasks quickCreateEnqueuer, binding bindingMinter) (*SlashCommandProcessor, *string, *int) {
|
|
captured := new(string)
|
|
count := new(int)
|
|
p := &SlashCommandProcessor{
|
|
q: q,
|
|
tasks: tasks,
|
|
binding: binding,
|
|
appURL: "https://app.example",
|
|
bindingPath: "/slack/bind",
|
|
logger: slog.Default(),
|
|
}
|
|
p.respond = func(_ context.Context, _ string, text string) error {
|
|
*count++
|
|
*captured = text
|
|
return nil
|
|
}
|
|
return p, captured, count
|
|
}
|
|
|
|
func activeSlashInstallation() db.ChannelInstallation {
|
|
return db.ChannelInstallation{
|
|
ID: slashTestUUID(1),
|
|
WorkspaceID: slashTestUUID(2),
|
|
AgentID: slashTestUUID(3),
|
|
InstallerUserID: slashTestUUID(4),
|
|
Status: "active",
|
|
Config: []byte(`{"app_id":"A1","team_id":"T1"}`),
|
|
}
|
|
}
|
|
|
|
func issueSlashCmd() slack.SlashCommand {
|
|
return slack.SlashCommand{
|
|
Command: "/issue",
|
|
Text: "Fix login",
|
|
APIAppID: "A1",
|
|
TeamID: "T1",
|
|
UserID: "U1",
|
|
ChannelID: "C1",
|
|
ResponseURL: "https://hooks.slack.test/response",
|
|
}
|
|
}
|
|
|
|
// ---- tests ----
|
|
|
|
func TestSlashHandle_EnqueuesQuickCreateAndAcks(t *testing.T) {
|
|
q := &fakeSlashQueries{
|
|
inst: activeSlashInstallation(),
|
|
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
|
|
}
|
|
tasks := &fakeQuickCreate{}
|
|
p, captured, count := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
|
|
|
|
p.Handle(context.Background(), issueSlashCmd())
|
|
|
|
if tasks.calls != 1 {
|
|
t.Fatalf("expected 1 quick-create enqueue, got %d", tasks.calls)
|
|
}
|
|
if *count != 1 {
|
|
t.Fatalf("expected 1 ephemeral reply, got %d", *count)
|
|
}
|
|
if *captured != slashQueuedText {
|
|
t.Fatalf("expected queued ack, got %q", *captured)
|
|
}
|
|
if q.gotAppID != "A1" {
|
|
t.Errorf("installation lookup used app id %q, want A1", q.gotAppID)
|
|
}
|
|
if tasks.prompt != "Fix login" {
|
|
t.Errorf("quick-create prompt = %q, want Fix login", tasks.prompt)
|
|
}
|
|
if tasks.workspaceID != slashTestUUID(2) {
|
|
t.Errorf("quick-create workspace is not the installation workspace")
|
|
}
|
|
if tasks.agentID != slashTestUUID(3) {
|
|
t.Errorf("quick-create not dispatched to the installation agent")
|
|
}
|
|
if tasks.requesterID != slashTestUUID(9) {
|
|
t.Errorf("quick-create requester is not the bound member")
|
|
}
|
|
if tasks.squadID.Valid {
|
|
t.Errorf("slash-command quick-create must not carry a squad id")
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_MultilinePromptPassedThrough(t *testing.T) {
|
|
q := &fakeSlashQueries{
|
|
inst: activeSlashInstallation(),
|
|
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
|
|
}
|
|
tasks := &fakeQuickCreate{}
|
|
p, _, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
|
|
|
|
cmd := issueSlashCmd()
|
|
cmd.Text = " Title\nline one\nline two "
|
|
p.Handle(context.Background(), cmd)
|
|
|
|
// The whole (trimmed) natural-language text is the prompt — no title/body
|
|
// split; the agent authors the well-formed issue from it.
|
|
if tasks.prompt != "Title\nline one\nline two" {
|
|
t.Errorf("prompt = %q, want the full trimmed text", tasks.prompt)
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_EmptyPromptIsUsage(t *testing.T) {
|
|
tasks := &fakeQuickCreate{}
|
|
p, captured, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
|
|
|
|
cmd := issueSlashCmd()
|
|
cmd.Text = " "
|
|
p.Handle(context.Background(), cmd)
|
|
|
|
if tasks.calls != 0 {
|
|
t.Fatalf("empty prompt must not enqueue a task")
|
|
}
|
|
if *count != 1 || *captured != slashUsageText {
|
|
t.Fatalf("expected usage reply, got %q", *captured)
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_UnboundUserGetsLink(t *testing.T) {
|
|
q := &fakeSlashQueries{inst: activeSlashInstallation(), bindErr: pgx.ErrNoRows}
|
|
tasks := &fakeQuickCreate{}
|
|
bind := &fakeBindingMinter{raw: "TOKEN123"}
|
|
p, captured, _ := newTestSlashProcessor(q, tasks, bind)
|
|
|
|
p.Handle(context.Background(), issueSlashCmd())
|
|
|
|
if tasks.calls != 0 {
|
|
t.Fatalf("unbound user must not enqueue a task")
|
|
}
|
|
if bind.calls != 1 {
|
|
t.Fatalf("expected a binding token to be minted, got %d", bind.calls)
|
|
}
|
|
if !strings.Contains(*captured, "link your account") || !strings.Contains(*captured, "TOKEN123") {
|
|
t.Fatalf("reply missing bind link: %q", *captured)
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_NonMemberDropped(t *testing.T) {
|
|
q := &fakeSlashQueries{
|
|
inst: activeSlashInstallation(),
|
|
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
|
|
memberErr: pgx.ErrNoRows,
|
|
}
|
|
tasks := &fakeQuickCreate{}
|
|
p, captured, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
|
|
|
|
p.Handle(context.Background(), issueSlashCmd())
|
|
|
|
if tasks.calls != 0 {
|
|
t.Fatalf("non-member must not enqueue a task")
|
|
}
|
|
if *captured != slashNotMemberText {
|
|
t.Fatalf("expected not-member reply, got %q", *captured)
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_InactiveInstallation(t *testing.T) {
|
|
inst := activeSlashInstallation()
|
|
inst.Status = "revoked"
|
|
tasks := &fakeQuickCreate{}
|
|
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: inst}, tasks, &fakeBindingMinter{})
|
|
|
|
p.Handle(context.Background(), issueSlashCmd())
|
|
|
|
if tasks.calls != 0 || *captured != slashDisabledText {
|
|
t.Fatalf("inactive install: calls=%d reply=%q", tasks.calls, *captured)
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_TeamMismatchTreatedAsDisconnected(t *testing.T) {
|
|
tasks := &fakeQuickCreate{}
|
|
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
|
|
|
|
cmd := issueSlashCmd()
|
|
cmd.TeamID = "T2" // config team is T1
|
|
p.Handle(context.Background(), cmd)
|
|
|
|
if tasks.calls != 0 || *captured != slashDisabledText {
|
|
t.Fatalf("team mismatch: calls=%d reply=%q", tasks.calls, *captured)
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_EnqueueFailureIsInternalError(t *testing.T) {
|
|
q := &fakeSlashQueries{
|
|
inst: activeSlashInstallation(),
|
|
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
|
|
}
|
|
tasks := &fakeQuickCreate{err: errors.New("agent has no runtime")}
|
|
p, captured, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
|
|
|
|
p.Handle(context.Background(), issueSlashCmd())
|
|
|
|
if tasks.calls != 1 {
|
|
t.Fatalf("expected the enqueue to be attempted once, got %d", tasks.calls)
|
|
}
|
|
if *captured != slashInternalErrorText {
|
|
t.Fatalf("expected internal-error reply, got %q", *captured)
|
|
}
|
|
}
|
|
|
|
func TestSlashHandle_IgnoresOtherCommands(t *testing.T) {
|
|
tasks := &fakeQuickCreate{}
|
|
p, _, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
|
|
|
|
cmd := issueSlashCmd()
|
|
cmd.Command = "/other"
|
|
p.Handle(context.Background(), cmd)
|
|
|
|
if tasks.calls != 0 || *count != 0 {
|
|
t.Fatalf("non-/issue command must be ignored: calls=%d replies=%d", tasks.calls, *count)
|
|
}
|
|
}
|