From efbf795363dfa99961e71e0bd80492f3902785c4 Mon Sep 17 00:00:00 2001 From: jichangjun Date: Tue, 26 Aug 2025 19:50:01 +0800 Subject: [PATCH] feat: add configurable mention triggers support - Add mention configuration to config structure with triggers array and default trigger - Implement enhanced mention parsing with regex-based exact matching - Support multiple mention triggers (@ai, @code-assistant, @qiniu-ci) - Add comprehensive test coverage for mention parsing functionality - Maintain backward compatibility with existing @qiniu-ci trigger --- config.example.yaml | 10 ++ internal/config/config.go | 17 +++ pkg/models/events.go | 91 +++++++++---- pkg/models/events_test.go | 276 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 23 deletions(-) create mode 100644 pkg/models/events_test.go diff --git a/config.example.yaml b/config.example.yaml index 0378fc9..f2c1ab3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -34,3 +34,13 @@ docker: # Code provider configuration code_provider: claude # Options: claude, gemini use_docker: true # Whether to use Docker, false means use local CLI + +# AI mention configuration +mention: + # List of mention triggers that will activate AI processing + # Supports multiple triggers for different use cases + triggers: + - "@qiniu-ci" # Default company AI assistant + - "@qiniu-ai" # another company AI assistant + # Default trigger (used when no specific trigger list is provided) + default_trigger: "@qiniu-ci" diff --git a/internal/config/config.go b/internal/config/config.go index 18d206b..fc41f9c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,8 @@ type Config struct { // v0.6 Configuration Commands CommandsConfig `yaml:"commands"` + // AI Mention Configuration + Mention MentionConfig `yaml:"mention"` } type GeminiConfig struct { @@ -73,6 +75,13 @@ type CommandsConfig struct { GlobalPath string `yaml:"global_path"` } +type MentionConfig struct { + // 可配置的mention目标,支持多个 + Triggers []string `yaml:"triggers"` + // 默认的mention目标(向后兼容) + DefaultTrigger string `yaml:"default_trigger"` +} + func Load(configPath string) (*Config, error) { // 首先尝试从文件加载 if _, err := os.Stat(configPath); err == nil { @@ -168,6 +177,10 @@ func (c *Config) loadFromEnv() { if globalPath := os.Getenv("GLOBAL_COMMANDS_PATH"); globalPath != "" { c.Commands.GlobalPath = globalPath } + // Mention configuration from environment + if mentionTrigger := os.Getenv("MENTION_TRIGGER"); mentionTrigger != "" { + c.Mention.DefaultTrigger = mentionTrigger + } } func loadFromEnv() *Config { @@ -213,6 +226,10 @@ func loadFromEnv() *Config { Commands: CommandsConfig{ GlobalPath: os.Getenv("GLOBAL_COMMANDS_PATH"), }, + Mention: MentionConfig{ + Triggers: []string{getEnvOrDefault("MENTION_TRIGGER", "@qiniu-ci")}, + DefaultTrigger: getEnvOrDefault("MENTION_TRIGGER", "@qiniu-ci"), + }, CodeProvider: getEnvOrDefault("CODE_PROVIDER", "claude"), UseDocker: getEnvBoolOrDefault("USE_DOCKER", true), } diff --git a/pkg/models/events.go b/pkg/models/events.go index 52e6410..718897e 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -1,6 +1,7 @@ package models import ( + "regexp" "strings" "time" @@ -156,8 +157,33 @@ const ( AIModelGemini = "gemini" ) -// HasCommand 检查上下文是否包含命令 +// MentionConfig 提及配置接口 +type MentionConfig interface { + GetTriggers() []string + GetDefaultTrigger() string +} + +// ConfigMentionAdapter 从内部config包适配到models包 +type ConfigMentionAdapter struct { + Triggers []string + DefaultTrigger string +} + +func (c *ConfigMentionAdapter) GetTriggers() []string { + return c.Triggers +} + +func (c *ConfigMentionAdapter) GetDefaultTrigger() string { + return c.DefaultTrigger +} + +// HasCommand 检查上下文是否包含命令(使用默认mention配置) func HasCommand(ctx GitHubContext) (*CommandInfo, bool) { + return HasCommandWithConfig(ctx, nil) +} + +// HasCommandWithConfig 检查上下文是否包含命令(支持自定义mention配置) +func HasCommandWithConfig(ctx GitHubContext, mentionConfig MentionConfig) (*CommandInfo, bool) { var content string switch c := ctx.(type) { @@ -186,7 +212,11 @@ func HasCommand(ctx GitHubContext) (*CommandInfo, bool) { return cmdInfo, true } - // Then try to parse as @claude mention + // Then try to parse as mention with config + if mentionConfig != nil { + return parseMentionWithConfig(content, mentionConfig) + } + // Fallback to default mention parsing return parseMention(content) } @@ -231,43 +261,58 @@ func parseCommand(content string) (*CommandInfo, bool) { }, true } -// parseMention 解析@qiniu-ci提及 +// parseMention 解析@qiniu-ci提及(默认触发词,向后兼容) func parseMention(content string) (*CommandInfo, bool) { - content = strings.TrimSpace(content) + return parseMentionWithTrigger(content, CommandClaude) +} - // 检查是否包含@qiniu-ci - if !strings.Contains(content, CommandClaude) { - return nil, false +// parseMentionWithConfig 使用配置解析mention +func parseMentionWithConfig(content string, config MentionConfig) (*CommandInfo, bool) { + triggers := config.GetTriggers() + if len(triggers) == 0 { + triggers = []string{config.GetDefaultTrigger()} } - // 找到@qiniu-ci的位置 - mentionIndex := strings.Index(content, CommandClaude) - if mentionIndex == -1 { + // 尝试所有配置的触发词 + for _, trigger := range triggers { + if trigger == "" { + continue + } + if cmdInfo, found := parseMentionWithTrigger(content, trigger); found { + return cmdInfo, true + } + } + + return nil, false +} + +// parseMentionWithTrigger 使用指定触发词解析mention,传递完整评论内容 +func parseMentionWithTrigger(content string, trigger string) (*CommandInfo, bool) { + content = strings.TrimSpace(content) + pattern := `(^|\s)` + regexp.QuoteMeta(trigger) + `([\s.,!?;:]|$)` + re := regexp.MustCompile(pattern) + + match := re.FindStringSubmatch(content) + if match == nil { return nil, false } - // 提取@qiniu-ci之后的内容作为参数 - afterMention := strings.TrimSpace(content[mentionIndex+len(CommandClaude):]) + // NOTE(CarlJin): mention 模式传递暂时完整评论内容 + fullContent := content - // 解析AI模型和参数(类似于parseCommand的逻辑) var aiModel string - var args string - if strings.HasPrefix(afterMention, "-claude") { + // 查找模型指定标志 + if strings.Contains(fullContent, "-claude") { aiModel = AIModelClaude - args = strings.TrimSpace(strings.TrimPrefix(afterMention, "-claude")) - } else if strings.HasPrefix(afterMention, "-gemini") { + } else if strings.Contains(fullContent, "-gemini") { aiModel = AIModelGemini - args = strings.TrimSpace(strings.TrimPrefix(afterMention, "-gemini")) - } else { - aiModel = "" - args = afterMention } return &CommandInfo{ - Command: CommandClaude, + Command: CommandClaude, // 总是使用CommandClaude作为mention的标识 AIModel: aiModel, - Args: args, + Args: fullContent, // 传递完整评论内容 RawText: content, }, true } diff --git a/pkg/models/events_test.go b/pkg/models/events_test.go new file mode 100644 index 0000000..10806f8 --- /dev/null +++ b/pkg/models/events_test.go @@ -0,0 +1,276 @@ +package models + +import ( + "testing" + + "github.com/google/go-github/v58/github" +) + +func TestParseMention(t *testing.T) { + tests := []struct { + name string + content string + expected bool + args string // 现在应该是完整内容 + aiModel string + }{ + // 正确的匹配案例 + { + name: "独立的@qiniu-ci", + content: "@qiniu-ci", + expected: true, + args: "@qiniu-ci", // 现在传递完整内容 + aiModel: "", + }, + { + name: "@qiniu-ci后跟标点", + content: "@qiniu-ci, 请分析代码", + expected: true, + args: "@qiniu-ci, 请分析代码", // 完整内容 + aiModel: "", + }, + { + name: "@qiniu-ci在句子开头", + content: "@qiniu-ci 这个函数有性能问题吗?", + expected: true, + args: "@qiniu-ci 这个函数有性能问题吗?", // 完整内容 + aiModel: "", + }, + { + name: "前后有空格的@qiniu-ci", + content: "Hello @qiniu-ci please help me", + expected: true, + args: "Hello @qiniu-ci please help me", // 完整内容 + aiModel: "", + }, + { + name: "@qiniu-ci后跟感叹号", + content: "@qiniu-ci! 快来帮忙", + expected: true, + args: "@qiniu-ci! 快来帮忙", // 完整内容 + aiModel: "", + }, + { + name: "@qiniu-ci指定Claude模型", + content: "@qiniu-ci -claude 检查算法复杂度", + expected: true, + args: "@qiniu-ci -claude 检查算法复杂度", // 完整内容 + aiModel: AIModelClaude, + }, + { + name: "@qiniu-ci指定Gemini模型", + content: "@qiniu-ci -gemini 这个模块如何重构", + expected: true, + args: "@qiniu-ci -gemini 这个模块如何重构", // 完整内容 + aiModel: AIModelGemini, + }, + // 不匹配的案例 + { + name: "作为其他词的一部分", + content: "@qiniu-ci-test", + expected: false, + }, + { + name: "在邮箱地址中", + content: "email@qiniu-ci.com", + expected: false, + }, + { + name: "在URL中", + content: "https://github.com/@qiniu-ci/repo", + expected: false, + }, + { + name: "不包含@qiniu-ci", + content: "这是一个普通的评论", + expected: false, + }, + { + name: "空字符串", + content: "", + expected: false, + }, + { + name: "只包含@qiniu但不完整", + content: "@qiniu 请帮忙", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmdInfo, found := parseMention(tt.content) + + if found != tt.expected { + t.Errorf("parseMention(%q) found = %v, want %v", tt.content, found, tt.expected) + return + } + + if !tt.expected { + return // 如果期望不匹配,不需要检查其他字段 + } + + if cmdInfo == nil { + t.Errorf("parseMention(%q) returned nil cmdInfo when found = true", tt.content) + return + } + + if cmdInfo.Command != CommandClaude { + t.Errorf("parseMention(%q) command = %v, want %v", tt.content, cmdInfo.Command, CommandClaude) + } + + if cmdInfo.Args != tt.args { + t.Errorf("parseMention(%q) args = %q, want %q", tt.content, cmdInfo.Args, tt.args) + } + + if cmdInfo.AIModel != tt.aiModel { + t.Errorf("parseMention(%q) aiModel = %q, want %q", tt.content, cmdInfo.AIModel, tt.aiModel) + } + + if cmdInfo.RawText != tt.content { + t.Errorf("parseMention(%q) rawText = %q, want %q", tt.content, cmdInfo.RawText, tt.content) + } + }) + } +} + +func TestHasCommand(t *testing.T) { + tests := []struct { + name string + context string + expected bool + command string + }{ + { + name: "Issue评论中的@qiniu-ci", + context: "请帮我 @qiniu-ci 分析这个函数", + expected: true, + command: CommandClaude, + }, + { + name: "斜杠命令优先级更高", + context: "/code 实现登录功能 @qiniu-ci", + expected: true, + command: CommandCode, + }, + { + name: "无匹配命令", + context: "这是一个普通的评论,没有特殊指令", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // 创建一个模拟的IssueCommentContext + body := tt.context + mockComment := &IssueCommentContext{ + BaseContext: BaseContext{ + Type: EventIssueComment, + }, + Comment: &github.IssueComment{ + Body: &body, + }, + } + + cmdInfo, found := HasCommand(mockComment) + + if found != tt.expected { + t.Errorf("HasCommand() found = %v, want %v", found, tt.expected) + return + } + + if tt.expected && cmdInfo.Command != tt.command { + t.Errorf("HasCommand() command = %v, want %v", cmdInfo.Command, tt.command) + } + }) + } +} + +func TestParseMentionWithConfig(t *testing.T) { + // 创建自定义mention配置 + mentionConfig := &ConfigMentionAdapter{ + Triggers: []string{"@ai", "@code-assistant", "@qiniu-ci"}, + DefaultTrigger: "@ai", + } + + tests := []struct { + name string + content string + expected bool + args string + aiModel string + }{ + { + name: "使用@ai触发", + content: "@ai 请帮我优化这个函数", + expected: true, + args: "@ai 请帮我优化这个函数", + aiModel: "", + }, + { + name: "使用@code-assistant触发", + content: "Hello @code-assistant, can you review this?", + expected: true, + args: "Hello @code-assistant, can you review this?", + aiModel: "", + }, + { + name: "使用原有@qiniu-ci触发", + content: "@qiniu-ci -claude 分析性能", + expected: true, + args: "@qiniu-ci -claude 分析性能", + aiModel: AIModelClaude, + }, + { + name: "不匹配的mention", + content: "@unknown 请帮忙", + expected: false, + }, + { + name: "空配置时不匹配", + content: "@ai 请帮忙", + expected: false, + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cmdInfo *CommandInfo + var found bool + + // 最后一个测试用例使用空配置 + if i == len(tests)-1 { + emptyConfig := &ConfigMentionAdapter{ + Triggers: []string{}, + DefaultTrigger: "", + } + cmdInfo, found = parseMentionWithConfig(tt.content, emptyConfig) + } else { + cmdInfo, found = parseMentionWithConfig(tt.content, mentionConfig) + } + + if found != tt.expected { + t.Errorf("parseMentionWithConfig(%q) found = %v, want %v", tt.content, found, tt.expected) + return + } + + if !tt.expected { + return + } + + if cmdInfo == nil { + t.Errorf("parseMentionWithConfig(%q) returned nil cmdInfo when found = true", tt.content) + return + } + + if cmdInfo.Args != tt.args { + t.Errorf("parseMentionWithConfig(%q) args = %q, want %q", tt.content, cmdInfo.Args, tt.args) + } + + if cmdInfo.AIModel != tt.aiModel { + t.Errorf("parseMentionWithConfig(%q) aiModel = %q, want %q", tt.content, cmdInfo.AIModel, tt.aiModel) + } + }) + } +}