forked from Gitlink/gitlink-cli
302 lines
10 KiB
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
|
|
}
|