Initial commit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:19:47 +08:00
commit 3f7bdf0222
49 changed files with 7818 additions and 0 deletions

187
internal/handler/build.go Normal file
View File

@@ -0,0 +1,187 @@
package handler
import (
"fmt"
"io"
"net/http"
"time"
"context"
"ci/internal/model"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// BuildHandler handles build/run/log endpoints.
type BuildHandler struct {
Svc *service.ProjectService
}
func NewBuildHandler(svc *service.ProjectService) *BuildHandler {
return &BuildHandler{Svc: svc}
}
// TriggerBuild starts a build for the project.
func (h *BuildHandler) TriggerBuild(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Build in background
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
go func() {
h.Svc.BuildProject(ctx, id)
}()
c.JSON(http.StatusAccepted, gin.H{"message": "build started"})
}
// StartProject starts the project's binary.
func (h *BuildHandler) StartProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.Svc.StartProject(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "started"})
}
// StopProject stops a running project.
func (h *BuildHandler) StopProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
if err := h.Svc.StopProject(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "stopped"})
}
// RestartProject restarts a project.
func (h *BuildHandler) RestartProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := h.Svc.RestartProject(ctx, id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restarted"})
}
// GetBuilds lists build history for a project.
func (h *BuildHandler) GetBuilds(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
records, err := h.Svc.GetBuildHistory(id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if records == nil {
records = []model.BuildRecord{}
}
c.JSON(http.StatusOK, records)
}
// GetBuildLog returns the full log for a specific build.
func (h *BuildHandler) GetBuildLog(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid build id"})
return
}
logContent, err := h.Svc.GetBuildLog(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.String(http.StatusOK, logContent)
}
// StreamLogs streams real-time logs via SSE for a project.
func (h *BuildHandler) StreamLogs(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
ch := h.Svc.SubscribeLogs(id)
defer h.Svc.UnsubscribeLogs(id, ch)
clientGone := c.Request.Context().Done()
c.Stream(func(w io.Writer) bool {
select {
case <-clientGone:
return false
case line, ok := <-ch:
if !ok {
return false
}
fmt.Fprintf(w, "data: %s\n\n", line)
return true
}
})
}
// Webhook handles Git webhook triggers (Gitee/GitHub).
func (h *BuildHandler) Webhook(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Verify project exists
_, err = h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// Trigger build
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
go func() {
h.Svc.BuildProject(ctx, id)
}()
c.JSON(http.StatusOK, gin.H{"message": "webhook received, build triggered"})
}

151
internal/handler/deploy.go Normal file
View File

@@ -0,0 +1,151 @@
package handler
import (
"context"
"net/http"
"strconv"
"time"
"ci/internal/model"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// DeployHandler handles deployment endpoints.
type DeployHandler struct {
Svc *service.ProjectService
}
// NewDeployHandler creates a new DeployHandler.
func NewDeployHandler(svc *service.ProjectService) *DeployHandler {
return &DeployHandler{Svc: svc}
}
// GetDeployConfig returns the deploy config for a project.
func (h *DeployHandler) GetDeployConfig(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
cfg, err := h.Svc.GetDeployConfig(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if cfg == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no deploy config"})
return
}
c.JSON(http.StatusOK, cfg)
}
// UpdateDeployConfig creates or updates the deploy config for a project.
func (h *DeployHandler) UpdateDeployConfig(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
var cfg model.DeployConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg.ProjectID = id
if err := h.Svc.UpdateDeployConfig(&cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-fetch to return clean state (sensitive fields hidden by json:"-")
result, _ := h.Svc.GetDeployConfig(id)
if result == nil {
c.JSON(http.StatusOK, gin.H{"message": "created"})
return
}
c.JSON(http.StatusOK, result)
}
// DeleteDeployConfig removes the deploy config for a project.
func (h *DeployHandler) DeleteDeployConfig(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
if err := h.Svc.DeleteDeployConfig(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deploy config deleted"})
}
// TriggerDeploy starts a deployment for the project.
func (h *DeployHandler) TriggerDeploy(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Deploy in background
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
go func() {
h.Svc.TriggerDeploy(ctx, id, nil)
}()
c.JSON(http.StatusAccepted, gin.H{"message": "deployment started"})
}
// GetDeployRecords lists deploy history for a project.
func (h *DeployHandler) GetDeployRecords(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
records, err := h.Svc.GetDeployHistory(id, 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if records == nil {
records = []model.DeployRecord{}
}
c.JSON(http.StatusOK, records)
}
// GetDeployLog returns the full log for a specific deployment.
func (h *DeployHandler) GetDeployLog(c *gin.Context) {
didStr := c.Param("did")
did, err := parseIDStr(didStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deploy id"})
return
}
logContent, err := h.Svc.GetDeployLog(did)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.String(http.StatusOK, logContent)
}
// parseIDStr parses an ID string for non-param-based IDs (e.g., deploy record ID from URL path).
func parseIDStr(s string) (uint, error) {
id, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}

129
internal/handler/project.go Normal file
View File

@@ -0,0 +1,129 @@
package handler
import (
"net/http"
"strconv"
"ci/internal/model"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// ProjectHandler handles project CRUD endpoints.
type ProjectHandler struct {
Svc *service.ProjectService
}
func NewProjectHandler(svc *service.ProjectService) *ProjectHandler {
return &ProjectHandler{Svc: svc}
}
// ListProjects returns all projects.
func (h *ProjectHandler) ListProjects(c *gin.Context) {
projects, err := h.Svc.ListProjects()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if projects == nil {
projects = []model.Project{}
}
c.JSON(http.StatusOK, projects)
}
// GetProject returns a single project by ID.
func (h *ProjectHandler) GetProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
project, err := h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
c.JSON(http.StatusOK, project)
}
// CreateProject creates a new project.
func (h *ProjectHandler) CreateProject(c *gin.Context) {
var p model.Project
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if p.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if p.Mode != "git" && p.Mode != "upload" {
p.Mode = "git" // default
}
if err := h.Svc.CreateProject(&p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, p)
}
// UpdateProject updates an existing project.
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
existing, err := h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
var updates model.Project
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Preserve fields that should not be updated from the request
updates.ID = existing.ID
updates.CreatedAt = existing.CreatedAt
updates.Status = existing.Status
updates.PID = existing.PID
// Preserve password if not sent
if updates.GitPassword == "" {
updates.GitPassword = existing.GitPassword
}
if err := h.Svc.UpdateProject(&updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updates)
}
// DeleteProject deletes a project.
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
if err := h.Svc.DeleteProject(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func parseID(c *gin.Context) (uint, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}

View File

@@ -0,0 +1,76 @@
package handler
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"ci/internal/service"
"github.com/gin-gonic/gin"
)
// UploadHandler handles file upload endpoints.
type UploadHandler struct {
Svc *service.ProjectService
}
func NewUploadHandler(svc *service.ProjectService) *UploadHandler {
return &UploadHandler{Svc: svc}
}
// UploadFiles handles source file upload for a project (zip/tar.gz).
func (h *UploadHandler) UploadFiles(c *gin.Context) {
id, err := parseID(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
return
}
// Verify project exists and is upload mode
project, err := h.Svc.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
return
}
// Validate file extension
ext := strings.ToLower(filepath.Ext(file.Filename))
valid := false
if ext == ".zip" {
valid = true
}
if strings.HasSuffix(strings.ToLower(file.Filename), ".tar.gz") || strings.HasSuffix(strings.ToLower(file.Filename), ".tgz") {
valid = true
}
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "only .zip and .tar.gz files are supported"})
return
}
// Save uploaded file to temp location
tmpPath := filepath.Join(h.Svc.Workspace.ProjectDir(project.ID), file.Filename)
if err := c.SaveUploadedFile(file, tmpPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save file: %v", err)})
return
}
// Extract to src directory
if err := h.Svc.Workspace.ExtractArchive(project.ID, tmpPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to extract: %v", err)})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "file uploaded and extracted successfully",
"filename": file.Filename,
})
}