gitlink-cli/shortcuts/workflow/rules/triage.go

220 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ""
}