feat: Persist review comments to database

Review comments on phase diffs now survive page reloads and phase
switches. Adds review_comments table (migration 0028), repository
port/adapter (13th repo), tRPC procedures (listReviewComments,
createReviewComment, resolveReviewComment, unresolveReviewComment),
and replaces useState-based comments in ReviewTab with tRPC queries
and mutations.
This commit is contained in:
Lukas May
2026-03-05 11:16:54 +01:00
parent 69d2543995
commit 173c7f7916
14 changed files with 293 additions and 27 deletions

View File

@@ -21,6 +21,7 @@ import {
DrizzleLogChunkRepository,
DrizzleConversationRepository,
DrizzleChatSessionRepository,
DrizzleReviewCommentRepository,
} from './db/index.js';
import type { InitiativeRepository } from './db/repositories/initiative-repository.js';
import type { PhaseRepository } from './db/repositories/phase-repository.js';
@@ -34,6 +35,7 @@ import type { ChangeSetRepository } from './db/repositories/change-set-repositor
import type { LogChunkRepository } from './db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from './db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from './db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from './db/repositories/review-comment-repository.js';
import type { EventBus } from './events/index.js';
import { createEventBus } from './events/index.js';
import { ProcessManager, ProcessRegistry } from './process/index.js';
@@ -58,7 +60,7 @@ import type { ServerContextDeps } from './server/index.js';
// =============================================================================
/**
* All 12 repository ports.
* All 13 repository ports.
*/
export interface Repositories {
initiativeRepository: InitiativeRepository;
@@ -73,10 +75,11 @@ export interface Repositories {
logChunkRepository: LogChunkRepository;
conversationRepository: ConversationRepository;
chatSessionRepository: ChatSessionRepository;
reviewCommentRepository: ReviewCommentRepository;
}
/**
* Create all 12 Drizzle repository adapters from a database instance.
* Create all 13 Drizzle repository adapters from a database instance.
* Reusable by both the production server and the test harness.
*/
export function createRepositories(db: DrizzleDatabase): Repositories {
@@ -93,6 +96,7 @@ export function createRepositories(db: DrizzleDatabase): Repositories {
logChunkRepository: new DrizzleLogChunkRepository(db),
conversationRepository: new DrizzleConversationRepository(db),
chatSessionRepository: new DrizzleChatSessionRepository(db),
reviewCommentRepository: new DrizzleReviewCommentRepository(db),
};
}

View File

@@ -17,3 +17,4 @@ export { DrizzleChangeSetRepository } from './change-set.js';
export { DrizzleLogChunkRepository } from './log-chunk.js';
export { DrizzleConversationRepository } from './conversation.js';
export { DrizzleChatSessionRepository } from './chat-session.js';
export { DrizzleReviewCommentRepository } from './review-comment.js';

View File

@@ -0,0 +1,78 @@
/**
* Drizzle Review Comment Repository Adapter
*
* Implements ReviewCommentRepository interface using Drizzle ORM.
*/
import { eq, asc } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { DrizzleDatabase } from '../../index.js';
import { reviewComments, type ReviewComment } from '../../schema.js';
import type { ReviewCommentRepository, CreateReviewCommentData } from '../review-comment-repository.js';
export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
constructor(private db: DrizzleDatabase) {}
async create(data: CreateReviewCommentData): Promise<ReviewComment> {
const now = new Date();
const id = nanoid();
await this.db.insert(reviewComments).values({
id,
phaseId: data.phaseId,
filePath: data.filePath,
lineNumber: data.lineNumber,
lineType: data.lineType,
body: data.body,
author: data.author ?? 'you',
resolved: false,
createdAt: now,
updatedAt: now,
});
const rows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, id))
.limit(1);
return rows[0]!;
}
async findByPhaseId(phaseId: string): Promise<ReviewComment[]> {
return this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.phaseId, phaseId))
.orderBy(asc(reviewComments.createdAt));
}
async resolve(id: string): Promise<ReviewComment | null> {
await this.db
.update(reviewComments)
.set({ resolved: true, updatedAt: new Date() })
.where(eq(reviewComments.id, id));
const rows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, id))
.limit(1);
return rows[0] ?? null;
}
async unresolve(id: string): Promise<ReviewComment | null> {
await this.db
.update(reviewComments)
.set({ resolved: false, updatedAt: new Date() })
.where(eq(reviewComments.id, id));
const rows = await this.db
.select()
.from(reviewComments)
.where(eq(reviewComments.id, id))
.limit(1);
return rows[0] ?? null;
}
async delete(id: string): Promise<void> {
await this.db
.delete(reviewComments)
.where(eq(reviewComments.id, id));
}
}

View File

@@ -78,3 +78,8 @@ export type {
CreateChatSessionData,
CreateChatMessageData,
} from './chat-session-repository.js';
export type {
ReviewCommentRepository,
CreateReviewCommentData,
} from './review-comment-repository.js';

View File

@@ -0,0 +1,24 @@
/**
* Review Comment Repository Port Interface
*
* Port for persisting inline review comments on phase diffs.
*/
import type { ReviewComment } from '../schema.js';
export interface CreateReviewCommentData {
phaseId: string;
filePath: string;
lineNumber: number;
lineType: 'added' | 'removed' | 'context';
body: string;
author?: string;
}
export interface ReviewCommentRepository {
create(data: CreateReviewCommentData): Promise<ReviewComment>;
findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
resolve(id: string): Promise<ReviewComment | null>;
unresolve(id: string): Promise<ReviewComment | null>;
delete(id: string): Promise<void>;
}

View File

@@ -604,3 +604,27 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
export type ChatMessage = InferSelectModel<typeof chatMessages>;
export type NewChatMessage = InferInsertModel<typeof chatMessages>;
// ============================================================================
// REVIEW COMMENTS
// ============================================================================
export const reviewComments = sqliteTable('review_comments', {
id: text('id').primaryKey(),
phaseId: text('phase_id')
.notNull()
.references(() => phases.id, { onDelete: 'cascade' }),
filePath: text('file_path').notNull(),
lineNumber: integer('line_number').notNull(),
lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(),
body: text('body').notNull(),
author: text('author').notNull().default('you'),
resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => [
index('review_comments_phase_id_idx').on(table.phaseId),
]);
export type ReviewComment = InferSelectModel<typeof reviewComments>;
export type NewReviewComment = InferInsertModel<typeof reviewComments>;

View File

@@ -0,0 +1,15 @@
-- Add review_comments table for persisting inline review comments on phase diffs
CREATE TABLE IF NOT EXISTS review_comments (
id TEXT PRIMARY KEY NOT NULL,
phase_id TEXT NOT NULL REFERENCES phases(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
line_number INTEGER NOT NULL,
line_type TEXT NOT NULL,
body TEXT NOT NULL,
author TEXT NOT NULL DEFAULT 'you',
resolved INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS review_comments_phase_id_idx ON review_comments (phase_id);

View File

@@ -21,6 +21,7 @@ import type { ChangeSetRepository } from '../db/repositories/change-set-reposito
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
@@ -76,6 +77,8 @@ export interface TrpcAdapterOptions {
conversationRepository?: ConversationRepository;
/** Chat session repository for iterative phase/task chat */
chatSessionRepository?: ChatSessionRepository;
/** Review comment repository for inline review comments on phase diffs */
reviewCommentRepository?: ReviewCommentRepository;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
@@ -158,6 +161,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
previewManager: options.previewManager,
conversationRepository: options.conversationRepository,
chatSessionRepository: options.chatSessionRepository,
reviewCommentRepository: options.reviewCommentRepository,
workspaceRoot: options.workspaceRoot,
}),
});

View File

@@ -18,6 +18,7 @@ import type { ChangeSetRepository } from '../db/repositories/change-set-reposito
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
import type { AccountCredentialManager } from '../agent/credentials/types.js';
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
import type { CoordinationManager } from '../coordination/types.js';
@@ -76,6 +77,8 @@ export interface TRPCContext {
conversationRepository?: ConversationRepository;
/** Chat session repository for iterative phase/task chat */
chatSessionRepository?: ChatSessionRepository;
/** Review comment repository for inline review comments on phase diffs */
reviewCommentRepository?: ReviewCommentRepository;
/** Absolute path to the workspace root (.cwrc directory) */
workspaceRoot?: string;
}
@@ -106,6 +109,7 @@ export interface CreateContextOptions {
previewManager?: PreviewManager;
conversationRepository?: ConversationRepository;
chatSessionRepository?: ChatSessionRepository;
reviewCommentRepository?: ReviewCommentRepository;
workspaceRoot?: string;
}
@@ -139,6 +143,7 @@ export function createContext(options: CreateContextOptions): TRPCContext {
previewManager: options.previewManager,
conversationRepository: options.conversationRepository,
chatSessionRepository: options.chatSessionRepository,
reviewCommentRepository: options.reviewCommentRepository,
workspaceRoot: options.workspaceRoot,
};
}

View File

@@ -18,6 +18,7 @@ import type { ChangeSetRepository } from '../../db/repositories/change-set-repos
import type { LogChunkRepository } from '../../db/repositories/log-chunk-repository.js';
import type { ConversationRepository } from '../../db/repositories/conversation-repository.js';
import type { ChatSessionRepository } from '../../db/repositories/chat-session-repository.js';
import type { ReviewCommentRepository } from '../../db/repositories/review-comment-repository.js';
import type { DispatchManager, PhaseDispatchManager } from '../../dispatch/types.js';
import type { CoordinationManager } from '../../coordination/types.js';
import type { BranchManager } from '../../git/branch-manager.js';
@@ -203,3 +204,13 @@ export function requireChatSessionRepository(ctx: TRPCContext): ChatSessionRepos
}
return ctx.chatSessionRepository;
}
export function requireReviewCommentRepository(ctx: TRPCContext): ReviewCommentRepository {
if (!ctx.reviewCommentRepository) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Review comment repository not available',
});
}
return ctx.reviewCommentRepository;
}

View File

@@ -6,7 +6,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator } from './_helpers.js';
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository } from './_helpers.js';
import { phaseBranchName } from '../../git/branch-naming.js';
import { ensureProjectClone } from '../../git/project-clones.js';
@@ -298,5 +298,48 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return { rawDiff };
}),
listReviewComments: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requireReviewCommentRepository(ctx);
return repo.findByPhaseId(input.phaseId);
}),
createReviewComment: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
filePath: z.string().min(1),
lineNumber: z.number().int(),
lineType: z.enum(['added', 'removed', 'context']),
body: z.string().min(1),
author: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requireReviewCommentRepository(ctx);
return repo.create(input);
}),
resolveReviewComment: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireReviewCommentRepository(ctx);
const comment = await repo.resolve(input.id);
if (!comment) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` });
}
return comment;
}),
unresolveReviewComment: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireReviewCommentRepository(ctx);
const comment = await repo.unresolve(input.id);
if (!comment) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` });
}
return comment;
}),
};
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useRef } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { trpc } from "@/lib/trpc";
@@ -6,14 +6,13 @@ import { parseUnifiedDiff } from "./parse-diff";
import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar";
import { ReviewHeader } from "./ReviewHeader";
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
import type { ReviewStatus, DiffLine } from "./types";
interface ReviewTabProps {
initiativeId: string;
}
export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [comments, setComments] = useState<ReviewComment[]>([]);
const [status, setStatus] = useState<ReviewStatus>("pending");
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -116,6 +115,44 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
}
: null;
// Review comments — persisted to DB
const utils = trpc.useUtils();
const commentsQuery = trpc.listReviewComments.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId },
);
const comments = useMemo(() => {
return (commentsQuery.data ?? []).map((c) => ({
id: c.id,
filePath: c.filePath,
lineNumber: c.lineNumber,
lineType: c.lineType as "added" | "removed" | "context",
body: c.body,
author: c.author,
createdAt: c.createdAt instanceof Date ? c.createdAt.toISOString() : String(c.createdAt),
resolved: c.resolved,
}));
}, [commentsQuery.data]);
const createCommentMutation = trpc.createReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
onError: (err) => toast.error(`Failed to save comment: ${err.message}`),
});
const resolveCommentMutation = trpc.resolveReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
});
const unresolveCommentMutation = trpc.unresolveReviewComment.useMutation({
onSuccess: () => {
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
},
});
const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => {
setStatus("approved");
@@ -141,33 +178,26 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const handleAddComment = useCallback(
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
const newComment: ReviewComment = {
id: `c${Date.now()}`,
if (!activePhaseId) return;
createCommentMutation.mutate({
phaseId: activePhaseId,
filePath,
lineNumber,
lineType,
body,
author: "you",
createdAt: new Date().toISOString(),
resolved: false,
};
setComments((prev) => [...prev, newComment]);
});
toast.success("Comment added");
},
[],
[activePhaseId, createCommentMutation],
);
const handleResolveComment = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: true } : c)),
);
}, []);
resolveCommentMutation.mutate({ id: commentId });
}, [resolveCommentMutation]);
const handleUnresolveComment = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: false } : c)),
);
}, []);
unresolveCommentMutation.mutate({ id: commentId });
}, [unresolveCommentMutation]);
const handleApprove = useCallback(() => {
if (!activePhaseId) return;
@@ -192,7 +222,6 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
setSelectedPhaseId(id);
setSelectedCommit(null);
setStatus("pending");
setComments([]);
}, []);
const unresolvedCount = comments.filter((c) => !c.resolved).length;

View File

@@ -5,8 +5,8 @@
## Architecture
- **Schema**: `apps/server/db/schema.ts` — all tables, columns, relations
- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 12 repository interfaces
- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 12 Drizzle adapters
- **Ports** (interfaces): `apps/server/db/repositories/*.ts` — 13 repository interfaces
- **Adapters** (implementations): `apps/server/db/repositories/drizzle/*.ts` — 13 Drizzle adapters
- **Barrel exports**: `apps/server/db/index.ts` re-exports everything
All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.returning()` for atomic reads after writes.
@@ -195,9 +195,27 @@ Messages within a chat session.
Index: `(chatSessionId)`.
### review_comments
Inline review comments on phase diffs, persisted across page reloads.
| Column | Type | Notes |
|--------|------|-------|
| id | text PK | nanoid |
| phaseId | text FK → phases (cascade) | scopes comment to phase |
| filePath | text NOT NULL | file in diff |
| lineNumber | integer NOT NULL | line number (new-side or old-side for deletions) |
| lineType | text enum | 'added' \| 'removed' \| 'context' |
| body | text NOT NULL | comment text |
| author | text NOT NULL | default 'you' |
| resolved | integer/boolean | default false |
| createdAt, updatedAt | integer/timestamp | |
Index: `(phaseId)`.
## Repository Interfaces
12 repositories, each with standard CRUD plus domain-specific methods:
13 repositories, each with standard CRUD plus domain-specific methods:
| Repository | Key Methods |
|-----------|-------------|
@@ -213,6 +231,7 @@ Index: `(chatSessionId)`.
| LogChunkRepository | insertChunk, findByAgentId, deleteByAgentId, getSessionCount |
| ConversationRepository | create, findById, findPendingForAgent, answer |
| ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId |
| ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete |
## Migrations
@@ -224,4 +243,4 @@ Key rules:
- See [database-migrations.md](database-migrations.md) for full workflow
- Snapshots stale after 0008; migrations 0008+ are hand-written
Current migrations: 0000 through 0027 (28 total).
Current migrations: 0000 through 0028 (29 total).

View File

@@ -113,6 +113,10 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getPhaseReviewCommits | query | List commits between initiative and phase branch |
| getCommitDiff | query | Diff for a single commit (by hash) in a phase |
| approvePhaseReview | mutation | Approve and merge phase branch |
| listReviewComments | query | List review comments by phaseId |
| createReviewComment | mutation | Create inline review comment on diff |
| resolveReviewComment | mutation | Mark review comment as resolved |
| unresolveReviewComment | mutation | Mark review comment as unresolved |
### Phase Dispatch
| Procedure | Type | Description |