forked from chroe/gitlink-cli
270 lines
6.7 KiB
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 ""
|
|
} |