forked from Gitlink/gitlink-cli
feat(workflows): 社区运营自动化 — Webhook接收器 + 部署 + systemd
This commit is contained in:
parent
411403ac5a
commit
0b6d176a45
|
|
@ -0,0 +1,410 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Webhook HTTP Listener (实时链路接收器)
|
||||
# Role: HTTP server listening for GitLink webhook events (Issue created)
|
||||
# → Parse payload → Call 01a-issue-triage.ps1 to classify & assign
|
||||
#
|
||||
# 这是社区运营自动化"实时链路"的入口,负责接收 GitLink 平台推送的
|
||||
# Webhook 事件,解析 Issue 编号,然后调用分类脚本。
|
||||
#
|
||||
# 架构:
|
||||
# GitLink 平台 (Issue 创建)
|
||||
# → POST https://<YOUR_URL>:<PORT>/webhook
|
||||
# → 01a-webhook-listener.ps1 (本脚本,HTTP 服务器)
|
||||
# → 01a-issue-triage.ps1 (分类+打标签+分配)
|
||||
#
|
||||
# 部署方式:
|
||||
# A. 本地 + ngrok 内网穿透:
|
||||
# 1. 启动本脚本: powershell 01a-webhook-listener.ps1
|
||||
# 2. 启动 ngrok: ngrok http 8080
|
||||
# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://xxx.ngrok.io/webhook"
|
||||
#
|
||||
# B. 部署到公网服务器:
|
||||
# 1. 上传脚本到服务器
|
||||
# 2. 启动本脚本: powershell 01a-webhook-listener.ps1 -Port 443 -Ssl
|
||||
# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://your-server.com/webhook"
|
||||
#
|
||||
# C. 仅手动触发 (无需 webhook):
|
||||
# powershell 01a-issue-triage.ps1 -IssueNumber 42
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
param(
|
||||
[int]$Port = 8080,
|
||||
[string]$Secret = "",
|
||||
[string]$HostPrefix = "+",
|
||||
[switch]$Ssl,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ScriptDir = $PSScriptRoot
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-webhook-listener.ps1 [-Port PORT] [-Secret SECRET] [-HostPrefix +] [-Ssl]"
|
||||
Write-Host ""
|
||||
Write-Host " Webhook HTTP 接收器 — 监听 GitLink 平台的 Issue 事件,自动触发分类。"
|
||||
Write-Host ""
|
||||
Write-Host " -Port PORT 监听端口 (默认: 8080)"
|
||||
Write-Host " -Secret SECRET HMAC 密钥,用于验证 GitLink 请求来源(需与注册时一致)"
|
||||
Write-Host " -HostPrefix PREFIX 监听主机前缀 (默认: + 表示所有IP,也可用 localhost)"
|
||||
Write-Host " -Ssl 启用 HTTPS (需要已导入的 SSL 证书)"
|
||||
Write-Host ""
|
||||
Write-Host " 部署前准备:"
|
||||
Write-Host " 如需公网访问,请使用 ngrok 或部署到有公网IP的服务器:"
|
||||
Write-Host " ngrok http $Port"
|
||||
Write-Host " 然后运行 01a-webhook-setup.ps1 在 GitLink 平台注册 webhook"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Color Helpers (no dependency on common.psm1 since this is a server)
|
||||
# ================================================================
|
||||
function Log-Step { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [STEP] $Msg" -ForegroundColor Blue }
|
||||
function Log-Ok { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ OK] $Msg" -ForegroundColor Green }
|
||||
function Log-Warn { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [WARN] $Msg" -ForegroundColor Yellow }
|
||||
function Log-Err { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ ERR] $Msg" -ForegroundColor Red }
|
||||
function Log-Info { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [INFO] $Msg" -ForegroundColor Cyan }
|
||||
|
||||
# ================================================================
|
||||
# HMAC Signature Verification
|
||||
# ================================================================
|
||||
function Test-WebhookSignature {
|
||||
param(
|
||||
[string]$RequestBody,
|
||||
[string]$SignatureHeader,
|
||||
[string]$Secret
|
||||
)
|
||||
if (-not $Secret) { return $true } # No secret configured, skip verification
|
||||
|
||||
if (-not $SignatureHeader) {
|
||||
Log-Warn "No signature header in request (expected X-GitLink-Signature or X-Hub-Signature-256)"
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
$hmac = New-Object System.Security.Cryptography.HMACSHA256
|
||||
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret)
|
||||
$hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($RequestBody))
|
||||
$computed = "sha256=" + [System.BitConverter]::ToString($hash).Replace("-", "").ToLower()
|
||||
|
||||
# Support both X-GitLink-Signature and X-Hub-Signature-256 (GitHub-compatible)
|
||||
# Strip prefix if present (e.g., "sha256=abc123..." → "abc123...")
|
||||
$received = $SignatureHeader
|
||||
if ($received -match '^sha256=') {
|
||||
$received = $received
|
||||
} else {
|
||||
$received = "sha256=$received"
|
||||
}
|
||||
|
||||
return $computed -eq $received
|
||||
} catch {
|
||||
Log-Err "HMAC verification error: $_"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Extract Issue Number from Webhook Payload
|
||||
# ================================================================
|
||||
function Get-IssueNumberFromPayload {
|
||||
param([string]$Body, [string]$EventType)
|
||||
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
|
||||
# Try multiple known payload structures
|
||||
# GitLink format: { action: "opened", issue: { id: ..., number: ..., project_issues_index: ... } }
|
||||
if ($payload.issue) {
|
||||
$num = $payload.issue.project_issues_index
|
||||
if (-not $num) { $num = $payload.issue.number }
|
||||
if (-not $num) { $num = $payload.issue.id }
|
||||
if ($num) {
|
||||
Log-Info "Extracted issue number: #$num (event: $EventType)"
|
||||
return $num.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
# GitHub-compatible format: { action: "opened", issue: { number: ... } }
|
||||
if ($payload.issue -and $payload.issue.number) {
|
||||
Log-Info "Extracted issue number (GitHub format): #$($payload.issue.number)"
|
||||
return $payload.issue.number.ToString()
|
||||
}
|
||||
|
||||
# Direct format: { number: ..., id: ... }
|
||||
if ($payload.number) { return $payload.number.ToString() }
|
||||
if ($payload.id) {
|
||||
Log-Info "Extracted issue id: $($payload.id)"
|
||||
return $payload.id.ToString()
|
||||
}
|
||||
|
||||
Log-Warn "Could not extract issue number from payload"
|
||||
Log-Info "Payload keys: $($payload.PSObject.Properties.Name -join ', ')"
|
||||
if ($payload.issue) {
|
||||
Log-Info "Issue keys: $($payload.issue.PSObject.Properties.Name -join ', ')"
|
||||
}
|
||||
return $null
|
||||
} catch {
|
||||
Log-Err "Failed to parse webhook payload: $_"
|
||||
Log-Info "Raw body (first 500 chars): $($Body.Substring(0, [Math]::Min(500, $Body.Length)))"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Extract Owner/Repo from payload or git remote
|
||||
# ================================================================
|
||||
function Get-RepoInfoFromPayload {
|
||||
param([string]$Body)
|
||||
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
$owner = $null
|
||||
$repo = $null
|
||||
|
||||
# GitLink format
|
||||
if ($payload.repository) {
|
||||
if ($payload.repository.owner) {
|
||||
$owner = if ($payload.repository.owner.login) { $payload.repository.owner.login }
|
||||
elseif ($payload.repository.owner.username) { $payload.repository.owner.username }
|
||||
else { $payload.repository.owner }
|
||||
}
|
||||
if ($payload.repository.name) { $repo = $payload.repository.name }
|
||||
}
|
||||
|
||||
# GitHub-compatible format
|
||||
if ((-not $owner) -and $payload.repository -and $payload.repository.full_name) {
|
||||
$parts = $payload.repository.full_name -split '/'
|
||||
$owner = $parts[0]
|
||||
$repo = $parts[1]
|
||||
}
|
||||
|
||||
return @{ Owner = $owner; Repo = $repo }
|
||||
} catch {
|
||||
return @{ Owner = $null; Repo = $null }
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Process Incoming Webhook
|
||||
# ================================================================
|
||||
function Invoke-WebhookHandler {
|
||||
param(
|
||||
[string]$Body,
|
||||
[string]$EventType,
|
||||
[string]$EventHeader,
|
||||
[string]$SignatureHeader
|
||||
)
|
||||
|
||||
# Validate secret if configured
|
||||
if ($Secret -and -not (Test-WebhookSignature -RequestBody $Body -SignatureHeader $SignatureHeader -Secret $Secret)) {
|
||||
Log-Err "HMAC signature verification FAILED — request rejected"
|
||||
return @{ StatusCode = 403; Body = '{"error":"Invalid signature"}' }
|
||||
}
|
||||
|
||||
# Only process issue events
|
||||
if ($EventType -notmatch '^issue' -and $EventHeader -notmatch 'issue') {
|
||||
Log-Info "Ignoring non-issue event: $EventType"
|
||||
return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"non-issue event"}' }
|
||||
}
|
||||
|
||||
# Only process "opened" action (new issue created)
|
||||
try {
|
||||
$payload = $Body | ConvertFrom-Json
|
||||
if ($payload.action -and $payload.action -ne 'opened') {
|
||||
Log-Info "Ignoring issue event with action: $($payload.action)"
|
||||
return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"action is not opened"}' }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# Extract issue number
|
||||
$issueNumber = Get-IssueNumberFromPayload -Body $Body -EventType $EventType
|
||||
if (-not $issueNumber) {
|
||||
Log-Err "Cannot extract issue number — skipping triage"
|
||||
return @{ StatusCode = 400; Body = '{"error":"Cannot extract issue number from payload"}' }
|
||||
}
|
||||
|
||||
Log-Ok "=== New Issue #$issueNumber — dispatching to triage ==="
|
||||
|
||||
# Extract owner/repo to pass to triage script
|
||||
$repoInfo = Get-RepoInfoFromPayload -Body $Body
|
||||
|
||||
# Dispatch triage script asynchronously so we can respond to webhook quickly
|
||||
$jobScript = {
|
||||
param($ScriptDir, $IssueNum, $Owner, $Repo, $Body)
|
||||
$argList = @("-File", "$ScriptDir\01a-issue-triage.ps1", "-IssueNumber", $IssueNum)
|
||||
if ($Owner) { $argList += @("-Owner", $Owner) }
|
||||
if ($Repo) { $argList += @("-Repo", $Repo) }
|
||||
$result = & powershell.exe -NoProfile -ExecutionPolicy Bypass @argList 2>&1
|
||||
$result | Out-File "$ScriptDir\webhook-triage-$IssueNum-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8
|
||||
}
|
||||
|
||||
Start-Job -ScriptBlock $jobScript -ArgumentList $ScriptDir, $issueNumber, $repoInfo.Owner, $repoInfo.Repo, $Body | Out-Null
|
||||
Log-Ok "Triage job started for #$issueNumber (running in background)"
|
||||
|
||||
return @{ StatusCode = 200; Body = '{"status":"accepted","issue_number":' + $issueNumber + '}' }
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Main: Start HTTP Listener
|
||||
# ================================================================
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
|
||||
Write-Host "║ GitLink Community Ops — Webhook Listener ║" -ForegroundColor Cyan
|
||||
Write-Host "║ 实时链路接收器: Issue 创建 → 自动分类 → 分配责任人 ║" -ForegroundColor Cyan
|
||||
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
$protocol = if ($Ssl) { "https" } else { "http" }
|
||||
$listenUrl = "$($protocol)://$($HostPrefix):$Port/"
|
||||
|
||||
Log-Info "Starting listener on: $listenUrl"
|
||||
Log-Info "Triage script: $ScriptDir\01a-issue-triage.ps1"
|
||||
if ($Secret) {
|
||||
Log-Info "HMAC verification: ENABLED"
|
||||
} else {
|
||||
Log-Warn "HMAC verification: DISABLED (set -Secret to enable)"
|
||||
}
|
||||
|
||||
# Try to register URL ACL if not running as admin for non-localhost
|
||||
if ($HostPrefix -ne "localhost" -and $HostPrefix -ne "127.0.0.1") {
|
||||
Write-Host ""
|
||||
Log-Warn "Listening on $HostPrefix requires URL ACL registration."
|
||||
Log-Info "If you get 'Access Denied', run as Administrator OR use -HostPrefix localhost"
|
||||
}
|
||||
|
||||
# Create HttpListener
|
||||
$listener = $null
|
||||
try {
|
||||
$listener = New-Object System.Net.HttpListener
|
||||
$listener.Prefixes.Add($listenUrl + "webhook/")
|
||||
$listener.Prefixes.Add($listenUrl) # Also listen on root path
|
||||
$listener.Start()
|
||||
Log-Ok "HTTP listener started successfully"
|
||||
} catch {
|
||||
Log-Err "Failed to start HTTP listener: $_"
|
||||
Write-Host ""
|
||||
Write-Host "Troubleshooting:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Run as Administrator"
|
||||
Write-Host " 2. Or register URL ACL manually:"
|
||||
Write-Host " netsh http add urlacl url=$listenUrl user=Everyone"
|
||||
Write-Host " 3. Or use localhost only: -HostPrefix localhost"
|
||||
Write-Host " 4. Check if port $Port is already in use: netstat -ano | findstr $Port"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Log-Ok " Listening on: $listenUrl"
|
||||
Log-Ok " Webhook URL: ${listenUrl}webhook"
|
||||
Log-Ok " Health check: ${listenUrl}"
|
||||
Log-Ok ""
|
||||
Log-Ok " 按 Ctrl+C 停止服务"
|
||||
Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Write-Host ""
|
||||
|
||||
# Handle Ctrl+C gracefully
|
||||
$keepRunning = $true
|
||||
$null = Register-EngineEvent -SourceIdentifier "WebhookListenerStop" -Forward -SupportEvent
|
||||
try {
|
||||
[Console]::TreatControlCAsInput = $false
|
||||
} catch { }
|
||||
|
||||
# Main event loop
|
||||
while ($keepRunning) {
|
||||
try {
|
||||
$context = $listener.GetContext()
|
||||
$request = $context.Request
|
||||
$response = $context.Response
|
||||
|
||||
$requestMethod = $request.HttpMethod
|
||||
$requestUrl = $request.Url.ToString()
|
||||
$remoteIp = $request.RemoteEndPoint.Address.ToString()
|
||||
|
||||
Log-Step "$requestMethod $requestUrl (from $remoteIp)"
|
||||
|
||||
if ($requestMethod -eq "GET" -and ($requestUrl -notmatch '/webhook$')) {
|
||||
# Health check / root page
|
||||
$html = @"
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>GitLink Webhook Listener</title>
|
||||
<style>body{font-family:sans-serif;max-width:800px;margin:40px auto;padding:20px}
|
||||
h1{color:#333}.status{color:green;font-weight:bold}code{background:#f0f0f0;padding:2px 6px;border-radius:3px}</style>
|
||||
</head><body>
|
||||
<h1>GitLink Community Ops — Webhook Listener</h1>
|
||||
<p class="status">✓ Running</p>
|
||||
<p>Listening for <code>issue</code> events at <code>${listenUrl}webhook</code></p>
|
||||
<p>When a new Issue is created, this server will:</p>
|
||||
<ol>
|
||||
<li>Receive the webhook payload from GitLink</li>
|
||||
<li>Validate HMAC signature (if secret is configured)</li>
|
||||
<li>Extract the issue number</li>
|
||||
<li>Call <code>01a-issue-triage.ps1</code> to AI-classify and assign</li>
|
||||
</ol>
|
||||
<p><small>Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Port: $Port | HMAC: $(if($Secret){'enabled'}else{'disabled'})</small></p>
|
||||
</body></html>
|
||||
"@
|
||||
$buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
|
||||
$response.ContentType = "text/html; charset=utf-8"
|
||||
$response.ContentLength64 = $buffer.Length
|
||||
$response.OutputStream.Write($buffer, 0, $buffer.Length)
|
||||
$response.OutputStream.Close()
|
||||
Log-Ok "Health check OK"
|
||||
continue
|
||||
}
|
||||
|
||||
# Read request body
|
||||
$reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding)
|
||||
$body = $reader.ReadToEnd()
|
||||
$reader.Close()
|
||||
|
||||
# Get event headers
|
||||
$eventType = $request.Headers.Get("X-GitLink-Event")
|
||||
if (-not $eventType) {
|
||||
$eventType = $request.Headers.Get("X-GitHub-Event") # GitHub-compatible
|
||||
}
|
||||
if (-not $eventType) {
|
||||
$eventType = $request.Headers.Get("X-Event-Type")
|
||||
}
|
||||
|
||||
$signatureHeader = $request.Headers.Get("X-GitLink-Signature")
|
||||
if (-not $signatureHeader) {
|
||||
$signatureHeader = $request.Headers.Get("X-Hub-Signature-256") # GitHub-compatible
|
||||
}
|
||||
|
||||
Log-Info "Event: $eventType | Body length: $($body.Length) bytes"
|
||||
|
||||
# Process the webhook
|
||||
$result = Invoke-WebhookHandler -Body $body -EventType $eventType -EventHeader $eventType -SignatureHeader $signatureHeader
|
||||
|
||||
# Send response
|
||||
$response.StatusCode = $result.StatusCode
|
||||
$responseBuffer = [System.Text.Encoding]::UTF8.GetBytes($result.Body)
|
||||
$response.ContentType = "application/json; charset=utf-8"
|
||||
$response.ContentLength64 = $responseBuffer.Length
|
||||
$response.OutputStream.Write($responseBuffer, 0, $responseBuffer.Length)
|
||||
$response.OutputStream.Close()
|
||||
|
||||
} catch [System.Net.HttpListenerException] {
|
||||
if ($_.Exception.ErrorCode -eq 995) {
|
||||
# Operation aborted — likely shutting down
|
||||
Log-Info "Listener shutting down..."
|
||||
$keepRunning = $false
|
||||
} else {
|
||||
Log-Err "HTTP error: $_"
|
||||
}
|
||||
} catch {
|
||||
Log-Err "Unexpected error: $_"
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
if ($listener -and $listener.IsListening) {
|
||||
$listener.Stop()
|
||||
$listener.Close()
|
||||
Log-Ok "HTTP listener stopped"
|
||||
}
|
||||
|
||||
Log-Ok "Webhook listener exited"
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
#!/usr/bin/env python3
|
||||
# ================================================================
|
||||
# Scenario 1a: Webhook HTTP Listener (Linux 版)
|
||||
# Role: HTTP server 监听 GitLink webhook → 解析 Issue 编号 → 调用分类脚本
|
||||
#
|
||||
# 部署: systemd 管理,端口 8080,无需 root(用 systemd socket activation 或 sudo)
|
||||
# 依赖: Python 3.6+ (无需额外 pip 包)
|
||||
# ================================================================
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────
|
||||
PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
|
||||
SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
TRIAGE_SCRIPT = os.path.join(SCRIPT_DIR, "01a-issue-triage.sh")
|
||||
LOG_DIR = os.path.join(SCRIPT_DIR, "webhook-logs")
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def log(msg, level="INFO"):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
color = {"INFO": "\033[36m", "OK": "\033[32m", "WARN": "\033[33m", "ERR": "\033[31m"}.get(level, "")
|
||||
reset = "\033[0m"
|
||||
print(f"[{ts}] {color}[{level:>4}]{reset} {msg}", flush=True)
|
||||
# Also append to log file
|
||||
logfile = os.path.join(LOG_DIR, datetime.now().strftime("webhook-%Y%m%d.log"))
|
||||
with open(logfile, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{ts}] [{level:>4}] {msg}\n")
|
||||
|
||||
|
||||
def verify_signature(body: bytes, signature_header: str) -> bool:
|
||||
"""HMAC-SHA256 签名验证"""
|
||||
if not SECRET:
|
||||
return True # 未配置密钥,跳过验证
|
||||
if not signature_header:
|
||||
log("No signature header in request", "WARN")
|
||||
return False
|
||||
|
||||
try:
|
||||
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
||||
# Support both "sha256=abc..." and plain "abc..." formats
|
||||
received = signature_header
|
||||
if received.startswith("sha256="):
|
||||
received = received[7:]
|
||||
return hmac.compare_digest(expected, received)
|
||||
except Exception as e:
|
||||
log(f"HMAC error: {e}", "ERR")
|
||||
return False
|
||||
|
||||
|
||||
def extract_issue_number(payload: dict) -> str:
|
||||
"""从 webhook payload 提取 Issue 编号"""
|
||||
# GitLink 格式
|
||||
issue = payload.get("issue", {})
|
||||
if issue:
|
||||
num = issue.get("project_issues_index") or issue.get("number") or issue.get("id")
|
||||
if num:
|
||||
log(f"Extracted issue number: #{num}", "OK")
|
||||
return str(num)
|
||||
|
||||
# 直接格式
|
||||
if "number" in payload:
|
||||
return str(payload["number"])
|
||||
if "id" in payload:
|
||||
return str(payload["id"])
|
||||
|
||||
log("Could not extract issue number from payload", "WARN")
|
||||
return None
|
||||
|
||||
|
||||
def extract_repo_info(payload: dict):
|
||||
"""从 payload 提取 owner/repo"""
|
||||
repo = payload.get("repository", {})
|
||||
owner = repo.get("owner", {})
|
||||
owner_name = owner.get("login") or owner.get("username") or str(owner) if isinstance(owner, dict) else str(owner)
|
||||
repo_name = repo.get("name", "")
|
||||
return owner_name, repo_name
|
||||
|
||||
|
||||
def run_triage_async(issue_number: str, owner: str = "", repo: str = ""):
|
||||
"""异步调用分类脚本,另起线程避免阻塞 webhook 响应"""
|
||||
def _run():
|
||||
cmd = ["bash", TRIAGE_SCRIPT, "--issue-number", issue_number]
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if repo:
|
||||
cmd += ["--repo", repo]
|
||||
|
||||
log(f"Dispatching triage: {' '.join(cmd)}", "INFO")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=SCRIPT_DIR)
|
||||
# Save output to log
|
||||
outfile = os.path.join(LOG_DIR, f"triage-{issue_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log")
|
||||
with open(outfile, "w", encoding="utf-8") as f:
|
||||
f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n")
|
||||
if result.returncode == 0:
|
||||
log(f"Triage #{issue_number} completed successfully → {outfile}", "OK")
|
||||
else:
|
||||
log(f"Triage #{issue_number} failed (exit={result.returncode}) → {outfile}", "ERR")
|
||||
except subprocess.TimeoutExpired:
|
||||
log(f"Triage #{issue_number} TIMEOUT after 120s", "ERR")
|
||||
except Exception as e:
|
||||
log(f"Triage #{issue_number} error: {e}", "ERR")
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
class WebhookHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, format, *args):
|
||||
log(f"{self.client_address[0]} - {format % args}", "INFO")
|
||||
|
||||
def do_GET(self):
|
||||
"""健康检查 / 根页面"""
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><title>GitLink Webhook Listener</title>
|
||||
<style>body{{font-family:sans-serif;max-width:800px;margin:40px auto;padding:20px}}
|
||||
h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padding:2px 6px;border-radius:3px}}</style>
|
||||
</head><body>
|
||||
<h1>GitLink Community Ops — Webhook Listener</h1>
|
||||
<p class="ok">✓ Running</p>
|
||||
<p>Listening for <code>issue</code> events at <code>/webhook</code></p>
|
||||
<p><small>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}</small></p>
|
||||
</body></html>"""
|
||||
self._respond(200, html, "text/html")
|
||||
|
||||
def do_POST(self):
|
||||
"""接收 webhook"""
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b""
|
||||
|
||||
event_type = self.headers.get("X-GitLink-Event") or self.headers.get("X-GitHub-Event") or ""
|
||||
signature = self.headers.get("X-GitLink-Signature") or self.headers.get("X-Hub-Signature-256") or ""
|
||||
|
||||
log(f"POST {self.path} | Event: {event_type} | Size: {content_length}B | From: {self.client_address[0]}")
|
||||
|
||||
# HMAC 验证
|
||||
if SECRET and not verify_signature(body, signature):
|
||||
self._respond(403, '{"error":"Invalid signature"}')
|
||||
return
|
||||
|
||||
# 只处理 issue 事件
|
||||
if "issue" not in event_type.lower():
|
||||
log(f"Ignoring non-issue event: {event_type}", "INFO")
|
||||
self._respond(200, '{"status":"ignored","reason":"non-issue event"}')
|
||||
return
|
||||
|
||||
# 解析 payload
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
log("Failed to parse JSON payload", "ERR")
|
||||
self._respond(400, '{"error":"Invalid JSON"}')
|
||||
return
|
||||
|
||||
# 只处理 "opened" 动作
|
||||
action = payload.get("action", "")
|
||||
if action and action != "opened":
|
||||
log(f"Ignoring issue event with action: {action}", "INFO")
|
||||
self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}')
|
||||
return
|
||||
|
||||
# 提取 Issue 编号
|
||||
issue_number = extract_issue_number(payload)
|
||||
if not issue_number:
|
||||
self._respond(400, '{"error":"Cannot extract issue number"}')
|
||||
return
|
||||
|
||||
# 提取 owner/repo
|
||||
owner, repo = extract_repo_info(payload)
|
||||
|
||||
log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK")
|
||||
|
||||
# 异步调起分类
|
||||
run_triage_async(issue_number, owner or "", repo or "")
|
||||
|
||||
self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}')
|
||||
|
||||
def _respond(self, code, body, content_type="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body.encode("utf-8"))
|
||||
|
||||
|
||||
def main():
|
||||
print()
|
||||
print("\033[36m╔══════════════════════════════════════════════════════════════╗\033[0m")
|
||||
print("\033[36m║ GitLink Community Ops — Webhook Listener (Linux) ║\033[0m")
|
||||
print("\033[36m╚══════════════════════════════════════════════════════════════╝\033[0m")
|
||||
print()
|
||||
|
||||
log(f"Starting on port {PORT}")
|
||||
log(f"Triage script: {TRIAGE_SCRIPT}")
|
||||
log(f"HMAC verification: {'ENABLED' if SECRET else 'DISABLED (set WEBHOOK_SECRET env var)'}")
|
||||
print()
|
||||
|
||||
server = HTTPServer(("0.0.0.0", PORT), WebhookHandler)
|
||||
log(f"Listening on http://0.0.0.0:{PORT}/webhook", "OK")
|
||||
log(f"Health check: http://0.0.0.0:{PORT}/", "OK")
|
||||
print()
|
||||
log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK")
|
||||
log(" Press Ctrl+C to stop", "OK")
|
||||
log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK")
|
||||
print()
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log("Shutting down...", "INFO")
|
||||
server.shutdown()
|
||||
log("Stopped", "OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
# ----------------------------------------------------------------
|
||||
# Scenario 1a: Webhook Setup — Register on GitLink Platform
|
||||
# Role: 在 GitLink 平台上注册 webhook,连接"平台事件"到"本地接收器"
|
||||
#
|
||||
# 这是整个实时链路的最后一块拼图——把 GitLink 平台的 Issue 创建事件
|
||||
# 和本地的 01a-webhook-listener.ps1 接收器连接起来。
|
||||
#
|
||||
# 完整链路:
|
||||
# GitLink Issue 创建
|
||||
# → Webhook POST 到 <WebhookUrl>
|
||||
# → 01a-webhook-listener.ps1 (接收 HTTP 请求)
|
||||
# → 01a-issue-triage.ps1 (AI 分类 + 打标签 + 分配)
|
||||
#
|
||||
# 前置条件:
|
||||
# 方案A (本地开发): 先启动 ngrok → 再启动 01a-webhook-listener.ps1 → 再运行本脚本
|
||||
# 方案B (公网服务器): 先启动 01a-webhook-listener.ps1 → 再运行本脚本(直接给公网URL)
|
||||
# ----------------------------------------------------------------
|
||||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, HelpMessage="Webhook 回调 URL,GitLink 会向此 URL 推送事件")]
|
||||
[string]$WebhookUrl,
|
||||
|
||||
[string]$Owner = "",
|
||||
[string]$Repo = "",
|
||||
[string]$Secret = "",
|
||||
[string]$Events = "issue",
|
||||
[string]$Description = "Community Ops — Issue Auto-Triage (created by gitlink-cli)",
|
||||
[switch]$DryRun,
|
||||
[switch]$ListExisting,
|
||||
[switch]$DeleteExisting,
|
||||
[switch]$Force,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: powershell 01a-webhook-setup.ps1 -WebhookUrl URL [-Owner OWNER] [-Repo REPO] [-Secret SECRET] [-Events EVENTS] [-DryRun]"
|
||||
Write-Host ""
|
||||
Write-Host " 在 GitLink 平台上注册 webhook,将 Issue 创建事件连接到本地接收器。"
|
||||
Write-Host ""
|
||||
Write-Host " -WebhookUrl URL (必填) Webhook 回调地址,GitLink 会向此 URL POST 事件"
|
||||
Write-Host " - 本地开发: https://xxx.ngrok-free.app/webhook"
|
||||
Write-Host " - 公网服务器: https://your-server.com:8080/webhook"
|
||||
Write-Host " -Owner OWNER 仓库所有者(在git仓库内可自动检测)"
|
||||
Write-Host " -Repo REPO 仓库名称(在git仓库内可自动检测)"
|
||||
Write-Host " -Secret SECRET HMAC 密钥(需与 01a-webhook-listener.ps1 的 -Secret 一致)"
|
||||
Write-Host " -Events EVENTS 触发事件类型(默认: issue)"
|
||||
Write-Host " -Description DESC Webhook 描述"
|
||||
Write-Host " -ListExisting 列出当前仓库已有的 webhook"
|
||||
Write-Host " -DeleteExisting 删除当前仓库所有非 gitlink-cli 创建的 issue 类 webhook(需配合 -Force)"
|
||||
Write-Host " -Force 配合 -DeleteExisting 使用"
|
||||
Write-Host " -DryRun 预览模式"
|
||||
Write-Host ""
|
||||
Write-Host " 部署步骤:"
|
||||
Write-Host " # 终端 1: 启动 ngrok(本地开发)"
|
||||
Write-Host " ngrok http 8080"
|
||||
Write-Host ""
|
||||
Write-Host " # 终端 2: 启动接收器"
|
||||
Write-Host " powershell workflows/01a-webhook-listener.ps1 -Port 8080 -Secret 'your-secret'"
|
||||
Write-Host ""
|
||||
Write-Host " # 终端 3: 注册 webhook(ngrok 提供的 URL)"
|
||||
Write-Host " powershell workflows/01a-webhook-setup.ps1 -WebhookUrl 'https://xxx.ngrok-free.app/webhook' -Secret 'your-secret'"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Check-Auth
|
||||
$r = Resolve-OwnerRepo $Owner $Repo
|
||||
$Owner = $r.Owner; $Repo = $r.Repo
|
||||
|
||||
# ================================================================
|
||||
# Validation
|
||||
# ================================================================
|
||||
Log-Title "Webhook Setup: $Owner/$Repo"
|
||||
|
||||
# Validate URL format
|
||||
if ($WebhookUrl -notmatch '^https?://') {
|
||||
Log-Err "Webhook URL must start with http:// or https://"
|
||||
Log-Info "For GitLink platform, HTTPS is required. Use ngrok for local dev: ngrok http 8080"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($WebhookUrl -notmatch '^https://') {
|
||||
Log-Warn "GitLink requires HTTPS URLs for webhooks. Your URL uses HTTP which may be rejected."
|
||||
Log-Info "Consider using ngrok to get an HTTPS URL: ngrok http <port>"
|
||||
}
|
||||
|
||||
Write-Host " Owner: $Owner"
|
||||
Write-Host " Repo: $Repo"
|
||||
Write-Host " Webhook URL: $WebhookUrl"
|
||||
Write-Host " Events: $Events"
|
||||
Write-Host " Secret: $(if ($Secret) { '***configured***' } else { '(not set)' })"
|
||||
Write-Host " Description: $Description"
|
||||
Divider
|
||||
|
||||
# ================================================================
|
||||
# List existing webhooks
|
||||
# ================================================================
|
||||
if ($ListExisting -or $DeleteExisting) {
|
||||
Log-Title "Existing Webhooks"
|
||||
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
if (-not $listResult) {
|
||||
Log-Warn "Could not list webhooks (API may not be available or no webhooks)"
|
||||
} else {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
if ($listData.ok -and $listData.data) {
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
if ($webhooks.Count -eq 0) {
|
||||
Log-Info "No webhooks configured for this repo"
|
||||
} else {
|
||||
Write-Host ""
|
||||
foreach ($wh in $webhooks) {
|
||||
$whId = if ($wh.id) { $wh.id } else { "?" }
|
||||
$whUrl = if ($wh.hook_url) { $wh.hook_url } elseif ($wh.url) { $wh.url } else { "?" }
|
||||
$whActive = if ($wh.is_active -ne $null) { $wh.is_active } elseif ($wh.active -ne $null) { $wh.active } else { "?" }
|
||||
$whEvents = if ($wh.events) { ($wh.events -join ',') } else { "?" }
|
||||
$whDesc = if ($wh.description) { $wh.description } else { "(no description)" }
|
||||
Write-Host " [#$whId] $whUrl" -ForegroundColor Cyan
|
||||
Write-Host " Active: $whActive | Events: $whEvents"
|
||||
Write-Host " $whDesc"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log-Warn "Could not parse webhook list: $_"
|
||||
Log-Info "Raw output: $listResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Delete existing issue-related webhooks (cleanup before create)
|
||||
# ================================================================
|
||||
if ($DeleteExisting) {
|
||||
if (-not $Force) {
|
||||
Log-Warn "-DeleteExisting requires -Force flag for safety. Add -Force to confirm deletion."
|
||||
} else {
|
||||
Log-Warn "Removing existing webhooks that match issue events..."
|
||||
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
if ($listResult) {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
foreach ($wh in $webhooks) {
|
||||
$whId = if ($wh.id) { $wh.id } else { $null }
|
||||
$whEvents = if ($wh.events) { $wh.events } else { @() }
|
||||
$whUrl = if ($wh.hook_url) { $wh.hook_url } else { "" }
|
||||
|
||||
# Only delete issue-related ones that point to gitlink-cli created URLs
|
||||
$isIssueWebhook = ($whEvents -contains "issue") -or ($whEvents -is [string] -and $whEvents -match "issue")
|
||||
if ($isIssueWebhook) {
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would delete webhook #$whId ($whUrl)"
|
||||
} else {
|
||||
Log-Step "Deleting webhook #$whId..."
|
||||
$delResult = Invoke-GL webhook,+delete,--owner,$Owner,--repo,$Repo,--id,$whId
|
||||
if ($delResult) {
|
||||
Log-Ok "Deleted webhook #$whId"
|
||||
} else {
|
||||
Log-Warn "Failed to delete webhook #$whId"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $ListExisting) {
|
||||
Log-Info "Cleanup complete. Proceeding to create new webhook..."
|
||||
} else {
|
||||
# User just wanted to list, exit
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if ($ListExisting) { exit 0 }
|
||||
|
||||
# ================================================================
|
||||
# Check for existing webhook with same URL
|
||||
# ================================================================
|
||||
Log-Step "Checking for existing webhooks with same URL..."
|
||||
$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo
|
||||
$existingId = $null
|
||||
if ($listResult) {
|
||||
try {
|
||||
$listData = $listResult | ConvertFrom-Json
|
||||
$webhooks = @()
|
||||
if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) }
|
||||
elseif ($listData.data -is [array]) { $webhooks = $listData.data }
|
||||
|
||||
foreach ($wh in $webhooks) {
|
||||
if ($wh.hook_url -eq $WebhookUrl -or $wh.url -eq $WebhookUrl) {
|
||||
$existingId = $wh.id
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
if ($existingId) {
|
||||
Log-Warn "A webhook with URL '$WebhookUrl' already exists (ID: #$existingId)"
|
||||
Log-Info "To replace it, delete the existing one first with:"
|
||||
Log-Info " gitlink-cli webhook +delete --owner $Owner --repo $Repo --id $existingId"
|
||||
Log-Info "Or run this script with -DeleteExisting -Force to clean up"
|
||||
exit 1
|
||||
}
|
||||
Log-Ok "No duplicate webhook found"
|
||||
|
||||
# ================================================================
|
||||
# Register Webhook on GitLink
|
||||
# ================================================================
|
||||
Log-Title "Registering Webhook"
|
||||
|
||||
# Build command arguments
|
||||
$createArgs = @(
|
||||
"webhook", "+create",
|
||||
"--owner", $Owner,
|
||||
"--repo", $Repo,
|
||||
"--url", $WebhookUrl,
|
||||
"--events", $Events,
|
||||
"--description", $Description
|
||||
)
|
||||
|
||||
if ($Secret) {
|
||||
$createArgs += @("--secret", $Secret)
|
||||
}
|
||||
|
||||
Log-Step "Creating webhook on GitLink platform..."
|
||||
Log-Info "Command: gitlink-cli $($createArgs -join ' ')"
|
||||
|
||||
if ($DryRun) {
|
||||
Log-Warn "[DRY RUN] Would create webhook with above parameters"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$createResult = Invoke-GLCheck @createArgs
|
||||
if (-not $createResult) {
|
||||
Log-Err "Webhook creation failed"
|
||||
Log-Info "Common issues:"
|
||||
Log-Info " 1. URL must be HTTPS (GitLink requirement)"
|
||||
Log-Info " 2. URL must be publicly accessible from GitLink's servers"
|
||||
Log-Info " 3. You may need admin permissions on the repo"
|
||||
Log-Info " 4. Max 20 webhooks per repo — use -ListExisting to check"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$webhookId = ""
|
||||
if ($createResult.data.id) { $webhookId = $createResult.data.id }
|
||||
elseif ($createResult.data.webhook.id) { $webhookId = $createResult.data.webhook.id }
|
||||
Log-Ok "Webhook created! ID: #$webhookId"
|
||||
|
||||
# ================================================================
|
||||
# Test Webhook
|
||||
# ================================================================
|
||||
Log-Step "Testing webhook connectivity..."
|
||||
$testResult = Invoke-GL webhook,+test,--owner,$Owner,--repo,$Repo,--id,$webhookId,--event,issue
|
||||
if ($testResult) {
|
||||
try {
|
||||
$testOk = (($testResult | ConvertFrom-Json).ok -eq $true)
|
||||
} catch { $testOk = $false }
|
||||
|
||||
if ($testOk) {
|
||||
Log-Ok "Webhook test ping sent successfully"
|
||||
Log-Info "Check the listener console for the test event"
|
||||
} else {
|
||||
Log-Warn "Webhook test may have failed — check that your listener is running and accessible"
|
||||
Log-Info "Verify: curl -X POST $WebhookUrl -H 'Content-Type: application/json' -d '{}'"
|
||||
}
|
||||
} else {
|
||||
Log-Warn "Could not test webhook — check that your listener is running"
|
||||
}
|
||||
|
||||
# ================================================================
|
||||
# Complete
|
||||
# ================================================================
|
||||
Log-Title "Webhook Setup Complete"
|
||||
Write-Host ""
|
||||
|
||||
$checkmark = [char]0x2714
|
||||
Write-Host " ${checkmark} GitLink Platform: Webhook registered" -ForegroundColor Green
|
||||
Write-Host " → When a new Issue is created in $Owner/$Repo" -ForegroundColor Gray
|
||||
Write-Host " → GitLink POSTs to: $WebhookUrl" -ForegroundColor Gray
|
||||
Write-Host " → Webhook ID: #$webhookId" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " ${checkmark} Local Listener: 01a-webhook-listener.ps1" -ForegroundColor Green
|
||||
Write-Host " → Receives HTTP POST from GitLink" -ForegroundColor Gray
|
||||
Write-Host " → Validates HMAC signature" -ForegroundColor Gray
|
||||
Write-Host " → Calls 01a-issue-triage.ps1" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " ${checkmark} Triage Script: 01a-issue-triage.ps1" -ForegroundColor Green
|
||||
Write-Host " → AI analyzes issue content" -ForegroundColor Gray
|
||||
Write-Host " → Selects matching label from repo's existing labels" -ForegroundColor Gray
|
||||
Write-Host " → Assigns to issue creator" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||
Write-Host " 验证方式:" -ForegroundColor Cyan
|
||||
Write-Host " 1. 在 GitLink 网页端 $Owner/$Repo 创建一个新 Issue" -ForegroundColor White
|
||||
Write-Host " 2. 观察 01a-webhook-listener.ps1 的控制台输出" -ForegroundColor White
|
||||
Write-Host " 3. 检查 Issue 是否自动被打上标签并分配了负责人" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " 手动测试(不走 webhook,直接触发分类):" -ForegroundColor Cyan
|
||||
Write-Host " powershell workflows/01a-issue-triage.ps1 -IssueNumber <N>" -ForegroundColor White
|
||||
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Webhook 管理:" -ForegroundColor Cyan
|
||||
Write-Host " - 查看: gitlink-cli webhook +list"
|
||||
Write-Host " - 详情: gitlink-cli webhook +info --id $webhookId"
|
||||
Write-Host " - 删除: gitlink-cli webhook +delete --id $webhookId"
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# Scenario 1a: Webhook Setup — 在 GitLink 平台注册 webhook
|
||||
# ================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
WEBHOOK_URL=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
SECRET=""
|
||||
EVENTS="issue"
|
||||
DESCRIPTION="Community Ops - Issue Auto-Triage (gitlink-cli)"
|
||||
DRY_RUN=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --webhook-url URL [--owner OWNER] [--repo REPO] [--secret SECRET] [--events EVENTS] [--dry-run]"
|
||||
echo ""
|
||||
echo " 在 GitLink 平台注册 webhook,连接 Issue 创建事件到服务器接收器。"
|
||||
echo ""
|
||||
echo " --webhook-url URL (必填) Webhook 回调地址"
|
||||
echo " 例: https://your-server.com:8080/webhook"
|
||||
echo " --owner OWNER 仓库所有者"
|
||||
echo " --repo REPO 仓库名称"
|
||||
echo " --secret SECRET HMAC 密钥(需与监听器环境变量一致)"
|
||||
echo " --events EVENTS 触发事件(默认: issue)"
|
||||
echo " --dry-run 预览模式"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--webhook-url) WEBHOOK_URL="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--secret) SECRET="$2"; shift 2 ;;
|
||||
--events) EVENTS="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN="true"; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) log_err "Unknown arg: $1"; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$WEBHOOK_URL" ]] && { log_err "--webhook-url is required"; usage; }
|
||||
|
||||
check_auth
|
||||
require_owner_repo
|
||||
|
||||
log_title "Webhook Setup: $OWNER/$REPO"
|
||||
echo " Webhook URL: $WEBHOOK_URL"
|
||||
echo " Events: $EVENTS"
|
||||
echo " Secret: $( [[ -n "$SECRET" ]] && echo '***configured***' || echo '(not set)' )"
|
||||
|
||||
# 检查重复
|
||||
log_step "Checking for existing webhooks with same URL..."
|
||||
EXISTING_ID=$(gl_run webhook +list --owner "$OWNER" --repo "$REPO" 2>/dev/null | \
|
||||
jq -r --arg url "$WEBHOOK_URL" '(.data.webhooks // .data // [])[] | select(.url == $url or .hook_url == $url) | .id' 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$EXISTING_ID" && "$EXISTING_ID" != "null" ]]; then
|
||||
log_warn "A webhook with URL '$WEBHOOK_URL' already exists (ID: #$EXISTING_ID)"
|
||||
log_info "Delete it first: gitlink-cli webhook +delete --id $EXISTING_ID"
|
||||
exit 1
|
||||
fi
|
||||
log_ok "No duplicate found"
|
||||
|
||||
# 注册
|
||||
log_step "Creating webhook on GitLink..."
|
||||
|
||||
CREATE_ARGS=(webhook +create --owner "$OWNER" --repo "$REPO" --url "$WEBHOOK_URL" --events "$EVENTS" --description "$DESCRIPTION")
|
||||
[[ -n "$SECRET" ]] && CREATE_ARGS+=(--secret "$SECRET")
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log_warn "[DRY RUN] Would run: gitlink-cli ${CREATE_ARGS[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CREATE_RESULT=$(gl_check "${CREATE_ARGS[@]}" 2>&1) || {
|
||||
log_err "Webhook creation failed"
|
||||
log_info "Make sure the URL is HTTPS and publicly accessible"
|
||||
exit 1
|
||||
}
|
||||
|
||||
WEBHOOK_ID=$(echo "$CREATE_RESULT" | jq -r '.data.id // .data.webhook.id')
|
||||
log_ok "Webhook created! ID: #$WEBHOOK_ID"
|
||||
|
||||
# 测试
|
||||
log_step "Testing webhook connectivity..."
|
||||
gl_run webhook +test --owner "$OWNER" --repo "$REPO" --id "$WEBHOOK_ID" --event issue > /dev/null 2>&1 && {
|
||||
log_ok "Webhook test ping sent successfully"
|
||||
} || {
|
||||
log_warn "Webhook test failed — check that the listener is running"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN} ✓ Webhook registered: https://www.gitlink.org.cn/$OWNER/$REPO${NC}"
|
||||
echo " → When a new Issue is created"
|
||||
echo " → GitLink POSTs to: $WEBHOOK_URL"
|
||||
echo " → Webhook ID: #$WEBHOOK_ID"
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
#!/usr/bin/env bash
|
||||
# ================================================================
|
||||
# GitLink Community Ops — 一键部署到 Linux 服务器
|
||||
#
|
||||
# 用法:
|
||||
# 在本地执行 (scp 上传 + 远程安装):
|
||||
# bash deploy.sh --host 1.2.3.4 --port 8080 \
|
||||
# --secret "my-secret" --owner mengcheng --repo gitlink_help_center
|
||||
#
|
||||
# 或在服务器本地执行 (已经上传完文件后):
|
||||
# sudo bash deploy.sh --local --port 8080 --secret "my-secret" \
|
||||
# --owner mengcheng --repo gitlink_help_center --webhook-url "https://1.2.3.4:8080/webhook"
|
||||
# ================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
log() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[ OK]${NC} $*"; }
|
||||
err() { echo -e "${RED}[ ERR]${NC} $*"; exit 1; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
|
||||
# ── 参数 ────────────────────────────────────────────────────────
|
||||
HOST=""
|
||||
PORT="8080"
|
||||
SECRET=""
|
||||
OWNER=""
|
||||
REPO=""
|
||||
WEBHOOK_URL=""
|
||||
LOCAL=false
|
||||
INSTALL_DIR="/opt/gitlink-webhook"
|
||||
SYSTEMD_USER="gitlink"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --host IP --port PORT --secret SECRET --owner OWNER --repo REPO"
|
||||
echo " 或 $0 --local --port PORT --secret SECRET --owner OWNER --repo REPO --webhook-url URL"
|
||||
echo ""
|
||||
echo " 远程部署 (本地执行):"
|
||||
echo " --host IP 服务器公网 IP"
|
||||
echo " --port PORT 监听端口 (默认 8080)"
|
||||
echo " --secret SECRET HMAC 密钥"
|
||||
echo " --owner OWNER GitLink 仓库所有者"
|
||||
echo " --repo REPO GitLink 仓库名"
|
||||
echo ""
|
||||
echo " 本地安装 (服务器上执行):"
|
||||
echo " --local 在当前机器安装"
|
||||
echo " --webhook-url URL 完整 webhook 回调 URL"
|
||||
exit 1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--secret) SECRET="$2"; shift 2 ;;
|
||||
--owner) OWNER="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--webhook-url) WEBHOOK_URL="$2"; shift 2 ;;
|
||||
--local) LOCAL=true; shift ;;
|
||||
--help|-h) usage ;;
|
||||
*) err "Unknown arg: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── 校验 ────────────────────────────────────────────────────────
|
||||
if [[ "$LOCAL" == "true" ]]; then
|
||||
[[ -z "$WEBHOOK_URL" ]] && err "--webhook-url is required in --local mode"
|
||||
[[ -z "$SECRET" ]] && err "--secret is required"
|
||||
else
|
||||
[[ -z "$HOST" ]] && err "--host is required for remote deployment"
|
||||
[[ -z "$SECRET" ]] && err "--secret is required"
|
||||
WEBHOOK_URL="https://${HOST}:${PORT}/webhook"
|
||||
fi
|
||||
|
||||
WORKFLOW_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REQUIRED_FILES=(
|
||||
"01a-webhook-listener.py"
|
||||
"01a-issue-triage.sh"
|
||||
"01a-webhook-setup.sh"
|
||||
"01-community-ops.sh"
|
||||
"lib/common.sh"
|
||||
"gitlink-webhook.service"
|
||||
)
|
||||
|
||||
# ── 远程部署 ─────────────────────────────────────────────────────
|
||||
if [[ "$LOCAL" != "true" ]]; then
|
||||
log "Deploying to $HOST ..."
|
||||
|
||||
# 检查文件
|
||||
for f in "${REQUIRED_FILES[@]}"; do
|
||||
[[ -f "$WORKFLOW_DIR/$f" ]] || err "Missing: $WORKFLOW_DIR/$f"
|
||||
done
|
||||
|
||||
log "Uploading files to $HOST:$INSTALL_DIR ..."
|
||||
ssh "root@$HOST" "mkdir -p $INSTALL_DIR/webhook-logs $INSTALL_DIR/workflows/lib" || err "SSH connection failed"
|
||||
|
||||
scp "$WORKFLOW_DIR/01a-webhook-listener.py" "root@$HOST:$INSTALL_DIR/"
|
||||
scp "$WORKFLOW_DIR/01a-issue-triage.sh" "root@$HOST:$INSTALL_DIR/workflows/"
|
||||
scp "$WORKFLOW_DIR/01-community-ops.sh" "root@$HOST:$INSTALL_DIR/workflows/"
|
||||
scp "$WORKFLOW_DIR/01a-webhook-setup.sh" "root@$HOST:$INSTALL_DIR/workflows/"
|
||||
scp "$WORKFLOW_DIR/lib/common.sh" "root@$HOST:$INSTALL_DIR/workflows/lib/"
|
||||
scp "$WORKFLOW_DIR/gitlink-webhook.service" "root@$HOST:$INSTALL_DIR/"
|
||||
ok "Files uploaded"
|
||||
|
||||
log "Running remote installation..."
|
||||
ssh "root@$HOST" "bash -s" << REMOTE_SCRIPT
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="$INSTALL_DIR"
|
||||
PORT="$PORT"
|
||||
SECRET="$SECRET"
|
||||
OWNER="$OWNER"
|
||||
REPO="$REPO"
|
||||
WEBHOOK_URL="$WEBHOOK_URL"
|
||||
SYSTEMD_USER="$SYSTEMD_USER"
|
||||
|
||||
echo '=== Installing GitLink Webhook ==='
|
||||
|
||||
# 1. 创建用户
|
||||
if ! id -u \$SYSTEMD_USER &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -d \$INSTALL_DIR \$SYSTEMD_USER
|
||||
echo "[OK] User \$SYSTEMD_USER created"
|
||||
else
|
||||
echo "[OK] User \$SYSTEMD_USER exists"
|
||||
fi
|
||||
|
||||
# 2. 设置权限
|
||||
chown -R \$SYSTEMD_USER:\$SYSTEMD_USER \$INSTALL_DIR
|
||||
chmod +x \$INSTALL_DIR/01a-webhook-listener.py
|
||||
chmod +x \$INSTALL_DIR/workflows/*.sh
|
||||
echo "[OK] Permissions set"
|
||||
|
||||
# 3. 创建 .env
|
||||
cat > \$INSTALL_DIR/.env << EOF
|
||||
WEBHOOK_PORT=$PORT
|
||||
WEBHOOK_SECRET=$SECRET
|
||||
EOF
|
||||
chmod 600 \$INSTALL_DIR/.env
|
||||
chown \$SYSTEMD_USER:\$SYSTEMD_USER \$INSTALL_DIR/.env
|
||||
echo "[OK] .env created"
|
||||
|
||||
# 4. 开放防火墙
|
||||
if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then
|
||||
ufw allow \$PORT/tcp 2>/dev/null || true
|
||||
echo "[OK] Firewall: port \$PORT opened"
|
||||
elif command -v firewall-cmd &>/dev/null; then
|
||||
firewall-cmd --permanent --add-port=\$PORT/tcp 2>/dev/null || true
|
||||
firewall-cmd --reload 2>/dev/null || true
|
||||
echo "[OK] Firewall: port \$PORT opened"
|
||||
else
|
||||
echo "[WARN] No firewall detected — ensure port \$PORT is open in security group"
|
||||
fi
|
||||
|
||||
# 5. 安装 systemd 服务
|
||||
cp \$INSTALL_DIR/gitlink-webhook.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable gitlink-webhook
|
||||
systemctl restart gitlink-webhook
|
||||
echo "[OK] Systemd service installed and started"
|
||||
|
||||
# 6. 等待启动
|
||||
sleep 2
|
||||
systemctl status gitlink-webhook --no-pager | head -5
|
||||
|
||||
echo ''
|
||||
echo '=== Installation complete ==='
|
||||
echo "Health check: http://$HOST:$PORT/"
|
||||
echo "Webhook URL: $WEBHOOK_URL"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
ok "Remote installation complete"
|
||||
|
||||
# 7. 注册 webhook
|
||||
echo ""
|
||||
log "Registering webhook on GitLink..."
|
||||
ssh "root@$HOST" "cd \$INSTALL_DIR/workflows && bash 01a-webhook-setup.sh --webhook-url '$WEBHOOK_URL' --owner '$OWNER' --repo '$REPO' --secret '$SECRET'" || {
|
||||
warn "Webhook registration failed — you can run manually:"
|
||||
echo " ssh root@$HOST"
|
||||
echo " cd $INSTALL_DIR/workflows"
|
||||
echo " bash 01a-webhook-setup.sh --webhook-url '$WEBHOOK_URL' --owner '$OWNER' --repo '$REPO' --secret '$SECRET'"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Deployment Complete! ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ Health: http://$HOST:$PORT/ ║${NC}"
|
||||
echo -e "${GREEN}║ Webhook: $WEBHOOK_URL ║${NC}"
|
||||
echo -e "${GREEN}║ Logs: ssh root@$HOST journalctl -u gitlink-webhook -f ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# ── 本地安装(在服务器上执行)─────────────────────────────────────
|
||||
else
|
||||
[[ "$EUID" -ne 0 ]] && err "Please run as root (sudo)"
|
||||
|
||||
log "Installing locally to $INSTALL_DIR ..."
|
||||
|
||||
# 创建用户
|
||||
if ! id -u "$SYSTEMD_USER" &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -d "$INSTALL_DIR" "$SYSTEMD_USER"
|
||||
ok "User $SYSTEMD_USER created"
|
||||
fi
|
||||
|
||||
# 设置权限
|
||||
chown -R "$SYSTEMD_USER:$SYSTEMD_USER" "$INSTALL_DIR"
|
||||
chmod +x "$INSTALL_DIR/01a-webhook-listener.py"
|
||||
chmod +x "$INSTALL_DIR/workflows/"*.sh 2>/dev/null || true
|
||||
ok "Permissions set"
|
||||
|
||||
# .env
|
||||
cat > "$INSTALL_DIR/.env" << EOF
|
||||
WEBHOOK_PORT=$PORT
|
||||
WEBHOOK_SECRET=$SECRET
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
chown "$SYSTEMD_USER:$SYSTEMD_USER" "$INSTALL_DIR/.env"
|
||||
ok ".env created"
|
||||
|
||||
# 防火墙
|
||||
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
ufw allow "$PORT/tcp" 2>/dev/null || true
|
||||
ok "UFW: port $PORT opened"
|
||||
fi
|
||||
|
||||
# systemd
|
||||
cp "$INSTALL_DIR/gitlink-webhook.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable gitlink-webhook
|
||||
systemctl restart gitlink-webhook
|
||||
ok "Systemd service installed"
|
||||
|
||||
sleep 2
|
||||
systemctl status gitlink-webhook --no-pager | head -8
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Local installation complete!${NC}"
|
||||
echo " Health check: curl http://localhost:$PORT/"
|
||||
echo " Status: systemctl status gitlink-webhook"
|
||||
echo " Logs: journalctl -u gitlink-webhook -f"
|
||||
echo ""
|
||||
echo " Next: register webhook on GitLink:"
|
||||
echo " bash $INSTALL_DIR/workflows/01a-webhook-setup.sh \\"
|
||||
echo " --webhook-url '$WEBHOOK_URL' \\"
|
||||
echo " --owner '$OWNER' --repo '$REPO' --secret '$SECRET'"
|
||||
fi
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# ================================================================
|
||||
# GitLink Community Ops — Webhook Listener Systemd Service
|
||||
#
|
||||
# 安装:
|
||||
# sudo cp gitlink-webhook.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now gitlink-webhook
|
||||
#
|
||||
# 管理:
|
||||
# sudo systemctl status gitlink-webhook # 查看状态
|
||||
# sudo systemctl restart gitlink-webhook # 重启
|
||||
# sudo journalctl -u gitlink-webhook -f # 查看日志
|
||||
# ================================================================
|
||||
[Unit]
|
||||
Description=GitLink Community Ops Webhook Listener
|
||||
Documentation=https://github.com/your-org/gitlink-cli
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=gitlink
|
||||
Group=gitlink
|
||||
WorkingDirectory=/opt/gitlink-webhook
|
||||
EnvironmentFile=/opt/gitlink-webhook/.env
|
||||
ExecStart=/usr/bin/python3 /opt/gitlink-webhook/01a-webhook-listener.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# 安全加固
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ReadWritePaths=/opt/gitlink-webhook/webhook-logs
|
||||
ReadOnlyPaths=/opt/gitlink-webhook/workflows
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Loading…
Reference in New Issue