gitlink-cli/shortcuts/workflow/triage_rules.go

302 lines
10 KiB
Go

package workflow
import (
"fmt"
"strings"
)
type keywordRule struct {
issueType string
keywords []string
weight int
}
var triageKeywordRules = []keywordRule{
{IssueTypeBug, []string{"crash", "error", "panic", "exception", "fail", "failed", "failure", "broken", "cannot", "bug", "报错", "崩溃", "异常", "失败", "无法", "不能", "问题"}, 10},
{IssueTypeFeature, []string{"feature", "request", "support", "add", "implement", "enhancement", "功能", "支持", "增加", "建议", "增强"}, 8},
{IssueTypeQuestion, []string{"how", "why", "help", "usage", "question", "如何", "怎么", "为什么", "请问", "求助"}, 7},
{IssueTypeDocs, []string{"doc", "docs", "documentation", "readme", "typo", "example", "guide", "文档", "说明", "错别字", "示例", "教程"}, 8},
{IssueTypeCI, []string{"ci", "build", "workflow", "action", "test failed", "pipeline", "构建", "测试失败", "流水线"}, 10},
{IssueTypeSecurity, []string{"token", "leak", "leaked", "secret", "auth", "permission", "vulnerability", "cve", "泄露", "密钥", "权限", "漏洞", "安全"}, 12},
{IssueTypePerformance, []string{"slow", "timeout", "latency", "memory", "cpu", "performance", "慢", "超时", "性能", "内存"}, 8},
{IssueTypeRefactor, []string{"refactor", "cleanup", "simplify", "restructure", "重构", "清理", "简化", "结构调整"}, 6},
}
var typeTieOrder = []string{
IssueTypeSecurity,
IssueTypeBug,
IssueTypeCI,
IssueTypePerformance,
IssueTypeFeature,
IssueTypeDocs,
IssueTypeQuestion,
IssueTypeRefactor,
}
func AnalyzeIssue(input IssueInput, lang string) TriageResult {
lang = normalizeLang(lang)
text := normalizeIssueText(input)
scores, matchedRules, reasoning := scoreIssueTypes(text, input.Labels)
detectedType := chooseDetectedType(scores)
riskFlags := detectRiskFlags(text, detectedType)
priority, priorityReason := determinePriority(text, detectedType)
if priorityReason != "" {
matchedRules = append(matchedRules, priorityReason)
reasoning = append(reasoning, priorityReason)
}
missingInformation := detectMissingInformation(text, detectedType, lang)
if len(missingInformation) > 0 {
riskFlags = append(riskFlags, RiskInsufficientInfo)
matchedRules = append(matchedRules, "missing information detected")
reasoning = append(reasoning, "missing information detected")
}
confidence := calculateConfidence(detectedType, scores, matchedRules, input.Labels)
return TriageResult{
Issue: IssueRef{
ID: input.ID,
Number: input.Number,
Title: input.Title,
URL: input.URL,
Author: input.Author,
State: input.State,
},
DetectedType: detectedType,
Priority: priority,
Confidence: confidence,
SuggestedLabels: suggestedLabels(detectedType, priority, riskFlags),
MissingInformation: missingInformation,
RiskFlags: uniqueStrings(riskFlags),
RecommendedAction: recommendedAction(detectedType, priority, riskFlags, missingInformation),
SuggestedComment: suggestedComment(lang, detectedType, missingInformation),
Reasoning: uniqueStrings(reasoning),
MatchedRules: uniqueStrings(matchedRules),
}
}
func normalizeIssueText(input IssueInput) string {
parts := make([]string, 2, 2+len(input.Labels))
parts[0] = input.Title
parts[1] = input.Body
parts = append(parts, input.Labels...)
return strings.ToLower(strings.Join(parts, "\n"))
}
func scoreIssueTypes(text string, labels []string) (map[string]int, []string, []string) {
scores := make(map[string]int)
matchedRules := []string{}
reasoning := []string{}
for _, rule := range triageKeywordRules {
for _, keyword := range rule.keywords {
if strings.Contains(text, strings.ToLower(keyword)) {
scores[rule.issueType] += rule.weight
ruleText := "matched keyword: " + keyword
matchedRules = append(matchedRules, ruleText)
reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, rule.issueType))
}
}
}
for _, label := range labels {
normalized := strings.ToLower(strings.TrimSpace(label))
for _, issueType := range typeTieOrder {
if normalized == issueType || strings.Contains(normalized, issueType) {
scores[issueType] += 14
ruleText := "label matched: " + normalized
matchedRules = append(matchedRules, ruleText)
reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, issueType))
}
}
}
return scores, matchedRules, reasoning
}
func chooseDetectedType(scores map[string]int) string {
bestType := IssueTypeUnknown
bestScore := 0
for _, issueType := range typeTieOrder {
score := scores[issueType]
if score > bestScore {
bestType = issueType
bestScore = score
}
}
return bestType
}
func determinePriority(text string, detectedType string) (string, string) {
if detectedType == IssueTypeSecurity || containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "auth bypass", "permission bypass", "vulnerability", "cve", "密钥泄露", "漏洞", "认证绕过", "权限绕过"}) {
return PriorityP0, "priority rule: security sensitive token leak"
}
if containsAny(text, []string{"cannot login", "install failed", "installation failed", "core command unavailable", "command unavailable", "login failed", "crash", "panic", "无法登录", "安装失败", "核心命令不可用", "崩溃"}) {
return PriorityP1, "priority rule: core blocker"
}
if detectedType == IssueTypeBug || detectedType == IssueTypeCI || detectedType == IssueTypePerformance {
return PriorityP2, "priority rule: normal bug or operational failure"
}
if detectedType == IssueTypeFeature {
return PriorityP2, "priority rule: feature request"
}
return PriorityP3, "priority rule: low risk request"
}
func calculateConfidence(detectedType string, scores map[string]int, matchedRules []string, labels []string) int {
if detectedType == IssueTypeUnknown {
return 20
}
confidence := 35 + scores[detectedType]
if len(matchedRules) > 1 {
confidence += minInt(len(matchedRules)*3, 18)
}
if len(labels) > 0 {
confidence += 8
}
if detectedType == IssueTypeSecurity || detectedType == IssueTypeBug || detectedType == IssueTypeCI {
confidence += 10
}
return clampInt(confidence, 100)
}
func detectMissingInformation(text string, detectedType string, lang string) []string {
if detectedType != IssueTypeBug {
return nil
}
checks := []struct {
key string
present []string
messageK string
}{
{"reproduction_steps", []string{"reproduction", "reproduce", "steps", "复现", "步骤"}, "missing_reproduction_steps"},
{"expected_behavior", []string{"expected", "expect", "期望", "预期"}, "missing_expected_behavior"},
{"actual_behavior", []string{"actual", "实际"}, "missing_actual_behavior"},
{"version", []string{"version", "版本"}, "missing_version"},
{"os_or_platform", []string{"os", "platform", "windows", "linux", "macos", "darwin", "系统", "平台"}, "missing_os_or_platform"},
{"command_output_or_logs", []string{"output", "log", "trace", "stdout", "stderr", "输出", "日志"}, "missing_command_output_or_logs"},
}
missing := []string{}
for _, check := range checks {
if !containsAny(text, check.present) {
if normalizeLang(lang) == langZH {
missing = append(missing, message(lang, check.messageK))
} else {
missing = append(missing, check.key)
}
}
}
return missing
}
func detectRiskFlags(text string, detectedType string) []string {
flags := []string{}
if detectedType == IssueTypeSecurity || containsAny(text, []string{"vulnerability", "cve", "漏洞", "安全", "auth bypass", "permission bypass", "认证绕过", "权限绕过"}) {
flags = append(flags, RiskSecuritySensitive)
}
if containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "token leaked", "secret leaked", "密钥泄露", "泄露"}) {
flags = append(flags, RiskPossibleSecretLeak)
}
if containsAny(text, []string{"install failed", "installation failed", "安装失败"}) {
flags = append(flags, RiskInstallationBlocker)
}
if containsAny(text, []string{"cannot login", "login failed", "auth failed", "无法登录", "登录失败"}) {
flags = append(flags, RiskAuthenticationBlocker)
}
if detectedType == IssueTypeCI || containsAny(text, []string{"test failed", "pipeline failed", "build failed", "测试失败", "构建失败"}) {
flags = append(flags, RiskCIBlocker)
}
return flags
}
func suggestedLabels(detectedType string, priority string, riskFlags []string) []string {
labels := []string{}
if detectedType != IssueTypeUnknown {
labels = append(labels, detectedType)
}
labels = append(labels, strings.ToLower(priority))
for _, flag := range riskFlags {
switch flag {
case RiskSecuritySensitive, RiskPossibleSecretLeak:
labels = append(labels, "security")
case RiskCIBlocker:
labels = append(labels, "ci")
}
}
return uniqueStrings(labels)
}
func recommendedAction(detectedType string, priority string, riskFlags []string, missingInformation []string) string {
if containsString(riskFlags, RiskSecuritySensitive) || containsString(riskFlags, RiskPossibleSecretLeak) || detectedType == IssueTypeSecurity {
return ActionReviewSecurity
}
if priority == PriorityP0 {
return ActionPrioritizeImmediate
}
if len(missingInformation) > 0 {
return ActionRequestMoreInfo
}
switch detectedType {
case IssueTypeQuestion:
return ActionConvertToDiscussion
case IssueTypeDocs:
return ActionUpdateDocs
case IssueTypeCI:
return ActionInvestigateCI
default:
return ActionScheduleFix
}
}
func suggestedComment(lang string, detectedType string, missingInformation []string) string {
lang = normalizeLang(lang)
if detectedType == IssueTypeSecurity {
return message(lang, "comment_security")
}
if len(missingInformation) > 0 {
return fmt.Sprintf(message(lang, "comment_more_info"), strings.Join(missingInformation, ", "))
}
if detectedType == IssueTypeDocs {
return message(lang, "comment_docs")
}
return message(lang, "comment_default")
}
func containsAny(text string, keywords []string) bool {
for _, keyword := range keywords {
if strings.Contains(text, strings.ToLower(keyword)) {
return true
}
}
return false
}
func containsString(values []string, needle string) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
unique := []string{}
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique
}