178
internal/builder/builder.go
Normal file
178
internal/builder/builder.go
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user