Files
ci/internal/builder/builder.go
2026-06-18 21:19:47 +08:00

179 lines
4.3 KiB
Go

package builder
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"ci/internal/model"
)
// LineCallback is called for each line of build output.
type LineCallback func(line string)
// BuildResult holds the outcome of a build.
type BuildResult struct {
Success bool
LogPath string
CommitHash string
Output string
}
// Service handles build operations.
type Service struct{}
// New creates a new builder Service.
func New() *Service {
return &Service{}
}
// Execute runs a build for the given project, streaming output via callback.
func (s *Service) Execute(ctx context.Context, project *model.Project, workDir, logsDir, commitHash string, onLine LineCallback) (*BuildResult, error) {
// Create log file
logFileName := fmt.Sprintf("build-%d.log", time.Now().UnixMilli())
logPath := filepath.Join(logsDir, logFileName)
logFile, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
defer logFile.Close()
writer := io.MultiWriter(logFile, &callbackWriter{fn: onLine})
// Resolve the build script
script := strings.TrimSpace(project.BuildScript)
if script == "" {
// Default: try go build
script = "go build -o app ."
}
// Determine shell for the build script
var cmd *exec.Cmd
if isShellScript(script) {
// Multi-line script or complex — write to temp file and execute
scriptPath := filepath.Join(workDir, ".build-script.sh")
if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\nset -e\n"+script), 0755); err != nil {
return nil, fmt.Errorf("failed to write build script: %w", err)
}
cmd = exec.CommandContext(ctx, "sh", scriptPath)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", script)
}
cmd.Dir = workDir
cmd.Env = os.Environ()
cmd.Stdout = writer
cmd.Stderr = writer
fmt.Fprintf(writer, "=== Build started at %s ===\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(writer, "Working directory: %s\n", workDir)
fmt.Fprintf(writer, "Build script: %s\n", script)
fmt.Fprintf(writer, "Commit: %s\n\n", commitHash)
startTime := time.Now()
err = cmd.Run()
duration := time.Since(startTime)
result := &BuildResult{
LogPath: logPath,
CommitHash: commitHash,
}
if err != nil {
fmt.Fprintf(writer, "\n=== Build FAILED after %s ===\n", duration.Round(time.Second))
fmt.Fprintf(writer, "Error: %v\n", err)
result.Success = false
result.Output = fmt.Sprintf("Build failed: %v", err)
} else {
fmt.Fprintf(writer, "\n=== Build SUCCESS after %s ===\n", duration.Round(time.Second))
result.Success = true
result.Output = fmt.Sprintf("Build succeeded in %s", duration.Round(time.Second))
}
return result, nil
}
// ReadLog reads a build log file and returns its contents.
func (s *Service) ReadLog(logPath string) (string, error) {
data, err := os.ReadFile(logPath)
if err != nil {
return "", err
}
return string(data), nil
}
// ReadLogTail reads the last n lines from a log file.
func (s *Service) ReadLogTail(logPath string, lines int) ([]string, error) {
file, err := os.Open(logPath)
if err != nil {
return nil, err
}
defer file.Close()
var result []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
result = append(result, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(result) > lines {
result = result[len(result)-lines:]
}
return result, nil
}
// isShellScript checks if the script looks multi-line or complex enough to need a temp file.
func isShellScript(script string) bool {
return strings.Contains(script, "\n") || len(script) > 200
}
// callbackWriter implements io.Writer, calling a function for each line written.
type callbackWriter struct {
fn LineCallback
buf []byte
}
func (w *callbackWriter) Write(p []byte) (n int, err error) {
if w.fn != nil {
w.buf = append(w.buf, p...)
for {
idx := indexOfByte(w.buf, '\n')
if idx < 0 {
break
}
line := string(w.buf[:idx])
if strings.HasSuffix(line, "\r") {
line = line[:len(line)-1]
}
w.fn(line)
w.buf = w.buf[idx+1:]
}
}
return len(p), nil
}
func (w *callbackWriter) flush() {
if w.fn != nil && len(w.buf) > 0 {
w.fn(string(w.buf))
w.buf = nil
}
}
func indexOfByte(data []byte, b byte) int {
for i, c := range data {
if c == b {
return i
}
}
return -1
}