Merge origin/master into zzx_branch, local wins on conflicts
This commit is contained in:
commit
de9bca30bb
|
|
@ -1,13 +1,18 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1: Community Operations Automation
|
||||
# Flow: Issue auto-classify -> Assign responsible -> Weekly report -> Release notes
|
||||
# Scenario 1: Community Operations Automation (定时批量链路)
|
||||
# Flow: 收集周期数据 → 10类关键词分类 → 生成 Release Notes + 社区周报
|
||||
# 遵循 gitlink-changelog skill 的收集→分类→发布流程
|
||||
# 条目格式: - 描述 (#编号) (@作者) [分类]
|
||||
=======
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[int]$WeeksAgo = 0,
|
||||
[int]$PeriodHours = 6,
|
||||
[string]$ReleaseVersion = "",
|
||||
[switch]$DryRun,
|
||||
[switch]$Help
|
||||
)
|
||||
|
|
@ -16,7 +21,8 @@ $ErrorActionPreference = "Stop"
|
|||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01-community-ops.ps1 -Owner OWNER -Repo REPO [-WeeksAgo N] [-DryRun]"
|
||||
Write-Host "Usage: powershell 01-community-ops.ps1 [-Owner O] [-Repo R] [-PeriodHours N] [-ReleaseVersion TAG] [-DryRun]"
|
||||
=======
|
||||
exit 0
|
||||
}
|
||||
|
||||
|
|
@ -24,125 +30,168 @@ Check-Auth
|
|||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
$BugKw = @('bug','error','crash','fault','fix')
|
||||
$FeatureKw = @('feature','enhancement','add','support','request')
|
||||
$QuestionKw = @('how','question','help')
|
||||
$DocsKw = @('doc','readme','guide','tutorial','example')
|
||||
# ================================================================
|
||||
# Phase 1: 时间窗口和基线
|
||||
# ================================================================
|
||||
Log-Title "Phase 1: Time Window"
|
||||
=======
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Phase 1: Issue Auto-Classification"
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
Log-Step "Fetching open issues..."
|
||||
$issuesJson = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100"
|
||||
if (-not $issuesJson) { Log-Err "Failed to fetch issues"; exit 1 }
|
||||
$periodStart = (Get-Date).AddHours(-$PeriodHours)
|
||||
$periodEnd = Get-Date
|
||||
$periodStartStr = $periodStart.ToString("yyyy-MM-dd HH:mm")
|
||||
$periodEndStr = $periodEnd.ToString("yyyy-MM-dd HH:mm")
|
||||
|
||||
$issues = @($issuesJson.data.issues)
|
||||
$issueCount = $issues.Count
|
||||
Log-Ok "Found $issueCount open issues"
|
||||
|
||||
$BugIds = @(); $FeatureIds = @(); $QuestionIds = @(); $DocsIds = @()
|
||||
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 { "" }
|
||||
=======
|
||||
|
||||
if ($issueCount -gt 0) {
|
||||
Log-Step "Classifying issues by content..."
|
||||
foreach ($issue in $issues) {
|
||||
$id = $issue.id
|
||||
$title = if ($issue.subject) { $issue.subject } elseif ($issue.title) { $issue.title } else { "" }
|
||||
$desc = if ($issue.description) { $issue.description } else { "" }
|
||||
$combined = "$title $desc".ToLower()
|
||||
|
||||
$classified = $false
|
||||
foreach ($kw in $BugKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $BugIds += $id; Log-Info " #$id -> BUG: $title"; $classified = $true; break }
|
||||
}
|
||||
if (-not $classified) {
|
||||
foreach ($kw in $FeatureKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $FeatureIds += $id; Log-Info " #$id -> FEATURE: $title"; $classified = $true; break }
|
||||
} 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"
|
||||
}
|
||||
}
|
||||
if (-not $classified) {
|
||||
foreach ($kw in $QuestionKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $QuestionIds += $id; Log-Info " #$id -> QUESTION: $title"; $classified = $true; break }
|
||||
}
|
||||
}
|
||||
if (-not $classified) {
|
||||
foreach ($kw in $DocsKw) {
|
||||
if ($combined -match [regex]::Escape($kw)) { $DocsIds += $id; Log-Info " #$id -> DOCS: $title"; $classified = $true; break }
|
||||
}
|
||||
}
|
||||
if (-not $classified) { Log-Info " #$id -> UNCATEGORIZED: $title" }
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Divider
|
||||
Log-Info "Classification summary:"
|
||||
Log-Info " Bugs: $($BugIds.Count)"
|
||||
Log-Info " Features: $($FeatureIds.Count)"
|
||||
Log-Info " Questions: $($QuestionIds.Count)"
|
||||
Log-Info " Docs: $($DocsIds.Count)"
|
||||
|
||||
$labelGroups = @(
|
||||
@{ Ids = $BugIds; Label = "bug" },
|
||||
@{ Ids = $FeatureIds; Label = "feature" },
|
||||
@{ Ids = $QuestionIds; Label = "question" },
|
||||
@{ Ids = $DocsIds; Label = "documentation" }
|
||||
)
|
||||
foreach ($g in $labelGroups) {
|
||||
if ($g.Ids.Count -gt 0) {
|
||||
Log-Step "Labeling $($g.Label) issues..."
|
||||
foreach ($id in $g.Ids) {
|
||||
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Repo, "--number", $id, "--labels", $g.Label | Out-Null
|
||||
}
|
||||
Log-Ok "Labeled $($g.Ids.Count) $($g.Label) issues"
|
||||
}
|
||||
}
|
||||
}
|
||||
Log-Ok "Commits: $commitCount"
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Phase 2: Assign Responsible Persons"
|
||||
# ----------------------------------------------------------------
|
||||
<<<<<<< HEAD
|
||||
# -- 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.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
|
||||
|
||||
Log-Step "Fetching repo members..."
|
||||
$membersJson = Invoke-GL "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "50"
|
||||
$members = @()
|
||||
if ($membersJson) {
|
||||
$md = $membersJson.data
|
||||
if ($md.members) { $members = @($md.members) }
|
||||
elseif ($md -is [array]) { $members = $md }
|
||||
}
|
||||
|
||||
$memberLogins = @()
|
||||
foreach ($m in $members) {
|
||||
$login = if ($m.login) { $m.login } elseif ($m.username) { $m.username } else { $null }
|
||||
if ($login) { $memberLogins += $login }
|
||||
}
|
||||
|
||||
if ($memberLogins.Count -gt 0) {
|
||||
$assignIds = @($BugIds + $FeatureIds)
|
||||
if ($assignIds.Count -gt 0) {
|
||||
Log-Step "Assigning issues to members (round-robin)..."
|
||||
$idx = 0
|
||||
foreach ($id in $assignIds) {
|
||||
$assignee = $memberLogins[$idx % $memberLogins.Count]
|
||||
$bodyJson = "{`"assigned_to_id`": `"$assignee`"}"
|
||||
Invoke-GL "api", "PATCH", "/v1/$Owner/$Repo/issues/$id", "--body", $bodyJson | Out-Null
|
||||
Log-Info " Assigned #$id -> @$assignee"
|
||||
$idx++
|
||||
}
|
||||
Log-Ok "Assignment complete"
|
||||
}
|
||||
} else {
|
||||
Log-Warn "No repo members found, skipping assignment"
|
||||
} catch {}
|
||||
}
|
||||
$prCount = $prItems.Count
|
||||
$prText = if ($prCount -gt 0) { ($prItems -join "`n") } else { "(本周期无 PR 合并)" }
|
||||
Log-Ok "Merged PRs: $prCount"
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
Log-Title "Phase 3: Generate Community Weekly Report"
|
||||
# ----------------------------------------------------------------
|
||||
# -- Issues (新增 + 关闭) --
|
||||
Log-Step "Collecting issues..."
|
||||
$bugLines = @()
|
||||
$featLines = @()
|
||||
$docLines = @()
|
||||
$otherLines = @()
|
||||
$issContributors = @{}
|
||||
$issCount = 0
|
||||
$today = (Get-Date -Format "yyyy-MM-dd")
|
||||
$yesterday = ((Get-Date).AddDays(-1)).ToString("yyyy-MM-dd")
|
||||
|
||||
$weekStart = (Get-Date).AddDays(-$WeeksAgo * 7).ToString("yyyy-MM-dd")
|
||||
$weekEnd = Get-DateToday
|
||||
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 { "?" }
|
||||
$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 { "?" }
|
||||
|
||||
Log-Step "Collecting weekly data (week of $weekStart)..."
|
||||
# 时间筛选: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 }
|
||||
|
||||
<<<<<<< HEAD
|
||||
$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"
|
||||
=======
|
||||
$closedJson = Invoke-GL "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
|
||||
$closedCount = if ($closedJson) { @($closedJson.data.issues).Count } else { 0 }
|
||||
|
||||
|
|
@ -156,10 +205,18 @@ if ($mergedJson) {
|
|||
elseif ($d -is [array]) { $mergedData = $d }
|
||||
$mergedCount = $mergedData.Count
|
||||
}
|
||||
>>>>>>> master
|
||||
|
||||
$newIssuesCount = $issueCount
|
||||
$totalClassified = $BugIds.Count + $FeatureIds.Count + $QuestionIds.Count + $DocsIds.Count
|
||||
# -- 贡献者汇总 --
|
||||
$allContribs = @($prContributors.Keys; $issContributors.Keys) | Select-Object -Unique | Sort-Object
|
||||
$contribText = if ($allContribs.Count -gt 0) { ($allContribs | ForEach-Object { "- @$_" }) -join "`n" } else { "(无活跃贡献者)" }
|
||||
|
||||
<<<<<<< HEAD
|
||||
# ================================================================
|
||||
# Phase 3: 生成 Release Notes (changelog skill 模板)
|
||||
# ================================================================
|
||||
Log-Title "Phase 3: Generate Release Notes"
|
||||
=======
|
||||
$reportTitle = "Community Weekly Report: $weekStart ~ $weekEnd"
|
||||
$reportBody = "# $reportTitle" + "`n`n"
|
||||
$reportBody += "## 概览" + "`n"
|
||||
|
|
@ -267,11 +324,63 @@ $reportBody += "- 自动分类并打标 Issue: **$totalClassified** 条" + "`n"
|
|||
$reportBody += "- 已为 Bug/Feature 类 Issue 指派负责人" + "`n`n"
|
||||
$reportBody += "---" + "`n"
|
||||
$reportBody += "*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
>>>>>>> master
|
||||
|
||||
Log-Ok "Weekly report generated"
|
||||
$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 ""
|
||||
Write-Host $reportBody
|
||||
|
||||
<<<<<<< HEAD
|
||||
# ================================================================
|
||||
# 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)"
|
||||
=======
|
||||
Log-Step "Publishing weekly report to Wiki..."
|
||||
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $reportTitle, "--content", $reportBody
|
||||
if ($wikiResult -and $wikiResult.ok) { Log-Ok "Weekly report published to Wiki" } else { Log-Warn "Wiki publish may have failed" }
|
||||
|
|
@ -294,20 +403,69 @@ if ($mergedCount -gt 0) {
|
|||
$prTitle = if ($mergedData[$i].subject) { $mergedData[$i].subject } elseif ($mergedData[$i].title) { $mergedData[$i].title } else { "" }
|
||||
$prNum = if ($mergedData[$i].id) { $mergedData[$i].id } elseif ($mergedData[$i].number) { $mergedData[$i].number } else { "" }
|
||||
$releaseBody += "`n- #$prNum $prTitle"
|
||||
>>>>>>> master
|
||||
}
|
||||
}
|
||||
|
||||
$releaseBody += "`n`n## Closed Issues ($closedCount)"
|
||||
if ($closedCount -gt 0) {
|
||||
$closedIssues = @($closedJson.data.issues)
|
||||
$limit = [Math]::Min($closedCount, 10)
|
||||
for ($i = 0; $i -lt $limit; $i++) {
|
||||
$issueTitle = if ($closedIssues[$i].subject) { $closedIssues[$i].subject } elseif ($closedIssues[$i].title) { $closedIssues[$i].title } else { "" }
|
||||
$issueNum = if ($closedIssues[$i].number) { $closedIssues[$i].number } elseif ($closedIssues[$i].id) { $closedIssues[$i].id } else { "" }
|
||||
if ($issueTitle) { $releaseBody += "`n- #$issueNum $issueTitle" }
|
||||
# ================================================================
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
Log-Title "Complete"
|
||||
Write-Host " Release: $newVersion" -ForegroundColor Green
|
||||
Write-Host " PRs merged: $prCount" -ForegroundColor Green
|
||||
Write-Host " Issues: $issCount" -ForegroundColor Green
|
||||
Write-Host " Contributors: $($allContribs.Count)" -ForegroundColor Green
|
||||
Write-Host " Wiki: $wikiTitle" -ForegroundColor Green
|
||||
=======
|
||||
$releaseBody += "`n`n---`n*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
|
||||
Log-Step "Creating release: $tagName..."
|
||||
|
|
@ -323,3 +481,4 @@ Write-Host " Closed this week: $closedCount" -ForegroundColor Green
|
|||
Write-Host " Merged PRs: $mergedCount" -ForegroundColor Green
|
||||
Write-Host " Weekly report: Published to Wiki" -ForegroundColor Green
|
||||
Write-Host " Release notes: $tagName" -ForegroundColor Green
|
||||
>>>>>>> master
|
||||
|
|
|
|||
|
|
@ -1,302 +1,327 @@
|
|||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Scenario 1: Community Operations Automation
|
||||
# Flow: Issue auto-classify → Assign responsible → Weekly report → Release notes
|
||||
# ================================================================
|
||||
# Scenario 1: Community Operations (定时批量链路, 48h周期)
|
||||
# Flow: 收集周期数据 → 分类 → 生成 Release Notes → 发布 Release + Wiki
|
||||
#
|
||||
# Commands/Skills chained:
|
||||
# 1. issue +list -- fetch open issues
|
||||
# 2. issue +view -- read issue details
|
||||
# 3. issue +batch-label -- add classification labels
|
||||
# 4. issue +batch-assign -- assign responsible person
|
||||
# 5. pr +list -- collect merged PRs for weekly report
|
||||
# 6. wiki +create -- publish community weekly report
|
||||
# 7. release +create -- publish release notes
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# 遵循 gitlink-changelog skill 的收集→分类→发布流程
|
||||
# 条目格式: - 描述 (#编号) (@作者)
|
||||
# ================================================================
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
PERIOD_HOURS=48
|
||||
RELEASE_VERSION=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --owner OWNER --repo REPO [--week WEEKS_AGO] [--dry-run]"
|
||||
echo "Usage: $0 [--owner O] [--repo R] [--period-hours N] [--release-version TAG] [--dry-run]"
|
||||
echo ""
|
||||
echo " --owner OWNER Repository owner (org or user)"
|
||||
echo " --repo REPO Repository name"
|
||||
echo " --week N Generate report for N weeks ago (default: 0 = this week)"
|
||||
echo " --dry-run Preview actions without executing"
|
||||
echo " 定时链路 — 收集周期内数据,按 gitlink-changelog skill 生成 Release Notes 和社区周报。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
WEEKS_AGO=0
|
||||
DRY_RUN=false
|
||||
OWNER=""
|
||||
REPO=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--week) WEEKS_AGO="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--period-hours) PERIOD_HOURS="$2"; shift 2 ;;
|
||||
--release-version) RELEASE_VERSION="$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
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 1: Issue Auto-Classification"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# ================================================================
|
||||
# Phase 1: 确定时间窗口和基线
|
||||
# ================================================================
|
||||
log_title "Phase 1: Time Window"
|
||||
|
||||
log_step "Fetching open issues..."
|
||||
ISSUES_JSON=$(gl_check issue +list --owner "$OWNER" --repo "$REPO" --state open --limit 100)
|
||||
ISSUE_COUNT=$(echo "$ISSUES_JSON" | jq '.data.issues | length')
|
||||
log_ok "Found $ISSUE_COUNT open issues"
|
||||
PERIOD_START=$(date -d "$PERIOD_HOURS hours ago" +"%Y-%m-%d %H:%M" 2>/dev/null || date -v-${PERIOD_HOURS}H +"%Y-%m-%d %H:%M")
|
||||
PERIOD_END=$(date +"%Y-%m-%d %H:%M")
|
||||
PERIOD_START_TS=$(date -d "$PERIOD_START" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M" "$PERIOD_START" +%s)
|
||||
|
||||
if [[ "$ISSUE_COUNT" -gt 0 ]]; then
|
||||
# Classify each issue by keywords in title/description
|
||||
declare -A LABEL_MAP=()
|
||||
BUG_IDS=()
|
||||
FEATURE_IDS=()
|
||||
QUESTION_IDS=()
|
||||
DOCS_IDS=()
|
||||
# 找上一版本
|
||||
log_step "Finding previous release..."
|
||||
PREV_TAG=""
|
||||
PREV_DATE=""
|
||||
RELEASES_JSON=$(gl_run release +list --owner "$OWNER" --repo "$REPO" --limit 5 2>/dev/null || true)
|
||||
if [[ -n "$RELEASES_JSON" ]]; then
|
||||
PREV_TAG=$(echo "$RELEASES_JSON" | jq -r '(.data.releases // .data // [])[0].tag_name // ""' 2>/dev/null || echo "")
|
||||
PREV_DATE=$(echo "$RELEASES_JSON" | jq -r '(.data.releases // .data // [])[0].created_at // ""' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
log_step "Classifying issues by content..."
|
||||
if [[ -z "$PREV_TAG" || "$PREV_TAG" == "null" ]]; then
|
||||
log_info "No previous release — this will be the first"
|
||||
PREV_TAG="initial"
|
||||
fi
|
||||
|
||||
for i in $(seq 0 $((ISSUE_COUNT - 1))); do
|
||||
ISSUE_ID=$(echo "$ISSUES_JSON" | jq -r ".data.issues[$i].id")
|
||||
ISSUE_TITLE=$(echo "$ISSUES_JSON" | jq -r ".data.issues[$i].subject // .data.issues[$i].title // \"\"")
|
||||
ISSUE_DESC=$(echo "$ISSUES_JSON" | jq -r ".data.issues[$i].description // \"\"" | head -c 500)
|
||||
COMBINED="$ISSUE_TITLE $ISSUE_DESC"
|
||||
NEW_VERSION="${RELEASE_VERSION:-weekly-$(date +%Y%m%d)}"
|
||||
log_ok "Period: ${PERIOD_START} ~ ${PERIOD_END}"
|
||||
log_ok "Release: ${PREV_TAG} → ${NEW_VERSION}"
|
||||
|
||||
# Keyword-based classification
|
||||
if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|修复|错误|异常|崩溃'; then
|
||||
BUG_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → BUG: $ISSUE_TITLE"
|
||||
elif echo "$COMBINED" | grep -qiE 'feature|新增|建议|enhancement|add|support|功能'; then
|
||||
FEATURE_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → FEATURE: $ISSUE_TITLE"
|
||||
elif echo "$COMBINED" | grep -qiE 'how|怎么|如何|question|help|\?|?'; then
|
||||
QUESTION_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → QUESTION: $ISSUE_TITLE"
|
||||
elif echo "$COMBINED" | grep -qiE 'doc|文档|readme|说明|guide'; then
|
||||
DOCS_IDS+=("$ISSUE_ID")
|
||||
log_info " #$ISSUE_ID → DOCS: $ISSUE_TITLE"
|
||||
# ================================================================
|
||||
# Phase 2: 收集数据 (changelog skill: collect-data)
|
||||
# ================================================================
|
||||
log_title "Phase 2: Collect Data"
|
||||
|
||||
# ── Commits ──
|
||||
log_step "Collecting commits..."
|
||||
COMMIT_LIST=""
|
||||
COMMIT_COUNT=0
|
||||
if [[ "$PREV_TAG" != "initial" ]]; then
|
||||
COMPARE_JSON=$(gl_run api GET "/v1/$OWNER/$REPO/compare/$PREV_TAG...master" 2>/dev/null || true)
|
||||
if [[ -n "$COMPARE_JSON" ]]; then
|
||||
COMMIT_COUNT=$(echo "$COMPARE_JSON" | jq '.data.total_commits // 0' 2>/dev/null || echo "0")
|
||||
COMMIT_LIST=$(echo "$COMPARE_JSON" | jq -r '[.data.commits[]? | "- \(.commit.message | split("\n")[0]) (\(.commit.author.name // "unknown"))"] | .[:50] | join("\n")' 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
[[ -z "$COMMIT_LIST" ]] && COMMIT_LIST="(无 commit 数据)"
|
||||
log_ok "Commits: $COMMIT_COUNT"
|
||||
|
||||
# ── Merged PRs (用临时文件避免 pipefail 静默退出) ──
|
||||
log_step "Collecting merged PRs..."
|
||||
PR_ITEMS=""
|
||||
PR_COUNT=0
|
||||
PR_CONTRIBUTORS=""
|
||||
PR_TMP=$(mktemp)
|
||||
PRS_JSON=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100 2>/dev/null || true)
|
||||
if [[ -n "$PRS_JSON" ]]; then
|
||||
echo "$PRS_JSON" | jq -r '(.data.issues // .data.pulls // .data // [])[]? | "\(.subject // .title // "N/A")\t\(.pull_request_number // .number // .id)\t\(.author_login // .author.login // "?")"' 2>/dev/null > "$PR_TMP" || true
|
||||
while IFS=$'\t' read -r pr_title pr_num pr_author || [[ -n "$pr_title" ]]; do
|
||||
PR_ITEMS+="- ${pr_title} (#${pr_num}) (@${pr_author})"$'\n'
|
||||
PR_CONTRIBUTORS+="@${pr_author} "
|
||||
PR_COUNT=$((PR_COUNT + 1)) || true
|
||||
done < "$PR_TMP"
|
||||
fi
|
||||
rm -f "$PR_TMP"
|
||||
[[ -z "$PR_ITEMS" ]] && PR_ITEMS="(本周期无 PR 合并)"
|
||||
log_ok "Merged PRs in period: $PR_COUNT"
|
||||
|
||||
# ── Issues (新增 + 关闭) — 用临时文件避免子 shell 变量丢失 ──
|
||||
log_step "Collecting issues..."
|
||||
|
||||
ISS_FEAT_FILE=$(mktemp)
|
||||
ISS_BUG_FILE=$(mktemp)
|
||||
ISS_DOC_FILE=$(mktemp)
|
||||
ISS_OTHER_FILE=$(mktemp)
|
||||
ISS_AUTHORS_FILE=$(mktemp)
|
||||
ISS_COUNT_FILE=$(mktemp)
|
||||
echo "0" > "$ISS_COUNT_FILE"
|
||||
|
||||
for state in open closed; do
|
||||
ISS_JSON=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 100 2>/dev/null || true)
|
||||
[[ -z "$ISS_JSON" ]] && continue
|
||||
echo "$ISS_JSON" | jq -c '(.data.issues // .data // [])[]' 2>/dev/null | while read -r issue; do
|
||||
NUM=$(echo "$issue" | jq -r '.project_issues_index // .number // .id')
|
||||
TITLE=$(echo "$issue" | jq -r '.subject // .title // "N/A"')
|
||||
DESC=$(echo "$issue" | jq -r '.description // ""')
|
||||
AUTHOR=$(echo "$issue" | jq -r '.author.login // .author.username // "?"')
|
||||
CREATED=$(echo "$issue" | jq -r '.created_at // ""')
|
||||
CLOSED=$(echo "$issue" | jq -r '.closed_at // ""')
|
||||
STATE=$(echo "$issue" | jq -r '.status.name // .state // "?"')
|
||||
|
||||
# 时间筛选:created 或 closed >= PERIOD_START
|
||||
IN_PERIOD=false
|
||||
if [[ -n "$CREATED" ]]; then CT_TS=$(date -d "$CREATED" +%s 2>/dev/null || echo "0"); [[ "$CT_TS" -ge "$PERIOD_START_TS" ]] && IN_PERIOD=true; fi
|
||||
[[ "$IN_PERIOD" != "true" && -n "$CLOSED" ]] && { CL_TS=$(date -d "$CLOSED" +%s 2>/dev/null || echo "0"); [[ "$CL_TS" -ge "$PERIOD_START_TS" ]] && IN_PERIOD=true; }
|
||||
[[ "$IN_PERIOD" != "true" ]] && continue
|
||||
|
||||
LINE="- ${TITLE} (#${NUM}) (@${AUTHOR})"
|
||||
[[ "$STATE" =~ 关闭|closed ]] && LINE+=" [已关闭]"
|
||||
|
||||
# 关键词分类 — 标题 + 描述 都参与匹配, 10 个标准类别
|
||||
COMBINED=$(echo "$TITLE $DESC" | tr '[:upper:]' '[:lower:]')
|
||||
if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障'; then
|
||||
CATEGORY="缺陷"
|
||||
echo "${LINE} [缺陷]" >> "$ISS_BUG_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request'; then
|
||||
CATEGORY="功能"
|
||||
echo "${LINE} [功能]" >> "$ISS_FEAT_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'doc|readme|guide|wiki|tutorial|文档|说明|教程|手册'; then
|
||||
CATEGORY="文档"
|
||||
echo "${LINE} [文档]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'test|测试|用例|覆盖|验证'; then
|
||||
CATEGORY="测试"
|
||||
echo "${LINE} [测试]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'duplicate|重复|重复的|和.*一样'; then
|
||||
CATEGORY="重复"
|
||||
echo "${LINE} [重复]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'question|疑问|不确定|讨论|澄清|是否|可否'; then
|
||||
CATEGORY="疑问"
|
||||
echo "${LINE} [疑问]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'help|协助|帮助|协作|请求帮助|互助'; then
|
||||
CATEGORY="协助"
|
||||
echo "${LINE} [协助]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay'; then
|
||||
CATEGORY="搁置"
|
||||
echo "${LINE} [搁置]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'task|todo|任务|待办|计划|安排'; then
|
||||
CATEGORY="任务"
|
||||
echo "${LINE} [任务]" >> "$ISS_DOC_FILE"
|
||||
elif echo "$COMBINED" | grep -qiE 'support|兼容|环境|依赖|平台|适配'; then
|
||||
CATEGORY="支持"
|
||||
echo "${LINE} [支持]" >> "$ISS_DOC_FILE"
|
||||
else
|
||||
log_info " #$ISSUE_ID → UNCATEGORIZED: $ISSUE_TITLE"
|
||||
echo "${LINE} [其他]" >> "$ISS_OTHER_FILE"
|
||||
fi
|
||||
echo "@${AUTHOR}" >> "$ISS_AUTHORS_FILE"
|
||||
# 计数
|
||||
CNT=$(cat "$ISS_COUNT_FILE"); echo $((CNT + 1)) > "$ISS_COUNT_FILE"
|
||||
done
|
||||
done
|
||||
|
||||
divider
|
||||
log_info "Classification summary:"
|
||||
log_info " Bugs: ${#BUG_IDS[@]}"
|
||||
log_info " Features: ${#FEATURE_IDS[@]}"
|
||||
log_info " Questions: ${#QUESTION_IDS[@]}"
|
||||
log_info " Docs: ${#DOCS_IDS[@]}"
|
||||
ISSUE_FEATURES=$(cat "$ISS_FEAT_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_BUGS=$(cat "$ISS_BUG_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_DOCS=$(cat "$ISS_DOC_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_OTHER=$(cat "$ISS_OTHER_FILE" 2>/dev/null || echo "")
|
||||
ISSUE_COUNT=$(cat "$ISS_COUNT_FILE" 2>/dev/null || echo "0")
|
||||
ISSUE_CONTRIBUTORS=$(sort -u "$ISS_AUTHORS_FILE" 2>/dev/null | tr '\n' ' ' || echo "")
|
||||
rm -f "$ISS_FEAT_FILE" "$ISS_BUG_FILE" "$ISS_DOC_FILE" "$ISS_OTHER_FILE" "$ISS_AUTHORS_FILE" "$ISS_COUNT_FILE"
|
||||
|
||||
# Apply labels via batch-label
|
||||
if [[ ${#BUG_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling bug issues..."
|
||||
for bid in "${BUG_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$bid" --labels "bug" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#BUG_IDS[@]} bug issues"
|
||||
fi
|
||||
log_ok "Issues in period: $ISSUE_COUNT"
|
||||
|
||||
if [[ ${#FEATURE_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling feature issues..."
|
||||
for fid in "${FEATURE_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$fid" --labels "feature" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#FEATURE_IDS[@]} feature issues"
|
||||
fi
|
||||
# ── 贡献者汇总 ──
|
||||
ALL_CONTRIBUTORS=$(echo "$PR_CONTRIBUTORS $ISSUE_CONTRIBUTORS" | tr ' ' '\n' | sort -u | grep -v '^$' | sed 's/^/- /' | tr '\n' ' ')
|
||||
[[ -z "$ALL_CONTRIBUTORS" ]] && ALL_CONTRIBUTORS="(无活跃贡献者)"
|
||||
|
||||
if [[ ${#QUESTION_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling question issues..."
|
||||
for qid in "${QUESTION_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$qid" --labels "question" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#QUESTION_IDS[@]} question issues"
|
||||
fi
|
||||
# ================================================================
|
||||
# Phase 3: 生成 Release Notes (changelog skill: 标准模板)
|
||||
# ================================================================
|
||||
log_title "Phase 3: Generate Release Notes"
|
||||
|
||||
if [[ ${#DOCS_IDS[@]} -gt 0 ]]; then
|
||||
log_step "Labeling docs issues..."
|
||||
for did in "${DOCS_IDS[@]}"; do
|
||||
gl_run issue +label-add --owner "$OWNER" --repo "$REPO" --number "$did" --labels "documentation" > /dev/null 2>&1 || true
|
||||
done
|
||||
log_ok "Labeled ${#DOCS_IDS[@]} docs issues"
|
||||
fi
|
||||
fi
|
||||
FEAT_SECTION="${ISSUE_FEATURES:-_无_}"
|
||||
BUG_SECTION="${ISSUE_BUGS:-_无_}"
|
||||
DOC_SECTION="${ISSUE_DOCS:-_无_}"
|
||||
OTHER_SECTION="${ISSUE_OTHER:-_无_}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 2: Assign Responsible Persons"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
FEAT_COUNT=$(echo "$ISSUE_FEATURES" | grep -c '^-' 2>/dev/null || echo "0")
|
||||
BUG_COUNT=$(echo "$ISSUE_BUGS" | grep -c '^-' 2>/dev/null || echo "0")
|
||||
|
||||
log_step "Fetching repo members for assignment..."
|
||||
MEMBERS_JSON=$(gl_run repo +members --owner "$OWNER" --repo "$REPO" --limit 50)
|
||||
MEMBER_COUNT=$(echo "$MEMBERS_JSON" | jq '(.data.members // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
|
||||
RELEASE_BODY="# 🎉 Release ${NEW_VERSION}
|
||||
|
||||
if [[ "$MEMBER_COUNT" -gt 0 ]]; then
|
||||
# Assign bugs to first member, features to second, etc. (round-robin)
|
||||
MEMBER_LOGINS=()
|
||||
MEMBERS_DATA_PATH='(.data.members // .data | if type == "array" then . else [] end)'
|
||||
for i in $(seq 0 $((MEMBER_COUNT - 1))); do
|
||||
LOGIN=$(echo "$MEMBERS_JSON" | jq -r "$MEMBERS_DATA_PATH[$i].login // $MEMBERS_DATA_PATH[$i].username // empty")
|
||||
[[ -n "$LOGIN" ]] && MEMBER_LOGINS+=("$LOGIN")
|
||||
done
|
||||
## 📊 变更统计
|
||||
- **周期**: ${PERIOD_START} ~ ${PERIOD_END}
|
||||
- **已合并 PR**: ${PR_COUNT} 个
|
||||
- **Issue 活动**: ${ISSUE_COUNT} 条(新增/关闭)
|
||||
- **Commits**: ${COMMIT_COUNT} 条
|
||||
|
||||
if [[ ${#MEMBER_LOGINS[@]} -gt 0 ]]; then
|
||||
assign_issues() {
|
||||
local label="$1"
|
||||
shift
|
||||
local ids=("$@")
|
||||
local member_idx=0
|
||||
for id in "${ids[@]}"; do
|
||||
local assignee="${MEMBER_LOGINS[$((member_idx % ${#MEMBER_LOGINS[@]}))]}"
|
||||
# issue +update doesn't support --assignee, use raw API
|
||||
gl_run api PATCH "/v1/$OWNER/$REPO/issues/$id" \
|
||||
--body "{\"assigned_to_id\": \"$assignee\"}" > /dev/null 2>&1 || true
|
||||
log_info " Assigned #$id → @$assignee"
|
||||
((member_idx++))
|
||||
done
|
||||
}
|
||||
## 📝 Issue 活动
|
||||
|
||||
log_step "Assigning bug issues..."
|
||||
assign_issues "bug" "${BUG_IDS[@]}" 2>/dev/null || true
|
||||
log_step "Assigning feature issues..."
|
||||
assign_issues "feature" "${FEATURE_IDS[@]}" 2>/dev/null || true
|
||||
log_ok "Assignment complete"
|
||||
fi
|
||||
else
|
||||
log_warn "No repo members found, skipping assignment"
|
||||
fi
|
||||
### 🐛 缺陷 / Bug
|
||||
${BUG_SECTION}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 3: Generate Community Weekly Report"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
### ✨ 新功能 / Feature
|
||||
${FEAT_SECTION}
|
||||
|
||||
WEEK_START=$(date -d "$((WEEKS_AGO * 7)) days ago" +%Y-%m-%d 2>/dev/null || date_today)
|
||||
WEEK_END=$(date_today)
|
||||
### 📖 文档
|
||||
${DOC_SECTION}
|
||||
|
||||
log_step "Collecting weekly data (week of $WEEK_START)..."
|
||||
### 💡 其他
|
||||
${OTHER_SECTION}
|
||||
|
||||
# Closed issues this week
|
||||
CLOSED_ISSUES=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 100)
|
||||
CLOSED_COUNT=$(echo "$CLOSED_ISSUES" | jq '.data.issues | length' 2>/dev/null || echo "0")
|
||||
## 🔀 已合并 PR
|
||||
${PR_ITEMS}
|
||||
|
||||
# Merged PRs this week
|
||||
MERGED_PRS=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100)
|
||||
# PR list may return .data[], .data.pulls[], or .data.issues[]
|
||||
MERGED_COUNT=$(echo "$MERGED_PRS" | jq '
|
||||
(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length
|
||||
' 2>/dev/null || echo "0")
|
||||
|
||||
# New issues
|
||||
NEW_ISSUES_COUNT=$ISSUE_COUNT
|
||||
|
||||
# Build weekly report
|
||||
REPORT_TITLE="Community Weekly Report: $WEEK_START ~ $WEEK_END"
|
||||
REPORT_BODY="# $REPORT_TITLE
|
||||
|
||||
## Summary
|
||||
- New Issues: **$NEW_ISSUES_COUNT**
|
||||
- Closed Issues: **$CLOSED_COUNT**
|
||||
- Merged PRs: **$MERGED_COUNT**
|
||||
|
||||
## Issue Classification
|
||||
| Type | Count |
|
||||
|------|-------|
|
||||
| Bug | ${#BUG_IDS[@]} |
|
||||
| Feature | ${#FEATURE_IDS[@]} |
|
||||
| Question | ${#QUESTION_IDS[@]} |
|
||||
| Docs | ${#DOCS_IDS[@]} |
|
||||
|
||||
## Highlights
|
||||
- Auto-classified and labeled $(( ${#BUG_IDS[@]} + ${#FEATURE_IDS[@]} + ${#QUESTION_IDS[@]} + ${#DOCS_IDS[@]} )) issues
|
||||
- Assigned responsible persons for bug and feature issues
|
||||
## 🙏 贡献者
|
||||
${ALL_CONTRIBUTORS}
|
||||
|
||||
---
|
||||
*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
**完整变更日志**: https://www.gitlink.org.cn/${OWNER}/${REPO}/compare/${PREV_TAG}...${NEW_VERSION}
|
||||
|
||||
*Auto-generated by gitlink-cli community-ops workflow (gitlink-changelog skill)*"
|
||||
|
||||
log_ok "Weekly report generated"
|
||||
echo ""
|
||||
echo "$REPORT_BODY"
|
||||
echo "$RELEASE_BODY"
|
||||
echo ""
|
||||
|
||||
# Publish to Wiki
|
||||
log_step "Publishing weekly report to Wiki..."
|
||||
WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO" \
|
||||
--title "$REPORT_TITLE" \
|
||||
--content "$REPORT_BODY" 2>&1) || true
|
||||
# ================================================================
|
||||
# Phase 4: 发布 Release
|
||||
# ================================================================
|
||||
log_title "Phase 4: Publish Release"
|
||||
|
||||
if [[ "$(json_ok "$WIKI_RESULT")" == "true" ]]; then
|
||||
log_ok "Weekly report published to Wiki"
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would create release: $NEW_VERSION"
|
||||
else
|
||||
log_warn "Wiki publish may have failed (wiki module might not be enabled)"
|
||||
log_step "Creating release $NEW_VERSION..."
|
||||
RELEASE_RESULT=$(gl_run release +create \
|
||||
--owner "$OWNER" --repo "$REPO" \
|
||||
--tag "$NEW_VERSION" \
|
||||
--name "Release $NEW_VERSION" \
|
||||
--body "$RELEASE_BODY" 2>&1) || true
|
||||
|
||||
if echo "$RELEASE_RESULT" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Release $NEW_VERSION published!"
|
||||
log_info "View: https://www.gitlink.org.cn/$OWNER/$REPO/releases"
|
||||
else
|
||||
log_warn "Release may have failed (tag might exist):"
|
||||
echo "$RELEASE_RESULT" | jq -r '.error.message // "unknown"' 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Phase 4: Auto-Publish Release Notes"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# ================================================================
|
||||
# Phase 5: 发布社区周报到 Wiki
|
||||
# ================================================================
|
||||
log_title "Phase 5: Publish Weekly Report"
|
||||
|
||||
log_step "Collecting recent changes for release notes..."
|
||||
WIKI_TITLE="社区周报"
|
||||
WIKI_BODY="# 社区周报 - ${OWNER}/${REPO}
|
||||
|
||||
# Get recent commits via compare API
|
||||
TAG_NAME="weekly-$(date +%Y%m%d)"
|
||||
RELEASE_NAME="Weekly Release $(date +%Y-%m-%d)"
|
||||
|
||||
# Build release notes from closed issues and merged PRs
|
||||
RELEASE_BODY="# Release Notes - $(date +%Y-%m-%d)
|
||||
|
||||
## Merged PRs ($MERGED_COUNT)"
|
||||
|
||||
if [[ "$MERGED_COUNT" -gt 0 ]]; then
|
||||
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
|
||||
for i in $(seq 0 $((MERGED_COUNT > 10 ? 9 : MERGED_COUNT - 1))); do
|
||||
PR_TITLE=$(echo "$MERGED_PRS" | jq -r "$PR_DATA_PATH[$i].subject // $PR_DATA_PATH[$i].title // \"\"")
|
||||
PR_NUM=$(echo "$MERGED_PRS" | jq -r "$PR_DATA_PATH[$i].id // $PR_DATA_PATH[$i].number // \"\"")
|
||||
RELEASE_BODY+=$'\n'"- #$PR_NUM $PR_TITLE"
|
||||
done
|
||||
fi
|
||||
|
||||
RELEASE_BODY+="
|
||||
|
||||
## Closed Issues ($CLOSED_COUNT)"
|
||||
|
||||
if [[ "$CLOSED_COUNT" -gt 0 ]]; then
|
||||
for i in $(seq 0 $((CLOSED_COUNT > 10 ? 9 : CLOSED_COUNT - 1))); do
|
||||
ISSUE_TITLE=$(echo "$CLOSED_ISSUES" | jq -r ".data.issues[$i].subject // .data.issues[$i].title // empty")
|
||||
ISSUE_NUM=$(echo "$CLOSED_ISSUES" | jq -r ".data.issues[$i].number // .data.issues[$i].id // empty")
|
||||
[[ -n "$ISSUE_TITLE" ]] && RELEASE_BODY+=$'\n'"- #${ISSUE_NUM:-?} $ISSUE_TITLE"
|
||||
done
|
||||
fi
|
||||
|
||||
RELEASE_BODY+="
|
||||
**${PERIOD_START} ~ ${PERIOD_END}**
|
||||
|
||||
---
|
||||
*Auto-generated by gitlink-cli community-ops workflow*"
|
||||
|
||||
log_step "Creating release: $TAG_NAME..."
|
||||
RELEASE_RESULT=$(gl_run release +create --owner "$OWNER" --repo "$REPO" \
|
||||
--tag "$TAG_NAME" \
|
||||
--name "$RELEASE_NAME" \
|
||||
--body "$RELEASE_BODY" 2>&1) || true
|
||||
## 📊 数据总览
|
||||
- 已合并 PR: **${PR_COUNT}** 个
|
||||
- Issue 活动: **${ISSUE_COUNT}** 条
|
||||
- 活跃贡献者: $(echo "$ALL_CONTRIBUTORS" | tr '\n' ' ')
|
||||
|
||||
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
|
||||
log_ok "Release $TAG_NAME created successfully"
|
||||
## 🔀 合并的 PR
|
||||
${PR_ITEMS}
|
||||
|
||||
## 📝 Issue 动态
|
||||
${BUG_SECTION}
|
||||
${FEAT_SECTION}
|
||||
${DOC_SECTION}
|
||||
${OTHER_SECTION}
|
||||
|
||||
---
|
||||
|
||||
*Auto-generated by gitlink-cli | ${PERIOD_START} ~ ${PERIOD_END} | Next report in ~${PERIOD_HOURS}h*"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would publish Wiki"
|
||||
else
|
||||
log_warn "Release creation may have failed (tag might already exist)"
|
||||
log_info "You can manually create with: gitlink-cli release +create --owner $OWNER --repo $REPO --tag $TAG_NAME --name '$RELEASE_NAME'"
|
||||
log_step "Publishing to Wiki..."
|
||||
# 先尝试覆盖更新,页面不存在则新建
|
||||
WIKI_RESULT=$(gl_run wiki +update --owner "$OWNER" --repo "$REPO" \
|
||||
--title "$WIKI_TITLE" --cover "$WIKI_BODY" 2>&1) || true
|
||||
if echo "$WIKI_RESULT" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Weekly report updated on Wiki"
|
||||
else
|
||||
# 页面可能还不存在,新建
|
||||
log_info "Page not found, creating new..."
|
||||
WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO" \
|
||||
--title "$WIKI_TITLE" --content "$WIKI_BODY" 2>&1) || true
|
||||
if echo "$WIKI_RESULT" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Weekly report created on Wiki"
|
||||
else
|
||||
log_warn "Wiki publish failed"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
log_title "Community Operations Complete"
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${GREEN}Summary:${NC}"
|
||||
echo " Issues classified: $(( ${#BUG_IDS[@]} + ${#FEATURE_IDS[@]} + ${#QUESTION_IDS[@]} + ${#DOCS_IDS[@]} ))"
|
||||
echo " Closed this week: $CLOSED_COUNT"
|
||||
echo " Merged PRs: $MERGED_COUNT"
|
||||
echo " Weekly report: Published to Wiki"
|
||||
echo " Release notes: $TAG_NAME"
|
||||
echo ""
|
||||
# ================================================================
|
||||
log_title "Complete"
|
||||
echo -e "${GREEN} Release: $NEW_VERSION${NC}"
|
||||
echo -e "${GREEN} PRs merged: $PR_COUNT${NC}"
|
||||
echo -e "${GREEN} Issues: $ISSUE_COUNT${NC}"
|
||||
echo -e "${GREEN} Contributors: $(echo "$ALL_CONTRIBUTORS" | wc -w)${NC}"
|
||||
echo -e "${GREEN} Wiki: ${WIKI_TITLE}${NC}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Real-Time Issue Triage (Webhook触发的单条Issue分类)
|
||||
# Flow: Webhook触发 → 拉取Issue详情 → 动态收集标签ID → 分类 → 打tags → 分配
|
||||
#
|
||||
# 10 个标准中文分类: 缺陷/功能/文档/任务/测试/支持/重复/疑问/协助/搁置
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$IssueNumber,
|
||||
[string]$Assignee = "",
|
||||
[switch]$DryRun,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-issue-triage.ps1 -IssueNumber N [-Owner OWNER] [-Repo REPO] [-Assignee USER] [-DryRun]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Check-Auth
|
||||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
# 10 个标准中文分类标签
|
||||
$STANDARD_LABELS = @("缺陷","功能","文档","任务","测试","支持","重复","疑问","协助","搁置")
|
||||
|
||||
# ================================================================
|
||||
Log-Title "Issue Triage: #$IssueNumber ($Owner/$Repo)"
|
||||
|
||||
# -- Phase 1: 拉取Issue详情 --
|
||||
Log-Step "Fetching issue #$IssueNumber details..."
|
||||
$issueJson = Invoke-GLCheck issue,+view,--owner,$Owner,--repo,$Repo,--number,$IssueNumber
|
||||
if (-not $issueJson) { Log-Err "Failed to fetch issue #$IssueNumber"; exit 1 }
|
||||
|
||||
$issueData = $issueJson.data
|
||||
$issueId = if ($issueData.id) { $issueData.id } else { $IssueNumber }
|
||||
$issueTitle = if ($issueData.subject) { $issueData.subject } elseif ($issueData.title) { $issueData.title } else { "N/A" }
|
||||
$issueDesc = if ($issueData.description) { $issueData.description } else { "" }
|
||||
$issueAuthor = if ($issueData.author.login) { $issueData.author.login } elseif ($issueData.author.username) { $issueData.author.username } else { "" }
|
||||
|
||||
$existingTags = @()
|
||||
if ($issueData.tags) { $existingTags = @($issueData.tags | ForEach-Object { $_.name }) }
|
||||
if ($existingTags.Count -gt 0) { Log-Info "Already tagged: $($existingTags -join ', ')" }
|
||||
|
||||
Log-Ok "Issue `"$issueTitle`" by @$issueAuthor"
|
||||
|
||||
# -- Phase 2: 动态收集标签 ID 映射 --
|
||||
Log-Step "Discovering label IDs..."
|
||||
$tagIdMap = @{}
|
||||
|
||||
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
|
||||
$issues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() }
|
||||
foreach ($iss in $issues) {
|
||||
if ($iss.tags) {
|
||||
foreach ($t in $iss.tags) {
|
||||
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
$found = @(); $missing = @()
|
||||
foreach ($lbl in $STANDARD_LABELS) {
|
||||
if ($tagIdMap.ContainsKey($lbl)) { $found += "$lbl($($tagIdMap[$lbl]))" } else { $missing += $lbl }
|
||||
}
|
||||
Log-Ok "Found tags: $($found -join ' ')"
|
||||
if ($missing.Count -gt 0) { Log-Warn "Not in repo yet: $($missing -join ' ')" }
|
||||
|
||||
# -- Phase 3: 关键词分类 (标题+描述) --
|
||||
Log-Step "Analyzing issue content..."
|
||||
|
||||
$combined = "$issueTitle $issueDesc".ToLower()
|
||||
$chosenLabel = ""
|
||||
|
||||
if ($combined -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
|
||||
$chosenLabel = "缺陷"
|
||||
} elseif ($combined -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能') {
|
||||
$chosenLabel = "功能"
|
||||
} elseif ($combined -match '(?i)doc|readme|guide|wiki|文档|说明|教程') {
|
||||
$chosenLabel = "文档"
|
||||
} elseif ($combined -match '(?i)test|测试|用例|覆盖') {
|
||||
$chosenLabel = "测试"
|
||||
} elseif ($combined -match '(?i)duplicate|重复|重复的|和.*重复') {
|
||||
$chosenLabel = "重复"
|
||||
} elseif ($combined -match '(?i)question|疑问|不确定|讨论|澄清') {
|
||||
$chosenLabel = "疑问"
|
||||
} elseif ($combined -match '(?i)help|协助|帮助|协作|请求帮助') {
|
||||
$chosenLabel = "协助"
|
||||
} elseif ($combined -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急') {
|
||||
$chosenLabel = "搁置"
|
||||
} elseif ($combined -match '(?i)support|兼容|环境|依赖|支持') {
|
||||
$chosenLabel = "支持"
|
||||
} elseif ($combined -match '(?i)task|todo|任务|待办|计划') {
|
||||
$chosenLabel = "任务"
|
||||
}
|
||||
|
||||
if ($chosenLabel) {
|
||||
Log-Ok "Classified: $chosenLabel"
|
||||
} else {
|
||||
Log-Info "No matching label — skipping"
|
||||
}
|
||||
|
||||
# -- Phase 4: 打标签 (raw API PATCH tags) --
|
||||
if (-not $chosenLabel) {
|
||||
Log-Info "No label assigned"
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would tag #$IssueNumber with '$chosenLabel'"
|
||||
} else {
|
||||
$tgtId = $tagIdMap[$chosenLabel]
|
||||
if (-not $tgtId) {
|
||||
Log-Warn "Label '$chosenLabel' not found in repo tags — create it on website first"
|
||||
Log-Info " https://www.gitlink.org.cn/$Owner/$Repo/settings/labels"
|
||||
} else {
|
||||
Log-Step "Tagging with '$chosenLabel' (ID:$tgtId)..."
|
||||
|
||||
$curTagsJson = if ($issueData.tags) {
|
||||
($issueData.tags | ForEach-Object { "{`"id`":$($_.id),`"name`":`"$($_.name)`"" }) -join "," | ForEach-Object { "[$_]" }
|
||||
} else { "[]" }
|
||||
|
||||
$bodyJson = "{`"tags`":[{`"id`":$tgtId,`"name`":`"$chosenLabel`"}]}"
|
||||
|
||||
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
|
||||
if ($tagResult) {
|
||||
try {
|
||||
$tagOk = (($tagResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $tagOk = $false }
|
||||
if ($tagOk) { Log-Ok "Tagged: $chosenLabel" } else { Log-Warn "Tag failed" }
|
||||
} else { Log-Warn "Tag failed" }
|
||||
}
|
||||
}
|
||||
|
||||
# -- Phase 5: 分配责任人 --
|
||||
$targetAssignee = if ($Assignee) { $Assignee } else { $issueAuthor }
|
||||
|
||||
Log-Step "Assigning..."
|
||||
if (-not $targetAssignee) {
|
||||
Log-Warn "No assignee, skipping"
|
||||
} elseif ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would assign @$targetAssignee"
|
||||
} else {
|
||||
$bodyJson = "{`"subject`":`"$($issueTitle -replace '"','\"')`",`"description`":`"$($issueDesc -replace '"','\"')`",`"assigned_to_id`":`"$targetAssignee`"}"
|
||||
$result = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
|
||||
if ($result -and ((($result | ConvertFrom-Json).ok -eq $true))) {
|
||||
Log-Ok "Assigned: @$targetAssignee"
|
||||
} else {
|
||||
Log-Warn "Assign failed"
|
||||
}
|
||||
}
|
||||
|
||||
Log-Title "Triage Complete"
|
||||
Write-Host " Issue: #$IssueNumber - $issueTitle" -ForegroundColor Green
|
||||
Write-Host " Author: @$issueAuthor" -ForegroundColor Green
|
||||
Write-Host " Label: $(if($chosenLabel){$chosenLabel}else{'无'})" -ForegroundColor Green
|
||||
Write-Host " Assignee: @$targetAssignee" -ForegroundColor Green
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# Scenario 1a: Real-Time Issue Triage (Linux Webhook 版)
|
||||
# Flow: 接收 Issue 编号 → 拉取详情 → 分类 → 打 tags → 分配人
|
||||
# ================================================================
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
ISSUE_NUMBER=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
ASSIGNEE=""
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --issue-number N [--owner OWNER] [--repo REPO] [--assignee USER] [--dry-run]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--issue-number) ISSUE_NUMBER="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--assignee) ASSIGNEE="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$ISSUE_NUMBER" ]] && { log_err "--issue-number is required"; usage; }
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
# 10 个标准中文分类标签(必须和仓库实际标签名一致)
|
||||
STANDARD_LABELS="缺陷 功能 文档 任务 测试 支持 重复 疑问 协助 搁置"
|
||||
|
||||
# ================================================================
|
||||
log_title "Issue Triage: #$ISSUE_NUMBER ($OWNER/$REPO)"
|
||||
|
||||
# ── Phase 1: 拉取 Issue 详情 ─────────────────────────────────────
|
||||
log_step "Fetching issue #$ISSUE_NUMBER details..."
|
||||
ISSUE_JSON=$(gl_check issue +view --owner "$OWNER" --repo "$REPO" --number "$ISSUE_NUMBER")
|
||||
[[ -z "$ISSUE_JSON" ]] && { log_err "Failed to fetch issue #$ISSUE_NUMBER"; exit 1; }
|
||||
|
||||
ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.data.subject // .data.title // "N/A"')
|
||||
ISSUE_DESC=$(echo "$ISSUE_JSON" | jq -r '.data.description // ""' | head -c 2000)
|
||||
ISSUE_AUTHOR=$(echo "$ISSUE_JSON" | jq -r '.data.author.login // .data.author.username // ""')
|
||||
ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.data.status.name // .data.state.name // .data.state // "N/A"')
|
||||
|
||||
EXISTING_TAGS=$(echo "$ISSUE_JSON" | jq -r '[.data.tags[]?.name // ""] | join(", ")' 2>/dev/null || echo "")
|
||||
[[ -n "$EXISTING_TAGS" ]] && log_info "Already tagged: $EXISTING_TAGS"
|
||||
|
||||
log_ok "Issue \"$ISSUE_TITLE\" by @$ISSUE_AUTHOR"
|
||||
|
||||
# ── Phase 2: 动态收集标签 ID 映射 ─────────────────────────────────
|
||||
log_step "Discovering label IDs..."
|
||||
|
||||
# 从已有 issue 的 tags 字段收集 name→ID 映射
|
||||
ID_MAP_FILE=$(mktemp)
|
||||
for state in open closed; do
|
||||
SAMPLE=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 50 2>/dev/null || true)
|
||||
echo "$SAMPLE" | jq -r '(.data.issues // .data // [])[]?.tags[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true
|
||||
done
|
||||
|
||||
declare -A TAG_ID
|
||||
while IFS='|' read -r tid tname || [[ -n "$tid" ]]; do
|
||||
[[ -n "$tid" && -n "$tname" ]] && TAG_ID["$tname"]="$tid"
|
||||
done < "$ID_MAP_FILE"
|
||||
rm -f "$ID_MAP_FILE"
|
||||
|
||||
FOUND=""; MISSING=""
|
||||
for lbl in $STANDARD_LABELS; do
|
||||
if [[ -n "${TAG_ID[$lbl]:-}" ]]; then
|
||||
FOUND="$FOUND $lbl(${TAG_ID[$lbl]})"
|
||||
else
|
||||
MISSING="$MISSING $lbl"
|
||||
fi
|
||||
done
|
||||
log_ok "Found tags:${FOUND:- (none)}"
|
||||
[[ -n "$MISSING" ]] && log_warn "Not in repo yet:${MISSING}"
|
||||
|
||||
# ── Phase 3: 分类(关键词 → 中文标签)─────────────────────────────
|
||||
log_step "Analyzing issue content..."
|
||||
|
||||
COMBINED=$(echo "$ISSUE_TITLE $ISSUE_DESC" | tr '[:upper:]' '[:lower:]')
|
||||
CHOSEN_LABEL=""
|
||||
CONFIDENCE=""
|
||||
|
||||
# 优先匹配具体意图,再匹配通用
|
||||
if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|错误|异常|崩溃|缺陷|修复|故障'; then
|
||||
CHOSEN_LABEL="缺陷"
|
||||
elif echo "$COMBINED" | grep -qiE 'feature|enhancement|add|新增|建议|功能|特性|新功能'; then
|
||||
CHOSEN_LABEL="功能"
|
||||
elif echo "$COMBINED" | grep -qiE 'doc|readme|guide|wiki|文档|说明|教程'; then
|
||||
CHOSEN_LABEL="文档"
|
||||
elif echo "$COMBINED" | grep -qiE 'test|测试|用例|覆盖'; then
|
||||
CHOSEN_LABEL="测试"
|
||||
elif echo "$COMBINED" | grep -qiE 'duplicate|重复|重复的|和.*重复'; then
|
||||
CHOSEN_LABEL="重复"
|
||||
elif echo "$COMBINED" | grep -qiE 'question|疑问|不确定|讨论|澄清'; then
|
||||
CHOSEN_LABEL="疑问"
|
||||
elif echo "$COMBINED" | grep -qiE 'help|协助|帮助|协作|请求帮助'; then
|
||||
CHOSEN_LABEL="协助"
|
||||
elif echo "$COMBINED" | grep -qiE 'postpone|wontfix|暂缓|搁置|低优|不重要|不紧急'; then
|
||||
CHOSEN_LABEL="搁置"
|
||||
elif echo "$COMBINED" | grep -qiE 'support|兼容|环境|依赖|支持'; then
|
||||
CHOSEN_LABEL="支持"
|
||||
elif echo "$COMBINED" | grep -qiE 'task|todo|任务|待办|计划'; then
|
||||
CHOSEN_LABEL="任务"
|
||||
elif echo "$COMBINED" | grep -qiE 'how|怎么|如何|求助|使用|用法'; then
|
||||
CHOSEN_LABEL="疑问"
|
||||
fi
|
||||
|
||||
if [[ -n "$CHOSEN_LABEL" ]]; then
|
||||
CONFIDENCE="keyword"
|
||||
log_ok "Classified: $CHOSEN_LABEL"
|
||||
else
|
||||
log_info "No matching label — skipping"
|
||||
fi
|
||||
|
||||
# ── Phase 4: 打标签(raw API PATCH tags)─────────────────────────
|
||||
if [[ -z "$CHOSEN_LABEL" ]]; then
|
||||
log_info "No label assigned"
|
||||
elif [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would tag #$ISSUE_NUMBER with '$CHOSEN_LABEL'"
|
||||
else
|
||||
TGT_ID="${TAG_ID[$CHOSEN_LABEL]:-}"
|
||||
if [[ -z "$TGT_ID" ]]; then
|
||||
log_warn "Label '$CHOSEN_LABEL' not found in repo tags — create it on website first"
|
||||
log_info " https://www.gitlink.org.cn/$OWNER/$REPO/settings/labels"
|
||||
else
|
||||
log_step "Tagging with '$CHOSEN_LABEL' (ID:$TGT_ID)..."
|
||||
|
||||
# 获取当前 tags,追加新标签(去重)
|
||||
CUR_TAGS=$(echo "$ISSUE_JSON" | jq -c '[.data.tags[]? | {id:.id, name:.name}]' 2>/dev/null || echo "[]")
|
||||
NEW_TAGS=$(echo "$CUR_TAGS" | jq -c --argjson nt "{\"id\":$TGT_ID,\"name\":\"$CHOSEN_LABEL\"}" \
|
||||
'. + [$nt] | unique_by(.id)' 2>/dev/null)
|
||||
|
||||
TAG_RESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
|
||||
--body "{\"tags\":$NEW_TAGS}" 2>&1) || true
|
||||
|
||||
if echo "$TAG_RESP" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Tagged: $CHOSEN_LABEL"
|
||||
else
|
||||
ERR=$(echo "$TAG_RESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
|
||||
log_warn "Tag failed: $ERR"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 5: 分配责任人 ──────────────────────────────────────────
|
||||
TARGET_ASSIGNEE="${ASSIGNEE:-$ISSUE_AUTHOR}"
|
||||
|
||||
log_step "Assigning..."
|
||||
if [[ -z "$TARGET_ASSIGNEE" ]]; then
|
||||
log_warn "No assignee, skipping"
|
||||
elif [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would assign @$TARGET_ASSIGNEE"
|
||||
else
|
||||
BODY="{\"assigned_to_id\":\"$TARGET_ASSIGNEE\"}"
|
||||
ARESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" --body "$BODY" 2>&1) || true
|
||||
if echo "$ARESP" | jq -e '.ok == true' &>/dev/null; then
|
||||
log_ok "Assigned: @$TARGET_ASSIGNEE"
|
||||
else
|
||||
ERR2=$(echo "$ARESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
|
||||
log_warn "Assign failed: $ERR2"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Complete ──────────────────────────────────────────────────────
|
||||
log_title "Triage Complete"
|
||||
echo -e "${GREEN} Issue: #$ISSUE_NUMBER - $ISSUE_TITLE${NC}"
|
||||
echo -e "${GREEN} Author: @$ISSUE_AUTHOR${NC}"
|
||||
echo -e "${GREEN} Label: ${CHOSEN_LABEL:-无}${NC}"
|
||||
echo -e "${GREEN} Assignee: @$TARGET_ASSIGNEE${NC}"
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Webhook HTTP Listener (实时链路接收器)
|
||||
# Role: HTTP server listening for GitLink webhook events (Issue created)
|
||||
# → Parse payload → Call 01a-issue-triage.ps1 to classify & assign
|
||||
#
|
||||
# 这是社区运营自动化"实时链路"的入口,负责接收 GitLink 平台推送的
|
||||
# Webhook 事件,解析 Issue 编号,然后调用分类脚本。
|
||||
#
|
||||
# 架构:
|
||||
# GitLink 平台 (Issue 创建)
|
||||
# → POST https://<YOUR_URL>:<PORT>/webhook
|
||||
# → 01a-webhook-listener.ps1 (本脚本,HTTP 服务器)
|
||||
# → 01a-issue-triage.ps1 (分类+打标签+分配)
|
||||
#
|
||||
# 部署方式:
|
||||
# A. 本地 + ngrok 内网穿透:
|
||||
# 1. 启动本脚本: powershell 01a-webhook-listener.ps1
|
||||
# 2. 启动 ngrok: ngrok http 8080
|
||||
# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://xxx.ngrok.io/webhook"
|
||||
#
|
||||
# B. 部署到公网服务器:
|
||||
# 1. 上传脚本到服务器
|
||||
# 2. 启动本脚本: powershell 01a-webhook-listener.ps1 -Port 443 -Ssl
|
||||
# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://your-server.com/webhook"
|
||||
#
|
||||
# C. 仅手动触发 (无需 webhook):
|
||||
# powershell 01a-issue-triage.ps1 -IssueNumber 42
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
param(
|
||||
[int]$Port = 8080,
|
||||
[string]$Secret = "",
|
||||
[string]$HostPrefix = "+",
|
||||
[switch]$Ssl,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ScriptDir = $PSScriptRoot
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-webhook-listener.ps1 [-Port PORT] [-Secret SECRET] [-HostPrefix +] [-Ssl]"
|
||||
Write-Host ""
|
||||
Write-Host " Webhook HTTP 接收器 — 监听 GitLink 平台的 Issue 事件,自动触发分类。"
|
||||
Write-Host ""
|
||||
Write-Host " -Port PORT 监听端口 (默认: 8080)"
|
||||
Write-Host " -Secret SECRET HMAC 密钥,用于验证 GitLink 请求来源(需与注册时一致)"
|
||||
Write-Host " -HostPrefix PREFIX 监听主机前缀 (默认: + 表示所有IP,也可用 localhost)"
|
||||
Write-Host " -Ssl 启用 HTTPS (需要已导入的 SSL 证书)"
|
||||
Write-Host ""
|
||||
Write-Host " 部署前准备:"
|
||||
Write-Host " 如需公网访问,请使用 ngrok 或部署到有公网IP的服务器:"
|
||||
Write-Host " ngrok http $Port"
|
||||
Write-Host " 然后运行 01a-webhook-setup.ps1 在 GitLink 平台注册 webhook"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Color Helpers (no dependency on common.psm1 since this is a server)
|
||||
# ================================================================
|
||||
function Log-Step { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [STEP] $Msg" -ForegroundColor Blue }
|
||||
function Log-Ok { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ OK] $Msg" -ForegroundColor Green }
|
||||
function Log-Warn { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [WARN] $Msg" -ForegroundColor Yellow }
|
||||
function Log-Err { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ ERR] $Msg" -ForegroundColor Red }
|
||||
function Log-Info { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [INFO] $Msg" -ForegroundColor Cyan }
|
||||
|
||||
# ================================================================
|
||||
# HMAC Signature Verification
|
||||
# ================================================================
|
||||
function Test-WebhookSignature {
|
||||
param(
|
||||
[string]$RequestBody,
|
||||
[string]$SignatureHeader,
|
||||
[string]$Secret
|
||||
)
|
||||
if (-not $Secret) { return $true } # No secret configured, skip verification
|
||||
|
||||
if (-not $SignatureHeader) {
|
||||
Log-Warn "No signature header in request (expected X-GitLink-Signature or X-Hub-Signature-256)"
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$hmac = New-Object System.Security.Cryptography.HMACSHA256
|
||||
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
|
||||
$hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($RequestBody))
|
||||
$computed = "sha256=" + [System.BitConverter]::ToString($hash).Replace("-", "").ToLower()
|
||||
|
||||
# Support both X-GitLink-Signature and X-Hub-Signature-256 (GitHub-compatible)
|
||||
# Strip prefix if present (e.g., "sha256=abc123..." → "abc123...")
|
||||
$received = $SignatureHeader
|
||||
if ($received -match '^sha256=') {
|
||||
$received = $received
|
||||
} else {
|
||||
$received = "sha256=$received"
|
||||
}
|
||||
|
||||
return $computed -eq $received
|
||||
} catch {
|
||||
Log-Err "HMAC verification error: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Extract Issue Number from Webhook Payload
|
||||
# ================================================================
|
||||
function Get-IssueNumberFromPayload {
|
||||
param([string]$Body, [string]$EventType)
|
||||
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
|
||||
# Try multiple known payload structures
|
||||
# GitLink format: { action: "opened", issue: { id: ..., number: ..., project_issues_index: ... } }
|
||||
if ($payload.issue) {
|
||||
$num = $payload.issue.project_issues_index
|
||||
if (-not $num) { $num = $payload.issue.number }
|
||||
if (-not $num) { $num = $payload.issue.id }
|
||||
if ($num) {
|
||||
Log-Info "Extracted issue number: #$num (event: $EventType)"
|
||||
return $num.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
# GitHub-compatible format: { action: "opened", issue: { number: ... } }
|
||||
if ($payload.issue -and $payload.issue.number) {
|
||||
Log-Info "Extracted issue number (GitHub format): #$($payload.issue.number)"
|
||||
return $payload.issue.number.ToString()
|
||||
}
|
||||
|
||||
# Direct format: { number: ..., id: ... }
|
||||
if ($payload.number) { return $payload.number.ToString() }
|
||||
if ($payload.id) {
|
||||
Log-Info "Extracted issue id: $($payload.id)"
|
||||
return $payload.id.ToString()
|
||||
}
|
||||
|
||||
Log-Warn "Could not extract issue number from payload"
|
||||
Log-Info "Payload keys: $($payload.PSObject.Properties.Name -join ', ')"
|
||||
if ($payload.issue) {
|
||||
Log-Info "Issue keys: $($payload.issue.PSObject.Properties.Name -join ', ')"
|
||||
}
|
||||
return $null
|
||||
} catch {
|
||||
Log-Err "Failed to parse webhook payload: $_"
|
||||
Log-Info "Raw body (first 500 chars): $($Body.Substring(0, [Math]::Min(500, $Body.Length)))"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Extract Owner/Repo from payload or git remote
|
||||
# ================================================================
|
||||
function Get-RepoInfoFromPayload {
|
||||
param([string]$Body)
|
||||
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
$owner = $null
|
||||
$repo = $null
|
||||
|
||||
# GitLink format
|
||||
if ($payload.repository) {
|
||||
if ($payload.repository.owner) {
|
||||
$owner = if ($payload.repository.owner.login) { $payload.repository.owner.login }
|
||||
elseif ($payload.repository.owner.username) { $payload.repository.owner.username }
|
||||
else { $payload.repository.owner }
|
||||
}
|
||||
if ($payload.repository.name) { $repo = $payload.repository.name }
|
||||
}
|
||||
|
||||
# GitHub-compatible format
|
||||
if ((-not $owner) -and $payload.repository -and $payload.repository.full_name) {
|
||||
$parts = $payload.repository.full_name -split '/'
|
||||
$owner = $parts[0]
|
||||
$repo = $parts[1]
|
||||
}
|
||||
|
||||
return @{ Owner = $owner; Repo = $repo }
|
||||
} catch {
|
||||
return @{ Owner = $null; Repo = $null }
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Process Incoming Webhook
|
||||
# ================================================================
|
||||
function Invoke-WebhookHandler {
|
||||
param(
|
||||
[string]$Body,
|
||||
[string]$EventType,
|
||||
[string]$EventHeader,
|
||||
[string]$SignatureHeader
|
||||
)
|
||||
|
||||
# Validate secret if configured
|
||||
if ($Secret -and -not (Test-WebhookSignature -RequestBody $Body -SignatureHeader $SignatureHeader -Secret $Secret)) {
|
||||
Log-Err "HMAC signature verification FAILED — request rejected"
|
||||
return @{ StatusCode = 403; Body = '{"error":"Invalid signature"}' }
|
||||
}
|
||||
|
||||
# Only process issue events
|
||||
if ($EventType -notmatch '^issue' -and $EventHeader -notmatch 'issue') {
|
||||
Log-Info "Ignoring non-issue event: $EventType"
|
||||
return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"non-issue event"}' }
|
||||
}
|
||||
|
||||
# Only process "opened" action (new issue created)
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
if ($payload.action -and $payload.action -ne 'opened') {
|
||||
Log-Info "Ignoring issue event with action: $($payload.action)"
|
||||
return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"action is not opened"}' }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# Extract issue number
|
||||
$issueNumber = Get-IssueNumberFromPayload -Body $Body -EventType $EventType
|
||||
if (-not $issueNumber) {
|
||||
Log-Err "Cannot extract issue number — skipping triage"
|
||||
return @{ StatusCode = 400; Body = '{"error":"Cannot extract issue number from payload"}' }
|
||||
}
|
||||
|
||||
Log-Ok "=== New Issue #$issueNumber — dispatching to triage ==="
|
||||
|
||||
# Extract owner/repo to pass to triage script
|
||||
$repoInfo = Get-RepoInfoFromPayload -Body $Body
|
||||
|
||||
# Dispatch triage script asynchronously so we can respond to webhook quickly
|
||||
$jobScript = {
|
||||
param($ScriptDir, $IssueNum, $Owner, $Repo, $Body)
|
||||
$argList = @("-File", "$ScriptDir\01a-issue-triage.ps1", "-IssueNumber", $IssueNum)
|
||||
if ($Owner) { $argList += @("-Owner", $Owner) }
|
||||
if ($Repo) { $argList += @("-Repo", $Repo) }
|
||||
$result = & powershell.exe -NoProfile -ExecutionPolicy Bypass @argList 2>&1
|
||||
$result | Out-File "$ScriptDir\webhook-triage-$IssueNum-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8
|
||||
}
|
||||
|
||||
Start-Job -ScriptBlock $jobScript -ArgumentList $ScriptDir, $issueNumber, $repoInfo.Owner, $repoInfo.Repo, $Body | Out-Null
|
||||
Log-Ok "Triage job started for #$issueNumber (running in background)"
|
||||
|
||||
return @{ StatusCode = 200; Body = '{"status":"accepted","issue_number":' + $issueNumber + '}' }
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Main: Start HTTP Listener
|
||||
# ================================================================
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
|
||||
Write-Host "║ GitLink Community Ops — Webhook Listener ║" -ForegroundColor Cyan
|
||||
Write-Host "║ 实时链路接收器: Issue 创建 → 自动分类 → 分配责任人 ║" -ForegroundColor Cyan
|
||||
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
$protocol = if ($Ssl) { "https" } else { "http" }
|
||||
$listenUrl = "$($protocol)://$($HostPrefix):$Port/"
|
||||
|
||||
Log-Info "Starting listener on: $listenUrl"
|
||||
Log-Info "Triage script: $ScriptDir\01a-issue-triage.ps1"
|
||||
if ($Secret) {
|
||||
Log-Info "HMAC verification: ENABLED"
|
||||
} else {
|
||||
Log-Warn "HMAC verification: DISABLED (set -Secret to enable)"
|
||||
}
|
||||
|
||||
# Try to register URL ACL if not running as admin for non-localhost
|
||||
if ($HostPrefix -ne "localhost" -and $HostPrefix -ne "127.0.0.1") {
|
||||
Write-Host ""
|
||||
Log-Warn "Listening on $HostPrefix requires URL ACL registration."
|
||||
Log-Info "If you get 'Access Denied', run as Administrator OR use -HostPrefix localhost"
|
||||
}
|
||||
|
||||
# Create HttpListener
|
||||
$listener = $null
|
||||
try {
|
||||
$listener = New-Object System.Net.HttpListener
|
||||
$listener.Prefixes.Add($listenUrl + "webhook/")
|
||||
$listener.Prefixes.Add($listenUrl) # Also listen on root path
|
||||
$listener.Start()
|
||||
Log-Ok "HTTP listener started successfully"
|
||||
} catch {
|
||||
Log-Err "Failed to start HTTP listener: $_"
|
||||
Write-Host ""
|
||||
Write-Host "Troubleshooting:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Run as Administrator"
|
||||
Write-Host " 2. Or register URL ACL manually:"
|
||||
Write-Host " netsh http add urlacl url=$listenUrl user=Everyone"
|
||||
Write-Host " 3. Or use localhost only: -HostPrefix localhost"
|
||||
Write-Host " 4. Check if port $Port is already in use: netstat -ano | findstr $Port"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Log-Ok " Listening on: $listenUrl"
|
||||
Log-Ok " Webhook URL: ${listenUrl}webhook"
|
||||
Log-Ok " Health check: ${listenUrl}"
|
||||
Log-Ok ""
|
||||
Log-Ok " 按 Ctrl+C 停止服务"
|
||||
Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Write-Host ""
|
||||
|
||||
# Handle Ctrl+C gracefully
|
||||
$keepRunning = $true
|
||||
$null = Register-EngineEvent -SourceIdentifier "WebhookListenerStop" -Forward -SupportEvent
|
||||
try {
|
||||
[Console]::TreatControlCAsInput = $false
|
||||
} catch { }
|
||||
|
||||
# Main event loop
|
||||
while ($keepRunning) {
|
||||
try {
|
||||
$context = $listener.GetContext()
|
||||
$request = $context.Request
|
||||
$response = $context.Response
|
||||
|
||||
$requestMethod = $request.HttpMethod
|
||||
$requestUrl = $request.Url.ToString()
|
||||
$remoteIp = $request.RemoteEndPoint.Address.ToString()
|
||||
|
||||
Log-Step "$requestMethod $requestUrl (from $remoteIp)"
|
||||
|
||||
if ($requestMethod -eq "GET" -and ($requestUrl -notmatch '/webhook$')) {
|
||||
# Health check / root page
|
||||
$html = @"
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>GitLink Webhook Listener</title>
|
||||
<style>body{font-family:sans-serif;max-width:800px;margin:40px auto;padding:20px}
|
||||
h1{color:#333}.status{color:green;font-weight:bold}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style>
|
||||
</head><body>
|
||||
<h1>GitLink Community Ops — Webhook Listener</h1>
|
||||
<p class="status">✓ Running</p>
|
||||
<p>Listening for <code>issue</code> events at <code>${listenUrl}webhook</code></p>
|
||||
<p>When a new Issue is created, this server will:</p>
|
||||
<ol>
|
||||
<li>Receive the webhook payload from GitLink</li>
|
||||
<li>Validate HMAC signature (if secret is configured)</li>
|
||||
<li>Extract the issue number</li>
|
||||
<li>Call <code>01a-issue-triage.ps1</code> to AI-classify and assign</li>
|
||||
</ol>
|
||||
<p><small>Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Port: $Port | HMAC: $(if($Secret){'enabled'}else{'disabled'})</small></p>
|
||||
</body></html>
|
||||
"@
|
||||
$buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
|
||||
$response.ContentType = "text/html; charset=utf-8"
|
||||
$response.ContentLength64 = $buffer.Length
|
||||
$response.OutputStream.Write($buffer, 0, $buffer.Length)
|
||||
$response.OutputStream.Close()
|
||||
Log-Ok "Health check OK"
|
||||
continue
|
||||
}
|
||||
|
||||
# Read request body
|
||||
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
|
||||
$body = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
|
||||
# Get event headers
|
||||
$eventType = $request.Headers.Get("X-GitLink-Event")
|
||||
if (-not $eventType) {
|
||||
$eventType = $request.Headers.Get("X-GitHub-Event") # GitHub-compatible
|
||||
}
|
||||
if (-not $eventType) {
|
||||
$eventType = $request.Headers.Get("X-Event-Type")
|
||||
}
|
||||
|
||||
$signatureHeader = $request.Headers.Get("X-GitLink-Signature")
|
||||
if (-not $signatureHeader) {
|
||||
$signatureHeader = $request.Headers.Get("X-Hub-Signature-256") # GitHub-compatible
|
||||
}
|
||||
|
||||
Log-Info "Event: $eventType | Body length: $($body.Length) bytes"
|
||||
|
||||
# Process the webhook
|
||||
$result = Invoke-WebhookHandler -Body $body -EventType $eventType -EventHeader $eventType -SignatureHeader $signatureHeader
|
||||
|
||||
# Send response
|
||||
$response.StatusCode = $result.StatusCode
|
||||
$responseBuffer = [System.Text.Encoding]::UTF8.GetBytes($result.Body)
|
||||
$response.ContentType = "application/json; charset=utf-8"
|
||||
$response.ContentLength64 = $responseBuffer.Length
|
||||
$response.OutputStream.Write($responseBuffer, 0, $responseBuffer.Length)
|
||||
$response.OutputStream.Close()
|
||||
|
||||
} catch [System.Net.HttpListenerException] {
|
||||
if ($_.Exception.ErrorCode -eq 995) {
|
||||
# Operation aborted — likely shutting down
|
||||
Log-Info "Listener shutting down..."
|
||||
$keepRunning = $false
|
||||
} else {
|
||||
Log-Err "HTTP error: $_"
|
||||
}
|
||||
} catch {
|
||||
Log-Err "Unexpected error: $_"
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
if ($listener -and $listener.IsListening) {
|
||||
$listener.Stop()
|
||||
$listener.Close()
|
||||
Log-Ok "HTTP listener stopped"
|
||||
}
|
||||
|
||||
Log-Ok "Webhook listener exited"
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
#!/usr/bin/env python3
|
||||
# ================================================================
|
||||
# Scenario 1a: Webhook HTTP Listener (Linux 版)
|
||||
# Role: HTTP server 监听 GitLink webhook → 解析 Issue 编号 → 调用分类脚本
|
||||
#
|
||||
# 部署: systemd 管理,端口 8080,无需 root(用 systemd socket activation 或 sudo)
|
||||
# 依赖: Python 3.6+ (无需额外 pip 包)
|
||||
# ================================================================
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────
|
||||
PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
|
||||
SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
TRIAGE_SCRIPT = os.path.join(SCRIPT_DIR, "01a-issue-triage.sh")
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "webhook-logs")
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def log(msg, level="INFO"):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
color = {"INFO": "\033[36m", "OK": "\033[32m", "WARN": "\033[33m", "ERR": "\033[31m"}.get(level, "")
|
||||
reset = "\033[0m"
|
||||
print(f"[{ts}] {color}[{level:>4}]{reset} {msg}", flush=True)
|
||||
# Also append to log file
|
||||
logfile = os.path.join(LOG_DIR, datetime.now().strftime("webhook-%Y%m%d.log"))
|
||||
with open(logfile, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{ts}] [{level:>4}] {msg}\n")
|
||||
|
||||
|
||||
def verify_signature(body: bytes, signature_header: str) -> bool:
|
||||
"""HMAC-SHA256 签名验证"""
|
||||
if not SECRET:
|
||||
return True # 未配置密钥,跳过验证
|
||||
if not signature_header:
|
||||
log("No signature header in request", "WARN")
|
||||
return False
|
||||
|
||||
try:
|
||||
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
# Support both "sha256=abc..." and plain "abc..." formats
|
||||
received = signature_header
|
||||
if received.startswith("sha256="):
|
||||
received = received[7:]
|
||||
return hmac.compare_digest(expected, received)
|
||||
except Exception as e:
|
||||
log(f"HMAC error: {e}", "ERR")
|
||||
return False
|
||||
|
||||
|
||||
def extract_issue_number(payload: dict) -> str:
|
||||
"""从 webhook payload 提取 Issue 编号"""
|
||||
# GitLink 格式
|
||||
issue = payload.get("issue", {})
|
||||
if issue:
|
||||
num = issue.get("project_issues_index") or issue.get("number") or issue.get("id")
|
||||
if num:
|
||||
log(f"Extracted issue number: #{num}", "OK")
|
||||
return str(num)
|
||||
|
||||
# 直接格式
|
||||
if "number" in payload:
|
||||
return str(payload["number"])
|
||||
if "id" in payload:
|
||||
return str(payload["id"])
|
||||
|
||||
log("Could not extract issue number from payload", "WARN")
|
||||
return None
|
||||
|
||||
|
||||
def extract_repo_info(payload: dict):
|
||||
"""从 payload 提取 owner/repo"""
|
||||
repo = payload.get("repository", {})
|
||||
owner = repo.get("owner", {})
|
||||
owner_name = owner.get("login") or owner.get("username") or str(owner) if isinstance(owner, dict) else str(owner)
|
||||
repo_name = repo.get("name", "")
|
||||
return owner_name, repo_name
|
||||
|
||||
|
||||
def run_triage_async(issue_number: str, owner: str = "", repo: str = ""):
|
||||
"""异步调用分类脚本,另起线程避免阻塞 webhook 响应"""
|
||||
def _run():
|
||||
cmd = ["bash", TRIAGE_SCRIPT, "--issue-number", issue_number]
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if repo:
|
||||
cmd += ["--repo", repo]
|
||||
|
||||
log(f"Dispatching triage: {' '.join(cmd)}", "INFO")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=SCRIPT_DIR)
|
||||
# Save output to log
|
||||
outfile = os.path.join(LOG_DIR, f"triage-{issue_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log")
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n")
|
||||
if result.returncode == 0:
|
||||
log(f"Triage #{issue_number} completed successfully → {outfile}", "OK")
|
||||
else:
|
||||
log(f"Triage #{issue_number} failed (exit={result.returncode}) → {outfile}", "ERR")
|
||||
except subprocess.TimeoutExpired:
|
||||
log(f"Triage #{issue_number} TIMEOUT after 120s", "ERR")
|
||||
except Exception as e:
|
||||
log(f"Triage #{issue_number} error: {e}", "ERR")
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
log(f"{self.client_address[0]} - {format % args}", "INFO")
|
||||
|
||||
def do_GET(self):
|
||||
"""健康检查 / 根页面"""
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>GitLink Webhook Listener</title>
|
||||
<style>body{{font-family:sans-serif;max-width:800px;margin:40px auto;padding:20px}}
|
||||
h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padding:2px 6px;border-radius:3px}}</style>
|
||||
</head><body>
|
||||
<h1>GitLink Community Ops — Webhook Listener</h1>
|
||||
<p class="ok">✓ Running</p>
|
||||
<p>Listening for <code>issue</code> events at <code>/webhook</code></p>
|
||||
<p><small>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}</small></p>
|
||||
</body></html>"""
|
||||
self._respond(200, html, "text/html")
|
||||
|
||||
def do_POST(self):
|
||||
"""接收 webhook"""
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b""
|
||||
|
||||
event_type = self.headers.get("X-GitLink-Event") or self.headers.get("X-GitHub-Event") or ""
|
||||
signature = self.headers.get("X-GitLink-Signature") or self.headers.get("X-Hub-Signature-256") or ""
|
||||
|
||||
log(f"POST {self.path} | Event: {event_type} | Size: {content_length}B | From: {self.client_address[0]}")
|
||||
|
||||
# HMAC 验证
|
||||
if SECRET and not verify_signature(body, signature):
|
||||
self._respond(403, '{"error":"Invalid signature"}')
|
||||
return
|
||||
|
||||
# 只处理 issue 事件
|
||||
if "issue" not in event_type.lower():
|
||||
log(f"Ignoring non-issue event: {event_type}", "INFO")
|
||||
self._respond(200, '{"status":"ignored","reason":"non-issue event"}')
|
||||
return
|
||||
|
||||
# 解析 payload
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
log("Failed to parse JSON payload", "ERR")
|
||||
self._respond(400, '{"error":"Invalid JSON"}')
|
||||
return
|
||||
|
||||
# 只处理 "opened" 动作
|
||||
action = payload.get("action", "")
|
||||
if action and action != "opened":
|
||||
log(f"Ignoring issue event with action: {action}", "INFO")
|
||||
self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}')
|
||||
return
|
||||
|
||||
# 提取 Issue 编号
|
||||
issue_number = extract_issue_number(payload)
|
||||
if not issue_number:
|
||||
self._respond(400, '{"error":"Cannot extract issue number"}')
|
||||
return
|
||||
|
||||
# 提取 owner/repo
|
||||
owner, repo = extract_repo_info(payload)
|
||||
|
||||
log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK")
|
||||
|
||||
# 异步调起分类
|
||||
run_triage_async(issue_number, owner or "", repo or "")
|
||||
|
||||
self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}')
|
||||
|
||||
def _respond(self, code, body, content_type="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body.encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
print()
|
||||
print("\033[36m╔══════════════════════════════════════════════════════════════╗\033[0m")
|
||||
print("\033[36m║ GitLink Community Ops — Webhook Listener (Linux) ║\033[0m")
|
||||
print("\033[36m╚══════════════════════════════════════════════════════════════╝\033[0m")
|
||||
print()
|
||||
|
||||
log(f"Starting on port {PORT}")
|
||||
log(f"Triage script: {TRIAGE_SCRIPT}")
|
||||
log(f"HMAC verification: {'ENABLED' if SECRET else 'DISABLED (set WEBHOOK_SECRET env var)'}")
|
||||
print()
|
||||
|
||||
server = HTTPServer(("0.0.0.0", PORT), WebhookHandler)
|
||||
log(f"Listening on http://0.0.0.0:{PORT}/webhook", "OK")
|
||||
log(f"Health check: http://0.0.0.0:{PORT}/", "OK")
|
||||
print()
|
||||
log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK")
|
||||
log(" Press Ctrl+C to stop", "OK")
|
||||
log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK")
|
||||
print()
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("Shutting down...", "INFO")
|
||||
server.shutdown()
|
||||
log("Stopped", "OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Webhook Setup — Register on GitLink Platform
|
||||
# Role: 在 GitLink 平台上注册 webhook,连接"平台事件"到"本地接收器"
|
||||
#
|
||||
# 这是整个实时链路的最后一块拼图——把 GitLink 平台的 Issue 创建事件
|
||||
# 和本地的 01a-webhook-listener.ps1 接收器连接起来。
|
||||
#
|
||||
# 完整链路:
|
||||
# GitLink Issue 创建
|
||||
# → Webhook POST 到 <WebhookUrl>
|
||||
# → 01a-webhook-listener.ps1 (接收 HTTP 请求)
|
||||
# → 01a-issue-triage.ps1 (AI 分类 + 打标签 + 分配)
|
||||
#
|
||||
# 前置条件:
|
||||
# 方案A (本地开发): 先启动 ngrok → 再启动 01a-webhook-listener.ps1 → 再运行本脚本
|
||||
# 方案B (公网服务器): 先启动 01a-webhook-listener.ps1 → 再运行本脚本(直接给公网URL)
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, HelpMessage="Webhook 回调 URL,GitLink 会向此 URL 推送事件")]
|
||||
[string]$WebhookUrl,
|
||||
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[string]$Secret = "",
|
||||
[string]$Events = "issue",
|
||||
[string]$Description = "Community Ops — Issue Auto-Triage (created by gitlink-cli)",
|
||||
[switch]$DryRun,
|
||||
[switch]$ListExisting,
|
||||
[switch]$DeleteExisting,
|
||||
[switch]$Force,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-webhook-setup.ps1 -WebhookUrl URL [-Owner OWNER] [-Repo REPO] [-Secret SECRET] [-Events EVENTS] [-DryRun]"
|
||||
Write-Host ""
|
||||
Write-Host " 在 GitLink 平台上注册 webhook,将 Issue 创建事件连接到本地接收器。"
|
||||
Write-Host ""
|
||||
Write-Host " -WebhookUrl URL (必填) Webhook 回调地址,GitLink 会向此 URL POST 事件"
|
||||
Write-Host " - 本地开发: https://xxx.ngrok-free.app/webhook"
|
||||
Write-Host " - 公网服务器: https://your-server.com:8080/webhook"
|
||||
Write-Host " -Owner OWNER 仓库所有者(在git仓库内可自动检测)"
|
||||
Write-Host " -Repo REPO 仓库名称(在git仓库内可自动检测)"
|
||||
Write-Host " -Secret SECRET HMAC 密钥(需与 01a-webhook-listener.ps1 的 -Secret 一致)"
|
||||
Write-Host " -Events EVENTS 触发事件类型(默认: issue)"
|
||||
Write-Host " -Description DESC Webhook 描述"
|
||||
Write-Host " -ListExisting 列出当前仓库已有的 webhook"
|
||||
Write-Host " -DeleteExisting 删除当前仓库所有非 gitlink-cli 创建的 issue 类 webhook(需配合 -Force)"
|
||||
Write-Host " -Force 配合 -DeleteExisting 使用"
|
||||
Write-Host " -DryRun 预览模式"
|
||||
Write-Host ""
|
||||
Write-Host " 部署步骤:"
|
||||
Write-Host " # 终端 1: 启动 ngrok(本地开发)"
|
||||
Write-Host " ngrok http 8080"
|
||||
Write-Host ""
|
||||
Write-Host " # 终端 2: 启动接收器"
|
||||
Write-Host " powershell workflows/01a-webhook-listener.ps1 -Port 8080 -Secret 'your-secret'"
|
||||
Write-Host ""
|
||||
Write-Host " # 终端 3: 注册 webhook(ngrok 提供的 URL)"
|
||||
Write-Host " powershell workflows/01a-webhook-setup.ps1 -WebhookUrl 'https://xxx.ngrok-free.app/webhook' -Secret 'your-secret'"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Check-Auth
|
||||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
# ================================================================
|
||||
# Validation
|
||||
# ================================================================
|
||||
Log-Title "Webhook Setup: $Owner/$Repo"
|
||||
|
||||
# Validate URL format
|
||||
if ($WebhookUrl -notmatch '^https?://') {
|
||||
Log-Err "Webhook URL must start with http:// or https://"
|
||||
Log-Info "For GitLink platform, HTTPS is required. Use ngrok for local dev: ngrok http 8080"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($WebhookUrl -notmatch '^https://') {
|
||||
Log-Warn "GitLink requires HTTPS URLs for webhooks. Your URL uses HTTP which may be rejected."
|
||||
Log-Info "Consider using ngrok to get an HTTPS URL: ngrok http <port>"
|
||||
}
|
||||
|
||||
Write-Host " Owner: $Owner"
|
||||
Write-Host " Repo: $Repo"
|
||||
Write-Host " Webhook URL: $WebhookUrl"
|
||||
Write-Host " Events: $Events"
|
||||
Write-Host " Secret: $(if ($Secret) { '***configured***' } else { '(not set)' })"
|
||||
Write-Host " Description: $Description"
|
||||
Divider
|
||||
|
||||
# ================================================================
|
||||
# List existing webhooks
|
||||
# ================================================================
|
||||
if ($ListExisting -or $DeleteExisting) {
|
||||
Log-Title "Existing Webhooks"
|
||||
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
if (-not $listResult) {
|
||||
Log-Warn "Could not list webhooks (API may not be available or no webhooks)"
|
||||
} else {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
if ($listData.ok -and $listData.data) {
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
if ($webhooks.Count -eq 0) {
|
||||
Log-Info "No webhooks configured for this repo"
|
||||
} else {
|
||||
Write-Host ""
|
||||
foreach ($wh in $webhooks) {
|
||||
$whId = if ($wh.id) { $wh.id } else { "?" }
|
||||
$whUrl = if ($wh.hook_url) { $wh.hook_url } elseif ($wh.url) { $wh.url } else { "?" }
|
||||
$whActive = if ($wh.is_active -ne $null) { $wh.is_active } elseif ($wh.active -ne $null) { $wh.active } else { "?" }
|
||||
$whEvents = if ($wh.events) { ($wh.events -join ',') } else { "?" }
|
||||
$whDesc = if ($wh.description) { $wh.description } else { "(no description)" }
|
||||
Write-Host " [#$whId] $whUrl" -ForegroundColor Cyan
|
||||
Write-Host " Active: $whActive | Events: $whEvents"
|
||||
Write-Host " $whDesc"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Could not parse webhook list: $_"
|
||||
Log-Info "Raw output: $listResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Delete existing issue-related webhooks (cleanup before create)
|
||||
# ================================================================
|
||||
if ($DeleteExisting) {
|
||||
if (-not $Force) {
|
||||
Log-Warn "-DeleteExisting requires -Force flag for safety. Add -Force to confirm deletion."
|
||||
} else {
|
||||
Log-Warn "Removing existing webhooks that match issue events..."
|
||||
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
if ($listResult) {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
foreach ($wh in $webhooks) {
|
||||
$whId = if ($wh.id) { $wh.id } else { $null }
|
||||
$whEvents = if ($wh.events) { $wh.events } else { @() }
|
||||
$whUrl = if ($wh.hook_url) { $wh.hook_url } else { "" }
|
||||
|
||||
# Only delete issue-related ones that point to gitlink-cli created URLs
|
||||
$isIssueWebhook = ($whEvents -contains "issue") -or ($whEvents -is [string] -and $whEvents -match "issue")
|
||||
if ($isIssueWebhook) {
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would delete webhook #$whId ($whUrl)"
|
||||
} else {
|
||||
Log-Step "Deleting webhook #$whId..."
|
||||
$delResult = Invoke-GL webhook,+delete,--owner,$Owner,--repo,$Repo,--id,$whId
|
||||
if ($delResult) {
|
||||
Log-Ok "Deleted webhook #$whId"
|
||||
} else {
|
||||
Log-Warn "Failed to delete webhook #$whId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ListExisting) {
|
||||
Log-Info "Cleanup complete. Proceeding to create new webhook..."
|
||||
} else {
|
||||
# User just wanted to list, exit
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if ($ListExisting) { exit 0 }
|
||||
|
||||
# ================================================================
|
||||
# Check for existing webhook with same URL
|
||||
# ================================================================
|
||||
Log-Step "Checking for existing webhooks with same URL..."
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
$existingId = $null
|
||||
if ($listResult) {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
foreach ($wh in $webhooks) {
|
||||
if ($wh.hook_url -eq $WebhookUrl -or $wh.url -eq $WebhookUrl) {
|
||||
$existingId = $wh.id
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if ($existingId) {
|
||||
Log-Warn "A webhook with URL '$WebhookUrl' already exists (ID: #$existingId)"
|
||||
Log-Info "To replace it, delete the existing one first with:"
|
||||
Log-Info " gitlink-cli webhook +delete --owner $Owner --repo $Repo --id $existingId"
|
||||
Log-Info "Or run this script with -DeleteExisting -Force to clean up"
|
||||
exit 1
|
||||
}
|
||||
Log-Ok "No duplicate webhook found"
|
||||
|
||||
# ================================================================
|
||||
# Register Webhook on GitLink
|
||||
# ================================================================
|
||||
Log-Title "Registering Webhook"
|
||||
|
||||
# Build command arguments
|
||||
$createArgs = @(
|
||||
"webhook", "+create",
|
||||
"--owner", $Owner,
|
||||
"--repo", $Repo,
|
||||
"--url", $WebhookUrl,
|
||||
"--events", $Events,
|
||||
"--description", $Description
|
||||
)
|
||||
|
||||
if ($Secret) {
|
||||
$createArgs += @("--secret", $Secret)
|
||||
}
|
||||
|
||||
Log-Step "Creating webhook on GitLink platform..."
|
||||
Log-Info "Command: gitlink-cli $($createArgs -join ' ')"
|
||||
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create webhook with above parameters"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$createResult = Invoke-GLCheck @createArgs
|
||||
if (-not $createResult) {
|
||||
Log-Err "Webhook creation failed"
|
||||
Log-Info "Common issues:"
|
||||
Log-Info " 1. URL must be HTTPS (GitLink requirement)"
|
||||
Log-Info " 2. URL must be publicly accessible from GitLink's servers"
|
||||
Log-Info " 3. You may need admin permissions on the repo"
|
||||
Log-Info " 4. Max 20 webhooks per repo — use -ListExisting to check"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$webhookId = ""
|
||||
if ($createResult.data.id) { $webhookId = $createResult.data.id }
|
||||
elseif ($createResult.data.webhook.id) { $webhookId = $createResult.data.webhook.id }
|
||||
Log-Ok "Webhook created! ID: #$webhookId"
|
||||
|
||||
# ================================================================
|
||||
# Test Webhook
|
||||
# ================================================================
|
||||
Log-Step "Testing webhook connectivity..."
|
||||
$testResult = Invoke-GL webhook,+test,--owner,$Owner,--repo,$Repo,--id,$webhookId,--event,issue
|
||||
if ($testResult) {
|
||||
try {
|
||||
$testOk = (($testResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $testOk = $false }
|
||||
|
||||
if ($testOk) {
|
||||
Log-Ok "Webhook test ping sent successfully"
|
||||
Log-Info "Check the listener console for the test event"
|
||||
} else {
|
||||
Log-Warn "Webhook test may have failed — check that your listener is running and accessible"
|
||||
Log-Info "Verify: curl -X POST $WebhookUrl -H 'Content-Type: application/json' -d '{}'"
|
||||
}
|
||||
} else {
|
||||
Log-Warn "Could not test webhook — check that your listener is running"
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Complete
|
||||
# ================================================================
|
||||
Log-Title "Webhook Setup Complete"
|
||||
Write-Host ""
|
||||
|
||||
$checkmark = [char]0x2714
|
||||
Write-Host " ${checkmark} GitLink Platform: Webhook registered" -ForegroundColor Green
|
||||
Write-Host " → When a new Issue is created in $Owner/$Repo" -ForegroundColor Gray
|
||||
Write-Host " → GitLink POSTs to: $WebhookUrl" -ForegroundColor Gray
|
||||
Write-Host " → Webhook ID: #$webhookId" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " ${checkmark} Local Listener: 01a-webhook-listener.ps1" -ForegroundColor Green
|
||||
Write-Host " → Receives HTTP POST from GitLink" -ForegroundColor Gray
|
||||
Write-Host " → Validates HMAC signature" -ForegroundColor Gray
|
||||
Write-Host " → Calls 01a-issue-triage.ps1" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " ${checkmark} Triage Script: 01a-issue-triage.ps1" -ForegroundColor Green
|
||||
Write-Host " → AI analyzes issue content" -ForegroundColor Gray
|
||||
Write-Host " → Selects matching label from repo's existing labels" -ForegroundColor Gray
|
||||
Write-Host " → Assigns to issue creator" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||
Write-Host " 验证方式:" -ForegroundColor Cyan
|
||||
Write-Host " 1. 在 GitLink 网页端 $Owner/$Repo 创建一个新 Issue" -ForegroundColor White
|
||||
Write-Host " 2. 观察 01a-webhook-listener.ps1 的控制台输出" -ForegroundColor White
|
||||
Write-Host " 3. 检查 Issue 是否自动被打上标签并分配了负责人" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " 手动测试(不走 webhook,直接触发分类):" -ForegroundColor Cyan
|
||||
Write-Host " powershell workflows/01a-issue-triage.ps1 -IssueNumber <N>" -ForegroundColor White
|
||||
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Webhook 管理:" -ForegroundColor Cyan
|
||||
Write-Host " - 查看: gitlink-cli webhook +list"
|
||||
Write-Host " - 详情: gitlink-cli webhook +info --id $webhookId"
|
||||
Write-Host " - 删除: gitlink-cli webhook +delete --id $webhookId"
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# Scenario 1a: Webhook Setup — 在 GitLink 平台注册 webhook
|
||||
# ================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
WEBHOOK_URL=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
SECRET=""
|
||||
EVENTS="issue"
|
||||
DESCRIPTION="Community Ops - Issue Auto-Triage (gitlink-cli)"
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --webhook-url URL [--owner OWNER] [--repo REPO] [--secret SECRET] [--events EVENTS] [--dry-run]"
|
||||
echo ""
|
||||
echo " 在 GitLink 平台注册 webhook,连接 Issue 创建事件到服务器接收器。"
|
||||
echo ""
|
||||
echo " --webhook-url URL (必填) Webhook 回调地址"
|
||||
echo " 例: https://your-server.com:8080/webhook"
|
||||
echo " --owner OWNER 仓库所有者"
|
||||
echo " --repo REPO 仓库名称"
|
||||
echo " --secret SECRET HMAC 密钥(需与监听器环境变量一致)"
|
||||
echo " --events EVENTS 触发事件(默认: issue)"
|
||||
echo " --dry-run 预览模式"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--webhook-url) WEBHOOK_URL="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--secret) SECRET="$2"; shift 2 ;;
|
||||
--events) EVENTS="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$WEBHOOK_URL" ]] && { log_err "--webhook-url is required"; usage; }
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
log_title "Webhook Setup: $OWNER/$REPO"
|
||||
echo " Webhook URL: $WEBHOOK_URL"
|
||||
echo " Events: $EVENTS"
|
||||
echo " Secret: $( [[ -n "$SECRET" ]] && echo '***configured***' || echo '(not set)' )"
|
||||
|
||||
# 检查重复
|
||||
log_step "Checking for existing webhooks with same URL..."
|
||||
EXISTING_ID=$(gl_run webhook +list --owner "$OWNER" --repo "$REPO" 2>/dev/null | \
|
||||
jq -r --arg url "$WEBHOOK_URL" '(.data.webhooks // .data // [])[] | select(.url == $url or .hook_url == $url) | .id' 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$EXISTING_ID" && "$EXISTING_ID" != "null" ]]; then
|
||||
log_warn "A webhook with URL '$WEBHOOK_URL' already exists (ID: #$EXISTING_ID)"
|
||||
log_info "Delete it first: gitlink-cli webhook +delete --id $EXISTING_ID"
|
||||
exit 1
|
||||
fi
|
||||
log_ok "No duplicate found"
|
||||
|
||||
# 注册
|
||||
log_step "Creating webhook on GitLink..."
|
||||
|
||||
CREATE_ARGS=(webhook +create --owner "$OWNER" --repo "$REPO" --url "$WEBHOOK_URL" --events "$EVENTS" --description "$DESCRIPTION")
|
||||
[[ -n "$SECRET" ]] && CREATE_ARGS+=(--secret "$SECRET")
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would run: gitlink-cli ${CREATE_ARGS[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CREATE_RESULT=$(gl_check "${CREATE_ARGS[@]}" 2>&1) || {
|
||||
log_err "Webhook creation failed"
|
||||
log_info "Make sure the URL is HTTPS and publicly accessible"
|
||||
exit 1
|
||||
}
|
||||
|
||||
WEBHOOK_ID=$(echo "$CREATE_RESULT" | jq -r '.data.id // .data.webhook.id')
|
||||
log_ok "Webhook created! ID: #$WEBHOOK_ID"
|
||||
|
||||
# 测试
|
||||
log_step "Testing webhook connectivity..."
|
||||
gl_run webhook +test --owner "$OWNER" --repo "$REPO" --id "$WEBHOOK_ID" --event issue > /dev/null 2>&1 && {
|
||||
log_ok "Webhook test ping sent successfully"
|
||||
} || {
|
||||
log_warn "Webhook test failed — check that the listener is running"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN} ✓ Webhook registered: https://www.gitlink.org.cn/$OWNER/$REPO${NC}"
|
||||
echo " → When a new Issue is created"
|
||||
echo " → GitLink POSTs to: $WEBHOOK_URL"
|
||||
echo " → Webhook ID: #$WEBHOOK_ID"
|
||||
|
|
@ -92,8 +92,13 @@ $readmeContent += "This project is licensed under the MIT License."
|
|||
|
||||
$wikiOk = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
<<<<<<< HEAD
|
||||
$wikiResult = Invoke-GL wiki,+create,--owner,$Owner,--repo,$Name,--title,"README",--content,$readmeContent
|
||||
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
|
||||
=======
|
||||
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "README", "--content", $readmeContent
|
||||
if ($wikiResult -and (Get-JsonOk $wikiResult)) {
|
||||
>>>>>>> master
|
||||
Log-Ok "README created"
|
||||
$wikiOk = $true
|
||||
break
|
||||
|
|
@ -127,8 +132,13 @@ $contribContent += "- Include environment details"
|
|||
|
||||
$wikiOk = $false
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
<<<<<<< HEAD
|
||||
$wikiContrib = Invoke-GL wiki,+create,--owner,$Owner,--repo,$Name,--title,"CONTRIBUTING",--content,$contribContent
|
||||
if ($wikiContrib -and (Get-JsonOk ($wikiContrib | ConvertFrom-Json))) {
|
||||
=======
|
||||
$wikiContrib = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "CONTRIBUTING", "--content", $contribContent
|
||||
if ($wikiContrib -and (Get-JsonOk $wikiContrib)) {
|
||||
>>>>>>> master
|
||||
Log-Ok "CONTRIBUTING guide created"
|
||||
$wikiOk = $true
|
||||
break
|
||||
|
|
@ -150,7 +160,11 @@ $ciContent += "3. **Deploy**: Deploy to staging (master branch only)" + "`n`n"
|
|||
$ciContent += "### Configuration" + "`n`n"
|
||||
$ciContent += "Create a ``.gitlink-ci.yml`` file in the repository root."
|
||||
|
||||
<<<<<<< HEAD
|
||||
Invoke-GL wiki,+create,--owner,$Owner,--repo,$Name,--title,"CI/CD Configuration",--content,$ciContent | Out-Null
|
||||
=======
|
||||
Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "CI/CD Configuration", "--content", $ciContent | Out-Null
|
||||
>>>>>>> master
|
||||
Log-Ok "CI/CD configuration guide created"
|
||||
|
||||
# -- Step 5: Create Initial Issues --
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# GitLink Community Ops — 一键部署到 Linux 服务器
|
||||
#
|
||||
# 用法:
|
||||
# 在本地执行 (scp 上传 + 远程安装):
|
||||
# bash deploy.sh --host 1.2.3.4 --port 8080 \
|
||||
# --secret "my-secret" --owner mengcheng --repo gitlink_help_center
|
||||
#
|
||||
# 或在服务器本地执行 (已经上传完文件后):
|
||||
# sudo bash deploy.sh --local --port 8080 --secret "my-secret" \
|
||||
# --owner mengcheng --repo gitlink_help_center --webhook-url "https://1.2.3.4:8080/webhook"
|
||||
# ================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
log() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[ OK]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ ERR]${NC} $*"; exit 1; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
|
||||
# ── 参数 ────────────────────────────────────────────────────────
|
||||
HOST=""
|
||||
PORT="8080"
|
||||
SECRET=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
WEBHOOK_URL=""
|
||||
LOCAL=false
|
||||
INSTALL_DIR="/opt/gitlink-webhook"
|
||||
SYSTEMD_USER="gitlink"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --host IP --port PORT --secret SECRET --owner OWNER --repo REPO"
|
||||
echo " 或 $0 --local --port PORT --secret SECRET --owner OWNER --repo REPO --webhook-url URL"
|
||||
echo ""
|
||||
echo " 远程部署 (本地执行):"
|
||||
echo " --host IP 服务器公网 IP"
|
||||
echo " --port PORT 监听端口 (默认 8080)"
|
||||
echo " --secret SECRET HMAC 密钥"
|
||||
echo " --owner OWNER GitLink 仓库所有者"
|
||||
echo " --repo REPO GitLink 仓库名"
|
||||
echo ""
|
||||
echo " 本地安装 (服务器上执行):"
|
||||
echo " --local 在当前机器安装"
|
||||
echo " --webhook-url URL 完整 webhook 回调 URL"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--secret) SECRET="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--webhook-url) WEBHOOK_URL="$2"; shift 2 ;;
|
||||
--local) LOCAL=true; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) err "Unknown arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── 校验 ────────────────────────────────────────────────────────
|
||||
if [[ "$LOCAL" == "true" ]]; then
|
||||
[[ -z "$WEBHOOK_URL" ]] && err "--webhook-url is required in --local mode"
|
||||
[[ -z "$SECRET" ]] && err "--secret is required"
|
||||
else
|
||||
[[ -z "$HOST" ]] && err "--host is required for remote deployment"
|
||||
[[ -z "$SECRET" ]] && err "--secret is required"
|
||||
WEBHOOK_URL="https://${HOST}:${PORT}/webhook"
|
||||
fi
|
||||
|
||||
WORKFLOW_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REQUIRED_FILES=(
|
||||
"01a-webhook-listener.py"
|
||||
"01a-issue-triage.sh"
|
||||
"01a-webhook-setup.sh"
|
||||
"01-community-ops.sh"
|
||||
"lib/common.sh"
|
||||
"gitlink-webhook.service"
|
||||
)
|
||||
|
||||
# ── 远程部署 ─────────────────────────────────────────────────────
|
||||
if [[ "$LOCAL" != "true" ]]; then
|
||||
log "Deploying to $HOST ..."
|
||||
|
||||
# 检查文件
|
||||
for f in "${REQUIRED_FILES[@]}"; do
|
||||
[[ -f "$WORKFLOW_DIR/$f" ]] || err "Missing: $WORKFLOW_DIR/$f"
|
||||
done
|
||||
|
||||
log "Uploading files to $HOST:$INSTALL_DIR ..."
|
||||
ssh "root@$HOST" "mkdir -p $INSTALL_DIR/webhook-logs $INSTALL_DIR/workflows/lib" || err "SSH connection failed"
|
||||
|
||||
scp "$WORKFLOW_DIR/01a-webhook-listener.py" "root@$HOST:$INSTALL_DIR/"
|
||||
scp "$WORKFLOW_DIR/01a-issue-triage.sh" "root@$HOST:$INSTALL_DIR/workflows/"
|
||||
scp "$WORKFLOW_DIR/01-community-ops.sh" "root@$HOST:$INSTALL_DIR/workflows/"
|
||||
scp "$WORKFLOW_DIR/01a-webhook-setup.sh" "root@$HOST:$INSTALL_DIR/workflows/"
|
||||
scp "$WORKFLOW_DIR/lib/common.sh" "root@$HOST:$INSTALL_DIR/workflows/lib/"
|
||||
scp "$WORKFLOW_DIR/gitlink-webhook.service" "root@$HOST:$INSTALL_DIR/"
|
||||
ok "Files uploaded"
|
||||
|
||||
log "Running remote installation..."
|
||||
ssh "root@$HOST" "bash -s" << REMOTE_SCRIPT
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="$INSTALL_DIR"
|
||||
PORT="$PORT"
|
||||
SECRET="$SECRET"
|
||||
OWNER="$OWNER"
|
||||
REPO="$REPO"
|
||||
WEBHOOK_URL="$WEBHOOK_URL"
|
||||
SYSTEMD_USER="$SYSTEMD_USER"
|
||||
|
||||
echo '=== Installing GitLink Webhook ==='
|
||||
|
||||
# 1. 创建用户
|
||||
if ! id -u \$SYSTEMD_USER &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -d \$INSTALL_DIR \$SYSTEMD_USER
|
||||
echo "[OK] User \$SYSTEMD_USER created"
|
||||
else
|
||||
echo "[OK] User \$SYSTEMD_USER exists"
|
||||
fi
|
||||
|
||||
# 2. 设置权限
|
||||
chown -R \$SYSTEMD_USER:\$SYSTEMD_USER \$INSTALL_DIR
|
||||
chmod +x \$INSTALL_DIR/01a-webhook-listener.py
|
||||
chmod +x \$INSTALL_DIR/workflows/*.sh
|
||||
echo "[OK] Permissions set"
|
||||
|
||||
# 3. 创建 .env
|
||||
cat > \$INSTALL_DIR/.env << EOF
|
||||
WEBHOOK_PORT=$PORT
|
||||
WEBHOOK_SECRET=$SECRET
|
||||
EOF
|
||||
chmod 600 \$INSTALL_DIR/.env
|
||||
chown \$SYSTEMD_USER:\$SYSTEMD_USER \$INSTALL_DIR/.env
|
||||
echo "[OK] .env created"
|
||||
|
||||
# 4. 开放防火墙
|
||||
if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then
|
||||
ufw allow \$PORT/tcp 2>/dev/null || true
|
||||
echo "[OK] Firewall: port \$PORT opened"
|
||||
elif command -v firewall-cmd &>/dev/null; then
|
||||
firewall-cmd --permanent --add-port=\$PORT/tcp 2>/dev/null || true
|
||||
firewall-cmd --reload 2>/dev/null || true
|
||||
echo "[OK] Firewall: port \$PORT opened"
|
||||
else
|
||||
echo "[WARN] No firewall detected — ensure port \$PORT is open in security group"
|
||||
fi
|
||||
|
||||
# 5. 安装 systemd 服务
|
||||
cp \$INSTALL_DIR/gitlink-webhook.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable gitlink-webhook
|
||||
systemctl restart gitlink-webhook
|
||||
echo "[OK] Systemd service installed and started"
|
||||
|
||||
# 6. 等待启动
|
||||
sleep 2
|
||||
systemctl status gitlink-webhook --no-pager | head -5
|
||||
|
||||
echo ''
|
||||
echo '=== Installation complete ==='
|
||||
echo "Health check: http://$HOST:$PORT/"
|
||||
echo "Webhook URL: $WEBHOOK_URL"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
ok "Remote installation complete"
|
||||
|
||||
# 7. 注册 webhook
|
||||
echo ""
|
||||
log "Registering webhook on GitLink..."
|
||||
ssh "root@$HOST" "cd \$INSTALL_DIR/workflows && bash 01a-webhook-setup.sh --webhook-url '$WEBHOOK_URL' --owner '$OWNER' --repo '$REPO' --secret '$SECRET'" || {
|
||||
warn "Webhook registration failed — you can run manually:"
|
||||
echo " ssh root@$HOST"
|
||||
echo " cd $INSTALL_DIR/workflows"
|
||||
echo " bash 01a-webhook-setup.sh --webhook-url '$WEBHOOK_URL' --owner '$OWNER' --repo '$REPO' --secret '$SECRET'"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Deployment Complete! ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ Health: http://$HOST:$PORT/ ║${NC}"
|
||||
echo -e "${GREEN}║ Webhook: $WEBHOOK_URL ║${NC}"
|
||||
echo -e "${GREEN}║ Logs: ssh root@$HOST journalctl -u gitlink-webhook -f ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# ── 本地安装(在服务器上执行)─────────────────────────────────────
|
||||
else
|
||||
[[ "$EUID" -ne 0 ]] && err "Please run as root (sudo)"
|
||||
|
||||
log "Installing locally to $INSTALL_DIR ..."
|
||||
|
||||
# 创建用户
|
||||
if ! id -u "$SYSTEMD_USER" &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -d "$INSTALL_DIR" "$SYSTEMD_USER"
|
||||
ok "User $SYSTEMD_USER created"
|
||||
fi
|
||||
|
||||
# 设置权限
|
||||
chown -R "$SYSTEMD_USER:$SYSTEMD_USER" "$INSTALL_DIR"
|
||||
chmod +x "$INSTALL_DIR/01a-webhook-listener.py"
|
||||
chmod +x "$INSTALL_DIR/workflows/"*.sh 2>/dev/null || true
|
||||
ok "Permissions set"
|
||||
|
||||
# .env
|
||||
cat > "$INSTALL_DIR/.env" << EOF
|
||||
WEBHOOK_PORT=$PORT
|
||||
WEBHOOK_SECRET=$SECRET
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
chown "$SYSTEMD_USER:$SYSTEMD_USER" "$INSTALL_DIR/.env"
|
||||
ok ".env created"
|
||||
|
||||
# 防火墙
|
||||
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
ufw allow "$PORT/tcp" 2>/dev/null || true
|
||||
ok "UFW: port $PORT opened"
|
||||
fi
|
||||
|
||||
# systemd
|
||||
cp "$INSTALL_DIR/gitlink-webhook.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable gitlink-webhook
|
||||
systemctl restart gitlink-webhook
|
||||
ok "Systemd service installed"
|
||||
|
||||
sleep 2
|
||||
systemctl status gitlink-webhook --no-pager | head -8
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Local installation complete!${NC}"
|
||||
echo " Health check: curl http://localhost:$PORT/"
|
||||
echo " Status: systemctl status gitlink-webhook"
|
||||
echo " Logs: journalctl -u gitlink-webhook -f"
|
||||
echo ""
|
||||
echo " Next: register webhook on GitLink:"
|
||||
echo " bash $INSTALL_DIR/workflows/01a-webhook-setup.sh \\"
|
||||
echo " --webhook-url '$WEBHOOK_URL' \\"
|
||||
echo " --owner '$OWNER' --repo '$REPO' --secret '$SECRET'"
|
||||
fi
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# ================================================================
|
||||
# GitLink Community Ops — Webhook Listener Systemd Service
|
||||
#
|
||||
# 安装:
|
||||
# sudo cp gitlink-webhook.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now gitlink-webhook
|
||||
#
|
||||
# 管理:
|
||||
# sudo systemctl status gitlink-webhook # 查看状态
|
||||
# sudo systemctl restart gitlink-webhook # 重启
|
||||
# sudo journalctl -u gitlink-webhook -f # 查看日志
|
||||
# ================================================================
|
||||
[Unit]
|
||||
Description=GitLink Community Ops Webhook Listener
|
||||
Documentation=https://github.com/your-org/gitlink-cli
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=gitlink
|
||||
Group=gitlink
|
||||
WorkingDirectory=/opt/gitlink-webhook
|
||||
EnvironmentFile=/opt/gitlink-webhook/.env
|
||||
ExecStart=/usr/bin/python3 /opt/gitlink-webhook/01a-webhook-listener.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# 安全加固
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/opt/gitlink-webhook/webhook-logs
|
||||
ReadOnlyPaths=/opt/gitlink-webhook/workflows
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -6,6 +6,11 @@
|
|||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# CRITICAL: gitlink-cli outputs UTF-8 JSON, but PS 5.1 on Chinese Windows
|
||||
# defaults to GBK (codepage 936) for decoding external program output.
|
||||
# Without this, multi-byte UTF-8 chars get garbled and JSON parsing fails.
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$Script:GL = "gitlink-cli"
|
||||
|
||||
# -- Logging --
|
||||
|
|
@ -39,6 +44,34 @@ function Check-Auth {
|
|||
# Returns parsed JSON object on success, $null on failure
|
||||
# Usage: Invoke-GL @("issue", "+list", "--owner", $Owner, "--repo", $Repo)
|
||||
function Invoke-GL {
|
||||
<<<<<<< HEAD
|
||||
param([string[]]$Arguments)
|
||||
# Suppress stderr to keep JSON output clean (errors go to console via error stream)
|
||||
$output = & $Script:GL @Arguments --format json 2>$null
|
||||
if ($output) { return ($output -join "`n") }
|
||||
return ""
|
||||
}
|
||||
|
||||
function Invoke-GLCheck {
|
||||
param([string[]]$Arguments)
|
||||
$output = Invoke-GL $Arguments
|
||||
if (-not $output) {
|
||||
Log-Err "Command returned no output: $Script:GL $($Arguments -join ' ')"
|
||||
return $null
|
||||
}
|
||||
try {
|
||||
$json = $output | ConvertFrom-Json
|
||||
if (-not $json.ok) {
|
||||
$errMsg = if ($json.error.message) { $json.error.message } else { "unknown error" }
|
||||
Log-Err "Command failed: $Script:GL $($Arguments -join ' ')"
|
||||
Log-Err $errMsg
|
||||
return $null
|
||||
}
|
||||
return $json
|
||||
} catch {
|
||||
Log-Err "Command failed (non-JSON response): $Script:GL $($Arguments -join ' ')"
|
||||
Log-Err $output
|
||||
=======
|
||||
param([string[]]$CmdArgs)
|
||||
$allArgs = @($CmdArgs) + @("--format", "json")
|
||||
# Capture stdout only; stderr goes to console
|
||||
|
|
@ -48,6 +81,7 @@ function Invoke-GL {
|
|||
try {
|
||||
return ($raw | ConvertFrom-Json)
|
||||
} catch {
|
||||
>>>>>>> master
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue