blob: 0fd7ee1ff9cb77dd597984616038eec375d82451 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package server provides HTTP server functionality for the sketch loop.
2package server
3
4import (
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "html"
9 "io"
10 "io/fs"
11 "log/slog"
12 "net/http"
13 "net/http/pprof"
14 "os"
15 "os/exec"
16 "strconv"
17 "strings"
18 "sync"
19 "syscall"
20 "time"
21
22 "github.com/creack/pty"
23 "sketch.dev/ant"
24 "sketch.dev/loop"
25 "sketch.dev/loop/webui"
26)
27
28// terminalSession represents a terminal session with its PTY and the event channel
29type terminalSession struct {
30 pty *os.File
31 eventsClients map[chan []byte]bool
32 lastEventClientID int
33 eventsClientsMutex sync.Mutex
34 cmd *exec.Cmd
35}
36
37// TerminalMessage represents a message sent from the client for terminal resize events
38type TerminalMessage struct {
39 Type string `json:"type"`
40 Cols uint16 `json:"cols"`
41 Rows uint16 `json:"rows"`
42}
43
44// TerminalResponse represents the response for a new terminal creation
45type TerminalResponse struct {
46 SessionID string `json:"sessionId"`
47}
48
49// Server serves sketch HTTP. Server implements http.Handler.
50type Server struct {
51 mux *http.ServeMux
52 agent loop.CodingAgent
53 hostname string
54 logFile *os.File
55 // Mutex to protect terminalSessions
56 ptyMutex sync.Mutex
57 terminalSessions map[string]*terminalSession
58}
59
60func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
61 s.mux.ServeHTTP(w, r)
62}
63
64// New creates a new HTTP server.
65func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
66 s := &Server{
67 mux: http.NewServeMux(),
68 agent: agent,
69 hostname: getHostname(),
70 logFile: logFile,
71 terminalSessions: make(map[string]*terminalSession),
72 }
73
74 webBundle, err := webui.Build()
75 if err != nil {
76 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
77 }
78
79 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
80 // Check if a specific commit hash was requested
81 commit := r.URL.Query().Get("commit")
82
83 // Get the diff, optionally for a specific commit
84 var diff string
85 var err error
86 if commit != "" {
87 // Validate the commit hash format
88 if !isValidGitSHA(commit) {
89 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
90 return
91 }
92
93 diff, err = agent.Diff(&commit)
94 } else {
95 diff, err = agent.Diff(nil)
96 }
97
98 if err != nil {
99 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
100 return
101 }
102
103 w.Header().Set("Content-Type", "text/plain")
104 w.Write([]byte(diff))
105 })
106
107 // Handler for initialization called by host sketch binary when inside docker.
108 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
109 defer func() {
110 if err := recover(); err != nil {
111 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
112
113 // Return an error response to the client
114 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
115 }
116 }()
117
118 if r.Method != "POST" {
119 http.Error(w, "POST required", http.StatusBadRequest)
120 return
121 }
122
123 body, err := io.ReadAll(r.Body)
124 r.Body.Close()
125 if err != nil {
126 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
127 return
128 }
129 m := make(map[string]string)
130 if err := json.Unmarshal(body, &m); err != nil {
131 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
132 return
133 }
134 hostAddr := m["host_addr"]
135 gitRemoteAddr := m["git_remote_addr"]
136 commit := m["commit"]
137 ini := loop.AgentInit{
138 WorkingDir: "/app",
139 InDocker: true,
140 Commit: commit,
141 GitRemoteAddr: gitRemoteAddr,
142 HostAddr: hostAddr,
143 }
144 if err := agent.Init(ini); err != nil {
145 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
146 return
147 }
148 w.Header().Set("Content-Type", "application/json")
149 io.WriteString(w, "{}\n")
150 })
151
152 // Handler for /messages?start=N&end=M (start/end are optional)
153 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
154 w.Header().Set("Content-Type", "application/json")
155
156 // Extract query parameters for range
157 var start, end int
158 var err error
159
160 currentCount := agent.MessageCount()
161
162 startParam := r.URL.Query().Get("start")
163 if startParam != "" {
164 start, err = strconv.Atoi(startParam)
165 if err != nil {
166 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
167 return
168 }
169 }
170
171 endParam := r.URL.Query().Get("end")
172 if endParam != "" {
173 end, err = strconv.Atoi(endParam)
174 if err != nil {
175 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
176 return
177 }
178 } else {
179 end = currentCount
180 }
181
182 if start < 0 || start > end || end > currentCount {
183 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
184 return
185 }
186
187 start = max(0, start)
188 end = min(agent.MessageCount(), end)
189 messages := agent.Messages(start, end)
190
191 // Create a JSON encoder with indentation for pretty-printing
192 encoder := json.NewEncoder(w)
193 encoder.SetIndent("", " ") // Two spaces for each indentation level
194
195 err = encoder.Encode(messages)
196 if err != nil {
197 http.Error(w, err.Error(), http.StatusInternalServerError)
198 }
199 })
200
201 // Handler for /logs - displays the contents of the log file
202 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
203 if s.logFile == nil {
204 http.Error(w, "log file not set", http.StatusNotFound)
205 return
206 }
207 logContents, err := os.ReadFile(s.logFile.Name())
208 if err != nil {
209 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
210 return
211 }
212 w.Header().Set("Content-Type", "text/html; charset=utf-8")
213 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
214 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
215 fmt.Fprintf(w, "</body>\n</html>")
216 })
217
218 // Handler for /download - downloads both messages and status as a JSON file
219 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
220 // Set headers for file download
221 w.Header().Set("Content-Type", "application/octet-stream")
222
223 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
224 timestamp := time.Now().Format("20060102-150405")
225 filename := fmt.Sprintf("sketch-%s.json", timestamp)
226
227 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
228
229 // Get all messages
230 messageCount := agent.MessageCount()
231 messages := agent.Messages(0, messageCount)
232
233 // Get status information (usage and other metadata)
234 totalUsage := agent.TotalUsage()
235 hostname := getHostname()
236 workingDir := getWorkingDir()
237
238 // Create a combined structure with all information
239 downloadData := struct {
240 Messages []loop.AgentMessage `json:"messages"`
241 MessageCount int `json:"message_count"`
242 TotalUsage ant.CumulativeUsage `json:"total_usage"`
243 Hostname string `json:"hostname"`
244 WorkingDir string `json:"working_dir"`
245 DownloadTime string `json:"download_time"`
246 }{
247 Messages: messages,
248 MessageCount: messageCount,
249 TotalUsage: totalUsage,
250 Hostname: hostname,
251 WorkingDir: workingDir,
252 DownloadTime: time.Now().Format(time.RFC3339),
253 }
254
255 // Marshal the JSON with indentation for better readability
256 jsonData, err := json.MarshalIndent(downloadData, "", " ")
257 if err != nil {
258 http.Error(w, err.Error(), http.StatusInternalServerError)
259 return
260 }
261 w.Write(jsonData)
262 })
263
264 // The latter doesn't return until the number of messages has changed (from seen
265 // or from when this was called.)
266 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
267 pollParam := r.URL.Query().Get("poll")
268 seenParam := r.URL.Query().Get("seen")
269
270 // Get the client's current message count (if provided)
271 clientMessageCount := -1
272 var err error
273 if seenParam != "" {
274 clientMessageCount, err = strconv.Atoi(seenParam)
275 if err != nil {
276 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
277 return
278 }
279 }
280
281 serverMessageCount := agent.MessageCount()
282
283 // Let lazy clients not have to specify this.
284 if clientMessageCount == -1 {
285 clientMessageCount = serverMessageCount
286 }
287
288 if pollParam == "true" {
289 ch := make(chan string)
290 go func() {
291 // This is your blocking operation
292 agent.WaitForMessageCount(r.Context(), clientMessageCount)
293 close(ch)
294 }()
295 select {
296 case <-r.Context().Done():
297 slog.DebugContext(r.Context(), "abandoned poll request")
298 return
299 case <-time.After(90 * time.Second):
300 // Let the user call /state again to get the latest to limit how long our long polls hang out.
301 slog.DebugContext(r.Context(), "longish poll request")
302 break
303 case <-ch:
304 break
305 }
306 }
307
308 serverMessageCount = agent.MessageCount()
309 totalUsage := agent.TotalUsage()
310
311 w.Header().Set("Content-Type", "application/json")
312
313 state := struct {
314 MessageCount int `json:"message_count"`
315 TotalUsage ant.CumulativeUsage `json:"total_usage"`
316 Hostname string `json:"hostname"`
317 WorkingDir string `json:"working_dir"`
318 InitialCommit string `json:"initial_commit"`
319 Title string `json:"title"`
320 OS string `json:"os"`
321 }{
322 MessageCount: serverMessageCount,
323 TotalUsage: totalUsage,
324 Hostname: s.hostname,
325 WorkingDir: getWorkingDir(),
326 InitialCommit: agent.InitialCommit(),
327 Title: agent.Title(),
328 OS: agent.OS(),
329 }
330
331 // Create a JSON encoder with indentation for pretty-printing
332 encoder := json.NewEncoder(w)
333 encoder.SetIndent("", " ") // Two spaces for each indentation level
334
335 err = encoder.Encode(state)
336 if err != nil {
337 http.Error(w, err.Error(), http.StatusInternalServerError)
338 }
339 })
340
341 s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(webBundle)))
342
343 // Terminal WebSocket handler
344 // Terminal endpoints - predefined terminals 1-9
345 // TODO: The UI doesn't actually know how to use terminals 2-9!
346 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
347 if r.Method != http.MethodGet {
348 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
349 return
350 }
351 pathParts := strings.Split(r.URL.Path, "/")
352 if len(pathParts) < 4 {
353 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
354 return
355 }
356
357 sessionID := pathParts[3]
358 // Validate that the terminal ID is between 1-9
359 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
360 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
361 return
362 }
363
364 s.handleTerminalEvents(w, r, sessionID)
365 })
366
367 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
368 if r.Method != http.MethodPost {
369 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
370 return
371 }
372 pathParts := strings.Split(r.URL.Path, "/")
373 if len(pathParts) < 4 {
374 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
375 return
376 }
377 sessionID := pathParts[3]
378 s.handleTerminalInput(w, r, sessionID)
379 })
380
381 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
382 // Serve the timeline.html file directly from the embedded filesystem
383 data, err := fs.ReadFile(webBundle, "timeline.html")
384 if err != nil {
385 http.Error(w, "File not found", http.StatusNotFound)
386 return
387 }
388 w.Header().Set("Content-Type", "text/html")
389 w.Write(data)
390 })
391
392 // Handler for POST /chat
393 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
394 if r.Method != http.MethodPost {
395 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
396 return
397 }
398
399 // Parse the request body
400 var requestBody struct {
401 Message string `json:"message"`
402 }
403
404 decoder := json.NewDecoder(r.Body)
405 if err := decoder.Decode(&requestBody); err != nil {
406 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
407 return
408 }
409 defer r.Body.Close()
410
411 if requestBody.Message == "" {
412 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
413 return
414 }
415
416 agent.UserMessage(r.Context(), requestBody.Message)
417
418 w.WriteHeader(http.StatusOK)
419 })
420
421 // Handler for /cancel - cancels the current inner loop in progress
422 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
423 if r.Method != http.MethodPost {
424 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
425 return
426 }
427
428 // Parse the request body (optional)
429 var requestBody struct {
430 Reason string `json:"reason"`
431 ToolCallID string `json:"tool_call_id"`
432 }
433
434 decoder := json.NewDecoder(r.Body)
435 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
436 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
437 return
438 }
439 defer r.Body.Close()
440
441 cancelReason := "user requested cancellation"
442 if requestBody.Reason != "" {
443 cancelReason = requestBody.Reason
444 }
445
446 if requestBody.ToolCallID != "" {
447 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
448 if err != nil {
449 http.Error(w, err.Error(), http.StatusBadRequest)
450 return
451 }
452 // Return a success response
453 w.Header().Set("Content-Type", "application/json")
454 json.NewEncoder(w).Encode(map[string]string{
455 "status": "cancelled",
456 "too_use_id": requestBody.ToolCallID,
457 "reason": cancelReason})
458 return
459 }
460 // Call the CancelInnerLoop method
461 agent.CancelInnerLoop(fmt.Errorf("%s", cancelReason))
462 // Return a success response
463 w.Header().Set("Content-Type", "application/json")
464 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
465 })
466
467 debugMux := initDebugMux()
468 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
469 debugMux.ServeHTTP(w, r)
470 })
471
472 return s, nil
473}
474
475// Utility functions
476func getHostname() string {
477 hostname, err := os.Hostname()
478 if err != nil {
479 return "unknown"
480 }
481 return hostname
482}
483
484func getWorkingDir() string {
485 wd, err := os.Getwd()
486 if err != nil {
487 return "unknown"
488 }
489 return wd
490}
491
492// createTerminalSession creates a new terminal session with the given ID
493func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
494 // Start a new shell process
495 shellPath := getShellPath()
496 cmd := exec.Command(shellPath)
497
498 // Get working directory from the agent if possible
499 workDir := getWorkingDir()
500 cmd.Dir = workDir
501
502 // Set up environment
503 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
504
505 // Start the command with a pty
506 ptmx, err := pty.Start(cmd)
507 if err != nil {
508 slog.Error("Failed to start pty", "error", err)
509 return nil, err
510 }
511
512 // Create the terminal session
513 session := &terminalSession{
514 pty: ptmx,
515 eventsClients: make(map[chan []byte]bool),
516 cmd: cmd,
517 }
518
519 // Start goroutine to read from pty and broadcast to all connected SSE clients
520 go s.readFromPtyAndBroadcast(sessionID, session)
521
522 return session, nil
523} // handleTerminalEvents handles SSE connections for terminal output
524func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
525 // Check if the session exists, if not, create it
526 s.ptyMutex.Lock()
527 session, exists := s.terminalSessions[sessionID]
528
529 if !exists {
530 // Create a new terminal session
531 var err error
532 session, err = s.createTerminalSession(sessionID)
533 if err != nil {
534 s.ptyMutex.Unlock()
535 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
536 return
537 }
538
539 // Store the new session
540 s.terminalSessions[sessionID] = session
541 }
542 s.ptyMutex.Unlock()
543
544 // Set headers for SSE
545 w.Header().Set("Content-Type", "text/event-stream")
546 w.Header().Set("Cache-Control", "no-cache")
547 w.Header().Set("Connection", "keep-alive")
548 w.Header().Set("Access-Control-Allow-Origin", "*")
549
550 // Create a channel for this client
551 events := make(chan []byte, 4096) // Buffer to prevent blocking
552
553 // Register this client's channel
554 session.eventsClientsMutex.Lock()
555 clientID := session.lastEventClientID + 1
556 session.lastEventClientID = clientID
557 session.eventsClients[events] = true
558 session.eventsClientsMutex.Unlock()
559
560 // When the client disconnects, remove their channel
561 defer func() {
562 session.eventsClientsMutex.Lock()
563 delete(session.eventsClients, events)
564 close(events)
565 session.eventsClientsMutex.Unlock()
566 }()
567
568 // Flush to send headers to client immediately
569 if f, ok := w.(http.Flusher); ok {
570 f.Flush()
571 }
572
573 // Send events to the client as they arrive
574 for {
575 select {
576 case <-r.Context().Done():
577 return
578 case data := <-events:
579 // Format as SSE with base64 encoding
580 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
581
582 // Flush the data immediately
583 if f, ok := w.(http.Flusher); ok {
584 f.Flush()
585 }
586 }
587 }
588}
589
590// handleTerminalInput processes input to the terminal
591func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
592 // Check if the session exists
593 s.ptyMutex.Lock()
594 session, exists := s.terminalSessions[sessionID]
595 s.ptyMutex.Unlock()
596
597 if !exists {
598 http.Error(w, "Terminal session not found", http.StatusNotFound)
599 return
600 }
601
602 // Read the request body (terminal input or resize command)
603 body, err := io.ReadAll(r.Body)
604 if err != nil {
605 http.Error(w, "Failed to read request body", http.StatusBadRequest)
606 return
607 }
608
609 // Check if it's a resize message
610 if len(body) > 0 && body[0] == '{' {
611 var msg TerminalMessage
612 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
613 if msg.Cols > 0 && msg.Rows > 0 {
614 pty.Setsize(session.pty, &pty.Winsize{
615 Cols: msg.Cols,
616 Rows: msg.Rows,
617 })
618
619 // Respond with success
620 w.WriteHeader(http.StatusOK)
621 return
622 }
623 }
624 }
625
626 // Regular terminal input
627 _, err = session.pty.Write(body)
628 if err != nil {
629 slog.Error("Failed to write to pty", "error", err)
630 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
631 return
632 }
633
634 // Respond with success
635 w.WriteHeader(http.StatusOK)
636}
637
638// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
639func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
640 buf := make([]byte, 4096)
641 defer func() {
642 // Clean up when done
643 s.ptyMutex.Lock()
644 delete(s.terminalSessions, sessionID)
645 s.ptyMutex.Unlock()
646
647 // Close the PTY
648 session.pty.Close()
649
650 // Ensure process is terminated
651 if session.cmd.Process != nil {
652 session.cmd.Process.Signal(syscall.SIGTERM)
653 time.Sleep(100 * time.Millisecond)
654 session.cmd.Process.Kill()
655 }
656
657 // Close all client channels
658 session.eventsClientsMutex.Lock()
659 for ch := range session.eventsClients {
660 delete(session.eventsClients, ch)
661 close(ch)
662 }
663 session.eventsClientsMutex.Unlock()
664 }()
665
666 for {
667 n, err := session.pty.Read(buf)
668 if err != nil {
669 if err != io.EOF {
670 slog.Error("Failed to read from pty", "error", err)
671 }
672 break
673 }
674
675 // Make a copy of the data for each client
676 data := make([]byte, n)
677 copy(data, buf[:n])
678
679 // Broadcast to all connected clients
680 session.eventsClientsMutex.Lock()
681 for ch := range session.eventsClients {
682 // Try to send, but don't block if channel is full
683 select {
684 case ch <- data:
685 default:
686 // Channel is full, drop the message for this client
687 }
688 }
689 session.eventsClientsMutex.Unlock()
690 }
691}
692
693// getShellPath returns the path to the shell to use
694func getShellPath() string {
695 // Try to use the user's preferred shell
696 shell := os.Getenv("SHELL")
697 if shell != "" {
698 return shell
699 }
700
701 // Default to bash on Unix-like systems
702 if _, err := os.Stat("/bin/bash"); err == nil {
703 return "/bin/bash"
704 }
705
706 // Fall back to sh
707 return "/bin/sh"
708}
709
710func initDebugMux() *http.ServeMux {
711 mux := http.NewServeMux()
712 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
713 w.Header().Set("Content-Type", "text/html; charset=utf-8")
714 fmt.Fprintf(w, `<!doctype html>
715 <html><head><title>sketch debug</title></head><body>
716 <h1>sketch debug</h1>
717 <ul>
718 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
719 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
720 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
721 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
722 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
723 <li><a href="/debug/metrics">metrics</a></li>
724 </ul>
725 </body>
726 </html>
727 `)
728 })
729 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
730 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
731 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
732 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
733 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
734 return mux
735}
736
737// isValidGitSHA validates if a string looks like a valid git SHA hash.
738// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
739func isValidGitSHA(sha string) bool {
740 // Git SHA must be a hexadecimal string with at least 4 characters
741 if len(sha) < 4 || len(sha) > 40 {
742 return false
743 }
744
745 // Check if the string only contains hexadecimal characters
746 for _, char := range sha {
747 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
748 return false
749 }
750 }
751
752 return true
753}