commit 9b1d12395091648470d253e216ba472e1afe10fb Author: tao.chen <93983997+taochen-ct@users.noreply.github.com> Date: Fri Aug 14 13:32:37 2026 +0800 init diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ea19bd6 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module git-webhook + +go 1.25.0 + +require github.com/gin-gonic/gin v1.12.0 + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..444dad8 --- /dev/null +++ b/main.go @@ -0,0 +1,257 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// Config 对应 config.json 结构 +type Config struct { + SecretToken string `json:"secret_token"` + Projects map[string]map[string]string `json:"projects"` + DeviceKeys []string `json:"device_keys"` +} + +// 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"` +} + +// BarkPushPayload 定义 Bark 推送的请求 JSON 结构(单 device_key) +type BarkPushPayload struct { + Title string `json:"title"` + Body string `json:"body"` + Group string `json:"group,omitempty"` + DeviceKey string `json:"device_key"` + IsArchive string `json:"isArchive,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +// SendNotification 依次对每个 device_key 发送 Bark 通知,遇错即返回 +func SendNotification(pushURL string, deviceKeys []string, title, body string) error { + for _, key := range deviceKeys { + if err := sendOne(pushURL, key, title, body); err != nil { + return err + } + } + return nil +} + +// sendOne 向单个 device_key 发送推送 +func sendOne(pushURL, deviceKey, title, body string) error { + payload := BarkPushPayload{ + Title: title, + Body: body, + Group: "Model Development Platform", + DeviceKey: deviceKey, + IsArchive: "1", + TTL: 3600, + } + + jsonBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("JSON 序列化失败: %w", err) + } + + req, err := http.NewRequest("POST", pushURL, bytes.NewBuffer(jsonBytes)) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("发送推送请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + bodyText := string(bytes.TrimSpace(bodyBytes)) + if bodyText != "" { + return fmt.Errorf("推送服务器返回异常状态码: %d, 响应: %s", resp.StatusCode, bodyText) + } + return fmt.Errorf("推送服务器返回异常状态码: %d", resp.StatusCode) + } + + return nil +} + +var ( + globalConfig Config + // lastExecutionMap 用于记录某个仓库+分支的上一次触发时间 key: "repoName:ref", value: time.Time + lastExecutionMap sync.Map + // 防抖冷却时间限制:5 分钟 + debounceDuration = 5 * time.Minute + // bark 推送消息 + pushURL = "https://bark.maimaicuizhiji.top/push" +) + +func loadConfig(path string) error { + file, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(file, &globalConfig) +} + +// 异步执行 Shell 脚本 +func runScript(scriptPath, repoName, ref, commitID string) { + log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s)\n", scriptPath, repoName, ref) + + cmd := exec.Command("bash", scriptPath) + + // 传入环境变量,脚本内可读取 + cmd.Env = append(os.Environ(), + fmt.Sprintf("GIT_REPO_NAME=%s", repoName), + fmt.Sprintf("GIT_REF=%s", ref), + fmt.Sprintf("GIT_COMMIT_ID=%s", commitID), + ) + + // 捕获输出 + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf("[ERROR] 脚本执行失败 [%s]: %v\n输出:\n%s\n", scriptPath, err, string(output)) + return + } + + log.Printf("[SUCCESS] 脚本执行成功 [%s]\n输出:\n%s\n", scriptPath, string(output)) + + title := fmt.Sprintf("%s更新成功", repoName) + body := fmt.Sprintf("分支: %s\nCommit: %s\n服务器已接收到 Push,开始更新容器...", ref, commitID[:7]) + if err := SendNotification(pushURL, globalConfig.DeviceKeys, title, body); err != nil { + log.Printf("[WARNING] 推送通知失败: %v\n", err) + } else { + log.Println("[INFO] 推送通知发送成功") + } +} + +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. 核心逻辑:5 分钟防抖检查 + lockKey := fmt.Sprintf("%s:%s", repoName, ref) + now := time.Now() + + if lastTimeVal, loaded := lastExecutionMap.Load(lockKey); loaded { + lastTime := lastTimeVal.(time.Time) + elapsed := now.Sub(lastTime) + if elapsed < debounceDuration { + remainingSeconds := int((debounceDuration - elapsed).Seconds()) + 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, + }) + return + } + } + + // 更新上一次触发时间 + lastExecutionMap.Store(lockKey, now) + + // 7. 开启 goroutine 异步执行脚本 + go runScript(scriptPath, repoName, ref, commitID) + + // 8. 立即回应 200 给 GitLab + c.JSON(http.StatusOK, gin.H{ + "status": "triggered", + "repository": repoName, + "ref": ref, + "script": scriptPath, + }) +} + +func main() { + portFlag := flag.Int("port", 8000, "服务监听端口") + flag.Parse() + + if err := loadConfig("config.json"); err != nil { + log.Fatalf("加载配置文件失败: %v", err) + } + + //gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + + r.Use(func(c *gin.Context) { + t := time.Now() + c.Next() + log.Printf("[%s] %s %d %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(t)) + }) + + r.POST("/webhook", handleWebhook) + + listenAddr := fmt.Sprintf(":%d", *portFlag) + log.Printf("GitLab Webhook 服务已启动 (带 5 分钟防抖),监听端口 %d ...\n", *portFlag) + + if err := r.Run(listenAddr); err != nil { + log.Fatalf("服务启动失败: %v", err) + } +} diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29