Files
git-hook/webhook.go
T
2026-08-18 10:29:58 +08:00

176 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:上次实际执行时刻(首次或 timer fire)。
// timer:当前活动定时器,为 nil 表示已 fire 或未安排。
// pendingCommit:防抖窗口内累计的最新 commitID,timer fire 时用它执行。
// 其余字段缓存以便 timer fire 回调无需外部参数。
type debounceEntry struct {
mu sync.Mutex
lastExec time.Time
timer *time.Timer
pendingCommit string
scriptPath string
repoName string
ref string
}
// scheduleLocked 续命或新建防抖定时器(必须持有 entry.mu)。
// fireAt = now + debounceDurationpendingCommit 由调用方预先设置。
// fire 闭包内通过指针身份做代际校验:若已被新定时器顶替,本次不执行。
func (e *debounceEntry) scheduleLocked(lockKey string) {
if e.timer != nil {
e.timer.Stop()
}
e.timer = time.AfterFunc(debounceDuration, func() {
e.mu.Lock()
if e.timer == nil {
e.mu.Unlock()
return
}
fired := e.timer
e.mu.Unlock()
// 代际校验:再次拿锁确认 fired 仍是当前定时器
e.mu.Lock()
if fired == nil || e.timer != fired {
e.mu.Unlock()
return // 已被新 push 顶替
}
e.timer = nil
e.lastExec = time.Now()
finalCommit := e.pendingCommit
sp, rn, rf := e.scriptPath, e.repoName, e.ref
pendingDebounceMap.Delete(lockKey)
e.mu.Unlock()
log.Printf("[DEBOUNCE-FIRE] 防抖窗口结束,补刀执行 [%s] (使用最新 commit %s)\n",
lockKey, shortCommit(finalCommit))
go runScript(sp, rn, rf, finalCommit)
})
}
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. 防抖 + 尾部补刀
lockKey := fmt.Sprintf("%s:%s", repoName, ref)
now := time.Now()
entryVal, _ := pendingDebounceMap.LoadOrStore(lockKey, &debounceEntry{
scriptPath: scriptPath,
repoName: repoName,
ref: ref,
})
entry := entryVal.(*debounceEntry)
entry.mu.Lock()
// 是否首次/窗口外执行:没有活动定时器即视为窗口外
// timer==nil 涵盖首次 lastExec==0 和上一次 timer fire 后两种情况)
firstRun := entry.timer == nil
if firstRun {
// 立即执行本次请求的 commitID;同时安排尾部补刀捕获期间新 push
entry.lastExec = now
entry.pendingCommit = commitID // 兜底:timer fire 时无新 push 则用本次 commitID
entry.scheduleLocked(lockKey)
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,
})
return
}
// 防抖窗口内(活动定时器存在):续命定时器,使用最新 commitID
remainingSeconds := max(int((debounceDuration - now.Sub(entry.lastExec)).Seconds()), 0)
entry.pendingCommit = commitID
entry.scheduleLocked(lockKey)
entry.mu.Unlock()
msg := fmt.Sprintf("触发太频繁,防抖拦截中(5分钟限制),剩余等待时间: %d 秒", remainingSeconds)
log.Printf("[DEBOUNCE] 跳过执行 [%s] -> %s (已安排尾部补刀)\n", lockKey, msg)
c.JSON(http.StatusOK, gin.H{
"status": "debounced",
"message": msg,
"remaining_seconds": remainingSeconds,
})
}