Files
kandev/apps/backend/internal/utility/template/engine.go
T
João Salavisa 7e0b46c227 feat(slack): cookie-mode integration that triages threads via a utility agent (#775)
* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:03:42 +01:00

143 lines
6.3 KiB
Go

package template
import (
"regexp"
"strings"
)
// Context holds the data available for template resolution.
type Context struct {
// Git-related
GitDiff string // Output of git diff --staged or git diff
CommitLog string // Output of git log for the branch
ChangedFiles string // List of changed files
DiffSummary string // Summary of changes (insertions, deletions)
BranchName string // Current branch name
BaseBranch string // Base branch (main/master)
// Task-related
TaskTitle string // Title of the current task
TaskDescription string // Description of the current task
// Session-related
SessionID string // Current session ID
WorkspacePath string // Path to the workspace/worktree
// User input
UserPrompt string // User's original prompt text (for enhance-prompt)
// Conversation
ConversationHistory string // Formatted conversation transcript (for summarize-session)
// Custom key-value pairs for extensibility
Custom map[string]string
}
// Engine resolves template variables in prompts.
type Engine struct{}
// NewEngine creates a new template engine.
func NewEngine() *Engine {
return &Engine{}
}
// templatePattern matches {{Key}} placeholders.
var templatePattern = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`)
// Resolve resolves template variables in the given prompt using the provided context.
// Uses {{Key}} format (no dot) for consistency with scriptengine.
// Unknown placeholders are left unchanged.
func (e *Engine) Resolve(promptTemplate string, ctx *Context) (string, error) {
return e.ResolveWithOptions(promptTemplate, ctx, ResolveOptions{})
}
// ResolveOptions configures template resolution.
type ResolveOptions struct {
// MissingAsEmpty substitutes unknown placeholders with an empty string
// instead of leaving them as {{Var}}. Used for sessionless utility execution
// where some context variables (e.g. GitDiff, TaskTitle) are meaningless.
MissingAsEmpty bool
}
// ResolveWithOptions resolves template variables with the given options.
func (e *Engine) ResolveWithOptions(promptTemplate string, ctx *Context, opts ResolveOptions) (string, error) {
if ctx == nil {
ctx = &Context{}
}
data := e.buildTemplateData(ctx)
result := templatePattern.ReplaceAllStringFunc(promptTemplate, func(match string) string {
key := strings.TrimSuffix(strings.TrimPrefix(match, "{{"), "}}")
if val, ok := data[key]; ok {
return val
}
if opts.MissingAsEmpty {
return ""
}
return match
})
return result, nil
}
// buildTemplateData creates the data map for template execution.
func (e *Engine) buildTemplateData(ctx *Context) map[string]string {
data := map[string]string{
"GitDiff": ctx.GitDiff,
"CommitLog": ctx.CommitLog,
"ChangedFiles": ctx.ChangedFiles,
"DiffSummary": ctx.DiffSummary,
"BranchName": ctx.BranchName,
"BaseBranch": ctx.BaseBranch,
"TaskTitle": ctx.TaskTitle,
"TaskDescription": ctx.TaskDescription,
"SessionID": ctx.SessionID,
"WorkspacePath": ctx.WorkspacePath,
"UserPrompt": ctx.UserPrompt,
"ConversationHistory": ctx.ConversationHistory,
}
// Add custom key-value pairs
for k, v := range ctx.Custom {
data[k] = v
}
return data
}
// AvailableVariables returns the list of available template variables.
// Integration-specific variables (Slack, future webhooks, etc.) are listed
// in their own category so the UI can group them and users can tell which
// ones make sense in a given utility-agent context.
func (e *Engine) AvailableVariables() []VariableInfo {
const categorySlack = "slack"
return []VariableInfo{
{Name: "GitDiff", Description: "Output of git diff --staged or git diff", Example: "diff --git a/main.go...", Category: "git"},
{Name: "CommitLog", Description: "Git log for the current branch", Example: "abc123 Fix bug in handler", Category: "git"},
{Name: "ChangedFiles", Description: "List of changed files", Example: "src/main.go\nsrc/utils.go", Category: "git"},
{Name: "DiffSummary", Description: "Summary of changes (files, insertions, deletions)", Example: "3 files, +50 -10", Category: "git"},
{Name: "BranchName", Description: "Current git branch name", Example: "feature/add-login", Category: "git"},
{Name: "BaseBranch", Description: "Base branch (main/master/develop)", Example: "main", Category: "git"},
{Name: "TaskTitle", Description: "Title of the current task", Example: "Add user authentication", Category: "task"},
{Name: "TaskDescription", Description: "Description of the current task", Example: "Implement OAuth2 login flow", Category: "task"},
{Name: "SessionID", Description: "Current session ID", Example: "sess_abc123", Category: "session"},
{Name: "WorkspacePath", Description: "Path to the workspace/worktree", Example: "/home/user/project", Category: "session"},
{Name: "UserPrompt", Description: "User's original prompt text", Example: "Fix the login bug", Category: "input"},
{Name: "ConversationHistory", Description: "Formatted conversation transcript", Example: "User: Fix the bug\nAgent: I'll look into...", Category: "session"},
{Name: "SlackInstruction", Description: "User's instruction (the !kandev message minus the prefix)", Example: "investigate the safari login bug", Category: categorySlack},
{Name: "SlackThread", Description: "Surrounding Slack thread, one line per message", Example: "alice (1714658000.0): bug repros every Monday\nbob (1714659000.0): seeing it on safari only", Category: categorySlack},
{Name: "SlackPermalink", Description: "Canonical URL of the triggering Slack message", Example: "https://acme.slack.com/archives/C1/p1714659000000100", Category: categorySlack},
{Name: "SlackUser", Description: "Sender of the !kandev message (@name or user id)", Example: "@alice", Category: categorySlack},
{Name: "SlackChannelID", Description: "Slack channel id of the trigger message", Example: "C0123ABCD", Category: categorySlack},
{Name: "SlackTS", Description: "Slack timestamp of the trigger message", Example: "1714659000.000100", Category: categorySlack},
}
}
// VariableInfo describes a template variable.
type VariableInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Example string `json:"example"`
Category string `json:"category"` // "git", "task", "session", "input", "slack"
}