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

408 lines
11 KiB
Go

package rules
import (
"encoding/json"
"fmt"
"sort"
"strings"
"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"),
}
// Build wiki page content.
wikiContent := buildContributorWiki(analysis, rankings)
pageName := "贡献者排行榜 " + time.Now().Format("2006-01-02")
actions := []workflow.AIAction{
{
Type: "cli", Module: "wiki", Command: "+create",
Args: map[string]string{
"name": pageName,
"content": wikiContent,
"message": "自动生成贡献者排行榜",
},
},
{
Type: "cli", Module: "wiki", Command: "+update",
Args: map[string]string{
"name": pageName,
"content": wikiContent,
"message": "自动更新贡献者排行榜",
},
},
}
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, 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
}
// Unwrap envelope: {"ok": true, "data": {"collaborators": [...]}}
if m, ok := raw.(map[string]interface{}); ok {
if data, ok := m["data"]; ok {
raw = data
}
}
// Unwrap inner key: {"members": [...]} or {"collaborators": [...]}
if m, ok := raw.(map[string]interface{}); ok {
for _, key := range []string{"members", "collaborators"} {
if list, ok := m[key]; ok {
raw = list
break
}
}
}
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")
id := fmt.Sprint(m["id"])
if login != "" && id != "" && id != "0" && id != "<nil>" {
members[login] = id
}
}
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
}
}
// Some CLI commands return data as a JSON-encoded string; try to decode it.
if s, ok := raw.(string); ok {
var parsed interface{}
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
raw = parsed
}
}
// GitLink API wraps lists inside a map: {"issues": [...], "milestones": [...], ...}
if m, ok := raw.(map[string]interface{}); ok {
for _, listKey := range []string{"commits", "issues", "pull_requests", "issue_tags", "tags", "releases", "members", "items", "milestones", "branches"} {
if v, ok := m[listKey]; ok {
if arr, ok := v.([]interface{}); ok {
raw = arr
break
}
}
}
}
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 {
// Try top-level keys first.
if s := str(m, "login", "username"); s != "" {
return s
}
// Try nested author/committer/user.
for _, key := range []string{"author", "committer", "user"} {
if a, ok := m[key].(map[string]interface{}); ok {
if s := str(a, "login", "username", "name"); s != "" {
return s
}
}
}
return ""
}
func commitTimestamp(m map[string]interface{}) string {
// Try Unix timestamp (commit_time).
for _, key := range []string{"commit_time", "committed_date", "authored_date"} {
switch v := m[key].(type) {
case float64:
if v > 0 {
return time.Unix(int64(v), 0).UTC().Format(time.RFC3339)
}
case string:
if v != "" {
return v
}
}
}
// Try string timestamps.
for _, key := range []string{"created_at", "updated_at"} {
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 ""
}
// buildContributorWiki generates a markdown wiki page from ranking data.
func buildContributorWiki(analysis map[string]interface{}, rankings []map[string]interface{}) string {
var sb strings.Builder
sb.WriteString("# 贡献者排行榜\n\n")
sb.WriteString(fmt.Sprintf("> 自动生成于 %s\n\n", time.Now().Format("2006-01-02 15:04")))
sb.WriteString("## 总览\n\n")
sb.WriteString("| 排名 | 贡献者 | 提交 | Issue | PR | 总计 | 趋势 | 标签 |\n")
sb.WriteString("|------|--------|------|-------|-----|------|------|------|\n")
for _, r := range rankings {
name := fmt.Sprint(r["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(r["login"])
}
tags := ""
if t, ok := r["tags"].([]string); ok && len(t) > 0 {
tags = strings.Join(t, ", ")
}
sb.WriteString(fmt.Sprintf("| %v | %s | %v | %v | %v | %v | %.0f%% | %s |\n",
r["rank"], name, r["commits"], r["issues"], r["prs"], r["total"], r["trend"], tags))
}
// 新星
if newStars, ok := analysis["new_stars"].([]map[string]interface{}); ok && len(newStars) > 0 {
sb.WriteString("\n## 新星\n\n")
for _, s := range newStars {
name := fmt.Sprint(s["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(s["login"])
}
sb.WriteString(fmt.Sprintf("- **%s** — 趋势 +%.0f%%\n", name, s["trend"]))
}
}
// 流失风险
if churn, ok := analysis["churn_risk"].([]map[string]interface{}); ok && len(churn) > 0 {
sb.WriteString("\n## 流失风险\n\n")
for _, c := range churn {
name := fmt.Sprint(c["name"])
if name == "" || name == "<nil>" {
name = fmt.Sprint(c["login"])
}
last := fmt.Sprint(c["last_activity"])
if t, err := time.Parse(time.RFC3339, last); err == nil {
last = t.Format("2006-01-02")
}
sb.WriteString(fmt.Sprintf("- **%s** — 最后活动 %s\n", name, last))
}
}
sb.WriteString("\n> 由 contributor-growth 工作流自动生成\n")
return sb.String()
}