diff --git a/apps/server/container.ts b/apps/server/container.ts index 3722579..2251ab4 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -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), }; } diff --git a/apps/server/db/repositories/drizzle/index.ts b/apps/server/db/repositories/drizzle/index.ts index 464f884..c29daba 100644 --- a/apps/server/db/repositories/drizzle/index.ts +++ b/apps/server/db/repositories/drizzle/index.ts @@ -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'; diff --git a/apps/server/db/repositories/drizzle/review-comment.ts b/apps/server/db/repositories/drizzle/review-comment.ts new file mode 100644 index 0000000..836c9d1 --- /dev/null +++ b/apps/server/db/repositories/drizzle/review-comment.ts @@ -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 { + 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 { + return this.db + .select() + .from(reviewComments) + .where(eq(reviewComments.phaseId, phaseId)) + .orderBy(asc(reviewComments.createdAt)); + } + + async resolve(id: string): Promise { + 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 { + 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 { + await this.db + .delete(reviewComments) + .where(eq(reviewComments.id, id)); + } +} diff --git a/apps/server/db/repositories/index.ts b/apps/server/db/repositories/index.ts index d5b896b..c9258d3 100644 --- a/apps/server/db/repositories/index.ts +++ b/apps/server/db/repositories/index.ts @@ -78,3 +78,8 @@ export type { CreateChatSessionData, CreateChatMessageData, } from './chat-session-repository.js'; + +export type { + ReviewCommentRepository, + CreateReviewCommentData, +} from './review-comment-repository.js'; diff --git a/apps/server/db/repositories/review-comment-repository.ts b/apps/server/db/repositories/review-comment-repository.ts new file mode 100644 index 0000000..50831bb --- /dev/null +++ b/apps/server/db/repositories/review-comment-repository.ts @@ -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; + findByPhaseId(phaseId: string): Promise; + resolve(id: string): Promise; + unresolve(id: string): Promise; + delete(id: string): Promise; +} diff --git a/apps/server/db/schema.ts b/apps/server/db/schema.ts index b27d28f..e2effc5 100644 --- a/apps/server/db/schema.ts +++ b/apps/server/db/schema.ts @@ -604,3 +604,27 @@ export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({ export type ChatMessage = InferSelectModel; export type NewChatMessage = InferInsertModel; + +// ============================================================================ +// 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; +export type NewReviewComment = InferInsertModel; diff --git a/apps/server/drizzle/0028_add_review_comments.sql b/apps/server/drizzle/0028_add_review_comments.sql new file mode 100644 index 0000000..9781b9a --- /dev/null +++ b/apps/server/drizzle/0028_add_review_comments.sql @@ -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); diff --git a/apps/server/server/trpc-adapter.ts b/apps/server/server/trpc-adapter.ts index 0e8d94a..40fd6b2 100644 --- a/apps/server/server/trpc-adapter.ts +++ b/apps/server/server/trpc-adapter.ts @@ -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, }), }); diff --git a/apps/server/trpc/context.ts b/apps/server/trpc/context.ts index 1b3b89b..9995662 100644 --- a/apps/server/trpc/context.ts +++ b/apps/server/trpc/context.ts @@ -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, }; } diff --git a/apps/server/trpc/routers/_helpers.ts b/apps/server/trpc/routers/_helpers.ts index cbd4f59..3948ce6 100644 --- a/apps/server/trpc/routers/_helpers.ts +++ b/apps/server/trpc/routers/_helpers.ts @@ -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; +} diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index 876140b..6ce5fbf 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -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; + }), }; } diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 20d46d9..410c41a 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -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([]); const [status, setStatus] = useState("pending"); const [selectedCommit, setSelectedCommit] = useState(null); const fileRefs = useRef>(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; diff --git a/docs/database.md b/docs/database.md index d9838ee..b9d2a0a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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). diff --git a/docs/server-api.md b/docs/server-api.md index 295d386..bc87365 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -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 |