forked from Gitlink/gitlink-cli
408 lines
11 KiB
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()
|
|
} |