mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 05:39:08 +02:00
The Slack `/issue` slash command used to directly create a raw issue: the
typed line became the title verbatim and a `todo` issue was assigned to the
agent to work on immediately. That files a rough, unstructured issue and starts
the agent on it before it is well-formed.
Switch the slash command to the quick-create pipeline instead
(TaskService.EnqueueQuickCreateTask, the same path as the web "quick create"
modal): the invoker's natural-language description is handed to the
installation's agent as a prompt, and the agent authors a well-formed issue
(proper title + structured description) in the background, attributed to the
bound member. Because creation is now asynchronous, the ephemeral reply is an
acknowledgement ("On it…") rather than a created-confirmation with a number;
the agent's completion surfaces to the invoker as a Multica inbox notification
through the shared quick-create completion path.
Installation routing and identity/membership checks are unchanged, so the same
workspace boundary and account-binding rules apply. Scope is the slash command
only — the message-based `@bot /issue` still runs through the shared
cross-platform engine (which also serves Lark) and keeps its direct-create
behavior.
- slash_command.go: swap IssueService.Create for EnqueueQuickCreateTask via a
narrow quickCreateEnqueuer interface; prompt is the full text (no title/body
split); drop the now-unused splitIssueText / issueCreatedText / GetWorkspace.
- router.go: wire h.TaskService instead of h.IssueService.
- tests: cover enqueue + ack, multiline prompt pass-through, empty prompt,
unbound, non-member, inactive, team mismatch, and enqueue-failure.
- docs (4 locales): describe the quick-create behavior.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
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)
|
|
}
|
|
}
|