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

270 lines
6.7 KiB
Go

package rules
import (
"sort"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// contributorEntry holds per-contributor aggregate data.
type contributorEntry struct {
Login string `json:"login"`
Name string `json:"name"`
Commits int `json:"commits"`
Issues int `json:"issues"`
PRs int `json:"prs"`
Total int `json:"total"`
Trend float64 `json:"trend"`
LastActivity string `json:"last_activity"`
Tags []string `json:"tags"`
}
// ContributorRankingRule produces a contributor ranking report.
func ContributorRankingRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
commits := extractCommits(upstream)
issues := append(extractIssues(upstream, "open-issues"), extractIssues(upstream, "closed-issues")...)
prs := extractPRs(upstream, "merged-prs")
members := extractMembers(upstream)
// Aggregate per login.
stats := map[string]*contributorEntry{}
for _, c := range commits {
login := authorLogin(c)
if login == "" {
continue
}
e := ensureEntry(stats, login, members)
e.Commits++
e.Total++
if ts := commitTimestamp(c); ts != "" && ts > e.LastActivity {
e.LastActivity = ts
}
}
for _, i := range issues {
login := authorLogin(i)
if login == "" {
continue
}
e := ensureEntry(stats, login, members)
e.Issues++
e.Total++
if ts := issueTimestamp(i); ts != "" && ts > e.LastActivity {
e.LastActivity = ts
}
}
for _, p := range prs {
login := authorLogin(p)
if login == "" {
continue
}
e := ensureEntry(stats, login, members)
e.PRs++
e.Total++
if ts := prTimestamp(p); ts != "" && ts > e.LastActivity {
e.LastActivity = ts
}
}
// Calculate trends using 30-day windows.
now := time.Now()
cutoff30 := now.Add(-30 * 24 * time.Hour)
cutoff60 := now.Add(-60 * 24 * time.Hour)
recent := countInWindow(commits, cutoff30, now)
prev := countInWindow(commits, cutoff60, cutoff30)
for login := range stats {
rc := recent[login]
pc := prev[login]
if pc > 0 {
stats[login].Trend = float64(rc-pc) / float64(pc) * 100
} else if rc > 0 {
stats[login].Trend = 100
}
// Tagging.
if stats[login].Trend > 50 {
stats[login].Tags = append(stats[login].Tags, "new-star")
}
if stats[login].LastActivity != "" {
t, err := time.Parse(time.RFC3339, stats[login].LastActivity)
if err == nil && now.Sub(t) > 30*24*time.Hour {
stats[login].Tags = append(stats[login].Tags, "churn-risk")
}
}
}
// Sort by total desc.
entries := make([]contributorEntry, 0, len(stats))
for _, e := range stats {
entries = append(entries, *e)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Total > entries[j].Total })
// Build analysis.
rankings := make([]map[string]interface{}, len(entries))
for i, e := range entries {
rankings[i] = map[string]interface{}{
"rank": i + 1,
"login": e.Login,
"name": e.Name,
"commits": e.Commits,
"issues": e.Issues,
"prs": e.PRs,
"total": e.Total,
"trend": e.Trend,
"last_activity": e.LastActivity,
"tags": e.Tags,
}
}
analysis := map[string]interface{}{
"title": "贡献者排行榜",
"rankings": rankings,
"churn_risk": filterByTag(rankings, "churn-risk"),
"new_stars": filterByTag(rankings, "new-star"),
}
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
}
func ensureEntry(stats map[string]*contributorEntry, login string, members map[string]string) *contributorEntry {
if e, ok := stats[login]; ok {
return e
}
e := &contributorEntry{Login: login, Name: members[login]}
stats[login] = e
return e
}
func filterByTag(rankings []map[string]interface{}, tag string) []map[string]interface{} {
var out []map[string]interface{}
for _, r := range rankings {
if tags, ok := r["tags"].([]string); ok {
for _, t := range tags {
if t == tag {
out = append(out, r)
break
}
}
}
}
return out
}
func countInWindow(commits []map[string]interface{}, start, end time.Time) map[string]int {
m := map[string]int{}
for _, c := range commits {
ts := commitTimestamp(c)
if ts == "" {
continue
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
continue
}
if t.After(start) && t.Before(end) {
m[authorLogin(c)]++
}
}
return m
}
// --- helpers ---
func extractCommits(upstream map[string]interface{}) []map[string]interface{} {
return extractList(upstream, "commits")
}
func extractIssues(upstream map[string]interface{}, key string) []map[string]interface{} {
return extractList(upstream, key)
}
func extractPRs(upstream map[string]interface{}, key string) []map[string]interface{} {
return extractList(upstream, key)
}
func extractMembers(upstream map[string]interface{}) map[string]string {
raw, ok := upstream["members"]
if !ok {
return nil
}
members := map[string]string{}
list, ok := raw.([]interface{})
if !ok {
return members
}
for _, item := range list {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
login := str(m, "login", "username", "name")
name := str(m, "name", "full_name", "display_name")
if login != "" {
members[login] = name
}
}
return members
}
func extractList(upstream map[string]interface{}, key string) []map[string]interface{} {
raw, ok := upstream[key]
if !ok {
return nil
}
// upstream values may be stored as an envelope: {"ok": true, "data": [...]}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
list, _ := raw.([]interface{})
var out []map[string]interface{}
for _, item := range list {
if m, ok := item.(map[string]interface{}); ok {
out = append(out, m)
}
}
return out
}
func authorLogin(m map[string]interface{}) string {
return str(m, "author", "login", "username", "committer", "user")
}
func commitTimestamp(m map[string]interface{}) string {
// Commits and issues may be nested under author/committer.
for _, key := range []string{"created_at", "committed_date", "updated_at", "authored_date"} {
if s := str(m, key); s != "" {
return s
}
}
// Try nested author.
if a, ok := m["author"].(map[string]interface{}); ok {
return str(a, "date", "created_at")
}
if a, ok := m["committer"].(map[string]interface{}); ok {
return str(a, "date", "created_at")
}
return ""
}
func issueTimestamp(m map[string]interface{}) string {
return str(m, "created_at", "updated_at", "closed_at")
}
func prTimestamp(m map[string]interface{}) string {
return str(m, "created_at", "merged_at", "updated_at")
}
// str returns the first non-empty string value for the given keys.
func str(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
v, _ := m[k].(string)
if v != "" {
return v
}
}
return ""
}