gitlink-cli/workflows/05-contributor-growth.sh

402 lines
19 KiB
Bash

#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────
# Scenario 5: Contributor Growth System
# Flow: Collect data → Calculate scores → Generate HTML → Publish Wiki
#
# Scoring (based on available shortcuts):
# - Issue created: 15% weight (issue +list)
# - PR merged: 25% weight (pr +list state=merged)
# - Code changes: 30% weight (pr +files)
# - Issue comments: 15% weight (issue +view)
# - Team member: 15% weight (repo +members)
# ─────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
usage() {
echo "Usage: $0 --owner OWNER --repo REPO [--sample N] [--dry-run]"
echo ""
echo " --owner OWNER Repository owner"
echo " --repo REPO Repository name"
echo " --sample N Sample N PRs for code stats (default: 10)"
echo " --dry-run Preview actions without executing"
exit 1
}
DRY_RUN=false
OWNER=""
REPO=""
SAMPLE_SIZE=10
while [[ $# -gt 0 ]]; do
case "$1" in
--owner) OWNER="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--sample) SAMPLE_SIZE="$2"; shift 2 ;;
--dry-run) DRY_RUN="true"; shift ;;
--help|-h) usage ;;
*) log_err "Unknown arg: $1"; usage ;;
esac
done
check_auth
require_owner_repo
REPORT_FILE="contrib-report-$OWNER-$REPO.html"
# ─────────────────────────────────────────────────────────────────────
log_title "Contributor Growth System: $OWNER/$REPO"
# ─────────────────────────────────────────────────────────────────────
# ── Step 1: Collect Data ─────────────────────────────────────────────
log_step "Collecting data..."
ISSUES_OPEN=$(gl_check issue +list --owner "$OWNER" --repo "$REPO" --state open --limit 100)
ISSUES_CLOSED=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 100)
OPEN_COUNT=$(echo "$ISSUES_OPEN" | jq '.data.issues | length' 2>/dev/null || echo "0")
CLOSED_COUNT=$(echo "$ISSUES_CLOSED" | jq '.data.issues | length' 2>/dev/null || echo "0")
PRS_MERGED=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100)
PR_MERGED_COUNT=$(echo "$PRS_MERGED" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
MEMBERS=$(gl_run repo +members --owner "$OWNER" --repo "$REPO" --limit 100)
# Members may be in .data.members[] or .data[]
MEMBER_COUNT=$(echo "$MEMBERS" | jq '(.data.members // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
log_ok "Issues(open:$OPEN_COUNT closed:$CLOSED_COUNT) PRs(merged:$PR_MERGED_COUNT) Members:$MEMBER_COUNT"
# ── Step 2: Build Contributor Data ───────────────────────────────────
log_step "Building contributor profiles..."
declare -A C_ISSUES C_MERGED C_ADDITIONS C_DELETIONS C_COMMENTS C_IS_MEMBER
# Issues
for i in $(seq 0 $((OPEN_COUNT - 1))); do
A=$(echo "$ISSUES_OPEN" | jq -r ".data.issues[$i].author.login // empty")
[[ -n "$A" ]] && C_ISSUES["$A"]=$(( ${C_ISSUES["$A"]:-0} + 1 ))
done
for i in $(seq 0 $((CLOSED_COUNT - 1))); do
A=$(echo "$ISSUES_CLOSED" | jq -r ".data.issues[$i].author.login // empty")
[[ -n "$A" ]] && C_ISSUES["$A"]=$(( ${C_ISSUES["$A"]:-0} + 1 ))
done
# Merged PRs + code stats
log_step "Analyzing PR code changes (sampling $SAMPLE_SIZE)..."
PR_SAMPLE=$((PR_MERGED_COUNT > SAMPLE_SIZE ? SAMPLE_SIZE : PR_MERGED_COUNT))
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
for i in $(seq 0 $((PR_MERGED_COUNT - 1))); do
A=$(echo "$PRS_MERGED" | jq -r "$PR_DATA_PATH[$i].author_login // $PR_DATA_PATH[$i].author.login // empty")
ID=$(echo "$PRS_MERGED" | jq -r "$PR_DATA_PATH[$i].pull_request_number // $PR_DATA_PATH[$i].number // $PR_DATA_PATH[$i].id // empty")
[[ -n "$A" ]] && C_MERGED["$A"]=$(( ${C_MERGED["$A"]:-0} + 1 ))
if [[ $i -lt $PR_SAMPLE ]] && [[ -n "$ID" ]]; then
FILES=$(gl_run pr +files --owner "$OWNER" --repo "$REPO" --id "$ID" 2>&1)
ADD=$(echo "$FILES" | jq -r '[.data.files[]? | (.additions // .addition // 0)] | add // 0' 2>/dev/null || echo "0")
DEL=$(echo "$FILES" | jq -r '[.data.files[]? | (.deletions // .deletion // 0)] | add // 0' 2>/dev/null || echo "0")
[[ -n "$A" ]] && C_ADDITIONS["$A"]=$(( ${C_ADDITIONS["$A"]:-0} + ADD ))
[[ -n "$A" ]] && C_DELETIONS["$A"]=$(( ${C_DELETIONS["$A"]:-0} + DEL ))
fi
done
# Members
MEMBERS_DATA_PATH='(.data.members // .data | if type == "array" then . else [] end)'
for i in $(seq 0 $((MEMBER_COUNT - 1))); do
L=$(echo "$MEMBERS" | jq -r "$MEMBERS_DATA_PATH[$i].login // $MEMBERS_DATA_PATH[$i].username // empty")
[[ -n "$L" ]] && C_IS_MEMBER["$L"]="yes"
done
# Comments (sample)
log_step "Sampling issue comments..."
for i in $(seq 0 $((OPEN_COUNT > 10 ? 9 : OPEN_COUNT - 1))); do
ID=$(echo "$ISSUES_OPEN" | jq -r ".data.issues[$i].id // empty")
[[ -z "$ID" ]] && continue
DETAIL=$(gl_run issue +view --owner "$OWNER" --repo "$REPO" --number "$ID" 2>&1)
C=$(echo "$DETAIL" | jq -r '.data.comment_journals_count // 0' 2>/dev/null || echo "0")
if [[ "$C" -gt 0 ]]; then
A=$(echo "$ISSUES_OPEN" | jq -r ".data.issues[$i].author.login // empty")
[[ -n "$A" ]] && C_COMMENTS["$A"]=$(( ${C_COMMENTS["$A"]:-0} + C ))
fi
done
# ── Step 3: Calculate Scores ─────────────────────────────────────────
log_step "Calculating scores..."
declare -A SCORES
ALL_USERS=()
for user in "${!C_ISSUES[@]}" "${!C_MERGED[@]}" "${!C_COMMENTS[@]}"; do
[[ -n "$user" ]] && ALL_USERS+=("$user")
done
ALL_USERS=($(printf '%s\n' "${ALL_USERS[@]}" | sort -u))
MAX_ISSUES=0; MAX_MERGED=0; MAX_LINES=0; MAX_COMMENTS=0
for user in "${ALL_USERS[@]}"; do
[[ ${C_ISSUES[$user]:-0} -gt $MAX_ISSUES ]] && MAX_ISSUES=${C_ISSUES[$user]}
[[ ${C_MERGED[$user]:-0} -gt $MAX_MERGED ]] && MAX_MERGED=${C_MERGED[$user]}
LINES=$(( ${C_ADDITIONS[$user]:-0} + ${C_DELETIONS[$user]:-0} ))
[[ $LINES -gt $MAX_LINES ]] && MAX_LINES=$LINES
[[ ${C_COMMENTS[$user]:-0} -gt $MAX_COMMENTS ]] && MAX_COMMENTS=${C_COMMENTS[$user]}
done
for user in "${ALL_USERS[@]}"; do
SCORE=$(awk -v iss="${C_ISSUES[$user]:-0}" -v mi="$MAX_ISSUES" \
-v mer="${C_MERGED[$user]:-0}" -v mm="$MAX_MERGED" \
-v lin="$(( ${C_ADDITIONS[$user]:-0} + ${C_DELETIONS[$user]:-0} ))" -v ml="$MAX_LINES" \
-v com="${C_COMMENTS[$user]:-0}" -v mc="$MAX_COMMENTS" \
-v mem="${C_IS_MEMBER[$user]:-no}" \
'BEGIN {
ni=(mi>0)?iss/mi:0; nm=(mm>0)?mer/mm:0; nl=(ml>0)?lin/ml:0; nc=(mc>0)?com/mc:0; ms=(mem=="yes")?1:0;
printf "%.1f", (ni*15+nm*25+nl*30+nc*15+ms*15)
}')
SCORES["$user"]="$SCORE"
done
# ── Step 4: Display Rankings ─────────────────────────────────────────
log_title "Contributor Rankings"
echo ""
printf " ${BOLD}%-4s %-18s %-8s %-8s %-12s %-10s %-8s %s${NC}\n" "Rank" "Contributor" "Issues" "Merged" "+/- Lines" "Comments" "Score" "Badge"
echo " ──── ─────────────────── ──────── ──────── ──────────── ────────── ──────── ─────────────"
TEMP=$(mktemp)
for u in "${!SCORES[@]}"; do echo "${SCORES[$u]} $u" >> "$TEMP"; done
RANK=1
sort -rn "$TEMP" | while read -r score user; do
iss=${C_ISSUES[$user]:-0}; mer=${C_MERGED[$user]:-0}
add=${C_ADDITIONS[$user]:-0}; del=${C_DELETIONS[$user]:-0}
com=${C_COMMENTS[$user]:-0}; si=${score%.*}
if [[ $si -ge 80 ]]; then B="Champion"
elif [[ $si -ge 60 ]]; then B="Core Contributor"
elif [[ $si -ge 40 ]]; then B="Active Contributor"
elif [[ $si -ge 20 ]]; then B="Contributor"
else B="Newcomer"
fi
printf " %-4d %-18s %-8d %-8d +%-6d/-%-4d %-10d %-8s %s\n" "$RANK" "$user" "$iss" "$mer" "$add" "$del" "$com" "$score" "$B"
RANK=$((RANK + 1))
done
rm -f "$TEMP"
# ── Step 5: Generate HTML Report ─────────────────────────────────────
log_title "Generating HTML Report"
# Build JSON data for charts
PIE_DATA=""
TABLE_ROWS=""
RANK=1
TEMP2=$(mktemp)
for u in "${!SCORES[@]}"; do echo "${SCORES[$u]} $u" >> "$TEMP2"; done
sort -rn "$TEMP2" | while read -r score user; do
iss=${C_ISSUES[$user]:-0}; mer=${C_MERGED[$user]:-0}
add=${C_ADDITIONS[$user]:-0}; del=${C_DELETIONS[$user]:-0}
lin=$((add + del)); com=${C_COMMENTS[$user]:-0}
si=${score%.*}
if [[ $si -ge 80 ]]; then B="Champion"
elif [[ $si -ge 60 ]]; then B="Core Contributor"
elif [[ $si -ge 40 ]]; then B="Active Contributor"
elif [[ $si -ge 20 ]]; then B="Contributor"
else B="Newcomer"
fi
# Output as CSV for processing
echo "$RANK|$user|$iss|$mer|$lin|$com|$score|$B|$add|$del"
RANK=$((RANK + 1))
done > "$TEMP2.csv"
rm -f "$TEMP2"
# Generate HTML
cat > "$REPORT_FILE" << 'HTMLHEAD'
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contributor Report</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 40px 20px; }
.container { max-width: 1200px; margin: 0 auto; }
.header { text-align: center; color: white; margin-bottom: 40px; }
.header h1 { font-size: 2.5rem; margin-bottom: 10px; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); }
.header p { font-size: 1.1rem; opacity: 0.9; }
.card { background: white; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); padding: 30px; margin-bottom: 30px; }
.card h2 { color: #333; margin-bottom: 20px; font-size: 1.5rem; border-bottom: 3px solid #667eea; padding-bottom: 10px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 20px; color: white; text-align: center; }
.stat-value { font-size: 2rem; font-weight: bold; margin-bottom: 5px; }
.stat-label { font-size: 0.9rem; opacity: 0.9; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 12px; text-align: left; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 1px; }
td { padding: 12px; border-bottom: 1px solid #eee; }
tr:hover { background: #f8f9ff; }
.rank { font-weight: bold; color: #667eea; font-size: 1.2rem; }
.rank-1 { color: #FFD700; }
.rank-2 { color: #C0C0C0; }
.rank-3 { color: #CD7F32; }
.badge { padding: 4px 12px; border-radius: 20px; font-size: 0.8rem; font-weight: 600; }
.badge-champion { background: #FFD700; color: #333; }
.badge-core { background: #C0C0C0; color: #333; }
.badge-active { background: #CD7F32; color: white; }
.badge-contributor { background: #4CAF50; color: white; }
.badge-newcomer { background: #9E9E9E; color: white; }
.chart-container { width: 100%; height: 400px; }
.weight-info { background: #f8f9ff; border-radius: 12px; padding: 20px; margin-top: 20px; }
.weight-info h3 { color: #667eea; margin-bottom: 15px; }
.weight-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; }
.weight-item { display: flex; justify-content: space-between; padding: 8px 12px; background: white; border-radius: 8px; border-left: 4px solid #667eea; }
.weight-label { color: #666; }
.weight-value { font-weight: 600; color: #667eea; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Contributor Report</h1>
HTMLHEAD
echo " <p>$OWNER/$REPO - Team Contribution Analysis</p>" >> "$REPORT_FILE"
echo " </div>" >> "$REPORT_FILE"
# Stats cards
TOTAL_ISSUES=$((OPEN_COUNT + CLOSED_COUNT))
echo " <div class=\"stats-grid\">" >> "$REPORT_FILE"
echo " <div class=\"stat-card\"><div class=\"stat-value\">${#SCORES[@]}</div><div class=\"stat-label\">Contributors</div></div>" >> "$REPORT_FILE"
echo " <div class=\"stat-card\"><div class=\"stat-value\">$TOTAL_ISSUES</div><div class=\"stat-label\">Total Issues</div></div>" >> "$REPORT_FILE"
echo " <div class=\"stat-card\"><div class=\"stat-value\">$PR_MERGED_COUNT</div><div class=\"stat-label\">Merged PRs</div></div>" >> "$REPORT_FILE"
echo " </div>" >> "$REPORT_FILE"
# Pie chart
echo " <div class=\"card\">" >> "$REPORT_FILE"
echo " <h2>Score Distribution</h2>" >> "$REPORT_FILE"
echo " <div id=\"pieChart\" class=\"chart-container\"></div>" >> "$REPORT_FILE"
echo " </div>" >> "$REPORT_FILE"
# Rankings table
echo " <div class=\"card\">" >> "$REPORT_FILE"
echo " <h2>Detailed Rankings</h2>" >> "$REPORT_FILE"
echo " <table><thead><tr><th>Rank</th><th>Contributor</th><th>Issues</th><th>Merged PRs</th><th>Code Lines</th><th>Comments</th><th>Score</th><th>Badge</th></tr></thead><tbody>" >> "$REPORT_FILE"
PIE_JSON=""
while IFS='|' read -r rank user iss mer lin com score badge add del; do
cls=""; [[ $rank -eq 1 ]] && cls=" rank-1"
[[ $rank -eq 2 ]] && cls=" rank-2"
[[ $rank -eq 3 ]] && cls=" rank-3"
badge_cls="newcomer"
[[ "$badge" == "Champion" ]] && badge_cls="champion"
[[ "$badge" == "Core Contributor" ]] && badge_cls="core"
[[ "$badge" == "Active Contributor" ]] && badge_cls="active"
[[ "$badge" == "Contributor" ]] && badge_cls="contributor"
echo " <tr><td class=\"rank$cls\">$rank</td><td>@$user</td><td>$iss</td><td>$mer</td><td>$lin</td><td>$com</td><td>$score</td><td><span class=\"badge badge-$badge_cls\">$badge</span></td></tr>" >> "$REPORT_FILE"
PIE_JSON+="{value: $score, name: '$user'},"
done < "$TEMP2.csv"
echo " </tbody></table>" >> "$REPORT_FILE"
echo " </div>" >> "$REPORT_FILE"
# Weight info
echo " <div class=\"card\">" >> "$REPORT_FILE"
echo " <h2>Scoring System (AHP Weights)</h2>" >> "$REPORT_FILE"
echo " <div class=\"weight-info\">" >> "$REPORT_FILE"
echo " <div class=\"weight-grid\">" >> "$REPORT_FILE"
echo " <div class=\"weight-item\"><span class=\"weight-label\">Issues Created</span><span class=\"weight-value\">15%</span></div>" >> "$REPORT_FILE"
echo " <div class=\"weight-item\"><span class=\"weight-label\">PRs Merged</span><span class=\"weight-value\">25%</span></div>" >> "$REPORT_FILE"
echo " <div class=\"weight-item\"><span class=\"weight-label\">Code Changes</span><span class=\"weight-value\">30%</span></div>" >> "$REPORT_FILE"
echo " <div class=\"weight-item\"><span class=\"weight-label\">Issue Comments</span><span class=\"weight-value\">15%</span></div>" >> "$REPORT_FILE"
echo " <div class=\"weight-item\"><span class=\"weight-label\">Team Member</span><span class=\"weight-value\">15%</span></div>" >> "$REPORT_FILE"
echo " </div>" >> "$REPORT_FILE"
echo " </div>" >> "$REPORT_FILE"
echo " </div>" >> "$REPORT_FILE"
# JavaScript
cat >> "$REPORT_FILE" << HTMLFOOT
</div>
<script>
var chart = echarts.init(document.getElementById('pieChart'));
chart.setOption({
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
legend: { orient: 'vertical', left: 'left', top: 'middle' },
series: [{
name: 'Score',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}\\n{d}%' },
data: [$PIE_JSON]
}]
});
window.addEventListener('resize', () => chart.resize());
</script>
</body>
</html>
HTMLFOOT
rm -f "$TEMP2.csv"
log_ok "HTML report: $REPORT_FILE"
# ── Step 6: Publish to Wiki ──────────────────────────────────────────
log_step "Publishing to Wiki..."
WIKI_CONTENT="# Contributor Leaderboard - $OWNER/$REPO
*Generated: $(date '+%Y-%m-%d %H:%M')*
## Scoring System
| Dimension | Weight | Source |
|-----------|--------|--------|
| Issues Created | 15% | \`issue +list\` |
| PRs Merged | 25% | \`pr +list state=merged\` |
| Code Changes | 30% | \`pr +files\` |
| Issue Comments | 15% | \`issue +view\` |
| Team Member | 15% | \`repo +members\` |
## Rankings
| Rank | Contributor | Issues | Merged | Lines | Comments | Score | Badge |
|------|-------------|--------|--------|-------|----------|-------|-------|
"
TEMP3=$(mktemp)
for u in "${!SCORES[@]}"; do echo "${SCORES[$u]} $u" >> "$TEMP3"; done
RANK=1
sort -rn "$TEMP3" | while read -r score user; do
iss=${C_ISSUES[$user]:-0}; mer=${C_MERGED[$user]:-0}
lin=$(( ${C_ADDITIONS[$user]:-0} + ${C_DELETIONS[$user]:-0} ))
com=${C_COMMENTS[$user]:-0}; si=${score%.*}
if [[ $si -ge 80 ]]; then B="Champion"
elif [[ $si -ge 60 ]]; then B="Core"
elif [[ $si -ge 40 ]]; then B="Active"
elif [[ $si -ge 20 ]]; then B="Contributor"
else B="Newcomer"
fi
echo "| $RANK | @$user | $iss | $mer | $lin | $com | $score | $B |"
RANK=$((RANK + 1))
done > "$TEMP3.rows"
WIKI_CONTENT+=$(cat "$TEMP3.rows")
rm -f "$TEMP3" "$TEMP3.rows"
WIKI_CONTENT+="
---
*Auto-generated by gitlink-cli*"
# Use timestamp to avoid title conflicts with cached deletions
WIKI_TITLE="Contributor Leaderboard $(date '+%Y-%m-%d')"
WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO" \
--title "$WIKI_TITLE" --content "$WIKI_CONTENT" 2>&1) || true
if [[ "$(json_ok "$WIKI_RESULT")" == "true" ]]; then
log_ok "Published to Wiki: $WIKI_TITLE"
else
log_warn "Wiki publish failed"
fi
# ─────────────────────────────────────────────────────────────────────
log_title "Complete"
echo " Contributors: ${#SCORES[@]}"
echo " HTML Report: $REPORT_FILE"
echo ""