package inventory import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // A Plan is what a merge produces (novox/hq ADR 0162): the modules it changed and everything // standing on them, sorted into tiers, each module's state, and the tier the plan is at. Kept in // the store so a controller replaced mid-plan resumes it, and so `status` can say what a merge // still waits for. type Plan struct { ID string `json:"id"` Repository string `json:"repository"` Commit string `json:"commit"` Created time.Time `json:"created"` Updated time.Time `json:"updated"` State string `json:"state"` Tier int `json:"tier"` Tiers [][]string `json:"tiers"` Modules map[string]*PlanModule `json:"modules"` Note string `json:"note,omitempty"` } // PlanModule is one module's state within a plan. type PlanModule struct { // State: asked, built, failed; empty for a module whose tier has not been asked yet. State string `json:"state,omitempty"` AskedAt *time.Time `json:"asked_at,omitempty"` BuiltAt *time.Time `json:"built_at,omitempty"` // SentAt is when the plan sent the machines running this module its new build, because a // later tier is built by it (ADR 0163's gate): the reports that open the gate are the ones // after this. SentAt *time.Time `json:"sent_at,omitempty"` Commit string `json:"commit,omitempty"` Why string `json:"why,omitempty"` } // The states a plan passes through. const ( PlanBuilding = "building" PlanRolling = "rolling" PlanDone = "done" PlanFailed = "failed" ) // Open says whether the plan is still being worked. func (p Plan) Open() bool { return p.State == PlanBuilding || p.State == PlanRolling } // SavePlan writes a plan, new or changed, whole: the plan is small and read as one thing. func (i *Inventory) SavePlan(ctx context.Context, p Plan) error { tiers, err := json.Marshal(p.Tiers) if err != nil { return err } modules, err := json.Marshal(p.Modules) if err != nil { return err } _, err = i.store.Pool().Exec(ctx, `insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note) values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9) on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier, tiers = excluded.tiers, modules = excluded.modules, note = excluded.note`, p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note) return err } // OpenPlans is every plan still being worked, oldest first. func (i *Inventory) OpenPlans(ctx context.Context) ([]Plan, error) { return i.plans(ctx, `where state in ('building', 'rolling') order by created`) } // RecentPlans is the last few plans, newest first, open or not — what the overview shows. func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) { return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit)) } // PlanByID is one plan. func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) { plans, err := i.plans(ctx, `where id = '`+id+`'`) if err != nil { return Plan{}, err } if len(plans) == 0 { return Plan{}, fmt.Errorf("no plan %s", id) } return plans[0], nil } func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) { rows, err := i.store.Pool().Query(ctx, `select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note from release_plan `+tail) if err != nil { return nil, err } defer rows.Close() var out []Plan for rows.Next() { var p Plan var tiers, modules []byte if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State, &p.Tier, &tiers, &modules, &p.Note); err != nil { return nil, err } if err := json.Unmarshal(tiers, &p.Tiers); err != nil { return nil, err } if err := json.Unmarshal(modules, &p.Modules); err != nil { return nil, err } if p.Modules == nil { p.Modules = map[string]*PlanModule{} } out = append(out, p) } if errors.Is(rows.Err(), pgx.ErrNoRows) { return nil, nil } return out, rows.Err() }