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 + debounceDuration,pendingCommit 由调用方预先设置。 // 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, }) }