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

433 lines
19 KiB
PowerShell

# ----------------------------------------------------------------
# Scenario 5: Contributor Growth System
# Flow: Collect data -> Calculate scores -> Generate HTML -> Publish Wiki -> Award badges
#
# Scoring (AHP weight model):
# - Issues Created: 15% weight (issue +list)
# - PRs 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)
#
# Badges:
# - Champion >= 80
# - Core Contributor >= 60
# - Active Contributor >= 40
# - Contributor >= 20
# - Newcomer < 20
# ----------------------------------------------------------------
#Requires -Version 5.1
param(
[string]$Owner = "",
[string]$Repo = "",
[int]$Sample = 10,
[switch]$Award,
[switch]$DryRun,
[switch]$Help
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 05-contributor-growth.ps1 -Owner OWNER -Repo REPO [-Sample N] [-Award] [-DryRun]"
Write-Host ""
Write-Host " -Owner OWNER Repository owner"
Write-Host " -Repo REPO Repository name"
Write-Host " -Sample N Sample N PRs for code stats (default: 10)"
Write-Host " -Award Auto-create badge award issues"
Write-Host " -DryRun Preview actions without executing"
exit 0
}
Check-Auth
$r = Resolve-OwnerRepo $Owner $Repo
$Owner = $r.Owner; $Repo = $r.Repo
$reportFile = "contrib-report-$Owner-$Repo.html"
# ----------------------------------------------------------------
Log-Title "Contributor Growth System: $Owner/$Repo"
# ----------------------------------------------------------------
# -- Step 1: Collect Data --
Log-Step "Collecting data..."
$issuesOpen = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100"
$issuesClosed = Invoke-GL "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
$openCount = if ($issuesOpen) { @($issuesOpen.data.issues).Count } else { 0 }
$closedCount = if ($issuesClosed) { @($issuesClosed.data.issues).Count } else { 0 }
$prsMerged = Invoke-GL "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100"
$prMergedData = @()
if ($prsMerged) {
$pd = $prsMerged.data
if ($pd.issues) { $prMergedData = @($pd.issues) }
elseif ($pd.pulls) { $prMergedData = @($pd.pulls) }
elseif ($pd -is [array]) { $prMergedData = $pd }
}
$prMergedCount = $prMergedData.Count
$membersJson = Invoke-GL "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "100"
$memberData = @()
if ($membersJson) {
$md = $membersJson.data
if ($md.members) { $memberData = @($md.members) }
elseif ($md -is [array]) { $memberData = $md }
}
$memberCount = $memberData.Count
Log-Ok "Issues(open:$openCount closed:$closedCount) PRs(merged:$prMergedCount) Members:$memberCount"
# -- Step 2: Build Contributor Data --
Log-Step "Building contributor profiles..."
$contribData = @{}
function Ensure-Contrib {
param([string]$User)
if (-not $User) { return }
if (-not $contribData.ContainsKey($User)) {
$contribData[$User] = @{
Issues = 0; Merged = 0; Additions = 0; Deletions = 0; Comments = 0; IsMember = $false
}
}
}
# Issues
$allIssues = @()
if ($issuesOpen) { $allIssues += @($issuesOpen.data.issues) }
if ($issuesClosed) { $allIssues += @($issuesClosed.data.issues) }
foreach ($issue in $allIssues) {
$author = if ($issue.author.login) { $issue.author.login } elseif ($issue.author.username) { $issue.author.username } else { $null }
if ($author) {
Ensure-Contrib $author
$contribData[$author].Issues++
}
}
# Merged PRs + code stats
Log-Step "Analyzing PR code changes (sampling $Sample)..."
$prSample = [Math]::Min($prMergedCount, $Sample)
for ($i = 0; $i -lt $prMergedCount; $i++) {
$pr = $prMergedData[$i]
$author = if ($pr.author_login) { $pr.author_login } elseif ($pr.author.login) { $pr.author.login } else { $null }
$prId = if ($pr.pull_request_number) { $pr.pull_request_number } elseif ($pr.number) { $pr.number } elseif ($pr.id) { $pr.id } else { $null }
if ($author) {
Ensure-Contrib $author
$contribData[$author].Merged++
}
if ($i -lt $prSample -and $prId -and $author) {
$filesJson = Invoke-GL "pr", "+files", "--owner", $Owner, "--repo", $Repo, "--id", $prId
if ($filesJson -and $filesJson.data.files) {
foreach ($f in $filesJson.data.files) {
$add = if ($f.additions) { $f.additions } elseif ($f.addition) { $f.addition } else { 0 }
$del = if ($f.deletions) { $f.deletions } elseif ($f.deletion) { $f.deletion } else { 0 }
$contribData[$author].Additions += $add
$contribData[$author].Deletions += $del
}
}
}
}
# Members
foreach ($m in $memberData) {
$login = if ($m.login) { $m.login } elseif ($m.username) { $m.username } else { $null }
if ($login) {
Ensure-Contrib $login
$contribData[$login].IsMember = $true
}
}
# Comments (sample open issues)
Log-Step "Sampling issue comments..."
if ($issuesOpen) {
$openIssuesArr = @($issuesOpen.data.issues)
$commentSample = [Math]::Min($openIssuesArr.Count, 10)
for ($i = 0; $i -lt $commentSample; $i++) {
$id = $openIssuesArr[$i].id
if (-not $id) { continue }
$detail = Invoke-GL "issue", "+view", "--owner", $Owner, "--repo", $Repo, "--number", $id
if ($detail) {
$commentCount = if ($detail.data.comment_journals_count) { $detail.data.comment_journals_count } else { 0 }
if ($commentCount -gt 0) {
$author = $openIssuesArr[$i].author.login
if ($author) {
Ensure-Contrib $author
$contribData[$author].Comments += $commentCount
}
}
}
}
}
# -- Step 3: Calculate Scores --
Log-Step "Calculating scores..."
$maxIssues = 0; $maxMerged = 0; $maxLines = 0; $maxComments = 0
foreach ($user in $contribData.Keys) {
$c = $contribData[$user]
if ($c.Issues -gt $maxIssues) { $maxIssues = $c.Issues }
if ($c.Merged -gt $maxMerged) { $maxMerged = $c.Merged }
$lines = $c.Additions + $c.Deletions
if ($lines -gt $maxLines) { $maxLines = $lines }
if ($c.Comments -gt $maxComments) { $maxComments = $c.Comments }
}
$scores = @{}
foreach ($user in $contribData.Keys) {
$c = $contribData[$user]
$ni = if ($maxIssues -gt 0) { $c.Issues / $maxIssues } else { 0 }
$nm = if ($maxMerged -gt 0) { $c.Merged / $maxMerged } else { 0 }
$lines = $c.Additions + $c.Deletions
$nl = if ($maxLines -gt 0) { $lines / $maxLines } else { 0 }
$nc = if ($maxComments -gt 0) { $c.Comments / $maxComments } else { 0 }
$ms = if ($c.IsMember) { 1 } else { 0 }
$score = [Math]::Round($ni * 15 + $nm * 25 + $nl * 30 + $nc * 15 + $ms * 15, 1)
$scores[$user] = $score
}
# -- Step 4: Display Rankings --
Log-Title "Contributor Rankings"
Write-Host ""
Write-Host ("{0,-4} {1,-18} {2,-8} {3,-8} {4,-12} {5,-10} {6,-8} {7}" -f "Rank","Contributor","Issues","Merged","+/- Lines","Comments","Score","Badge") -ForegroundColor White
Write-Host " ---- ------------------ -------- -------- ------------ ---------- -------- -------------"
$ranked = $scores.GetEnumerator() | Sort-Object -Property Value -Descending
$rank = 1
$rankedList = @()
foreach ($entry in $ranked) {
$user = $entry.Key
$score = $entry.Value
$c = $contribData[$user]
$si = [int]$score
$badge = if ($si -ge 80) { "Champion" } elseif ($si -ge 60) { "Core Contributor" } elseif ($si -ge 40) { "Active Contributor" } elseif ($si -ge 20) { "Contributor" } else { "Newcomer" }
$lines = $c.Additions + $c.Deletions
Write-Host ("{0,-4} {1,-18} {2,-8} {3,-8} +{4,-6}/-{5,-4} {6,-10} {7,-8} {8}" -f $rank,$user,$c.Issues,$c.Merged,$c.Additions,$c.Deletions,$c.Comments,$score,$badge)
$rankedList += @{ Rank=$rank; User=$user; Issues=$c.Issues; Merged=$c.Merged; Lines=$lines; Additions=$c.Additions; Deletions=$c.Deletions; Comments=$c.Comments; Score=$score; Badge=$badge }
$rank++
}
# -- Step 5: Generate HTML Report --
Log-Title "Generating HTML Report"
$pieData = ""
foreach ($entry in $ranked) {
$pieData += "{value: $($entry.Value), name: '$($entry.Key)'},"
}
$tableRows = ""
foreach ($r in $rankedList) {
$rankCls = ""
if ($r.Rank -eq 1) { $rankCls = " rank-1" }
elseif ($r.Rank -eq 2) { $rankCls = " rank-2" }
elseif ($r.Rank -eq 3) { $rankCls = " rank-3" }
$badgeCls = switch ($r.Badge) {
"Champion" { "champion" }
"Core Contributor" { "core" }
"Active Contributor" { "active" }
"Contributor" { "contributor" }
default { "newcomer" }
}
$tableRows += ' <tr><td class="rank' + $rankCls + '">' + $r.Rank + '</td><td>@' + $r.User + '</td><td>' + $r.Issues + '</td><td>' + $r.Merged + '</td><td>' + $r.Lines + '</td><td>' + $r.Comments + '</td><td>' + $r.Score + '</td><td><span class="badge badge-' + $badgeCls + '">' + $r.Badge + '</span></td></tr>' + "`n"
}
$totalIssuesCount = $openCount + $closedCount
$contribCount = $contribData.Count
$html = '<!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 - ' + $Owner + '/' + $Repo + '</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>
<p>' + $Owner + '/' + $Repo + ' - Team Contribution Analysis</p>
</div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">' + $contribCount + '</div><div class="stat-label">Contributors</div></div>
<div class="stat-card"><div class="stat-value">' + $totalIssuesCount + '</div><div class="stat-label">Total Issues</div></div>
<div class="stat-card"><div class="stat-value">' + $prMergedCount + '</div><div class="stat-label">Merged PRs</div></div>
</div>
<div class="card">
<h2>Score Distribution</h2>
<div id="pieChart" class="chart-container"></div>
</div>
<div class="card">
<h2>Detailed Rankings</h2>
<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>
' + $tableRows + '
</tbody></table>
</div>
<div class="card">
<h2>Scoring System (AHP Weights)</h2>
<div class="weight-info">
<div class="weight-grid">
<div class="weight-item"><span class="weight-label">Issues Created</span><span class="weight-value">15%</span></div>
<div class="weight-item"><span class="weight-label">PRs Merged</span><span class="weight-value">25%</span></div>
<div class="weight-item"><span class="weight-label">Code Changes</span><span class="weight-value">30%</span></div>
<div class="weight-item"><span class="weight-label">Issue Comments</span><span class="weight-value">15%</span></div>
<div class="weight-item"><span class="weight-label">Team Member</span><span class="weight-value">15%</span></div>
</div>
</div>
</div>
</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: [' + $pieData + ']
}]
});
window.addEventListener("resize", function() { chart.resize(); });
</script>
</body>
</html>'
$html | Out-File -FilePath $reportFile -Encoding UTF8
Log-Ok "HTML report: $reportFile"
# -- Step 6: Publish to Wiki --
Log-Step "Publishing to Wiki..."
$wikiRankRows = ""
foreach ($r in $rankedList) {
$shortBadge = switch ($r.Badge) {
"Champion" { "Champion" }
"Core Contributor" { "Core" }
"Active Contributor" { "Active" }
"Contributor" { "Contributor" }
default { "Newcomer" }
}
$wikiRankRows += "| $($r.Rank) | @$($r.User) | $($r.Issues) | $($r.Merged) | $($r.Lines) | $($r.Comments) | $($r.Score) | $shortBadge |" + "`n"
}
$wikiTitle = "Contributor Leaderboard $(Get-Date -Format 'yyyy-MM-dd')"
$wikiContent = "# Contributor Leaderboard - $Owner/$Repo" + "`n`n"
$wikiContent += "*Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm')*" + "`n`n"
$wikiContent += "## Scoring System" + "`n`n"
$wikiContent += "| Dimension | Weight | Source |" + "`n"
$wikiContent += "|-----------|--------|--------|" + "`n"
$wikiContent += "| Issues Created | 15% | issue +list |" + "`n"
$wikiContent += "| PRs Merged | 25% | pr +list state=merged |" + "`n"
$wikiContent += "| Code Changes | 30% | pr +files |" + "`n"
$wikiContent += "| Issue Comments | 15% | issue +view |" + "`n"
$wikiContent += "| Team Member | 15% | repo +members |" + "`n`n"
$wikiContent += "## Rankings" + "`n`n"
$wikiContent += "| Rank | Contributor | Issues | Merged | Lines | Comments | Score | Badge |" + "`n"
$wikiContent += "|------|-------------|--------|--------|-------|----------|-------|-------|" + "`n"
$wikiContent += $wikiRankRows + "`n"
$wikiContent += "---" + "`n"
$wikiContent += "*Auto-generated by gitlink-cli*"
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--content", $wikiContent
if ($wikiResult -and (Get-JsonOk $wikiResult)) {
Log-Ok "Published to Wiki: $wikiTitle"
} else {
Log-Warn "Wiki publish failed"
}
# -- Step 7: Award Badges (optional) --
if ($Award) {
Log-Title "Awarding Badges"
$badgeGroups = @{}
foreach ($r in $rankedList) {
if (-not $badgeGroups.ContainsKey($r.Badge)) { $badgeGroups[$r.Badge] = @() }
$badgeGroups[$r.Badge] += $r.User
}
foreach ($badge in $badgeGroups.Keys) {
$users = $badgeGroups[$badge]
if ($badge -eq "Newcomer") { continue }
$userList = ($users | ForEach-Object { "@$_" }) -join ", "
$issueTitle = "Badge Award: $badge"
$issueBody = "## Congratulations!" + "`n`n"
$issueBody += "The following contributors have earned the **$badge** badge:" + "`n`n"
$issueBody += $userList + "`n`n"
$issueBody += "### Badge Criteria" + "`n"
$issueBody += switch ($badge) {
"Champion" { "- Score >= 80: Exceptional contribution to the project" }
"Core Contributor" { "- Score >= 60: Significant and consistent contributions" }
"Active Contributor" { "- Score >= 40: Regular contributions to the project" }
"Contributor" { "- Score >= 20: Made meaningful contributions" }
}
$issueBody += "`n`n---`n*Auto-awarded by gitlink-cli contributor-growth workflow*"
$issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $issueTitle, "--body", $issueBody
if ($issueResult) {
try {
$issueJson = $issueResult
$issueNum = if ($issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.data.number) { $issueJson.data.number } else { $null }
if ($issueNum) {
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Repo, "--number", $issueNum, "--labels", "badge" | Out-Null
Log-Ok "Badge issue created: #$issueNum - $issueTitle ($($users.Count) recipients)"
}
} catch {
Log-Warn "Badge issue creation may have failed: $issueTitle"
}
}
}
}
# ----------------------------------------------------------------
Log-Title "Complete"
# ----------------------------------------------------------------
Write-Host " Contributors: $contribCount" -ForegroundColor Green
Write-Host " HTML Report: $reportFile" -ForegroundColor Green
Write-Host " Wiki: $wikiTitle" -ForegroundColor Green
if ($Award) { Write-Host " Badges: Awarded" -ForegroundColor Green }