176 lines
5.1 KiB
Go
176 lines
5.1 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:上次实际执行时刻(首次或 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,
|
||
})
|
||
}
|