From 23b8fe43cf01fefa0262fe49a30b0776d9b4e612 Mon Sep 17 00:00:00 2001 From: Pritom14 Date: Sat, 30 May 2026 21:53:14 +0530 Subject: [PATCH] feat(backend): add projects and pr_enrichment tables to SQLite store Migration 0002 adds two tables off the canonical CDC path: - projects: durable registry of managed repos (the twin of the old YAML config). Soft-deletable via archived_at so a session's project_id always resolves; ListProjects returns active rows only, GetProject resolves any. - pr_enrichment: per-session cache of rich SCM facts (CI summary, review decision, mergeability, pending comments, CI log tail) that do not live in the canonical lifecycle. 1:1 with a session, cascades on session delete. Both are written outside the LCM write path: no revision bump, no change_log/outbox event. Store methods mirror the reaction_trackers adapter pattern with storage-local row structs. --- backend/internal/storage/sqlite/gen/models.go | 25 +++ .../storage/sqlite/gen/pr_enrichment.sql.go | 76 +++++++++ .../storage/sqlite/gen/projects.sql.go | 154 ++++++++++++++++++ .../internal/storage/sqlite/gen/querier.go | 8 + .../sqlite/migrations/0002_pr_projects.sql | 50 ++++++ .../storage/sqlite/pr_projects_test.go | 128 +++++++++++++++ backend/internal/storage/sqlite/pr_store.go | 66 ++++++++ .../internal/storage/sqlite/project_store.go | 115 +++++++++++++ .../storage/sqlite/queries/pr_enrichment.sql | 18 ++ .../storage/sqlite/queries/projects.sql | 32 ++++ 10 files changed, 672 insertions(+) create mode 100644 backend/internal/storage/sqlite/gen/pr_enrichment.sql.go create mode 100644 backend/internal/storage/sqlite/gen/projects.sql.go create mode 100644 backend/internal/storage/sqlite/migrations/0002_pr_projects.sql create mode 100644 backend/internal/storage/sqlite/pr_projects_test.go create mode 100644 backend/internal/storage/sqlite/pr_store.go create mode 100644 backend/internal/storage/sqlite/project_store.go create mode 100644 backend/internal/storage/sqlite/queries/pr_enrichment.sql create mode 100644 backend/internal/storage/sqlite/queries/projects.sql diff --git a/backend/internal/storage/sqlite/gen/models.go b/backend/internal/storage/sqlite/gen/models.go index 210fe245a..dccf25c46 100644 --- a/backend/internal/storage/sqlite/gen/models.go +++ b/backend/internal/storage/sqlite/gen/models.go @@ -34,6 +34,31 @@ type Outbox struct { CreatedAt time.Time } +type PrEnrichment struct { + SessionID string + CiSummary string + ReviewDecision string + Mergeability string + PendingComments string + CiLogTail string + LastFetchedAt time.Time +} + +type Project struct { + ID string + Path string + RepoOwner string + RepoName string + RepoPlatform string + RepoOriginUrl string + DefaultBranch string + DisplayName string + SessionPrefix string + Source string + RegisteredAt time.Time + ArchivedAt sql.NullTime +} + type ReactionTracker struct { SessionID string ReactionKey string diff --git a/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go b/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go new file mode 100644 index 000000000..c0643104b --- /dev/null +++ b/backend/internal/storage/sqlite/gen/pr_enrichment.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: pr_enrichment.sql + +package gen + +import ( + "context" + "time" +) + +const deletePREnrichment = `-- name: DeletePREnrichment :exec +DELETE FROM pr_enrichment WHERE session_id = ? +` + +func (q *Queries) DeletePREnrichment(ctx context.Context, sessionID string) error { + _, err := q.db.ExecContext(ctx, deletePREnrichment, sessionID) + return err +} + +const getPREnrichment = `-- name: GetPREnrichment :one +SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at +FROM pr_enrichment +WHERE session_id = ? +` + +func (q *Queries) GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error) { + row := q.db.QueryRowContext(ctx, getPREnrichment, sessionID) + var i PrEnrichment + err := row.Scan( + &i.SessionID, + &i.CiSummary, + &i.ReviewDecision, + &i.Mergeability, + &i.PendingComments, + &i.CiLogTail, + &i.LastFetchedAt, + ) + return i, err +} + +const upsertPREnrichment = `-- name: UpsertPREnrichment :exec +INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + ci_summary = excluded.ci_summary, + review_decision = excluded.review_decision, + mergeability = excluded.mergeability, + pending_comments = excluded.pending_comments, + ci_log_tail = excluded.ci_log_tail, + last_fetched_at = excluded.last_fetched_at +` + +type UpsertPREnrichmentParams struct { + SessionID string + CiSummary string + ReviewDecision string + Mergeability string + PendingComments string + CiLogTail string + LastFetchedAt time.Time +} + +func (q *Queries) UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error { + _, err := q.db.ExecContext(ctx, upsertPREnrichment, + arg.SessionID, + arg.CiSummary, + arg.ReviewDecision, + arg.Mergeability, + arg.PendingComments, + arg.CiLogTail, + arg.LastFetchedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/projects.sql.go b/backend/internal/storage/sqlite/gen/projects.sql.go new file mode 100644 index 000000000..33959b765 --- /dev/null +++ b/backend/internal/storage/sqlite/gen/projects.sql.go @@ -0,0 +1,154 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: projects.sql + +package gen + +import ( + "context" + "database/sql" + "time" +) + +const archiveProject = `-- name: ArchiveProject :exec +UPDATE projects SET archived_at = ? WHERE id = ? +` + +type ArchiveProjectParams struct { + ArchivedAt sql.NullTime + ID string +} + +func (q *Queries) ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error { + _, err := q.db.ExecContext(ctx, archiveProject, arg.ArchivedAt, arg.ID) + return err +} + +const deleteProject = `-- name: DeleteProject :exec +DELETE FROM projects WHERE id = ? +` + +func (q *Queries) DeleteProject(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteProject, id) + return err +} + +const getProject = `-- name: GetProject :one +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE id = ? +` + +func (q *Queries) GetProject(ctx context.Context, id string) (Project, error) { + row := q.db.QueryRowContext(ctx, getProject, id) + var i Project + err := row.Scan( + &i.ID, + &i.Path, + &i.RepoOwner, + &i.RepoName, + &i.RepoPlatform, + &i.RepoOriginUrl, + &i.DefaultBranch, + &i.DisplayName, + &i.SessionPrefix, + &i.Source, + &i.RegisteredAt, + &i.ArchivedAt, + ) + return i, err +} + +const listProjects = `-- name: ListProjects :many +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE archived_at IS NULL +ORDER BY id +` + +func (q *Queries) ListProjects(ctx context.Context) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, listProjects) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Project{} + for rows.Next() { + var i Project + if err := rows.Scan( + &i.ID, + &i.Path, + &i.RepoOwner, + &i.RepoName, + &i.RepoPlatform, + &i.RepoOriginUrl, + &i.DefaultBranch, + &i.DisplayName, + &i.SessionPrefix, + &i.Source, + &i.RegisteredAt, + &i.ArchivedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertProject = `-- name: UpsertProject :exec +INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (id) DO UPDATE SET + path = excluded.path, + repo_owner = excluded.repo_owner, + repo_name = excluded.repo_name, + repo_platform = excluded.repo_platform, + repo_origin_url = excluded.repo_origin_url, + default_branch = excluded.default_branch, + display_name = excluded.display_name, + session_prefix = excluded.session_prefix, + source = excluded.source, + registered_at = excluded.registered_at, + archived_at = excluded.archived_at +` + +type UpsertProjectParams struct { + ID string + Path string + RepoOwner string + RepoName string + RepoPlatform string + RepoOriginUrl string + DefaultBranch string + DisplayName string + SessionPrefix string + Source string + RegisteredAt time.Time + ArchivedAt sql.NullTime +} + +func (q *Queries) UpsertProject(ctx context.Context, arg UpsertProjectParams) error { + _, err := q.db.ExecContext(ctx, upsertProject, + arg.ID, + arg.Path, + arg.RepoOwner, + arg.RepoName, + arg.RepoPlatform, + arg.RepoOriginUrl, + arg.DefaultBranch, + arg.DisplayName, + arg.SessionPrefix, + arg.Source, + arg.RegisteredAt, + arg.ArchivedAt, + ) + return err +} diff --git a/backend/internal/storage/sqlite/gen/querier.go b/backend/internal/storage/sqlite/gen/querier.go index 074fe053c..76dd1aab5 100644 --- a/backend/internal/storage/sqlite/gen/querier.go +++ b/backend/internal/storage/sqlite/gen/querier.go @@ -9,11 +9,16 @@ import ( ) type Querier interface { + ArchiveProject(ctx context.Context, arg ArchiveProjectParams) error + DeletePREnrichment(ctx context.Context, sessionID string) error + DeleteProject(ctx context.Context, id string) error DeleteReactionTracker(ctx context.Context, arg DeleteReactionTrackerParams) error DeleteSentOutboxBelow(ctx context.Context, changeLogSeq int64) (int64, error) DeleteSessionReactionTrackers(ctx context.Context, sessionID string) error GetConsumerOffset(ctx context.Context, consumer string) (int64, error) GetMetadata(ctx context.Context, sessionID string) ([]GetMetadataRow, error) + GetPREnrichment(ctx context.Context, sessionID string) (PrEnrichment, error) + GetProject(ctx context.Context, id string) (Project, error) GetSession(ctx context.Context, id string) (Session, error) GetSessionRevision(ctx context.Context, id string) (int64, error) // Appends a canonical-write record and returns its monotonic seq so the same @@ -24,6 +29,7 @@ type Querier interface { // the row is persisted at revision 1. InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) ListAllSessions(ctx context.Context) ([]Session, error) + ListProjects(ctx context.Context) ([]Project, error) ListReactionTrackers(ctx context.Context) ([]ReactionTracker, error) ListSessionsByProject(ctx context.Context, projectID string) ([]Session, error) ListUnsentOutbox(ctx context.Context, limit int64) ([]ListUnsentOutboxRow, error) @@ -36,6 +42,8 @@ type Querier interface { UpdateSessionCAS(ctx context.Context, arg UpdateSessionCASParams) (int64, error) UpsertConsumerOffset(ctx context.Context, arg UpsertConsumerOffsetParams) error UpsertMetadata(ctx context.Context, arg UpsertMetadataParams) error + UpsertPREnrichment(ctx context.Context, arg UpsertPREnrichmentParams) error + UpsertProject(ctx context.Context, arg UpsertProjectParams) error UpsertReactionTracker(ctx context.Context, arg UpsertReactionTrackerParams) error } diff --git a/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql new file mode 100644 index 000000000..4421f0ddc --- /dev/null +++ b/backend/internal/storage/sqlite/migrations/0002_pr_projects.sql @@ -0,0 +1,50 @@ +-- +goose Up +-- +goose StatementBegin + +-- projects is the durable registry of repos AO manages, the SQLite twin of the +-- old YAML config (global config.yaml + per-repo agent-orchestrator.yaml). id is +-- the {basename}_{sha256(path:originUrl)[:10]} key the session layer references +-- via sessions.project_id. The relationship is app-enforced, NOT a hard FK: +-- SQLite cannot ALTER ADD a FK without a table rebuild, and an existing-session +-- backfill may land sessions before their project row. +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL, + repo_owner TEXT NOT NULL DEFAULT '', + repo_name TEXT NOT NULL DEFAULT '', + repo_platform TEXT NOT NULL DEFAULT '', + repo_origin_url TEXT NOT NULL DEFAULT '', + default_branch TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + session_prefix TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + registered_at TIMESTAMP NOT NULL, + + -- soft delete: NULL = active. Archiving keeps the row so a session's + -- project_id always resolves (there is no FK to enforce it), avoiding + -- dangling references; active-only reads filter archived_at IS NULL. + archived_at TIMESTAMP +); + +-- pr_enrichment is the SCM observer's per-session cache of the rich PR facts that +-- do NOT live in the canonical lifecycle (which keeps only pr_state/reason/number/ +-- url). It is 1:1 with a session (a PR is always tied to a session by its branch), +-- written by the SCM observer OFF the canonical CDC path (no revision bump, no +-- change_log/outbox event), and cascades away with its session. +CREATE TABLE pr_enrichment ( + session_id TEXT PRIMARY KEY REFERENCES sessions (id) ON DELETE CASCADE, + ci_summary TEXT NOT NULL DEFAULT '', + review_decision TEXT NOT NULL DEFAULT '', + mergeability TEXT NOT NULL DEFAULT '', + pending_comments TEXT NOT NULL DEFAULT '', + ci_log_tail TEXT NOT NULL DEFAULT '', + last_fetched_at TIMESTAMP NOT NULL +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE pr_enrichment; +DROP TABLE projects; +-- +goose StatementEnd diff --git a/backend/internal/storage/sqlite/pr_projects_test.go b/backend/internal/storage/sqlite/pr_projects_test.go new file mode 100644 index 000000000..6cdd20bc1 --- /dev/null +++ b/backend/internal/storage/sqlite/pr_projects_test.go @@ -0,0 +1,128 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +func TestProjectUpsertGetListDelete(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + if _, ok, err := s.GetProject(ctx, "p1"); err != nil || ok { + t.Fatalf("get missing: ok=%v err=%v", ok, err) + } + + p := ProjectRow{ + ID: "p1", Path: "/repo", RepoOwner: "acme", RepoName: "widget", + RepoPlatform: "github", RepoOriginURL: "git@github.com:acme/widget.git", + DefaultBranch: "main", DisplayName: "Widget", SessionPrefix: "wid", + Source: "local", RegisteredAt: now, + } + if err := s.UpsertProject(ctx, p); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, ok, err := s.GetProject(ctx, "p1") + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + if got != p { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, p) + } + + // Upsert again with a changed field updates in place (no duplicate). + p.DisplayName = "Widget 2" + if err := s.UpsertProject(ctx, p); err != nil { + t.Fatalf("re-upsert: %v", err) + } + list, err := s.ListProjects(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 1 || list[0].DisplayName != "Widget 2" { + t.Fatalf("list after re-upsert = %+v", list) + } + + if err := s.DeleteProject(ctx, "p1"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, ok, _ := s.GetProject(ctx, "p1"); ok { + t.Fatal("project should be gone after delete") + } +} + +func TestArchiveProjectHidesFromListButGetResolves(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + if err := s.UpsertProject(ctx, ProjectRow{ID: "p1", Path: "/repo", RegisteredAt: now}); err != nil { + t.Fatalf("upsert: %v", err) + } + if err := s.ArchiveProject(ctx, "p1", now); err != nil { + t.Fatalf("archive: %v", err) + } + + // Active-only list hides it. + list, err := s.ListProjects(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 0 { + t.Fatalf("archived project should not appear in ListProjects, got %+v", list) + } + + // Get still resolves it (a session's project_id must not dangle) and reports + // the archived marker. + got, ok, err := s.GetProject(ctx, "p1") + if err != nil || !ok { + t.Fatalf("get archived: ok=%v err=%v", ok, err) + } + if got.ArchivedAt.IsZero() { + t.Fatal("archived project should carry a non-zero ArchivedAt") + } +} + +func TestPREnrichmentUpsertGetDelete(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + // pr_enrichment FKs sessions(id); seed the session first. + if err := s.Upsert(ctx, sampleRecord("s1"), ports.EventSessionCreated); err != nil { + t.Fatalf("seed session: %v", err) + } + + if _, ok, err := s.GetPREnrichment(ctx, "s1"); err != nil || ok { + t.Fatalf("get missing: ok=%v err=%v", ok, err) + } + + e := PREnrichmentRow{ + SessionID: "s1", CISummary: "3 passing, 1 failing", ReviewDecision: "changes_requested", + Mergeability: "blocked", PendingComments: `[{"path":"a.go"}]`, CILogTail: "FAIL TestX", + LastFetchedAt: now, + } + if err := s.UpsertPREnrichment(ctx, e); err != nil { + t.Fatalf("upsert: %v", err) + } + + got, ok, err := s.GetPREnrichment(ctx, "s1") + if err != nil || !ok { + t.Fatalf("get: ok=%v err=%v", ok, err) + } + if got != e { + t.Fatalf("round-trip mismatch:\n got %+v\nwant %+v", got, e) + } + + if err := s.DeletePREnrichment(ctx, "s1"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, ok, _ := s.GetPREnrichment(ctx, "s1"); ok { + t.Fatal("enrichment should be gone after delete") + } +} diff --git a/backend/internal/storage/sqlite/pr_store.go b/backend/internal/storage/sqlite/pr_store.go new file mode 100644 index 000000000..70efb7ce7 --- /dev/null +++ b/backend/internal/storage/sqlite/pr_store.go @@ -0,0 +1,66 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// PREnrichmentRow is the SCM observer's cache of the rich PR facts that do not +// live in the canonical lifecycle (which keeps only pr_state/reason/number/url). +// It is 1:1 with a session and written OFF the canonical CDC path: upserting it +// never bumps revision and never emits a change_log/outbox event. pending_comments +// and ci_log_tail are opaque blobs the SCM observer serializes. +type PREnrichmentRow struct { + SessionID string + CISummary string + ReviewDecision string + Mergeability string + PendingComments string + CILogTail string + LastFetchedAt time.Time +} + +// UpsertPREnrichment inserts or replaces the cached PR facts for one session. +func (s *Store) UpsertPREnrichment(ctx context.Context, r PREnrichmentRow) error { + return s.q.UpsertPREnrichment(ctx, gen.UpsertPREnrichmentParams{ + SessionID: r.SessionID, + CiSummary: r.CISummary, + ReviewDecision: r.ReviewDecision, + Mergeability: r.Mergeability, + PendingComments: r.PendingComments, + CiLogTail: r.CILogTail, + LastFetchedAt: r.LastFetchedAt, + }) +} + +// GetPREnrichment returns the cached PR facts for one session. ok is false when +// no row exists (the SCM observer has not yet fetched, or the session has no PR). +func (s *Store) GetPREnrichment(ctx context.Context, sessionID string) (PREnrichmentRow, bool, error) { + e, err := s.q.GetPREnrichment(ctx, sessionID) + if errors.Is(err, sql.ErrNoRows) { + return PREnrichmentRow{}, false, nil + } + if err != nil { + return PREnrichmentRow{}, false, fmt.Errorf("get pr enrichment: %w", err) + } + return PREnrichmentRow{ + SessionID: e.SessionID, + CISummary: e.CiSummary, + ReviewDecision: e.ReviewDecision, + Mergeability: e.Mergeability, + PendingComments: e.PendingComments, + CILogTail: e.CiLogTail, + LastFetchedAt: e.LastFetchedAt, + }, true, nil +} + +// DeletePREnrichment drops the cached PR facts for one session. Normally +// unnecessary (the FK cascades on session delete), exposed for explicit eviction. +func (s *Store) DeletePREnrichment(ctx context.Context, sessionID string) error { + return s.q.DeletePREnrichment(ctx, sessionID) +} diff --git a/backend/internal/storage/sqlite/project_store.go b/backend/internal/storage/sqlite/project_store.go new file mode 100644 index 000000000..fb75e18ae --- /dev/null +++ b/backend/internal/storage/sqlite/project_store.go @@ -0,0 +1,115 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/storage/sqlite/gen" +) + +// ProjectRow is one registered repo, the durable twin of the old YAML config +// entry. It is the unit the registration path upserts and cross-project readers +// list. Off the canonical CDC path: writing a project never emits a change_log +// or outbox event. +type ProjectRow struct { + ID string + Path string + RepoOwner string + RepoName string + RepoPlatform string + RepoOriginURL string + DefaultBranch string + DisplayName string + SessionPrefix string + Source string + RegisteredAt time.Time + // ArchivedAt is the soft-delete marker; zero means active. GetProject returns + // it regardless of state (so a session can resolve its archived project); + // ListProjects returns only rows where it is zero. + ArchivedAt time.Time +} + +// UpsertProject inserts or updates one registered project. +func (s *Store) UpsertProject(ctx context.Context, r ProjectRow) error { + return s.q.UpsertProject(ctx, gen.UpsertProjectParams{ + ID: r.ID, + Path: r.Path, + RepoOwner: r.RepoOwner, + RepoName: r.RepoName, + RepoPlatform: r.RepoPlatform, + RepoOriginUrl: r.RepoOriginURL, + DefaultBranch: r.DefaultBranch, + DisplayName: r.DisplayName, + SessionPrefix: r.SessionPrefix, + Source: r.Source, + RegisteredAt: r.RegisteredAt, + ArchivedAt: nullTime(r.ArchivedAt), + }) +} + +// ArchiveProject soft-deletes one project, keeping the row so a session's +// project_id still resolves. Active-only reads (ListProjects) then hide it. +func (s *Store) ArchiveProject(ctx context.Context, id string, t time.Time) error { + return s.q.ArchiveProject(ctx, gen.ArchiveProjectParams{ + ArchivedAt: nullTime(t), + ID: id, + }) +} + +// GetProject returns one project by id. ok is false when no row exists. +func (s *Store) GetProject(ctx context.Context, id string) (ProjectRow, bool, error) { + p, err := s.q.GetProject(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return ProjectRow{}, false, nil + } + if err != nil { + return ProjectRow{}, false, fmt.Errorf("get project: %w", err) + } + return projectRowFromGen(p), true, nil +} + +// ListProjects returns every registered project, ordered by id. +func (s *Store) ListProjects(ctx context.Context) ([]ProjectRow, error) { + rows, err := s.q.ListProjects(ctx) + if err != nil { + return nil, fmt.Errorf("list projects: %w", err) + } + out := make([]ProjectRow, 0, len(rows)) + for _, p := range rows { + out = append(out, projectRowFromGen(p)) + } + return out, nil +} + +// DeleteProject removes one project by id. +func (s *Store) DeleteProject(ctx context.Context, id string) error { + return s.q.DeleteProject(ctx, id) +} + +func projectRowFromGen(p gen.Project) ProjectRow { + return ProjectRow{ + ID: p.ID, + Path: p.Path, + RepoOwner: p.RepoOwner, + RepoName: p.RepoName, + RepoPlatform: p.RepoPlatform, + RepoOriginURL: p.RepoOriginUrl, + DefaultBranch: p.DefaultBranch, + DisplayName: p.DisplayName, + SessionPrefix: p.SessionPrefix, + Source: p.Source, + RegisteredAt: p.RegisteredAt, + ArchivedAt: p.ArchivedAt.Time, + } +} + +// nullTime maps a zero time.Time to a NULL column, else a valid timestamp. +func nullTime(t time.Time) sql.NullTime { + if t.IsZero() { + return sql.NullTime{} + } + return sql.NullTime{Time: t, Valid: true} +} diff --git a/backend/internal/storage/sqlite/queries/pr_enrichment.sql b/backend/internal/storage/sqlite/queries/pr_enrichment.sql new file mode 100644 index 000000000..7c2ac0a03 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/pr_enrichment.sql @@ -0,0 +1,18 @@ +-- name: UpsertPREnrichment :exec +INSERT INTO pr_enrichment (session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (session_id) DO UPDATE SET + ci_summary = excluded.ci_summary, + review_decision = excluded.review_decision, + mergeability = excluded.mergeability, + pending_comments = excluded.pending_comments, + ci_log_tail = excluded.ci_log_tail, + last_fetched_at = excluded.last_fetched_at; + +-- name: GetPREnrichment :one +SELECT session_id, ci_summary, review_decision, mergeability, pending_comments, ci_log_tail, last_fetched_at +FROM pr_enrichment +WHERE session_id = ?; + +-- name: DeletePREnrichment :exec +DELETE FROM pr_enrichment WHERE session_id = ?; diff --git a/backend/internal/storage/sqlite/queries/projects.sql b/backend/internal/storage/sqlite/queries/projects.sql new file mode 100644 index 000000000..054b8f0e8 --- /dev/null +++ b/backend/internal/storage/sqlite/queries/projects.sql @@ -0,0 +1,32 @@ +-- name: UpsertProject :exec +INSERT INTO projects (id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (id) DO UPDATE SET + path = excluded.path, + repo_owner = excluded.repo_owner, + repo_name = excluded.repo_name, + repo_platform = excluded.repo_platform, + repo_origin_url = excluded.repo_origin_url, + default_branch = excluded.default_branch, + display_name = excluded.display_name, + session_prefix = excluded.session_prefix, + source = excluded.source, + registered_at = excluded.registered_at, + archived_at = excluded.archived_at; + +-- name: GetProject :one +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE id = ?; + +-- name: ListProjects :many +SELECT id, path, repo_owner, repo_name, repo_platform, repo_origin_url, default_branch, display_name, session_prefix, source, registered_at, archived_at +FROM projects +WHERE archived_at IS NULL +ORDER BY id; + +-- name: ArchiveProject :exec +UPDATE projects SET archived_at = ? WHERE id = ?; + +-- name: DeleteProject :exec +DELETE FROM projects WHERE id = ?;