实现多仓库协同自动化工作流(04)

串联 repo/issue/pr/release +list 命令完成端到端任务链:
逐仓库采集数据 → 对齐 gitlink-health 的健康度评分 → 生成中文 HTML
看板 → 协调发版(支持真 --dry-run 预览)
This commit is contained in:
camelliamc 2026-07-10 20:54:32 +08:00
parent e893efa817
commit 8a2c4d3bd9
2 changed files with 520 additions and 282 deletions

View File

@ -1,14 +1,14 @@
# ----------------------------------------------------------------
# ----------------------------------------------------------------
# Scenario 4: Multi-Repo Collaboration
# Flow: Cross-repo issue tracking -> PR status dashboard -> Coordinated release
#
# Commands chained:
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch issues from each repo
# 3. pr +list -- fetch PRs from each repo
# 4. release +list -- check release status across repos
# 5. release +create -- coordinated release (optional)
# 6. Generate HTML dashboard
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch open/closed issues from each repo
# 3. pr +list -- fetch open/merged PRs from each repo
# 4. release +list -- check latest release across repos
# 5. Generate HTML dashboard (summary cards + table + per-repo details)
# 6. release +create -- (optional) coordinated release across all repos
# ----------------------------------------------------------------
#Requires -Version 5.1
@ -17,6 +17,7 @@ param(
[string]$Repos = "",
[string]$Release = "",
[string]$Output = "dashboard.html",
[int]$DetailLimit = 10,
[switch]$DryRun,
[switch]$Help
)
@ -25,7 +26,7 @@ $ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 04-multi-repo-collab.ps1 -Org ORG [-Repos 'repo1,repo2'] [-Release TAG] [-Output FILE] [-DryRun]"
Write-Host "Usage: powershell 04-multi-repo-collab.ps1 -Org ORG [-Repos 'repo1,repo2'] [-Release TAG] [-Output FILE] [-DetailLimit N] [-DryRun]"
exit 0
}
@ -33,24 +34,66 @@ if (-not $Org) { Log-Err "Org is required (use -Help for usage)"; exit 1 }
Check-Auth
# -- Step 1: List Repositories --
Log-Title "Multi-Repo Collaboration Dashboard"
# -- Helpers --
function Html-Escape([string]$s) {
if ($null -eq $s) { return "" }
$s = $s -replace '&', '&'
$s = $s -replace '<', '&lt;'
$s = $s -replace '>', '&gt;'
$s = $s -replace '"', '&quot;'
return $s
}
Log-Step "Fetching repositories for org: $Org..."
# Health score (0-100) aligned with gitlink-health skill.
# Core 75 (issue efficiency / PR efficiency / activity, -25 each),
# auxiliary 25 (issue backlog -10, PR backlog -10, no recent release -5).
# releaseAge: days since latest release, -1 = none.
function Compute-Health([int]$oi, [int]$ci, [int]$op, [int]$mp, [int]$age) {
$health = 100
# 核心指标 (75)
$ti = $oi + $ci
$res = if ($ti -gt 0) { $ci / $ti } else { 1 }
if ($res -lt 0.5) { $health -= 25 } # Issue 处理效率 (响应慢代理)
$tp = $op + $mp
$mr = if ($tp -gt 0) { $mp / $tp } else { 1 }
if ($mr -lt 0.5) { $health -= 25 } # PR 合并效率 (合并慢代理)
if (($mp + $ci) -eq 0) { $health -= 25 } # 活跃度低: 无已完成工作
# 辅助指标 (25)
if ($oi -gt 20) { $health -= 10 } # Issue 积压
if ($op -gt 10) { $health -= 10 } # PR 积压
if ($age -lt 0 -or $age -gt 30) { $health -= 5 } # 无近期发布
if ($health -lt 0) { $health = 0 }
if ($health -gt 100) { $health = 100 }
return [int]$health
}
# Map score -> @{ Label; Class } (5 bands per gitlink-health skill)
function Get-HealthGrade([int]$score) {
if ($score -ge 90) { return @{ Label = "优秀"; Class = "excellent" } }
elseif ($score -ge 70) { return @{ Label = "良好"; Class = "good" } }
elseif ($score -ge 50) { return @{ Label = "一般"; Class = "moderate" } }
elseif ($score -ge 30) { return @{ Label = "需关注"; Class = "attention" } }
else { return @{ Label = "严重"; Class = "critical" } }
}
# -- Step 1: List Repositories --
Log-Title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)"
Log-Step "获取组织 $Org 下的仓库列表..."
$reposJson = Invoke-GLCheck "repo", "+list", "--user", $Org, "--limit", "100"
if (-not $reposJson) { Log-Err "Failed to fetch repos"; exit 1 }
if (-not $reposJson) { Log-Err "获取仓库列表失败"; exit 1 }
$allRepos = @()
$rd = $reposJson.data
if ($rd.projects) { $allRepos = @($rd.projects) }
elseif ($rd -is [array]) { $allRepos = $rd }
Log-Ok "Found $($allRepos.Count) repositories"
Log-Ok "组织下共有 $($allRepos.Count) 个仓库"
$repoList = @()
if ($Repos) {
$repoList = $Repos -split ','
Log-Info "Filtering to specified repos: $($repoList -join ', ')"
Log-Info "仅处理指定仓库: $($repoList -join ', ')"
} else {
foreach ($r in $allRepos) {
$rname = if ($r.name) { $r.name } elseif ($r.identifier) { $r.identifier } else { $null }
@ -58,182 +101,259 @@ if ($Repos) {
}
}
Log-Ok "Will process $($repoList.Count) repositories"
if ($repoList.Count -eq 0) { Log-Err "没有可处理的仓库。请检查 -Org 或用 -Repos 指定。"; exit 1 }
Log-Ok "将处理 $($repoList.Count) 个仓库"
# -- Step 2-3: Collect Issues and PRs from each repo --
Log-Title "Collecting Data Across Repos"
# -- Step 2-4: Collect data across repos --
Log-Title "跨仓库数据采集"
$totalIssues = 0; $totalOpenIssues = 0; $totalPRs = 0; $totalOpenPRs = 0
$totalActivity = 0; $totalOpenIssues = 0; $totalOpenPRs = 0; $totalMergedPRs = 0; $healthSum = 0
$dashboardRows = ""
$repoDetails = ""
foreach ($repo in $repoList) {
Divider
Log-Step "Processing $Org/$repo..."
Log-Step "处理 $Org/$repo..."
# Open issues
$issuesObj = $null; $openIssues = 0; $openIssueItems = @()
$issuesRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
$openIssues = 0
if ($issuesRaw) {
try {
$issuesObj = $issuesRaw | ConvertFrom-Json
if ($issuesObj.ok -and $issuesObj.data.issues) {
$openIssues = @($issuesObj.data.issues).Count
}
} catch {}
}
if ($issuesRaw) { try {
$issuesObj = $issuesRaw | ConvertFrom-Json
if ($issuesObj.ok -and $issuesObj.data.issues) { $openIssueItems = @($issuesObj.data.issues); $openIssues = $openIssueItems.Count }
} catch {} }
$closedRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "closed", "--limit", "50"
# Closed issues
$closedIssues = 0
if ($closedRaw) {
try {
$closedObj = $closedRaw | ConvertFrom-Json
if ($closedObj.ok -and $closedObj.data.issues) {
$closedIssues = @($closedObj.data.issues).Count
}
} catch {}
}
$closedRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "closed", "--limit", "50"
if ($closedRaw) { try {
$closedObj = $closedRaw | ConvertFrom-Json
if ($closedObj.ok -and $closedObj.data.issues) { $closedIssues = @($closedObj.data.issues).Count }
} catch {} }
# Open PRs
$openPRs = 0; $openPRItems = @()
$prsRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
$openPRs = 0
if ($prsRaw) {
try {
$prsObj = $prsRaw | ConvertFrom-Json
if ($prsObj.ok) {
if ($prsObj.data.issues) { $openPRs = @($prsObj.data.issues).Count }
elseif ($prsObj.data.pulls) { $openPRs = @($prsObj.data.pulls).Count }
elseif ($prsObj.data -is [array]) { $openPRs = $prsObj.data.Count }
}
} catch {}
}
if ($prsRaw) { try {
$prsObj = $prsRaw | ConvertFrom-Json
if ($prsObj.ok) {
if ($prsObj.data.issues) { $openPRItems = @($prsObj.data.issues) }
elseif ($prsObj.data.pulls) { $openPRItems = @($prsObj.data.pulls) }
elseif ($prsObj.data -is [array]) { $openPRItems = @($prsObj.data) }
$openPRs = $openPRItems.Count
}
} catch {} }
$mergedRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "merged", "--limit", "50"
# Merged PRs
$mergedPRs = 0
if ($mergedRaw) {
try {
$mergedObj = $mergedRaw | ConvertFrom-Json
if ($mergedObj.ok) {
if ($mergedObj.data.issues) { $mergedPRs = @($mergedObj.data.issues).Count }
elseif ($mergedObj.data.pulls) { $mergedPRs = @($mergedObj.data.pulls).Count }
elseif ($mergedObj.data -is [array]) { $mergedPRs = $mergedObj.data.Count }
}
} catch {}
}
$mergedRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "merged", "--limit", "50"
if ($mergedRaw) { try {
$mergedObj = $mergedRaw | ConvertFrom-Json
if ($mergedObj.ok) {
if ($mergedObj.data.issues) { $mergedPRs = @($mergedObj.data.issues).Count }
elseif ($mergedObj.data.pulls) { $mergedPRs = @($mergedObj.data.pulls).Count }
elseif ($mergedObj.data -is [array]) { $mergedPRs = @($mergedObj.data).Count }
}
} catch {} }
# Latest release + age
$latestRelease = "none"; $releaseAge = -1
$releaseRaw = Invoke-GL "release", "+list", "--owner", $Org, "--repo", $repo, "--limit", "1"
$latestRelease = "none"
if ($releaseRaw) {
try {
$releaseObj = $releaseRaw | ConvertFrom-Json
if ($releaseObj.ok -and $releaseObj.data.releases) {
$releases = @($releaseObj.data.releases)
if ($releases.Count -gt 0) {
$latestRelease = if ($releases[0].tag_name) { $releases[0].tag_name } elseif ($releases[0].name) { $releases[0].name } else { "none" }
}
}
} catch {}
if ($releaseRaw) { try {
$releaseObj = $releaseRaw | ConvertFrom-Json
$rels = $null
if ($releaseObj.ok -and $releaseObj.data.releases) { $rels = @($releaseObj.data.releases) }
elseif ($releaseObj.ok -and $releaseObj.data -is [array]) { $rels = @($releaseObj.data) }
if ($rels -and $rels.Count -gt 0) {
$latestRelease = if ($rels[0].tag_name) { $rels[0].tag_name } elseif ($rels[0].name) { $rels[0].name } else { "none" }
$relDate = if ($rels[0].created_at) { $rels[0].created_at } elseif ($rels[0].created_on) { $rels[0].created_on } elseif ($rels[0].published_at) { $rels[0].published_at } else { $null }
if ($relDate) {
try { $releaseAge = [int]((Get-Date) - [datetime]$relDate).TotalDays } catch { $releaseAge = 0 }
} else { $releaseAge = 0 }
}
} catch {} }
$health = Compute-Health $openIssues $closedIssues $openPRs $mergedPRs $releaseAge
$grade = Get-HealthGrade $health
Log-Ok "$repo : Issue(开:$openIssues 闭:$closedIssues) PR(开:$openPRs 合:$mergedPRs) Release:$latestRelease 健康度:$health($($grade.Label))"
# Open issue detail rows
$issueRows = ""
$limit = [Math]::Min($openIssues, $DetailLimit)
for ($i = 0; $i -lt $limit; $i++) {
$it = $openIssueItems[$i]
$iid = if ($it.id) { $it.id } elseif ($it.number) { $it.number } else { "" }
$ititle = if ($it.name) { $it.name } elseif ($it.subject) { $it.subject } elseif ($it.title) { $it.title } else { "" }
$iauthor = if ($it.author_name) { $it.author_name } elseif ($it.author_login) { $it.author_login } elseif ($it.author.login) { $it.author.login } else { "unknown" }
$issueRows += "<tr><td>#$(Html-Escape ([string]$iid))</td><td>$(Html-Escape ([string]$ititle))</td><td>@$(Html-Escape ([string]$iauthor))</td></tr>"
}
if (-not $issueRows) { $issueRows = '<tr><td colspan="3" class="empty">无未关闭 Issue</td></tr>' }
Log-Ok "$repo : Issues(open:$openIssues closed:$closedIssues) PRs(open:$openPRs merged:$mergedPRs) Release:$latestRelease"
# Open PR detail rows
$prRows = ""
$limit = [Math]::Min($openPRs, $DetailLimit)
for ($i = 0; $i -lt $limit; $i++) {
$pt = $openPRItems[$i]
$pid = if ($pt.pull_request_number) { $pt.pull_request_number } elseif ($pt.number) { $pt.number } elseif ($pt.id) { $pt.id } else { "" }
$ptitle = if ($pt.subject) { $pt.subject } elseif ($pt.title) { $pt.title } elseif ($pt.name) { $pt.name } else { "" }
$pauthor = if ($pt.author_name) { $pt.author_name } elseif ($pt.author_login) { $pt.author_login } elseif ($pt.author.login) { $pt.author.login } else { "unknown" }
$prRows += "<tr><td>#$(Html-Escape ([string]$pid))</td><td>$(Html-Escape ([string]$ptitle))</td><td>@$(Html-Escape ([string]$pauthor))</td></tr>"
}
if (-not $prRows) { $prRows = '<tr><td colspan="3" class="empty">无未合并 PR</td></tr>' }
$statusColor = "green"
$healthText = "Healthy"
if ($openIssues -gt 10) { $statusColor = "orange"; $healthText = "Moderate" }
if ($openIssues -gt 20) { $statusColor = "red"; $healthText = "Needs Attention" }
$repoDetails += @"
<details><summary><span class="badge $($grade.Class)">$health</span> $(Html-Escape $repo) Issue $openIssues / PR $openPRs</summary>
<div class="detail-grid">
<div><h4>未关闭 Issue最多 $DetailLimit </h4>
<table class="detail-table"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$issueRows</tbody></table></div>
<div><h4>未合并 PR最多 $DetailLimit </h4>
<table class="detail-table"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$prRows</tbody></table></div>
</div>
</details>
"@
$dashboardRows += "<tr>`n"
$dashboardRows += " <td><a href=`"https://gitlink.org.cn/$Org/$repo`">$repo</a></td>`n"
$dashboardRows += " <td>$openIssues</td><td>$closedIssues</td>`n"
$dashboardRows += " <td>$openPRs</td><td>$mergedPRs</td><td>$latestRelease</td>`n"
$dashboardRows += " <td style=`"color:$statusColor;font-weight:bold;`">$healthText</td>`n"
$dashboardRows += "</tr>`n"
$totalIssues += $openIssues + $closedIssues
$totalActivity += $openIssues + $closedIssues + $openPRs + $mergedPRs
$totalOpenIssues += $openIssues
$totalPRs += $openPRs + $mergedPRs
$totalOpenPRs += $openPRs
$totalMergedPRs += $mergedPRs
$healthSum += $health
$relDisplay = if ($latestRelease -eq "none") { "" } else { Html-Escape $latestRelease }
$dashboardRows += @"
<tr>
<td><a href="https://gitlink.org.cn/$Org/$repo" target="_blank">$(Html-Escape $repo)</a></td>
<td>$openIssues</td><td>$closedIssues</td>
<td>$openPRs</td><td>$mergedPRs</td><td>$relDisplay</td>
<td><span class="badge $($grade.Class)">$health</span> <span class="grade $($grade.Class)">$($grade.Label)</span></td>
</tr>
"@
}
# -- Step 4: Generate HTML Dashboard --
Log-Title "Generating Dashboard"
$avgHealth = if ($repoList.Count -gt 0) { [int]($healthSum / $repoList.Count) } else { 0 }
# -- Step 5: Generate HTML Dashboard --
Log-Title "生成 HTML 看板"
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$repoCount = $repoList.Count
$html = '<!DOCTYPE html>
<html lang="en">
$html = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Repo Collaboration Dashboard</title>
<title>多仓库协同看板 - $Org</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 30px; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { color: #666; font-size: 14px; margin-bottom: 8px; }
.card .value { font-size: 32px; font-weight: bold; color: #333; }
.card.blue .value { color: #2196F3; }
.card.green .value { color: #4CAF50; }
.card.orange .value { color: #FF9800; }
.card.purple .value { color: #9C27B0; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #2196F3; color: white; padding: 12px 16px; text-align: left; }
td { padding: 12px 16px; border-bottom: 1px solid #eee; }
tr:hover { background: #f9f9f9; }
a { color: #2196F3; text-decoration: none; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; background: #f0f2f5; padding: 24px; color: #1f2329; }
.container { max-width: 1280px; margin: 0 auto; }
h1 { color: #1f2329; margin-bottom: 6px; font-size: 26px; }
.timestamp { color: #8a919f; font-size: 13px; margin-bottom: 24px; }
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px; }
.card { background: #fff; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card h3 { color: #8a919f; font-size: 13px; margin-bottom: 8px; font-weight: 500; }
.card .value { font-size: 30px; font-weight: 700; }
.card.blue .value { color: #2f80ed; }
.card.orange .value { color: #f2994a; }
.card.purple .value { color: #9b51e0; }
.card.green .value { color: #27ae60; }
.card.teal .value { color: #17a2b8; }
h2 { font-size: 18px; margin: 24px 0 14px; color: #1f2329; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
th { background: #2f80ed; color: #fff; padding: 12px 16px; text-align: left; font-weight: 500; font-size: 14px; }
td { padding: 11px 16px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f7f9fc; }
a { color: #2f80ed; text-decoration: none; font-weight: 500; }
a:hover { text-decoration: underline; }
.timestamp { color: #999; font-size: 14px; margin-bottom: 20px; }
.badge { display: inline-block; min-width: 34px; text-align: center; padding: 2px 8px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px; }
.grade { font-size: 13px; margin-left: 4px; }
.badge.excellent { background: #27ae60; } .grade.excellent { color: #27ae60; }
.badge.good { background: #2f80ed; } .grade.good { color: #2f80ed; }
.badge.moderate { background: #f2c94c; } .grade.moderate { color: #d9a400; }
.badge.attention { background: #f2994a; } .grade.attention { color: #f2994a; }
.badge.critical { background: #eb5757; } .grade.critical { color: #eb5757; }
details { background: #fff; border-radius: 10px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 15px; user-select: none; }
summary:hover { background: #f7f9fc; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 0 18px 18px; }
.detail-table th { background: #eef2f8; color: #4a5568; padding: 8px 12px; font-size: 13px; }
.detail-table td { padding: 8px 12px; font-size: 13px; }
.detail-grid h4 { font-size: 14px; margin-bottom: 8px; color: #4a5568; }
.empty { color: #b0b7c3; text-align: center; font-style: italic; }
@media (max-width: 900px) { .summary { grid-template-columns: repeat(2, 1fr); } .detail-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<h1>Multi-Repo Collaboration Dashboard</h1>
<p class="timestamp">Generated: ' + $timestamp + ' | Organization: ' + $Org + '</p>
<h1>多仓库协同看板</h1>
<p class="timestamp">生成时间$timestamp 组织$Org</p>
<div class="summary">
<div class="card blue"><h3>Total Repos</h3><div class="value">' + $repoCount + '</div></div>
<div class="card orange"><h3>Open Issues</h3><div class="value">' + $totalOpenIssues + '</div></div>
<div class="card purple"><h3>Open PRs</h3><div class="value">' + $totalOpenPRs + '</div></div>
<div class="card green"><h3>Total Activity</h3><div class="value">' + $totalIssues + '</div></div>
<div class="card blue"><h3>仓库总数</h3><div class="value">$repoCount</div></div>
<div class="card orange"><h3>未关闭 Issue</h3><div class="value">$totalOpenIssues</div></div>
<div class="card purple"><h3>未合并 PR</h3><div class="value">$totalOpenPRs</div></div>
<div class="card green"><h3>已合并 PR</h3><div class="value">$totalMergedPRs</div></div>
<div class="card teal"><h3>平均健康度</h3><div class="value">$avgHealth</div></div>
</div>
<h2>仓库概览</h2>
<table>
<thead><tr><th>Repository</th><th>Open Issues</th><th>Closed Issues</th><th>Open PRs</th><th>Merged PRs</th><th>Latest Release</th><th>Health</th></tr></thead>
<thead><tr><th>仓库</th><th>未关闭 Issue</th><th>已关闭 Issue</th><th>未合并 PR</th><th>已合并 PR</th><th>最新 Release</th><th>健康度</th></tr></thead>
<tbody>
' + $dashboardRows + '
$dashboardRows
</tbody>
</table>
<h2>仓库详情</h2>
$repoDetails
</div>
</body>
</html>'
</html>
"@
$html | Out-File -FilePath $Output -Encoding UTF8
Log-Ok "Dashboard saved to: $Output"
$utf8 = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText((Join-Path (Get-Location) $Output), $html, $utf8)
Log-Ok "看板已保存: $Output"
# -- Step 5: Coordinated Release --
# -- Step 6: Coordinated Release --
if ($Release) {
Log-Title "Coordinated Release: $Release"
Log-Title "协调发版: $Release"
foreach ($repo in $repoList) {
Log-Step "Creating release for $Org/$repo..."
$relBody = "Coordinated release $Release for $Org/$repo"
$relResult = Invoke-GL "release", "+create", "--owner", $Org, "--repo", $repo, "--tag", $Release, "--name", "Release $Release", "--body", $relBody
if ($relResult -and (Get-JsonOk ($relResult | ConvertFrom-Json))) {
Log-Ok "Release $Release created for $repo"
} else {
Log-Warn "Release creation failed for $repo (tag may already exist)"
if ($DryRun) {
Log-Warn "[DRY RUN] 将为以下 $($repoList.Count) 个仓库创建 Release ${Release}:"
foreach ($repo in $repoList) { Log-Warn " [DRY RUN] $Org/$repo -> $Release" }
} else {
$relOk = 0; $relFail = 0
foreach ($repo in $repoList) {
Log-Step "$Org/$repo 创建 Release..."
$relBody = "Coordinated release $Release for $Org/$repo"
$relResult = Invoke-GL "release", "+create", "--owner", $Org, "--repo", $repo, "--tag", $Release, "--name", "Release $Release", "--body", $relBody
$ok = $false
if ($relResult) { try { $ok = ($relResult | ConvertFrom-Json).ok } catch { $ok = $false } }
if ($ok) { Log-Ok "Release $Release 已在 $repo 创建"; $relOk++ }
else { Log-Warn "Release 创建失败: $repo (tag 可能已存在)"; $relFail++ }
}
Log-Info "协调发版结果: 成功 $relOk / 失败 $relFail"
}
}
# ----------------------------------------------------------------
Log-Title "Multi-Repo Dashboard Complete"
Log-Title "多仓库看板完成"
# ----------------------------------------------------------------
Write-Host " Repos processed: $repoCount" -ForegroundColor Green
Write-Host " Total issues: $totalIssues (open: $totalOpenIssues)" -ForegroundColor Green
Write-Host " Total PRs: $totalPRs (open: $totalOpenPRs)" -ForegroundColor Green
Write-Host " Dashboard: $Output" -ForegroundColor Green
if ($Release) { Write-Host " Coordinated release: $Release" -ForegroundColor Green }
Write-Host " 处理仓库: $repoCount" -ForegroundColor Green
Write-Host " 未关闭 Issue: $totalOpenIssues" -ForegroundColor Green
Write-Host " 未合并 PR: $totalOpenPRs" -ForegroundColor Green
Write-Host " 已合并 PR: $totalMergedPRs" -ForegroundColor Green
Write-Host " 平均健康度: $avgHealth / 100" -ForegroundColor Green
Write-Host " 看板文件: $Output" -ForegroundColor Green
if ($Release) {
$suffix = if ($DryRun) { " (dry-run)" } else { "" }
Write-Host " 协调发版: $Release$suffix" -ForegroundColor Green
}
Write-Host ""
Write-Host "Open dashboard:" -ForegroundColor Cyan
Write-Host "打开看板:" -ForegroundColor Cyan
Write-Host " Start-Process $Output"

View File

@ -3,27 +3,27 @@
# Scenario 4: Multi-Repo Collaboration
# Flow: Cross-repo issue tracking → PR status dashboard → Coordinated release
#
# Commands/Skills chained:
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch issues from each repo
# 3. pr +list -- fetch PRs from each repo
# 4. pr +view -- get PR details for dashboard
# 5. release +list -- check release status across repos
# 6. release +create -- coordinated release
# 7. Generate HTML dashboard
# Commands chained:
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch open/closed issues from each repo
# 3. pr +list -- fetch open/merged PRs from each repo
# 4. release +list -- check latest release across repos
# 5. Generate HTML dashboard (summary cards + table + per-repo details)
# 6. release +create -- (optional) coordinated release across all repos
# ─────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
usage() {
echo "Usage: $0 --org ORG [--repos REPO1,REPO2,...] [--release TAG] [--dry-run]"
echo "Usage: $0 --org ORG [--repos REPO1,REPO2,...] [--release TAG] [--output FILE] [--dry-run]"
echo ""
echo " --org ORG Organization name"
echo " --org ORG Organization / user name (required)"
echo " --repos REPO1,REPO2 Comma-separated repo list (default: all repos in org)"
echo " --release TAG Coordinated release tag to create"
echo " --release TAG Coordinated release tag to create across all repos"
echo " --output FILE Output HTML dashboard file (default: dashboard.html)"
echo " --dry-run Preview actions without executing"
echo " --detail-limit N Max open issues/PRs listed per repo in details (default: 10)"
echo " --dry-run Preview actions without creating releases"
exit 1
}
@ -32,16 +32,18 @@ ORG=""
REPOS=""
RELEASE_TAG=""
OUTPUT_FILE="dashboard.html"
DETAIL_LIMIT=10
while [[ $# -gt 0 ]]; do
case "$1" in
--org) ORG="$2"; shift 2 ;;
--repos) REPOS="$2"; shift 2 ;;
--release) RELEASE_TAG="$2"; shift 2 ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
--dry-run) DRY_RUN="true"; shift ;;
--help|-h) usage ;;
*) log_err "Unknown arg: $1"; usage ;;
--org) ORG="$2"; shift 2 ;;
--repos) REPOS="$2"; shift 2 ;;
--release) RELEASE_TAG="$2"; shift 2 ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
--detail-limit) DETAIL_LIMIT="$2"; shift 2 ;;
--dry-run) DRY_RUN="true"; shift ;;
--help|-h) usage ;;
*) log_err "Unknown arg: $1"; usage ;;
esac
done
@ -52,214 +54,330 @@ fi
check_auth
# ── Step 1: List Repositories ────────────────────────────────────────
log_title "Multi-Repo Collaboration Dashboard"
# ── HTML escape helper ───────────────────────────────────────────────
html_escape() {
local s="$1"
s="${s//&/&amp;}"
s="${s//</&lt;}"
s="${s//>/&gt;}"
s="${s//\"/&quot;}"
echo "$s"
}
log_step "Fetching repositories for org: $ORG..."
# ── Health score (0-100) — aligned with gitlink-health skill ─────────
# Model: start 100, deduct. Core 75 (issue efficiency / PR efficiency /
# activity, -25 each), auxiliary 25 (issue backlog -10, PR backlog -10,
# no recent release -5). Time-based core metrics are approximated by
# list-level ratios for a fast multi-repo scan.
# release_age: days since latest release, -1 = no release.
compute_health() {
local open_issues="$1" closed_issues="$2" open_prs="$3" merged_prs="$4" release_age="$5"
awk -v oi="$open_issues" -v ci="$closed_issues" -v op="$open_prs" \
-v mp="$merged_prs" -v age="$release_age" 'BEGIN {
health = 100
# ── 核心指标 (75) ──
# Issue 处理效率 (proxy for 响应慢): 解决率 < 50% 扣 25
ti = oi + ci
res = (ti > 0) ? ci / ti : 1
if (res < 0.5) health -= 25
# PR 合并效率 (proxy for 合并慢): 合并率 < 50% 扣 25
tp = op + mp
mr = (tp > 0) ? mp / tp : 1
if (mr < 0.5) health -= 25
# 活跃度低: 无任何已完成工作 (合并 PR + 关闭 Issue == 0) 扣 25
if ((mp + ci) == 0) health -= 25
# ── 辅助指标 (25) ──
if (oi > 20) health -= 10 # Issue 积压
if (op > 10) health -= 10 # PR 积压
if (age < 0 || age > 30) health -= 5 # 无近期发布 (>30天或无)
if (health < 0) health = 0
if (health > 100) health = 100
printf "%d", health
}'
}
# Map score → (grade, css class) — 5 bands per gitlink-health skill
health_grade() {
local score="$1"
if [[ "$score" -ge 90 ]]; then echo "优秀|excellent"
elif [[ "$score" -ge 70 ]]; then echo "良好|good"
elif [[ "$score" -ge 50 ]]; then echo "一般|moderate"
elif [[ "$score" -ge 30 ]]; then echo "需关注|attention"
else echo "严重|critical"
fi
}
# ── Step 1: List Repositories ────────────────────────────────────────
log_title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)"
log_step "获取组织 $ORG 下的仓库列表..."
REPOS_JSON=$(gl_check repo +list --user "$ORG" --limit 100)
# Response may have .data.projects[] or .data[]
ALL_REPO_COUNT=$(echo "$REPOS_JSON" | jq '(.data.projects // .data | if type == "array" then . else [] end) | length')
log_ok "Found $ALL_REPO_COUNT repositories"
REPOS_DATA_PATH='(.data.projects // .data | if type == "array" then . else [] end)'
ALL_REPO_COUNT=$(echo "$REPOS_JSON" | jq "$REPOS_DATA_PATH | length")
log_ok "组织下共有 $ALL_REPO_COUNT 个仓库"
# Filter repos if --repos specified
REPO_LIST=()
if [[ -n "$REPOS" ]]; then
IFS=',' read -ra REPO_LIST <<< "$REPOS"
log_info "Filtering to specified repos: ${REPO_LIST[*]}"
log_info "仅处理指定仓库: ${REPO_LIST[*]}"
else
REPOS_DATA_PATH='(.data.projects // .data | if type == "array" then . else [] end)'
for i in $(seq 0 $((ALL_REPO_COUNT - 1))); do
RNAME=$(echo "$REPOS_JSON" | jq -r "$REPOS_DATA_PATH[$i].name // $REPOS_DATA_PATH[$i].identifier // empty")
[[ -n "$RNAME" ]] && REPO_LIST+=("$RNAME")
done
fi
log_ok "Will process ${#REPO_LIST[@]} repositories"
if [[ "${#REPO_LIST[@]}" -eq 0 ]]; then
log_err "没有可处理的仓库。请检查 --org 是否正确,或用 --repos 指定。"
exit 1
fi
log_ok "将处理 ${#REPO_LIST[@]} 个仓库"
# ── Step 2-3: Collect Issues and PRs from each repo ──────────────────
log_title "Collecting Data Across Repos"
# ── Step 2-4: Collect data across repos ──────────────────────────────
log_title "跨仓库数据采集"
# Data arrays for dashboard
DASHBOARD_ROWS=""
TOTAL_ISSUES=0
TOTAL_PRS=0
REPO_DETAILS=""
TOTAL_ACTIVITY=0
TOTAL_OPEN_ISSUES=0
TOTAL_OPEN_PRS=0
TOTAL_MERGED_PRS=0
HEALTH_SUM=0
ISSUE_DATA_PATH='(.data.issues // .data | if type == "array" then . else [] end)'
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
for repo in "${REPO_LIST[@]}"; do
divider
log_step "Processing $ORG/$repo..."
log_step "处理 $ORG/$repo..."
# Fetch open issues
ISSUES_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state open --limit 50)
OPEN_ISSUES=$(echo "$ISSUES_JSON" | jq '.data.issues | length' 2>/dev/null || echo "0")
OPEN_ISSUES=$(echo "$ISSUES_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$OPEN_ISSUES" =~ ^[0-9]+$ ]] || OPEN_ISSUES=0
# Fetch closed issues (recent)
CLOSED_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state closed --limit 50)
CLOSED_ISSUES=$(echo "$CLOSED_JSON" | jq '.data.issues | length' 2>/dev/null || echo "0")
CLOSED_ISSUES=$(echo "$CLOSED_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$CLOSED_ISSUES" =~ ^[0-9]+$ ]] || CLOSED_ISSUES=0
# Fetch open PRs
PRS_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state open --limit 50)
OPEN_PRS=$(echo "$PRS_JSON" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
OPEN_PRS=$(echo "$PRS_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$OPEN_PRS" =~ ^[0-9]+$ ]] || OPEN_PRS=0
# Fetch merged PRs (recent)
MERGED_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state merged --limit 50)
MERGED_PRS=$(echo "$MERGED_JSON" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
MERGED_PRS=$(echo "$MERGED_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$MERGED_PRS" =~ ^[0-9]+$ ]] || MERGED_PRS=0
# Fetch latest release
RELEASES_JSON=$(gl_run release +list --owner "$ORG" --repo "$repo" --limit 1)
LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r '.data.releases[0].tag_name // .data.releases[0].name // "none"' 2>/dev/null)
REL_ARR='(.data.releases // .data | if type == "array" then . else [] end)'
LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].tag_name // $REL_ARR[0].name // \"none\"" 2>/dev/null)
[[ -z "$LATEST_RELEASE" || "$LATEST_RELEASE" == "null" ]] && LATEST_RELEASE="none"
log_ok "$repo: Issues(open:$OPEN_ISSUES closed:$CLOSED_ISSUES) PRs(open:$OPEN_PRS merged:$MERGED_PRS) Release:$LATEST_RELEASE"
# Release age in days (-1 if none / unparseable)
RELEASE_AGE=-1
if [[ "$LATEST_RELEASE" != "none" ]]; then
REL_DATE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].created_at // $REL_ARR[0].created_on // $REL_ARR[0].published_at // empty" 2>/dev/null)
REL_DATE="${REL_DATE%% *}"; REL_DATE="${REL_DATE%%T*}"
if [[ "$REL_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
RELEASE_AGE=$(days_between "$REL_DATE" "$(date_today)")
else
RELEASE_AGE=0 # has release but no parseable date → treat as recent
fi
fi
# Get PR details for open PRs
PR_DETAILS=""
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
if [[ "$OPEN_PRS" -gt 0 ]] && [[ "$OPEN_PRS" != "null" ]]; then
for pi in $(seq 0 $((OPEN_PRS > 5 ? 4 : OPEN_PRS - 1))); do
PR_ID=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].pull_request_number // $PR_DATA_PATH[$pi].number // $PR_DATA_PATH[$pi].id // empty")
PR_TITLE=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].subject // $PR_DATA_PATH[$pi].title // empty")
PR_AUTHOR=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].author_login // $PR_DATA_PATH[$pi].author.login // \"unknown\"")
PR_DETAILS+="<tr><td>#$PR_ID</td><td>$PR_TITLE</td><td>@$PR_AUTHOR</td><td>open</td></tr>"
# Health score (aligned with gitlink-health skill)
HEALTH=$(compute_health "$OPEN_ISSUES" "$CLOSED_ISSUES" "$OPEN_PRS" "$MERGED_PRS" "$RELEASE_AGE")
IFS='|' read -r GRADE_LABEL GRADE_CLASS <<< "$(health_grade "$HEALTH")"
log_ok "$repo: Issue(开:$OPEN_ISSUES 闭:$CLOSED_ISSUES) PR(开:$OPEN_PRS 合:$MERGED_PRS) Release:$LATEST_RELEASE 健康度:$HEALTH($GRADE_LABEL)"
# ── Collect open issue details ──
ISSUE_ROWS=""
if [[ "$OPEN_ISSUES" -gt 0 ]]; then
limit=$(( OPEN_ISSUES < DETAIL_LIMIT ? OPEN_ISSUES : DETAIL_LIMIT ))
for ii in $(seq 0 $((limit - 1))); do
I_ID=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].id // $ISSUE_DATA_PATH[$ii].number // empty")
I_TITLE=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].name // $ISSUE_DATA_PATH[$ii].subject // $ISSUE_DATA_PATH[$ii].title // empty")
I_AUTHOR=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].author_name // $ISSUE_DATA_PATH[$ii].author_login // $ISSUE_DATA_PATH[$ii].author.login // \"unknown\"")
ISSUE_ROWS+="<tr><td>#$(html_escape "$I_ID")</td><td>$(html_escape "$I_TITLE")</td><td>@$(html_escape "$I_AUTHOR")</td></tr>"
done
fi
# Accumulate totals
TOTAL_ISSUES=$((TOTAL_ISSUES + OPEN_ISSUES + CLOSED_ISSUES))
TOTAL_OPEN_ISSUES=$((TOTAL_OPEN_ISSUES + OPEN_ISSUES))
TOTAL_PRS=$((TOTAL_PRS + OPEN_PRS + MERGED_PRS))
TOTAL_OPEN_PRS=$((TOTAL_OPEN_PRS + OPEN_PRS))
# ── Collect open PR details ──
PR_ROWS=""
if [[ "$OPEN_PRS" -gt 0 ]]; then
limit=$(( OPEN_PRS < DETAIL_LIMIT ? OPEN_PRS : DETAIL_LIMIT ))
for pi in $(seq 0 $((limit - 1))); do
P_ID=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].pull_request_number // $PR_DATA_PATH[$pi].number // $PR_DATA_PATH[$pi].id // empty")
P_TITLE=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].subject // $PR_DATA_PATH[$pi].title // $PR_DATA_PATH[$pi].name // empty")
P_AUTHOR=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].author_name // $PR_DATA_PATH[$pi].author_login // $PR_DATA_PATH[$pi].author.login // \"unknown\"")
PR_ROWS+="<tr><td>#$(html_escape "$P_ID")</td><td>$(html_escape "$P_TITLE")</td><td>@$(html_escape "$P_AUTHOR")</td></tr>"
done
fi
# Add to dashboard rows
STATUS_COLOR="green"
[[ "$OPEN_ISSUES" -gt 10 ]] && STATUS_COLOR="orange"
[[ "$OPEN_ISSUES" -gt 20 ]] && STATUS_COLOR="red"
# ── Per-repo collapsible detail section ──
[[ -z "$ISSUE_ROWS" ]] && ISSUE_ROWS="<tr><td colspan=\"3\" class=\"empty\">无未关闭 Issue</td></tr>"
[[ -z "$PR_ROWS" ]] && PR_ROWS="<tr><td colspan=\"3\" class=\"empty\">无未合并 PR</td></tr>"
REPO_DETAILS+="<details><summary><span class=\"badge $GRADE_CLASS\">$HEALTH</span> $(html_escape "$repo") — Issue 开 $OPEN_ISSUES / PR 开 $OPEN_PRS</summary>
<div class=\"detail-grid\">
<div><h4>未关闭 Issue最多 $DETAIL_LIMIT 条)</h4>
<table class=\"detail-table\"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$ISSUE_ROWS</tbody></table></div>
<div><h4>未合并 PR最多 $DETAIL_LIMIT 条)</h4>
<table class=\"detail-table\"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$PR_ROWS</tbody></table></div>
</div>
</details>"
# Accumulate totals
TOTAL_ACTIVITY=$((TOTAL_ACTIVITY + OPEN_ISSUES + CLOSED_ISSUES + OPEN_PRS + MERGED_PRS))
TOTAL_OPEN_ISSUES=$((TOTAL_OPEN_ISSUES + OPEN_ISSUES))
TOTAL_OPEN_PRS=$((TOTAL_OPEN_PRS + OPEN_PRS))
TOTAL_MERGED_PRS=$((TOTAL_MERGED_PRS + MERGED_PRS))
HEALTH_SUM=$((HEALTH_SUM + HEALTH))
REL_DISPLAY="$LATEST_RELEASE"
[[ "$REL_DISPLAY" == "none" ]] && REL_DISPLAY="—"
DASHBOARD_ROWS+="<tr>
<td><a href=\"https://gitlink.org.cn/$ORG/$repo\">$repo</a></td>
<td><a href=\"https://gitlink.org.cn/$ORG/$repo\" target=\"_blank\">$(html_escape "$repo")</a></td>
<td>$OPEN_ISSUES</td>
<td>$CLOSED_ISSUES</td>
<td>$OPEN_PRS</td>
<td>$MERGED_PRS</td>
<td>$LATEST_RELEASE</td>
<td style=\"color:$STATUS_COLOR;font-weight:bold;\">$(
[[ "$OPEN_ISSUES" -le 5 ]] && echo "Healthy" || \
[[ "$OPEN_ISSUES" -le 15 ]] && echo "Moderate" || echo "Needs Attention"
)</td>
<td>$(html_escape "$REL_DISPLAY")</td>
<td><span class=\"badge $GRADE_CLASS\">$HEALTH</span> <span class=\"grade $GRADE_CLASS\">$GRADE_LABEL</span></td>
</tr>"
done
# ── Step 4: Generate HTML Dashboard ──────────────────────────────────
log_title "Generating Dashboard"
# Average health
AVG_HEALTH=0
[[ "${#REPO_LIST[@]}" -gt 0 ]] && AVG_HEALTH=$((HEALTH_SUM / ${#REPO_LIST[@]}))
log_step "Creating HTML dashboard..."
# ── Step 5: Generate HTML Dashboard ──────────────────────────────────
log_title "生成 HTML 看板"
log_step "写入 $OUTPUT_FILE..."
cat > "$OUTPUT_FILE" << 'HTMLEOF'
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
cat > "$OUTPUT_FILE" << HTMLEOF
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Repo Collaboration Dashboard</title>
<title>多仓库协同看板 - $ORG</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 30px; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { color: #666; font-size: 14px; margin-bottom: 8px; }
.card .value { font-size: 32px; font-weight: bold; color: #333; }
.card.blue .value { color: #2196F3; }
.card.green .value { color: #4CAF50; }
.card.orange .value { color: #FF9800; }
.card.purple .value { color: #9C27B0; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #2196F3; color: white; padding: 12px 16px; text-align: left; }
td { padding: 12px 16px; border-bottom: 1px solid #eee; }
tr:hover { background: #f9f9f9; }
a { color: #2196F3; text-decoration: none; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; background: #f0f2f5; padding: 24px; color: #1f2329; }
.container { max-width: 1280px; margin: 0 auto; }
h1 { color: #1f2329; margin-bottom: 6px; font-size: 26px; }
.timestamp { color: #8a919f; font-size: 13px; margin-bottom: 24px; }
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px; }
.card { background: #fff; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card h3 { color: #8a919f; font-size: 13px; margin-bottom: 8px; font-weight: 500; }
.card .value { font-size: 30px; font-weight: 700; }
.card.blue .value { color: #2f80ed; }
.card.orange .value { color: #f2994a; }
.card.purple .value { color: #9b51e0; }
.card.green .value { color: #27ae60; }
.card.teal .value { color: #17a2b8; }
h2 { font-size: 18px; margin: 24px 0 14px; color: #1f2329; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
th { background: #2f80ed; color: #fff; padding: 12px 16px; text-align: left; font-weight: 500; font-size: 14px; }
td { padding: 11px 16px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f7f9fc; }
a { color: #2f80ed; text-decoration: none; font-weight: 500; }
a:hover { text-decoration: underline; }
.timestamp { color: #999; font-size: 14px; margin-bottom: 20px; }
.badge { display: inline-block; min-width: 34px; text-align: center; padding: 2px 8px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px; }
.grade { font-size: 13px; margin-left: 4px; }
.excellent, .badge.excellent { background: #27ae60; } .grade.excellent { color: #27ae60; background: none; }
.good, .badge.good { background: #2f80ed; } .grade.good { color: #2f80ed; background: none; }
.moderate, .badge.moderate { background: #f2c94c; } .grade.moderate { color: #d9a400; background: none; }
.attention, .badge.attention { background: #f2994a; } .grade.attention { color: #f2994a; background: none; }
.critical, .badge.critical { background: #eb5757; } .grade.critical { color: #eb5757; background: none; }
details { background: #fff; border-radius: 10px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 15px; user-select: none; }
summary:hover { background: #f7f9fc; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 0 18px 18px; }
.detail-table th { background: #eef2f8; color: #4a5568; padding: 8px 12px; font-size: 13px; }
.detail-table td { padding: 8px 12px; font-size: 13px; }
.detail-table h4, .detail-grid h4 { font-size: 14px; margin-bottom: 8px; color: #4a5568; }
.empty { color: #b0b7c3; text-align: center; font-style: italic; }
@media (max-width: 900px) { .summary { grid-template-columns: repeat(2, 1fr); } .detail-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<h1>Multi-Repo Collaboration Dashboard</h1>
<p class="timestamp">Generated: TIMESTAMP_PLACEHOLDER | Organization: ORG_PLACEHOLDER</p>
<h1>多仓库协同看板</h1>
<p class="timestamp">生成时间:$TIMESTAMP 组织:$ORG</p>
<div class="summary">
<div class="card blue"><h3>Total Repos</h3><div class="value">REPOS_COUNT</div></div>
<div class="card orange"><h3>Open Issues</h3><div class="value">OPEN_ISSUES_COUNT</div></div>
<div class="card purple"><h3>Open PRs</h3><div class="value">OPEN_PRS_COUNT</div></div>
<div class="card green"><h3>Total Activity</h3><div class="value">TOTAL_ACTIVITY</div></div>
<div class="card blue"><h3>仓库总数</h3><div class="value">${#REPO_LIST[@]}</div></div>
<div class="card orange"><h3>未关闭 Issue</h3><div class="value">$TOTAL_OPEN_ISSUES</div></div>
<div class="card purple"><h3>未合并 PR</h3><div class="value">$TOTAL_OPEN_PRS</div></div>
<div class="card green"><h3>已合并 PR</h3><div class="value">$TOTAL_MERGED_PRS</div></div>
<div class="card teal"><h3>平均健康度</h3><div class="value">$AVG_HEALTH</div></div>
</div>
<h2>仓库概览</h2>
<table>
<thead><tr><th>Repository</th><th>Open Issues</th><th>Closed Issues</th><th>Open PRs</th><th>Merged PRs</th><th>Latest Release</th><th>Health</th></tr></thead>
<tbody>DASHBOARD_ROWS_PLACEHOLDER</tbody>
<thead><tr><th>仓库</th><th>未关闭 Issue</th><th>已关闭 Issue</th><th>未合并 PR</th><th>已合并 PR</th><th>最新 Release</th><th>健康度</th></tr></thead>
<tbody>$DASHBOARD_ROWS</tbody>
</table>
<h2>仓库详情</h2>
$REPO_DETAILS
</div>
</body>
</html>
HTMLEOF
# Replace placeholders using temp file approach for complex content
TEMP_HTML=$(mktemp)
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
log_ok "看板已保存: $OUTPUT_FILE"
while IFS= read -r line; do
line="${line//TIMESTAMP_PLACEHOLDER/$TIMESTAMP}"
line="${line//ORG_PLACEHOLDER/$ORG}"
line="${line//REPOS_COUNT/${#REPO_LIST[@]}}"
line="${line//OPEN_ISSUES_COUNT/$TOTAL_OPEN_ISSUES}"
line="${line//OPEN_PRS_COUNT/$TOTAL_OPEN_PRS}"
line="${line//TOTAL_ACTIVITY/$TOTAL_ISSUES}"
line="${line//DASHBOARD_ROWS_PLACEHOLDER/$DASHBOARD_ROWS}"
echo "$line"
done < "$OUTPUT_FILE" > "$TEMP_HTML"
mv "$TEMP_HTML" "$OUTPUT_FILE"
log_ok "Dashboard saved to: $OUTPUT_FILE"
# ── Step 5: Coordinated Release ──────────────────────────────────────
# ── Step 6: Coordinated Release ──────────────────────────────────────
if [[ -n "$RELEASE_TAG" ]]; then
log_title "Coordinated Release: $RELEASE_TAG"
log_title "协调发版: $RELEASE_TAG"
RELEASE_BODY="# Coordinated Release: $RELEASE_TAG
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] 将为以下 ${#REPO_LIST[@]} 个仓库创建 Release $RELEASE_TAG:"
for repo in "${REPO_LIST[@]}"; do
log_warn " [DRY RUN] $ORG/$repo$RELEASE_TAG"
done
else
RELEASE_OK=0
RELEASE_FAIL=0
for repo in "${REPO_LIST[@]}"; do
log_step "$ORG/$repo 创建 Release..."
RELEASE_RESULT=$(gl_run release +create --owner "$ORG" --repo "$repo" \
--tag "$RELEASE_TAG" --name "Release $RELEASE_TAG" \
--body "Coordinated release $RELEASE_TAG for $ORG/$repo" 2>&1) || true
## Repos Included
"
for repo in "${REPO_LIST[@]}"; do
log_step "Creating release for $ORG/$repo..."
RELEASE_BODY+="- $ORG/$repo"$'\n'
RELEASE_RESULT=$(gl_run release +create --owner "$ORG" --repo "$repo" \
--tag "$RELEASE_TAG" --name "Release $RELEASE_TAG" \
--body "Coordinated release $RELEASE_TAG for $ORG/$repo" 2>&1) || true
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
log_ok "Release $RELEASE_TAG created for $repo"
else
log_warn "Release creation failed for $repo (tag may already exist)"
fi
done
RELEASE_BODY+=$'\n'"---"$'\n'"*Coordinated release by gitlink-cli multi-repo-collab workflow*"
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
log_ok "Release $RELEASE_TAG 已在 $repo 创建"
RELEASE_OK=$((RELEASE_OK + 1))
else
log_warn "Release 创建失败: $repo (tag 可能已存在)"
RELEASE_FAIL=$((RELEASE_FAIL + 1))
fi
done
log_info "协调发版结果: 成功 $RELEASE_OK / 失败 $RELEASE_FAIL"
fi
fi
# ─────────────────────────────────────────────────────────────────────
log_title "Multi-Repo Dashboard Complete"
log_title "多仓库看板完成"
# ─────────────────────────────────────────────────────────────────────
echo -e "${GREEN}Summary:${NC}"
echo " Repos processed: ${#REPO_LIST[@]}"
echo " Total issues: $TOTAL_ISSUES (open: $TOTAL_OPEN_ISSUES)"
echo " Total PRs: $TOTAL_PRS (open: $TOTAL_OPEN_PRS)"
echo " Dashboard: $OUTPUT_FILE"
[[ -n "$RELEASE_TAG" ]] && echo " Coordinated release: $RELEASE_TAG"
echo -e "${GREEN}汇总:${NC}"
echo " 处理仓库: ${#REPO_LIST[@]}"
echo " 未关闭 Issue: $TOTAL_OPEN_ISSUES"
echo " 未合并 PR: $TOTAL_OPEN_PRS"
echo " 已合并 PR: $TOTAL_MERGED_PRS"
echo " 平均健康度: $AVG_HEALTH / 100"
echo " 看板文件: $OUTPUT_FILE"
[[ -n "$RELEASE_TAG" ]] && echo " 协调发版: $RELEASE_TAG$([[ "$DRY_RUN" == "true" ]] && echo " (dry-run)")"
echo ""
echo -e "${CYAN}Open dashboard:${NC}"
echo -e "${CYAN}打开看板:${NC}"
echo " start $OUTPUT_FILE # Windows"
echo " xdg-open $OUTPUT_FILE # Linux"
echo " open $OUTPUT_FILE # macOS"
echo ""