Files
git-hook/webhook.go
T
2026-08-26 10:42:53 +08:00

116 lines
3.2 KiB
Go

package main
import (
"fmt"
"log"
"net/http"
"os"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// GitLabPayload 简化版的 GitLab Push Webhook 数据结构
type GitLabPayload struct {
Ref string `json:"ref"`
After string `json:"after"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
} `json:"project"`
}
// debounceEntry 单个 key 的冷却去重状态。
// lastExec:上次实际执行时刻;冷却窗口内到达的 push 直接丢弃,无补跑。
type debounceEntry struct {
mu sync.Mutex
lastExec time.Time
}
func handleWebhook(c *gin.Context) {
// 1. 校验 GitLab Secret Token Header
clientToken := c.GetHeader("X-Gitlab-Token")
if globalConfig.SecretToken != "" && clientToken != globalConfig.SecretToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Secret Token"})
return
}
// 2. 校验 Event 类型
eventType := c.GetHeader("X-Gitlab-Event")
if eventType != "Push Hook" {
c.JSON(http.StatusOK, gin.H{"status": "ignored", "reason": "Only Push Hook is handled"})
return
}
// 3. 解析 Body JSON
var payload GitLabPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON Payload"})
return
}
repoName := payload.Project.PathWithNamespace
ref := payload.Ref
commitID := payload.After
if repoName == "" || ref == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing repo or ref in payload"})
return
}
// 4. 匹配脚本路径
repoConfig, exists := globalConfig.Projects[repoName]
if !exists {
c.JSON(http.StatusOK, gin.H{"status": "ignored", "message": "No script config for repository " + repoName})
return
}
scriptPath, exists := repoConfig[ref]
if !exists {
c.JSON(http.StatusOK, gin.H{"status": "ignored", "message": fmt.Sprintf("No script config for %s on %s", repoName, ref)})
return
}
// 5. 校验脚本文件是否存在
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Script file not found: " + scriptPath})
return
}
// 6. 冷却去重:窗口外 push 立即执行并刷新 lastExec,窗口内 push 直接丢弃
lockKey := fmt.Sprintf("%s:%s", repoName, ref)
entryVal, _ := lastExecMap.LoadOrStore(lockKey, &debounceEntry{})
entry := entryVal.(*debounceEntry)
entry.mu.Lock()
now := time.Now()
if !entry.lastExec.IsZero() && now.Sub(entry.lastExec) < debounceDuration {
remainingSeconds := max(int((debounceDuration - now.Sub(entry.lastExec)).Seconds()), 0)
entry.mu.Unlock()
msg := fmt.Sprintf("3 分钟内频繁提交被拦截, 剩余冷却时间: %d 秒", remainingSeconds)
log.Printf("[DEBOUNCE] 跳过执行 [%s] %s\n", lockKey, msg)
c.JSON(http.StatusOK, gin.H{
"status": "debounced",
"message": msg,
"remaining_seconds": remainingSeconds,
})
return
}
entry.lastExec = now
entry.mu.Unlock()
log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s, commit: %s)\n",
scriptPath, repoName, ref, shortCommit(commitID))
go runScript(scriptPath, repoName, ref, commitID)
c.JSON(http.StatusOK, gin.H{
"status": "triggered",
"repository": repoName,
"ref": ref,
"script": scriptPath,
})
}