diff --git a/workflows/01-community-ops.ps1 b/workflows/01-community-ops.ps1 index 23c99591..3e5c5c0a 100644 --- a/workflows/01-community-ops.ps1 +++ b/workflows/01-community-ops.ps1 @@ -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 diff --git a/workflows/01-community-ops.sh b/workflows/01-community-ops.sh index b3b53bb2..d779295c 100644 --- a/workflows/01-community-ops.sh +++ b/workflows/01-community-ops.sh @@ -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}" diff --git a/workflows/01a-issue-triage.ps1 b/workflows/01a-issue-triage.ps1 new file mode 100644 index 00000000..79c49716 --- /dev/null +++ b/workflows/01a-issue-triage.ps1 @@ -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 diff --git a/workflows/01a-issue-triage.sh b/workflows/01a-issue-triage.sh new file mode 100644 index 00000000..989f5393 --- /dev/null +++ b/workflows/01a-issue-triage.sh @@ -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}" diff --git a/workflows/01a-webhook-listener.ps1 b/workflows/01a-webhook-listener.ps1 new file mode 100644 index 00000000..d138cec9 --- /dev/null +++ b/workflows/01a-webhook-listener.ps1 @@ -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://:/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 = @" + +GitLink Webhook Listener + + +

GitLink Community Ops — Webhook Listener

+

✓ Running

+

Listening for issue events at ${listenUrl}webhook

+

When a new Issue is created, this server will:

+
    +
  1. Receive the webhook payload from GitLink
  2. +
  3. Validate HMAC signature (if secret is configured)
  4. +
  5. Extract the issue number
  6. +
  7. Call 01a-issue-triage.ps1 to AI-classify and assign
  8. +
+

Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Port: $Port | HMAC: $(if($Secret){'enabled'}else{'disabled'})

+ +"@ + $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" diff --git a/workflows/01a-webhook-listener.py b/workflows/01a-webhook-listener.py new file mode 100644 index 00000000..2a3da6f5 --- /dev/null +++ b/workflows/01a-webhook-listener.py @@ -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""" +GitLink Webhook Listener + + +

GitLink Community Ops — Webhook Listener

+

✓ Running

+

Listening for issue events at /webhook

+

{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}

+""" + 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() diff --git a/workflows/01a-webhook-setup.ps1 b/workflows/01a-webhook-setup.ps1 new file mode 100644 index 00000000..7d34bcf2 --- /dev/null +++ b/workflows/01a-webhook-setup.ps1 @@ -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 到 +# → 01a-webhook-listener.ps1 (接收 HTTP 请求) +# → 01a-issue-triage.ps1 (AI 分类 + 打标签 + 分配) +# +# 前置条件: +# 方案A (本地开发): 先启动 ngrok → 再启动 01a-webhook-listener.ps1 → 再运行本脚本 +# 方案B (公网服务器): 先启动 01a-webhook-listener.ps1 → 再运行本脚本(直接给公网URL) +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [Parameter(Mandatory=$true, HelpMessage="Webhook 回调 URL,GitLink 会向此 URL 推送事件")] + [string]$WebhookUrl, + + [string]$Owner = "", + [string]$Repo = "", + [string]$Secret = "", + [string]$Events = "issue", + [string]$Description = "Community Ops — Issue Auto-Triage (created by gitlink-cli)", + [switch]$DryRun, + [switch]$ListExisting, + [switch]$DeleteExisting, + [switch]$Force, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force + +if ($Help) { + Write-Host "Usage: powershell 01a-webhook-setup.ps1 -WebhookUrl URL [-Owner OWNER] [-Repo REPO] [-Secret SECRET] [-Events EVENTS] [-DryRun]" + Write-Host "" + Write-Host " 在 GitLink 平台上注册 webhook,将 Issue 创建事件连接到本地接收器。" + Write-Host "" + Write-Host " -WebhookUrl URL (必填) Webhook 回调地址,GitLink 会向此 URL POST 事件" + Write-Host " - 本地开发: https://xxx.ngrok-free.app/webhook" + Write-Host " - 公网服务器: https://your-server.com:8080/webhook" + Write-Host " -Owner OWNER 仓库所有者(在git仓库内可自动检测)" + Write-Host " -Repo REPO 仓库名称(在git仓库内可自动检测)" + Write-Host " -Secret SECRET HMAC 密钥(需与 01a-webhook-listener.ps1 的 -Secret 一致)" + Write-Host " -Events EVENTS 触发事件类型(默认: issue)" + Write-Host " -Description DESC Webhook 描述" + Write-Host " -ListExisting 列出当前仓库已有的 webhook" + Write-Host " -DeleteExisting 删除当前仓库所有非 gitlink-cli 创建的 issue 类 webhook(需配合 -Force)" + Write-Host " -Force 配合 -DeleteExisting 使用" + Write-Host " -DryRun 预览模式" + Write-Host "" + Write-Host " 部署步骤:" + Write-Host " # 终端 1: 启动 ngrok(本地开发)" + Write-Host " ngrok http 8080" + Write-Host "" + Write-Host " # 终端 2: 启动接收器" + Write-Host " powershell workflows/01a-webhook-listener.ps1 -Port 8080 -Secret 'your-secret'" + Write-Host "" + Write-Host " # 终端 3: 注册 webhook(ngrok 提供的 URL)" + Write-Host " powershell workflows/01a-webhook-setup.ps1 -WebhookUrl 'https://xxx.ngrok-free.app/webhook' -Secret 'your-secret'" + exit 0 +} + +Check-Auth +$r = Resolve-OwnerRepo $Owner $Repo +$Owner = $r.Owner; $Repo = $r.Repo + +# ================================================================ +# Validation +# ================================================================ +Log-Title "Webhook Setup: $Owner/$Repo" + +# Validate URL format +if ($WebhookUrl -notmatch '^https?://') { + Log-Err "Webhook URL must start with http:// or https://" + Log-Info "For GitLink platform, HTTPS is required. Use ngrok for local dev: ngrok http 8080" + exit 1 +} + +if ($WebhookUrl -notmatch '^https://') { + Log-Warn "GitLink requires HTTPS URLs for webhooks. Your URL uses HTTP which may be rejected." + Log-Info "Consider using ngrok to get an HTTPS URL: ngrok http " +} + +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 " -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" diff --git a/workflows/01a-webhook-setup.sh b/workflows/01a-webhook-setup.sh new file mode 100644 index 00000000..f368dbf3 --- /dev/null +++ b/workflows/01a-webhook-setup.sh @@ -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" diff --git a/workflows/03-project-init.ps1 b/workflows/03-project-init.ps1 index 04ae95e5..f9c9a502 100644 --- a/workflows/03-project-init.ps1 +++ b/workflows/03-project-init.ps1 @@ -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 -- diff --git a/workflows/deploy.sh b/workflows/deploy.sh new file mode 100644 index 00000000..1f09786b --- /dev/null +++ b/workflows/deploy.sh @@ -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 diff --git a/workflows/gitlink-webhook.service b/workflows/gitlink-webhook.service new file mode 100644 index 00000000..c63fd72f --- /dev/null +++ b/workflows/gitlink-webhook.service @@ -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 diff --git a/workflows/lib/common.psm1 b/workflows/lib/common.psm1 index e8f3a146..b0520891 100644 --- a/workflows/lib/common.psm1 +++ b/workflows/lib/common.psm1 @@ -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 } }