Merge pull request '实现场景1:社区运营自动化' (#27) from mc_branch into master

This commit is contained in:
mengcheng 2026-07-07 09:43:33 +08:00
commit fe4341fede
12 changed files with 2284 additions and 359 deletions

View File

@ -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

View File

@ -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}"

View File

@ -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

View File

@ -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}"

View File

@ -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"

View File

@ -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()

View File

@ -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 回调 URLGitLink 会向此 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: 注册 webhookngrok 提供的 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"

View File

@ -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"

View File

@ -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 --

245
workflows/deploy.sh Normal file
View File

@ -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

View File

@ -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

View File

@ -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
}
}