// Package store holds the in-flight repo requests. State lives in memory and is // rebuilt from the request Jobs on startup, so the deployment must stay at one // replica with a Recreate strategy. package store import ( "crypto/rand" "encoding/hex" "reflect" "sort" "sync" "time" "git.unkin.net/unkin/repospawner/internal/repospec" ) // State is where a request has reached. type State string const ( // StateOpeningPR means the PR job is running. StateOpeningPR State = "opening-pr" // StatePROpen means the PR exists and the watch job is following it. StatePROpen State = "pr-open" // StateMerged means the terraform-git PR merged. StateMerged State = "merged" // StateEnablingCI means the Woodpecker enablement job is running. StateEnablingCI State = "enabling-ci" // StateReady means everything the request asked for is done. StateReady State = "ready" // StateClosed means the PR was closed without merging. StateClosed State = "closed" // StateFailed means a job failed; Error carries why. StateFailed State = "failed" ) // Terminal reports whether a state will not change again. func (s State) Terminal() bool { return s == StateReady || s == StateClosed || s == StateFailed } // Request is one tracked new-repo request. type Request struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Woodpecker bool `json:"woodpecker"` StatusChecks []string `json:"status_checks"` State State `json:"state"` PRNumber int `json:"pr_number,omitempty"` PRURL string `json:"pr_url,omitempty"` CIEnabled bool `json:"ci_enabled"` Error string `json:"error,omitempty"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` } // Store is a concurrency-safe map of requests keyed by id. type Store struct { mu sync.RWMutex byID map[string]Request now func() time.Time } // New builds an empty Store. func New() *Store { return &Store{byID: map[string]Request{}, now: time.Now} } // SetClock replaces the timestamp source; tests use it for stable output. func (s *Store) SetClock(now func() time.Time) { s.mu.Lock() defer s.mu.Unlock() s.now = now } // NewID returns a short random request id, unique enough to name a Job. func NewID() string { var b [6]byte if _, err := rand.Read(b[:]); err != nil { // crypto/rand failing is unrecoverable; a timestamp id keeps the // caller's request identifiable rather than crashing the server. return hex.EncodeToString([]byte(time.Now().UTC().Format("150405"))) } return hex.EncodeToString(b[:]) } // NewRequest builds an unstored request in StateOpeningPR. The caller stores it // only once the PR Job actually exists, so a failed create leaves no orphan. func NewRequest(id string, spec repospec.Request, now time.Time) Request { return Request{ ID: id, Name: spec.Name, Description: spec.Description, Woodpecker: spec.Woodpecker, StatusChecks: spec.StatusChecks, State: StateOpeningPR, Created: now, Updated: now, } } // Now returns the store's clock, so callers stamp requests consistently. func (s *Store) Now() time.Time { s.mu.RLock() defer s.mu.RUnlock() return s.now() } // Put stores r verbatim, stamping Updated when anything actually changed. func (s *Store) Put(r Request) { s.mu.Lock() defer s.mu.Unlock() if prev, ok := s.byID[r.ID]; ok { r.Created = prev.Created if equalIgnoringUpdated(prev, r) { return } } else if r.Created.IsZero() { r.Created = s.now() } r.Updated = s.now() s.byID[r.ID] = r } // Get returns a request by id. func (s *Store) Get(id string) (Request, bool) { s.mu.RLock() defer s.mu.RUnlock() r, ok := s.byID[id] return r, ok } // List returns every request, most recently created first. func (s *Store) List() []Request { s.mu.RLock() defer s.mu.RUnlock() out := make([]Request, 0, len(s.byID)) for _, r := range s.byID { out = append(out, r) } sort.Slice(out, func(i, j int) bool { if out[i].Created.Equal(out[j].Created) { return out[i].ID > out[j].ID } return out[i].Created.After(out[j].Created) }) return out } // HasActiveName reports whether a non-terminal request already claims name. // Two in-flight requests for the same name would race on the same branch. func (s *Store) HasActiveName(name string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, r := range s.byID { if r.Name == name && !r.State.Terminal() { return true } } return false } func equalIgnoringUpdated(a, b Request) bool { a.Updated, b.Updated = time.Time{}, time.Time{} return reflect.DeepEqual(a, b) }