gitlink-cli/workflows/01-community-ops.ps1

502 lines
21 KiB
PowerShell
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ----------------------------------------------------------------
# Scenario 1: Community Operations Automation (定时批量链路)
# Flow: 收集周期数据 → 10类关键词分类 → 生成 Release Notes + 社区周报
# 遵循 gitlink-changelog skill 的收集→分类→发布流程
# 条目格式: - 描述 (#编号) (@作者) [分类]
# ----------------------------------------------------------------
#Requires -Version 5.1
param(
[string]$Owner = "",
[string]$Repo = "",
[int]$PeriodHours = 6,
[string]$ReleaseVersion = "",
[switch]$DryRun,
[switch]$Help
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 01-community-ops.ps1 [-Owner O] [-Repo R] [-PeriodHours N] [-ReleaseVersion TAG] [-DryRun]"
exit 0
}
Check-Auth
$r = Resolve-OwnerRepo $Owner $Repo
$Owner = $r.Owner; $Repo = $r.Repo
# ================================================================
# Phase 1: 时间窗口和基线
# ================================================================
Log-Title "Phase 1: Time Window"
$periodStart = (Get-Date).AddHours(-$PeriodHours)
$periodEnd = Get-Date
$periodStartStr = $periodStart.ToString("yyyy-MM-dd HH:mm")
$periodEndStr = $periodEnd.ToString("yyyy-MM-dd HH:mm")
Log-Step "Finding previous release..."
$prevTag = ""
$releasesJson = Invoke-GL release,+list,--owner,$Owner,--repo,$Repo,--limit,5
if ($releasesJson) {
try {
$relData = ($releasesJson | ConvertFrom-Json).data
$releases = if ($relData.releases) { @($relData.releases) } elseif ($relData -is [array]) { @($relData) } else { @() }
if ($releases.Count -gt 0) {
$prevTag = if ($releases[0].tag_name) { $releases[0].tag_name } else { "" }
}
} catch {}
}
if (-not $prevTag) { $prevTag = "initial" }
$newVersion = if ($ReleaseVersion) { $ReleaseVersion } else { "weekly-$(Get-Date -Format 'yyyyMMdd')" }
Log-Ok "Period: $periodStartStr ~ $periodEndStr"
Log-Ok "Release: $prevTag -> $newVersion"
# ================================================================
# Phase 2: 收集数据
# ================================================================
Log-Title "Phase 2: Collect Data"
# -- Commits --
Log-Step "Collecting commits..."
$commitCount = 0
$commitList = "(无 commit 数据)"
if ($prevTag -ne "initial") {
$compareJson = Invoke-GL api,GET,"/v1/$Owner/$Repo/compare/$prevTag...master"
if ($compareJson) {
try {
$cData = ($compareJson | ConvertFrom-Json).data
if ($cData.commits) {
$commits = @($cData.commits)
$commitCount = $commits.Count
$lines = @()
$show = [Math]::Min($commitCount, 50)
for ($i = 0; $i -lt $show; $i++) {
$msg = if ($commits[$i].commit.message) { ($commits[$i].commit.message -split "`n")[0] } else { "N/A" }
$author = if ($commits[$i].commit.author.name) { $commits[$i].commit.author.name } else { "unknown" }
$lines += "- $msg ($author)"
}
$commitList = $lines -join "`n"
}
} catch {}
}
}
Log-Ok "Commits: $commitCount"
# -- Merged PRs --
Log-Step "Collecting merged PRs..."
$prItems = @()
$prContributors = @{}
$prsJson = Invoke-GL pr,+list,--owner,$Owner,--repo,$Repo,--state,merged,--limit,100
if ($prsJson) {
try {
$prData = ($prsJson | ConvertFrom-Json).data
$prs = if ($prData.issues) { @($prData.issues) } elseif ($prData.pulls) { @($prData.pulls) } elseif ($prData -is [array]) { @($prData) } else { @() }
foreach ($pr in $prs) {
$prTitle = if ($pr.name) { $pr.name } elseif ($pr.subject) { $pr.subject } elseif ($pr.title) { $pr.title } else { "N/A" }
$prNum = if ($pr.pull_request_number) { $pr.pull_request_number } elseif ($pr.number) { $pr.number } else { "?" }
$prAuthor = if ($pr.author_login) { $pr.author_login } elseif ($pr.author.login) { $pr.author.login } else { "?" }
$prItems += "- $prTitle (#$prNum) (@$prAuthor)"
$prContributors[$prAuthor] = $true
}
} catch {}
}
$prCount = $prItems.Count
$prText = if ($prCount -gt 0) { ($prItems -join "`n") } else { "(本周期无 PR 合并)" }
Log-Ok "Merged PRs: $prCount"
# -- Issues (新增 + 关闭) --
Log-Step "Collecting issues..."
$bugLines = @()
$featLines = @()
$docLines = @()
$otherLines = @()
$issContributors = @{}
$issCount = 0
# Dedupe by issue index: the same issue can be returned by both the open
# and closed state queries, which would otherwise double-count it.
$seenIssues = @{}
$today = (Get-Date -Format "yyyy-MM-dd")
$yesterday = ((Get-Date).AddDays(-1)).ToString("yyyy-MM-dd")
foreach ($state in @("open","closed")) {
$issJson = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100
if (-not $issJson) { continue }
try {
$issData = ($issJson | ConvertFrom-Json).data
$issues = if ($issData.issues) { @($issData.issues) } elseif ($issData -is [array]) { @($issData) } else { @() }
foreach ($iss in $issues) {
$num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" }
if ($num -ne "?" -and $seenIssues.ContainsKey($num)) { continue }
if ($num -ne "?") { $seenIssues[$num] = $true }
$title = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "N/A" }
$desc = if ($iss.description) { $iss.description } else { "" }
$author = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "?" }
$created = if ($iss.created_at) { $iss.created_at } else { "" }
$closed = if ($iss.closed_at) { $iss.closed_at } else { "" }
$stateName = if ($iss.status.name) { $iss.status.name } elseif ($iss.state) { $iss.state } else { "?" }
# 时间筛选created 或 closed >= periodStart
$inPeriod = $false
if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} }
if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} }
if (-not $inPeriod) { continue }
$line = "- $title (#$num) (@$author)"
if ($stateName -match "关闭|closed") { $line += " [已关闭]" }
# 标题+描述 关键词分类 (10 个标准类别)
$combined = "$title $desc".ToLower()
if ($combined -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
$line += " [缺陷]"; $bugLines += $line
} elseif ($combined -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') {
$line += " [功能]"; $featLines += $line
} elseif ($combined -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') {
$line += " [文档]"; $docLines += $line
} elseif ($combined -match '(?i)test|测试|用例|覆盖|验证') {
$line += " [测试]"; $docLines += $line
} elseif ($combined -match '(?i)duplicate|重复|重复的') {
$line += " [重复]"; $docLines += $line
} elseif ($combined -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') {
$line += " [疑问]"; $docLines += $line
} elseif ($combined -match '(?i)help|协助|帮助|协作|请求帮助|互助') {
$line += " [协助]"; $docLines += $line
} elseif ($combined -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') {
$line += " [搁置]"; $docLines += $line
} elseif ($combined -match '(?i)task|todo|任务|待办|计划|安排') {
$line += " [任务]"; $docLines += $line
} elseif ($combined -match '(?i)support|兼容|环境|依赖|平台|适配') {
$line += " [支持]"; $docLines += $line
} else {
$line += " [其他]"; $otherLines += $line
}
if ($author -ne "?") { $issContributors[$author] = $true }
$issCount++
}
} catch {}
}
$bugSection = if ($bugLines.Count -gt 0) { ($bugLines -join "`n") } else { "_无_" }
$featSection = if ($featLines.Count -gt 0) { ($featLines -join "`n") } else { "_无_" }
$docSection = if ($docLines.Count -gt 0) { ($docLines -join "`n") } else { "_无_" }
$otherSection = if ($otherLines.Count -gt 0) { ($otherLines -join "`n") } else { "_无_" }
Log-Ok "Issues in period: $issCount"
# -- 动态收集标签 ID 映射 (与 01a-issue-triage.ps1 一致) --
Log-Step "Discovering label IDs..."
$tagIdMap = @{}
# 优先从项目 issue_tags API 获取
$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags"
if ($tagsApi) {
try {
$tData = ($tagsApi | ConvertFrom-Json).data
$tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() }
foreach ($t in $tagList) {
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
}
} catch {}
}
# 补充:从已有 issue 的 tags 字段收集
foreach ($state in @("open","closed")) {
$sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50
if ($sample) {
try {
$sData = ($sample | ConvertFrom-Json).data
$sIssues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() }
foreach ($iss in $sIssues) {
if ($iss.tags) {
foreach ($t in $iss.tags) {
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
}
}
}
} catch {}
}
}
$tagFound = @()
foreach ($k in $tagIdMap.Keys) { $tagFound += "$k($($tagIdMap[$k]))" }
if ($tagFound.Count -gt 0) { Log-Ok "Found tags: $($tagFound -join ' ')" } else { Log-Warn "No tags found in repo" }
# -- 打标签 & 分配责任人 (自动分类后的实际写入) --
# 收集需要处理的 Issue 信息 (编号, issue ID, 标签名, 作者)
$triageItems = @()
$seenTriage = @{}
foreach ($state in @("open","closed")) {
$issJson2 = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100
if (-not $issJson2) { continue }
try {
$issData2 = ($issJson2 | ConvertFrom-Json).data
$issues2 = if ($issData2.issues) { @($issData2.issues) } elseif ($issData2 -is [array]) { @($issData2) } else { @() }
foreach ($iss in $issues2) {
$num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" }
if ($num -eq "?" -or $seenTriage.ContainsKey($num)) { continue }
$created = if ($iss.created_at) { $iss.created_at } else { "" }
$closed = if ($iss.closed_at) { $iss.closed_at } else { "" }
$inPeriod = $false
if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} }
if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} }
if (-not $inPeriod) { continue }
$seenTriage[$num] = $true
$title2 = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "" }
$desc2 = if ($iss.description) { $iss.description } else { "" }
$author2 = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "" }
$issueId2 = if ($iss.id) { $iss.id } else { $num }
# 检查是否已有标签
$hasLabel = $false
if ($iss.tags -and @($iss.tags).Count -gt 0) { $hasLabel = $true }
# 关键词分类
$combined2 = "$title2 $desc2".ToLower()
$chosenLabel = ""
if ($combined2 -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
$chosenLabel = "缺陷"
} elseif ($combined2 -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') {
$chosenLabel = "功能"
} elseif ($combined2 -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') {
$chosenLabel = "文档"
} elseif ($combined2 -match '(?i)test|测试|用例|覆盖|验证') {
$chosenLabel = "测试"
} elseif ($combined2 -match '(?i)duplicate|重复|重复的') {
$chosenLabel = "重复"
} elseif ($combined2 -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') {
$chosenLabel = "疑问"
} elseif ($combined2 -match '(?i)help|协助|帮助|协作|请求帮助|互助') {
$chosenLabel = "协助"
} elseif ($combined2 -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') {
$chosenLabel = "搁置"
} elseif ($combined2 -match '(?i)task|todo|任务|待办|计划|安排') {
$chosenLabel = "任务"
} elseif ($combined2 -match '(?i)support|兼容|环境|依赖|平台|适配') {
$chosenLabel = "支持"
}
# 检查是否已有负责人
$hasAssignee = $false
if ($iss.assigned_to_id -and "$($iss.assigned_to_id)" -ne "0" -and "$($iss.assigned_to_id)" -ne "") { $hasAssignee = $true }
if ($iss.assigned_to -and $iss.assigned_to.login) { $hasAssignee = $true }
if ($chosenLabel -or -not $hasAssignee) {
$triageItems += @{ num=$num; id=$issueId2; title=$title2; desc=$desc2; author=$author2; label=$chosenLabel; hasLabel=$hasLabel; hasAssignee=$hasAssignee }
}
}
} catch {}
}
$labelOk = 0; $labelFail = 0; $labelSkip = 0
$assignOk = 0; $assignFail = 0; $assignSkip = 0
if ($triageItems.Count -gt 0) {
Log-Step "Applying labels and assignees to $($triageItems.Count) issue(s)..."
foreach ($item in $triageItems) {
# -- 打标签 --
if (-not $item.label) {
$labelSkip++
} elseif ($item.hasLabel) {
Log-Info " #$($item.num) already has label, skipping"
$labelSkip++
} elseif ($DryRun) {
Log-Warn " [DRY RUN] Would tag #$($item.num) with '$($item.label)'"
$labelSkip++
} else {
$tgtId = $tagIdMap[$item.label]
if (-not $tgtId) {
Log-Warn " #$($item.num): label '$($item.label)' not found in repo tags — create on website first"
$labelFail++
} else {
$bodyJson = "{`"issue_tag_ids`":[$tgtId]}"
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
if ($tagResult) {
try {
$tagOkFlag = (($tagResult | ConvertFrom-Json).ok -eq $true)
} catch { $tagOkFlag = $false }
if ($tagOkFlag) { Log-Ok " #$($item.num) tagged: $($item.label)"; $labelOk++ } else { Log-Warn " #$($item.num) tag failed"; $labelFail++ }
} else { Log-Warn " #$($item.num) tag failed (no response)"; $labelFail++ }
}
}
# -- 分配责任人 (默认分配给 Issue 作者) --
if ($item.hasAssignee) {
$assignSkip++
} elseif (-not $item.author -or $item.author -eq "?") {
Log-Info " #$($item.num): no author info, skipping assign"
$assignSkip++
} elseif ($DryRun) {
Log-Warn " [DRY RUN] Would assign #$($item.num) to @$($item.author)"
$assignSkip++
} else {
# Resolve login name to numeric user ID
$authorID = $null
$isNum = $false
try { [void][int]$item.author; $isNum = $true } catch {}
if ($isNum) {
$authorID = $item.author
} else {
$userJson = Invoke-GL api,GET,"/users/$($item.author)"
if ($userJson) {
try {
$userData = ($userJson | ConvertFrom-Json).data
if ($userData.id) { $authorID = [int]$userData.id }
elseif ($userData.user_id) { $authorID = [int]$userData.user_id }
} catch {}
}
}
if (-not $authorID) {
Log-Warn " #$($item.num): cannot resolve user ID for '@$($item.author)'"
$assignFail++
} else {
$bodyJson = "{`"assigner_ids`":[$authorID]}"
$assignResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
if ($assignResult) {
try {
$assignOkFlag = (($assignResult | ConvertFrom-Json).ok -eq $true)
} catch { $assignOkFlag = $false }
if ($assignOkFlag) { Log-Ok " #$($item.num) assigned to @$($item.author) (ID:$authorID)"; $assignOk++ } else { Log-Warn " #$($item.num) assign failed"; $assignFail++ }
} else { Log-Warn " #$($item.num) assign failed (no response)"; $assignFail++ }
}
}
}
Log-Ok "Labels: $labelOk applied, $labelFail failed, $labelSkip skipped"
Log-Ok "Assign: $assignOk applied, $assignFail failed, $assignSkip skipped"
} else {
Log-Info "No new issues require triage in this period"
}
# -- 贡献者汇总 --
$allContribs = @($prContributors.Keys; $issContributors.Keys) | Select-Object -Unique | Sort-Object
$contribText = if ($allContribs.Count -gt 0) { ($allContribs | ForEach-Object { "- @$_" }) -join "`n" } else { "(无活跃贡献者)" }
# ================================================================
# Phase 3: 生成 Release Notes (changelog skill 模板)
# ================================================================
Log-Title "Phase 3: Generate Release Notes"
$releaseBody = @"
# 🎉 Release $newVersion
## 📊
- ****: $periodStartStr ~ $periodEndStr
- ** PR**: $prCount
- **Issue **: $issCount /
- **Commits**: $commitCount
## 📝 Issue
### 🐛 / Bug
$bugSection
### / Feature
$featSection
### 📖 /
$docSection
### 💡
$otherSection
## 🔀 PR
$prText
## 🙏
$contribText
---
****: https://www.gitlink.org.cn/$Owner/$Repo/compare/$prevTag...$newVersion
*Auto-generated by gitlink-cli community-ops workflow (gitlink-changelog skill)*
"@
Write-Host $releaseBody
Write-Host ""
# ================================================================
# Phase 4: 发布 Release
# ================================================================
Log-Title "Phase 4: Publish Release"
if ($DryRun) {
Log-Warn "[DRY RUN] Would create release: $newVersion"
} else {
Log-Step "Creating release $newVersion..."
$relResult = Invoke-GL release,+create,--owner,$Owner,--repo,$Repo,--tag,$newVersion,--name,"Release $newVersion",--body,$releaseBody
if ($relResult -and (Get-JsonOk ($relResult | ConvertFrom-Json))) {
Log-Ok "Release $newVersion published!"
Log-Info "View: https://www.gitlink.org.cn/$Owner/$Repo/releases"
} else {
Log-Warn "Release may have failed (tag might exist)"
}
}
# ================================================================
# Phase 5: 发布社区周报 (覆盖更新同一天)
# ================================================================
Log-Title "Phase 5: Publish Weekly Report"
$wikiTitle = "社区周报"
$wikiBody = @"
# - $Owner/$Repo
**$periodStartStr ~ $periodEndStr**
---
## 📊
- PR: **$prCount**
- Issue : **$issCount**
- : $($allContribs.Count)
## 🔀 PR
$prText
## 📝 Issue
$bugSection
$featSection
$docSection
$otherSection
---
*Auto-generated by gitlink-cli | $periodStartStr ~ $periodEndStr | Next report in ~$PeriodHours h*
"@
if ($DryRun) {
Log-Warn "[DRY RUN] Would publish Wiki"
} else {
Log-Step "Publishing to Wiki..."
# 先尝试覆盖更新,不存在则新建
$wikiResult = Invoke-GL wiki,+update,--owner,$Owner,--repo,$Repo,--title,$wikiTitle,--cover,$wikiBody
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
Log-Ok "Weekly report updated on Wiki"
} else {
Log-Info "Page not found, creating new..."
$wikiResult = Invoke-GL wiki,+create,--owner,$Owner,--repo,$Repo,--title,$wikiTitle,--content,$wikiBody
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
Log-Ok "Weekly report created on Wiki"
} else {
Log-Warn "Wiki publish failed"
}
}
}
Log-Title "Complete"
Write-Host " Release: $newVersion" -ForegroundColor Green
Write-Host " PRs merged: $prCount" -ForegroundColor Green
Write-Host " Issues: $issCount" -ForegroundColor Green
Write-Host " Labels: $labelOk applied / $labelFail failed / $labelSkip skipped" -ForegroundColor Green
Write-Host " Assigned: $assignOk applied / $assignFail failed / $assignSkip skipped" -ForegroundColor Green
Write-Host " Contributors: $($allContribs.Count)" -ForegroundColor Green
Write-Host " Wiki: $wikiTitle" -ForegroundColor Green