package issues import ( "context" "encoding/json" "fmt" "os" "sync" "time" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" ) // TriageState is the part of an issue a person decided, kept across restarts. // The tracker is in-memory, so without this every deploy would reopen every // resolved and ignored issue. type TriageState struct { Status string `json:"status"` ResolvedAt int64 `json:"resolvedAt,omitempty"` } // StatusStore persists triage state by issue id. type StatusStore interface { Load(ctx context.Context) (map[string]TriageState, error) Save(ctx context.Context, id string, s TriageState) error } // FileStatusStore keeps all states in one JSON file, rewritten on each save. type FileStatusStore struct { mu sync.Mutex path string states map[string]TriageState } func NewFileStatusStore(path string) (*FileStatusStore, error) { fs := &FileStatusStore{path: path, states: map[string]TriageState{}} b, err := os.ReadFile(path) if os.IsNotExist(err) { return fs, nil } if err != nil { return nil, err } if err := json.Unmarshal(b, &fs.states); err != nil { return nil, err } return fs, nil } func (s *FileStatusStore) Load(context.Context) (map[string]TriageState, error) { s.mu.Lock() defer s.mu.Unlock() out := make(map[string]TriageState, len(s.states)) for k, v := range s.states { out[k] = v } return out, nil } func (s *FileStatusStore) Save(_ context.Context, id string, st TriageState) error { s.mu.Lock() defer s.mu.Unlock() if st.Status == StatusOpen { delete(s.states, id) } else { s.states[id] = st } b, err := json.Marshal(s.states) if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o644); err != nil { return err } return os.Rename(tmp, s.path) } // ClickHouseStatusStore keeps the latest state per issue id. type ClickHouseStatusStore struct { conn driver.Conn db string } func NewClickHouseStatusStore(ctx context.Context, conn driver.Conn, db string) (*ClickHouseStatusStore, error) { err := conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.issue_status ( id String, status LowCardinality(String), resolved_at Int64, updated_at DateTime64(3, 'UTC') ) ENGINE = ReplacingMergeTree(updated_at) ORDER BY id`, db)) if err != nil { return nil, err } return &ClickHouseStatusStore{conn: conn, db: db}, nil } func (s *ClickHouseStatusStore) Load(ctx context.Context) (map[string]TriageState, error) { rows, err := s.conn.Query(ctx, fmt.Sprintf(`SELECT id, status, resolved_at FROM %s.issue_status FINAL`, s.db)) if err != nil { return nil, err } defer rows.Close() out := map[string]TriageState{} for rows.Next() { var id string var st TriageState if err := rows.Scan(&id, &st.Status, &st.ResolvedAt); err != nil { return nil, err } if st.Status != StatusOpen { out[id] = st } } return out, rows.Err() } func (s *ClickHouseStatusStore) Save(ctx context.Context, id string, st TriageState) error { return s.conn.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.issue_status (id, status, resolved_at, updated_at) VALUES (?, ?, ?, ?)`, s.db), id, st.Status, st.ResolvedAt, time.Now().UTC()) }