Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README | LICENSE

cron.go (7657B)


      1 package jci
      2 
      3 import (
      4 	"bufio"
      5 	"bytes"
      6 	"crypto/sha256"
      7 	"fmt"
      8 	"os"
      9 	"os/exec"
     10 	"path/filepath"
     11 	"regexp"
     12 	"strings"
     13 )
     14 
     15 const cronMarkerPrefix = "# JCI:"
     16 
     17 // CronEntry represents a parsed crontab entry
     18 // Additional metadata (line number, command, raw text) is populated when
     19 // entries are loaded via the cron parser utilities.
     20 type CronEntry struct {
     21 	Schedule string // e.g., "0 * * * *"
     22 	Name     string // optional name/comment
     23 	Branch   string // branch to run on (default: current)
     24 	Line     int    // source line number (optional)
     25 	Command  string // parsed command (optional)
     26 	Raw      string // raw line text (optional)
     27 }
     28 
     29 // Cron handles cron subcommands
     30 func Cron(args []string) error {
     31 	if len(args) == 0 {
     32 		return fmt.Errorf("usage: git jci cron <ls|sync>")
     33 	}
     34 
     35 	switch args[0] {
     36 	case "ls":
     37 		return cronList()
     38 	case "sync":
     39 		return cronSync()
     40 	default:
     41 		return fmt.Errorf("unknown cron command: %s (use ls or sync)", args[0])
     42 	}
     43 }
     44 
     45 // cronList shows current cron jobs from .jci/crontab and system cron
     46 func cronList() error {
     47 	repoRoot, err := GetRepoRoot()
     48 	if err != nil {
     49 		return fmt.Errorf("not in a git repository: %w", err)
     50 	}
     51 
     52 	repoID := getRepoID(repoRoot)
     53 
     54 	// Show .jci/crontab entries
     55 	crontabFile := filepath.Join(repoRoot, ".jci", "crontab")
     56 	entries, err := parseCrontab(crontabFile)
     57 	if err != nil && !os.IsNotExist(err) {
     58 		return fmt.Errorf("failed to parse .jci/crontab: %w", err)
     59 	}
     60 
     61 	fmt.Printf("Repository: %s\n", repoRoot)
     62 	fmt.Printf("Repo ID: %s\n\n", repoID[:12])
     63 
     64 	if len(entries) == 0 {
     65 		fmt.Println("No entries in .jci/crontab")
     66 	} else {
     67 		fmt.Println("Configured in .jci/crontab:")
     68 		for _, e := range entries {
     69 			branch := e.Branch
     70 			if branch == "" {
     71 				branch = "(current)"
     72 			}
     73 			name := e.Name
     74 			if name == "" {
     75 				name = "(unnamed)"
     76 			}
     77 			fmt.Printf("  %-20s %-15s %s\n", e.Schedule, branch, name)
     78 		}
     79 	}
     80 
     81 	// Show what's in system crontab
     82 	fmt.Println("\nInstalled in system cron:")
     83 	systemEntries, err := getSystemCronEntries(repoID)
     84 	if err != nil {
     85 		fmt.Printf("  (could not read system cron: %v)\n", err)
     86 	} else if len(systemEntries) == 0 {
     87 		fmt.Println("  (none)")
     88 	} else {
     89 		for _, line := range systemEntries {
     90 			fmt.Printf("  %s\n", line)
     91 		}
     92 	}
     93 
     94 	return nil
     95 }
     96 
     97 // cronSync synchronizes .jci/crontab with system cron
     98 func cronSync() error {
     99 	repoRoot, err := GetRepoRoot()
    100 	if err != nil {
    101 		return fmt.Errorf("not in a git repository: %w", err)
    102 	}
    103 
    104 	repoID := getRepoID(repoRoot)
    105 	marker := cronMarkerPrefix + repoID
    106 
    107 	// Parse .jci/crontab
    108 	crontabFile := filepath.Join(repoRoot, ".jci", "crontab")
    109 	entries, err := parseCrontab(crontabFile)
    110 	if err != nil {
    111 		if os.IsNotExist(err) {
    112 			entries = nil // No crontab file = remove all entries
    113 		} else {
    114 			return fmt.Errorf("failed to parse .jci/crontab: %w", err)
    115 		}
    116 	}
    117 
    118 	// Get current system crontab
    119 	currentCron, err := getCurrentCrontab()
    120 	if err != nil {
    121 		return fmt.Errorf("failed to read current crontab: %w", err)
    122 	}
    123 
    124 	// Remove old JCI entries for this repo
    125 	var newLines []string
    126 	for _, line := range strings.Split(currentCron, "\n") {
    127 		if !strings.Contains(line, marker) {
    128 			newLines = append(newLines, line)
    129 		}
    130 	}
    131 
    132 	// Find git-jci binary path
    133 	jciBinary, err := findJCIBinary()
    134 	if err != nil {
    135 		return fmt.Errorf("could not find git-jci binary: %w", err)
    136 	}
    137 
    138 	// Add new entries
    139 	for _, e := range entries {
    140 		cmd := fmt.Sprintf("cd %s && git fetch --quiet 2>/dev/null; ", shellEscape(repoRoot))
    141 		if e.Branch != "" {
    142 			cmd += fmt.Sprintf("git checkout --quiet %s 2>/dev/null && git pull --quiet 2>/dev/null; ", shellEscape(e.Branch))
    143 		}
    144 		cmd += fmt.Sprintf("%s run", jciBinary)
    145 
    146 		comment := e.Name
    147 		if comment == "" {
    148 			comment = "jci"
    149 		}
    150 
    151 		line := fmt.Sprintf("%s %s %s [%s]", e.Schedule, cmd, marker, comment)
    152 		newLines = append(newLines, line)
    153 	}
    154 
    155 	// Write new crontab
    156 	newCron := strings.Join(newLines, "\n")
    157 	// Clean up multiple empty lines
    158 	for strings.Contains(newCron, "\n\n\n") {
    159 		newCron = strings.ReplaceAll(newCron, "\n\n\n", "\n\n")
    160 	}
    161 	newCron = strings.TrimSpace(newCron) + "\n"
    162 
    163 	if err := installCrontab(newCron); err != nil {
    164 		return fmt.Errorf("failed to install crontab: %w", err)
    165 	}
    166 
    167 	if len(entries) == 0 {
    168 		fmt.Printf("Removed all JCI cron entries for %s\n", repoRoot)
    169 	} else {
    170 		fmt.Printf("Synced %d cron entries for %s\n", len(entries), repoRoot)
    171 	}
    172 
    173 	return nil
    174 }
    175 
    176 // parseCrontab parses a .jci/crontab file
    177 // Format:
    178 //
    179 //	# comment
    180 //	SCHEDULE [branch:BRANCH] [name:NAME]
    181 //	0 * * * *                    # every hour, current branch
    182 //	0 0 * * * branch:main        # daily at midnight, main branch
    183 //	*/15 * * * * name:quick-test # every 15 min
    184 func parseCrontab(path string) ([]CronEntry, error) {
    185 	f, err := os.Open(path)
    186 	if err != nil {
    187 		return nil, err
    188 	}
    189 	defer f.Close()
    190 
    191 	var entries []CronEntry
    192 	scanner := bufio.NewScanner(f)
    193 	scheduleRe := regexp.MustCompile(`^([*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+\s+[*0-9,/-]+)\s*(.*)`)
    194 
    195 	for scanner.Scan() {
    196 		line := strings.TrimSpace(scanner.Text())
    197 
    198 		// Skip empty lines and comments
    199 		if line == "" || strings.HasPrefix(line, "#") {
    200 			continue
    201 		}
    202 
    203 		matches := scheduleRe.FindStringSubmatch(line)
    204 		if matches == nil {
    205 			continue // Invalid line, skip
    206 		}
    207 
    208 		entry := CronEntry{
    209 			Schedule: matches[1],
    210 		}
    211 
    212 		// Parse options
    213 		opts := matches[2]
    214 		for _, part := range strings.Fields(opts) {
    215 			if strings.HasPrefix(part, "branch:") {
    216 				entry.Branch = strings.TrimPrefix(part, "branch:")
    217 			} else if strings.HasPrefix(part, "name:") {
    218 				entry.Name = strings.TrimPrefix(part, "name:")
    219 			}
    220 		}
    221 
    222 		entries = append(entries, entry)
    223 	}
    224 
    225 	return entries, scanner.Err()
    226 }
    227 
    228 // getRepoID generates a unique ID for a repository based on its path
    229 func getRepoID(repoRoot string) string {
    230 	h := sha256.Sum256([]byte(repoRoot))
    231 	return fmt.Sprintf("%x", h)
    232 }
    233 
    234 // getCurrentCrontab returns the current user's crontab
    235 func getCurrentCrontab() (string, error) {
    236 	cmd := exec.Command("crontab", "-l")
    237 	out, err := cmd.Output()
    238 	if err != nil {
    239 		// No crontab for user is not an error
    240 		if strings.Contains(err.Error(), "no crontab") {
    241 			return "", nil
    242 		}
    243 		// Check stderr for "no crontab" message
    244 		if exitErr, ok := err.(*exec.ExitError); ok {
    245 			if strings.Contains(string(exitErr.Stderr), "no crontab") {
    246 				return "", nil
    247 			}
    248 		}
    249 		return "", err
    250 	}
    251 	return string(out), nil
    252 }
    253 
    254 // installCrontab installs a new crontab
    255 func installCrontab(content string) error {
    256 	cmd := exec.Command("crontab", "-")
    257 	cmd.Stdin = strings.NewReader(content)
    258 	var stderr bytes.Buffer
    259 	cmd.Stderr = &stderr
    260 	if err := cmd.Run(); err != nil {
    261 		return fmt.Errorf("%v: %s", err, stderr.String())
    262 	}
    263 	return nil
    264 }
    265 
    266 // getSystemCronEntries returns JCI entries for this repo from system cron
    267 func getSystemCronEntries(repoID string) ([]string, error) {
    268 	current, err := getCurrentCrontab()
    269 	if err != nil {
    270 		return nil, err
    271 	}
    272 
    273 	marker := cronMarkerPrefix + repoID
    274 	var entries []string
    275 	for _, line := range strings.Split(current, "\n") {
    276 		if strings.Contains(line, marker) {
    277 			entries = append(entries, line)
    278 		}
    279 	}
    280 	return entries, nil
    281 }
    282 
    283 // findJCIBinary finds the path to git-jci binary
    284 func findJCIBinary() (string, error) {
    285 	// First try to find ourselves
    286 	exe, err := os.Executable()
    287 	if err == nil {
    288 		return exe, nil
    289 	}
    290 
    291 	// Try PATH
    292 	path, err := exec.LookPath("git-jci")
    293 	if err == nil {
    294 		return path, nil
    295 	}
    296 
    297 	return "", fmt.Errorf("git-jci not found in PATH")
    298 }
    299 
    300 // shellEscape escapes a string for safe use in shell
    301 func shellEscape(s string) string {
    302 	// Simple escaping - wrap in single quotes and escape existing single quotes
    303 	return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
    304 }