forked from chroe/gitlink-cli
220 lines
5.6 KiB
Go
220 lines
5.6 KiB
Go
package rules
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||
)
|
||
|
||
// TriageRule classifies issues, assigns priorities, matches labels, and distributes work.
|
||
func TriageRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||
issues := extractIssues(upstream, "open-issues")
|
||
labels := extractLabels(upstream)
|
||
members := extractMembers(upstream)
|
||
|
||
if len(issues) == 0 {
|
||
return &workflow.AIResponse{
|
||
Analysis: map[string]interface{}{"classified": 0, "message": "no open issues to triage"},
|
||
Actions: nil,
|
||
}, nil
|
||
}
|
||
|
||
var actions []workflow.AIAction
|
||
memberLoad := map[string]int{}
|
||
classified := []map[string]interface{}{}
|
||
gfi := []map[string]interface{}{}
|
||
|
||
for _, issue := range issues {
|
||
num := issueNumber(issue)
|
||
title := str(issue, "title")
|
||
body := str(issue, "body", "description")
|
||
text := title + " " + body
|
||
|
||
cat := classifyIssue(text)
|
||
pri := assignPriority(text)
|
||
labelIDs := matchLabels(cat, labels)
|
||
assignee := leastLoaded(memberLoad, members)
|
||
|
||
if assignee != "" {
|
||
memberLoad[assignee]++
|
||
}
|
||
|
||
result := map[string]interface{}{
|
||
"number": num,
|
||
"title": title,
|
||
"category": cat,
|
||
"priority": pri,
|
||
"assignee": assignee,
|
||
}
|
||
classified = append(classified, result)
|
||
|
||
// Build PATCH action if we have labels or assignee.
|
||
body2 := map[string]interface{}{}
|
||
if len(labelIDs) > 0 {
|
||
body2["issue_tag_ids"] = labelIDs
|
||
}
|
||
if assignee != "" {
|
||
body2["assigner_ids"] = []string{assignee}
|
||
}
|
||
if pri > 0 {
|
||
body2["priority_id"] = pri
|
||
}
|
||
if len(body2) > 0 && num != "" {
|
||
actions = append(actions, workflow.AIAction{
|
||
Type: "api", Method: "PATCH",
|
||
Path: fmt.Sprintf("{v1}/issues/%s", num),
|
||
Body: body2,
|
||
})
|
||
}
|
||
|
||
if isGoodFirstIssue(text) {
|
||
gfi = append(gfi, result)
|
||
actions = append(actions, workflow.AIAction{
|
||
Type: "cli", Module: "issue", Command: "+comment",
|
||
Args: map[string]string{
|
||
"number": num,
|
||
"body": "👋 感谢提交 Issue!这个 Issue 已被标记为 **Good First Issue**,适合新贡献者参与。欢迎提交 PR!",
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
analysis := map[string]interface{}{
|
||
"classified": len(classified),
|
||
"results": classified,
|
||
"good_first_issues": gfi,
|
||
}
|
||
|
||
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
||
}
|
||
|
||
// --- classification ---
|
||
|
||
var catPatterns = []struct {
|
||
re *regexp.Regexp
|
||
category string
|
||
}{
|
||
{regexp.MustCompile(`(?i)错误|失败|异常|崩溃|crash|error|bug|broken|404|500`), "bug"},
|
||
{regexp.MustCompile(`(?i)安全|漏洞|泄露|vulnerability|CVE|敏感`), "security"},
|
||
{regexp.MustCompile(`(?i)性能|慢|卡顿|优化|performance|speed`), "performance"},
|
||
{regexp.MustCompile(`(?i)重构|代码质量|refactor|clean\s*up|tech\s*debt`), "refactor"},
|
||
{regexp.MustCompile(`(?i)建议|希望|新增|支持|feature|enhancement|add|improve`), "enhancement"},
|
||
{regexp.MustCompile(`(?i)文档|README|帮助|doc|documentation|typo`), "docs"},
|
||
{regexp.MustCompile(`(?i)如何|怎么|请问|how\s*to|question|help|求助`), "question"},
|
||
}
|
||
|
||
var priorityPatterns = []struct {
|
||
re *regexp.Regexp
|
||
pri int
|
||
}{
|
||
{regexp.MustCompile(`(?i)紧急|urgent|critical|崩溃|crash|严重|安全|漏洞|CVE|P0`), 4},
|
||
{regexp.MustCompile(`(?i)重要|high|important|P1|阻断`), 3},
|
||
{regexp.MustCompile(`(?i)低|low|trivial|minor|P3`), 1},
|
||
}
|
||
|
||
func classifyIssue(text string) string {
|
||
for _, p := range catPatterns {
|
||
if p.re.MatchString(text) {
|
||
return p.category
|
||
}
|
||
}
|
||
return "enhancement" // default
|
||
}
|
||
|
||
func assignPriority(text string) int {
|
||
for _, p := range priorityPatterns {
|
||
if p.re.MatchString(text) {
|
||
return p.pri
|
||
}
|
||
}
|
||
return 2 // default: medium
|
||
}
|
||
|
||
func isGoodFirstIssue(text string) bool {
|
||
gfiRe := regexp.MustCompile(`(?i)good\s*first\s*issue|beginner|easy|简单|新手|入门`)
|
||
if gfiRe.MatchString(text) {
|
||
return true
|
||
}
|
||
// Also mark simple enhancements/docs as GFI.
|
||
cat := classifyIssue(text)
|
||
pri := assignPriority(text)
|
||
return (cat == "docs" || cat == "enhancement") && pri <= 2 &&
|
||
len(strings.Fields(text)) < 200
|
||
}
|
||
|
||
// --- label matching ---
|
||
|
||
func extractLabels(upstream map[string]interface{}) []map[string]interface{} {
|
||
return extractList(upstream, "labels")
|
||
}
|
||
|
||
func matchLabels(category string, labels []map[string]interface{}) []interface{} {
|
||
catLower := strings.ToLower(category)
|
||
var ids []interface{}
|
||
for _, l := range labels {
|
||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||
if name == "" {
|
||
continue
|
||
}
|
||
// Direct match or contains.
|
||
if name == catLower || strings.Contains(name, catLower) || strings.Contains(catLower, name) {
|
||
if id := labelID(l); id != nil {
|
||
ids = append(ids, id)
|
||
}
|
||
}
|
||
}
|
||
// Also match sub-categories for bug.
|
||
if catLower == "bug" {
|
||
for _, l := range labels {
|
||
name := strings.ToLower(str(l, "name", "title", "label"))
|
||
if strings.Contains(name, "bug") || strings.Contains(name, "fix") {
|
||
if id := labelID(l); id != nil {
|
||
ids = append(ids, id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
func labelID(l map[string]interface{}) interface{} {
|
||
for _, k := range []string{"id", "tag_id", "label_id"} {
|
||
if v := l[k]; v != nil {
|
||
return v
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// --- assignment ---
|
||
|
||
func leastLoaded(load map[string]int, members map[string]string) string {
|
||
if len(members) == 0 {
|
||
return ""
|
||
}
|
||
best := ""
|
||
bestN := -1
|
||
for login := range members {
|
||
n := load[login]
|
||
if bestN < 0 || n < bestN {
|
||
bestN = n
|
||
best = login
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
func issueNumber(issue map[string]interface{}) string {
|
||
for _, k := range []string{"project_issues_index", "number", "iid", "id"} {
|
||
s := fmt.Sprint(issue[k])
|
||
if s != "" && s != "0" && s != "<nil>" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|