feat(slack): route /issue slash command through quick-create (MUL-3908) (#4793)

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>
This commit is contained in:
Bohan Jiang
2026-07-01 17:32:42 +08:00
committed by GitHub
parent 247e8ed5ab
commit 159d9bebb1
7 changed files with 155 additions and 173 deletions

View File

@@ -125,7 +125,7 @@ app-level トークンは Socket Mode 接続を認可します。これはコン
| **エージェント → Integrations** | owner と admin には **Connect Slack** が表示され、接続すると **Connected to Slack** バッジと **Disconnect** コントロールに切り替わります。 |
| **Bot に DM** | ワークスペースメンバーが Bot に直接メッセージを送ります。会話はそのエージェントとの Multica [chat](/chat) セッションになり、すべての DM メッセージが読み取られます。 |
| **チャンネルで @ メンション** | Bot を招待し(`/invite @your-bot`)、@ メンションします。読み取られるのはメンションしたメッセージだけで、Bot はチャンネル全体を聞いているわけではありません。各 @bot **スレッド**がそれぞれ独立したセッションになります。 |
| **`/issue` スラッシュコマンド** | `/issue <タイトル>`(チャンネルでも DM でも)と入力すると、ワークスペースに新しい Multica イシューが作られ、あなたの名義になります。確認は本人にだけ返信されます——@ メンションは不要です。 |
| **`/issue` スラッシュコマンド** | `/issue <説明>`(チャンネルでも DM でも)と入力すると、エージェントがあなたの自然言語の説明を整った Multica イシューに仕立て、あなたの名義で起票します。まず本人にだけ受付の返信をし、イシューが用意できたら Multica の通知が届きます——@ メンションは不要です。 |
| **返信** | エージェントの回答は、同じ DM またはスレッドに投稿し返されます。 |
## Bot を使う(メンバー)
@@ -144,7 +144,7 @@ Bot を使えるのは **ワークスペースのメンバー** だけです。
- **チャンネルで** —— Bot は自動では参加しません。一度 `/invite @your-bot` を実行してから、`@your-bot <あなたのメッセージ>` とします。フォローアップのたびに再度メンションしてくださいBot は自分をメンションしたメッセージだけを読みます)。
- **DM で** —— Slack サイドバーの **Apps** 区画から Bot を開いて直接メッセージを送ります。メンションは不要です。
- **イシューを起票する** —— `/issue` スラッシュコマンドを使います(例: `/issue Fix the login redirect`。チャンネルでも DM でも使え(@ メンション不要)、確認は本人にだけ返信されます。初回のユーザーには、まずアカウント連携の使い切りリンクが届きます。
- **イシューを起票する** —— `/issue` スラッシュコマンドを使います(例: `/issue ログインのリダイレクトが Safari で壊れている`)。自然言語で説明すれば、エージェントがタイトルと構造化された説明を書いて起票してくれます。チャンネルでも DM でも使え(@ メンション不要)、まず本人にだけ受付の返信をし、イシューが作成されたら Multica の通知が届きます。初回のユーザーには、まずアカウント連携の使い切りリンクが届きます。
## 管理と切断

View File

@@ -125,7 +125,7 @@ app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성
| **Agent → Integrations** | owner와 admin에게는 **Connect Slack**이 보이며, 연결되면 **Connected to Slack** 배지와 **Disconnect** 컨트롤로 바뀝니다. |
| **봇에게 DM** | 워크스페이스 멤버가 봇에게 직접 메시지를 보냅니다. 그 대화는 에이전트와의 Multica [chat](/chat) 세션이 되며, 모든 DM 메시지를 읽습니다. |
| **채널에서 `@`-멘션** | 봇을 초대(`/invite @your-bot`)하고 `@`로 멘션하세요. 멘션한 메시지만 읽으며, 봇이 채널 전체를 듣지는 않습니다. 각 @bot **스레드**가 자체 세션입니다. |
| **`/issue` 슬래시 명령** | `/issue <제목>`을(채널이나 DM에서) 입력하면 워크스페이스에 새 Multica 이슈가 생성되고, 당신 이름으로 귀속됩니다. 확인은 본인에게만 비공개로 답하며 — @ 멘션이 필요 없습니다. |
| **`/issue` 슬래시 명령** | `/issue <설명>`을(채널이나 DM에서) 입력하면 에이전트가 당신의 자연어 설명을 잘 정리된 Multica 이슈로 만들어 당신 이름으로 등록합니다. 먼저 본인에게만 접수 확인을 비공개로 답하고, 이슈가 준비되면 Multica 알림을 받습니다 — @ 멘션이 필요 없습니다. |
| **답변** | 에이전트의 답변은 같은 DM 또는 스레드로 다시 게시됩니다. |
## 봇 사용하기 (멤버)
@@ -144,7 +144,7 @@ app-level token은 Socket Mode 연결을 인가합니다. 콘솔에서만 생성
- **채널에서** — 봇은 자동으로 참여하지 않습니다. `/invite @your-bot`을 한 번 실행한 다음 `@your-bot <당신의 메시지>`로 보내세요. 후속 메시지마다 다시 멘션하세요(봇은 자신을 멘션한 메시지만 읽습니다).
- **DM에서** — Slack 사이드바의 **Apps** 섹션에서 봇을 열고 직접 메시지를 보내세요. 멘션이 필요 없습니다.
- **이슈 생성** — `/issue` 슬래시 명령을 사용하세요(예: `/issue Fix the login redirect`). 채널이나 DM에서 모두 쓸 수 있고(@ 멘션 불필요), 확인은 본인에게만 비공개로 답합니다. 처음 사용하는 사람은 먼저 일회용 계정 연결 링크를 받습니다.
- **이슈 생성** — `/issue` 슬래시 명령을 사용하세요(예: `/issue 로그인 리디렉트가 Safari에서 깨져요`). 자연어로 설명하면 에이전트가 제목과 구조화된 설명을 작성해 이슈를 등록해 줍니다. 채널이나 DM에서 모두 쓸 수 있고(@ 멘션 불필요), 먼저 본인에게만 접수 확인을 비공개로 답하고, 이슈가 생성되면 Multica 알림을 받습니다. 처음 사용하는 사람은 먼저 일회용 계정 연결 링크를 받습니다.
## 관리 및 연결 해제

View File

@@ -125,7 +125,7 @@ Setting this up for **multiple agents**? Repeat the whole flow once per agent
| **Agent → Integrations** | Owners and admins see **Connect Slack**; once connected it flips to a **Connected to Slack** badge with a **Disconnect** control. |
| **DM the bot** | A workspace member messages the bot directly. The conversation becomes a Multica [chat](/chat) session with the agent; every DM message is read. |
| **@-mention in a channel** | Invite the bot (`/invite @your-bot`) and @-mention it. Only the mentioning message is read — the bot does not listen to the whole channel. Each @bot **thread** is its own session. |
| **`/issue` slash command** | Type `/issue <title>` (in a channel or a DM) to create a Multica issue in the workspace, attributed to you. It replies privately with a confirmation — no @-mention needed. |
| **`/issue` slash command** | Type `/issue <description>` (in a channel or a DM) and the agent turns your plain-language description into a well-formed Multica issue, attributed to you. It replies privately to acknowledge — you get a Multica notification when the issue is ready. No @-mention needed. |
| **Reply** | The agent's answer is posted back into the same DM or thread. |
## Use the bot (members)
@@ -144,7 +144,7 @@ Only **members of the workspace** can use the bot. If you aren't a member, or yo
- **In a channel** — the bot isn't auto-joined. Run `/invite @your-bot` once, then `@your-bot <your message>`. Re-mention it for each follow-up (the bot only reads messages that mention it).
- **In a DM** — open the bot from the Slack sidebar's **Apps** section and message it directly; no mention needed.
- **File an issue** — use the `/issue` slash command, e.g. `/issue Fix the login redirect`. It works in a channel or a DM (no @-mention needed) and replies with a private confirmation. First-time users get a one-time link to connect their account.
- **File an issue** — use the `/issue` slash command, e.g. `/issue the login redirect is broken on Safari`. Describe it in plain language; the agent writes a proper title and structured description for you and files the issue. It works in a channel or a DM (no @-mention needed) and replies privately to acknowledge — you'll get a Multica notification when the issue is created. First-time users get a one-time link to connect their account.
## Manage and disconnect

View File

@@ -125,7 +125,7 @@ App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建
| **智能体 → Integrations** | 所有者和管理员能看到 **Connect Slack**;连接后它会变成一个 **Connected to Slack** 徽标,并带一个 **Disconnect** 操作。 |
| **私聊 Bot** | 工作区成员直接给 Bot 发消息。这段对话会成为该智能体的一个 Multica [chat](/chat) 会话;每一条私聊消息都会被读取。 |
| **频道里 @ 它** | 把 Bot 邀请进来(`/invite @your-bot`)再 @ 它。只有 @ 它的那条消息会被读取——Bot 不会监听整个频道。每个 @bot 的 **thread** 都是它自己的会话。 |
| **`/issue` 斜杠命令** | 输入 `/issue <标题>`(在频道或私聊里)会在工作区创建一个新的 Multica issue记在你名下。它会私下回一条确认——不需要 @ Bot。 |
| **`/issue` 斜杠命令** | 输入 `/issue <描述>`(在频道或私聊里),智能体会把你的自然语言描述整理成一个规整的 Multica issue记在你名下。它会私下回一条「收到」——issue 创建好后你会收到一条 Multica 通知。不需要 @ Bot。 |
| **回复** | 智能体的答复会被发回同一段私聊或 thread 里。 |
## 使用 Bot成员
@@ -144,7 +144,7 @@ App-level token 用来授权 Socket Mode 连接。它只能在控制台里创建
- **在频道里** —— Bot 不会自动加入。先运行一次 `/invite @your-bot`,然后 `@your-bot <你的消息>`。每次追问都要重新 @ 它一下Bot 只读取 @ 了它的消息)。
- **在私聊里** —— 从 Slack 侧栏的 **Apps** 区块打开 Bot 并直接给它发消息;不用 @。
- **创建 issue** —— 使用 `/issue` 斜杠命令,比如 `/issue Fix the login redirect`。在频道或私聊里都能用(不用 @ Bot会私下回一条确认。第一次使用的人会先收到一个一次性的账号绑定链接。
- **创建 issue** —— 使用 `/issue` 斜杠命令,比如 `/issue 登录跳转在 Safari 上坏了`。用自然语言描述就行,智能体会替你写好标题和结构化描述再建 issue。在频道或私聊里都能用(不用 @ Bot会私下回一条「收到」——issue 创建好后你会收到一条 Multica 通知。第一次使用的人会先收到一个一次性的账号绑定链接。
## 管理与断开

View File

@@ -479,13 +479,15 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
h.SlackHistory = slack.NewHistory(queries, box.Open, slog.Default())
// `/issue` slash command (MUL-3908): a real Slack slash command,
// delivered over the same Socket Mode connection. It is a one-shot
// issue creation (no chat session or chat run; a todo issue assigned to
// the agent still triggers it via normal issue-assignment) with a private
// ephemeral confirmation, reusing the shared IssueService + binding service.
// delivered over the same Socket Mode connection. It is a quick-create
// entry point — the invoker's natural-language description is enqueued as
// a quick-create task (no chat session or chat run) and the agent authors
// the well-formed issue in the background — reusing the shared TaskService
// + binding service. The invoker gets a private ephemeral acknowledgement
// and a Multica notification when the issue lands.
slackSlash := slack.NewSlashCommandProcessor(slack.SlashCommandConfig{
Queries: queries,
Issues: h.IssueService,
Tasks: h.TaskService,
Binding: slackBindingSvc,
AppURL: appURLFromEnv(),
Logger: slog.Default(),

View File

@@ -3,7 +3,6 @@ package slack
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
@@ -13,7 +12,6 @@ import (
"github.com/slack-go/slack"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -25,21 +23,29 @@ import (
// slash command in the app manifest is what makes it reach us — as an
// `EventTypeSlashCommand` over the same Socket Mode connection.
//
// Unlike the message path (chat session + dedup + debounced chat run), a slash
// command creates no channel message and starts no chat session / chat run: it
// is a one-shot issue creation with a PRIVATE (ephemeral) confirmation back to
// the invoker via the command's response_url. It reuses the same installation
// routing, identity + membership checks, and the shared IssueService, so a
// slash-command issue shares the counter, dup guard, project boundary,
// broadcast, analytics and agent-enqueue with every other create path — i.e. a
// `todo` issue assigned to the agent still triggers the agent through the normal
// issue-assignment path (maybeEnqueueOnAssign), exactly like the message /issue.
// The command is a QUICK-CREATE entry point: it does NOT create the issue
// itself. It takes the invoker's natural-language description as a prompt and
// enqueues a quick-create task against the installation's agent — the very same
// pipeline as the web "quick create" modal (TaskService.EnqueueQuickCreateTask).
// The agent turns the prompt into a well-formed `multica issue create` in the
// background, so the issue gets a proper title + structured description instead
// of the raw one-liner the user typed. Because creation is asynchronous, the
// command replies with a PRIVATE (ephemeral) acknowledgement via the command's
// response_url — there is no issue number to hand back yet — and the agent's
// completion surfaces to the invoker as a Multica inbox notification through the
// shared quick-create completion path. It starts no chat session / chat run.
//
// The installation routing and identity + membership checks mirror the message
// path (resolvers.go) so a slash-command quick-create respects the same
// workspace boundary and account binding as every other Slack entry point; they
// are kept local so the proven inbound pipeline is untouched.
const issueSlashCommand = "/issue"
// User-facing ephemeral replies. Kept terse; only the invoker sees them.
const (
slashUsageText = "Please give the issue a title, e.g. `/issue Fix the login redirect`."
slashUsageText = "Tell me what to file, e.g. `/issue the login button does nothing on Safari`."
slashQueuedText = "✅ On it — I'm turning that into an issue. You'll get a Multica notification when it's ready."
slashNotMemberText = "You're not a member of this Multica workspace, so I can't file an issue for you."
slashLinkAccountFallback = "Link your Slack account to Multica first, then try `/issue` again."
slashInternalErrorText = "⚠️ Something went wrong creating the issue. Please try again."
@@ -54,13 +60,19 @@ type slashQueries interface {
GetChannelInstallationByAppID(ctx context.Context, arg db.GetChannelInstallationByAppIDParams) (db.ChannelInstallation, error)
GetChannelUserBindingByUserID(ctx context.Context, arg db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error)
GetMemberByUserAndWorkspace(ctx context.Context, arg db.GetMemberByUserAndWorkspaceParams) (db.Member, error)
GetWorkspace(ctx context.Context, id pgtype.UUID) (db.Workspace, error)
}
// quickCreateEnqueuer is the narrow slice of *service.TaskService the slash
// command needs to hand the invoker's prompt to the agent. *service.TaskService
// satisfies it; tests supply a fake.
type quickCreateEnqueuer interface {
EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID, agentID, squadID pgtype.UUID, prompt string, projectID, parentIssueID pgtype.UUID, attachmentIDs []pgtype.UUID) (db.AgentTaskQueue, error)
}
// SlashCommandProcessor handles the Slack `/issue` slash command end to end.
type SlashCommandProcessor struct {
q slashQueries
issues engine.IssueCreator
tasks quickCreateEnqueuer
binding bindingMinter
appURL string
bindingPath string
@@ -72,11 +84,11 @@ type SlashCommandProcessor struct {
// SlashCommandConfig configures the processor. Binding + AppURL are required for
// the unbound-user "link your account" reply; without them that case falls back
// to a plain instruction. Issues + Queries are required for the command to do
// to a plain instruction. Tasks + Queries are required for the command to do
// anything.
type SlashCommandConfig struct {
Queries *db.Queries
Issues engine.IssueCreator
Tasks quickCreateEnqueuer
Binding bindingMinter
AppURL string
BindingPath string // default "/slack/bind"
@@ -100,7 +112,7 @@ func NewSlashCommandProcessor(cfg SlashCommandConfig) *SlashCommandProcessor {
}
p := &SlashCommandProcessor{
q: cfg.Queries,
issues: cfg.Issues,
tasks: cfg.Tasks,
binding: cfg.Binding,
appURL: strings.TrimRight(cfg.AppURL, "/"),
bindingPath: bindingPath,
@@ -135,8 +147,8 @@ func (p *SlashCommandProcessor) Handle(ctx context.Context, cmd slack.SlashComma
// process runs the command and returns the ephemeral text to reply with.
func (p *SlashCommandProcessor) process(ctx context.Context, cmd slack.SlashCommand) string {
title, description := splitIssueText(cmd.Text)
if title == "" {
prompt := strings.TrimSpace(cmd.Text)
if prompt == "" {
return slashUsageText
}
@@ -167,25 +179,27 @@ func (p *SlashCommandProcessor) process(ctx context.Context, cmd slack.SlashComm
}
}
res, err := p.issues.Create(ctx, service.IssueCreateParams{
WorkspaceID: inst.WorkspaceID,
Title: title,
Description: pgtype.Text{String: description, Valid: description != ""},
Status: "todo",
Priority: "none",
AssigneeType: pgtype.Text{String: "agent", Valid: true},
AssigneeID: inst.AgentID,
CreatorType: "member",
CreatorID: userID,
OriginType: pgtype.Text{String: originSlackChat, Valid: true},
// No chat session backs a slash command, so OriginID stays NULL.
}, service.IssueCreateOpts{})
if err != nil {
p.logger.WarnContext(ctx, "slack slash command: create issue failed",
// Hand the raw natural-language prompt to the installation's agent as a
// quick-create task; the agent authors the well-formed issue in the
// background and attributes it to the bound member. No project / parent /
// attachments and no squad routing — the slash command targets the
// installation's own agent directly.
if _, err := p.tasks.EnqueueQuickCreateTask(
ctx,
inst.WorkspaceID,
userID,
inst.AgentID,
pgtype.UUID{}, // no squad — dispatch straight to the installation agent
prompt,
pgtype.UUID{}, // no project
pgtype.UUID{}, // no parent issue
nil, // no attachments
); err != nil {
p.logger.WarnContext(ctx, "slack slash command: enqueue quick-create failed",
"app_id", cmd.APIAppID, "error", err)
return slashInternalErrorText
}
return p.issueCreatedText(ctx, inst.WorkspaceID, res.Issue)
return slashQueuedText
}
// resolveInstallation maps the command's api_app_id (+ event team) to its
@@ -259,40 +273,3 @@ func (p *SlashCommandProcessor) bindingText(ctx context.Context, inst engine.Res
return "👋 To file issues, link your Slack account to Multica: <" +
bindURL + "|link your account>\n(This link expires in 15 minutes.)"
}
// issueCreatedText renders the success confirmation with the workspace-prefixed
// identifier (falling back to #<number> when no prefix is set).
func (p *SlashCommandProcessor) issueCreatedText(ctx context.Context, workspaceID pgtype.UUID, issue db.Issue) string {
identifier := fmt.Sprintf("#%d", issue.Number)
if ws, err := p.q.GetWorkspace(ctx, workspaceID); err == nil && ws.IssuePrefix != "" {
identifier = fmt.Sprintf("%s-%d", ws.IssuePrefix, issue.Number)
}
title := strings.TrimSpace(issue.Title)
if title == "" {
return "✅ Created " + identifier
}
return "✅ Created " + identifier + " — " + title
}
// splitIssueText splits the slash-command argument string into a title (first
// non-empty line) and an optional description (the remaining lines). Slack
// slash-command input is normally single-line, so the description is usually
// empty.
func splitIssueText(text string) (title, description string) {
lines := strings.Split(text, "\n")
first := -1
for i, l := range lines {
if strings.TrimSpace(l) != "" {
first = i
break
}
}
if first == -1 {
return "", ""
}
title = strings.TrimSpace(lines[first])
if first+1 < len(lines) {
description = strings.TrimRight(strings.Join(lines[first+1:], "\n"), " \t\n")
}
return title, description
}

View File

@@ -2,6 +2,7 @@ package slack
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
@@ -10,8 +11,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
"github.com/multica-ai/multica/server/internal/integrations/channel/engine"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -23,8 +22,6 @@ type fakeSlashQueries struct {
binding db.ChannelUserBinding
bindErr error
memberErr error
ws db.Workspace
wsErr error
gotAppID string
}
@@ -41,21 +38,28 @@ func (f *fakeSlashQueries) GetMemberByUserAndWorkspace(_ context.Context, _ db.G
return db.Member{}, f.memberErr
}
func (f *fakeSlashQueries) GetWorkspace(_ context.Context, _ pgtype.UUID) (db.Workspace, error) {
return f.ws, f.wsErr
// 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
}
type fakeIssueCreator struct {
result service.IssueCreateResult
err error
calls int
params service.IssueCreateParams
}
func (f *fakeIssueCreator) Create(_ context.Context, p service.IssueCreateParams, _ service.IssueCreateOpts) (service.IssueCreateResult, error) {
func (f *fakeQuickCreate) EnqueueQuickCreateTask(_ context.Context, workspaceID, requesterID, agentID, squadID pgtype.UUID, prompt string, _, _ pgtype.UUID, _ []pgtype.UUID) (db.AgentTaskQueue, error) {
f.calls++
f.params = p
return f.result, f.err
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 {
@@ -69,12 +73,12 @@ func slashTestUUID(b byte) pgtype.UUID {
// 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, issues engine.IssueCreator, binding bindingMinter) (*SlashCommandProcessor, *string, *int) {
func newTestSlashProcessor(q slashQueries, tasks quickCreateEnqueuer, binding bindingMinter) (*SlashCommandProcessor, *string, *int) {
captured := new(string)
count := new(int)
p := &SlashCommandProcessor{
q: q,
issues: issues,
tasks: tasks,
binding: binding,
appURL: "https://app.example",
bindingPath: "/slack/bind",
@@ -113,74 +117,74 @@ func issueSlashCmd() slack.SlashCommand {
// ---- tests ----
func TestSlashHandle_CreatesIssueAndConfirms(t *testing.T) {
func TestSlashHandle_EnqueuesQuickCreateAndAcks(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
ws: db.Workspace{IssuePrefix: "MUL"},
}
issues := &fakeIssueCreator{result: service.IssueCreateResult{Issue: db.Issue{Number: 7, Title: "Fix login"}}}
p, captured, count := newTestSlashProcessor(q, issues, &fakeBindingMinter{})
tasks := &fakeQuickCreate{}
p, captured, count := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if issues.calls != 1 {
t.Fatalf("expected 1 issue create, got %d", issues.calls)
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 !strings.Contains(*captured, "MUL-7") || !strings.Contains(*captured, "Fix login") {
t.Fatalf("confirmation missing identifier/title: %q", *captured)
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 issues.params.Title != "Fix login" {
t.Errorf("issue title = %q, want Fix login", issues.params.Title)
if tasks.prompt != "Fix login" {
t.Errorf("quick-create prompt = %q, want Fix login", tasks.prompt)
}
if issues.params.AssigneeID != slashTestUUID(3) {
t.Errorf("issue not assigned to the installation agent")
if tasks.workspaceID != slashTestUUID(2) {
t.Errorf("quick-create workspace is not the installation workspace")
}
if issues.params.CreatorID != slashTestUUID(9) {
t.Errorf("issue creator is not the bound member")
if tasks.agentID != slashTestUUID(3) {
t.Errorf("quick-create not dispatched to the installation agent")
}
if issues.params.OriginID.Valid {
t.Errorf("slash-command issue must have no origin session id")
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_TitleAndDescription(t *testing.T) {
func TestSlashHandle_MultilinePromptPassedThrough(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
ws: db.Workspace{IssuePrefix: "MUL"},
}
issues := &fakeIssueCreator{result: service.IssueCreateResult{Issue: db.Issue{Number: 1, Title: "Title"}}}
p, _, _ := newTestSlashProcessor(q, issues, &fakeBindingMinter{})
tasks := &fakeQuickCreate{}
p, _, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Text = "Title\nline one\nline two"
cmd.Text = " Title\nline one\nline two "
p.Handle(context.Background(), cmd)
if issues.params.Title != "Title" {
t.Errorf("title = %q, want Title", issues.params.Title)
}
if got := issues.params.Description.String; got != "line one\nline two" {
t.Errorf("description = %q, want two body lines", got)
// 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_EmptyTitleIsUsage(t *testing.T) {
issues := &fakeIssueCreator{}
p, captured, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, issues, &fakeBindingMinter{})
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 issues.calls != 0 {
t.Fatalf("empty title must not create an issue")
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)
@@ -189,14 +193,14 @@ func TestSlashHandle_EmptyTitleIsUsage(t *testing.T) {
func TestSlashHandle_UnboundUserGetsLink(t *testing.T) {
q := &fakeSlashQueries{inst: activeSlashInstallation(), bindErr: pgx.ErrNoRows}
issues := &fakeIssueCreator{}
tasks := &fakeQuickCreate{}
bind := &fakeBindingMinter{raw: "TOKEN123"}
p, captured, _ := newTestSlashProcessor(q, issues, bind)
p, captured, _ := newTestSlashProcessor(q, tasks, bind)
p.Handle(context.Background(), issueSlashCmd())
if issues.calls != 0 {
t.Fatalf("unbound user must not create an issue")
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)
@@ -212,13 +216,13 @@ func TestSlashHandle_NonMemberDropped(t *testing.T) {
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
memberErr: pgx.ErrNoRows,
}
issues := &fakeIssueCreator{}
p, captured, _ := newTestSlashProcessor(q, issues, &fakeBindingMinter{})
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if issues.calls != 0 {
t.Fatalf("non-member must not create an issue")
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)
@@ -228,57 +232,56 @@ func TestSlashHandle_NonMemberDropped(t *testing.T) {
func TestSlashHandle_InactiveInstallation(t *testing.T) {
inst := activeSlashInstallation()
inst.Status = "revoked"
issues := &fakeIssueCreator{}
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: inst}, issues, &fakeBindingMinter{})
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: inst}, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if issues.calls != 0 || *captured != slashDisabledText {
t.Fatalf("inactive install: calls=%d reply=%q", issues.calls, *captured)
if tasks.calls != 0 || *captured != slashDisabledText {
t.Fatalf("inactive install: calls=%d reply=%q", tasks.calls, *captured)
}
}
func TestSlashHandle_TeamMismatchTreatedAsDisconnected(t *testing.T) {
issues := &fakeIssueCreator{}
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, issues, &fakeBindingMinter{})
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 issues.calls != 0 || *captured != slashDisabledText {
t.Fatalf("team mismatch: calls=%d reply=%q", issues.calls, *captured)
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) {
issues := &fakeIssueCreator{}
p, _, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, issues, &fakeBindingMinter{})
tasks := &fakeQuickCreate{}
p, _, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Command = "/other"
p.Handle(context.Background(), cmd)
if issues.calls != 0 || *count != 0 {
t.Fatalf("non-/issue command must be ignored: calls=%d replies=%d", issues.calls, *count)
}
}
func TestSplitIssueText(t *testing.T) {
cases := []struct {
in, title, desc string
}{
{"Fix login", "Fix login", ""},
{" Fix login ", "Fix login", ""},
{"Title\nbody one\nbody two", "Title", "body one\nbody two"},
{"", "", ""},
{" \n ", "", ""},
{"\n\nTitle\nbody", "Title", "body"},
}
for _, c := range cases {
gotTitle, gotDesc := splitIssueText(c.in)
if gotTitle != c.title || gotDesc != c.desc {
t.Errorf("splitIssueText(%q) = (%q,%q), want (%q,%q)", c.in, gotTitle, gotDesc, c.title, c.desc)
}
if tasks.calls != 0 || *count != 0 {
t.Fatalf("non-/issue command must be ignored: calls=%d replies=%d", tasks.calls, *count)
}
}