diff --git a/.env.example b/.env.example
index 0672a8b..3c6360d 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,5 @@
 NETLIFY_DATABASE_URL=postgresql://user:password@host:5432/db
+# Schema: use `pnpm db:generate` then `pnpm db:migrate` (see drizzle/README.md). Avoid `db:push` on DBs with data.
 BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
 BETTER_AUTH_URL=http://localhost:3001
 GOOGLE_CLIENT_ID=
@@ -13,3 +14,17 @@ SYNC_APP_ID=
 SYNC_PUBLISH_KEY=
 UPLOADTHING_TOKEN=
 SYNC_ENGINE_URL=
+# GitHub App integration (optional)
+# In the GitHub App settings set Setup URL and Callback URL to:
+#   http://localhost:3001/api/git/github/callback  (local)
+# Webhook URL points at smee/ngrok → /api/webhooks/github (not the callback).
+# Personal account linking also uses the Callback URL (user-to-server OAuth).
+GITHUB_APP_ID=
+GITHUB_APP_PRIVATE_KEY=
+GITHUB_APP_WEBHOOK_SECRET=
+GITHUB_APP_CLIENT_ID=
+GITHUB_APP_CLIENT_SECRET=
+GITHUB_APP_SLUG=project-y
+GIT_TOKEN_ENCRYPTION_KEY=
+# Log GitHub API payloads to the dev server console (on by default outside production)
+# GITHUB_API_DEBUG=true
diff --git a/.gitignore b/.gitignore
index 2d458e4..e930c60 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+dumps/
+
 node_modules
 package-lock.json
 yarn.lock
diff --git a/drizzle.baseline.config.ts b/drizzle.baseline.config.ts
new file mode 100644
index 0000000..f265e86
--- /dev/null
+++ b/drizzle.baseline.config.ts
@@ -0,0 +1,21 @@
+import { type Config } from "drizzle-kit";
+
+export default {
+  schema: ["./drizzle/baseline-schema/schema.ts", "./drizzle/baseline-schema/auth-schema.ts"],
+  out: "./drizzle",
+  dialect: "postgresql",
+  dbCredentials: {
+    url: process.env.NETLIFY_DATABASE_URL as string,
+  },
+  tablesFilter: [
+    "project-y_*",
+    "user",
+    "session",
+    "account",
+    "two_factor",
+    "verification",
+    "organization",
+    "member",
+    "invitation",
+  ],
+} satisfies Config;
diff --git a/drizzle.config.ts b/drizzle.config.ts
index 5414ef8..9a5246d 100644
--- a/drizzle.config.ts
+++ b/drizzle.config.ts
@@ -2,6 +2,7 @@ import { type Config } from "drizzle-kit";
 
 export default {
   schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"],
+  out: "./drizzle",
   dialect: "postgresql",
   dbCredentials: {
     url: process.env.NETLIFY_DATABASE_URL as string,
diff --git a/drizzle/0000_baseline.sql b/drizzle/0000_baseline.sql
new file mode 100644
index 0000000..21b85d1
--- /dev/null
+++ b/drizzle/0000_baseline.sql
@@ -0,0 +1,3 @@
+-- Baseline migration: production databases already contain this schema.
+-- This migration exists so Drizzle can diff against the pre-git snapshot in meta/.
+SELECT 1;
diff --git a/drizzle/0001_git_integration.sql b/drizzle/0001_git_integration.sql
new file mode 100644
index 0000000..2a88e2c
--- /dev/null
+++ b/drizzle/0001_git_integration.sql
@@ -0,0 +1,174 @@
+CREATE TYPE "public"."git_branch_state" AS ENUM('active', 'deleted');--> statement-breakpoint
+CREATE TYPE "public"."git_pr_state" AS ENUM('open', 'closed', 'merged', 'draft');--> statement-breakpoint
+CREATE TYPE "public"."git_provider" AS ENUM('github', 'gitlab', 'gitlab_self_hosted');--> statement-breakpoint
+CREATE TABLE "project-y_git_commit_status_application" (
+	"delivery_id" varchar(64) NOT NULL,
+	"commit_sha" varchar(64) NOT NULL,
+	"rule_id" uuid NOT NULL,
+	"task_id" uuid NOT NULL,
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	CONSTRAINT "project-y_git_commit_status_application_delivery_id_commit_sha_rule_id_task_id_pk" PRIMARY KEY("delivery_id","commit_sha","rule_id","task_id")
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_git_connection" (
+	"id" uuid PRIMARY KEY NOT NULL,
+	"owner" varchar(256) NOT NULL,
+	"provider" "git_provider" NOT NULL,
+	"installation_id" varchar(64),
+	"account_login" varchar(256),
+	"account_type" varchar(32),
+	"instance_url" varchar(512),
+	"credentials" text,
+	"connected_by_user_id" varchar(256),
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	"updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_git_repository" (
+	"id" uuid PRIMARY KEY NOT NULL,
+	"connection_id" uuid NOT NULL,
+	"owner" varchar(256) NOT NULL,
+	"external_id" varchar(64) NOT NULL,
+	"full_name" varchar(512) NOT NULL,
+	"default_branch" varchar(256) DEFAULT 'main' NOT NULL,
+	"html_url" varchar(512) NOT NULL,
+	"is_archived" boolean DEFAULT false NOT NULL,
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	"updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_git_status_rule" (
+	"id" uuid PRIMARY KEY NOT NULL,
+	"owner" varchar(256) NOT NULL,
+	"project_id" uuid,
+	"pattern" text NOT NULL,
+	"target_status_id" uuid NOT NULL,
+	"priority" integer DEFAULT 0 NOT NULL,
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	"updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_git_user_link" (
+	"id" uuid PRIMARY KEY NOT NULL,
+	"user_id" varchar(256) NOT NULL,
+	"provider" "git_provider" NOT NULL,
+	"provider_user_id" varchar(256) NOT NULL,
+	"provider_login" varchar(256),
+	"access_token" text NOT NULL,
+	"refresh_token" text,
+	"token_expires_at" timestamp with time zone,
+	"scopes" text,
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	"updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_git_webhook_delivery" (
+	"id" varchar(64) PRIMARY KEY NOT NULL,
+	"provider" "git_provider" NOT NULL,
+	"event_type" varchar(128) NOT NULL,
+	"processed_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_project_git_repository" (
+	"project_id" uuid NOT NULL,
+	"repository_id" uuid NOT NULL,
+	"owner" varchar(256) NOT NULL,
+	"is_primary" boolean DEFAULT false NOT NULL,
+	"default_base_branch" varchar(256),
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	CONSTRAINT "project-y_project_git_repository_project_id_repository_id_pk" PRIMARY KEY("project_id","repository_id")
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_task_git_activity" (
+	"id" uuid PRIMARY KEY NOT NULL,
+	"task_id" uuid NOT NULL,
+	"owner" varchar(256) NOT NULL,
+	"type" varchar(64) NOT NULL,
+	"payload" jsonb NOT NULL,
+	"actor_login" varchar(256),
+	"occurred_at" timestamp with time zone NOT NULL,
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_task_git_branch" (
+	"id" uuid PRIMARY KEY NOT NULL,
+	"task_id" uuid NOT NULL,
+	"repository_id" uuid NOT NULL,
+	"owner" varchar(256) NOT NULL,
+	"ref" varchar(512) NOT NULL,
+	"sha" varchar(64),
+	"state" "git_branch_state" DEFAULT 'active' NOT NULL,
+	"created_by_user_id" varchar(256),
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	"updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE "project-y_task_git_pull_request" (
+	"id" uuid PRIMARY KEY NOT NULL,
+	"task_id" uuid NOT NULL,
+	"repository_id" uuid NOT NULL,
+	"branch_id" uuid,
+	"owner" varchar(256) NOT NULL,
+	"number" integer NOT NULL,
+	"provider_pr_id" varchar(64) NOT NULL,
+	"url" varchar(512) NOT NULL,
+	"title" varchar(512) NOT NULL,
+	"state" "git_pr_state" DEFAULT 'open' NOT NULL,
+	"head_ref" varchar(512) NOT NULL,
+	"base_ref" varchar(512) NOT NULL,
+	"checks_status" varchar(32),
+	"merged_at" timestamp with time zone,
+	"closed_at" timestamp with time zone,
+	"created_at" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+	"updated_at" timestamp with time zone NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "project-y_project" ADD COLUMN "task_key_prefix" varchar(16);--> statement-breakpoint
+ALTER TABLE "project-y_project" ADD COLUMN "next_task_number" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
+ALTER TABLE "project-y_task" ADD COLUMN "number" integer;--> statement-breakpoint
+UPDATE "project-y_task" AS t
+SET "number" = numbered.rn
+FROM (
+  SELECT
+    id,
+    ROW_NUMBER() OVER (
+      PARTITION BY owner, project_id
+      ORDER BY created_at
+    ) AS rn
+  FROM "project-y_task"
+) AS numbered
+WHERE t.id = numbered.id;--> statement-breakpoint
+UPDATE "project-y_project" AS p
+SET "next_task_number" = COALESCE(
+  (
+    SELECT MAX(t."number") + 1
+    FROM "project-y_task" AS t
+    WHERE t.project_id = p.id
+      AND t.owner = p.owner
+  ),
+  1
+);--> statement-breakpoint
+ALTER TABLE "project-y_task" ALTER COLUMN "number" SET NOT NULL;--> statement-breakpoint
+ALTER TABLE "project-y_git_repository" ADD CONSTRAINT "project-y_git_repository_connection_id_project-y_git_connection_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."project-y_git_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "project-y_project_git_repository" ADD CONSTRAINT "project-y_project_git_repository_repository_id_project-y_git_repository_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."project-y_git_repository"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "project-y_task_git_branch" ADD CONSTRAINT "project-y_task_git_branch_repository_id_project-y_git_repository_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."project-y_git_repository"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "project-y_task_git_pull_request" ADD CONSTRAINT "project-y_task_git_pull_request_repository_id_project-y_git_repository_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."project-y_git_repository"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "project-y_task_git_pull_request" ADD CONSTRAINT "project-y_task_git_pull_request_branch_id_project-y_task_git_branch_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."project-y_task_git_branch"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "git_connection_owner_idx" ON "project-y_git_connection" USING btree ("owner");--> statement-breakpoint
+CREATE UNIQUE INDEX "git_connection_owner_provider_install_idx" ON "project-y_git_connection" USING btree ("owner","provider","installation_id");--> statement-breakpoint
+CREATE INDEX "git_repository_connection_idx" ON "project-y_git_repository" USING btree ("connection_id");--> statement-breakpoint
+CREATE INDEX "git_repository_owner_idx" ON "project-y_git_repository" USING btree ("owner");--> statement-breakpoint
+CREATE UNIQUE INDEX "git_repository_connection_external_idx" ON "project-y_git_repository" USING btree ("connection_id","external_id");--> statement-breakpoint
+CREATE INDEX "git_status_rule_owner_idx" ON "project-y_git_status_rule" USING btree ("owner");--> statement-breakpoint
+CREATE INDEX "git_user_link_user_idx" ON "project-y_git_user_link" USING btree ("user_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "git_user_link_user_provider_idx" ON "project-y_git_user_link" USING btree ("user_id","provider");--> statement-breakpoint
+CREATE INDEX "git_webhook_delivery_provider_idx" ON "project-y_git_webhook_delivery" USING btree ("provider");--> statement-breakpoint
+CREATE INDEX "project_git_repository_owner_idx" ON "project-y_project_git_repository" USING btree ("owner");--> statement-breakpoint
+CREATE INDEX "task_git_activity_task_idx" ON "project-y_task_git_activity" USING btree ("task_id");--> statement-breakpoint
+CREATE INDEX "task_git_branch_task_idx" ON "project-y_task_git_branch" USING btree ("task_id");--> statement-breakpoint
+CREATE INDEX "task_git_branch_owner_ref_idx" ON "project-y_task_git_branch" USING btree ("owner","ref");--> statement-breakpoint
+CREATE UNIQUE INDEX "task_git_branch_task_repo_active_idx" ON "project-y_task_git_branch" USING btree ("task_id","repository_id");--> statement-breakpoint
+CREATE INDEX "task_git_pr_task_idx" ON "project-y_task_git_pull_request" USING btree ("task_id");--> statement-breakpoint
+CREATE UNIQUE INDEX "task_git_pr_repo_number_idx" ON "project-y_task_git_pull_request" USING btree ("repository_id","number");--> statement-breakpoint
+CREATE UNIQUE INDEX "project_owner_task_key_prefix_idx" ON "project-y_project" USING btree ("owner","task_key_prefix");--> statement-breakpoint
+CREATE UNIQUE INDEX "task_owner_project_number_idx" ON "project-y_task" USING btree ("owner","project_id","number");
\ No newline at end of file
diff --git a/drizzle/README.md b/drizzle/README.md
new file mode 100644
index 0000000..79863d4
--- /dev/null
+++ b/drizzle/README.md
@@ -0,0 +1,30 @@
+# Database migrations
+
+Schema changes are managed with [Drizzle Kit](https://orm.drizzle.team/kit-docs/overview). **Do not hand-write migration files or edit `_journal.json`** — use the CLI so Drizzle keeps snapshots and the journal in sync.
+
+## Day to day
+
+1. Point `NETLIFY_DATABASE_URL` in `.env` at a database whose schema matches the latest applied migration (usually your local Docker Postgres after `pnpm db:switch-local`).
+2. Change `src/db/schema.ts` (and related schema files).
+3. Generate: `pnpm db:generate --name descriptive_name`
+4. Review the generated SQL in `drizzle/`. If a migration must backfill data before `NOT NULL` or unique constraints, adjust the **generated** file (add nullable column → `UPDATE` → `SET NOT NULL`) — do not rewrite the migration from scratch.
+5. Apply: `pnpm db:migrate`
+
+## Netlify deploys
+
+Production and preview builds run `pnpm db:migrate` before `pnpm build` (see `netlify.toml`). Ensure `NETLIFY_DATABASE_URL` is set in the Netlify site environment. Preview deploys must use a database whose schema matches the last applied migration (or a branch DB), not production unless you intend to migrate production from previews.
+
+## Existing databases (production / staging)
+
+Use **`pnpm db:migrate`**, not `pnpm db:push`. `db:push` diffs directly against the schema and can fail or drop data on columns like `tasks.number` without a backfill.
+
+- **`0000_baseline`**: no-op for databases that already had the pre–git-integration schema.
+- **`0001_git_integration`**: adds git tables, task keys, and backfills `tasks.number`.
+
+## Fresh empty database
+
+Run `pnpm db:migrate` (applies baseline + git integration), or use `pnpm db:push` only when you are sure the database is empty and you accept schema-as-code without migration history.
+
+## Regenerating a baseline (rare)
+
+If you need a new baseline snapshot (e.g. after a major schema reset), use `drizzle.baseline.config.ts` and the copy under `drizzle/baseline-schema/` — see git history for the two-step `generate` flow. This is a one-time setup aid, not part of normal development.
diff --git a/drizzle/baseline-schema/auth-schema.ts b/drizzle/baseline-schema/auth-schema.ts
new file mode 100644
index 0000000..6cd312a
--- /dev/null
+++ b/drizzle/baseline-schema/auth-schema.ts
@@ -0,0 +1,209 @@
+import { relations } from "drizzle-orm";
+import {
+  pgTable,
+  text,
+  timestamp,
+  boolean,
+  index,
+  uniqueIndex,
+} from "drizzle-orm/pg-core";
+
+export const user = pgTable("user", {
+  id: text("id").primaryKey(),
+  firstname: text("firstname").notNull(),
+  lastname: text("lastname").notNull(),
+  name: text("name").notNull(),
+  email: text("email").notNull().unique(),
+  emailVerified: boolean("email_verified").default(false).notNull(),
+  twoFactorEnabled: boolean("two_factor_enabled").default(false).notNull(),
+  image: text("image"),
+  createdAt: timestamp("created_at").defaultNow().notNull(),
+  updatedAt: timestamp("updated_at")
+    .defaultNow()
+    .$onUpdate(() => /* @__PURE__ */ new Date())
+    .notNull(),
+});
+
+export const session = pgTable(
+  "session",
+  {
+    id: text("id").primaryKey(),
+    expiresAt: timestamp("expires_at").notNull(),
+    token: text("token").notNull().unique(),
+    createdAt: timestamp("created_at").defaultNow().notNull(),
+    updatedAt: timestamp("updated_at")
+      .$onUpdate(() => /* @__PURE__ */ new Date())
+      .notNull(),
+    ipAddress: text("ip_address"),
+    userAgent: text("user_agent"),
+    userId: text("user_id")
+      .notNull()
+      .references(() => user.id, { onDelete: "cascade" }),
+    activeOrganizationId: text("active_organization_id"),
+  },
+  (table) => [index("session_userId_idx").on(table.userId)],
+);
+
+export const account = pgTable(
+  "account",
+  {
+    id: text("id").primaryKey(),
+    accountId: text("account_id").notNull(),
+    providerId: text("provider_id").notNull(),
+    userId: text("user_id")
+      .notNull()
+      .references(() => user.id, { onDelete: "cascade" }),
+    accessToken: text("access_token"),
+    refreshToken: text("refresh_token"),
+    idToken: text("id_token"),
+    accessTokenExpiresAt: timestamp("access_token_expires_at"),
+    refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
+    scope: text("scope"),
+    password: text("password"),
+    createdAt: timestamp("created_at").defaultNow().notNull(),
+    updatedAt: timestamp("updated_at")
+      .$onUpdate(() => /* @__PURE__ */ new Date())
+      .notNull(),
+  },
+  (table) => [index("account_userId_idx").on(table.userId)],
+);
+
+export const twoFactor = pgTable(
+  "two_factor",
+  {
+    id: text("id").primaryKey(),
+    userId: text("user_id")
+      .notNull()
+      .references(() => user.id, { onDelete: "cascade" }),
+    secret: text("secret").notNull(),
+    backupCodes: text("backup_codes").notNull(),
+    verified: boolean("verified").default(false).notNull(),
+  },
+  (table) => [index("two_factor_userId_idx").on(table.userId)],
+);
+
+export const verification = pgTable(
+  "verification",
+  {
+    id: text("id").primaryKey(),
+    identifier: text("identifier").notNull(),
+    value: text("value").notNull(),
+    expiresAt: timestamp("expires_at").notNull(),
+    createdAt: timestamp("created_at").defaultNow().notNull(),
+    updatedAt: timestamp("updated_at")
+      .defaultNow()
+      .$onUpdate(() => /* @__PURE__ */ new Date())
+      .notNull(),
+  },
+  (table) => [index("verification_identifier_idx").on(table.identifier)],
+);
+
+export const organization = pgTable(
+  "organization",
+  {
+    id: text("id").primaryKey(),
+    name: text("name").notNull(),
+    slug: text("slug").notNull().unique(),
+    logo: text("logo"),
+    createdAt: timestamp("created_at").notNull(),
+    metadata: text("metadata"),
+  },
+  (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
+);
+
+export const member = pgTable(
+  "member",
+  {
+    id: text("id").primaryKey(),
+    organizationId: text("organization_id")
+      .notNull()
+      .references(() => organization.id, { onDelete: "cascade" }),
+    userId: text("user_id")
+      .notNull()
+      .references(() => user.id, { onDelete: "cascade" }),
+    role: text("role").default("member").notNull(),
+    createdAt: timestamp("created_at").notNull(),
+  },
+  (table) => [
+    index("member_organizationId_idx").on(table.organizationId),
+    index("member_userId_idx").on(table.userId),
+  ],
+);
+
+export const invitation = pgTable(
+  "invitation",
+  {
+    id: text("id").primaryKey(),
+    organizationId: text("organization_id")
+      .notNull()
+      .references(() => organization.id, { onDelete: "cascade" }),
+    email: text("email").notNull(),
+    role: text("role"),
+    status: text("status").default("pending").notNull(),
+    expiresAt: timestamp("expires_at").notNull(),
+    createdAt: timestamp("created_at").defaultNow().notNull(),
+    inviterId: text("inviter_id")
+      .notNull()
+      .references(() => user.id, { onDelete: "cascade" }),
+  },
+  (table) => [
+    index("invitation_organizationId_idx").on(table.organizationId),
+    index("invitation_email_idx").on(table.email),
+  ],
+);
+
+export const userRelations = relations(user, ({ many }) => ({
+  sessions: many(session),
+  accounts: many(account),
+  twoFactors: many(twoFactor),
+  members: many(member),
+  invitations: many(invitation),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+  user: one(user, {
+    fields: [twoFactor.userId],
+    references: [user.id],
+  }),
+}));
+
+export const sessionRelations = relations(session, ({ one }) => ({
+  user: one(user, {
+    fields: [session.userId],
+    references: [user.id],
+  }),
+}));
+
+export const accountRelations = relations(account, ({ one }) => ({
+  user: one(user, {
+    fields: [account.userId],
+    references: [user.id],
+  }),
+}));
+
+export const organizationRelations = relations(organization, ({ many }) => ({
+  members: many(member),
+  invitations: many(invitation),
+}));
+
+export const memberRelations = relations(member, ({ one }) => ({
+  organization: one(organization, {
+    fields: [member.organizationId],
+    references: [organization.id],
+  }),
+  user: one(user, {
+    fields: [member.userId],
+    references: [user.id],
+  }),
+}));
+
+export const invitationRelations = relations(invitation, ({ one }) => ({
+  organization: one(organization, {
+    fields: [invitation.organizationId],
+    references: [organization.id],
+  }),
+  user: one(user, {
+    fields: [invitation.inviterId],
+    references: [user.id],
+  }),
+}));
diff --git a/drizzle/baseline-schema/schema.ts b/drizzle/baseline-schema/schema.ts
new file mode 100644
index 0000000..5eda446
--- /dev/null
+++ b/drizzle/baseline-schema/schema.ts
@@ -0,0 +1,524 @@
+// Example model schema from the Drizzle docs
+// https://orm.drizzle.team/docs/sql-schema-declaration
+
+import { relations, sql } from "drizzle-orm";
+import {
+  pgTableCreator,
+  uuid,
+  timestamp,
+  varchar,
+  text,
+  integer,
+  pgEnum,
+  primaryKey,
+  index,
+  boolean,
+  jsonb,
+} from "drizzle-orm/pg-core";
+import { createInsertSchema } from "drizzle-zod";
+import { z } from "zod";
+
+/**
+ * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
+ * database instance for multiple projects.
+ *
+ * @see https://orm.drizzle.team/docs/goodies#multi-project-schema
+ */
+export const createTable = pgTableCreator((name) => `project-y_${name}`);
+
+export const colorsEnum = pgEnum("color", [
+  "red",
+  "orange",
+  "yellow",
+  "green",
+  "emerald",
+  "teal",
+  "cyan",
+  "blue",
+  "indigo",
+  "violet",
+  "purple",
+  "fuchsia",
+  "pink",
+  "rose",
+  "neutral",
+]);
+export const COLOR_VALUES = colorsEnum.enumValues;
+export type Color = (typeof colorsEnum.enumValues)[number];
+
+export const priorityEnum = pgEnum("priority", [
+  "low",
+  "medium",
+  "high",
+  "critical",
+]);
+export const PRIORITY_VALUES = priorityEnum.enumValues;
+export type Priority = (typeof priorityEnum.enumValues)[number];
+
+export const tasks = createTable(
+  "task",
+  {
+    id: uuid("id").primaryKey(),
+    name: varchar("name", { length: 256 }).notNull(),
+    description: text("description"),
+    statusId: uuid("status_id"),
+    priority: priorityEnum("priority").notNull().default("medium"),
+    deadline: timestamp("deadline", { withTimezone: true }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    projectId: uuid("project_id").notNull(),
+    sprintId: uuid("sprint_id"),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (example) => [
+    index("task_owner_idx").on(example.owner),
+    index("task_owner_project_idx").on(example.owner, example.projectId),
+    index("task_owner_sprint_idx").on(example.owner, example.sprintId),
+  ]
+);
+
+export const taskAssignees = createTable(
+  "task_assignees",
+  {
+    id: uuid("id").primaryKey(),
+    taskId: uuid("task_id")
+      .notNull()
+      .references(() => tasks.id, { onDelete: "cascade" }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    userId: varchar("user_id", { length: 256 }).notNull(),
+    assignedAt: timestamp("assigned_at").defaultNow(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (example) => [index("task_assignees_owner_idx").on(example.owner)]
+);
+
+export const subTasks = createTable(
+  "sub_task",
+  {
+    id: uuid("id").primaryKey(),
+    description: text("description"),
+    taskId: uuid("task_id")
+      .notNull()
+      .references(() => tasks.id, { onDelete: "cascade" }),
+    done: boolean("done").notNull().default(false),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    projectId: uuid("project_id").notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (example) => [index("subtask_owner_idx").on(example.owner)]
+);
+
+export const subTaskAssignees = createTable(
+  "sub_task_assignees",
+  {
+    id: uuid("id").primaryKey(),
+    subTaskId: uuid("sub_task_id")
+      .notNull()
+      .references(() => subTasks.id, { onDelete: "cascade" }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    userId: varchar("user_id", { length: 256 }).notNull(),
+    assignedAt: timestamp("assigned_at").defaultNow(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (example) => [index("sub_task_assignees_owner_idx").on(example.owner)]
+);
+
+export const statuses = createTable(
+  "status",
+  {
+    id: uuid("id").primaryKey(),
+    name: varchar("name", { length: 256 }).notNull(),
+    color: colorsEnum("color").notNull().default("neutral"),
+    order: integer("order").notNull().default(0),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }),
+  },
+  (example) => [index("status_owner_idx").on(example.owner)]
+);
+
+export const labels = createTable(
+  "label",
+  {
+    id: uuid("id").primaryKey(),
+    name: varchar("name", { length: 256 }).notNull(),
+    color: colorsEnum("color").notNull().default("neutral"),
+    order: integer("order").notNull().default(0),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }),
+  },
+  (example) => [index("label_owner_idx").on(example.owner)]
+);
+
+export const projects = createTable(
+  "project",
+  {
+    id: uuid("id").primaryKey(),
+    name: varchar("name", { length: 256 }).notNull(),
+    description: text("description"),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    logo: varchar("logo", { length: 256 }),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }),
+  },
+  (example) => [index("project_owner_idx").on(example.owner)]
+);
+
+export const comments = createTable(
+  "comment",
+  {
+    id: uuid("id").primaryKey(),
+    taskId: uuid("task_id")
+      .notNull()
+      .references(() => tasks.id, { onDelete: "cascade" }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    author: varchar("author", { length: 256 }).notNull(),
+    content: text("content").notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }),
+  },
+  (example) => [
+    index("comment_owner_idx").on(example.owner),
+    index("comment_task_owner_idx").on(example.taskId, example.owner),
+  ]
+);
+
+export const attachments = createTable(
+  "attachment",
+  {
+    id: uuid("id").primaryKey(),
+    taskId: uuid("task_id")
+      .notNull()
+      .references(() => tasks.id),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    name: varchar("name", { length: 256 }).notNull(),
+    size: integer("size").notNull(),
+    type: varchar("type", { length: 256 }).notNull(),
+    uploadedBy: varchar("uploaded_by", { length: 256 }).notNull(),
+    providerFileId: varchar("provider_file_id", { length: 256 }).notNull(),
+    url: varchar("url", { length: 256 }).notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }),
+  },
+  (example) => [index("attachment_owner_idx").on(example.owner)]
+);
+
+export const sprints = createTable(
+  "sprint",
+  {
+    id: uuid("id").primaryKey(),
+    name: varchar("name", { length: 256 }).notNull(),
+    start: timestamp("start", { withTimezone: true }).notNull(),
+    end: timestamp("end", { withTimezone: true }).notNull(),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }),
+  },
+  (example) => [index("sprint_owner_idx").on(example.owner)]
+);
+
+export const attachmentRelations = relations(attachments, ({ one }) => ({
+  task: one(tasks, {
+    fields: [attachments.taskId],
+    references: [tasks.id],
+  }),
+}));
+
+export const commentRelations = relations(comments, ({ one }) => ({
+  task: one(tasks, {
+    fields: [comments.taskId],
+    references: [tasks.id],
+  }),
+}));
+
+export const TASK_VIEW_MODES = ["board", "list"] as const;
+export type TaskViewMode = (typeof TASK_VIEW_MODES)[number];
+
+export const userPreferencesSchema = z.object({
+  taskViewMode: z.enum(TASK_VIEW_MODES).default("board"),
+});
+export type UserPreferences = z.infer<typeof userPreferencesSchema>;
+
+export const updateUserPreferencesValidator = userPreferencesSchema
+  .partial()
+  .refine((data) => Object.keys(data).length > 0, {
+    message: "At least one preference must be provided",
+  });
+
+export const userPreferences = createTable("user_preferences", {
+  userId: text("user_id").primaryKey(),
+  preferences: jsonb("preferences")
+    .$type<UserPreferences>()
+    .notNull()
+    .default({ taskViewMode: "board" }),
+  updatedAt: timestamp("updated_at", { withTimezone: true })
+    .default(sql`CURRENT_TIMESTAMP`)
+    .notNull(),
+});
+
+export const labelsToTasks = createTable(
+  "labels_to_tasks",
+  {
+    labelId: uuid("label_id")
+      .notNull()
+      .references(() => labels.id),
+    taskId: uuid("task_id")
+      .notNull()
+      .references(() => tasks.id, { onDelete: "cascade" }),
+  },
+  (t) => [primaryKey({ columns: [t.labelId, t.taskId] })]
+);
+
+export const labelsToTasksRelations = relations(labelsToTasks, ({ one }) => ({
+  task: one(tasks, {
+    fields: [labelsToTasks.taskId],
+    references: [tasks.id],
+  }),
+  label: one(labels, {
+    fields: [labelsToTasks.labelId],
+    references: [labels.id],
+  }),
+}));
+
+export const taskRelations = relations(tasks, ({ one, many }) => ({
+  status: one(statuses, {
+    fields: [tasks.statusId],
+    references: [statuses.id],
+  }),
+  labelsToTasks: many(labelsToTasks),
+  project: one(projects, {
+    fields: [tasks.projectId],
+    references: [projects.id],
+  }),
+  attachments: many(attachments),
+  assignees: many(taskAssignees),
+  subTasks: many(subTasks),
+  sprint: one(sprints, {
+    fields: [tasks.sprintId],
+    references: [sprints.id],
+  }),
+}));
+
+export const taskAssigneesRelations = relations(taskAssignees, ({ one }) => ({
+  task: one(tasks, {
+    fields: [taskAssignees.taskId],
+    references: [tasks.id],
+  }),
+}));
+
+export const subTaskAssigneesRelations = relations(
+  subTaskAssignees,
+  ({ one }) => ({
+    subTask: one(subTasks, {
+      fields: [subTaskAssignees.subTaskId],
+      references: [subTasks.id],
+    }),
+  })
+);
+
+export const subTaskRelations = relations(subTasks, ({ one, many }) => ({
+  task: one(tasks, {
+    fields: [subTasks.taskId],
+    references: [tasks.id],
+  }),
+  assignees: many(subTaskAssignees),
+}));
+
+export const labelRelations = relations(labels, ({ many }) => ({
+  labelsToTasks: many(labelsToTasks),
+}));
+
+export type Task = typeof tasks.$inferSelect;
+export type TaskWithRelations = Task & {
+  labels: (typeof labels.$inferSelect)[];
+  attachments: (typeof attachments.$inferSelect)[];
+  project: Project;
+  sprint: Sprint | null;
+  assignees: (typeof taskAssignees.$inferSelect)[];
+  subTasks: SubTask[];
+};
+export const insertTaskValidator = createInsertSchema(tasks, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+/** Partial task updates; dates are coerced because RPC serializes them as strings. */
+export const updateTaskValidator = z.object({
+  id: z.string().uuid(),
+  name: z.string().min(1).max(256).optional(),
+  description: z.string().nullable().optional(),
+  statusId: z.string().uuid().nullable().optional(),
+  priority: z.enum(PRIORITY_VALUES).optional(),
+  deadline: z.coerce.date().nullable().optional(),
+  projectId: z.string().uuid().optional(),
+  sprintId: z.string().uuid().nullable().optional(),
+});
+export type CreateTask = Omit<
+  typeof tasks.$inferInsert,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateTask = z.infer<typeof updateTaskValidator>;
+
+export type SubTask = typeof subTasks.$inferSelect & {
+  assignees: (typeof subTaskAssignees.$inferSelect)[];
+};
+export const insertSubTaskValidator = createInsertSchema(subTasks, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+  updatedAt: (schema) => schema.optional(),
+});
+export const updateSubTaskValidator = createInsertSchema(subTasks, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+  done: (schema) => schema.optional(),
+  taskId: (schema) => schema.optional(),
+  projectId: (schema) => schema.optional(),
+  updatedAt: (schema) => schema.optional(),
+});
+export type CreateSubTask = Omit<
+  typeof subTasks.$inferInsert,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateSubTask = {
+  id: string;
+  description?: string;
+  taskId: string;
+  done?: boolean;
+  projectId: string;
+  owner?: string;
+  updatedAt?: Date;
+};
+
+export const assignTaskValidator = createInsertSchema(taskAssignees, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+  assignedAt: (schema) => schema.optional(),
+  updatedAt: (schema) => schema.optional(),
+});
+export type AssignTask = Omit<
+  typeof taskAssignees.$inferInsert,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+
+export type Comment = typeof comments.$inferSelect;
+export const insertCommentValidator = createInsertSchema(comments, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export type CreateComment = Omit<
+  typeof comments.$inferInsert,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateComment = {
+  id: string;
+  content?: string;
+};
+
+export type Attachment = typeof attachments.$inferSelect;
+export const insertAttachmentValidator = createInsertSchema(attachments, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export type CreateAttachment = Omit<
+  typeof attachments.$inferInsert,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateAttachment = {
+  id: string;
+  content?: string;
+};
+
+export type Sprint = typeof sprints.$inferSelect;
+export const insertSprintValidator = createInsertSchema(sprints, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export const updateSprintValidator = createInsertSchema(sprints, {
+  owner: (schema) => schema.optional(),
+  name: (schema) => schema.optional(),
+  start: (schema) => schema.optional(),
+  end: (schema) => schema.optional(),
+});
+export type CreateSprint = Omit<
+  typeof sprints.$inferInsert,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateSprint = {
+  id: string;
+  content?: string;
+};
+
+export type Project = typeof projects.$inferSelect;
+export const insertProjectValidator = createInsertSchema(projects, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export const updateProjectValidator = createInsertSchema(projects, {
+  owner: (schema) => schema.optional(),
+  name: (schema) => schema.optional(),
+  description: (schema) => schema.optional(),
+  logo: (schema) => schema.optional(),
+});
+export type CreateProject = Omit<
+  typeof projects.$inferInsert,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateProject = {
+  id: string;
+  content?: string;
+};
+
+export type Status = typeof statuses.$inferInsert;
+export const insertStatusValidator = createInsertSchema(statuses, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export const updateStatusValidator = createInsertSchema(statuses, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export const updateMultipleStatusesValidator = updateStatusValidator.array();
+export type CreateStatus = Omit<
+  Status,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateStatus = Omit<Status, "createdAt" | "updatedAt" | "owner">;
+
+export type Label = typeof labels.$inferInsert;
+export const insertLabelValidator = createInsertSchema(labels, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export const updateLabelValidator = createInsertSchema(labels, {
+  id: (schema) => schema.optional(),
+  owner: (schema) => schema.optional(),
+});
+export const updateMultipleLabelsValidator = updateLabelValidator.array();
+export type CreateLabel = Omit<
+  Label,
+  "id" | "createdAt" | "updatedAt" | "owner"
+>;
+export type UpdateLabel = Omit<Label, "createdAt" | "updatedAt" | "owner">;
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..6a5240d
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,1812 @@
+{
+  "id": "e0a26639-14be-401f-b3b0-d501e6ff64d9",
+  "prevId": "00000000-0000-0000-0000-000000000000",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.project-y_attachment": {
+      "name": "project-y_attachment",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "type": {
+          "name": "type",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "uploaded_by": {
+          "name": "uploaded_by",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_file_id": {
+          "name": "provider_file_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "attachment_owner_idx": {
+          "name": "attachment_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_attachment_task_id_project-y_task_id_fk": {
+          "name": "project-y_attachment_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_attachment",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_comment": {
+      "name": "project-y_comment",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "author": {
+          "name": "author",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "content": {
+          "name": "content",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "comment_owner_idx": {
+          "name": "comment_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "comment_task_owner_idx": {
+          "name": "comment_task_owner_idx",
+          "columns": [
+            {
+              "expression": "task_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_comment_task_id_project-y_task_id_fk": {
+          "name": "project-y_comment_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_comment",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_label": {
+      "name": "project-y_label",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "color": {
+          "name": "color",
+          "type": "color",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'neutral'"
+        },
+        "order": {
+          "name": "order",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "label_owner_idx": {
+          "name": "label_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_labels_to_tasks": {
+      "name": "project-y_labels_to_tasks",
+      "schema": "",
+      "columns": {
+        "label_id": {
+          "name": "label_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "project-y_labels_to_tasks_label_id_project-y_label_id_fk": {
+          "name": "project-y_labels_to_tasks_label_id_project-y_label_id_fk",
+          "tableFrom": "project-y_labels_to_tasks",
+          "tableTo": "project-y_label",
+          "columnsFrom": [
+            "label_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        },
+        "project-y_labels_to_tasks_task_id_project-y_task_id_fk": {
+          "name": "project-y_labels_to_tasks_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_labels_to_tasks",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "project-y_labels_to_tasks_label_id_task_id_pk": {
+          "name": "project-y_labels_to_tasks_label_id_task_id_pk",
+          "columns": [
+            "label_id",
+            "task_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_project": {
+      "name": "project-y_project",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "logo": {
+          "name": "logo",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "project_owner_idx": {
+          "name": "project_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_sprint": {
+      "name": "project-y_sprint",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "start": {
+          "name": "start",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "end": {
+          "name": "end",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "sprint_owner_idx": {
+          "name": "sprint_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_status": {
+      "name": "project-y_status",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "color": {
+          "name": "color",
+          "type": "color",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'neutral'"
+        },
+        "order": {
+          "name": "order",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "status_owner_idx": {
+          "name": "status_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_sub_task_assignees": {
+      "name": "project-y_sub_task_assignees",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "sub_task_id": {
+          "name": "sub_task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "assigned_at": {
+          "name": "assigned_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "sub_task_assignees_owner_idx": {
+          "name": "sub_task_assignees_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_sub_task_assignees_sub_task_id_project-y_sub_task_id_fk": {
+          "name": "project-y_sub_task_assignees_sub_task_id_project-y_sub_task_id_fk",
+          "tableFrom": "project-y_sub_task_assignees",
+          "tableTo": "project-y_sub_task",
+          "columnsFrom": [
+            "sub_task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_sub_task": {
+      "name": "project-y_sub_task",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "done": {
+          "name": "done",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "project_id": {
+          "name": "project_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "subtask_owner_idx": {
+          "name": "subtask_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_sub_task_task_id_project-y_task_id_fk": {
+          "name": "project-y_sub_task_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_sub_task",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_task_assignees": {
+      "name": "project-y_task_assignees",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "assigned_at": {
+          "name": "assigned_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "task_assignees_owner_idx": {
+          "name": "task_assignees_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_task_assignees_task_id_project-y_task_id_fk": {
+          "name": "project-y_task_assignees_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_task_assignees",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_task": {
+      "name": "project-y_task",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_id": {
+          "name": "status_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "priority",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'medium'"
+        },
+        "deadline": {
+          "name": "deadline",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "project_id": {
+          "name": "project_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "sprint_id": {
+          "name": "sprint_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "task_owner_idx": {
+          "name": "task_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_owner_project_idx": {
+          "name": "task_owner_project_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "project_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_owner_sprint_idx": {
+          "name": "task_owner_sprint_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "sprint_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_user_preferences": {
+      "name": "project-y_user_preferences",
+      "schema": "",
+      "columns": {
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "preferences": {
+          "name": "preferences",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'{\"taskViewMode\":\"board\"}'::jsonb"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.account": {
+      "name": "account",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "account_id": {
+          "name": "account_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "access_token": {
+          "name": "access_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "refresh_token": {
+          "name": "refresh_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "id_token": {
+          "name": "id_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "access_token_expires_at": {
+          "name": "access_token_expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "refresh_token_expires_at": {
+          "name": "refresh_token_expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "password": {
+          "name": "password",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "account_userId_idx": {
+          "name": "account_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "account_user_id_user_id_fk": {
+          "name": "account_user_id_user_id_fk",
+          "tableFrom": "account",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.invitation": {
+      "name": "invitation",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "organization_id": {
+          "name": "organization_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'pending'"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "inviter_id": {
+          "name": "inviter_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "invitation_organizationId_idx": {
+          "name": "invitation_organizationId_idx",
+          "columns": [
+            {
+              "expression": "organization_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "invitation_email_idx": {
+          "name": "invitation_email_idx",
+          "columns": [
+            {
+              "expression": "email",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "invitation_organization_id_organization_id_fk": {
+          "name": "invitation_organization_id_organization_id_fk",
+          "tableFrom": "invitation",
+          "tableTo": "organization",
+          "columnsFrom": [
+            "organization_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "invitation_inviter_id_user_id_fk": {
+          "name": "invitation_inviter_id_user_id_fk",
+          "tableFrom": "invitation",
+          "tableTo": "user",
+          "columnsFrom": [
+            "inviter_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.member": {
+      "name": "member",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "organization_id": {
+          "name": "organization_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'member'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "member_organizationId_idx": {
+          "name": "member_organizationId_idx",
+          "columns": [
+            {
+              "expression": "organization_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "member_userId_idx": {
+          "name": "member_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "member_organization_id_organization_id_fk": {
+          "name": "member_organization_id_organization_id_fk",
+          "tableFrom": "member",
+          "tableTo": "organization",
+          "columnsFrom": [
+            "organization_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "member_user_id_user_id_fk": {
+          "name": "member_user_id_user_id_fk",
+          "tableFrom": "member",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.organization": {
+      "name": "organization",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "slug": {
+          "name": "slug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "logo": {
+          "name": "logo",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "metadata": {
+          "name": "metadata",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "organization_slug_uidx": {
+          "name": "organization_slug_uidx",
+          "columns": [
+            {
+              "expression": "slug",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "organization_slug_unique": {
+          "name": "organization_slug_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "slug"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.session": {
+      "name": "session",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "token": {
+          "name": "token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "ip_address": {
+          "name": "ip_address",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "active_organization_id": {
+          "name": "active_organization_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "session_userId_idx": {
+          "name": "session_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "session_user_id_user_id_fk": {
+          "name": "session_user_id_user_id_fk",
+          "tableFrom": "session",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "session_token_unique": {
+          "name": "session_token_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "token"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.two_factor": {
+      "name": "two_factor",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "secret": {
+          "name": "secret",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "backup_codes": {
+          "name": "backup_codes",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "verified": {
+          "name": "verified",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        }
+      },
+      "indexes": {
+        "two_factor_userId_idx": {
+          "name": "two_factor_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "two_factor_user_id_user_id_fk": {
+          "name": "two_factor_user_id_user_id_fk",
+          "tableFrom": "two_factor",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.user": {
+      "name": "user",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "firstname": {
+          "name": "firstname",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "lastname": {
+          "name": "lastname",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "email_verified": {
+          "name": "email_verified",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "two_factor_enabled": {
+          "name": "two_factor_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "image": {
+          "name": "image",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "user_email_unique": {
+          "name": "user_email_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "email"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.verification": {
+      "name": "verification",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "identifier": {
+          "name": "identifier",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "verification_identifier_idx": {
+          "name": "verification_identifier_idx",
+          "columns": [
+            {
+              "expression": "identifier",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {
+    "public.color": {
+      "name": "color",
+      "schema": "public",
+      "values": [
+        "red",
+        "orange",
+        "yellow",
+        "green",
+        "emerald",
+        "teal",
+        "cyan",
+        "blue",
+        "indigo",
+        "violet",
+        "purple",
+        "fuchsia",
+        "pink",
+        "rose",
+        "neutral"
+      ]
+    },
+    "public.priority": {
+      "name": "priority",
+      "schema": "public",
+      "values": [
+        "low",
+        "medium",
+        "high",
+        "critical"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..502b72d
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,2975 @@
+{
+  "id": "1d79b88e-4e52-4d43-a326-af68d8afb91e",
+  "prevId": "e0a26639-14be-401f-b3b0-d501e6ff64d9",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.project-y_attachment": {
+      "name": "project-y_attachment",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "type": {
+          "name": "type",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "uploaded_by": {
+          "name": "uploaded_by",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_file_id": {
+          "name": "provider_file_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "attachment_owner_idx": {
+          "name": "attachment_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_attachment_task_id_project-y_task_id_fk": {
+          "name": "project-y_attachment_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_attachment",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_comment": {
+      "name": "project-y_comment",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "author": {
+          "name": "author",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "content": {
+          "name": "content",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "comment_owner_idx": {
+          "name": "comment_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "comment_task_owner_idx": {
+          "name": "comment_task_owner_idx",
+          "columns": [
+            {
+              "expression": "task_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_comment_task_id_project-y_task_id_fk": {
+          "name": "project-y_comment_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_comment",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_label": {
+      "name": "project-y_label",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "color": {
+          "name": "color",
+          "type": "color",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'neutral'"
+        },
+        "order": {
+          "name": "order",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "label_owner_idx": {
+          "name": "label_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_labels_to_tasks": {
+      "name": "project-y_labels_to_tasks",
+      "schema": "",
+      "columns": {
+        "label_id": {
+          "name": "label_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "project-y_labels_to_tasks_label_id_project-y_label_id_fk": {
+          "name": "project-y_labels_to_tasks_label_id_project-y_label_id_fk",
+          "tableFrom": "project-y_labels_to_tasks",
+          "tableTo": "project-y_label",
+          "columnsFrom": [
+            "label_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "no action",
+          "onUpdate": "no action"
+        },
+        "project-y_labels_to_tasks_task_id_project-y_task_id_fk": {
+          "name": "project-y_labels_to_tasks_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_labels_to_tasks",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "project-y_labels_to_tasks_label_id_task_id_pk": {
+          "name": "project-y_labels_to_tasks_label_id_task_id_pk",
+          "columns": [
+            "label_id",
+            "task_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_project": {
+      "name": "project-y_project",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "task_key_prefix": {
+          "name": "task_key_prefix",
+          "type": "varchar(16)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "next_task_number": {
+          "name": "next_task_number",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "logo": {
+          "name": "logo",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "project_owner_idx": {
+          "name": "project_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "project_owner_task_key_prefix_idx": {
+          "name": "project_owner_task_key_prefix_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "task_key_prefix",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_sprint": {
+      "name": "project-y_sprint",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "start": {
+          "name": "start",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "end": {
+          "name": "end",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "sprint_owner_idx": {
+          "name": "sprint_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_status": {
+      "name": "project-y_status",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "color": {
+          "name": "color",
+          "type": "color",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'neutral'"
+        },
+        "order": {
+          "name": "order",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "status_owner_idx": {
+          "name": "status_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_sub_task_assignees": {
+      "name": "project-y_sub_task_assignees",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "sub_task_id": {
+          "name": "sub_task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "assigned_at": {
+          "name": "assigned_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "sub_task_assignees_owner_idx": {
+          "name": "sub_task_assignees_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_sub_task_assignees_sub_task_id_project-y_sub_task_id_fk": {
+          "name": "project-y_sub_task_assignees_sub_task_id_project-y_sub_task_id_fk",
+          "tableFrom": "project-y_sub_task_assignees",
+          "tableTo": "project-y_sub_task",
+          "columnsFrom": [
+            "sub_task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_sub_task": {
+      "name": "project-y_sub_task",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "done": {
+          "name": "done",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "project_id": {
+          "name": "project_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "subtask_owner_idx": {
+          "name": "subtask_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_sub_task_task_id_project-y_task_id_fk": {
+          "name": "project-y_sub_task_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_sub_task",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_task_assignees": {
+      "name": "project-y_task_assignees",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "assigned_at": {
+          "name": "assigned_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "task_assignees_owner_idx": {
+          "name": "task_assignees_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_task_assignees_task_id_project-y_task_id_fk": {
+          "name": "project-y_task_assignees_task_id_project-y_task_id_fk",
+          "tableFrom": "project-y_task_assignees",
+          "tableTo": "project-y_task",
+          "columnsFrom": [
+            "task_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_task": {
+      "name": "project-y_task",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "number": {
+          "name": "number",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_id": {
+          "name": "status_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "priority",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'medium'"
+        },
+        "deadline": {
+          "name": "deadline",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "project_id": {
+          "name": "project_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "sprint_id": {
+          "name": "sprint_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "task_owner_idx": {
+          "name": "task_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_owner_project_idx": {
+          "name": "task_owner_project_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "project_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_owner_sprint_idx": {
+          "name": "task_owner_sprint_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "sprint_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_owner_project_number_idx": {
+          "name": "task_owner_project_number_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "project_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "number",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_user_preferences": {
+      "name": "project-y_user_preferences",
+      "schema": "",
+      "columns": {
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "preferences": {
+          "name": "preferences",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'{\"taskViewMode\":\"board\"}'::jsonb"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_git_commit_status_application": {
+      "name": "project-y_git_commit_status_application",
+      "schema": "",
+      "columns": {
+        "delivery_id": {
+          "name": "delivery_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "commit_sha": {
+          "name": "commit_sha",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "rule_id": {
+          "name": "rule_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "project-y_git_commit_status_application_delivery_id_commit_sha_rule_id_task_id_pk": {
+          "name": "project-y_git_commit_status_application_delivery_id_commit_sha_rule_id_task_id_pk",
+          "columns": [
+            "delivery_id",
+            "commit_sha",
+            "rule_id",
+            "task_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_git_connection": {
+      "name": "project-y_git_connection",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider": {
+          "name": "provider",
+          "type": "git_provider",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "installation_id": {
+          "name": "installation_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "account_login": {
+          "name": "account_login",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "account_type": {
+          "name": "account_type",
+          "type": "varchar(32)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "instance_url": {
+          "name": "instance_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "credentials": {
+          "name": "credentials",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "connected_by_user_id": {
+          "name": "connected_by_user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "git_connection_owner_idx": {
+          "name": "git_connection_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "git_connection_owner_provider_install_idx": {
+          "name": "git_connection_owner_provider_install_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "installation_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_git_repository": {
+      "name": "project-y_git_repository",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "connection_id": {
+          "name": "connection_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "external_id": {
+          "name": "external_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "full_name": {
+          "name": "full_name",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "default_branch": {
+          "name": "default_branch",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'main'"
+        },
+        "html_url": {
+          "name": "html_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_archived": {
+          "name": "is_archived",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "git_repository_connection_idx": {
+          "name": "git_repository_connection_idx",
+          "columns": [
+            {
+              "expression": "connection_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "git_repository_owner_idx": {
+          "name": "git_repository_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "git_repository_connection_external_idx": {
+          "name": "git_repository_connection_external_idx",
+          "columns": [
+            {
+              "expression": "connection_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "external_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_git_repository_connection_id_project-y_git_connection_id_fk": {
+          "name": "project-y_git_repository_connection_id_project-y_git_connection_id_fk",
+          "tableFrom": "project-y_git_repository",
+          "tableTo": "project-y_git_connection",
+          "columnsFrom": [
+            "connection_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_git_status_rule": {
+      "name": "project-y_git_status_rule",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "project_id": {
+          "name": "project_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "pattern": {
+          "name": "pattern",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "target_status_id": {
+          "name": "target_status_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "git_status_rule_owner_idx": {
+          "name": "git_status_rule_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_git_user_link": {
+      "name": "project-y_git_user_link",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider": {
+          "name": "provider",
+          "type": "git_provider",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_user_id": {
+          "name": "provider_user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_login": {
+          "name": "provider_login",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "access_token": {
+          "name": "access_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "refresh_token": {
+          "name": "refresh_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "token_expires_at": {
+          "name": "token_expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scopes": {
+          "name": "scopes",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "git_user_link_user_idx": {
+          "name": "git_user_link_user_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "git_user_link_user_provider_idx": {
+          "name": "git_user_link_user_provider_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "provider",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_git_webhook_delivery": {
+      "name": "project-y_git_webhook_delivery",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(64)",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider": {
+          "name": "provider",
+          "type": "git_provider",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "event_type": {
+          "name": "event_type",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "processed_at": {
+          "name": "processed_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        }
+      },
+      "indexes": {
+        "git_webhook_delivery_provider_idx": {
+          "name": "git_webhook_delivery_provider_idx",
+          "columns": [
+            {
+              "expression": "provider",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_project_git_repository": {
+      "name": "project-y_project_git_repository",
+      "schema": "",
+      "columns": {
+        "project_id": {
+          "name": "project_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "repository_id": {
+          "name": "repository_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_primary": {
+          "name": "is_primary",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "default_base_branch": {
+          "name": "default_base_branch",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        }
+      },
+      "indexes": {
+        "project_git_repository_owner_idx": {
+          "name": "project_git_repository_owner_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_project_git_repository_repository_id_project-y_git_repository_id_fk": {
+          "name": "project-y_project_git_repository_repository_id_project-y_git_repository_id_fk",
+          "tableFrom": "project-y_project_git_repository",
+          "tableTo": "project-y_git_repository",
+          "columnsFrom": [
+            "repository_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "project-y_project_git_repository_project_id_repository_id_pk": {
+          "name": "project-y_project_git_repository_project_id_repository_id_pk",
+          "columns": [
+            "project_id",
+            "repository_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_task_git_activity": {
+      "name": "project-y_task_git_activity",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "type": {
+          "name": "type",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "payload": {
+          "name": "payload",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "actor_login": {
+          "name": "actor_login",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "occurred_at": {
+          "name": "occurred_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        }
+      },
+      "indexes": {
+        "task_git_activity_task_idx": {
+          "name": "task_git_activity_task_idx",
+          "columns": [
+            {
+              "expression": "task_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_task_git_branch": {
+      "name": "project-y_task_git_branch",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "repository_id": {
+          "name": "repository_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "ref": {
+          "name": "ref",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "sha": {
+          "name": "sha",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "state": {
+          "name": "state",
+          "type": "git_branch_state",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'active'"
+        },
+        "created_by_user_id": {
+          "name": "created_by_user_id",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "task_git_branch_task_idx": {
+          "name": "task_git_branch_task_idx",
+          "columns": [
+            {
+              "expression": "task_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_git_branch_owner_ref_idx": {
+          "name": "task_git_branch_owner_ref_idx",
+          "columns": [
+            {
+              "expression": "owner",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "ref",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_git_branch_task_repo_active_idx": {
+          "name": "task_git_branch_task_repo_active_idx",
+          "columns": [
+            {
+              "expression": "task_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "repository_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_task_git_branch_repository_id_project-y_git_repository_id_fk": {
+          "name": "project-y_task_git_branch_repository_id_project-y_git_repository_id_fk",
+          "tableFrom": "project-y_task_git_branch",
+          "tableTo": "project-y_git_repository",
+          "columnsFrom": [
+            "repository_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.project-y_task_git_pull_request": {
+      "name": "project-y_task_git_pull_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "task_id": {
+          "name": "task_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "repository_id": {
+          "name": "repository_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "branch_id": {
+          "name": "branch_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "owner": {
+          "name": "owner",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "number": {
+          "name": "number",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_pr_id": {
+          "name": "provider_pr_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "title": {
+          "name": "title",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "state": {
+          "name": "state",
+          "type": "git_pr_state",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'open'"
+        },
+        "head_ref": {
+          "name": "head_ref",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "base_ref": {
+          "name": "base_ref",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "checks_status": {
+          "name": "checks_status",
+          "type": "varchar(32)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "merged_at": {
+          "name": "merged_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "closed_at": {
+          "name": "closed_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "CURRENT_TIMESTAMP"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "task_git_pr_task_idx": {
+          "name": "task_git_pr_task_idx",
+          "columns": [
+            {
+              "expression": "task_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "task_git_pr_repo_number_idx": {
+          "name": "task_git_pr_repo_number_idx",
+          "columns": [
+            {
+              "expression": "repository_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "number",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "project-y_task_git_pull_request_repository_id_project-y_git_repository_id_fk": {
+          "name": "project-y_task_git_pull_request_repository_id_project-y_git_repository_id_fk",
+          "tableFrom": "project-y_task_git_pull_request",
+          "tableTo": "project-y_git_repository",
+          "columnsFrom": [
+            "repository_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "project-y_task_git_pull_request_branch_id_project-y_task_git_branch_id_fk": {
+          "name": "project-y_task_git_pull_request_branch_id_project-y_task_git_branch_id_fk",
+          "tableFrom": "project-y_task_git_pull_request",
+          "tableTo": "project-y_task_git_branch",
+          "columnsFrom": [
+            "branch_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "set null",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.account": {
+      "name": "account",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "account_id": {
+          "name": "account_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "access_token": {
+          "name": "access_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "refresh_token": {
+          "name": "refresh_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "id_token": {
+          "name": "id_token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "access_token_expires_at": {
+          "name": "access_token_expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "refresh_token_expires_at": {
+          "name": "refresh_token_expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "password": {
+          "name": "password",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "account_userId_idx": {
+          "name": "account_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "account_user_id_user_id_fk": {
+          "name": "account_user_id_user_id_fk",
+          "tableFrom": "account",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.invitation": {
+      "name": "invitation",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "organization_id": {
+          "name": "organization_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'pending'"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "inviter_id": {
+          "name": "inviter_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "invitation_organizationId_idx": {
+          "name": "invitation_organizationId_idx",
+          "columns": [
+            {
+              "expression": "organization_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "invitation_email_idx": {
+          "name": "invitation_email_idx",
+          "columns": [
+            {
+              "expression": "email",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "invitation_organization_id_organization_id_fk": {
+          "name": "invitation_organization_id_organization_id_fk",
+          "tableFrom": "invitation",
+          "tableTo": "organization",
+          "columnsFrom": [
+            "organization_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "invitation_inviter_id_user_id_fk": {
+          "name": "invitation_inviter_id_user_id_fk",
+          "tableFrom": "invitation",
+          "tableTo": "user",
+          "columnsFrom": [
+            "inviter_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.member": {
+      "name": "member",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "organization_id": {
+          "name": "organization_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "role": {
+          "name": "role",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'member'"
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {
+        "member_organizationId_idx": {
+          "name": "member_organizationId_idx",
+          "columns": [
+            {
+              "expression": "organization_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "member_userId_idx": {
+          "name": "member_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "member_organization_id_organization_id_fk": {
+          "name": "member_organization_id_organization_id_fk",
+          "tableFrom": "member",
+          "tableTo": "organization",
+          "columnsFrom": [
+            "organization_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "member_user_id_user_id_fk": {
+          "name": "member_user_id_user_id_fk",
+          "tableFrom": "member",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.organization": {
+      "name": "organization",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "slug": {
+          "name": "slug",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "logo": {
+          "name": "logo",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "metadata": {
+          "name": "metadata",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "organization_slug_uidx": {
+          "name": "organization_slug_uidx",
+          "columns": [
+            {
+              "expression": "slug",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "organization_slug_unique": {
+          "name": "organization_slug_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "slug"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.session": {
+      "name": "session",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "token": {
+          "name": "token",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "ip_address": {
+          "name": "ip_address",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "active_organization_id": {
+          "name": "active_organization_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "session_userId_idx": {
+          "name": "session_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "session_user_id_user_id_fk": {
+          "name": "session_user_id_user_id_fk",
+          "tableFrom": "session",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "session_token_unique": {
+          "name": "session_token_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "token"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.two_factor": {
+      "name": "two_factor",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "secret": {
+          "name": "secret",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "backup_codes": {
+          "name": "backup_codes",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "verified": {
+          "name": "verified",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        }
+      },
+      "indexes": {
+        "two_factor_userId_idx": {
+          "name": "two_factor_userId_idx",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {
+        "two_factor_user_id_user_id_fk": {
+          "name": "two_factor_user_id_user_id_fk",
+          "tableFrom": "two_factor",
+          "tableTo": "user",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.user": {
+      "name": "user",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "firstname": {
+          "name": "firstname",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "lastname": {
+          "name": "lastname",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "email": {
+          "name": "email",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "email_verified": {
+          "name": "email_verified",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "two_factor_enabled": {
+          "name": "two_factor_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "image": {
+          "name": "image",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "user_email_unique": {
+          "name": "user_email_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "email"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.verification": {
+      "name": "verification",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "identifier": {
+          "name": "identifier",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "verification_identifier_idx": {
+          "name": "verification_identifier_idx",
+          "columns": [
+            {
+              "expression": "identifier",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {
+    "public.color": {
+      "name": "color",
+      "schema": "public",
+      "values": [
+        "red",
+        "orange",
+        "yellow",
+        "green",
+        "emerald",
+        "teal",
+        "cyan",
+        "blue",
+        "indigo",
+        "violet",
+        "purple",
+        "fuchsia",
+        "pink",
+        "rose",
+        "neutral"
+      ]
+    },
+    "public.priority": {
+      "name": "priority",
+      "schema": "public",
+      "values": [
+        "low",
+        "medium",
+        "high",
+        "critical"
+      ]
+    },
+    "public.git_branch_state": {
+      "name": "git_branch_state",
+      "schema": "public",
+      "values": [
+        "active",
+        "deleted"
+      ]
+    },
+    "public.git_pr_state": {
+      "name": "git_pr_state",
+      "schema": "public",
+      "values": [
+        "open",
+        "closed",
+        "merged",
+        "draft"
+      ]
+    },
+    "public.git_provider": {
+      "name": "git_provider",
+      "schema": "public",
+      "values": [
+        "github",
+        "gitlab",
+        "gitlab_self_hosted"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
new file mode 100644
index 0000000..8834308
--- /dev/null
+++ b/drizzle/meta/_journal.json
@@ -0,0 +1,20 @@
+{
+  "version": "7",
+  "dialect": "postgresql",
+  "entries": [
+    {
+      "idx": 0,
+      "version": "7",
+      "when": 1779434449000,
+      "tag": "0000_baseline",
+      "breakpoints": true
+    },
+    {
+      "idx": 1,
+      "version": "7",
+      "when": 1779434451000,
+      "tag": "0001_git_integration",
+      "breakpoints": true
+    }
+  ]
+}
diff --git a/netlify.toml b/netlify.toml
index fe33e82..ceea5a9 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -1,3 +1,3 @@
 [build]
-  command = "pnpm build"
+  command = "pnpm db:migrate && pnpm build"
   publish = "dist/client"
diff --git a/package.json b/package.json
index b47509b..93ea7a1 100644
--- a/package.json
+++ b/package.json
@@ -10,14 +10,21 @@
     "build": "vite build && tsc --noEmit",
     "lint": "eslint src --max-warnings 0",
     "preview": "vite preview",
+    "db:generate": "drizzle-kit generate",
+    "db:migrate": "drizzle-kit migrate",
     "db:push": "drizzle-kit push",
-    "db:studio": "drizzle-kit studio"
+    "db:studio": "drizzle-kit studio",
+    "db:switch-local": "./scripts/switch-to-local-db.sh"
   },
   "dependencies": {
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@hookform/resolvers": "^5.1.1",
+    "@octokit/app": "^16.1.2",
+    "@octokit/rest": "^22.0.1",
+    "@pierre/diffs": "^1.2.1",
+    "@pierre/trees": "1.0.0-beta.4",
     "@radix-ui/react-alert-dialog": "^1.1.14",
     "@radix-ui/react-avatar": "^1.1.10",
     "@radix-ui/react-checkbox": "^1.3.3",
@@ -58,13 +65,17 @@
     "drizzle-zod": "^0.8.2",
     "lucide-react": "^0.525.0",
     "next-themes": "^0.4.6",
+    "node-mailjet": "^6.0.11",
     "postgres": "^3.4.7",
     "react": "^19.2.0",
     "react-day-picker": "^9.8.0",
     "react-dom": "^19.2.0",
     "react-hook-form": "^7.60.0",
+    "react-markdown": "^10.1.0",
     "redaxios": "^0.5.1",
-    "node-mailjet": "^6.0.11",
+    "rehype-raw": "^7.0.0",
+    "rehype-sanitize": "^6.0.0",
+    "remark-gfm": "^4.0.1",
     "sonner": "^2.0.6",
     "tailwind-merge": "^2.6.0",
     "tailwindcss": "^4.1.18",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9ea16ee..954a1d3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,18 @@ importers:
       '@hookform/resolvers':
         specifier: ^5.1.1
         version: 5.2.2(react-hook-form@7.76.0(react@19.2.6))
+      '@octokit/app':
+        specifier: ^16.1.2
+        version: 16.1.2
+      '@octokit/rest':
+        specifier: ^22.0.1
+        version: 22.0.1
+      '@pierre/diffs':
+        specifier: ^1.2.1
+        version: 1.2.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+      '@pierre/trees':
+        specifier: 1.0.0-beta.4
+        version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
       '@radix-ui/react-alert-dialog':
         specifier: ^1.1.14
         version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -158,9 +170,21 @@ importers:
       react-hook-form:
         specifier: ^7.60.0
         version: 7.76.0(react@19.2.6)
+      react-markdown:
+        specifier: ^10.1.0
+        version: 10.1.0(@types/react@19.2.14)(react@19.2.6)
       redaxios:
         specifier: ^0.5.1
         version: 0.5.1
+      rehype-raw:
+        specifier: ^7.0.0
+        version: 7.0.0
+      rehype-sanitize:
+        specifier: ^6.0.0
+        version: 6.0.0
+      remark-gfm:
+        specifier: ^4.0.1
+        version: 4.0.1
       sonner:
         specifier: ^2.0.6
         version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -1377,6 +1401,105 @@ packages:
     resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
     engines: {node: '>= 8'}
 
+  '@octokit/app@16.1.2':
+    resolution: {integrity: sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==}
+    engines: {node: '>= 20'}
+
+  '@octokit/auth-app@8.2.0':
+    resolution: {integrity: sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==}
+    engines: {node: '>= 20'}
+
+  '@octokit/auth-oauth-app@9.0.3':
+    resolution: {integrity: sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==}
+    engines: {node: '>= 20'}
+
+  '@octokit/auth-oauth-device@8.0.3':
+    resolution: {integrity: sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==}
+    engines: {node: '>= 20'}
+
+  '@octokit/auth-oauth-user@6.0.2':
+    resolution: {integrity: sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==}
+    engines: {node: '>= 20'}
+
+  '@octokit/auth-token@6.0.0':
+    resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
+    engines: {node: '>= 20'}
+
+  '@octokit/auth-unauthenticated@7.0.3':
+    resolution: {integrity: sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g==}
+    engines: {node: '>= 20'}
+
+  '@octokit/core@7.0.6':
+    resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==}
+    engines: {node: '>= 20'}
+
+  '@octokit/endpoint@11.0.3':
+    resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==}
+    engines: {node: '>= 20'}
+
+  '@octokit/graphql@9.0.3':
+    resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==}
+    engines: {node: '>= 20'}
+
+  '@octokit/oauth-app@8.0.3':
+    resolution: {integrity: sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg==}
+    engines: {node: '>= 20'}
+
+  '@octokit/oauth-authorization-url@8.0.0':
+    resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==}
+    engines: {node: '>= 20'}
+
+  '@octokit/oauth-methods@6.0.2':
+    resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==}
+    engines: {node: '>= 20'}
+
+  '@octokit/openapi-types@27.0.0':
+    resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==}
+
+  '@octokit/openapi-webhooks-types@12.1.0':
+    resolution: {integrity: sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==}
+
+  '@octokit/plugin-paginate-rest@14.0.0':
+    resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==}
+    engines: {node: '>= 20'}
+    peerDependencies:
+      '@octokit/core': '>=6'
+
+  '@octokit/plugin-request-log@6.0.0':
+    resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==}
+    engines: {node: '>= 20'}
+    peerDependencies:
+      '@octokit/core': '>=6'
+
+  '@octokit/plugin-rest-endpoint-methods@17.0.0':
+    resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==}
+    engines: {node: '>= 20'}
+    peerDependencies:
+      '@octokit/core': '>=6'
+
+  '@octokit/request-error@7.1.0':
+    resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==}
+    engines: {node: '>= 20'}
+
+  '@octokit/request@10.0.9':
+    resolution: {integrity: sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==}
+    engines: {node: '>= 20'}
+
+  '@octokit/rest@22.0.1':
+    resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==}
+    engines: {node: '>= 20'}
+
+  '@octokit/types@16.0.0':
+    resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==}
+
+  '@octokit/webhooks-methods@6.0.0':
+    resolution: {integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==}
+    engines: {node: '>= 20'}
+
+  '@octokit/webhooks@14.2.0':
+    resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==}
+    engines: {node: '>= 20'}
+
   '@oozcitak/dom@2.0.2':
     resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==}
     engines: {node: '>=20.0'}
@@ -1668,6 +1791,22 @@ packages:
     resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
     engines: {node: '>= 10.0.0'}
 
+  '@pierre/diffs@1.2.1':
+    resolution: {integrity: sha512-T9pYhh1KeaYLTwL1HMiAkeETj9CPjiKq6pqiUWRVJXvIkIvYutMrYyr3kN07cFWX2XPM8eqe6l2dknezs6K2AA==}
+    peerDependencies:
+      react: ^18.3.1 || ^19.0.0
+      react-dom: ^18.3.1 || ^19.0.0
+
+  '@pierre/theme@1.0.3':
+    resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==}
+    engines: {vscode: ^1.0.0}
+
+  '@pierre/trees@1.0.0-beta.4':
+    resolution: {integrity: sha512-OfT1yk9ne8Te5+GB5zUY8yqE6B8BqjBHQJleH4lu8ltwNpoocZl4vXt1AzlEExpxI/pp+AFX5QG+lR3JjtTEag==}
+    peerDependencies:
+      react: ^18.3.1 || ^19.0.0
+      react-dom: ^18.3.1 || ^19.0.0
+
   '@pkgjs/parseargs@0.11.0':
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
@@ -2288,6 +2427,30 @@ packages:
       rollup:
         optional: true
 
+  '@shikijs/core@3.23.0':
+    resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
+
+  '@shikijs/engine-javascript@3.23.0':
+    resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
+
+  '@shikijs/engine-oniguruma@3.23.0':
+    resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
+
+  '@shikijs/langs@3.23.0':
+    resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
+
+  '@shikijs/themes@3.23.0':
+    resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
+
+  '@shikijs/transformers@3.23.0':
+    resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==}
+
+  '@shikijs/types@3.23.0':
+    resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
+
+  '@shikijs/vscode-textmate@10.0.2':
+    resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+
   '@so-ric/colorspace@1.1.6':
     resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
 
@@ -2826,12 +2989,24 @@ packages:
   '@tybys/wasm-util@0.10.2':
     resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
 
+  '@types/aws-lambda@8.10.161':
+    resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==}
+
+  '@types/debug@4.1.13':
+    resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+
   '@types/esrecurse@4.3.1':
     resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
 
+  '@types/estree-jsx@1.0.5':
+    resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
   '@types/estree@1.0.9':
     resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
 
+  '@types/hast@3.0.4':
+    resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
@@ -2841,9 +3016,15 @@ packages:
   '@types/markdown-it@14.1.2':
     resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
 
+  '@types/mdast@4.0.4':
+    resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+
   '@types/mdurl@2.0.0':
     resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
 
+  '@types/ms@2.1.0':
+    resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
   '@types/node@22.19.19':
     resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
 
@@ -2867,6 +3048,12 @@ packages:
   '@types/triple-beam@1.3.5':
     resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
 
+  '@types/unist@2.0.11':
+    resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
+
+  '@types/unist@3.0.3':
+    resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+
   '@types/use-sync-external-store@0.0.6':
     resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
 
@@ -2958,6 +3145,9 @@ packages:
     resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
+  '@ungap/structured-clone@1.3.1':
+    resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
+
   '@uploadthing/mime-types@0.3.6':
     resolution: {integrity: sha512-t3tTzgwFV9+1D7lNDYc7Lr7kBwotHaX0ZsvoCGe7xGnXKo9z0jG2Sjl/msll12FeoLj77nyhsxevXyGpQDBvLg==}
 
@@ -3158,6 +3348,9 @@ packages:
   babel-dead-code-elimination@1.0.12:
     resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==}
 
+  bail@2.0.2:
+    resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
+
   balanced-match@1.0.2:
     resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
 
@@ -3214,6 +3407,9 @@ packages:
     engines: {node: '>=6.0.0'}
     hasBin: true
 
+  before-after-hook@4.0.0:
+    resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
+
   better-ajv-errors@1.2.0:
     resolution: {integrity: sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA==}
     engines: {node: '>= 12.13.0'}
@@ -3353,6 +3549,9 @@ packages:
   caniuse-lite@1.0.30001793:
     resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
 
+  ccount@2.0.1:
+    resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+
   chalk@4.1.2:
     resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
     engines: {node: '>=10'}
@@ -3361,6 +3560,18 @@ packages:
     resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
     engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
 
+  character-entities-html4@2.1.0:
+    resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
+
+  character-entities-legacy@3.0.0:
+    resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
+
+  character-entities@2.0.2:
+    resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
+
+  character-reference-invalid@2.0.1:
+    resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+
   cheerio-select@2.1.0:
     resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
 
@@ -3437,6 +3648,9 @@ packages:
     resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
     engines: {node: '>= 0.8'}
 
+  comma-separated-tokens@2.0.3:
+    resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+
   commander@10.0.1:
     resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
     engines: {node: '>=14'}
@@ -3466,6 +3680,10 @@ packages:
     resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
     engines: {node: ^14.18.0 || >=16.10.0}
 
+  content-type@2.0.0:
+    resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
+    engines: {node: '>=18'}
+
   convert-source-map@2.0.0:
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
 
@@ -3576,6 +3794,9 @@ packages:
   decache@4.6.2:
     resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==}
 
+  decode-named-character-reference@1.3.0:
+    resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
+
   dedent@1.7.2:
     resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
     peerDependencies:
@@ -3606,6 +3827,10 @@ packages:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
 
+  dequal@2.0.3:
+    resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+    engines: {node: '>=6'}
+
   destr@2.0.5:
     resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
 
@@ -3662,6 +3887,13 @@ packages:
   dettle@1.0.5:
     resolution: {integrity: sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==}
 
+  devlop@1.1.0:
+    resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
+
+  diff@8.0.3:
+    resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
+    engines: {node: '>=0.3.1'}
+
   diff@8.0.4:
     resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
     engines: {node: '>=0.3.1'}
@@ -3965,6 +4197,9 @@ packages:
     resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
     engines: {node: '>=4.0'}
 
+  estree-util-is-identifier-name@3.0.0:
+    resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+
   estree-walker@2.0.2:
     resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
 
@@ -3994,6 +4229,9 @@ packages:
   exsolve@1.0.8:
     resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
 
+  extend@3.0.2:
+    resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
   extract-zip@2.0.1:
     resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
     engines: {node: '>= 10.17.0'}
@@ -4003,6 +4241,9 @@ packages:
     resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
     engines: {node: '>=8.0.0'}
 
+  fast-content-type-parse@3.0.0:
+    resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==}
+
   fast-deep-equal@3.1.3:
     resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
 
@@ -4266,6 +4507,33 @@ packages:
     resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
     engines: {node: '>= 0.4'}
 
+  hast-util-from-parse5@8.0.3:
+    resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
+
+  hast-util-parse-selector@4.0.0:
+    resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
+
+  hast-util-raw@9.1.0:
+    resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
+
+  hast-util-sanitize@5.0.2:
+    resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
+
+  hast-util-to-html@9.0.5:
+    resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
+
+  hast-util-to-jsx-runtime@2.3.6:
+    resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
+
+  hast-util-to-parse5@8.0.1:
+    resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==}
+
+  hast-util-whitespace@3.0.0:
+    resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+
+  hastscript@9.0.1:
+    resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+
   hermes-estree@0.25.1:
     resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
 
@@ -4276,6 +4544,12 @@ packages:
     resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
     engines: {node: ^16.14.0 || >=18.0.0}
 
+  html-url-attributes@3.0.1:
+    resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
+
+  html-void-elements@3.0.0:
+    resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+
   htmlparser2@10.1.0:
     resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
 
@@ -4337,6 +4611,9 @@ packages:
   inherits@2.0.4:
     resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
 
+  inline-style-parser@0.2.7:
+    resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+
   internal-slot@1.1.0:
     resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
     engines: {node: '>= 0.4'}
@@ -4348,6 +4625,12 @@ packages:
   iron-webcrypto@1.2.1:
     resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
 
+  is-alphabetical@2.0.1:
+    resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
+
+  is-alphanumerical@2.0.1:
+    resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
+
   is-array-buffer@3.0.5:
     resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
     engines: {node: '>= 0.4'}
@@ -4384,6 +4667,9 @@ packages:
     resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
     engines: {node: '>= 0.4'}
 
+  is-decimal@2.0.1:
+    resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
+
   is-extglob@2.1.1:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     engines: {node: '>=0.10.0'}
@@ -4404,6 +4690,9 @@ packages:
     resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
     engines: {node: '>=0.10.0'}
 
+  is-hexadecimal@2.0.1:
+    resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+
   is-map@2.0.3:
     resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
     engines: {node: '>= 0.4'}
@@ -4548,6 +4837,9 @@ packages:
   json-stable-stringify-without-jsonify@1.0.1:
     resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
 
+  json-with-bigint@3.5.8:
+    resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==}
+
   json5@2.2.3:
     resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
     engines: {node: '>=6'}
@@ -4722,6 +5014,9 @@ packages:
     resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
     engines: {node: '>= 12.0.0'}
 
+  longest-streak@3.1.0:
+    resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+
   lru-cache@10.4.3:
     resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
 
@@ -4732,6 +5027,9 @@ packages:
   lru-cache@5.1.1:
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
 
+  lru_map@0.4.1:
+    resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
+
   lucide-react@0.525.0:
     resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==}
     peerDependencies:
@@ -4752,10 +5050,58 @@ packages:
     resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
     hasBin: true
 
+  markdown-table@3.0.4:
+    resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+
   math-intrinsics@1.1.0:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
 
+  mdast-util-find-and-replace@3.0.2:
+    resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
+
+  mdast-util-from-markdown@2.0.3:
+    resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==}
+
+  mdast-util-gfm-autolink-literal@2.0.1:
+    resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
+
+  mdast-util-gfm-footnote@2.1.0:
+    resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
+
+  mdast-util-gfm-strikethrough@2.0.0:
+    resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==}
+
+  mdast-util-gfm-table@2.0.0:
+    resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
+
+  mdast-util-gfm-task-list-item@2.0.0:
+    resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
+
+  mdast-util-gfm@3.1.0:
+    resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
+
+  mdast-util-mdx-expression@2.0.1:
+    resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
+
+  mdast-util-mdx-jsx@3.2.0:
+    resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
+
+  mdast-util-mdxjs-esm@2.0.1:
+    resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
+
+  mdast-util-phrasing@4.1.0:
+    resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
+
+  mdast-util-to-hast@13.2.1:
+    resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+
+  mdast-util-to-markdown@2.1.2:
+    resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
+
+  mdast-util-to-string@4.0.0:
+    resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+
   mdn-data@2.0.28:
     resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
 
@@ -4776,6 +5122,90 @@ packages:
     resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
     engines: {node: '>= 8'}
 
+  micromark-core-commonmark@2.0.3:
+    resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+
+  micromark-extension-gfm-autolink-literal@2.1.0:
+    resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
+
+  micromark-extension-gfm-footnote@2.1.0:
+    resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
+
+  micromark-extension-gfm-strikethrough@2.1.0:
+    resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
+
+  micromark-extension-gfm-table@2.1.1:
+    resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==}
+
+  micromark-extension-gfm-tagfilter@2.0.0:
+    resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
+
+  micromark-extension-gfm-task-list-item@2.1.0:
+    resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
+
+  micromark-extension-gfm@3.0.0:
+    resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
+
+  micromark-factory-destination@2.0.1:
+    resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
+
+  micromark-factory-label@2.0.1:
+    resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
+
+  micromark-factory-space@2.0.1:
+    resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==}
+
+  micromark-factory-title@2.0.1:
+    resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
+
+  micromark-factory-whitespace@2.0.1:
+    resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
+
+  micromark-util-character@2.1.1:
+    resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
+
+  micromark-util-chunked@2.0.1:
+    resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
+
+  micromark-util-classify-character@2.0.1:
+    resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
+
+  micromark-util-combine-extensions@2.0.1:
+    resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
+
+  micromark-util-decode-numeric-character-reference@2.0.2:
+    resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
+
+  micromark-util-decode-string@2.0.1:
+    resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
+
+  micromark-util-encode@2.0.1:
+    resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
+
+  micromark-util-html-tag-name@2.0.1:
+    resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
+
+  micromark-util-normalize-identifier@2.0.1:
+    resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
+
+  micromark-util-resolve-all@2.0.1:
+    resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
+
+  micromark-util-sanitize-uri@2.0.1:
+    resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
+
+  micromark-util-subtokenize@2.1.0:
+    resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
+
+  micromark-util-symbol@2.0.1:
+    resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
+
+  micromark-util-types@2.0.2:
+    resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
+
+  micromark@4.0.2:
+    resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+
   micromatch@4.0.8:
     resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
     engines: {node: '>=8.6'}
@@ -4982,6 +5412,12 @@ packages:
     resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
     engines: {node: '>=12'}
 
+  oniguruma-parser@0.12.2:
+    resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
+
+  oniguruma-to-es@4.3.6:
+    resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
+
   optionator@0.9.4:
     resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
     engines: {node: '>= 0.8.0'}
@@ -5036,6 +5472,9 @@ packages:
   package-json-from-dist@1.0.1:
     resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
 
+  parse-entities@4.0.2:
+    resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+
   parse-gitignore@2.0.0:
     resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==}
     engines: {node: '>=14'}
@@ -5131,6 +5570,14 @@ packages:
     resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
     engines: {node: '>=12'}
 
+  preact-render-to-string@6.6.5:
+    resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==}
+    peerDependencies:
+      preact: '>=10 || >= 11.0.0-0'
+
+  preact@11.0.0-beta.0:
+    resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==}
+
   precinct@12.3.2:
     resolution: {integrity: sha512-JbJevI1K80z8e/WIyDt/4vUN/4qcfBSKKqOjJA4mosPPPb7zODKRJQV7YN7apVWN3k58nZYm/vEsLgEGYmnxwg==}
     engines: {node: '>=18'}
@@ -5157,6 +5604,9 @@ packages:
     resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
     engines: {node: '>= 0.6.0'}
 
+  property-information@7.1.0:
+    resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+
   prosemirror-changeset@2.4.1:
     resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==}
 
@@ -5259,6 +5709,12 @@ packages:
     peerDependencies:
       react: ^16.8.0 || ^17 || ^18 || ^19
 
+  react-markdown@10.1.0:
+    resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
+    peerDependencies:
+      '@types/react': '>=18'
+      react: '>=18'
+
   react-remove-scroll-bar@2.3.8:
     resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
     engines: {node: '>=10'}
@@ -5334,10 +5790,37 @@ packages:
     resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
     engines: {node: '>= 0.4'}
 
+  regex-recursion@6.0.2:
+    resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
+
+  regex-utilities@2.3.0:
+    resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
+
+  regex@6.1.0:
+    resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
+
   regexp.prototype.flags@1.5.4:
     resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
     engines: {node: '>= 0.4'}
 
+  rehype-raw@7.0.0:
+    resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
+
+  rehype-sanitize@6.0.0:
+    resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
+
+  remark-gfm@4.0.1:
+    resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
+
+  remark-parse@11.0.0:
+    resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==}
+
+  remark-rehype@11.1.2:
+    resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
+
+  remark-stringify@11.0.0:
+    resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+
   remove-trailing-separator@1.1.0:
     resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==}
 
@@ -5475,6 +5958,9 @@ packages:
     resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
     engines: {node: '>= 0.4'}
 
+  shiki@3.23.0:
+    resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==}
+
   side-channel-list@1.0.1:
     resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
     engines: {node: '>= 0.4'}
@@ -5522,6 +6008,9 @@ packages:
     resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
     engines: {node: '>= 12'}
 
+  space-separated-tokens@2.0.2:
+    resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+
   spdx-correct@3.2.0:
     resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
 
@@ -5581,6 +6070,9 @@ packages:
   string_decoder@1.3.0:
     resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
 
+  stringify-entities@4.0.4:
+    resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
+
   strip-ansi@6.0.1:
     resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
     engines: {node: '>=8'}
@@ -5599,6 +6091,12 @@ packages:
   stubborn-utils@1.0.2:
     resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==}
 
+  style-to-js@1.1.21:
+    resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
+
+  style-to-object@1.0.14:
+    resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+
   supports-color@7.2.0:
     resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
     engines: {node: '>=8'}
@@ -5666,6 +6164,10 @@ packages:
     resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
     engines: {node: '>=8.0'}
 
+  toad-cache@3.7.1:
+    resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==}
+    engines: {node: '>=20'}
+
   toml@3.0.0:
     resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
 
@@ -5675,10 +6177,16 @@ packages:
   tr46@0.0.3:
     resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
 
+  trim-lines@3.0.1:
+    resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
+
   triple-beam@1.4.1:
     resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
     engines: {node: '>= 14.0.0'}
 
+  trough@2.2.0:
+    resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+
   ts-api-utils@2.5.0:
     resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
     engines: {node: '>=18.12'}
@@ -5760,6 +6268,30 @@ packages:
     resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
     engines: {node: '>=18'}
 
+  unified@11.0.5:
+    resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
+
+  unist-util-is@6.0.1:
+    resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
+
+  unist-util-position@5.0.0:
+    resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==}
+
+  unist-util-stringify-position@4.0.0:
+    resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
+
+  unist-util-visit-parents@6.0.2:
+    resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
+
+  unist-util-visit@5.1.0:
+    resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
+
+  universal-github-app-jwt@2.2.2:
+    resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==}
+
+  universal-user-agent@7.0.3:
+    resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
+
   unixify@1.0.0:
     resolution: {integrity: sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==}
     engines: {node: '>=0.10.0'}
@@ -5915,6 +6447,15 @@ packages:
     resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==}
     engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
 
+  vfile-location@5.0.3:
+    resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
+
+  vfile-message@4.0.3:
+    resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
+
+  vfile@6.0.3:
+    resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+
   vite@8.0.13:
     resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==}
     engines: {node: ^20.19.0 || >=22.12.0}
@@ -5969,6 +6510,9 @@ packages:
   w3c-keyname@2.2.8:
     resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
 
+  web-namespaces@2.0.1:
+    resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
+
   web-streams-polyfill@3.3.3:
     resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
     engines: {node: '>= 8'}
@@ -6110,6 +6654,9 @@ packages:
   zod@4.4.3:
     resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
 
+  zwitch@2.0.4:
+    resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+
 snapshots:
 
   '@babel/code-frame@7.27.1':
@@ -7235,6 +7782,149 @@ snapshots:
       '@nodelib/fs.scandir': 2.1.5
       fastq: 1.20.1
 
+  '@octokit/app@16.1.2':
+    dependencies:
+      '@octokit/auth-app': 8.2.0
+      '@octokit/auth-unauthenticated': 7.0.3
+      '@octokit/core': 7.0.6
+      '@octokit/oauth-app': 8.0.3
+      '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6)
+      '@octokit/types': 16.0.0
+      '@octokit/webhooks': 14.2.0
+
+  '@octokit/auth-app@8.2.0':
+    dependencies:
+      '@octokit/auth-oauth-app': 9.0.3
+      '@octokit/auth-oauth-user': 6.0.2
+      '@octokit/request': 10.0.9
+      '@octokit/request-error': 7.1.0
+      '@octokit/types': 16.0.0
+      toad-cache: 3.7.1
+      universal-github-app-jwt: 2.2.2
+      universal-user-agent: 7.0.3
+
+  '@octokit/auth-oauth-app@9.0.3':
+    dependencies:
+      '@octokit/auth-oauth-device': 8.0.3
+      '@octokit/auth-oauth-user': 6.0.2
+      '@octokit/request': 10.0.9
+      '@octokit/types': 16.0.0
+      universal-user-agent: 7.0.3
+
+  '@octokit/auth-oauth-device@8.0.3':
+    dependencies:
+      '@octokit/oauth-methods': 6.0.2
+      '@octokit/request': 10.0.9
+      '@octokit/types': 16.0.0
+      universal-user-agent: 7.0.3
+
+  '@octokit/auth-oauth-user@6.0.2':
+    dependencies:
+      '@octokit/auth-oauth-device': 8.0.3
+      '@octokit/oauth-methods': 6.0.2
+      '@octokit/request': 10.0.9
+      '@octokit/types': 16.0.0
+      universal-user-agent: 7.0.3
+
+  '@octokit/auth-token@6.0.0': {}
+
+  '@octokit/auth-unauthenticated@7.0.3':
+    dependencies:
+      '@octokit/request-error': 7.1.0
+      '@octokit/types': 16.0.0
+
+  '@octokit/core@7.0.6':
+    dependencies:
+      '@octokit/auth-token': 6.0.0
+      '@octokit/graphql': 9.0.3
+      '@octokit/request': 10.0.9
+      '@octokit/request-error': 7.1.0
+      '@octokit/types': 16.0.0
+      before-after-hook: 4.0.0
+      universal-user-agent: 7.0.3
+
+  '@octokit/endpoint@11.0.3':
+    dependencies:
+      '@octokit/types': 16.0.0
+      universal-user-agent: 7.0.3
+
+  '@octokit/graphql@9.0.3':
+    dependencies:
+      '@octokit/request': 10.0.9
+      '@octokit/types': 16.0.0
+      universal-user-agent: 7.0.3
+
+  '@octokit/oauth-app@8.0.3':
+    dependencies:
+      '@octokit/auth-oauth-app': 9.0.3
+      '@octokit/auth-oauth-user': 6.0.2
+      '@octokit/auth-unauthenticated': 7.0.3
+      '@octokit/core': 7.0.6
+      '@octokit/oauth-authorization-url': 8.0.0
+      '@octokit/oauth-methods': 6.0.2
+      '@types/aws-lambda': 8.10.161
+      universal-user-agent: 7.0.3
+
+  '@octokit/oauth-authorization-url@8.0.0': {}
+
+  '@octokit/oauth-methods@6.0.2':
+    dependencies:
+      '@octokit/oauth-authorization-url': 8.0.0
+      '@octokit/request': 10.0.9
+      '@octokit/request-error': 7.1.0
+      '@octokit/types': 16.0.0
+
+  '@octokit/openapi-types@27.0.0': {}
+
+  '@octokit/openapi-webhooks-types@12.1.0': {}
+
+  '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)':
+    dependencies:
+      '@octokit/core': 7.0.6
+      '@octokit/types': 16.0.0
+
+  '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)':
+    dependencies:
+      '@octokit/core': 7.0.6
+
+  '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)':
+    dependencies:
+      '@octokit/core': 7.0.6
+      '@octokit/types': 16.0.0
+
+  '@octokit/request-error@7.1.0':
+    dependencies:
+      '@octokit/types': 16.0.0
+
+  '@octokit/request@10.0.9':
+    dependencies:
+      '@octokit/endpoint': 11.0.3
+      '@octokit/request-error': 7.1.0
+      '@octokit/types': 16.0.0
+      content-type: 2.0.0
+      fast-content-type-parse: 3.0.0
+      json-with-bigint: 3.5.8
+      universal-user-agent: 7.0.3
+
+  '@octokit/rest@22.0.1':
+    dependencies:
+      '@octokit/core': 7.0.6
+      '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6)
+      '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6)
+      '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6)
+
+  '@octokit/types@16.0.0':
+    dependencies:
+      '@octokit/openapi-types': 27.0.0
+
+  '@octokit/webhooks-methods@6.0.0': {}
+
+  '@octokit/webhooks@14.2.0':
+    dependencies:
+      '@octokit/openapi-webhooks-types': 12.1.0
+      '@octokit/request-error': 7.1.0
+      '@octokit/webhooks-methods': 6.0.0
+
   '@oozcitak/dom@2.0.2':
     dependencies:
       '@oozcitak/infra': 2.0.2
@@ -7432,6 +8122,26 @@ snapshots:
       '@parcel/watcher-win32-ia32': 2.5.6
       '@parcel/watcher-win32-x64': 2.5.6
 
+  '@pierre/diffs@1.2.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+    dependencies:
+      '@pierre/theme': 1.0.3
+      '@shikijs/transformers': 3.23.0
+      diff: 8.0.3
+      hast-util-to-html: 9.0.5
+      lru_map: 0.4.1
+      react: 19.2.6
+      react-dom: 19.2.6(react@19.2.6)
+      shiki: 3.23.0
+
+  '@pierre/theme@1.0.3': {}
+
+  '@pierre/trees@1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+    dependencies:
+      preact: 11.0.0-beta.0
+      preact-render-to-string: 6.6.5(preact@11.0.0-beta.0)
+      react: 19.2.6
+      react-dom: 19.2.6(react@19.2.6)
+
   '@pkgjs/parseargs@0.11.0':
     optional: true
 
@@ -7998,6 +8708,44 @@ snapshots:
       estree-walker: 2.0.2
       picomatch: 4.0.4
 
+  '@shikijs/core@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+      hast-util-to-html: 9.0.5
+
+  '@shikijs/engine-javascript@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+      oniguruma-to-es: 4.3.6
+
+  '@shikijs/engine-oniguruma@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+
+  '@shikijs/langs@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+
+  '@shikijs/themes@3.23.0':
+    dependencies:
+      '@shikijs/types': 3.23.0
+
+  '@shikijs/transformers@3.23.0':
+    dependencies:
+      '@shikijs/core': 3.23.0
+      '@shikijs/types': 3.23.0
+
+  '@shikijs/types@3.23.0':
+    dependencies:
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
+  '@shikijs/vscode-textmate@10.0.2': {}
+
   '@so-ric/colorspace@1.1.6':
     dependencies:
       color: 5.0.3
@@ -8611,10 +9359,24 @@ snapshots:
       tslib: 2.8.1
     optional: true
 
+  '@types/aws-lambda@8.10.161': {}
+
+  '@types/debug@4.1.13':
+    dependencies:
+      '@types/ms': 2.1.0
+
   '@types/esrecurse@4.3.1': {}
 
+  '@types/estree-jsx@1.0.5':
+    dependencies:
+      '@types/estree': 1.0.9
+
   '@types/estree@1.0.9': {}
 
+  '@types/hast@3.0.4':
+    dependencies:
+      '@types/unist': 3.0.3
+
   '@types/json-schema@7.0.15': {}
 
   '@types/linkify-it@5.0.0': {}
@@ -8624,8 +9386,14 @@ snapshots:
       '@types/linkify-it': 5.0.0
       '@types/mdurl': 2.0.0
 
+  '@types/mdast@4.0.4':
+    dependencies:
+      '@types/unist': 3.0.3
+
   '@types/mdurl@2.0.0': {}
 
+  '@types/ms@2.1.0': {}
+
   '@types/node@22.19.19':
     dependencies:
       undici-types: 6.21.0
@@ -8646,6 +9414,10 @@ snapshots:
 
   '@types/triple-beam@1.3.5': {}
 
+  '@types/unist@2.0.11': {}
+
+  '@types/unist@3.0.3': {}
+
   '@types/use-sync-external-store@0.0.6': {}
 
   '@types/yauzl@2.10.3':
@@ -8779,6 +9551,8 @@ snapshots:
       '@typescript-eslint/types': 8.59.4
       eslint-visitor-keys: 5.0.1
 
+  '@ungap/structured-clone@1.3.1': {}
+
   '@uploadthing/mime-types@0.3.6': {}
 
   '@uploadthing/react@7.3.3(react@19.2.6)(uploadthing@7.7.4(h3@1.15.11)(tailwindcss@4.3.0))':
@@ -9030,6 +9804,8 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  bail@2.0.2: {}
+
   balanced-match@1.0.2: {}
 
   balanced-match@4.0.4: {}
@@ -9070,6 +9846,8 @@ snapshots:
 
   baseline-browser-mapping@2.10.30: {}
 
+  before-after-hook@4.0.0: {}
+
   better-ajv-errors@1.2.0(ajv@8.20.0):
     dependencies:
       '@babel/code-frame': 7.29.0
@@ -9182,6 +9960,8 @@ snapshots:
 
   caniuse-lite@1.0.30001793: {}
 
+  ccount@2.0.1: {}
+
   chalk@4.1.2:
     dependencies:
       ansi-styles: 4.3.0
@@ -9189,6 +9969,14 @@ snapshots:
 
   chalk@5.6.2: {}
 
+  character-entities-html4@2.1.0: {}
+
+  character-entities-legacy@3.0.0: {}
+
+  character-entities@2.0.2: {}
+
+  character-reference-invalid@2.0.1: {}
+
   cheerio-select@2.1.0:
     dependencies:
       boolbase: 1.0.0
@@ -9291,6 +10079,8 @@ snapshots:
     dependencies:
       delayed-stream: 1.0.0
 
+  comma-separated-tokens@2.0.3: {}
+
   commander@10.0.1: {}
 
   commander@11.1.0: {}
@@ -9313,6 +10103,8 @@ snapshots:
 
   consola@3.4.2: {}
 
+  content-type@2.0.0: {}
+
   convert-source-map@2.0.0: {}
 
   cookie-es@1.2.3: {}
@@ -9415,6 +10207,10 @@ snapshots:
     dependencies:
       callsite: 1.0.0
 
+  decode-named-character-reference@1.3.0:
+    dependencies:
+      character-entities: 2.0.2
+
   dedent@1.7.2: {}
 
   deep-is@0.1.4: {}
@@ -9437,6 +10233,8 @@ snapshots:
 
   delayed-stream@1.0.0: {}
 
+  dequal@2.0.3: {}
+
   destr@2.0.5: {}
 
   detect-libc@2.1.2: {}
@@ -9501,6 +10299,12 @@ snapshots:
 
   dettle@1.0.5: {}
 
+  devlop@1.1.0:
+    dependencies:
+      dequal: 2.0.3
+
+  diff@8.0.3: {}
+
   diff@8.0.4: {}
 
   dom-serializer@2.0.0:
@@ -9859,6 +10663,8 @@ snapshots:
 
   estraverse@5.3.0: {}
 
+  estree-util-is-identifier-name@3.0.0: {}
+
   estree-walker@2.0.2: {}
 
   esutils@2.0.3: {}
@@ -9889,6 +10695,8 @@ snapshots:
 
   exsolve@1.0.8: {}
 
+  extend@3.0.2: {}
+
   extract-zip@2.0.1:
     dependencies:
       debug: 4.4.3
@@ -9903,6 +10711,8 @@ snapshots:
     dependencies:
       pure-rand: 6.1.0
 
+  fast-content-type-parse@3.0.0: {}
+
   fast-deep-equal@3.1.3: {}
 
   fast-fifo@1.3.2: {}
@@ -10155,6 +10965,99 @@ snapshots:
     dependencies:
       function-bind: 1.1.2
 
+  hast-util-from-parse5@8.0.3:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      devlop: 1.1.0
+      hastscript: 9.0.1
+      property-information: 7.1.0
+      vfile: 6.0.3
+      vfile-location: 5.0.3
+      web-namespaces: 2.0.1
+
+  hast-util-parse-selector@4.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+
+  hast-util-raw@9.1.0:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      '@ungap/structured-clone': 1.3.1
+      hast-util-from-parse5: 8.0.3
+      hast-util-to-parse5: 8.0.1
+      html-void-elements: 3.0.0
+      mdast-util-to-hast: 13.2.1
+      parse5: 7.3.0
+      unist-util-position: 5.0.0
+      unist-util-visit: 5.1.0
+      vfile: 6.0.3
+      web-namespaces: 2.0.1
+      zwitch: 2.0.4
+
+  hast-util-sanitize@5.0.2:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@ungap/structured-clone': 1.3.1
+      unist-util-position: 5.0.0
+
+  hast-util-to-html@9.0.5:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      ccount: 2.0.1
+      comma-separated-tokens: 2.0.3
+      hast-util-whitespace: 3.0.0
+      html-void-elements: 3.0.0
+      mdast-util-to-hast: 13.2.1
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+      stringify-entities: 4.0.4
+      zwitch: 2.0.4
+
+  hast-util-to-jsx-runtime@2.3.6:
+    dependencies:
+      '@types/estree': 1.0.9
+      '@types/hast': 3.0.4
+      '@types/unist': 3.0.3
+      comma-separated-tokens: 2.0.3
+      devlop: 1.1.0
+      estree-util-is-identifier-name: 3.0.0
+      hast-util-whitespace: 3.0.0
+      mdast-util-mdx-expression: 2.0.1
+      mdast-util-mdx-jsx: 3.2.0
+      mdast-util-mdxjs-esm: 2.0.1
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+      style-to-js: 1.1.21
+      unist-util-position: 5.0.0
+      vfile-message: 4.0.3
+    transitivePeerDependencies:
+      - supports-color
+
+  hast-util-to-parse5@8.0.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      comma-separated-tokens: 2.0.3
+      devlop: 1.1.0
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+      web-namespaces: 2.0.1
+      zwitch: 2.0.4
+
+  hast-util-whitespace@3.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+
+  hastscript@9.0.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      comma-separated-tokens: 2.0.3
+      hast-util-parse-selector: 4.0.0
+      property-information: 7.1.0
+      space-separated-tokens: 2.0.2
+
   hermes-estree@0.25.1: {}
 
   hermes-parser@0.25.1:
@@ -10165,6 +11068,10 @@ snapshots:
     dependencies:
       lru-cache: 10.4.3
 
+  html-url-attributes@3.0.1: {}
+
+  html-void-elements@3.0.0: {}
+
   htmlparser2@10.1.0:
     dependencies:
       domelementtype: 2.3.0
@@ -10219,6 +11126,8 @@ snapshots:
 
   inherits@2.0.4: {}
 
+  inline-style-parser@0.2.7: {}
+
   internal-slot@1.1.0:
     dependencies:
       es-errors: 1.3.0
@@ -10267,6 +11176,13 @@ snapshots:
 
   iron-webcrypto@1.2.1: {}
 
+  is-alphabetical@2.0.1: {}
+
+  is-alphanumerical@2.0.1:
+    dependencies:
+      is-alphabetical: 2.0.1
+      is-decimal: 2.0.1
+
   is-array-buffer@3.0.5:
     dependencies:
       call-bind: 1.0.9
@@ -10311,6 +11227,8 @@ snapshots:
       call-bound: 1.0.4
       has-tostringtag: 1.0.2
 
+  is-decimal@2.0.1: {}
+
   is-extglob@2.1.1: {}
 
   is-finalizationregistry@1.1.1:
@@ -10331,6 +11249,8 @@ snapshots:
     dependencies:
       is-extglob: 2.1.1
 
+  is-hexadecimal@2.0.1: {}
+
   is-map@2.0.3: {}
 
   is-negative-zero@2.0.3: {}
@@ -10443,6 +11363,8 @@ snapshots:
 
   json-stable-stringify-without-jsonify@1.0.1: {}
 
+  json-with-bigint@3.5.8: {}
+
   json5@2.2.3: {}
 
   jsonpointer@5.0.1: {}
@@ -10614,6 +11536,8 @@ snapshots:
       safe-stable-stringify: 2.5.0
       triple-beam: 1.4.1
 
+  longest-streak@3.1.0: {}
+
   lru-cache@10.4.3: {}
 
   lru-cache@11.4.0: {}
@@ -10622,6 +11546,8 @@ snapshots:
     dependencies:
       yallist: 3.1.1
 
+  lru_map@0.4.1: {}
+
   lucide-react@0.525.0(react@19.2.6):
     dependencies:
       react: 19.2.6
@@ -10643,8 +11569,163 @@ snapshots:
       punycode.js: 2.3.1
       uc.micro: 2.1.0
 
+  markdown-table@3.0.4: {}
+
   math-intrinsics@1.1.0: {}
 
+  mdast-util-find-and-replace@3.0.2:
+    dependencies:
+      '@types/mdast': 4.0.4
+      escape-string-regexp: 5.0.0
+      unist-util-is: 6.0.1
+      unist-util-visit-parents: 6.0.2
+
+  mdast-util-from-markdown@2.0.3:
+    dependencies:
+      '@types/mdast': 4.0.4
+      '@types/unist': 3.0.3
+      decode-named-character-reference: 1.3.0
+      devlop: 1.1.0
+      mdast-util-to-string: 4.0.0
+      micromark: 4.0.2
+      micromark-util-decode-numeric-character-reference: 2.0.2
+      micromark-util-decode-string: 2.0.1
+      micromark-util-normalize-identifier: 2.0.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+      unist-util-stringify-position: 4.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-gfm-autolink-literal@2.0.1:
+    dependencies:
+      '@types/mdast': 4.0.4
+      ccount: 2.0.1
+      devlop: 1.1.0
+      mdast-util-find-and-replace: 3.0.2
+      micromark-util-character: 2.1.1
+
+  mdast-util-gfm-footnote@2.1.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      devlop: 1.1.0
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-to-markdown: 2.1.2
+      micromark-util-normalize-identifier: 2.0.1
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-gfm-strikethrough@2.0.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-to-markdown: 2.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-gfm-table@2.0.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      devlop: 1.1.0
+      markdown-table: 3.0.4
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-to-markdown: 2.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-gfm-task-list-item@2.0.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      devlop: 1.1.0
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-to-markdown: 2.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-gfm@3.1.0:
+    dependencies:
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-gfm-autolink-literal: 2.0.1
+      mdast-util-gfm-footnote: 2.1.0
+      mdast-util-gfm-strikethrough: 2.0.0
+      mdast-util-gfm-table: 2.0.0
+      mdast-util-gfm-task-list-item: 2.0.0
+      mdast-util-to-markdown: 2.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-mdx-expression@2.0.1:
+    dependencies:
+      '@types/estree-jsx': 1.0.5
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      devlop: 1.1.0
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-to-markdown: 2.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-mdx-jsx@3.2.0:
+    dependencies:
+      '@types/estree-jsx': 1.0.5
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      '@types/unist': 3.0.3
+      ccount: 2.0.1
+      devlop: 1.1.0
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-to-markdown: 2.1.2
+      parse-entities: 4.0.2
+      stringify-entities: 4.0.4
+      unist-util-stringify-position: 4.0.0
+      vfile-message: 4.0.3
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-mdxjs-esm@2.0.1:
+    dependencies:
+      '@types/estree-jsx': 1.0.5
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      devlop: 1.1.0
+      mdast-util-from-markdown: 2.0.3
+      mdast-util-to-markdown: 2.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  mdast-util-phrasing@4.1.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      unist-util-is: 6.0.1
+
+  mdast-util-to-hast@13.2.1:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      '@ungap/structured-clone': 1.3.1
+      devlop: 1.1.0
+      micromark-util-sanitize-uri: 2.0.1
+      trim-lines: 3.0.1
+      unist-util-position: 5.0.0
+      unist-util-visit: 5.1.0
+      vfile: 6.0.3
+
+  mdast-util-to-markdown@2.1.2:
+    dependencies:
+      '@types/mdast': 4.0.4
+      '@types/unist': 3.0.3
+      longest-streak: 3.1.0
+      mdast-util-phrasing: 4.1.0
+      mdast-util-to-string: 4.0.0
+      micromark-util-classify-character: 2.0.1
+      micromark-util-decode-string: 2.0.1
+      unist-util-visit: 5.1.0
+      zwitch: 2.0.4
+
+  mdast-util-to-string@4.0.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+
   mdn-data@2.0.28: {}
 
   mdn-data@2.27.1: {}
@@ -10659,6 +11740,197 @@ snapshots:
 
   merge2@1.4.1: {}
 
+  micromark-core-commonmark@2.0.3:
+    dependencies:
+      decode-named-character-reference: 1.3.0
+      devlop: 1.1.0
+      micromark-factory-destination: 2.0.1
+      micromark-factory-label: 2.0.1
+      micromark-factory-space: 2.0.1
+      micromark-factory-title: 2.0.1
+      micromark-factory-whitespace: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-chunked: 2.0.1
+      micromark-util-classify-character: 2.0.1
+      micromark-util-html-tag-name: 2.0.1
+      micromark-util-normalize-identifier: 2.0.1
+      micromark-util-resolve-all: 2.0.1
+      micromark-util-subtokenize: 2.1.0
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-extension-gfm-autolink-literal@2.1.0:
+    dependencies:
+      micromark-util-character: 2.1.1
+      micromark-util-sanitize-uri: 2.0.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-extension-gfm-footnote@2.1.0:
+    dependencies:
+      devlop: 1.1.0
+      micromark-core-commonmark: 2.0.3
+      micromark-factory-space: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-normalize-identifier: 2.0.1
+      micromark-util-sanitize-uri: 2.0.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-extension-gfm-strikethrough@2.1.0:
+    dependencies:
+      devlop: 1.1.0
+      micromark-util-chunked: 2.0.1
+      micromark-util-classify-character: 2.0.1
+      micromark-util-resolve-all: 2.0.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-extension-gfm-table@2.1.1:
+    dependencies:
+      devlop: 1.1.0
+      micromark-factory-space: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-extension-gfm-tagfilter@2.0.0:
+    dependencies:
+      micromark-util-types: 2.0.2
+
+  micromark-extension-gfm-task-list-item@2.1.0:
+    dependencies:
+      devlop: 1.1.0
+      micromark-factory-space: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-extension-gfm@3.0.0:
+    dependencies:
+      micromark-extension-gfm-autolink-literal: 2.1.0
+      micromark-extension-gfm-footnote: 2.1.0
+      micromark-extension-gfm-strikethrough: 2.1.0
+      micromark-extension-gfm-table: 2.1.1
+      micromark-extension-gfm-tagfilter: 2.0.0
+      micromark-extension-gfm-task-list-item: 2.1.0
+      micromark-util-combine-extensions: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-factory-destination@2.0.1:
+    dependencies:
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-factory-label@2.0.1:
+    dependencies:
+      devlop: 1.1.0
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-factory-space@2.0.1:
+    dependencies:
+      micromark-util-character: 2.1.1
+      micromark-util-types: 2.0.2
+
+  micromark-factory-title@2.0.1:
+    dependencies:
+      micromark-factory-space: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-factory-whitespace@2.0.1:
+    dependencies:
+      micromark-factory-space: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-util-character@2.1.1:
+    dependencies:
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-util-chunked@2.0.1:
+    dependencies:
+      micromark-util-symbol: 2.0.1
+
+  micromark-util-classify-character@2.0.1:
+    dependencies:
+      micromark-util-character: 2.1.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-util-combine-extensions@2.0.1:
+    dependencies:
+      micromark-util-chunked: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-util-decode-numeric-character-reference@2.0.2:
+    dependencies:
+      micromark-util-symbol: 2.0.1
+
+  micromark-util-decode-string@2.0.1:
+    dependencies:
+      decode-named-character-reference: 1.3.0
+      micromark-util-character: 2.1.1
+      micromark-util-decode-numeric-character-reference: 2.0.2
+      micromark-util-symbol: 2.0.1
+
+  micromark-util-encode@2.0.1: {}
+
+  micromark-util-html-tag-name@2.0.1: {}
+
+  micromark-util-normalize-identifier@2.0.1:
+    dependencies:
+      micromark-util-symbol: 2.0.1
+
+  micromark-util-resolve-all@2.0.1:
+    dependencies:
+      micromark-util-types: 2.0.2
+
+  micromark-util-sanitize-uri@2.0.1:
+    dependencies:
+      micromark-util-character: 2.1.1
+      micromark-util-encode: 2.0.1
+      micromark-util-symbol: 2.0.1
+
+  micromark-util-subtokenize@2.1.0:
+    dependencies:
+      devlop: 1.1.0
+      micromark-util-chunked: 2.0.1
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+
+  micromark-util-symbol@2.0.1: {}
+
+  micromark-util-types@2.0.2: {}
+
+  micromark@4.0.2:
+    dependencies:
+      '@types/debug': 4.1.13
+      debug: 4.4.3
+      decode-named-character-reference: 1.3.0
+      devlop: 1.1.0
+      micromark-core-commonmark: 2.0.3
+      micromark-factory-space: 2.0.1
+      micromark-util-character: 2.1.1
+      micromark-util-chunked: 2.0.1
+      micromark-util-combine-extensions: 2.0.1
+      micromark-util-decode-numeric-character-reference: 2.0.2
+      micromark-util-encode: 2.0.1
+      micromark-util-normalize-identifier: 2.0.1
+      micromark-util-resolve-all: 2.0.1
+      micromark-util-sanitize-uri: 2.0.1
+      micromark-util-subtokenize: 2.1.0
+      micromark-util-symbol: 2.0.1
+      micromark-util-types: 2.0.2
+    transitivePeerDependencies:
+      - supports-color
+
   micromatch@4.0.8:
     dependencies:
       braces: 3.0.3
@@ -10860,6 +12132,14 @@ snapshots:
     dependencies:
       mimic-fn: 4.0.0
 
+  oniguruma-parser@0.12.2: {}
+
+  oniguruma-to-es@4.3.6:
+    dependencies:
+      oniguruma-parser: 0.12.2
+      regex: 6.1.0
+      regex-recursion: 6.0.2
+
   optionator@0.9.4:
     dependencies:
       deep-is: 0.1.4
@@ -10941,6 +12221,16 @@ snapshots:
 
   package-json-from-dist@1.0.1: {}
 
+  parse-entities@4.0.2:
+    dependencies:
+      '@types/unist': 2.0.11
+      character-entities-legacy: 3.0.0
+      character-reference-invalid: 2.0.1
+      decode-named-character-reference: 1.3.0
+      is-alphanumerical: 2.0.1
+      is-decimal: 2.0.1
+      is-hexadecimal: 2.0.1
+
   parse-gitignore@2.0.0: {}
 
   parse-imports@2.2.1:
@@ -11023,6 +12313,12 @@ snapshots:
 
   postgres@3.4.9: {}
 
+  preact-render-to-string@6.6.5(preact@11.0.0-beta.0):
+    dependencies:
+      preact: 11.0.0-beta.0
+
+  preact@11.0.0-beta.0: {}
+
   precinct@12.3.2:
     dependencies:
       '@dependents/detective-less': 5.0.3
@@ -11057,6 +12353,8 @@ snapshots:
 
   process@0.11.10: {}
 
+  property-information@7.1.0: {}
+
   prosemirror-changeset@2.4.1:
     dependencies:
       prosemirror-transform: 1.12.0
@@ -11196,6 +12494,24 @@ snapshots:
     dependencies:
       react: 19.2.6
 
+  react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.6):
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      '@types/react': 19.2.14
+      devlop: 1.1.0
+      hast-util-to-jsx-runtime: 2.3.6
+      html-url-attributes: 3.0.1
+      mdast-util-to-hast: 13.2.1
+      react: 19.2.6
+      remark-parse: 11.0.0
+      remark-rehype: 11.1.2
+      unified: 11.0.5
+      unist-util-visit: 5.1.0
+      vfile: 6.0.3
+    transitivePeerDependencies:
+      - supports-color
+
   react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.6):
     dependencies:
       react: 19.2.6
@@ -11288,6 +12604,16 @@ snapshots:
       get-proto: 1.0.1
       which-builtin-type: 1.2.1
 
+  regex-recursion@6.0.2:
+    dependencies:
+      regex-utilities: 2.3.0
+
+  regex-utilities@2.3.0: {}
+
+  regex@6.1.0:
+    dependencies:
+      regex-utilities: 2.3.0
+
   regexp.prototype.flags@1.5.4:
     dependencies:
       call-bind: 1.0.9
@@ -11297,6 +12623,51 @@ snapshots:
       gopd: 1.2.0
       set-function-name: 2.0.2
 
+  rehype-raw@7.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+      hast-util-raw: 9.1.0
+      vfile: 6.0.3
+
+  rehype-sanitize@6.0.0:
+    dependencies:
+      '@types/hast': 3.0.4
+      hast-util-sanitize: 5.0.2
+
+  remark-gfm@4.0.1:
+    dependencies:
+      '@types/mdast': 4.0.4
+      mdast-util-gfm: 3.1.0
+      micromark-extension-gfm: 3.0.0
+      remark-parse: 11.0.0
+      remark-stringify: 11.0.0
+      unified: 11.0.5
+    transitivePeerDependencies:
+      - supports-color
+
+  remark-parse@11.0.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      mdast-util-from-markdown: 2.0.3
+      micromark-util-types: 2.0.2
+      unified: 11.0.5
+    transitivePeerDependencies:
+      - supports-color
+
+  remark-rehype@11.1.2:
+    dependencies:
+      '@types/hast': 3.0.4
+      '@types/mdast': 4.0.4
+      mdast-util-to-hast: 13.2.1
+      unified: 11.0.5
+      vfile: 6.0.3
+
+  remark-stringify@11.0.0:
+    dependencies:
+      '@types/mdast': 4.0.4
+      mdast-util-to-markdown: 2.1.2
+      unified: 11.0.5
+
   remove-trailing-separator@1.1.0: {}
 
   require-directory@2.1.1: {}
@@ -11464,6 +12835,17 @@ snapshots:
 
   shell-quote@1.8.3: {}
 
+  shiki@3.23.0:
+    dependencies:
+      '@shikijs/core': 3.23.0
+      '@shikijs/engine-javascript': 3.23.0
+      '@shikijs/engine-oniguruma': 3.23.0
+      '@shikijs/langs': 3.23.0
+      '@shikijs/themes': 3.23.0
+      '@shikijs/types': 3.23.0
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.4
+
   side-channel-list@1.0.1:
     dependencies:
       es-errors: 1.3.0
@@ -11518,6 +12900,8 @@ snapshots:
 
   source-map@0.7.6: {}
 
+  space-separated-tokens@2.0.2: {}
+
   spdx-correct@3.2.0:
     dependencies:
       spdx-expression-parse: 3.0.1
@@ -11597,6 +12981,11 @@ snapshots:
     dependencies:
       safe-buffer: 5.2.1
 
+  stringify-entities@4.0.4:
+    dependencies:
+      character-entities-html4: 2.1.0
+      character-entities-legacy: 3.0.0
+
   strip-ansi@6.0.1:
     dependencies:
       ansi-regex: 5.0.1
@@ -11613,6 +13002,14 @@ snapshots:
 
   stubborn-utils@1.0.2: {}
 
+  style-to-js@1.1.21:
+    dependencies:
+      style-to-object: 1.0.14
+
+  style-to-object@1.0.14:
+    dependencies:
+      inline-style-parser: 0.2.7
+
   supports-color@7.2.0:
     dependencies:
       has-flag: 4.0.0
@@ -11697,14 +13094,20 @@ snapshots:
     dependencies:
       is-number: 7.0.0
 
+  toad-cache@3.7.1: {}
+
   toml@3.0.0: {}
 
   tomlify-j0.4@3.0.0: {}
 
   tr46@0.0.3: {}
 
+  trim-lines@3.0.1: {}
+
   triple-beam@1.4.1: {}
 
+  trough@2.2.0: {}
+
   ts-api-utils@2.5.0(typescript@5.9.3):
     dependencies:
       typescript: 5.9.3
@@ -11792,6 +13195,43 @@ snapshots:
 
   unicorn-magic@0.1.0: {}
 
+  unified@11.0.5:
+    dependencies:
+      '@types/unist': 3.0.3
+      bail: 2.0.2
+      devlop: 1.1.0
+      extend: 3.0.2
+      is-plain-obj: 4.1.0
+      trough: 2.2.0
+      vfile: 6.0.3
+
+  unist-util-is@6.0.1:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-position@5.0.0:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-stringify-position@4.0.0:
+    dependencies:
+      '@types/unist': 3.0.3
+
+  unist-util-visit-parents@6.0.2:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-is: 6.0.1
+
+  unist-util-visit@5.1.0:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-is: 6.0.1
+      unist-util-visit-parents: 6.0.2
+
+  universal-github-app-jwt@2.2.2: {}
+
+  universal-user-agent@7.0.3: {}
+
   unixify@1.0.0:
     dependencies:
       normalize-path: 2.1.1
@@ -11881,6 +13321,21 @@ snapshots:
 
   validate-npm-package-name@5.0.1: {}
 
+  vfile-location@5.0.3:
+    dependencies:
+      '@types/unist': 3.0.3
+      vfile: 6.0.3
+
+  vfile-message@4.0.3:
+    dependencies:
+      '@types/unist': 3.0.3
+      unist-util-stringify-position: 4.0.0
+
+  vfile@6.0.3:
+    dependencies:
+      '@types/unist': 3.0.3
+      vfile-message: 4.0.3
+
   vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.1)(yaml@2.9.0):
     dependencies:
       lightningcss: 1.32.0
@@ -11902,6 +13357,8 @@ snapshots:
 
   w3c-keyname@2.2.8: {}
 
+  web-namespaces@2.0.1: {}
+
   web-streams-polyfill@3.3.3: {}
 
   webidl-conversions@3.0.1: {}
@@ -12058,3 +13515,5 @@ snapshots:
   zod@3.25.76: {}
 
   zod@4.4.3: {}
+
+  zwitch@2.0.4: {}
diff --git a/scripts/migrate-clerk-images.sql b/scripts/migrate-clerk-images.sql
deleted file mode 100644
index 0bdd03b..0000000
--- a/scripts/migrate-clerk-images.sql
+++ /dev/null
@@ -1,22 +0,0 @@
--- Restore profile images from Clerk (one-time).
--- Replace the two URL variables below, then run.
-
-DO $$
-DECLARE
-  clerk_user_image_url text := 'https://REPLACE_WITH_CLERK_USER_IMAGE_URL';
-  clerk_org_logo_url text := 'https://REPLACE_WITH_CLERK_ORG_IMAGE_URL';
-BEGIN
-  IF clerk_user_image_url LIKE '%REPLACE_WITH%'
-     OR clerk_org_logo_url LIKE '%REPLACE_WITH%' THEN
-    RAISE EXCEPTION
-      'Replace clerk_user_image_url and clerk_org_logo_url with real Clerk image URLs before running this migration.';
-  END IF;
-
-  UPDATE "user"
-  SET image = clerk_user_image_url
-  WHERE id = 'F2LaBzrU26nMG3dVl8uBsBbNq69FeaT2';
-
-  UPDATE organization
-  SET logo = clerk_org_logo_url
-  WHERE id = 'YVWL19ooaw0qTkFISrp6ng3d0I648Ykn';
-END $$;
diff --git a/scripts/switch-to-local-db.sh b/scripts/switch-to-local-db.sh
new file mode 100755
index 0000000..2546e21
--- /dev/null
+++ b/scripts/switch-to-local-db.sh
@@ -0,0 +1,158 @@
+#!/usr/bin/env bash
+# Dump the current (remote) database, switch .env to local Postgres, start Docker, and import.
+#
+# Usage: ./scripts/switch-to-local-db.sh
+# Requires: docker, and either local pg_dump/psql or the postgres Docker image.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT_DIR"
+
+DB_CONTAINER_NAME="project-y-postgres"
+DUMP_DIR="$ROOT_DIR/dumps"
+TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
+DUMP_FILE="$DUMP_DIR/project-y-$TIMESTAMP.sql"
+
+if [[ ! -f .env ]]; then
+  echo "Missing .env in $ROOT_DIR"
+  exit 1
+fi
+
+strip_quotes() {
+  local value="$1"
+  value="${value#\"}"
+  value="${value%\"}"
+  value="${value#\'}"
+  value="${value%\'}"
+  printf '%s' "$value"
+}
+
+read_env_url() {
+  local line="$1"
+  line="${line#NETLIFY_DATABASE_URL=}"
+  strip_quotes "$line"
+}
+
+read_active_database_url() {
+  local line
+  line="$(grep -E '^NETLIFY_DATABASE_URL=' .env | head -1 || true)"
+  if [[ -z "$line" ]]; then
+    return 1
+  fi
+  read_env_url "$line"
+}
+
+is_local_url() {
+  local url="$1"
+  [[ "$url" == *"@localhost:"* || "$url" == *"@127.0.0.1:"* ]]
+}
+
+extract_local_url_from_env() {
+  local line url
+  while IFS= read -r line; do
+    if [[ "$line" =~ ^#[[:space:]]*NETLIFY_DATABASE_URL= ]]; then
+      url="$(read_env_url "${line#\# }")"
+      if is_local_url "$url"; then
+        printf '%s' "$url"
+        return 0
+      fi
+    fi
+  done < .env
+  return 1
+}
+
+REMOTE_URL="$(read_active_database_url)"
+
+if is_local_url "$REMOTE_URL"; then
+  echo "NETLIFY_DATABASE_URL already points at a local database."
+  echo "Re-run after switching back to your remote URL, or import manually from $DUMP_DIR"
+  exit 1
+fi
+
+if LOCAL_URL="$(extract_local_url_from_env)"; then
+  echo "Using local URL from commented line in .env"
+else
+  echo "No commented local NETLIFY_DATABASE_URL found in .env."
+  echo "Add a line like:"
+  echo '  # NETLIFY_DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@localhost:5432/project-y"'
+  exit 1
+fi
+
+mkdir -p "$DUMP_DIR"
+
+echo "Dumping remote database to $DUMP_FILE ..."
+if command -v pg_dump >/dev/null 2>&1; then
+  pg_dump "$REMOTE_URL" \
+    --no-owner \
+    --no-acl \
+    --clean \
+    --if-exists \
+    -F p \
+    -f "$DUMP_FILE"
+else
+  docker run --rm \
+    -e "REMOTE_URL=$REMOTE_URL" \
+    docker.io/postgres \
+    sh -c 'pg_dump "$REMOTE_URL" --no-owner --no-acl --clean --if-exists -F p' \
+    >"$DUMP_FILE"
+fi
+
+echo "Updating .env to use local database ..."
+tmp_env="$(mktemp)"
+remote_written=false
+while IFS= read -r line || [[ -n "$line" ]]; do
+  if [[ "$line" =~ ^NETLIFY_DATABASE_URL= ]]; then
+    if [[ "$remote_written" == false ]]; then
+      printf '# NETLIFY_DATABASE_URL="%s"\n' "$REMOTE_URL" >>"$tmp_env"
+      printf 'NETLIFY_DATABASE_URL="%s"\n' "$LOCAL_URL" >>"$tmp_env"
+      remote_written=true
+    fi
+    continue
+  fi
+  if [[ "$line" =~ ^#[[:space:]]*NETLIFY_DATABASE_URL= ]]; then
+    continue
+  fi
+  printf '%s\n' "$line" >>"$tmp_env"
+done < .env
+
+if [[ "$remote_written" == false ]]; then
+  rm -f "$tmp_env"
+  echo "Could not find NETLIFY_DATABASE_URL in .env"
+  exit 1
+fi
+
+mv "$tmp_env" .env
+
+echo "Starting local database container ..."
+bash "$ROOT_DIR/start-database.sh"
+
+echo "Waiting for Postgres to accept connections ..."
+ready=false
+for _ in $(seq 1 60); do
+  if docker exec "$DB_CONTAINER_NAME" pg_isready -U postgres -d project-y >/dev/null 2>&1; then
+    ready=true
+    break
+  fi
+  sleep 1
+done
+
+if [[ "$ready" != true ]]; then
+  echo "Postgres did not become ready in time."
+  exit 1
+fi
+
+echo "Importing dump into local database ..."
+if command -v psql >/dev/null 2>&1; then
+  psql "$LOCAL_URL" -v ON_ERROR_STOP=1 -f "$DUMP_FILE"
+else
+  docker exec -i "$DB_CONTAINER_NAME" \
+    psql -U postgres -d project-y -v ON_ERROR_STOP=1 \
+    <"$DUMP_FILE"
+fi
+
+echo ""
+echo "Done."
+echo "  Dump:  $DUMP_FILE"
+echo "  Local: $LOCAL_URL"
+echo "Remote URL is preserved as a comment in .env."
diff --git a/src/components/OpenTask.tsx b/src/components/OpenTask.tsx
index 7dac849..f2c8873 100644
--- a/src/components/OpenTask.tsx
+++ b/src/components/OpenTask.tsx
@@ -40,8 +40,10 @@ import {
   Calendar,
   CircleDashed,
   ClockFading,
+  Code2,
   Ellipsis,
   Flag,
+  LayoutList,
   Tags,
   Trash2,
   Users,
@@ -66,6 +68,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
 import { SubTasks } from "./SubTasks";
 import { Badge } from "./ui/badge";
 import { SprintSelect } from "./SprintSelect";
+import { TaskDevelopmentSection } from "~/components/git/TaskDevelopmentSection";
+import { TaskPullRequestReviewFeed } from "~/components/git/TaskPullRequestReviewFeed";
+import { TaskGitReviewNavProvider } from "~/lib/git/task-git-review-nav";
 
 export function OpenTask({
   task,
@@ -102,7 +107,6 @@ export function OpenTask({
   );
   const currentStatus = statuses.find((status) => status.id === task?.statusId);
   const [isDeleting, setIsDeleting] = useState(false);
-
   const handleUpdateTitle = useCallback(
     async (content: string) => {
       if (!task?.id || !task.projectId) return;
@@ -158,6 +162,9 @@ export function OpenTask({
 
   const owner = useCurrentOwningIdentity();
   const [preventClose, setPreventClose] = useState(false);
+  const [dialogView, setDialogView] = useState<"overview" | "development">(
+    "overview"
+  );
   const [taskTab, setTaskTab] = useState<"attachments" | "subtasks">(
     "attachments"
   );
@@ -176,6 +183,7 @@ export function OpenTask({
 
   useEffect(() => {
     if (!task?.id) return;
+    setDialogView("overview");
     setTaskTab(task.subTasks.length > 0 ? "subtasks" : "attachments");
     clearFileDragOver();
     // Intentionally only when switching tasks — not when subtask count changes.
@@ -235,7 +243,7 @@ export function OpenTask({
         ref={dialogContentRef}
         size="large"
         isLoading={isDeleting}
-        className="gap-0 rounded-md p-0"
+        className="flex max-h-full flex-col gap-0 overflow-hidden rounded-md p-0"
         onDragEnter={handleTaskPanelDragEnter}
         onDragLeave={handleTaskPanelDragLeave}
         onDragOver={handleTaskPanelDragOver}
@@ -248,7 +256,7 @@ export function OpenTask({
         }}
       >
         <DialogDescription className="sr-only">{task?.name}</DialogDescription>
-        <DialogHeader className="border-b border-border/60 px-6 py-3">
+        <DialogHeader className="shrink-0 border-b border-border/60 px-4 py-3">
           <div className="flex justify-between items-center pr-6">
             <Breadcrumb>
               <BreadcrumbList>
@@ -309,181 +317,233 @@ export function OpenTask({
           </div>
         </DialogHeader>
         {task && currentStatus ? (
-          <div className="grid grid-cols-12 content-stretch grow">
-            <div className="col-span-8 flex flex-col gap-5.5 pt-4 pb-6 pl-6 pr-5.5 overflow-auto h-[calc(100vh-6rem-51px)]">
-              <EditableDialogTitle
-                initialContent={task?.name || ""}
-                onBlur={handleUpdateTitle}
-                onDebouncedUpdate={handleUpdateTitle}
-              />
-              <DetailList>
-                <DetailListItem
-                  label="Labels"
-                  icon={Tags}
-                  align="start"
-                  className="col-span-2"
-                >
-                  <Labels task={task} labels={labels} />
-                </DetailListItem>
-                <DetailListItem label="Status" icon={CircleDashed}>
-                  <StatusSwitch
-                    status={currentStatus}
-                    statuses={statuses}
-                    onValueChange={(statusId) => {
-                      handleUpdateTask({
-                        id: task.id,
-                        statusId,
-                        projectId: task.projectId,
-                      });
-                    }}
-                  />
-                </DetailListItem>
-                <DetailListItem label="Priority" icon={Flag}>
-                  <PrioritySwitch
-                    priority={task?.priority}
-                    onValueChange={(priority) => {
-                      handleUpdateTask({
-                        id: task.id,
-                        priority,
-                        projectId: task.projectId,
-                      });
-                    }}
-                  />
-                </DetailListItem>
-                <DetailListItem
-                  label="Due"
-                  icon={Calendar}
-                  statusColor={
-                    task.deadline && task.deadline < new Date()
-                      ? "text-red-500"
-                      : undefined
-                  }
+          <TaskGitReviewNavProvider
+            key={task.id}
+            onOpenDevelopment={() => setDialogView("development")}
+          >
+          <div className="grid min-h-0 flex-1 grid-cols-12">
+            <div className="col-span-8 flex min-h-0 flex-col">
+              <Tabs
+                value={dialogView}
+                onValueChange={(value) =>
+                  setDialogView(value as "overview" | "development")
+                }
+                className="flex min-h-0 flex-1 flex-col gap-0"
+              >
+                <div className="shrink-0">
+                  <TabsList
+                    variant="dialog"
+                    className="border-b border-border/60"
+                  >
+                    <TabsTrigger variant="dialog" value="overview">
+                      <LayoutList className="size-3.5" />
+                      Overview
+                    </TabsTrigger>
+                    <TabsTrigger variant="dialog" value="development">
+                      <Code2 className="size-3.5" />
+                      Development
+                    </TabsTrigger>
+                  </TabsList>
+                </div>
+
+                <TabsContent
+                  value="overview"
+                  className="mt-0 flex min-h-0 flex-1 flex-col gap-5.5 overflow-auto px-4 pb-6 pt-4 data-[state=inactive]:hidden"
                 >
-                  <DateTimePicker
-                    date={task?.deadline || undefined}
-                    setDate={(date) => {
-                      if (!task) return;
+                  <div className="pb-2">
+                    <EditableDialogTitle
+                      initialContent={task.name}
+                      onBlur={handleUpdateTitle}
+                      onDebouncedUpdate={handleUpdateTitle}
+                    />
+                  </div>
+                  <div className="border-b border-border/60 pb-4">
+                    <DetailList>
+                      <DetailListItem
+                        label="Labels"
+                        icon={Tags}
+                        align="start"
+                        className="col-span-2"
+                      >
+                        <Labels task={task} labels={labels} />
+                      </DetailListItem>
+                      <DetailListItem label="Status" icon={CircleDashed}>
+                        <StatusSwitch
+                          status={currentStatus}
+                          statuses={statuses}
+                          onValueChange={(statusId) => {
+                            handleUpdateTask({
+                              id: task.id,
+                              statusId,
+                              projectId: task.projectId,
+                            });
+                          }}
+                        />
+                      </DetailListItem>
+                      <DetailListItem label="Priority" icon={Flag}>
+                        <PrioritySwitch
+                          priority={task.priority}
+                          onValueChange={(priority) => {
+                            handleUpdateTask({
+                              id: task.id,
+                              priority,
+                              projectId: task.projectId,
+                            });
+                          }}
+                        />
+                      </DetailListItem>
+                      <DetailListItem
+                        label="Due"
+                        icon={Calendar}
+                        statusColor={
+                          task.deadline && task.deadline < new Date()
+                            ? "text-red-500"
+                            : undefined
+                        }
+                      >
+                        <DateTimePicker
+                          date={task.deadline || undefined}
+                          setDate={(date) => {
+                            handleUpdateTask({
+                              id: task.id,
+                              deadline: date ?? null,
+                              projectId: task.projectId,
+                            });
+                          }}
+                        />
+                      </DetailListItem>
+                      <DetailListItem label="Assigned to" icon={Users}>
+                        <UserSelect
+                          variant="bare"
+                          size="sm"
+                          isAssigning={isAssigningUser}
+                          selectedUserIds={task.assignees.map(
+                            (assignee) => assignee.userId
+                          )}
+                          onValueChange={async (ids) => {
+                            setIsAssigningUser(true);
+                            const add = ids.filter(
+                              (id) =>
+                                !task.assignees.find(
+                                  (assignee) => assignee.userId === id
+                                )
+                            );
+                            const remove = task.assignees
+                              .map((a) => a.userId)
+                              .filter((assignee) => !ids.includes(assignee));
+
+                            await Promise.all([
+                              assignTask(task, add),
+                              unassignTask(task, remove),
+                            ]);
+                            setIsAssigningUser(false);
+                          }}
+                        />
+                      </DetailListItem>
+                      <DetailListItem label="Sprint" icon={ClockFading}>
+                        <SprintSelect
+                          isAssigning={isAssigningSprint}
+                          selectedSprintId={task.sprintId || undefined}
+                          onValueChange={async (sprintId) => {
+                            setIsAssigningSprint(true);
+                            try {
+                              await updateTask({
+                                id: task.id,
+                                sprintId: sprintId ?? null,
+                              });
+                            } finally {
+                              setIsAssigningSprint(false);
+                            }
+                          }}
+                        />
+                      </DetailListItem>
+                    </DetailList>
+                  </div>
+                  <RichtextEditor
+                    content={task.description || ""}
+                    onUpdate={(data) => {
                       handleUpdateTask({
                         id: task.id,
-                        deadline: date ?? null,
+                        description: data.text,
                         projectId: task.projectId,
                       });
                     }}
                   />
-                </DetailListItem>
-                <DetailListItem label="Assigned to" icon={Users}>
-                  <UserSelect
-                    variant="bare"
-                    size="sm"
-                    isAssigning={isAssigningUser}
-                    selectedUserIds={task.assignees.map(
-                      (assignee) => assignee.userId
-                    )}
-                    onValueChange={async (ids) => {
-                      if (!task) return;
-                      setIsAssigningUser(true);
-                      const add = ids.filter(
-                        (id) =>
-                          !task.assignees.find(
-                            (assignee) => assignee.userId === id
-                          )
-                      );
-                      const remove = task.assignees
-                        .map((a) => a.userId)
-                        .filter((assignee) => !ids.includes(assignee));
 
-                      await Promise.all([
-                        await assignTask(task, add),
-                        await unassignTask(task, remove),
-                      ]);
-                      setIsAssigningUser(false);
-                    }}
-                  />
-                </DetailListItem>
-                <DetailListItem label="Sprint" icon={ClockFading}>
-                  <SprintSelect
-                    isAssigning={isAssigningSprint}
-                    selectedSprintId={task.sprintId || undefined}
-                    onValueChange={async (sprintId) => {
-                      if (!task) return;
-                      setIsAssigningSprint(true);
-                      try {
-                        await updateTask({
-                          id: task.id,
-                          sprintId: sprintId ?? null,
-                        });
-                      } finally {
-                        setIsAssigningSprint(false);
-                      }
-                    }}
-                  />
-                </DetailListItem>
-              </DetailList>
-              <div className="border-t border-border/60" role="separator" />
-              <RichtextEditor
-                content={task?.description || ""}
-                onUpdate={(data) => {
-                  if (!task) return;
-                  handleUpdateTask({
-                    id: task.id,
-                    description: data.text,
-                    projectId: task.projectId,
-                  });
-                }}
-              />
+                  <Tabs
+                    value={taskTab}
+                    onValueChange={(value) =>
+                      setTaskTab(value as "attachments" | "subtasks")
+                    }
+                  >
+                    <TabsList>
+                      <TabsTrigger value="attachments">
+                        Attachments
+                        <Badge
+                          variant="secondary"
+                          className="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums"
+                        >
+                          {task.attachments.length}
+                        </Badge>
+                      </TabsTrigger>
+                      <TabsTrigger value="subtasks">
+                        Subtasks
+                        <Badge
+                          variant="secondary"
+                          className="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums"
+                        >
+                          {task.subTasks.length}
+                        </Badge>
+                      </TabsTrigger>
+                    </TabsList>
+                    <TabsContent value="attachments">
+                      <AttachmentArea
+                        attachments={task.attachments}
+                        onUpload={handleUpload}
+                        fileDragOver={isFileDragOver}
+                        onDismissFileDrag={clearFileDragOver}
+                      />
+                    </TabsContent>
+                    <TabsContent value="subtasks">
+                      <SubTasks task={task} setPreventClose={setPreventClose} />
+                    </TabsContent>
+                  </Tabs>
+                </TabsContent>
 
-              <Tabs
-                value={taskTab}
-                onValueChange={(value) =>
-                  setTaskTab(value as "attachments" | "subtasks")
-                }
-              >
-                <TabsList>
-                  <TabsTrigger value="attachments">
-                    Attachments
-                    <Badge
-                      variant={"secondary"}
-                      className="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums"
-                    >
-                      {task.attachments.length}
-                    </Badge>
-                  </TabsTrigger>
-                  <TabsTrigger value="subtasks">
-                    Subtasks
-                    <Badge
-                      variant={"secondary"}
-                      className="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums"
-                    >
-                      {task.subTasks.length}
-                    </Badge>
-                  </TabsTrigger>
-                </TabsList>
-                <TabsContent value="attachments">
-                  <AttachmentArea
-                    attachments={task.attachments}
-                    onUpload={handleUpload}
-                    fileDragOver={isFileDragOver}
-                    onDismissFileDrag={clearFileDragOver}
+                <TabsContent
+                  value="development"
+                  className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
+                >
+                  <TaskDevelopmentSection
+                    layout="panel"
+                    taskId={task.id}
+                    taskName={task.name}
+                    projectId={task.projectId}
                   />
                 </TabsContent>
-                <TabsContent value="subtasks">
-                  <SubTasks task={task} setPreventClose={setPreventClose} />
-                </TabsContent>
               </Tabs>
             </div>
-            <div className="col-span-4 flex h-[calc(100vh-6rem-51px)] flex-col gap-4 border-l border-border/60 bg-muted/30 py-4 px-6">
-              <TaskLabel>Conversation</TaskLabel>
-              <Comments
-                className="grow"
-                comments={comments.map((comment) => ({
-                  ...comment,
-                  author: comment.author || "Unknown",
-                  authorAvatar: comment.authorAvatar ?? "",
-                }))}
-              />
-              <CommentInput onSubmit={handleComment} />
-            </div>
+
+            <aside className="col-span-4 flex min-h-0 flex-col overflow-hidden border-l border-border/60 bg-muted/30">
+              {dialogView === "overview" ? (
+                <div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-4">
+                  <TaskLabel>Conversation</TaskLabel>
+                  <Comments
+                    className="min-h-0 grow"
+                    comments={comments.map((comment) => ({
+                      ...comment,
+                      author: comment.author || "Unknown",
+                      authorAvatar: comment.authorAvatar ?? "",
+                    }))}
+                  />
+                  <CommentInput onSubmit={handleComment} />
+                </div>
+              ) : (
+                <TaskPullRequestReviewFeed taskId={task.id} />
+              )}
+            </aside>
           </div>
+          </TaskGitReviewNavProvider>
         ) : null}
       </DialogContent>
     </Dialog>
diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx
index 405fe68..0658776 100644
--- a/src/components/TaskCard.tsx
+++ b/src/components/TaskCard.tsx
@@ -11,9 +11,13 @@ import {
   Flag,
   Folder,
   Paperclip,
+  GitBranch,
+  GitPullRequest,
   TextIcon,
   Users,
 } from "lucide-react";
+import { formatTaskKey } from "~/lib/git/task-key";
+import type { TaskGitSummary } from "~/db/queries/git";
 import { DateDisplay } from "./ui/date-display";
 import { Avatar, AvatarFallback, AvatarImage, AvatarList } from "./ui/avatar";
 import { useUsersQuery } from "~/db/queries/users";
@@ -40,12 +44,14 @@ export default function TaskCard({
   showSprint,
   showProject,
   taskLinkTo,
+  gitSummary,
 }: {
   task: TaskWithRelations;
   columnTaskIds: string[];
   showSprint?: boolean;
   showProject?: boolean;
   taskLinkTo?: "project" | "sprint" | "all";
+  gitSummary?: TaskGitSummary;
 }) {
   const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
     id: `task:${task.id}`,
@@ -107,6 +113,7 @@ export default function TaskCard({
             taskLinkTo={taskLinkTo}
             isSelected={isSelected}
             isHovered={isHovered}
+            gitSummary={gitSummary}
           />
         </div>
       </div>
@@ -187,6 +194,7 @@ export const TaskCardComponent = ({
   taskLinkTo,
   isSelected = false,
   isHovered = false,
+  gitSummary,
 }: {
   task: TaskWithRelations;
   columnTaskIds?: string[];
@@ -195,8 +203,13 @@ export const TaskCardComponent = ({
   taskLinkTo?: "project" | "sprint" | "all";
   isSelected?: boolean;
   isHovered?: boolean;
+  gitSummary?: TaskGitSummary;
 }) => {
   const isOverdue = task.deadline && task.deadline < new Date();
+  const taskKeyLabel =
+    task.project.taskKeyPrefix != null
+      ? formatTaskKey(task.project.taskKeyPrefix, task.number)
+      : null;
   const usersQuery = useUsersQuery();
   const { data: session } = authClient.useSession();
   const currentUserId = session?.user.id;
@@ -287,7 +300,22 @@ export const TaskCardComponent = ({
               ))}
             </div>
           ) : null}
-          <CardTitle className="line-clamp-4">{task.name}</CardTitle>
+          <div className="flex items-start justify-between gap-2">
+            <CardTitle className="line-clamp-4">{task.name}</CardTitle>
+            <div className="flex shrink-0 items-center gap-1">
+              {taskKeyLabel ? (
+                <Badge variant="outline" className="font-mono text-[10px]">
+                  {taskKeyLabel}
+                </Badge>
+              ) : null}
+              {gitSummary?.hasBranch ? (
+                <GitBranch className="text-muted-foreground size-3.5" />
+              ) : null}
+              {gitSummary?.hasOpenPr ? (
+                <GitPullRequest className="text-primary size-3.5" />
+              ) : null}
+            </div>
+          </div>
         </CardHeader>
         <CardContent className="p-3">
           {task.subTasks.length ? (
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index 4daedbd..e81167e 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -8,6 +8,7 @@ import {
   Flag,
   Folder,
   FolderPlus,
+  GitBranch,
   LayoutDashboard,
   LucideIcon,
   Tag,
@@ -53,6 +54,11 @@ const settingsNav = [
     url: "/settings/organization",
     icon: Building2,
   },
+  {
+    name: "Integrations",
+    url: "/settings/integrations",
+    icon: GitBranch,
+  },
   {
     name: "Statuses",
     url: "/statuses",
diff --git a/src/components/auth/google-sign-in-button.tsx b/src/components/auth/google-sign-in-button.tsx
index 99e8597..d08bea8 100644
--- a/src/components/auth/google-sign-in-button.tsx
+++ b/src/components/auth/google-sign-in-button.tsx
@@ -4,6 +4,7 @@ import { toast } from "sonner";
 
 type GoogleSignInButtonProps = {
   disabled?: boolean;
+  callbackURL?: string;
 };
 
 function GoogleIcon() {
@@ -29,12 +30,15 @@ function GoogleIcon() {
   );
 }
 
-export function GoogleSignInButton({ disabled }: GoogleSignInButtonProps) {
+export function GoogleSignInButton({
+  disabled,
+  callbackURL = "/dashboard",
+}: GoogleSignInButtonProps) {
   const handleGoogleSignIn = async () => {
     try {
       const { error } = await authClient.signIn.social({
         provider: "google",
-        callbackURL: "/dashboard",
+        callbackURL,
       });
 
       if (error) {
diff --git a/src/components/forms/ProjectForm.tsx b/src/components/forms/ProjectForm.tsx
index 60c740e..1e6d2c5 100644
--- a/src/components/forms/ProjectForm.tsx
+++ b/src/components/forms/ProjectForm.tsx
@@ -23,6 +23,7 @@ import {
 } from "~/components/ui/surface-styles";
 import { cn } from "~/lib/utils";
 import { toast } from "sonner";
+import { ProjectGitSection } from "~/components/git/ProjectGitSection";
 
 type FormLayout = "page" | "sheet";
 
@@ -352,6 +353,7 @@ export function ProjectFormEdit({
                   )}
                 />
               </FormSheetSection>
+              <ProjectGitSection project={project} />
             </>
           ) : (
             <>
diff --git a/src/components/git/CommitList.tsx b/src/components/git/CommitList.tsx
new file mode 100644
index 0000000..473c6a0
--- /dev/null
+++ b/src/components/git/CommitList.tsx
@@ -0,0 +1,113 @@
+import type { GitCommit } from "~/lib/git/types";
+import { formatRelativeTime } from "~/lib/format-relative-time";
+import { cn } from "~/lib/utils";
+
+/** Visible strip rows before the list scrolls (`h-10` per row → 12.5rem total). */
+const STRIP_VISIBLE_ROWS = 5;
+
+export function CommitList({
+  commits,
+  selectedSha,
+  onSelect,
+  layout = "sidebar",
+  className,
+}: {
+  commits: GitCommit[];
+  selectedSha?: string | null;
+  onSelect: (sha: string) => void;
+  /** `strip` = compact rows for a list above the diff (email-client style). */
+  layout?: "sidebar" | "strip";
+  className?: string;
+}) {
+  if (commits.length === 0) {
+    return (
+      <p className={cn("text-muted-foreground px-3 py-2 text-sm", className)}>
+        No commits yet.
+      </p>
+    );
+  }
+
+  if (layout === "strip") {
+    const scrollable = commits.length > STRIP_VISIBLE_ROWS;
+
+    return (
+      <div
+        className={cn(
+          scrollable && "max-h-[12.5rem] overflow-y-auto overscroll-contain",
+          className
+        )}
+      >
+        <ul className="divide-y divide-border/60" role="listbox">
+          {commits.map((commit, index) => {
+            const selected = selectedSha === commit.sha;
+            const selectCommit = () => onSelect(commit.sha);
+            const tabIndex =
+              selected || (selectedSha == null && index === 0) ? 0 : -1;
+            return (
+              <li
+                key={commit.sha}
+                role="option"
+                aria-selected={selected}
+                tabIndex={tabIndex}
+                onClick={selectCommit}
+                onKeyDown={(e) => {
+                  if (e.key === "Enter" || e.key === " ") {
+                    e.preventDefault();
+                    selectCommit();
+                  }
+                }}
+                className={cn(
+                  "flex h-10 w-full cursor-pointer items-center gap-3 px-3 text-left transition-colors outline-none",
+                  "hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring/50",
+                  selected &&
+                    "bg-primary/10 ring-1 ring-inset ring-primary/25"
+                )}
+              >
+                <span className="shrink-0 font-mono text-xs font-semibold tabular-nums">
+                  {commit.sha.slice(0, 7)}
+                </span>
+                <span className="min-w-0 flex-1 truncate text-sm leading-snug">
+                  {commit.message}
+                </span>
+                <span className="text-muted-foreground hidden shrink-0 text-xs sm:inline">
+                  {commit.authorLogin ?? "Unknown"}
+                </span>
+                <span className="text-muted-foreground shrink-0 text-xs tabular-nums">
+                  {formatRelativeTime(commit.committedAt)}
+                </span>
+              </li>
+            );
+          })}
+        </ul>
+      </div>
+    );
+  }
+
+  return (
+    <ul className={cn("divide-y divide-border/60", className)}>
+      {commits.map((commit) => (
+        <li key={commit.sha}>
+          <button
+            type="button"
+            onClick={() => onSelect(commit.sha)}
+            className={cn(
+              "hover:bg-muted/50 w-full cursor-pointer px-3 py-2.5 text-left transition-colors",
+              selectedSha === commit.sha && "bg-muted"
+            )}
+          >
+            <p className="truncate font-mono text-xs font-medium">
+              {commit.sha.slice(0, 7)}
+            </p>
+            <p className="mt-0.5 line-clamp-2 text-sm leading-snug">
+              {commit.message}
+            </p>
+            <p className="text-muted-foreground mt-1 text-xs">
+              {commit.authorLogin ? `${commit.authorLogin} · ` : null}
+              {formatRelativeTime(commit.committedAt)}
+            </p>
+          </button>
+        </li>
+      ))}
+    </ul>
+  );
+}
diff --git a/src/components/git/DiffFileTree.tsx b/src/components/git/DiffFileTree.tsx
new file mode 100644
index 0000000..f4ca3af
--- /dev/null
+++ b/src/components/git/DiffFileTree.tsx
@@ -0,0 +1,105 @@
+import { FileTree, useFileTree } from "@pierre/trees/react";
+import { themeToTreeStyles } from "@pierre/trees";
+import type { GitReviewComment } from "~/lib/git/types";
+import type { GitDiffFile } from "~/lib/git/types";
+import {
+  diffFilesToGitStatus,
+  diffFilesToTreePaths,
+} from "~/lib/git/diff-file-tree";
+import { useTheme } from "next-themes";
+import { useEffect, useMemo, useRef } from "react";
+import { cn } from "~/lib/utils";
+
+export function DiffFileTree({
+  files,
+  activePath,
+  onSelectPath,
+  commentCountByPath,
+  className,
+}: {
+  files: GitDiffFile[];
+  activePath: string | null;
+  onSelectPath: (path: string) => void;
+  commentCountByPath?: Map<string, number>;
+  className?: string;
+}) {
+  const { resolvedTheme } = useTheme();
+  const paths = useMemo(() => diffFilesToTreePaths(files), [files]);
+  const gitStatus = useMemo(() => diffFilesToGitStatus(files), [files]);
+  const pathSet = useMemo(() => new Set(paths), [paths]);
+  const onSelectPathRef = useRef(onSelectPath);
+  onSelectPathRef.current = onSelectPath;
+
+  const { model } = useFileTree({
+    paths,
+    gitStatus,
+    flattenEmptyDirectories: true,
+    initialExpansion: 1,
+    icons: "standard",
+    density: "compact",
+    initialSelectedPaths: activePath ? [activePath] : [],
+    onSelectionChange: (selected) => {
+      const filePath = [...selected]
+        .reverse()
+        .find((p) => pathSet.has(p));
+      if (filePath) onSelectPathRef.current(filePath);
+    },
+    renderRowDecoration: commentCountByPath
+      ? (ctx) => {
+          const count = commentCountByPath.get(ctx.item.path);
+          if (!count) return null;
+          return { text: String(count), title: `${count} comment${count === 1 ? "" : "s"}` };
+        }
+      : undefined,
+  });
+
+  useEffect(() => {
+    model.resetPaths(paths);
+    model.setGitStatus(gitStatus);
+  }, [model, paths, gitStatus]);
+
+  useEffect(() => {
+    if (!activePath || !pathSet.has(activePath)) return;
+    const selected = model.getSelectedPaths();
+    if (!selected.includes(activePath)) {
+      for (const p of selected) {
+        model.getItem(p)?.deselect();
+      }
+      model.getItem(activePath)?.select();
+    }
+    model.scrollToPath(activePath, { focus: false });
+  }, [model, activePath, pathSet]);
+
+  const hostStyle = useMemo(
+    () =>
+      themeToTreeStyles({
+        type: resolvedTheme === "dark" ? "dark" : "light",
+      }),
+    [resolvedTheme]
+  );
+
+  return (
+    <div
+      className={cn(
+        "relative min-h-0 min-w-0 flex-1 overflow-hidden",
+        className
+      )}
+    >
+      <FileTree
+        model={model}
+        className="absolute inset-0 block min-h-0 w-full"
+        style={hostStyle}
+      />
+    </div>
+  );
+}
+
+export function buildCommentCountByPath(
+  comments: GitReviewComment[]
+): Map<string, number> {
+  const counts = new Map<string, number>();
+  for (const c of comments) {
+    counts.set(c.path, (counts.get(c.path) ?? 0) + 1);
+  }
+  return counts;
+}
diff --git a/src/components/git/DiffInlineReviewComment.tsx b/src/components/git/DiffInlineReviewComment.tsx
new file mode 100644
index 0000000..d16c55d
--- /dev/null
+++ b/src/components/git/DiffInlineReviewComment.tsx
@@ -0,0 +1,169 @@
+import { GitHubMarkdownBody } from "~/components/git/GitHubMarkdownBody";
+import { ReviewThreadResolveButton } from "~/components/git/ReviewThreadResolveButton";
+import { ReviewThreadResolvedShell } from "~/components/git/ReviewThreadResolvedShell";
+import type { GitReviewComment } from "~/lib/git/types";
+import { cn } from "~/lib/utils";
+
+function looksLikeMarkdown(body: string): boolean {
+  return /(^|\n)\s{0,3}#{1,6}\s|```|<details[\s>]|<!--|\*\*|__|\[.+\]\(.+\)/.test(
+    body
+  );
+}
+
+function CommentBody({ body }: { body: string }) {
+  const useMarkdown = looksLikeMarkdown(body);
+  return (
+    <div
+      className={cn(
+        "text-sm leading-relaxed",
+        useMarkdown &&
+          "gh-markdown diff-inline-comment__markdown text-inherit [&_a]:text-blue-700 dark:[&_a]:text-blue-300 [&_code]:font-mono [&_code]:text-[0.8125rem] [&_pre]:font-mono [&_pre]:text-[0.8125rem] [&_pre]:border-zinc-300 dark:[&_pre]:border-zinc-600"
+      )}
+    >
+      {useMarkdown ? (
+        <GitHubMarkdownBody
+          body={body}
+          collapsible={false}
+          className="text-sm leading-relaxed"
+        />
+      ) : (
+        <p className="whitespace-pre-wrap">{body}</p>
+      )}
+    </div>
+  );
+}
+
+function CommentAvatar({
+  comment,
+}: {
+  comment: GitReviewComment;
+}) {
+  const profileUrl =
+    comment.authorHtmlUrl ?? `https://github.com/${comment.authorLogin}`;
+
+  return (
+    <a
+      href={profileUrl}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="shrink-0 self-start"
+    >
+      {comment.authorAvatarUrl ? (
+        <img
+          src={comment.authorAvatarUrl}
+          alt=""
+          className="size-6 rounded-full"
+        />
+      ) : (
+        <span className="flex size-6 items-center justify-center rounded-full bg-zinc-200 text-[11px] font-medium text-zinc-800 dark:bg-zinc-700 dark:text-zinc-100">
+          {comment.authorLogin.slice(0, 1).toUpperCase()}
+        </span>
+      )}
+    </a>
+  );
+}
+
+/**
+ * Inline PR comments render inside Pierre’s dark diff, which does not inherit
+ * app theme variables — use a fixed high-contrast card surface and sans-serif
+ * (the diff itself stays monospace; only code spans in markdown use mono).
+ */
+export function DiffInlineReviewComment({
+  comment,
+  pending = false,
+  showResolve = false,
+  resolveLoading = false,
+  onToggleResolved,
+  threadCommentCount = 1,
+  className,
+}: {
+  comment: GitReviewComment;
+  pending?: boolean;
+  showResolve?: boolean;
+  resolveLoading?: boolean;
+  onToggleResolved?: () => void;
+  threadCommentCount?: number;
+  className?: string;
+}) {
+  const body = comment.body?.trim() ?? "";
+  if (!body) return null;
+
+  const resolved = comment.threadIsResolved ?? false;
+  const profileUrl =
+    comment.authorHtmlUrl ?? `https://github.com/${comment.authorLogin}`;
+
+  const card = (
+    <div
+      className={cn(
+        "diff-inline-comment rounded-md border px-3 py-2.5 text-left font-sans antialiased shadow-md",
+        "border-zinc-300 bg-zinc-50 text-zinc-900",
+        "dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-50"
+      )}
+    >
+      <div className="flex gap-2.5">
+        <CommentAvatar comment={comment} />
+        <div className="min-w-0 flex-1 space-y-1.5">
+          <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs leading-snug">
+            <a
+              href={profileUrl}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="font-medium text-inherit hover:underline"
+            >
+              {comment.authorLogin}
+            </a>
+            {pending ? (
+              <span className="rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
+                Pending
+              </span>
+            ) : null}
+            <a
+              href={comment.url}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="text-zinc-500 hover:underline dark:text-zinc-400"
+            >
+              on GitHub
+            </a>
+          </div>
+          <CommentBody body={body} />
+          {showResolve &&
+          !resolved &&
+          comment.threadNodeId &&
+          onToggleResolved ? (
+            <ReviewThreadResolveButton
+              resolved={resolved}
+              loading={resolveLoading}
+              onToggle={onToggleResolved}
+              className="-ml-2 text-zinc-700 hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-50"
+            />
+          ) : null}
+        </div>
+      </div>
+    </div>
+  );
+
+  const wrapClass = cn(
+    "diff-inline-comment-wrap max-w-2xl mt-2.5 mb-1 font-sans",
+    className
+  );
+
+  if (!resolved) {
+    return <div className={wrapClass}>{card}</div>;
+  }
+
+  return (
+    <ReviewThreadResolvedShell
+      resolved
+      commentCount={threadCommentCount}
+      authorLogin={comment.authorLogin}
+      variant="diff"
+      canResolve={showResolve && Boolean(comment.threadNodeId)}
+      resolveLoading={resolveLoading}
+      onToggleResolved={onToggleResolved}
+      className={wrapClass}
+    >
+      {card}
+    </ReviewThreadResolvedShell>
+  );
+}
diff --git a/src/components/git/DiffLineLocationLink.tsx b/src/components/git/DiffLineLocationLink.tsx
new file mode 100644
index 0000000..90e792f
--- /dev/null
+++ b/src/components/git/DiffLineLocationLink.tsx
@@ -0,0 +1,59 @@
+import { ExternalLink } from "lucide-react";
+import { inferCommentSide } from "~/lib/git/review-comment-annotation";
+import { useTaskGitReviewNav } from "~/lib/git/task-git-review-nav";
+import type { GitReviewComment } from "~/lib/git/types";
+import { cn } from "~/lib/utils";
+
+export function DiffLineLocationLink({
+  comment,
+  className,
+  showGithub = true,
+}: {
+  comment: GitReviewComment;
+  className?: string;
+  showGithub?: boolean;
+}) {
+  const nav = useTaskGitReviewNav();
+  const line = comment.line ?? comment.originalLine;
+  const label =
+    line != null ? `${comment.path}:${line}` : comment.path;
+  const canJump = line != null && Boolean(nav);
+
+  const handleJump = () => {
+    if (line == null || !nav) return;
+    nav.focusLineInDiff({
+      path: comment.path,
+      line,
+      side: inferCommentSide(comment),
+    });
+  };
+
+  return (
+    <span className={cn("inline-flex flex-wrap items-center gap-x-1.5", className)}>
+      {canJump ? (
+        <button
+          type="button"
+          onClick={handleJump}
+          className="text-primary font-mono text-[10px] font-medium underline-offset-2 hover:underline"
+          title="Show on pull request diff"
+        >
+          {label}
+        </button>
+      ) : (
+        <span className="text-muted-foreground font-mono text-[10px]">{label}</span>
+      )}
+      {showGithub ? (
+        <a
+          href={comment.url}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-muted-foreground inline-flex items-center gap-0.5 text-[10px] hover:underline"
+          title="Open on GitHub"
+        >
+          GitHub
+          <ExternalLink className="size-2.5" />
+        </a>
+      ) : null}
+    </span>
+  );
+}
diff --git a/src/components/git/DiffReviewPanel.tsx b/src/components/git/DiffReviewPanel.tsx
new file mode 100644
index 0000000..ab3f012
--- /dev/null
+++ b/src/components/git/DiffReviewPanel.tsx
@@ -0,0 +1,404 @@
+import { useEffect, useRef, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { DiffViewer } from "~/components/git/DiffViewer";
+import { LineCommentPopover } from "~/components/git/review/LineCommentPopover";
+import { ReviewLoadingOverlay } from "~/components/git/review/ReviewLoadingOverlay";
+import { ReviewToolbar } from "~/components/git/review/ReviewToolbar";
+import { ReviewToolbarSkeleton } from "~/components/git/review/ReviewToolbarSkeleton";
+import type {
+  ReviewOperation,
+  SelectedReviewLine,
+} from "~/components/git/review/types";
+import {
+  useGitConnectionQuery,
+  useTaskPullRequestReviewCommentsQuery,
+} from "~/db/queries/git";
+import {
+  useCreateTaskPullRequestReviewCommentMutation,
+  useDiscardTaskPullRequestReviewMutation,
+  useSetTaskPullRequestReviewThreadResolvedMutation,
+  useStartTaskPullRequestReviewMutation,
+  useSubmitTaskPullRequestReviewMutation,
+} from "~/db/mutations/git";
+import type {
+  GitDiffFile,
+  GitPullRequestReview,
+  GitReviewComment,
+} from "~/lib/git/types";
+import {
+  formatClientError,
+  isPendingReviewConflictMessage,
+} from "~/lib/git/errors";
+import { toast } from "sonner";
+import { useTaskGitReviewNav } from "~/lib/git/task-git-review-nav";
+import { cn } from "~/lib/utils";
+
+function shouldRecoverPendingReview(message: string): boolean {
+  return (
+    isPendingReviewConflictMessage(message) ||
+    /could not resume your in-progress review/i.test(message)
+  );
+}
+
+export function DiffReviewPanel({
+  taskId,
+  pullRequestId,
+  scopeLabel,
+  files,
+  headSha,
+  readOnly = false,
+  compactToolbar = false,
+  className,
+}: {
+  taskId: string;
+  pullRequestId: string;
+  scopeLabel: string;
+  files: GitDiffFile[];
+  headSha: string;
+  readOnly?: boolean;
+  compactToolbar?: boolean;
+  className?: string;
+}) {
+  const queryClient = useQueryClient();
+  const { data: connectionData, isLoading: connectionLoading } =
+    useGitConnectionQuery();
+  const {
+    data: reviewData,
+    refetch,
+    isLoading: reviewLoading,
+    isFetching: reviewFetching,
+  } = useTaskPullRequestReviewCommentsQuery(taskId, pullRequestId);
+  const activeHeadSha = reviewData?.headSha ?? headSha;
+  const startReview = useStartTaskPullRequestReviewMutation();
+  const createComment = useCreateTaskPullRequestReviewCommentMutation();
+  const submitReview = useSubmitTaskPullRequestReviewMutation();
+  const discardReview = useDiscardTaskPullRequestReviewMutation();
+  const setThreadResolved =
+    useSetTaskPullRequestReviewThreadResolvedMutation();
+
+  const [selectedLine, setSelectedLine] = useState<SelectedReviewLine | null>(
+    null
+  );
+  const [draft, setDraft] = useState("");
+  const [reviewBody, setReviewBody] = useState("");
+  const [operation, setOperation] = useState<ReviewOperation>(null);
+  const [resolvingThreadNodeId, setResolvingThreadNodeId] = useState<
+    string | null
+  >(null);
+  const [submittingEvent, setSubmittingEvent] = useState<
+    "COMMENT" | "APPROVE" | "REQUEST_CHANGES" | null
+  >(null);
+  const [finishReviewOpen, setFinishReviewOpen] = useState(false);
+  const [sessionPending, setSessionPending] =
+    useState<GitPullRequestReview | null>(null);
+  const startReviewInFlight = useRef<Promise<GitPullRequestReview> | null>(
+    null
+  );
+  const diffBoundsRef = useRef<HTMLDivElement>(null);
+  const gitReviewNav = useTaskGitReviewNav();
+
+  const submittedComments: GitReviewComment[] = reviewData?.comments ?? [];
+  const pendingComments: GitReviewComment[] = reviewData?.pendingComments ?? [];
+  const pendingReview = reviewData?.pendingReview ?? null;
+  const activePendingReview = pendingReview ?? sessionPending;
+  const hasUserLink = Boolean(connectionData?.userLink);
+  const canInteract = !readOnly && hasUserLink;
+  const reviewInProgress = Boolean(activePendingReview);
+
+  useEffect(() => {
+    if (pendingReview) setSessionPending(null);
+  }, [pendingReview]);
+
+  useEffect(() => {
+    const focus = gitReviewNav?.lineFocus;
+    if (!focus) return;
+    setSelectedLine({
+      path: focus.path,
+      line: focus.line,
+      side: focus.side,
+      anchorRect: null,
+      lineElement: null,
+      opensPopover: false,
+    });
+    const timer = window.setTimeout(() => gitReviewNav?.clearLineFocus(), 800);
+    return () => window.clearTimeout(timer);
+  }, [gitReviewNav?.lineFocus, gitReviewNav]);
+
+  const diffComments: GitReviewComment[] = [
+    ...submittedComments,
+    ...pendingComments,
+  ];
+
+  const interactionDisabled =
+    operation === "start" ||
+    operation === "submit" ||
+    operation === "addComment" ||
+    operation === "discard";
+
+  const overlayMessage =
+    operation === "submit"
+      ? "Submitting review on GitHub…"
+      : operation === "start"
+        ? "Starting review on GitHub…"
+        : null;
+
+  const applyPendingReviewToCache = (pending: GitPullRequestReview) => {
+    queryClient.setQueryData(
+      ["git", "pr-comments", taskId, pullRequestId],
+      (prev: typeof reviewData | undefined) =>
+        prev ? { ...prev, pendingReview: pending } : prev
+    );
+  };
+
+  const adoptPendingReview = (pending: GitPullRequestReview) => {
+    setSessionPending(pending);
+    applyPendingReviewToCache(pending);
+    return pending;
+  };
+
+  const recoverInProgressReview = async (): Promise<GitPullRequestReview | null> => {
+    const { data } = await refetch();
+    if (data?.pendingReview) {
+      return adoptPendingReview(data.pendingReview);
+    }
+    return null;
+  };
+
+  const ensureReviewStarted = async (): Promise<GitPullRequestReview> => {
+    if (activePendingReview) return activePendingReview;
+    if (startReviewInFlight.current) {
+      return startReviewInFlight.current;
+    }
+
+    const promise = (async () => {
+      const pending = await startReview({
+        taskId,
+        pullRequestId,
+        commitId: activeHeadSha,
+      });
+      return adoptPendingReview(pending);
+    })();
+
+    startReviewInFlight.current = promise;
+    try {
+      return await promise;
+    } finally {
+      if (startReviewInFlight.current === promise) {
+        startReviewInFlight.current = null;
+      }
+    }
+  };
+
+  const handleLineClick = (info: SelectedReviewLine) => {
+    if (!canInteract || interactionDisabled) return;
+    if (!reviewInProgress) {
+      toast.error("Click Start review before commenting on a line.");
+      return;
+    }
+    setSelectedLine(info);
+  };
+
+  const handleStartReview = async () => {
+    setOperation("start");
+    try {
+      const hadPending = Boolean(activePendingReview);
+      await ensureReviewStarted();
+      void refetch();
+      toast.success(
+        hadPending
+          ? "Continuing your review"
+          : "Review started — click a line to add comments"
+      );
+    } catch (e) {
+      const message = formatClientError(e, "Failed to start review");
+      if (shouldRecoverPendingReview(message)) {
+        const pending = await recoverInProgressReview();
+        if (pending) {
+          toast.success("Continuing your in-progress review");
+          return;
+        }
+      }
+      toast.error(message);
+    } finally {
+      setOperation(null);
+    }
+  };
+
+  const handleAddComment = async () => {
+    if (!selectedLine || !draft.trim()) return;
+    if (!activePendingReview) {
+      toast.error("Start a review before adding inline comments.");
+      return;
+    }
+    setOperation("addComment");
+    try {
+      await createComment({
+        taskId,
+        pullRequestId,
+        reviewId: activePendingReview.id,
+        body: draft.trim(),
+        commitId: activeHeadSha,
+        path: selectedLine.path,
+        line: selectedLine.line,
+        side: selectedLine.side,
+      });
+      setDraft("");
+      setSelectedLine(null);
+      toast.success("Comment added to your review");
+    } catch (e) {
+      toast.error(formatClientError(e, "Failed to add comment"));
+    } finally {
+      setOperation(null);
+    }
+  };
+
+  const handleFinishReview = async (
+    event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES"
+  ) => {
+    setSubmittingEvent(event);
+    setOperation("submit");
+    try {
+      await submitReview({
+        taskId,
+        pullRequestId,
+        commitId: activeHeadSha,
+        event,
+        body: reviewBody.trim() || undefined,
+      });
+      setSessionPending(null);
+      setReviewBody("");
+      setSelectedLine(null);
+      setFinishReviewOpen(false);
+      await refetch();
+      toast.success(
+        event === "APPROVE"
+          ? "Review submitted — approved"
+          : event === "REQUEST_CHANGES"
+            ? "Review submitted — changes requested"
+            : "Review submitted"
+      );
+    } catch (e) {
+      toast.error(formatClientError(e, "Failed to submit review"));
+    } finally {
+      setOperation(null);
+      setSubmittingEvent(null);
+    }
+  };
+
+  const handleDiscardReview = async () => {
+    setOperation("discard");
+    try {
+      await discardReview({ taskId, pullRequestId });
+      setSessionPending(null);
+      setDraft("");
+      setReviewBody("");
+      setSelectedLine(null);
+      setFinishReviewOpen(false);
+      toast.success("Review discarded");
+    } catch (e) {
+      toast.error(formatClientError(e, "Failed to discard review"));
+    } finally {
+      setOperation(null);
+    }
+  };
+
+  const toolbarLoading = connectionLoading || reviewLoading;
+
+  const handleToggleThreadResolved = async (
+    threadNodeId: string,
+    resolved: boolean
+  ) => {
+    setResolvingThreadNodeId(threadNodeId);
+    try {
+      await setThreadResolved({
+        taskId,
+        pullRequestId,
+        threadNodeId,
+        resolved,
+      });
+      toast.success(
+        resolved ? "Conversation resolved" : "Conversation unresolved"
+      );
+      await refetch();
+    } catch (e) {
+      toast.error(
+        formatClientError(e, "Failed to update conversation on GitHub")
+      );
+    } finally {
+      setResolvingThreadNodeId(null);
+    }
+  };
+
+  return (
+    <div className={cn("flex min-h-0 flex-1 flex-col", className)}>
+      {toolbarLoading ? (
+        <ReviewToolbarSkeleton />
+      ) : (
+        <ReviewToolbar
+          scopeLabel={scopeLabel}
+          compact={compactToolbar}
+          canInteract={canInteract}
+          readOnly={readOnly}
+          hasUserLink={hasUserLink}
+          reviewInProgress={reviewInProgress}
+          pendingCount={pendingComments.length}
+          reviewFetching={reviewFetching && !reviewLoading}
+          operation={operation}
+          submittingEvent={submittingEvent}
+          reviewBody={reviewBody}
+          onReviewBodyChange={setReviewBody}
+          onStartReview={handleStartReview}
+          onDiscardReview={handleDiscardReview}
+          finishReviewOpen={finishReviewOpen}
+          onFinishReviewOpenChange={setFinishReviewOpen}
+          onSubmitReview={handleFinishReview}
+        />
+      )}
+
+      <div
+        ref={diffBoundsRef}
+        className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
+      >
+        <DiffViewer
+          files={files}
+          comments={diffComments}
+          pendingCommentIds={new Set(pendingComments.map((c) => c.id))}
+          readOnly={!canInteract}
+          fillHeight
+          interactionDisabled={interactionDisabled}
+          className="min-h-0 flex-1 border-t-0"
+          selectedLine={selectedLine}
+          onLineClick={canInteract ? handleLineClick : undefined}
+          canResolveThreads={canInteract}
+          onToggleThreadResolved={
+            canInteract ? handleToggleThreadResolved : undefined
+          }
+          resolvingThreadNodeId={resolvingThreadNodeId}
+        />
+        <ReviewLoadingOverlay
+          active={overlayMessage != null}
+          message={overlayMessage ?? ""}
+        />
+      </div>
+
+      {canInteract ? (
+        <LineCommentPopover
+          open={Boolean(
+            selectedLine &&
+              reviewInProgress &&
+              (selectedLine.opensPopover ?? true)
+          )}
+          selectedLine={selectedLine}
+          diffBoundsRef={diffBoundsRef}
+          draft={draft}
+          onDraftChange={setDraft}
+          operation={operation}
+          onAdd={handleAddComment}
+          onCancel={() => {
+            if (operation !== "addComment") setSelectedLine(null);
+          }}
+        />
+      ) : null}
+    </div>
+  );
+}
diff --git a/src/components/git/DiffViewer.tsx b/src/components/git/DiffViewer.tsx
new file mode 100644
index 0000000..d5da410
--- /dev/null
+++ b/src/components/git/DiffViewer.tsx
@@ -0,0 +1,360 @@
+import { FileDiff } from "@pierre/diffs/react";
+import { parsePatchFiles } from "@pierre/diffs";
+import type { DiffLineAnnotation } from "@pierre/diffs";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTheme } from "next-themes";
+import type { GitDiffFile, GitReviewComment } from "~/lib/git/types";
+import {
+  buildDiffLineIndex,
+  commentToDiffAnnotation,
+} from "~/lib/git/review-comment-annotation";
+import { isReviewThreadRoot } from "~/lib/git/review-thread";
+import {
+  buildCommentCountByPath,
+  DiffFileTree,
+} from "~/components/git/DiffFileTree";
+import { DiffInlineReviewComment } from "~/components/git/DiffInlineReviewComment";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import { scrollDiffToLine } from "~/lib/git/scroll-diff-to-line";
+import { cn } from "~/lib/utils";
+
+function PatchDiff({ patch, path }: { patch: string; path: string }) {
+  const unified = `diff --git a/${path} b/${path}\n${patch}`;
+  const parsed = parsePatchFiles(unified);
+  const fileDiff = parsed[0]?.files[0];
+  if (!fileDiff) {
+    return <pre className="overflow-auto p-2 text-xs">{patch}</pre>;
+  }
+  return <FileDiff fileDiff={fileDiff} options={diffDisplayOptions} />;
+}
+
+const diffDisplayOptions = {
+  theme: {
+    dark: "pierre-dark" as const,
+    light: "pierre-light" as const,
+  },
+  themeType: "system" as const,
+  diffStyle: "unified" as const,
+};
+
+function commentsForFile(
+  comments: GitReviewComment[],
+  path: string
+): GitReviewComment[] {
+  return comments.filter((c) => c.path === path);
+}
+
+function annotationsForFile(
+  comments: GitReviewComment[],
+  path: string,
+  lineIndex?: ReturnType<typeof buildDiffLineIndex>
+): DiffLineAnnotation<GitReviewComment>[] {
+  return commentsForFile(comments, path)
+    .map((c) => commentToDiffAnnotation(c, lineIndex))
+    .filter((a): a is DiffLineAnnotation<GitReviewComment> => a != null);
+}
+
+type PierreDiffLineClick = {
+  type: "diff-line";
+  lineNumber: number;
+  annotationSide: "deletions" | "additions";
+  lineElement: HTMLElement;
+};
+
+export type DiffLineClickInfo = {
+  path: string;
+  line: number;
+  side: "LEFT" | "RIGHT";
+  anchorRect: DOMRect | null;
+  lineElement: HTMLElement | null;
+};
+
+export function DiffViewer({
+  files,
+  className,
+  comments = [],
+  pendingCommentIds,
+  readOnly = false,
+  fillHeight = true,
+  interactionDisabled = false,
+  onLineClick,
+  selectedLine,
+  canResolveThreads = false,
+  onToggleThreadResolved,
+  resolvingThreadNodeId = null,
+}: {
+  files: GitDiffFile[];
+  className?: string;
+  comments?: GitReviewComment[];
+  pendingCommentIds?: Set<number>;
+  readOnly?: boolean;
+  fillHeight?: boolean;
+  interactionDisabled?: boolean;
+  onLineClick?: (info: DiffLineClickInfo) => void;
+  selectedLine?: {
+    path: string;
+    line: number;
+    side?: "LEFT" | "RIGHT";
+  } | null;
+  canResolveThreads?: boolean;
+  onToggleThreadResolved?: (
+    threadNodeId: string,
+    resolved: boolean
+  ) => void | Promise<void>;
+  resolvingThreadNodeId?: string | null;
+}) {
+  const { resolvedTheme } = useTheme();
+  const diffScrollRef = useRef<HTMLDivElement>(null);
+  const withPatch = files.filter((f) => f.patch);
+  const [activePath, setActivePath] = useState<string | null>(
+    () => withPatch[0]?.path ?? null
+  );
+  const active =
+    withPatch.find((f) => f.path === activePath) ?? withPatch[0];
+  const commentCountByPath = useMemo(
+    () => buildCommentCountByPath(comments),
+    [comments]
+  );
+
+  useEffect(() => {
+    if (withPatch.length === 0) {
+      setActivePath(null);
+      return;
+    }
+    if (!activePath || !withPatch.some((f) => f.path === activePath)) {
+      setActivePath(withPatch[0]!.path);
+    }
+  }, [withPatch, activePath]);
+
+  useEffect(() => {
+    if (selectedLine?.path) {
+      setActivePath(selectedLine.path);
+    }
+  }, [selectedLine?.path]);
+
+  const lineClickDisabled = readOnly || interactionDisabled;
+
+  const pierreOptions = useMemo(
+    () => ({
+      ...diffDisplayOptions,
+      themeType:
+        resolvedTheme === "dark"
+          ? ("dark" as const)
+          : resolvedTheme === "light"
+            ? ("light" as const)
+            : ("system" as const),
+      lineHoverHighlight: lineClickDisabled
+        ? ("disabled" as const)
+        : ("line" as const),
+      onLineClick: lineClickDisabled
+        ? undefined
+        : (props: PierreDiffLineClick & { type?: string }) => {
+            if (!active || !onLineClick) return;
+            if (props.type != null && props.type !== "diff-line") return;
+            const side =
+              props.annotationSide === "deletions" ? "LEFT" : "RIGHT";
+            const lineElement = props.lineElement ?? null;
+            const anchorRect = lineElement?.getBoundingClientRect() ?? null;
+            onLineClick({
+              path: active.path,
+              line: props.lineNumber,
+              side,
+              anchorRect,
+              lineElement,
+            });
+          },
+    }),
+    [active, lineClickDisabled, onLineClick, resolvedTheme]
+  );
+
+  const parsed = useMemo(() => {
+    if (!active?.patch) return null;
+    return parsePatchFiles(
+      `diff --git a/${active.path} b/${active.path}\n${active.patch}`
+    );
+  }, [active?.path, active?.patch]);
+
+  const fileDiff = parsed?.[0]?.files[0];
+
+  useEffect(() => {
+    if (!selectedLine || active?.path !== selectedLine.path || !fileDiff) {
+      return;
+    }
+    const side = selectedLine.side ?? "RIGHT";
+    const timer = window.setTimeout(() => {
+      scrollDiffToLine(diffScrollRef.current, selectedLine.line, side);
+    }, 200);
+    return () => window.clearTimeout(timer);
+  }, [selectedLine, active?.path, fileDiff]);
+
+  const lineIndex = useMemo(
+    () => (fileDiff ? buildDiffLineIndex(fileDiff) : undefined),
+    [fileDiff]
+  );
+
+  const threadCommentCounts = useMemo(() => {
+    const counts = new Map<string, number>();
+    for (const c of comments) {
+      if (!c.threadNodeId) continue;
+      counts.set(c.threadNodeId, (counts.get(c.threadNodeId) ?? 0) + 1);
+    }
+    return counts;
+  }, [comments]);
+
+  const fileAnnotations = active
+    ? annotationsForFile(comments, active.path, lineIndex)
+    : [];
+
+  const unmatchedComments = active
+    ? commentsForFile(comments, active.path).filter(
+        (c) => !commentToDiffAnnotation(c, lineIndex)
+      )
+    : [];
+
+  if (withPatch.length === 0) {
+    return (
+      <p className="text-muted-foreground p-4 text-sm">
+        No diff available. Large files may omit patches on GitHub.
+      </p>
+    );
+  }
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-0 border-t",
+        fillHeight
+          ? "min-h-0 flex-1"
+          : "max-h-[min(480px,60vh)] min-h-[min(320px,40vh)]",
+        className
+      )}
+    >
+      <div className="flex min-h-0 w-48 shrink-0 flex-col overflow-hidden border-r">
+        <DiffFileTree
+          files={withPatch}
+          activePath={active?.path ?? null}
+          onSelectPath={setActivePath}
+          commentCountByPath={commentCountByPath}
+        />
+      </div>
+      <div
+        ref={diffScrollRef}
+        className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
+      >
+        <ScrollArea className="min-h-0 flex-1">
+          {fileDiff ? (
+            <div
+              className={cn(
+                "min-h-0",
+                resolvedTheme === "dark" && "dark"
+              )}
+            >
+              <FileDiff
+                fileDiff={fileDiff}
+                options={pierreOptions}
+                lineAnnotations={fileAnnotations}
+                selectedLines={
+                  selectedLine?.path === active?.path
+                    ? {
+                        start: selectedLine.line,
+                        end: selectedLine.line,
+                        side:
+                          selectedLine.side === "LEFT"
+                            ? ("deletions" as const)
+                            : ("additions" as const),
+                      }
+                    : null
+                }
+                renderAnnotation={(annotation) => {
+                  if (!annotation.metadata) return null;
+                  const meta = annotation.metadata;
+                  const threadNodeId = meta.threadNodeId;
+                  const threadResolved = meta.threadIsResolved ?? false;
+                  const isRoot = isReviewThreadRoot(meta, comments);
+
+                  if (threadResolved && threadNodeId && !isRoot) {
+                    return null;
+                  }
+
+                  const showResolve =
+                    canResolveThreads &&
+                    !pendingCommentIds?.has(meta.id) &&
+                    Boolean(threadNodeId) &&
+                    isRoot;
+
+                  return (
+                    <DiffInlineReviewComment
+                      comment={meta}
+                      pending={pendingCommentIds?.has(meta.id)}
+                      showResolve={showResolve}
+                      threadCommentCount={
+                        threadNodeId
+                          ? (threadCommentCounts.get(threadNodeId) ?? 1)
+                          : 1
+                      }
+                      resolveLoading={resolvingThreadNodeId === threadNodeId}
+                      onToggleResolved={
+                        threadNodeId && onToggleThreadResolved
+                          ? () =>
+                              void onToggleThreadResolved(
+                                threadNodeId,
+                                !threadResolved
+                              )
+                          : undefined
+                      }
+                    />
+                  );
+                }}
+              />
+            </div>
+          ) : active?.patch ? (
+            <div className="p-2 text-sm">
+              <PatchDiff patch={active.patch} path={active.path} />
+            </div>
+          ) : null}
+        </ScrollArea>
+        {unmatchedComments.length > 0 ? (
+          <div className="space-y-2.5 border-t border-border/60 bg-muted/20 p-3">
+            <p className="text-muted-foreground text-xs font-medium">
+              Comments on this file
+            </p>
+            {unmatchedComments.map((c) => {
+              const threadNodeId = c.threadNodeId;
+              const threadResolved = c.threadIsResolved ?? false;
+              const isRoot = isReviewThreadRoot(c, comments);
+              const showResolve =
+                canResolveThreads &&
+                !pendingCommentIds?.has(c.id) &&
+                Boolean(threadNodeId) &&
+                isRoot;
+
+              return (
+                <DiffInlineReviewComment
+                  key={c.id}
+                  comment={c}
+                  pending={pendingCommentIds?.has(c.id)}
+                  showResolve={showResolve}
+                  threadCommentCount={
+                    threadNodeId
+                      ? (threadCommentCounts.get(threadNodeId) ?? 1)
+                      : 1
+                  }
+                  resolveLoading={resolvingThreadNodeId === threadNodeId}
+                  onToggleResolved={
+                    threadNodeId && onToggleThreadResolved
+                      ? () =>
+                          void onToggleThreadResolved(
+                            threadNodeId,
+                            !threadResolved
+                          )
+                      : undefined
+                  }
+                />
+              );
+            })}
+          </div>
+        ) : null}
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/git/GitHubMarkdownBody.tsx b/src/components/git/GitHubMarkdownBody.tsx
new file mode 100644
index 0000000..4c9d107
--- /dev/null
+++ b/src/components/git/GitHubMarkdownBody.tsx
@@ -0,0 +1,214 @@
+import { useMemo, useState, type ReactNode } from "react";
+import { Copy } from "lucide-react";
+import type { Components } from "react-markdown";
+import ReactMarkdown from "react-markdown";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize from "rehype-sanitize";
+import remarkGfm from "remark-gfm";
+import { toast } from "sonner";
+import { Button } from "~/components/ui/button";
+import { githubMarkdownSanitizeSchema } from "~/components/git/github-markdown-sanitize";
+import {
+  hasGitHubCollapsibleSections,
+  preprocessGitHubMarkdown,
+} from "~/lib/git/preprocess-github-markdown";
+import { cn } from "~/lib/utils";
+
+const COLLAPSE_CHAR_THRESHOLD = 1_400;
+
+/** Paragraph spacing without <p> — GitHub HTML often nests blocks inside loose paragraphs. */
+const markdownParagraphClass = "gh-markdown-p my-1.5 leading-relaxed";
+
+function markdownNodeText(node: ReactNode): string {
+  if (node == null || typeof node === "boolean") return "";
+  if (typeof node === "string" || typeof node === "number") return String(node);
+  if (Array.isArray(node)) return node.map(markdownNodeText).join("");
+  if (typeof node === "object" && "props" in node) {
+    const el = node as { props: { children?: ReactNode } };
+    return markdownNodeText(el.props.children);
+  }
+  return "";
+}
+
+function MarkdownPreWithCopy({ children }: { children?: ReactNode }) {
+  const text = useMemo(() => markdownNodeText(children).trimEnd(), [children]);
+
+  const copy = async () => {
+    if (!text) return;
+    try {
+      await navigator.clipboard.writeText(text);
+      toast.success("Copied to clipboard");
+    } catch (e) {
+      const detail = e instanceof Error ? e.message : "Unknown error";
+      toast.error(`Failed to copy: ${detail}`);
+    }
+  };
+
+  return (
+    <div className="group/gh-pre relative my-2">
+      {text ? (
+        <Button
+          type="button"
+          variant="ghost"
+          size="icon"
+          className="text-muted-foreground absolute top-1 right-1 z-10 size-7 bg-background/80 opacity-100 shadow-sm backdrop-blur-sm sm:opacity-0 sm:group-hover/gh-pre:opacity-100 sm:focus-visible:opacity-100"
+          aria-label="Copy code"
+          onClick={() => void copy()}
+        >
+          <Copy className="size-3.5" />
+        </Button>
+      ) : null}
+      <pre className="bg-muted/80 overflow-x-auto rounded-md border border-border/60 p-2.5 pr-10 text-[11px] leading-relaxed">
+        {children}
+      </pre>
+    </div>
+  );
+}
+
+const markdownComponents: Components = {
+  a: ({ href, children }) => (
+    <a
+      href={href}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="text-primary font-medium underline-offset-2 hover:underline"
+    >
+      {children}
+    </a>
+  ),
+  pre: ({ children }) => <MarkdownPreWithCopy>{children}</MarkdownPreWithCopy>,
+  code: ({ className, children, ...props }) => {
+    const isBlock = Boolean(className);
+    if (isBlock) {
+      return (
+        <code className={cn("font-mono", className)} {...props}>
+          {children}
+        </code>
+      );
+    }
+    return (
+      <code
+        className="bg-muted rounded px-1 py-0.5 font-mono text-[11px]"
+        {...props}
+      >
+        {children}
+      </code>
+    );
+  },
+  blockquote: ({ children }) => (
+    <blockquote className="border-primary/30 text-muted-foreground my-2 border-l-2 pl-3 italic">
+      {children}
+    </blockquote>
+  ),
+  table: ({ children }) => (
+    <div className="my-2 overflow-x-auto">
+      <table className="w-full border-collapse text-[11px]">{children}</table>
+    </div>
+  ),
+  th: ({ children }) => (
+    <th className="border-border/60 bg-muted/50 border px-2 py-1 text-left font-medium">
+      {children}
+    </th>
+  ),
+  td: ({ children }) => (
+    <td className="border-border/60 border px-2 py-1">{children}</td>
+  ),
+  details: ({ children }) => (
+    <details className="bg-muted/30 my-2 rounded-md border border-border/60">
+      {children}
+    </details>
+  ),
+  summary: ({ children }) => (
+    <summary className="cursor-pointer px-2.5 py-2 text-xs font-medium select-none">
+      {children}
+    </summary>
+  ),
+  hr: () => <hr className="border-border/60 my-3" />,
+  ul: ({ children }) => (
+    <ul className="my-1.5 list-disc space-y-0.5 pl-5">{children}</ul>
+  ),
+  ol: ({ children }) => (
+    <ol className="my-1.5 list-decimal space-y-0.5 pl-5">{children}</ol>
+  ),
+  p: ({ children }) => (
+    <div className={markdownParagraphClass}>{children}</div>
+  ),
+  h1: ({ children }) => (
+    <h1 className="mt-2 mb-1 text-sm font-semibold">{children}</h1>
+  ),
+  h2: ({ children }) => (
+    <h2 className="mt-2 mb-1 text-sm font-semibold">{children}</h2>
+  ),
+  h3: ({ children }) => (
+    <h3 className="mt-2 mb-1 text-xs font-semibold">{children}</h3>
+  ),
+  h4: ({ children }) => (
+    <h4 className="mt-1.5 mb-0.5 text-xs font-medium">{children}</h4>
+  ),
+};
+
+export function GitHubMarkdownBody({
+  body,
+  className,
+  collapsible = true,
+}: {
+  body: string;
+  className?: string;
+  collapsible?: boolean;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const processed = useMemo(() => preprocessGitHubMarkdown(body), [body]);
+  const hasNestedCollapsibles = useMemo(
+    () => hasGitHubCollapsibleSections(processed),
+    [processed]
+  );
+  const isLong =
+    collapsible &&
+    !hasNestedCollapsibles &&
+    processed.length > COLLAPSE_CHAR_THRESHOLD;
+
+  if (!processed) return null;
+
+  const markdown = (
+    <ReactMarkdown
+      remarkPlugins={[remarkGfm]}
+      rehypePlugins={[
+        rehypeRaw,
+        [rehypeSanitize, githubMarkdownSanitizeSchema],
+      ]}
+      components={markdownComponents}
+    >
+      {processed}
+    </ReactMarkdown>
+  );
+
+  return (
+    <div className={cn("gh-markdown text-xs text-inherit", className)}>
+      <div
+        className={cn(
+          "relative",
+          isLong && !expanded && "max-h-56 overflow-hidden"
+        )}
+      >
+        {markdown}
+        {isLong && !expanded ? (
+          <div
+            className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-linear-to-t from-muted/30 to-transparent"
+            aria-hidden
+          />
+        ) : null}
+      </div>
+      {isLong ? (
+        <Button
+          type="button"
+          variant="ghost"
+          size="sm"
+          className="text-muted-foreground mt-1 h-7 px-0 text-xs"
+          onClick={() => setExpanded((v) => !v)}
+        >
+          {expanded ? "Show less" : "Show more"}
+        </Button>
+      ) : null}
+    </div>
+  );
+}
diff --git a/src/components/git/PhaseBanner.tsx b/src/components/git/PhaseBanner.tsx
new file mode 100644
index 0000000..93074f3
--- /dev/null
+++ b/src/components/git/PhaseBanner.tsx
@@ -0,0 +1,32 @@
+import type { TaskDevPhase } from "~/lib/git/task-dev-phase";
+import { cn } from "~/lib/utils";
+
+const MESSAGES: Record<TaskDevPhase, string> = {
+  no_repo: "Link a repository in project settings to start development.",
+  not_started:
+    "Start development to create a branch. Commits and diffs appear once you push.",
+  branch_only:
+    "Work on your branch below. Open a pull request to enable inline code review on GitHub.",
+  open_pr: "Use the toolbar to start a review, comment on lines, and finish on GitHub.",
+  closed_pr:
+    "This pull request is closed. Browse commits and diffs for context (read-only).",
+};
+
+export function PhaseBanner({
+  phase,
+  className,
+}: {
+  phase: TaskDevPhase;
+  className?: string;
+}) {
+  return (
+    <p
+      className={cn(
+        "text-muted-foreground border-b border-border/60 bg-muted/20 px-4 py-2 text-xs leading-relaxed",
+        className
+      )}
+    >
+      {MESSAGES[phase]}
+    </p>
+  );
+}
diff --git a/src/components/git/PrReviewFeedAttachedComments.tsx b/src/components/git/PrReviewFeedAttachedComments.tsx
new file mode 100644
index 0000000..af9e815
--- /dev/null
+++ b/src/components/git/PrReviewFeedAttachedComments.tsx
@@ -0,0 +1,38 @@
+import { DiffLineLocationLink } from "~/components/git/DiffLineLocationLink";
+import { GitHubMarkdownBody } from "~/components/git/GitHubMarkdownBody";
+import type { GitReviewComment } from "~/lib/git/types";
+import { cn } from "~/lib/utils";
+
+export function PrReviewFeedAttachedComments({
+  comments,
+  className,
+}: {
+  comments: GitReviewComment[];
+  className?: string;
+}) {
+  if (comments.length === 0) return null;
+
+  const sorted = [...comments].sort(
+    (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
+  );
+
+  return (
+    <ul className={cn("mt-2 space-y-2 border-t border-border/60 pt-2 pl-8", className)}>
+      {sorted.map((comment) => {
+        const body = comment.body?.trim() ?? "";
+        if (!body) return null;
+        return (
+          <li
+            key={comment.id}
+            className="rounded-md border border-border/50 bg-muted/30 px-2.5 py-2 text-xs"
+          >
+            <div className="mb-1">
+              <DiffLineLocationLink comment={comment} />
+            </div>
+            <GitHubMarkdownBody body={body} collapsible={false} />
+          </li>
+        );
+      })}
+    </ul>
+  );
+}
diff --git a/src/components/git/PrReviewFeedItem.tsx b/src/components/git/PrReviewFeedItem.tsx
new file mode 100644
index 0000000..df0c01d
--- /dev/null
+++ b/src/components/git/PrReviewFeedItem.tsx
@@ -0,0 +1,411 @@
+import type { ReactNode } from "react";
+import {
+  Check,
+  GitPullRequest,
+  MessageSquare,
+  MessageSquarePlus,
+  X,
+} from "lucide-react";
+import { DiffLineLocationLink } from "~/components/git/DiffLineLocationLink";
+import { GitHubMarkdownBody } from "~/components/git/GitHubMarkdownBody";
+import { PrReviewFeedAttachedComments } from "~/components/git/PrReviewFeedAttachedComments";
+import { ReviewThreadResolveButton } from "~/components/git/ReviewThreadResolveButton";
+import { ReviewThreadResolvedShell } from "~/components/git/ReviewThreadResolvedShell";
+import {
+  formatDateTimeTooltip,
+  formatRelativeTime,
+} from "~/lib/format-relative-time";
+import type { PrReviewCommentThread } from "~/lib/git/pr-review-feed-entries";
+import type {
+  GitPullRequestIssueComment,
+  GitPullRequestReview,
+  GitReviewComment,
+} from "~/lib/git/types";
+import { cn } from "~/lib/utils";
+
+function AuthorAvatar({
+  login,
+  avatarUrl,
+}: {
+  login: string;
+  avatarUrl: string | null;
+}) {
+  if (avatarUrl) {
+    return (
+      <img
+        src={avatarUrl}
+        alt=""
+        className="size-6 shrink-0 rounded-full"
+      />
+    );
+  }
+  return (
+    <span className="bg-muted flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-medium">
+      {login.slice(0, 1).toUpperCase()}
+    </span>
+  );
+}
+
+function reviewActionLabel(state: GitPullRequestReview["state"]): string {
+  switch (state) {
+    case "APPROVED":
+      return "approved";
+    case "CHANGES_REQUESTED":
+      return "requested changes";
+    case "COMMENTED":
+      return "commented";
+    case "DISMISSED":
+      return "dismissed";
+    default:
+      return "reviewed";
+  }
+}
+
+function ReviewStateIcon({
+  state,
+}: {
+  state: GitPullRequestReview["state"];
+}) {
+  const className = "size-3 shrink-0";
+  switch (state) {
+    case "APPROVED":
+      return <Check className={cn(className, "text-green-600")} />;
+    case "CHANGES_REQUESTED":
+      return <X className={cn(className, "text-amber-600")} />;
+    case "COMMENTED":
+      return <MessageSquare className={cn(className, "text-muted-foreground")} />;
+    default:
+      return <GitPullRequest className={cn(className, "text-muted-foreground")} />;
+  }
+}
+
+function FileLocation({ comment }: { comment: GitReviewComment }) {
+  if (!comment.path) return null;
+  return (
+    <span className="bg-muted inline-flex max-w-full rounded px-1.5 py-0.5">
+      <DiffLineLocationLink comment={comment} showGithub={false} />
+    </span>
+  );
+}
+
+function FeedCard({
+  children,
+  className,
+}: {
+  children: ReactNode;
+  className?: string;
+}) {
+  return (
+    <article
+      className={cn(
+        "rounded-lg border border-border/60 bg-background/80 px-3 py-2.5",
+        className
+      )}
+    >
+      {children}
+    </article>
+  );
+}
+
+function FeedHeader({
+  login,
+  avatarUrl,
+  htmlUrl,
+  action,
+  icon,
+  githubUrl,
+  occurredAt,
+  trailing,
+}: {
+  login: string;
+  avatarUrl: string | null;
+  htmlUrl: string | null;
+  action: ReactNode;
+  icon?: ReactNode;
+  githubUrl: string | null;
+  occurredAt: Date;
+  trailing?: ReactNode;
+}) {
+  const profileUrl = htmlUrl ?? `https://github.com/${login}`;
+
+  return (
+    <header className="flex items-start gap-2">
+      <a href={profileUrl} target="_blank" rel="noopener noreferrer">
+        <AuthorAvatar login={login} avatarUrl={avatarUrl} />
+      </a>
+      <div className="min-w-0 flex-1 space-y-1">
+        <div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 text-xs leading-snug">
+          <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
+            <a
+              href={profileUrl}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="font-medium hover:underline"
+            >
+              {login}
+            </a>
+            {icon}
+            <span className="text-muted-foreground">{action}</span>
+            {trailing}
+          </div>
+          <time
+            className="text-muted-foreground shrink-0 text-[10px]"
+            dateTime={occurredAt.toISOString()}
+            title={formatDateTimeTooltip(occurredAt)}
+          >
+            {formatRelativeTime(occurredAt)}
+          </time>
+        </div>
+        {githubUrl ? (
+          <a
+            href={githubUrl}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="text-muted-foreground text-[10px] hover:underline"
+          >
+            View on GitHub
+          </a>
+        ) : null}
+      </div>
+    </header>
+  );
+}
+
+function reviewDetailHint(
+  state: GitPullRequestReview["state"],
+  hasAttachedComments: boolean
+): string | null {
+  if (hasAttachedComments) return null;
+  switch (state) {
+    case "CHANGES_REQUESTED":
+      return "No summary was added. Feedback is usually on specific lines in the Pull request diff.";
+    case "APPROVED":
+      return "Approved without a written summary.";
+    case "COMMENTED":
+      return "Left a review without a summary comment.";
+    default:
+      return null;
+  }
+}
+
+export function PrReviewFeedItemIssueComment({
+  comment,
+  occurredAt,
+}: {
+  comment: GitPullRequestIssueComment;
+  occurredAt: Date;
+}) {
+  const body = comment.body?.trim() ?? "";
+  if (!body) return null;
+
+  return (
+    <FeedCard>
+      <FeedHeader
+        login={comment.authorLogin}
+        avatarUrl={comment.authorAvatarUrl}
+        htmlUrl={comment.authorHtmlUrl}
+        action="commented"
+        icon={<MessageSquare className="text-muted-foreground size-3 shrink-0" />}
+        githubUrl={comment.url}
+        occurredAt={occurredAt}
+        trailing={
+          comment.isBot ? (
+            <span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
+              Bot
+            </span>
+          ) : null
+        }
+      />
+      <div className="mt-2 pl-8">
+        <GitHubMarkdownBody body={body} collapsible={false} />
+      </div>
+    </FeedCard>
+  );
+}
+
+export function PrReviewFeedItemReview({
+  review,
+  occurredAt,
+  attachedComments = [],
+}: {
+  review: GitPullRequestReview;
+  occurredAt: Date;
+  attachedComments?: GitReviewComment[];
+}) {
+  const body = review.body?.trim() ?? "";
+  const visibleAttached = attachedComments.filter((c) => c.body?.trim());
+  const hint = reviewDetailHint(review.state, visibleAttached.length > 0);
+
+  return (
+    <FeedCard>
+      <FeedHeader
+        login={review.authorLogin}
+        avatarUrl={review.authorAvatarUrl}
+        htmlUrl={review.authorHtmlUrl}
+        action={reviewActionLabel(review.state)}
+        icon={<ReviewStateIcon state={review.state} />}
+        githubUrl={review.url}
+        occurredAt={occurredAt}
+      />
+      {body ? (
+        <div className="mt-2 pl-8">
+          <GitHubMarkdownBody body={body} collapsible={false} />
+        </div>
+      ) : null}
+      {visibleAttached.length > 0 ? (
+        <>
+          {visibleAttached.length > 0 && body ? (
+            <p className="text-muted-foreground mt-2 pl-8 text-[10px] font-medium">
+              Line comments in this review
+            </p>
+          ) : null}
+          <PrReviewFeedAttachedComments comments={visibleAttached} />
+        </>
+      ) : hint ? (
+        <p className="text-muted-foreground mt-2 pl-8 text-xs leading-relaxed">
+          {hint}
+        </p>
+      ) : null}
+    </FeedCard>
+  );
+}
+
+function PrReviewFeedLineComment({
+  comment,
+  pending,
+  occurredAt,
+  isReply,
+  replyToLogin,
+}: {
+  comment: GitReviewComment;
+  pending: boolean;
+  occurredAt: Date;
+  isReply: boolean;
+  replyToLogin?: string | null;
+}) {
+  const body = comment.body?.trim() ?? "";
+  if (!body) return null;
+
+  const action = isReply ? (
+    <>
+      replied
+      {replyToLogin ? (
+        <>
+          {" "}
+          to{" "}
+          <span className="text-foreground font-medium">@{replyToLogin}</span>
+        </>
+      ) : null}
+    </>
+  ) : (
+    "commented on a line"
+  );
+
+  return (
+    <div className={cn(isReply && "border-border/60 mt-3 border-l-2 pl-3")}>
+      <FeedHeader
+        login={comment.authorLogin}
+        avatarUrl={comment.authorAvatarUrl}
+        htmlUrl={comment.authorHtmlUrl}
+        action={action}
+        icon={<MessageSquarePlus className="text-muted-foreground size-3 shrink-0" />}
+        githubUrl={comment.url}
+        occurredAt={occurredAt}
+        trailing={
+          <>
+            {!isReply ? <FileLocation comment={comment} /> : null}
+            {pending ? (
+              <span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
+                Pending
+              </span>
+            ) : null}
+          </>
+        }
+      />
+      <div className="mt-2 pl-8">
+        <GitHubMarkdownBody body={body} collapsible={false} />
+      </div>
+    </div>
+  );
+}
+
+export function PrReviewFeedItemCommentThread({
+  thread,
+  canResolve = false,
+  resolveLoading = false,
+  onToggleResolved,
+}: {
+  thread: PrReviewCommentThread;
+  canResolve?: boolean;
+  resolveLoading?: boolean;
+  onToggleResolved?: (resolved: boolean) => void;
+}) {
+  const byId = new Map<number, GitReviewComment>([
+    [thread.root.id, thread.root],
+    ...thread.replies.map((c) => [c.id, c] as const),
+  ]);
+
+  const rootPending = thread.pendingCommentIds.has(thread.root.id);
+  if (!thread.root.body?.trim() && thread.replies.length === 0) return null;
+
+  const commentCount = 1 + thread.replies.length;
+
+  const threadBody = (
+    <>
+      {thread.root.body?.trim() ? (
+        <PrReviewFeedLineComment
+          comment={thread.root}
+          pending={rootPending}
+          occurredAt={thread.root.createdAt}
+          isReply={false}
+        />
+      ) : null}
+      {thread.replies.map((reply) => {
+        const parent =
+          reply.inReplyToId != null ? byId.get(reply.inReplyToId) : undefined;
+        return (
+          <PrReviewFeedLineComment
+            key={reply.id}
+            comment={reply}
+            pending={thread.pendingCommentIds.has(reply.id)}
+            occurredAt={reply.createdAt}
+            isReply
+            replyToLogin={parent?.authorLogin}
+          />
+        );
+      })}
+      {!thread.isResolved &&
+      canResolve &&
+      thread.threadNodeId &&
+      onToggleResolved ? (
+        <div className="pt-1">
+          <ReviewThreadResolveButton
+            resolved={false}
+            loading={resolveLoading}
+            onToggle={() => onToggleResolved(true)}
+          />
+        </div>
+      ) : null}
+    </>
+  );
+
+  if (thread.isResolved) {
+    return (
+      <ReviewThreadResolvedShell
+        resolved
+        commentCount={commentCount}
+        authorLogin={thread.root.authorLogin}
+        variant="feed"
+        canResolve={canResolve && Boolean(thread.threadNodeId)}
+        resolveLoading={resolveLoading}
+        onToggleResolved={
+          onToggleResolved ? () => onToggleResolved(false) : undefined
+        }
+      >
+        {threadBody}
+      </ReviewThreadResolvedShell>
+    );
+  }
+
+  return <FeedCard>{threadBody}</FeedCard>;
+}
diff --git a/src/components/git/ProjectGitSection.tsx b/src/components/git/ProjectGitSection.tsx
new file mode 100644
index 0000000..2a48e44
--- /dev/null
+++ b/src/components/git/ProjectGitSection.tsx
@@ -0,0 +1,130 @@
+import { useEffect, useState } from "react";
+import { FormSheetSection } from "~/components/FormSheetSection";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import { Checkbox } from "~/components/ui/checkbox";
+import { useQuery } from "@tanstack/react-query";
+import {
+  gitRepositoriesQueryOptions,
+  useProjectGitConfigQuery,
+} from "~/db/queries/git";
+import { useSaveProjectGitRepositoriesMutation } from "~/db/mutations/git";
+import { suggestTaskKeyPrefix } from "~/lib/git/task-key";
+import { Button } from "~/components/ui/button";
+import { toast } from "sonner";
+import type { Project } from "~/db/schema";
+
+export function ProjectGitSection({ project }: { project: Project }) {
+  const reposQuery = useQuery(gitRepositoriesQueryOptions());
+  const configQuery = useProjectGitConfigQuery(project.id);
+  const save = useSaveProjectGitRepositoriesMutation();
+
+  const [selectedIds, setSelectedIds] = useState<string[]>([]);
+  const [taskKeyPrefix, setTaskKeyPrefix] = useState(
+    project.taskKeyPrefix ?? suggestTaskKeyPrefix(project.name)
+  );
+
+  useEffect(() => {
+    if (!configQuery.data) return;
+    const ids = configQuery.data.repositories.map((r) => r.repositoryId);
+    setSelectedIds(ids);
+    if (configQuery.data.project.taskKeyPrefix) {
+      setTaskKeyPrefix(configQuery.data.project.taskKeyPrefix);
+    }
+  }, [configQuery.data]);
+
+  const toggleRepo = (id: string) => {
+    setSelectedIds((prev) => {
+      if (prev.includes(id)) {
+        return prev.filter((x) => x !== id);
+      }
+      if (prev.length >= 5) {
+        toast.error("Maximum 5 repositories per project");
+        return prev;
+      }
+      return [...prev, id];
+    });
+  };
+
+  const handleSave = async () => {
+    await save({
+      projectId: project.id,
+      repositoryIds: selectedIds,
+      taskKeyPrefix: taskKeyPrefix.toUpperCase(),
+    });
+    toast.success("Repository settings saved");
+  };
+
+  if (reposQuery.isLoading) {
+    return (
+      <FormSheetSection title="Repositories">
+        <p className="text-muted-foreground text-sm">Loading repositories…</p>
+      </FormSheetSection>
+    );
+  }
+
+  if (!reposQuery.data?.length) {
+    return (
+      <FormSheetSection title="Repositories">
+        <p className="text-muted-foreground text-sm">
+          Connect GitHub in{" "}
+          <a href="/settings/integrations" className="underline">
+            Integrations
+          </a>{" "}
+          and sync repositories first.
+        </p>
+      </FormSheetSection>
+    );
+  }
+
+  return (
+    <FormSheetSection title="Repositories">
+      <div className="space-y-4">
+        <div>
+          <Label htmlFor="task-key-prefix">Task key prefix</Label>
+          <Input
+            id="task-key-prefix"
+            value={taskKeyPrefix}
+            onChange={(e) =>
+              setTaskKeyPrefix(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))
+            }
+            className="mt-1 max-w-[120px] font-mono uppercase"
+            maxLength={16}
+          />
+          <p className="text-muted-foreground mt-1 text-xs">
+            Tasks will be keyed like {taskKeyPrefix || "PY"}-1, {taskKeyPrefix || "PY"}-2…
+          </p>
+        </div>
+
+        <p className="text-muted-foreground text-xs">
+          Link one or more repositories. When starting work on a task, you choose
+          the repository unless the task is already linked to a branch.
+        </p>
+
+        <ul className="space-y-2">
+          {reposQuery.data.map((repo) => (
+            <li
+              key={repo.id}
+              className="flex items-center gap-3 rounded-md border px-3 py-2"
+            >
+              <Checkbox
+                checked={selectedIds.includes(repo.id)}
+                onCheckedChange={() => toggleRepo(repo.id)}
+              />
+              <div className="min-w-0 flex-1">
+                <p className="truncate text-sm font-medium">{repo.fullName}</p>
+                <p className="text-muted-foreground text-xs">
+                  default: {repo.defaultBranch}
+                </p>
+              </div>
+            </li>
+          ))}
+        </ul>
+
+        <Button type="button" size="sm" onClick={handleSave}>
+          Save repository links
+        </Button>
+      </div>
+    </FormSheetSection>
+  );
+}
diff --git a/src/components/git/PullRequestDescriptionPanel.tsx b/src/components/git/PullRequestDescriptionPanel.tsx
new file mode 100644
index 0000000..4da42c5
--- /dev/null
+++ b/src/components/git/PullRequestDescriptionPanel.tsx
@@ -0,0 +1,158 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { ChevronDown, ExternalLink } from "lucide-react";
+import { GitHubMarkdownBody } from "~/components/git/GitHubMarkdownBody";
+import { PullRequestStateBadge } from "~/components/git/PullRequestStateBadge";
+import { Button } from "~/components/ui/button";
+import { LoadingSpinner } from "~/components/ui/loading-spinner";
+import {
+  useGitConnectionQuery,
+  useTaskPullRequestReviewCommentsQuery,
+} from "~/db/queries/git";
+import { cn } from "~/lib/utils";
+
+export function PullRequestDescriptionPanel({
+  taskId,
+  pullRequestId,
+  repository,
+  headerActions,
+  className,
+}: {
+  taskId: string;
+  pullRequestId: string;
+  repository?: { fullName: string; htmlUrl: string };
+  headerActions?: ReactNode;
+  className?: string;
+}) {
+  const { data: connectionData } = useGitConnectionQuery();
+  const { data, isLoading, isFetching } = useTaskPullRequestReviewCommentsQuery(
+    taskId,
+    pullRequestId,
+    Boolean(connectionData?.connection)
+  );
+
+  const pr = data?.pullRequest;
+  const description = pr?.body?.trim() ?? "";
+  const [collapsed, setCollapsed] = useState(false);
+
+  useEffect(() => {
+    setCollapsed(false);
+  }, [pullRequestId, description]);
+
+  if (!connectionData?.connection) return null;
+
+  if (isLoading && !pr) {
+    return (
+      <div
+        className={cn(
+          "border-b border-border/60 px-4 py-6",
+          className
+        )}
+      >
+        <div className="flex items-center gap-2">
+          <LoadingSpinner isActive className="size-4" />
+          <span className="text-muted-foreground text-sm">
+            Loading pull request…
+          </span>
+        </div>
+      </div>
+    );
+  }
+
+  if (!pr) return null;
+
+  return (
+    <section
+      className={cn("shrink-0 border-b border-border/60 px-4 py-3", className)}
+    >
+      <div className="flex items-start justify-between gap-3">
+        <h2 className="min-w-0 flex-1 text-base leading-snug font-semibold tracking-tight text-pretty">
+          {pr.title}
+        </h2>
+        <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
+          <Button type="button" variant="outline" size="sm" className="h-8" asChild>
+            <a href={pr.url} target="_blank" rel="noopener noreferrer">
+              Open on GitHub
+              <ExternalLink className="size-3.5" />
+            </a>
+          </Button>
+          {headerActions}
+        </div>
+      </div>
+
+      <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1">
+        {repository ? (
+          <a
+            href={repository.htmlUrl}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs transition-colors"
+          >
+            {repository.fullName}
+            <ExternalLink className="size-3 opacity-70" />
+          </a>
+        ) : null}
+        {repository ? (
+          <span className="text-muted-foreground/50 text-xs" aria-hidden>
+            ·
+          </span>
+        ) : null}
+        <span className="text-muted-foreground inline-flex items-center gap-1.5 font-mono text-xs">
+          <span className="text-foreground/90">{pr.headRef}</span>
+          <span aria-hidden>→</span>
+          <span>{pr.baseRef}</span>
+        </span>
+        <PullRequestStateBadge state={pr.state} />
+        {isFetching ? <LoadingSpinner isActive className="size-3" /> : null}
+      </div>
+
+      {!collapsed ? (
+        <div className="mt-2 min-w-0">
+          {description.length > 280 ? (
+            <div className="mb-1 flex justify-end">
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                className="text-muted-foreground -mr-2 h-7 text-xs"
+                onClick={() => setCollapsed(true)}
+              >
+                <ChevronDown className="size-3.5 rotate-180" />
+                Hide description
+              </Button>
+            </div>
+          ) : null}
+          <div
+            className={cn(
+              "[&_.gh-markdown-p:first-child]:mt-0",
+              description.length > 0 &&
+                "max-h-56 overflow-y-auto overscroll-contain pr-1"
+            )}
+          >
+            {description ? (
+              <GitHubMarkdownBody
+                body={description}
+                collapsible={false}
+                className="pr-description-markdown"
+              />
+            ) : (
+              <p className="text-muted-foreground text-sm italic">
+                No description on this pull request.
+              </p>
+            )}
+          </div>
+        </div>
+      ) : description.length > 280 ? (
+        <Button
+          type="button"
+          variant="ghost"
+          size="sm"
+          className="text-muted-foreground mt-2 h-7 text-xs"
+          onClick={() => setCollapsed(false)}
+        >
+          <ChevronDown className="size-3.5" />
+          Show description
+        </Button>
+      ) : null}
+    </section>
+  );
+}
diff --git a/src/components/git/PullRequestDiffViewer.tsx b/src/components/git/PullRequestDiffViewer.tsx
new file mode 100644
index 0000000..3b84e88
--- /dev/null
+++ b/src/components/git/PullRequestDiffViewer.tsx
@@ -0,0 +1,44 @@
+import { useQuery } from "@tanstack/react-query";
+import { fetchTaskPullRequestDiff } from "~/db/queries/git";
+import { DiffReviewPanel } from "~/components/git/DiffReviewPanel";
+import { DiffLoadingSkeleton } from "~/components/git/review/DiffLoadingSkeleton";
+import { cn } from "~/lib/utils";
+
+/** @deprecated Prefer TaskDevelopmentWorkspace; kept for inline layout fallback. */
+export function PullRequestDiffViewer({
+  taskId,
+  pullRequestId,
+  className,
+  readOnly = false,
+}: {
+  taskId: string;
+  pullRequestId: string;
+  className?: string;
+  readOnly?: boolean;
+}) {
+  const { data, isLoading } = useQuery({
+    queryKey: ["git", "diff", taskId, pullRequestId],
+    queryFn: () =>
+      fetchTaskPullRequestDiff({
+        data: { taskId, pullRequestId },
+      }),
+  });
+
+  if (isLoading) {
+    return <DiffLoadingSkeleton className="border-t-0" />;
+  }
+
+  if (!data) return null;
+
+  return (
+    <DiffReviewPanel
+      taskId={taskId}
+      pullRequestId={pullRequestId}
+      scopeLabel={`PR #${data.pullRequest.number} vs ${data.pullRequest.baseRef}`}
+      files={data.files}
+      headSha={data.headSha}
+      readOnly={readOnly}
+      className={cn(className)}
+    />
+  );
+}
diff --git a/src/components/git/PullRequestMergePanel.tsx b/src/components/git/PullRequestMergePanel.tsx
new file mode 100644
index 0000000..84eadf9
--- /dev/null
+++ b/src/components/git/PullRequestMergePanel.tsx
@@ -0,0 +1,366 @@
+import { useState } from "react";
+import {
+  Check,
+  ChevronDown,
+  Circle,
+  ExternalLink,
+  GitMerge,
+  Loader2,
+  Minus,
+  X,
+} from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "~/components/ui/button";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import { LoadingSpinner } from "~/components/ui/loading-spinner";
+import {
+  useCloseTaskPullRequestMutation,
+  useMergeTaskPullRequestMutation,
+} from "~/db/mutations/git";
+import { useTaskPullRequestMergeStatusQuery } from "~/db/queries/git";
+import { formatClientError } from "~/lib/git/errors";
+import { resolveCheckAvatarUrl } from "~/lib/git/check-avatar";
+import {
+  canMergePullRequest,
+  checksSummaryLabel,
+  groupPullRequestChecks,
+  mergeabilityMessage,
+} from "~/lib/git/pr-check-summary";
+import type { GitPullRequestCheck } from "~/lib/git/types";
+import { cn } from "~/lib/utils";
+
+function CheckAppAvatar({
+  check,
+  className,
+}: {
+  check: GitPullRequestCheck;
+  className?: string;
+}) {
+  const src = resolveCheckAvatarUrl(check);
+  if (!src) return null;
+
+  return (
+    <img
+      src={src}
+      alt=""
+      className={cn(
+        "size-4 shrink-0 rounded-sm border border-border/40 bg-background object-cover",
+        className
+      )}
+    />
+  );
+}
+
+function CheckStatusIcon({
+  check,
+  className,
+}: {
+  check: GitPullRequestCheck;
+  className?: string;
+}) {
+  if (check.status !== "completed") {
+    return (
+      <Loader2
+        className={cn("size-4 shrink-0 animate-spin text-muted-foreground", className)}
+      />
+    );
+  }
+  if (
+    check.conclusion === "failure" ||
+    check.conclusion === "timed_out" ||
+    check.conclusion === "action_required"
+  ) {
+    return <X className={cn("size-4 shrink-0 text-destructive", className)} />;
+  }
+  if (check.conclusion === "neutral") {
+    return <Minus className={cn("size-4 shrink-0 text-muted-foreground", className)} />;
+  }
+  return <Check className={cn("size-4 shrink-0 text-emerald-500", className)} />;
+}
+
+function CheckRow({ check }: { check: GitPullRequestCheck }) {
+  return (
+    <li className="flex items-start gap-2 py-1.5">
+      <CheckStatusIcon check={check} className="mt-0.5" />
+      <CheckAppAvatar check={check} className="mt-0.5" />
+      <div className="min-w-0 flex-1">
+        <a
+          href={check.htmlUrl}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-foreground hover:text-primary text-xs font-medium underline-offset-2 hover:underline"
+        >
+          {check.name}
+        </a>
+        {check.description ? (
+          <p className="text-muted-foreground mt-0.5 line-clamp-2 text-[11px] leading-snug">
+            {check.description}
+          </p>
+        ) : null}
+      </div>
+      <a
+        href={check.htmlUrl}
+        target="_blank"
+        rel="noopener noreferrer"
+        className="text-muted-foreground hover:text-foreground shrink-0 p-0.5"
+        aria-label="Open check on GitHub"
+      >
+        <ExternalLink className="size-3.5" />
+      </a>
+    </li>
+  );
+}
+
+function CheckGroup({
+  label,
+  checks,
+  defaultOpen = true,
+}: {
+  label: string;
+  checks: GitPullRequestCheck[];
+  defaultOpen?: boolean;
+}) {
+  const [open, setOpen] = useState(defaultOpen);
+  if (checks.length === 0) return null;
+
+  return (
+    <Collapsible open={open} onOpenChange={setOpen}>
+      <CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-1.5 py-1 text-left">
+        <ChevronDown
+          className={cn(
+            "text-muted-foreground size-3.5 shrink-0 transition-transform",
+            open && "rotate-180"
+          )}
+        />
+        <span className="text-muted-foreground text-[11px] font-medium">
+          {label}
+        </span>
+      </CollapsibleTrigger>
+      <CollapsibleContent>
+        <ul className="border-border/60 ml-1 border-l pl-3">
+          {checks.map((check) => (
+            <CheckRow key={check.id} check={check} />
+          ))}
+        </ul>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}
+
+export function PullRequestMergePanel({
+  taskId,
+  pullRequestId,
+  prNumber,
+  prUrl,
+  prState,
+  canAct = true,
+}: {
+  taskId: string;
+  pullRequestId: string;
+  prNumber: number;
+  prUrl: string;
+  prState: string;
+  /** Requires linked GitHub user OAuth for merge/close. */
+  canAct?: boolean;
+}) {
+  const { data: status, isLoading, isFetching, refetch } =
+    useTaskPullRequestMergeStatusQuery(taskId, pullRequestId);
+  const mergePr = useMergeTaskPullRequestMutation();
+  const closePr = useCloseTaskPullRequestMutation();
+  const [checksOpen, setChecksOpen] = useState(true);
+  const [busy, setBusy] = useState<"merge" | "close" | null>(null);
+
+  const liveState = status?.state ?? prState;
+  const isOpen = liveState === "open" || liveState === "draft";
+
+  if (!isOpen) {
+    const label =
+      liveState === "merged"
+        ? "Pull request merged"
+        : "Pull request closed";
+    return (
+      <div className="border-border/60 shrink-0 border-t px-4 py-3">
+        <p className="text-muted-foreground text-xs">{label}</p>
+        <a
+          href={prUrl}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-primary mt-1 inline-flex items-center gap-1 text-xs hover:underline"
+        >
+          View PR #{prNumber} on GitHub
+          <ExternalLink className="size-3" />
+        </a>
+      </div>
+    );
+  }
+
+  const checkGroup = groupPullRequestChecks(status?.checks ?? []);
+  const summary = checksSummaryLabel(checkGroup);
+  const mergeability = mergeabilityMessage({
+    mergeable: status?.mergeable ?? null,
+    mergeableState: status?.mergeableState ?? "unknown",
+  });
+  const mergeAllowed = status
+    ? canMergePullRequest({
+        state: status.state,
+        mergeable: status.mergeable,
+        mergeableState: status.mergeableState,
+        checkGroup,
+      })
+    : false;
+
+  const handleMerge = async () => {
+    setBusy("merge");
+    try {
+      await mergePr({ taskId, pullRequestId });
+      toast.success("Pull request merged");
+      await refetch();
+    } catch (e) {
+      toast.error(formatClientError(e, "Failed to merge pull request"));
+    } finally {
+      setBusy(null);
+    }
+  };
+
+  const handleClose = async () => {
+    setBusy("close");
+    try {
+      await closePr({ taskId, pullRequestId });
+      toast.success("Pull request closed");
+      await refetch();
+    } catch (e) {
+      toast.error(formatClientError(e, "Failed to close pull request"));
+    } finally {
+      setBusy(null);
+    }
+  };
+
+  return (
+    <div className="border-border/60 shrink-0 border-t">
+      <div className="px-4 py-3">
+        {isLoading ? (
+          <p className="text-muted-foreground text-xs">Loading checks…</p>
+        ) : (
+          <div className="space-y-3">
+            <Collapsible open={checksOpen} onOpenChange={setChecksOpen}>
+              <div
+                className={cn(
+                  "rounded-md border px-3 py-2.5",
+                  summary.tone === "success" &&
+                    "border-emerald-500/40 bg-emerald-500/5",
+                  summary.tone === "failure" &&
+                    "border-destructive/40 bg-destructive/5",
+                  summary.tone === "pending" &&
+                    "border-border/80 bg-muted/20",
+                  summary.tone === "none" && "border-border/60 bg-muted/10"
+                )}
+              >
+                <CollapsibleTrigger
+                  className={cn(
+                    "flex w-full items-start gap-2 text-left",
+                    (status?.checks.length ?? 0) > 0 && "cursor-pointer"
+                  )}
+                >
+                  {summary.tone === "success" ? (
+                    <Check className="mt-0.5 size-4 shrink-0 text-emerald-500" />
+                  ) : summary.tone === "failure" ? (
+                    <X className="text-destructive mt-0.5 size-4 shrink-0" />
+                  ) : summary.tone === "pending" ? (
+                    <Loader2 className="text-muted-foreground mt-0.5 size-4 shrink-0 animate-spin" />
+                  ) : (
+                    <Circle className="text-muted-foreground mt-0.5 size-4 shrink-0" />
+                  )}
+                  <div className="min-w-0 flex-1">
+                    <p className="text-xs font-semibold">{summary.headline}</p>
+                    {summary.detail ? (
+                      <p className="text-muted-foreground mt-0.5 text-[11px]">
+                        {summary.detail}
+                      </p>
+                    ) : null}
+                  </div>
+                  {(status?.checks.length ?? 0) > 0 ? (
+                    <ChevronDown
+                      className={cn(
+                        "text-muted-foreground size-4 shrink-0 transition-transform",
+                        checksOpen && "rotate-180"
+                      )}
+                    />
+                  ) : null}
+                </CollapsibleTrigger>
+                <CollapsibleContent className="mt-2">
+                  <CheckGroup
+                    label={`${checkGroup.inProgress.length} in progress`}
+                    checks={checkGroup.inProgress}
+                  />
+                  <CheckGroup
+                    label={`${checkGroup.neutral.length} neutral checks`}
+                    checks={checkGroup.neutral}
+                  />
+                  <CheckGroup
+                    label={`${checkGroup.successful.length} successful checks`}
+                    checks={checkGroup.successful}
+                  />
+                  <CheckGroup
+                    label={`${checkGroup.failed.length} failed checks`}
+                    checks={checkGroup.failed}
+                    defaultOpen
+                  />
+                </CollapsibleContent>
+              </div>
+            </Collapsible>
+
+            <div className="flex items-start gap-2">
+              {mergeability.ok ? (
+                <Check className="mt-0.5 size-4 shrink-0 text-emerald-500" />
+              ) : (
+                <X className="text-destructive mt-0.5 size-4 shrink-0" />
+              )}
+              <div>
+                <p className="text-xs font-medium">{mergeability.title}</p>
+                <p className="text-muted-foreground mt-0.5 text-[11px] leading-snug">
+                  {mergeability.detail}
+                </p>
+              </div>
+            </div>
+
+            {canAct ? (
+              <div className="flex flex-wrap items-center gap-2">
+                <Button
+                  size="sm"
+                  className="border-emerald-600 bg-emerald-600 text-white hover:border-emerald-700 hover:bg-emerald-700"
+                  disabled={!mergeAllowed || busy !== null}
+                  loading={busy === "merge"}
+                  icon={GitMerge}
+                  onClick={() => void handleMerge()}
+                >
+                  Merge pull request
+                </Button>
+                <Button
+                  size="sm"
+                  variant="outline"
+                  disabled={busy !== null}
+                  loading={busy === "close"}
+                  onClick={() => void handleClose()}
+                >
+                  Close without merging
+                </Button>
+                {isFetching && !isLoading ? (
+                  <LoadingSpinner isActive className="size-3.5" />
+                ) : null}
+              </div>
+            ) : (
+              <p className="text-muted-foreground text-[11px]">
+                Connect GitHub in settings to merge or close this pull request
+                here.
+              </p>
+            )}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/git/PullRequestStateBadge.tsx b/src/components/git/PullRequestStateBadge.tsx
new file mode 100644
index 0000000..57ee380
--- /dev/null
+++ b/src/components/git/PullRequestStateBadge.tsx
@@ -0,0 +1,29 @@
+import { Badge } from "~/components/ui/badge";
+import type { GitPrState } from "~/db/schema/git";
+import { cn } from "~/lib/utils";
+
+const openBadgeClass =
+  "border-blue-200 bg-blue-100 text-blue-700 dark:border-blue-800/80 dark:bg-blue-950/50 dark:text-blue-300";
+
+export function PullRequestStateBadge({
+  state,
+  className,
+}: {
+  state: GitPrState | string;
+  className?: string;
+}) {
+  const isOpen = state === "open";
+
+  return (
+    <Badge
+      variant="secondary"
+      className={cn(
+        "h-5 text-[10px] capitalize",
+        isOpen && openBadgeClass,
+        className
+      )}
+    >
+      {state}
+    </Badge>
+  );
+}
diff --git a/src/components/git/ReviewComment.tsx b/src/components/git/ReviewComment.tsx
new file mode 100644
index 0000000..e5003cd
--- /dev/null
+++ b/src/components/git/ReviewComment.tsx
@@ -0,0 +1,117 @@
+import { GitHubMarkdownBody } from "~/components/git/GitHubMarkdownBody";
+import type { GitPullRequestReview, GitReviewComment } from "~/lib/git/types";
+import { cn } from "~/lib/utils";
+
+function AuthorLink({
+  login,
+  avatarUrl,
+  htmlUrl,
+}: {
+  login: string;
+  avatarUrl: string | null;
+  htmlUrl: string | null;
+}) {
+  const profileUrl = htmlUrl ?? `https://github.com/${login}`;
+
+  return (
+    <a
+      href={profileUrl}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="inline-flex items-center gap-2 hover:underline"
+    >
+      {avatarUrl ? (
+        <img src={avatarUrl} alt="" className="size-5 rounded-full" />
+      ) : (
+        <span className="bg-muted flex size-5 items-center justify-center rounded-full text-[10px] font-medium">
+          {login.slice(0, 1).toUpperCase()}
+        </span>
+      )}
+      <span className="font-medium">{login}</span>
+    </a>
+  );
+}
+
+export function ReviewComment({
+  comment,
+  pending = false,
+  className,
+}: {
+  comment: GitReviewComment;
+  pending?: boolean;
+  className?: string;
+}) {
+  return (
+    <div className={cn("text-xs", className)}>
+      <div className="flex flex-wrap items-center gap-2 pl-0">
+        <AuthorLink
+          login={comment.authorLogin}
+          avatarUrl={comment.authorAvatarUrl}
+          htmlUrl={comment.authorHtmlUrl}
+        />
+        {pending ? (
+          <span className="text-muted-foreground rounded bg-muted px-1.5 py-0.5 text-[10px]">
+            Pending
+          </span>
+        ) : null}
+        <a
+          href={comment.url}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-muted-foreground hover:underline"
+        >
+          on GitHub
+        </a>
+      </div>
+      <div className="mt-1 pl-7">
+        <GitHubMarkdownBody body={comment.body} collapsible={false} />
+      </div>
+    </div>
+  );
+}
+
+const reviewEventLabels: Record<GitPullRequestReview["state"], string> = {
+  COMMENTED: "commented",
+  APPROVED: "approved",
+  CHANGES_REQUESTED: "requested changes",
+  DISMISSED: "dismissed",
+  PENDING: "pending review",
+};
+
+export function PullRequestReviewItem({
+  review,
+  className,
+}: {
+  review: GitPullRequestReview;
+  className?: string;
+}) {
+  return (
+    <div className={cn("rounded-md border border-border/60 px-3 py-2 text-xs", className)}>
+      <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
+        <AuthorLink
+          login={review.authorLogin}
+          avatarUrl={review.authorAvatarUrl}
+          htmlUrl={review.authorHtmlUrl}
+        />
+        <span className="text-muted-foreground">
+          {reviewEventLabels[review.state]}
+        </span>
+        {review.url ? (
+          <a
+            href={review.url}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="text-muted-foreground hover:underline"
+          >
+            on GitHub
+          </a>
+        ) : null}
+      </div>
+      {review.body?.trim() ? (
+        <div className="mt-1 pl-7">
+          <GitHubMarkdownBody body={review.body} collapsible={false} />
+        </div>
+      ) : null}
+    </div>
+  );
+}
diff --git a/src/components/git/ReviewThreadResolveButton.tsx b/src/components/git/ReviewThreadResolveButton.tsx
new file mode 100644
index 0000000..705d00a
--- /dev/null
+++ b/src/components/git/ReviewThreadResolveButton.tsx
@@ -0,0 +1,41 @@
+import { CheckCircle2, RotateCcw } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+export function ReviewThreadResolveButton({
+  resolved,
+  disabled = false,
+  loading = false,
+  onToggle,
+  className,
+}: {
+  resolved: boolean;
+  disabled?: boolean;
+  loading?: boolean;
+  onToggle: () => void;
+  className?: string;
+}) {
+  return (
+    <Button
+      type="button"
+      variant="ghost"
+      size="sm"
+      className={cn("h-7 gap-1 px-2 text-xs", className)}
+      disabled={disabled || loading}
+      loading={loading}
+      onClick={onToggle}
+    >
+      {resolved ? (
+        <>
+          <RotateCcw className="size-3.5" />
+          Unresolve conversation
+        </>
+      ) : (
+        <>
+          <CheckCircle2 className="size-3.5" />
+          Resolve conversation
+        </>
+      )}
+    </Button>
+  );
+}
diff --git a/src/components/git/ReviewThreadResolvedShell.tsx b/src/components/git/ReviewThreadResolvedShell.tsx
new file mode 100644
index 0000000..40b2774
--- /dev/null
+++ b/src/components/git/ReviewThreadResolvedShell.tsx
@@ -0,0 +1,141 @@
+import { useState, type ReactNode } from "react";
+import { CheckCircle2, ChevronDown } from "lucide-react";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import { Button } from "~/components/ui/button";
+import { cn } from "~/lib/utils";
+
+function hiddenCommentsLabel(count: number, authorLogin?: string | null): string {
+  const hidden =
+    count <= 1 ? "1 comment hidden" : `${count} comments hidden`;
+  if (authorLogin) {
+    return `${hidden} · ${authorLogin} marked this conversation as resolved`;
+  }
+  return `${hidden} · Marked as resolved`;
+}
+
+export function ReviewThreadResolvedShell({
+  resolved,
+  commentCount,
+  children,
+  canResolve = false,
+  resolveLoading = false,
+  onToggleResolved,
+  authorLogin = null,
+  variant = "feed",
+  className,
+}: {
+  resolved: boolean;
+  commentCount: number;
+  children: ReactNode;
+  authorLogin?: string | null;
+  canResolve?: boolean;
+  resolveLoading?: boolean;
+  onToggleResolved?: () => void;
+  /** `diff` uses Pierre-adjacent colors; `feed` uses app theme tokens. */
+  variant?: "diff" | "feed";
+  className?: string;
+}) {
+  const [open, setOpen] = useState(false);
+
+  if (!resolved) {
+    return <div className={className}>{children}</div>;
+  }
+
+  const bannerClass =
+    variant === "diff"
+      ? cn(
+          "border-green-700/35 bg-green-950/40 text-green-100",
+          "dark:border-green-500/30 dark:bg-green-950/60"
+        )
+      : cn(
+          "border-green-600/25 bg-green-50 text-green-950",
+          "dark:border-green-500/20 dark:bg-green-950/45 dark:text-green-50"
+        );
+
+  const unresolveClass =
+    variant === "diff"
+      ? "text-green-300 hover:text-green-100"
+      : "text-green-800 hover:text-green-950 dark:text-green-300 dark:hover:text-green-50";
+
+  return (
+    <Collapsible
+      open={open}
+      onOpenChange={setOpen}
+      className={cn(
+        "min-w-0",
+        variant === "diff" && "font-sans antialiased",
+        className
+      )}
+    >
+      <div
+        className={cn(
+          "flex items-center gap-2 rounded-md border px-2.5 py-2",
+          bannerClass
+        )}
+      >
+        <CheckCircle2
+          className={cn(
+            "size-4 shrink-0",
+            variant === "diff"
+              ? "fill-green-500 text-green-950"
+              : "fill-green-600 text-white dark:fill-green-500 dark:text-green-950"
+          )}
+          aria-hidden
+        />
+        <CollapsibleTrigger
+          className={cn(
+            "flex min-w-0 flex-1 cursor-pointer items-center gap-1 text-left text-xs font-medium leading-snug",
+            variant === "diff"
+              ? "text-green-100 hover:text-white"
+              : "text-green-900 hover:text-green-950 dark:text-green-100 dark:hover:text-white"
+          )}
+        >
+          <ChevronDown
+            className={cn(
+              "size-3.5 shrink-0 transition-transform",
+              open && "rotate-180"
+            )}
+          />
+          <span className="min-w-0 text-pretty">
+            {hiddenCommentsLabel(commentCount, authorLogin)}
+          </span>
+        </CollapsibleTrigger>
+        {canResolve && onToggleResolved ? (
+          <Button
+            type="button"
+            variant="link"
+            size="sm"
+            className={cn(
+              "h-auto shrink-0 px-1 text-xs font-medium",
+              unresolveClass
+            )}
+            disabled={resolveLoading}
+            loading={resolveLoading}
+            onClick={(e) => {
+              e.preventDefault();
+              e.stopPropagation();
+              onToggleResolved();
+            }}
+          >
+            Unresolve conversation
+          </Button>
+        ) : null}
+      </div>
+      <CollapsibleContent>
+        <div
+          className={cn(
+            "mt-2 space-y-2.5",
+            variant === "feed" &&
+              "rounded-md border border-border/50 bg-background/60 p-2"
+          )}
+        >
+          {children}
+        </div>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}
diff --git a/src/components/git/TaskDevelopmentSection.tsx b/src/components/git/TaskDevelopmentSection.tsx
new file mode 100644
index 0000000..a7fc758
--- /dev/null
+++ b/src/components/git/TaskDevelopmentSection.tsx
@@ -0,0 +1,643 @@
+import { useEffect, useState } from "react";
+import {
+  ChevronDown,
+  Copy,
+  ExternalLink,
+  GitPullRequest,
+  Link2,
+  Play,
+  Unlink,
+} from "lucide-react";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "~/components/ui/select";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import { useTaskGitContextQuery } from "~/db/queries/git";
+import {
+  useCreateTaskPullRequestMutation,
+  useDisconnectTaskGitBranchMutation,
+  useLinkTaskPullRequestMutation,
+  useStartTaskDevelopmentMutation,
+} from "~/db/mutations/git";
+import { MULTI_REPO_SELECTION_MESSAGE } from "~/lib/git/constants";
+import { useGitTaskLiveSync } from "~/hooks/use-git-task-live-sync";
+import { formatClientError } from "~/lib/git/errors";
+import { PullRequestDescriptionPanel } from "~/components/git/PullRequestDescriptionPanel";
+import { TaskDevelopmentWorkspace } from "~/components/git/TaskDevelopmentWorkspace";
+import {
+  getActiveBranch,
+  getOpenPr,
+  getTaskDevPhase,
+} from "~/lib/git/task-dev-phase";
+import { cn } from "~/lib/utils";
+import { toast } from "sonner";
+
+type StepState = "pending" | "active" | "done";
+
+function PipelineStep({
+  label,
+  detail,
+  state,
+}: {
+  label: string;
+  detail: string;
+  state: StepState;
+}) {
+  return (
+    <div className="min-w-0 flex-1">
+      <div className="flex items-center gap-1.5">
+        <span
+          className={cn(
+            "size-2 shrink-0 rounded-full",
+            state === "done" && "bg-primary",
+            state === "active" && "bg-primary/60 ring-2 ring-primary/30",
+            state === "pending" && "bg-muted-foreground/30"
+          )}
+        />
+        <span className="text-muted-foreground text-xs font-medium">{label}</span>
+      </div>
+      <p className="mt-0.5 truncate pl-3.5 text-xs">{detail}</p>
+    </div>
+  );
+}
+
+export function TaskDevelopmentSection({
+  taskId,
+  taskName,
+  projectId,
+  layout = "inline",
+}: {
+  taskId: string;
+  taskName: string;
+  projectId: string;
+  /** `panel` fills the development tab column; `inline` is the legacy card style. */
+  layout?: "inline" | "panel";
+}) {
+  const isPanel = layout === "panel";
+  const { data, isLoading } = useTaskGitContextQuery(taskId);
+  useGitTaskLiveSync(taskId);
+  const startDevelopment = useStartTaskDevelopmentMutation();
+  const disconnectBranch = useDisconnectTaskGitBranchMutation();
+  const createPr = useCreateTaskPullRequestMutation();
+  const linkPr = useLinkTaskPullRequestMutation();
+  const [prUrl, setPrUrl] = useState("");
+  const [selectedRepositoryId, setSelectedRepositoryId] = useState("");
+  const [busy, setBusy] = useState(false);
+  const [advancedOpen, setAdvancedOpen] = useState(false);
+  const [gitCommandsOpen, setGitCommandsOpen] = useState(false);
+
+  const projectRepos = data?.projectRepos ?? [];
+  const branches = data?.branches ?? [];
+  const pullRequests = data?.pullRequests ?? [];
+  const branch = data
+    ? getActiveBranch({ projectRepos, branches, pullRequests })
+    : undefined;
+
+  useEffect(() => {
+    if (!data) return;
+    if (branch) {
+      setSelectedRepositoryId(branch.repositoryId);
+      return;
+    }
+    if (projectRepos.length === 1) {
+      setSelectedRepositoryId(projectRepos[0]!.repositoryId);
+    }
+  }, [data, branch?.repositoryId, branch?.id, projectRepos]);
+
+  if (isLoading || !data) return null;
+
+  const { taskKey, activity } = data;
+  const phase = getTaskDevPhase({
+    projectRepos,
+    branches,
+    pullRequests,
+  });
+  const pr = pullRequests[0];
+  const openPr = getOpenPr({ projectRepos, branches, pullRequests });
+  const displayPr = openPr ?? pr;
+  const linkedRepo = branch?.repository;
+  const multipleRepos = projectRepos.length > 1;
+  const commitCount = activity.filter((a) => a.type === "commit").length;
+
+  const copy = (text: string, label: string) => {
+    void navigator.clipboard.writeText(text);
+    toast.success(`${label} copied`);
+  };
+
+  const checkoutCommand = branch
+    ? `git fetch origin && git checkout ${branch.ref}`
+    : null;
+
+  const branchStep: StepState = branch ? "done" : "pending";
+  const commitStep: StepState =
+    commitCount > 0 ? "done" : branch ? "active" : "pending";
+  const prStep: StepState = pr
+    ? openPr
+      ? "active"
+      : "done"
+    : branch
+      ? "active"
+      : "pending";
+  const reviewStep: StepState = openPr ? "active" : pr ? "done" : "pending";
+
+  if (projectRepos.length === 0) {
+    return (
+      <section
+        className={cn(
+          "px-4 py-3",
+          isPanel
+            ? "flex h-full min-h-0 flex-col justify-center"
+            : "rounded-lg border border-border/60 bg-muted/30"
+        )}
+      >
+        {!isPanel ? <h3 className="text-sm font-medium">Development</h3> : null}
+        <p className="text-muted-foreground mt-1 text-sm">
+          Link a repository in{" "}
+          <a
+            href={`/projects/${projectId}/settings`}
+            className="underline"
+          >
+            project settings
+          </a>{" "}
+          to start development on this task.
+        </p>
+      </section>
+    );
+  }
+
+  return (
+    <section
+      className={cn(
+        "flex min-h-0 flex-col",
+        isPanel ? "h-full" : "rounded-lg border border-border/60 bg-muted/30"
+      )}
+    >
+      {!isPanel ? (
+        <>
+          <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-4 py-3">
+            <div className="flex flex-wrap items-center gap-2">
+              <h3 className="text-sm font-medium">Development</h3>
+              <Badge variant="secondary" className="font-mono text-xs">
+                {taskKey}
+              </Badge>
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon"
+                className="size-7"
+                aria-label="Copy task key"
+                onClick={() => copy(taskKey, "Task key")}
+              >
+                <Copy className="size-3.5" />
+              </Button>
+            </div>
+            {linkedRepo ? (
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                className="h-7"
+                asChild
+              >
+                <a href={linkedRepo.htmlUrl} target="_blank" rel="noreferrer">
+                  {linkedRepo.fullName}
+                  <ExternalLink className="size-3.5" />
+                </a>
+              </Button>
+            ) : null}
+          </div>
+
+          <div className="flex gap-2 border-b border-border/60 px-4 py-3">
+            <PipelineStep
+              label="Branch"
+              detail={
+                branch
+                  ? multipleRepos
+                    ? `${branch.repository.fullName} · ${branch.ref}`
+                    : branch.ref
+                  : "Not started"
+              }
+              state={branchStep}
+            />
+            <PipelineStep
+              label="Commits"
+              detail={
+                commitCount > 0
+                  ? `${commitCount} on branch`
+                  : branch
+                    ? "Push to branch"
+                    : "—"
+              }
+              state={commitStep}
+            />
+            <PipelineStep
+              label="Pull request"
+              detail={
+                pr ? `#${pr.number} ${pr.state}` : branch ? "Not opened" : "—"
+              }
+              state={prStep}
+            />
+            <PipelineStep
+              label="Review"
+              detail={openPr ? "Ready" : pr ? "Closed" : "—"}
+              state={reviewStep}
+            />
+          </div>
+        </>
+      ) : isPanel && displayPr ? null : (
+        <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
+          <p className="text-muted-foreground min-w-0 truncate text-xs">
+            {branch
+              ? multipleRepos
+                ? `${branch.repository.fullName} · ${branch.ref}`
+                : branch.ref
+              : "No branch"}
+            {commitCount > 0 ? ` · ${commitCount} commit${commitCount === 1 ? "" : "s"}` : ""}
+            {pr && !displayPr ? ` · PR #${pr.number} ${pr.state}` : ""}
+          </p>
+          {linkedRepo ? (
+            <Button
+              type="button"
+              variant="ghost"
+              size="sm"
+              className="h-7 shrink-0 text-xs"
+              asChild
+            >
+              <a href={linkedRepo.htmlUrl} target="_blank" rel="noreferrer">
+                {linkedRepo.fullName}
+                <ExternalLink className="size-3.5" />
+              </a>
+            </Button>
+          ) : null}
+        </div>
+      )}
+
+      {displayPr ? (
+        <PullRequestDescriptionPanel
+          taskId={taskId}
+          pullRequestId={displayPr.id}
+          repository={
+            linkedRepo
+              ? { fullName: linkedRepo.fullName, htmlUrl: linkedRepo.htmlUrl }
+              : undefined
+          }
+          headerActions={
+            branch ? (
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                className="h-8"
+                disabled={busy}
+                onClick={async () => {
+                  if (
+                    !window.confirm(
+                      `Disconnect ${branch.repository.fullName} from this task? Linked pull requests for this repo will be removed. The branch stays on GitHub.`
+                    )
+                  ) {
+                    return;
+                  }
+                  setBusy(true);
+                  try {
+                    await disconnectBranch({ taskId, branchId: branch.id });
+                    toast.success("Disconnected from repository");
+                  } catch (e) {
+                    toast.error(formatClientError(e, "Failed to disconnect"));
+                  } finally {
+                    setBusy(false);
+                  }
+                }}
+              >
+                <Unlink className="size-3.5" />
+                Disconnect
+              </Button>
+            ) : null
+          }
+        />
+      ) : null}
+
+      {isPanel && displayPr ? null : (
+      <div
+        className={cn(
+          "flex flex-col gap-3",
+          isPanel ? "px-3 py-2" : "px-4 py-3"
+        )}
+      >
+        {!branch && multipleRepos ? (
+          <div className="space-y-1.5">
+            <Label htmlFor="task-dev-repo" className="text-xs">
+              Repository
+            </Label>
+            <Select
+              value={selectedRepositoryId}
+              onValueChange={setSelectedRepositoryId}
+            >
+              <SelectTrigger id="task-dev-repo" className="h-8 w-full max-w-md">
+                <SelectValue placeholder="Select repository" />
+              </SelectTrigger>
+              <SelectContent>
+                {projectRepos.map((link) => (
+                  <SelectItem
+                    key={link.repositoryId}
+                    value={link.repositoryId}
+                  >
+                    {link.repository.fullName}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+        ) : null}
+
+        <div className="flex flex-wrap items-center gap-2">
+        {!branch ? (
+          <Button
+            type="button"
+            disabled={busy || (multipleRepos && !selectedRepositoryId)}
+            onClick={async () => {
+              setBusy(true);
+              try {
+                const result = await startDevelopment({
+                  taskId,
+                  repositoryId: selectedRepositoryId || undefined,
+                });
+                void navigator.clipboard.writeText(result.checkoutCommand);
+                toast.success("Development started — checkout command copied");
+              } catch (e) {
+                const message = formatClientError(
+                  e,
+                  "Failed to start development"
+                );
+                toast.error(
+                  message.includes(MULTI_REPO_SELECTION_MESSAGE)
+                    ? "Select a repository first"
+                    : message
+                );
+              } finally {
+                setBusy(false);
+              }
+            }}
+          >
+            <Play className="size-3.5" />
+            Start development
+          </Button>
+        ) : !openPr && !pr ? (
+          <>
+            <Button
+              type="button"
+              variant="default"
+              size="sm"
+              disabled={busy}
+              onClick={async () => {
+                setBusy(true);
+                try {
+                  await createPr(taskId);
+                  toast.success("Pull request created");
+                } catch (e) {
+                  toast.error(
+                    formatClientError(e, "Failed to create pull request")
+                  );
+                } finally {
+                  setBusy(false);
+                }
+              }}
+            >
+              <GitPullRequest className="size-3.5" />
+              Create pull request
+            </Button>
+            {branch.repository && (
+              <Button type="button" variant="outline" size="sm" asChild>
+                <a
+                  href={`${branch.repository.htmlUrl}/tree/${branch.ref}`}
+                  target="_blank"
+                  rel="noreferrer"
+                >
+                  Open branch on GitHub
+                  <ExternalLink className="size-3.5" />
+                </a>
+              </Button>
+            )}
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              disabled={busy}
+              onClick={async () => {
+                if (
+                  !window.confirm(
+                    `Disconnect ${branch.ref} on ${branch.repository.fullName} from this task? The branch will remain on GitHub.`
+                  )
+                ) {
+                  return;
+                }
+                setBusy(true);
+                try {
+                  await disconnectBranch({ taskId, branchId: branch.id });
+                  toast.success("Branch disconnected from task");
+                } catch (e) {
+                  toast.error(
+                    formatClientError(e, "Failed to disconnect branch")
+                  );
+                } finally {
+                  setBusy(false);
+                }
+              }}
+            >
+              <Unlink className="size-3.5" />
+              Disconnect
+            </Button>
+          </>
+        ) : pr && branch && !displayPr ? (
+          <>
+            <Button type="button" variant="outline" size="sm" asChild>
+              <a href={pr.url} target="_blank" rel="noreferrer">
+                Open on GitHub
+                <ExternalLink className="size-3.5" />
+              </a>
+            </Button>
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              disabled={busy}
+              onClick={async () => {
+                if (
+                  !window.confirm(
+                    `Disconnect ${branch.repository.fullName} from this task? Linked pull requests for this repo will be removed. The branch stays on GitHub.`
+                  )
+                ) {
+                  return;
+                }
+                setBusy(true);
+                try {
+                  await disconnectBranch({ taskId, branchId: branch.id });
+                  toast.success("Disconnected from repository");
+                } catch (e) {
+                  toast.error(formatClientError(e, "Failed to disconnect"));
+                } finally {
+                  setBusy(false);
+                }
+              }}
+            >
+              <Unlink className="size-3.5" />
+              Disconnect
+            </Button>
+          </>
+        ) : null}
+        </div>
+      </div>
+      )}
+
+      {branch && checkoutCommand ? (
+        isPanel ? (
+          <Collapsible
+            open={gitCommandsOpen}
+            onOpenChange={setGitCommandsOpen}
+            className="border-t border-border/60"
+          >
+            <CollapsibleTrigger className="text-muted-foreground flex w-full items-center justify-between px-3 py-2 text-xs hover:bg-muted/40">
+              Git commands
+              <ChevronDown
+                className={cn(
+                  "size-3.5 transition-transform",
+                  gitCommandsOpen && "rotate-180"
+                )}
+              />
+            </CollapsibleTrigger>
+            <CollapsibleContent className="space-y-2 px-3 pb-3">
+              <div className="flex items-start gap-2">
+                <code className="bg-background/80 flex-1 truncate rounded border px-2 py-1.5 text-xs">
+                  {checkoutCommand}
+                </code>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  aria-label="Copy checkout command"
+                  onClick={() => copy(checkoutCommand, "Checkout command")}
+                >
+                  <Copy className="size-3.5" />
+                </Button>
+              </div>
+              <div className="flex items-start gap-2">
+                <code className="bg-background/80 flex-1 truncate rounded border px-2 py-1.5 text-xs">
+                  {branch.ref}
+                </code>
+                <Button
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  aria-label="Copy branch name"
+                  onClick={() => copy(branch.ref, "Branch name")}
+                >
+                  <Copy className="size-3.5" />
+                </Button>
+              </div>
+            </CollapsibleContent>
+          </Collapsible>
+        ) : (
+          <div className="space-y-2 border-t border-border/60 px-4 py-3">
+            <div className="flex items-start gap-2">
+              <code className="bg-background/80 flex-1 truncate rounded border px-2 py-1.5 text-xs">
+                {checkoutCommand}
+              </code>
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                aria-label="Copy checkout command"
+                onClick={() => copy(checkoutCommand, "Checkout command")}
+              >
+                <Copy className="size-3.5" />
+              </Button>
+            </div>
+            <div className="flex items-start gap-2">
+              <code className="bg-background/80 flex-1 truncate rounded border px-2 py-1.5 text-xs">
+                {branch.ref}
+              </code>
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                aria-label="Copy branch name"
+                onClick={() => copy(branch.ref, "Branch name")}
+              >
+                <Copy className="size-3.5" />
+              </Button>
+            </div>
+          </div>
+        )
+      ) : null}
+
+      {phase !== "no_repo" && phase !== "not_started" ? (
+        <TaskDevelopmentWorkspace
+          taskId={taskId}
+          gitContext={{ projectRepos, branches, pullRequests }}
+          isPanel={isPanel}
+          className={isPanel ? "min-h-0 flex-1" : undefined}
+        />
+      ) : null}
+
+      <Collapsible
+        open={advancedOpen}
+        onOpenChange={setAdvancedOpen}
+        className="border-t border-border/60"
+      >
+        <CollapsibleTrigger className="text-muted-foreground flex w-full items-center justify-between px-4 py-2 text-xs hover:bg-muted/40">
+          Advanced
+          <ChevronDown
+            className={cn(
+              "size-3.5 transition-transform",
+              advancedOpen && "rotate-180"
+            )}
+          />
+        </CollapsibleTrigger>
+        <CollapsibleContent className="px-4 pb-3">
+          <div className="flex gap-2">
+            <Input
+              placeholder="Paste PR URL"
+              value={prUrl}
+              onChange={(e) => setPrUrl(e.target.value)}
+              className="h-8 text-sm"
+            />
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              disabled={!prUrl || busy}
+              onClick={async () => {
+                setBusy(true);
+                try {
+                  await linkPr({ taskId, prUrl });
+                  setPrUrl("");
+                  toast.success("Pull request linked");
+                } catch (e) {
+                  toast.error(
+                    formatClientError(e, "Failed to link pull request")
+                  );
+                } finally {
+                  setBusy(false);
+                }
+              }}
+            >
+              <Link2 className="size-3.5" />
+              Link
+            </Button>
+          </div>
+        </CollapsibleContent>
+      </Collapsible>
+    </section>
+  );
+}
diff --git a/src/components/git/TaskDevelopmentWorkspace.tsx b/src/components/git/TaskDevelopmentWorkspace.tsx
new file mode 100644
index 0000000..7f6a1d4
--- /dev/null
+++ b/src/components/git/TaskDevelopmentWorkspace.tsx
@@ -0,0 +1,259 @@
+import { useEffect, useState } from "react";
+import { PhaseBanner } from "~/components/git/PhaseBanner";
+import { CommitList } from "~/components/git/CommitList";
+import { DiffViewer } from "~/components/git/DiffViewer";
+import { DiffReviewPanel } from "~/components/git/DiffReviewPanel";
+import { DiffLoadingSkeleton } from "~/components/git/review/DiffLoadingSkeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
+import { PullRequestStateBadge } from "~/components/git/PullRequestStateBadge";
+import {
+  fetchTaskPullRequestDiff,
+  useTaskBranchCommitsQuery,
+  useTaskBranchDiffQuery,
+  useTaskCommitDiffQuery,
+  useTaskPullRequestCommitsQuery,
+} from "~/db/queries/git";
+import { useQuery } from "@tanstack/react-query";
+import {
+  canReviewInline,
+  getActiveBranch,
+  getLatestPr,
+  getOpenPr,
+  getTaskDevPhase,
+  type TaskDevPhaseContext,
+} from "~/lib/git/task-dev-phase";
+import { useTaskGitReviewNav } from "~/lib/git/task-git-review-nav";
+import { cn } from "~/lib/utils";
+
+type WorkspaceTab = "pull_request" | "commits" | "branch";
+
+export function TaskDevelopmentWorkspace({
+  taskId,
+  gitContext,
+  isPanel,
+  className,
+}: {
+  taskId: string;
+  gitContext: TaskDevPhaseContext & {
+    pullRequests: Array<{
+      id: string;
+      number: number;
+      state: string;
+      baseRef: string;
+      headRef: string;
+      url: string;
+    }>;
+  };
+  isPanel: boolean;
+  className?: string;
+}) {
+  const phase = getTaskDevPhase(gitContext);
+  const branch = getActiveBranch(gitContext);
+  const openPr = getOpenPr(gitContext);
+  const latestPr = getLatestPr(gitContext);
+  const reviewPr = openPr ?? latestPr;
+
+  const [tab, setTab] = useState<WorkspaceTab>("commits");
+  const [selectedSha, setSelectedSha] = useState<string | null>(null);
+  const gitReviewNav = useTaskGitReviewNav();
+
+  useEffect(() => {
+    if (phase === "open_pr" || phase === "closed_pr") {
+      setTab("pull_request");
+    } else if (phase === "branch_only") {
+      setTab("commits");
+    }
+    setSelectedSha(null);
+  }, [phase, taskId]);
+
+  useEffect(() => {
+    if (gitReviewNav?.lineFocus) {
+      setTab("pull_request");
+    }
+  }, [gitReviewNav?.lineFocus]);
+
+  const showPrTab = Boolean(reviewPr);
+  const showBranchTab = Boolean(branch);
+  const showCommitsTab = Boolean(branch || reviewPr);
+  const readOnlyReview = !canReviewInline(phase);
+
+  const usePrCommitsList =
+    tab === "commits" &&
+    Boolean(reviewPr) &&
+    (phase === "open_pr" || phase === "closed_pr");
+
+  const branchCommitsQuery = useTaskBranchCommitsQuery(
+    taskId,
+    tab === "commits" && Boolean(branch) && !usePrCommitsList
+  );
+  const prCommitsQuery = useTaskPullRequestCommitsQuery(
+    taskId,
+    reviewPr?.id,
+    usePrCommitsList
+  );
+
+  const commits = usePrCommitsList
+    ? (prCommitsQuery.data?.commits ?? [])
+    : (branchCommitsQuery.data?.commits ?? []);
+
+  const prDiffQuery = useQuery({
+    queryKey: ["git", "diff", taskId, "pr", reviewPr?.id],
+    queryFn: () =>
+      fetchTaskPullRequestDiff({
+        data: { taskId, pullRequestId: reviewPr!.id },
+      }),
+    enabled: tab === "pull_request" && Boolean(reviewPr),
+  });
+
+  const branchDiffQuery = useTaskBranchDiffQuery(
+    taskId,
+    tab === "branch" && Boolean(branch)
+  );
+
+  const commitDiffQuery = useTaskCommitDiffQuery(
+    taskId,
+    selectedSha ?? undefined,
+    tab === "commits" && Boolean(selectedSha)
+  );
+
+  useEffect(() => {
+    if (commits.length > 0 && !selectedSha && tab === "commits") {
+      setSelectedSha(commits[0].sha);
+    }
+  }, [commits, selectedSha, tab]);
+
+  if (phase === "no_repo" || phase === "not_started") {
+    return <PhaseBanner phase={phase} />;
+  }
+
+  const scopeLabel =
+    tab === "pull_request" && reviewPr
+      ? `PR #${reviewPr.number} vs ${reviewPr.baseRef}`
+      : tab === "branch" && branchDiffQuery.data
+        ? `Branch ${branchDiffQuery.data.headRef} vs ${branchDiffQuery.data.baseRef}`
+        : selectedSha
+          ? `Commit ${selectedSha.slice(0, 7)}`
+          : "Changes";
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-0 flex-col border-t border-border/60",
+        isPanel && "flex-1",
+        className
+      )}
+    >
+      {!isPanel ? <PhaseBanner phase={phase} /> : null}
+
+      <Tabs
+        value={tab}
+        onValueChange={(v) => setTab(v as WorkspaceTab)}
+        className="flex min-h-0 flex-1 flex-col gap-0"
+      >
+        <TabsList className="h-9 w-full justify-start rounded-none border-b border-border/60 bg-transparent px-4">
+          {showPrTab ? (
+            <TabsTrigger value="pull_request" className="text-xs">
+              Pull request
+              {openPr ? (
+                <PullRequestStateBadge state={openPr.state} className="ml-1.5" />
+              ) : null}
+            </TabsTrigger>
+          ) : null}
+          {showCommitsTab ? (
+            <TabsTrigger value="commits" className="text-xs">
+              Commits
+            </TabsTrigger>
+          ) : null}
+          {showBranchTab ? (
+            <TabsTrigger value="branch" className="text-xs">
+              Branch diff
+            </TabsTrigger>
+          ) : null}
+        </TabsList>
+
+        <div className="flex min-h-0 min-w-0 flex-1 flex-col">
+            <TabsContent
+              value="pull_request"
+              className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
+            >
+              {prDiffQuery.isLoading ? (
+                <DiffLoadingSkeleton className="flex-1 border-t-0" />
+              ) : prDiffQuery.data && reviewPr ? (
+                <DiffReviewPanel
+                  taskId={taskId}
+                  pullRequestId={reviewPr.id}
+                  scopeLabel={scopeLabel}
+                  files={prDiffQuery.data.files}
+                  headSha={prDiffQuery.data.headSha}
+                  readOnly={readOnlyReview}
+                  compactToolbar={isPanel}
+                  className="flex-1"
+                />
+              ) : null}
+            </TabsContent>
+
+            <TabsContent
+              value="commits"
+              className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
+            >
+              <div className="bg-muted/20 shrink-0 border-b border-border/60">
+                <div className="text-muted-foreground border-b border-border/60 px-3 py-1.5 text-[10px] font-medium tracking-wide uppercase">
+                  Commits
+                </div>
+                <CommitList
+                  layout="strip"
+                  commits={commits}
+                  selectedSha={selectedSha}
+                  onSelect={setSelectedSha}
+                />
+              </div>
+              <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+                {commitDiffQuery.isLoading ? (
+                  <p className="text-muted-foreground p-4 text-sm">Loading diff…</p>
+                ) : commitDiffQuery.data ? (
+                  <>
+                    {!isPanel ? (
+                      <p className="shrink-0 border-b border-border/60 px-4 py-2 text-sm font-medium">
+                        {scopeLabel}
+                      </p>
+                    ) : null}
+                    <DiffViewer
+                      files={commitDiffQuery.data.files}
+                      readOnly
+                      className="min-h-0 flex-1 border-t-0"
+                    />
+                  </>
+                ) : (
+                  <p className="text-muted-foreground p-4 text-sm">
+                    Select a commit to view its diff.
+                  </p>
+                )}
+              </div>
+            </TabsContent>
+
+            <TabsContent
+              value="branch"
+              className="mt-0 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
+            >
+              {branchDiffQuery.isLoading ? (
+                <p className="text-muted-foreground p-4 text-sm">Loading diff…</p>
+              ) : branchDiffQuery.data ? (
+                <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
+                  {!isPanel ? (
+                    <p className="shrink-0 border-b border-border/60 px-4 py-2 text-sm font-medium">
+                      {scopeLabel}
+                    </p>
+                  ) : null}
+                  <DiffViewer
+                    files={branchDiffQuery.data.files}
+                    readOnly
+                    className="min-h-0 flex-1 border-t-0"
+                  />
+                </div>
+              ) : null}
+            </TabsContent>
+        </div>
+      </Tabs>
+    </div>
+  );
+}
diff --git a/src/components/git/TaskGitActivityFeed.tsx b/src/components/git/TaskGitActivityFeed.tsx
new file mode 100644
index 0000000..b359295
--- /dev/null
+++ b/src/components/git/TaskGitActivityFeed.tsx
@@ -0,0 +1,84 @@
+import { GitBranch } from "lucide-react";
+import { TaskLabel } from "~/components/ui/TaskLabel";
+import { useTaskGitContextQuery } from "~/db/queries/git";
+import { useGitTaskLiveSync } from "~/hooks/use-git-task-live-sync";
+import {
+  formatDateTimeTooltip,
+  formatRelativeTime,
+} from "~/lib/format-relative-time";
+import { formatGitActivityLabel } from "~/lib/git/activity-label";
+import { cn } from "~/lib/utils";
+
+export function TaskGitActivityFeed({
+  taskId,
+  className,
+}: {
+  taskId: string;
+  className?: string;
+}) {
+  const { data, isLoading } = useTaskGitContextQuery(taskId);
+  useGitTaskLiveSync(taskId);
+
+  const activity = data?.activity ?? [];
+
+  return (
+    <div
+      className={cn(
+        "flex h-full min-h-0 flex-col gap-3 px-4 py-4",
+        className
+      )}
+    >
+      <TaskLabel>Activity</TaskLabel>
+      <div className="min-h-0 flex-1 overflow-y-auto">
+        {isLoading ? (
+          <p className="text-muted-foreground text-sm">Loading…</p>
+        ) : activity.length === 0 ? (
+          <p className="text-muted-foreground text-sm">
+            Commits, branches, and pull requests linked to this task will appear
+            here.
+          </p>
+        ) : (
+          <ul className="space-y-4">
+            {activity.map((a) => {
+              const occurredAt = new Date(a.occurredAt);
+              const label = formatGitActivityLabel(
+                a.type,
+                a.payload,
+                a.actorLogin
+              );
+              const url =
+                typeof a.payload.url === "string" ? a.payload.url : null;
+
+              return (
+                <li key={a.id} className="flex gap-2.5 text-sm">
+                  <GitBranch className="text-foreground/50 mt-0.5 size-3.5 shrink-0" />
+                  <div className="min-w-0 space-y-0.5">
+                    {url ? (
+                      <a
+                        href={url}
+                        target="_blank"
+                        rel="noreferrer"
+                        className="text-foreground leading-snug underline-offset-2 hover:underline"
+                      >
+                        {label}
+                      </a>
+                    ) : (
+                      <p className="text-foreground leading-snug">{label}</p>
+                    )}
+                    <time
+                      className="text-muted-foreground block text-xs"
+                      dateTime={occurredAt.toISOString()}
+                      title={formatDateTimeTooltip(occurredAt)}
+                    >
+                      {formatRelativeTime(occurredAt)}
+                    </time>
+                  </div>
+                </li>
+              );
+            })}
+          </ul>
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/git/TaskPullRequestReviewFeed.tsx b/src/components/git/TaskPullRequestReviewFeed.tsx
new file mode 100644
index 0000000..6a03ed0
--- /dev/null
+++ b/src/components/git/TaskPullRequestReviewFeed.tsx
@@ -0,0 +1,212 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import {
+  PrReviewFeedItemCommentThread,
+  PrReviewFeedItemIssueComment,
+  PrReviewFeedItemReview,
+} from "~/components/git/PrReviewFeedItem";
+import { PullRequestMergePanel } from "~/components/git/PullRequestMergePanel";
+import { TaskLabel } from "~/components/ui/TaskLabel";
+import { LoadingSpinner } from "~/components/ui/loading-spinner";
+import { useSetTaskPullRequestReviewThreadResolvedMutation } from "~/db/mutations/git";
+import {
+  useGitConnectionQuery,
+  useTaskGitContextQuery,
+  useTaskPullRequestReviewCommentsQuery,
+} from "~/db/queries/git";
+import { useGitTaskLiveSync } from "~/hooks/use-git-task-live-sync";
+import { formatClientError } from "~/lib/git/errors";
+import { buildPrReviewFeedEntries } from "~/lib/git/pr-review-feed-entries";
+import { getLatestPr, getOpenPr } from "~/lib/git/task-dev-phase";
+import { cn } from "~/lib/utils";
+
+export function TaskPullRequestReviewFeed({
+  taskId,
+  className,
+}: {
+  taskId: string;
+  className?: string;
+}) {
+  const { data: gitContext, isLoading: contextLoading } =
+    useTaskGitContextQuery(taskId);
+  useGitTaskLiveSync(taskId);
+
+  const reviewPr = gitContext
+    ? (getOpenPr(gitContext) ?? getLatestPr(gitContext))
+    : null;
+
+  const { data: connectionData } = useGitConnectionQuery();
+  const {
+    data: reviewData,
+    isLoading: commentsLoading,
+    isFetching,
+    refetch,
+  } = useTaskPullRequestReviewCommentsQuery(
+    taskId,
+    reviewPr?.id,
+    Boolean(connectionData?.connection)
+  );
+  const setThreadResolved =
+    useSetTaskPullRequestReviewThreadResolvedMutation();
+  const [resolvingThreadNodeId, setResolvingThreadNodeId] = useState<
+    string | null
+  >(null);
+
+  const canResolve = Boolean(connectionData?.userLink) && Boolean(reviewPr);
+
+  const isLoading = contextLoading || commentsLoading;
+
+  const handleToggleThreadResolved = async (
+    threadNodeId: string,
+    resolved: boolean
+  ) => {
+    if (!reviewPr) return;
+    setResolvingThreadNodeId(threadNodeId);
+    try {
+      await setThreadResolved({
+        taskId,
+        pullRequestId: reviewPr.id,
+        threadNodeId,
+        resolved,
+      });
+      toast.success(
+        resolved ? "Conversation resolved" : "Conversation unresolved"
+      );
+      await refetch();
+    } catch (e) {
+      toast.error(
+        formatClientError(e, "Failed to update conversation on GitHub")
+      );
+    } finally {
+      setResolvingThreadNodeId(null);
+    }
+  };
+  const entries = reviewData
+    ? buildPrReviewFeedEntries(
+        reviewData.reviews,
+        reviewData.comments,
+        reviewData.pendingComments,
+        reviewData.issueComments
+      )
+    : [];
+
+  const scrollRef = useRef<HTMLDivElement>(null);
+
+  useLayoutEffect(() => {
+    const el = scrollRef.current;
+    if (!el || isLoading || entries.length === 0) return;
+    el.scrollTop = el.scrollHeight;
+  }, [entries, isLoading]);
+
+  return (
+    <div
+      className={cn(
+        "flex h-full min-h-0 flex-col gap-3 px-4 py-4",
+        className
+      )}
+    >
+      <div className="flex items-center justify-between gap-2">
+        <TaskLabel>Pull request</TaskLabel>
+        {isFetching && !isLoading ? (
+          <LoadingSpinner isActive className="size-3.5" />
+        ) : null}
+      </div>
+
+      <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
+        {isLoading ? (
+          <p className="text-muted-foreground text-sm">Loading…</p>
+        ) : !reviewPr ? (
+          <p className="text-muted-foreground text-sm">
+            Link or open a pull request to see review comments and feedback from
+            GitHub here.
+          </p>
+        ) : !connectionData?.connection ? (
+          <p className="text-muted-foreground text-sm">
+            Connect the GitHub app to load pull request activity.
+          </p>
+        ) : entries.length === 0 ? (
+          <p className="text-muted-foreground text-sm">
+            {connectionData.userLink
+              ? "No reviews or comments yet."
+              : "No reviews or comments yet. Connect your GitHub account in settings to see pending reviews and post comments."}
+          </p>
+        ) : (
+          <>
+            {!connectionData.userLink ? (
+              <p className="text-muted-foreground mb-2 text-xs">
+                Connect GitHub in settings to see pending reviews and post
+                comments.
+              </p>
+            ) : null}
+            <ul className="space-y-2.5">
+            {entries.map((entry) => {
+              if (entry.kind === "issue_comment") {
+                return (
+                  <li key={`issue-comment-${entry.comment.id}`}>
+                    <PrReviewFeedItemIssueComment
+                      comment={entry.comment}
+                      occurredAt={entry.at}
+                    />
+                  </li>
+                );
+              }
+
+              if (entry.kind === "review") {
+                return (
+                  <li key={`review-${entry.review.id}`}>
+                    <PrReviewFeedItemReview
+                      review={entry.review}
+                      occurredAt={entry.at}
+                      attachedComments={entry.attachedComments}
+                    />
+                  </li>
+                );
+              }
+
+              const { thread } = entry;
+              const hasContent =
+                thread.root.body?.trim() ||
+                thread.replies.some((r) => r.body?.trim());
+              if (!hasContent) return null;
+
+              return (
+                <li
+                  key={`thread-${thread.root.id}-${[...thread.pendingCommentIds].join("-")}`}
+                >
+                  <PrReviewFeedItemCommentThread
+                    thread={thread}
+                    canResolve={canResolve && Boolean(thread.threadNodeId)}
+                    resolveLoading={
+                      resolvingThreadNodeId === thread.threadNodeId
+                    }
+                    onToggleResolved={
+                      thread.threadNodeId
+                        ? (resolved) =>
+                            void handleToggleThreadResolved(
+                              thread.threadNodeId!,
+                              resolved
+                            )
+                        : undefined
+                    }
+                  />
+                </li>
+              );
+            })}
+            </ul>
+          </>
+        )}
+      </div>
+
+      {reviewPr && connectionData?.connection ? (
+        <PullRequestMergePanel
+          taskId={taskId}
+          pullRequestId={reviewPr.id}
+          prNumber={reviewPr.number}
+          prUrl={reviewPr.url}
+          prState={reviewPr.state}
+          canAct={Boolean(connectionData.userLink)}
+        />
+      ) : null}
+    </div>
+  );
+}
diff --git a/src/components/git/github-markdown-sanitize.ts b/src/components/git/github-markdown-sanitize.ts
new file mode 100644
index 0000000..a85d280
--- /dev/null
+++ b/src/components/git/github-markdown-sanitize.ts
@@ -0,0 +1,15 @@
+import { defaultSchema } from "rehype-sanitize";
+
+export const githubMarkdownSanitizeSchema = {
+  ...defaultSchema,
+  tagNames: [
+    ...(defaultSchema.tagNames ?? []),
+    "details",
+    "summary",
+  ],
+  attributes: {
+    ...defaultSchema.attributes,
+    details: [...(defaultSchema.attributes?.details ?? []), "open"],
+    summary: defaultSchema.attributes?.summary ?? [],
+  },
+} as const;
diff --git a/src/components/git/review/DiffLoadingSkeleton.tsx b/src/components/git/review/DiffLoadingSkeleton.tsx
new file mode 100644
index 0000000..043d643
--- /dev/null
+++ b/src/components/git/review/DiffLoadingSkeleton.tsx
@@ -0,0 +1,27 @@
+import { Skeleton } from "~/components/ui/skeleton";
+import { cn } from "~/lib/utils";
+
+export function DiffLoadingSkeleton({ className }: { className?: string }) {
+  return (
+    <div
+      className={cn("flex min-h-0 flex-1 border-t", className)}
+      role="status"
+      aria-label="Loading diff"
+    >
+      <div className="w-48 shrink-0 space-y-1 border-r p-2">
+        {Array.from({ length: 5 }).map((_, i) => (
+          <Skeleton key={i} className="h-7 w-full" />
+        ))}
+      </div>
+      <div className="flex min-w-0 flex-1 flex-col gap-2 p-4">
+        {Array.from({ length: 12 }).map((_, i) => (
+          <Skeleton
+            key={i}
+            className="h-4"
+            style={{ width: `${55 + ((i * 17) % 40)}%` }}
+          />
+        ))}
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/git/review/FinishReviewPopover.tsx b/src/components/git/review/FinishReviewPopover.tsx
new file mode 100644
index 0000000..4e5defd
--- /dev/null
+++ b/src/components/git/review/FinishReviewPopover.tsx
@@ -0,0 +1,107 @@
+import { Button } from "~/components/ui/button";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "~/components/ui/popover";
+import { Textarea } from "~/components/ui/textarea";
+import type { ReviewOperation } from "~/components/git/review/types";
+
+export function FinishReviewPopover({
+  open,
+  onOpenChange,
+  reviewBody,
+  onReviewBodyChange,
+  operation,
+  submittingEvent,
+  onSubmit,
+  disabled,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  reviewBody: string;
+  onReviewBodyChange: (value: string) => void;
+  operation: ReviewOperation;
+  submittingEvent: "COMMENT" | "APPROVE" | "REQUEST_CHANGES" | null;
+  onSubmit: (event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES") => void;
+  disabled?: boolean;
+}) {
+  const submitting = operation === "submit";
+
+  return (
+    <Popover
+      open={open}
+      onOpenChange={(next) => {
+        if (submitting) return;
+        onOpenChange(next);
+      }}
+    >
+      <PopoverTrigger asChild>
+        <Button
+          type="button"
+          size="sm"
+          variant="default"
+          className="h-7 text-xs"
+          disabled={disabled || submitting}
+        >
+          Finish review
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent
+        side="bottom"
+        align="end"
+        className="w-80 p-3"
+        collisionPadding={12}
+      >
+        <p className="text-sm font-medium">Finish your review</p>
+        <p className="text-muted-foreground mt-1 text-xs leading-relaxed">
+          Publishes pending inline comments and submits on GitHub.
+        </p>
+        <Textarea
+          value={reviewBody}
+          onChange={(e) => onReviewBodyChange(e.target.value)}
+          placeholder="Review summary (optional)…"
+          className="mt-3 min-h-[60px] text-sm"
+          disabled={submitting}
+        />
+        {submitting ? (
+          <p className="text-muted-foreground mt-2 text-xs">
+            Submitting review on GitHub…
+          </p>
+        ) : null}
+        <div className="mt-3 flex flex-wrap gap-2">
+          <Button
+            type="button"
+            size="sm"
+            variant="outline"
+            loading={submitting && submittingEvent === "COMMENT"}
+            disabled={submitting}
+            onClick={() => onSubmit("COMMENT")}
+          >
+            Comment
+          </Button>
+          <Button
+            type="button"
+            size="sm"
+            variant="default"
+            loading={submitting && submittingEvent === "APPROVE"}
+            disabled={submitting}
+            onClick={() => onSubmit("APPROVE")}
+          >
+            Approve
+          </Button>
+          <Button
+            type="button"
+            size="sm"
+            variant="destructive"
+            loading={submitting && submittingEvent === "REQUEST_CHANGES"}
+            disabled={submitting}
+            onClick={() => onSubmit("REQUEST_CHANGES")}
+          >
+            Request changes
+          </Button>
+        </div>
+      </PopoverContent>
+    </Popover>
+  );
+}
diff --git a/src/components/git/review/LineCommentPopover.tsx b/src/components/git/review/LineCommentPopover.tsx
new file mode 100644
index 0000000..355b554
--- /dev/null
+++ b/src/components/git/review/LineCommentPopover.tsx
@@ -0,0 +1,192 @@
+import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
+import { createPortal } from "react-dom";
+import { Button } from "~/components/ui/button";
+import { useDialogFloatingPortal } from "~/components/ui/dialog-floating-portal";
+import { popoverSurfaceClass } from "~/components/ui/surface-styles";
+import { Textarea } from "~/components/ui/textarea";
+import type { ReviewOperation, SelectedReviewLine } from "~/components/git/review/types";
+import { cn } from "~/lib/utils";
+
+const POPOVER_WIDTH = 320;
+const POPOVER_HEIGHT_ESTIMATE = 220;
+/** Matches DiffViewer file list `w-48`. */
+const FILE_LIST_WIDTH_PX = 192;
+
+type FixedPosition = { top: number; left: number };
+
+function measureLineRect(selectedLine: SelectedReviewLine): DOMRect | null {
+  const row = selectedLine.lineElement;
+  if (row?.isConnected) {
+    return row.getBoundingClientRect();
+  }
+  return selectedLine.anchorRect;
+}
+
+function computeFixedPosition(
+  lineRect: DOMRect,
+  diffBounds: DOMRect
+): FixedPosition {
+  const rowSpansPanel =
+    lineRect.width > diffBounds.width * 0.65 ||
+    lineRect.right >= diffBounds.right - 24;
+
+  let left = rowSpansPanel
+    ? diffBounds.left + FILE_LIST_WIDTH_PX + 16
+    : lineRect.right + 8;
+  let top = lineRect.top;
+
+  if (left + POPOVER_WIDTH > diffBounds.right - 12) {
+    left = diffBounds.right - POPOVER_WIDTH - 12;
+  }
+  if (left < diffBounds.left + 12) {
+    left = diffBounds.left + 12;
+  }
+
+  if (top + POPOVER_HEIGHT_ESTIMATE > diffBounds.bottom - 12) {
+    top = diffBounds.bottom - POPOVER_HEIGHT_ESTIMATE - 12;
+  }
+  if (top < diffBounds.top + 12) {
+    top = diffBounds.top + 12;
+  }
+
+  return { top, left };
+}
+
+export function LineCommentPopover({
+  open,
+  selectedLine,
+  diffBoundsRef,
+  draft,
+  onDraftChange,
+  operation,
+  onAdd,
+  onCancel,
+}: {
+  open: boolean;
+  selectedLine: SelectedReviewLine | null;
+  diffBoundsRef: RefObject<HTMLElement | null>;
+  draft: string;
+  onDraftChange: (value: string) => void;
+  operation: ReviewOperation;
+  onAdd: () => void;
+  onCancel: () => void;
+}) {
+  const portalHost = useDialogFloatingPortal();
+  const adding = operation === "addComment";
+  const [position, setPosition] = useState<FixedPosition | null>(null);
+
+  useLayoutEffect(() => {
+    if (!open || !selectedLine) {
+      setPosition(null);
+      return;
+    }
+
+    const update = () => {
+      const container = diffBoundsRef.current;
+      if (!container) {
+        setPosition(null);
+        return;
+      }
+      const diffBounds = container.getBoundingClientRect();
+      const lineRect = measureLineRect(selectedLine);
+      if (!lineRect || lineRect.width + lineRect.height <= 0) {
+        setPosition({
+          top: diffBounds.top + 96,
+          left: Math.max(
+            diffBounds.left + 12,
+            diffBounds.right - POPOVER_WIDTH - 12
+          ),
+        });
+        return;
+      }
+      setPosition(computeFixedPosition(lineRect, diffBounds));
+    };
+
+    update();
+    window.addEventListener("scroll", update, true);
+    window.addEventListener("resize", update);
+    return () => {
+      window.removeEventListener("scroll", update, true);
+      window.removeEventListener("resize", update);
+    };
+  }, [open, selectedLine, diffBoundsRef]);
+
+  useEffect(() => {
+    if (!open) return;
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape" && !adding) onCancel();
+    };
+    document.addEventListener("keydown", onKeyDown);
+    return () => document.removeEventListener("keydown", onKeyDown);
+  }, [open, adding, onCancel]);
+
+  if (!open || !selectedLine || !position) return null;
+
+  const host = portalHost ?? document.body;
+
+  return createPortal(
+    <>
+      <button
+        type="button"
+        className="fixed inset-0 z-[290] cursor-default bg-transparent"
+        aria-label="Close comment"
+        onClick={() => {
+          if (!adding) onCancel();
+        }}
+      />
+      <div
+        role="dialog"
+        aria-label={`Comment on ${selectedLine.path} line ${selectedLine.line}`}
+        className={cn(
+          popoverSurfaceClass,
+          "fixed z-[300] flex w-80 flex-col p-3 shadow-lg outline-hidden"
+        )}
+        style={{
+          top: position.top,
+          left: position.left,
+        }}
+        onClick={(e) => e.stopPropagation()}
+      >
+        <p className="text-muted-foreground mb-2 text-xs leading-snug">
+          <span className="font-medium text-foreground">
+            {selectedLine.path}:{selectedLine.line}
+          </span>
+          <br />
+          Added to your review when you finish
+        </p>
+        <Textarea
+          value={draft}
+          onChange={(e) => onDraftChange(e.target.value)}
+          placeholder="Leave a comment…"
+          className="min-h-[72px] text-sm"
+          disabled={adding}
+          autoFocus
+        />
+        {adding ? (
+          <p className="text-muted-foreground mt-2 text-xs">Sending to GitHub…</p>
+        ) : null}
+        <div className="mt-2 flex gap-2">
+          <Button
+            type="button"
+            size="sm"
+            loading={adding}
+            disabled={adding || !draft.trim()}
+            onClick={onAdd}
+          >
+            Add comment
+          </Button>
+          <Button
+            type="button"
+            size="sm"
+            variant="ghost"
+            disabled={adding}
+            onClick={onCancel}
+          >
+            Cancel
+          </Button>
+        </div>
+      </div>
+    </>,
+    host
+  );
+}
diff --git a/src/components/git/review/ReviewHistorySection.tsx b/src/components/git/review/ReviewHistorySection.tsx
new file mode 100644
index 0000000..8716787
--- /dev/null
+++ b/src/components/git/review/ReviewHistorySection.tsx
@@ -0,0 +1,49 @@
+import { useState } from "react";
+import { ChevronDown } from "lucide-react";
+import { PullRequestReviewItem } from "~/components/git/ReviewComment";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "~/components/ui/collapsible";
+import type { GitPullRequestReview } from "~/lib/git/types";
+import { cn } from "~/lib/utils";
+
+export function ReviewHistorySection({
+  reviews,
+  className,
+}: {
+  reviews: GitPullRequestReview[];
+  className?: string;
+}) {
+  const [open, setOpen] = useState(false);
+
+  if (reviews.length === 0) return null;
+
+  return (
+    <Collapsible
+      open={open}
+      onOpenChange={setOpen}
+      className={cn("shrink-0 border-t border-border/60", className)}
+    >
+      <CollapsibleTrigger className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm font-medium hover:bg-muted/40">
+        <ChevronDown
+          className={cn(
+            "size-4 shrink-0 transition-transform",
+            open && "rotate-180"
+          )}
+        />
+        Review history ({reviews.length})
+      </CollapsibleTrigger>
+      <CollapsibleContent>
+        <ul className="space-y-2 px-4 pb-4">
+          {reviews.map((review) => (
+            <li key={review.id}>
+              <PullRequestReviewItem review={review} />
+            </li>
+          ))}
+        </ul>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}
diff --git a/src/components/git/review/ReviewLoadingOverlay.tsx b/src/components/git/review/ReviewLoadingOverlay.tsx
new file mode 100644
index 0000000..900f34b
--- /dev/null
+++ b/src/components/git/review/ReviewLoadingOverlay.tsx
@@ -0,0 +1,30 @@
+import { EndlessLoadingSpinner } from "~/components/EndlessLoadingSpinner";
+import { cn } from "~/lib/utils";
+
+export function ReviewLoadingOverlay({
+  active,
+  message,
+  className,
+}: {
+  active: boolean;
+  message: string;
+  className?: string;
+}) {
+  if (!active) return null;
+
+  return (
+    <div
+      className={cn(
+        "absolute inset-0 z-10 flex flex-col items-center justify-center gap-2",
+        className
+      )}
+      role="status"
+      aria-live="polite"
+    >
+      <EndlessLoadingSpinner isActive centered hasBackdrop className="absolute inset-0" />
+      <p className="relative z-30 text-muted-foreground px-4 text-center text-sm font-medium">
+        {message}
+      </p>
+    </div>
+  );
+}
diff --git a/src/components/git/review/ReviewToolbar.tsx b/src/components/git/review/ReviewToolbar.tsx
new file mode 100644
index 0000000..ea7b6d3
--- /dev/null
+++ b/src/components/git/review/ReviewToolbar.tsx
@@ -0,0 +1,149 @@
+import { Link } from "@tanstack/react-router";
+import { PlayCircle } from "lucide-react";
+import { FinishReviewPopover } from "~/components/git/review/FinishReviewPopover";
+import type { ReviewOperation } from "~/components/git/review/types";
+import { Badge } from "~/components/ui/badge";
+import { Button } from "~/components/ui/button";
+import { LoadingSpinner } from "~/components/ui/loading-spinner";
+import { cn } from "~/lib/utils";
+
+export function ReviewToolbar({
+  scopeLabel,
+  compact = false,
+  canInteract,
+  readOnly,
+  hasUserLink,
+  reviewInProgress,
+  pendingCount,
+  reviewFetching,
+  operation,
+  submittingEvent,
+  reviewBody,
+  onReviewBodyChange,
+  onStartReview,
+  onDiscardReview,
+  finishReviewOpen,
+  onFinishReviewOpenChange,
+  onSubmitReview,
+  className,
+}: {
+  scopeLabel: string;
+  /** Hides the scope title row (e.g. when PR context is shown in tabs). */
+  compact?: boolean;
+  canInteract: boolean;
+  readOnly: boolean;
+  hasUserLink: boolean;
+  reviewInProgress: boolean;
+  pendingCount: number;
+  reviewFetching?: boolean;
+  operation: ReviewOperation;
+  submittingEvent: "COMMENT" | "APPROVE" | "REQUEST_CHANGES" | null;
+  reviewBody: string;
+  onReviewBodyChange: (value: string) => void;
+  onStartReview: () => void;
+  onDiscardReview: () => void;
+  finishReviewOpen: boolean;
+  onFinishReviewOpenChange: (open: boolean) => void;
+  onSubmitReview: (event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES") => void;
+  className?: string;
+}) {
+  const reviewActionsDisabled =
+    operation === "start" ||
+    operation === "submit" ||
+    operation === "discard" ||
+    operation === "addComment";
+
+  return (
+    <div
+      className={cn(
+        "flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-4 py-2",
+        className
+      )}
+    >
+      {compact ? (
+        canInteract && !reviewInProgress ? (
+          <p className="text-muted-foreground text-xs">
+            Start review, then click a line to comment
+          </p>
+        ) : (
+          <span />
+        )
+      ) : (
+        <div className="min-w-0">
+          <p className="truncate text-sm font-medium">{scopeLabel}</p>
+          {canInteract && !reviewInProgress ? (
+            <p className="text-muted-foreground mt-0.5 text-xs">
+              Start review, then click a line to comment
+            </p>
+          ) : null}
+        </div>
+      )}
+
+      {!canInteract ? (
+        <p className="text-muted-foreground text-xs">
+          {!readOnly && !hasUserLink ? (
+            <>
+              <Link to="/settings/integrations" className="underline">
+                Connect GitHub
+              </Link>{" "}
+              to review
+            </>
+          ) : (
+            "Read-only"
+          )}
+        </p>
+      ) : reviewInProgress ? (
+        <div className="flex flex-wrap items-center gap-2">
+          <Badge variant="secondary" className="gap-1.5 text-xs">
+            {reviewFetching ? (
+              <LoadingSpinner isActive className="size-3" />
+            ) : null}
+            Review in progress
+            {pendingCount > 0
+              ? ` · ${pendingCount} pending`
+              : ""}
+            {reviewFetching ? (
+              <span className="text-muted-foreground font-normal">
+                · syncing
+              </span>
+            ) : null}
+          </Badge>
+          <Button
+            type="button"
+            size="sm"
+            variant="ghost"
+            className="h-7 text-xs"
+            loading={operation === "discard"}
+            disabled={reviewActionsDisabled && operation !== "discard"}
+            onClick={onDiscardReview}
+          >
+            Discard
+          </Button>
+          <FinishReviewPopover
+            open={finishReviewOpen}
+            onOpenChange={onFinishReviewOpenChange}
+            reviewBody={reviewBody}
+            onReviewBodyChange={onReviewBodyChange}
+            operation={operation}
+            submittingEvent={submittingEvent}
+            onSubmit={onSubmitReview}
+            disabled={reviewActionsDisabled && operation !== "submit"}
+          />
+        </div>
+      ) : (
+        <Button
+          type="button"
+          size="sm"
+          variant="default"
+          icon={PlayCircle}
+          className="h-8 px-3.5 text-xs font-semibold shadow-sm"
+          loading={operation === "start"}
+          disabled={reviewActionsDisabled}
+          onClick={onStartReview}
+        >
+          Start review
+        </Button>
+      )}
+    </div>
+  );
+}
diff --git a/src/components/git/review/ReviewToolbarSkeleton.tsx b/src/components/git/review/ReviewToolbarSkeleton.tsx
new file mode 100644
index 0000000..e120e62
--- /dev/null
+++ b/src/components/git/review/ReviewToolbarSkeleton.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from "~/components/ui/skeleton";
+
+export function ReviewToolbarSkeleton() {
+  return (
+    <div
+      className="flex items-center justify-between gap-2 border-b border-border/60 px-4 py-2"
+      role="status"
+      aria-label="Loading review"
+    >
+      <Skeleton className="h-4 w-40" />
+      <Skeleton className="h-7 w-24" />
+    </div>
+  );
+}
diff --git a/src/components/git/review/types.ts b/src/components/git/review/types.ts
new file mode 100644
index 0000000..9f0b7b0
--- /dev/null
+++ b/src/components/git/review/types.ts
@@ -0,0 +1,11 @@
+export type ReviewOperation = "start" | "addComment" | "submit" | "discard" | null;
+
+export type SelectedReviewLine = {
+  path: string;
+  line: number;
+  side: "LEFT" | "RIGHT";
+  anchorRect: DOMRect | null;
+  lineElement: HTMLElement | null;
+  /** When false, line is highlighted/scrolled only (review nav), not comment draft. */
+  opensPopover?: boolean;
+};
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 424cac0..b55a68d 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -73,20 +73,18 @@ function Button({
     </>
   );
 
+  const mergedClassName = cn(buttonVariants({ variant, size }), className);
+
   return asChild ? (
-    <Comp
-      className={cn(buttonVariants({ variant, size, className }))}
-      type={type}
-      {...props}
-    >
+    <Comp className={mergedClassName} type={type} {...props}>
       {children}
     </Comp>
   ) : (
     <Comp
-      className={cn(buttonVariants({ variant, size, className }))}
-      disabled={loading}
+      className={mergedClassName}
       type={type}
       {...props}
+      disabled={loading || props.disabled}
     >
       {content}
     </Comp>
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
index 41ac72e..ec15f38 100644
--- a/src/components/ui/tabs.tsx
+++ b/src/components/ui/tabs.tsx
@@ -1,8 +1,40 @@
 import * as React from "react";
 import * as TabsPrimitive from "@radix-ui/react-tabs";
+import { cva, type VariantProps } from "class-variance-authority";
 
 import { cn } from "~/lib/utils";
 
+const tabsListVariants = cva("inline-flex w-full items-center", {
+  variants: {
+    variant: {
+      default:
+        "bg-muted/60 text-muted-foreground h-9 justify-center rounded-md border border-border/60 p-[3px]",
+      dialog:
+        "bg-transparent text-muted-foreground h-11 shrink-0 justify-start gap-0 rounded-none border-0 p-0",
+    },
+  },
+  defaultVariants: {
+    variant: "default",
+  },
+});
+
+const tabsTriggerVariants = cva(
+  "cursor-pointer inline-flex items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default:
+          "h-[calc(100%-1px)] flex-1 rounded-sm border border-transparent px-2 py-1 text-foreground data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:shadow-sm dark:data-[state=active]:border-border dark:data-[state=active]:bg-background",
+        dialog:
+          "text-muted-foreground -mb-px h-11 shrink-0 rounded-none border-0 border-b-2 border-transparent bg-transparent px-4 pb-3 pt-2 shadow-none data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+);
+
 function Tabs({
   className,
   ...props
@@ -18,15 +50,14 @@ function Tabs({
 
 function TabsList({
   className,
+  variant,
   ...props
-}: React.ComponentProps<typeof TabsPrimitive.List>) {
+}: React.ComponentProps<typeof TabsPrimitive.List> &
+  VariantProps<typeof tabsListVariants>) {
   return (
     <TabsPrimitive.List
       data-slot="tabs-list"
-      className={cn(
-        "bg-muted/60 text-muted-foreground inline-flex h-9 w-full items-center justify-center rounded-md border border-border/60 p-[3px]",
-        className
-      )}
+      className={cn(tabsListVariants({ variant }), className)}
       {...props}
     />
   );
@@ -34,15 +65,14 @@ function TabsList({
 
 function TabsTrigger({
   className,
+  variant,
   ...props
-}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+}: React.ComponentProps<typeof TabsPrimitive.Trigger> &
+  VariantProps<typeof tabsTriggerVariants>) {
   return (
     <TabsPrimitive.Trigger
       data-slot="tabs-trigger"
-      className={cn(
-        "cursor-pointer inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-sm border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-border data-[state=active]:bg-background dark:data-[state=active]:border-border dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
-        className
-      )}
+      className={cn(tabsTriggerVariants({ variant }), className)}
       {...props}
     />
   );
diff --git a/src/components/views/BoardView.tsx b/src/components/views/BoardView.tsx
index 07c87e2..d6a3b1b 100644
--- a/src/components/views/BoardView.tsx
+++ b/src/components/views/BoardView.tsx
@@ -1,5 +1,7 @@
 import { DndContext, DragOverlay } from "@dnd-kit/core";
+import { useQuery } from "@tanstack/react-query";
 import { useMemo, useState } from "react";
+import { fetchTaskGitSummaries } from "~/db/queries/git";
 import TaskCard, { TaskCardComponent } from "~/components/TaskCard";
 import TaskColumn from "~/components/TaskColumn";
 import { useStore } from "@tanstack/react-store";
@@ -31,6 +33,13 @@ export const BoardView = ({
 
   const sensors = useTaskViewSensors();
 
+  const taskIds = useMemo(() => tasks.map((t) => t.id), [tasks]);
+  const { data: gitSummaries } = useQuery({
+    queryKey: ["git", "summaries", taskIds],
+    queryFn: () => fetchTaskGitSummaries({ data: { taskIds } }),
+    enabled: taskIds.length > 0,
+  });
+
   const tasksByStatus = useMemo(() => {
     return tasks.reduce(
       (acc, task) => {
@@ -71,6 +80,7 @@ export const BoardView = ({
         showSprint={showSprint}
         showProject={showProject}
         taskLinkTo={taskLinkTo}
+        gitSummary={gitSummaries?.[task.id]}
       />
     ));
   };
diff --git a/src/db/mutations/git-callback.server.ts b/src/db/mutations/git-callback.server.ts
new file mode 100644
index 0000000..fa60370
--- /dev/null
+++ b/src/db/mutations/git-callback.server.ts
@@ -0,0 +1,202 @@
+import "@tanstack/react-start/server-only";
+
+import { and, eq } from "drizzle-orm";
+import { v7 as uuid } from "uuid";
+import { db } from "~/db";
+import {
+  gitConnections,
+  gitRepositories,
+  gitStatusRules,
+  gitUserLinks,
+  statuses,
+} from "~/db/schema";
+import { encryptSecret } from "~/lib/git/crypto";
+import { getGitProvider } from "~/lib/git/factory";
+import { DEFAULT_STATUS_RULE_PATTERNS } from "~/lib/git/status-rules";
+import { Octokit } from "@octokit/rest";
+import { exchangeGitHubUserAccessToken } from "~/lib/git/github/app";
+import { isGitHubConfigured } from "~/env";
+import { getOwningIdentity } from "~/lib/utils";
+import type { requireSessionFromRequest } from "~/lib/session";
+
+export async function runCompleteGitHubInstallation(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  data: {
+    installationId: string;
+    accountLogin: string;
+    accountType: string;
+  }
+) {
+  const owner = getOwningIdentity(session);
+  const now = new Date();
+
+  const existing = await db.query.gitConnections.findFirst({
+    where: and(
+      eq(gitConnections.owner, owner),
+      eq(gitConnections.provider, "github")
+    ),
+  });
+
+  let connectionId: string;
+  if (existing) {
+    connectionId = existing.id;
+    await db
+      .update(gitConnections)
+      .set({
+        installationId: data.installationId,
+        accountLogin: data.accountLogin,
+        accountType: data.accountType,
+        updatedAt: now,
+      })
+      .where(eq(gitConnections.id, existing.id));
+  } else {
+    connectionId = uuid();
+    await db.insert(gitConnections).values({
+      id: connectionId,
+      owner,
+      provider: "github",
+      installationId: data.installationId,
+      accountLogin: data.accountLogin,
+      accountType: data.accountType,
+      connectedByUserId: session.user.id,
+      updatedAt: now,
+    });
+
+    const ownerStatuses = await db.query.statuses.findMany({
+      where: eq(statuses.owner, owner),
+      orderBy: (s, { asc }) => [asc(s.order)],
+    });
+    const doneStatus =
+      ownerStatuses[ownerStatuses.length - 1] ?? ownerStatuses[0];
+    const inProgressStatus =
+      ownerStatuses[Math.min(1, ownerStatuses.length - 1)] ?? ownerStatuses[0];
+
+    if (doneStatus && inProgressStatus) {
+      await db.insert(gitStatusRules).values([
+        {
+          id: uuid(),
+          owner,
+          pattern: DEFAULT_STATUS_RULE_PATTERNS.close,
+          targetStatusId: doneStatus.id,
+          priority: 10,
+          updatedAt: now,
+        },
+        {
+          id: uuid(),
+          owner,
+          pattern: DEFAULT_STATUS_RULE_PATTERNS.wip,
+          targetStatusId: inProgressStatus.id,
+          priority: 5,
+          updatedAt: now,
+        },
+      ]);
+    }
+  }
+
+  const provider = getGitProvider("github");
+  const conn = await db.query.gitConnections.findFirst({
+    where: eq(gitConnections.id, connectionId),
+  });
+  if (conn) {
+    try {
+      const repos = await provider.syncRepositories(conn);
+      for (const repo of repos) {
+        const existingRepo = await db.query.gitRepositories.findFirst({
+          where: and(
+            eq(gitRepositories.connectionId, conn.id),
+            eq(gitRepositories.externalId, repo.externalId)
+          ),
+        });
+        if (!existingRepo) {
+          await db.insert(gitRepositories).values({
+            id: uuid(),
+            connectionId: conn.id,
+            owner,
+            externalId: repo.externalId,
+            fullName: repo.fullName,
+            defaultBranch: repo.defaultBranch,
+            htmlUrl: repo.htmlUrl,
+            isArchived: repo.isArchived,
+            updatedAt: now,
+          });
+        }
+      }
+    } catch (error) {
+      console.error(
+        "GitHub repository sync failed after install (connection saved)",
+        error
+      );
+    }
+  }
+
+  return { connectionId };
+}
+
+export async function runSaveGitHubUserLink(
+  userId: string,
+  data: {
+    providerUserId: string;
+    providerLogin?: string;
+    accessToken: string;
+    refreshToken?: string;
+    scopes?: string;
+    expiresAt?: string;
+  }
+) {
+  const now = new Date();
+  const encrypted = encryptSecret(data.accessToken);
+
+  const existing = await db.query.gitUserLinks.findFirst({
+    where: and(
+      eq(gitUserLinks.userId, userId),
+      eq(gitUserLinks.provider, "github")
+    ),
+  });
+
+  if (existing) {
+    await db
+      .update(gitUserLinks)
+      .set({
+        providerUserId: data.providerUserId,
+        providerLogin: data.providerLogin,
+        accessToken: encrypted,
+        refreshToken: data.refreshToken
+          ? encryptSecret(data.refreshToken)
+          : null,
+        scopes: data.scopes,
+        tokenExpiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
+        updatedAt: now,
+      })
+      .where(eq(gitUserLinks.id, existing.id));
+  } else {
+    await db.insert(gitUserLinks).values({
+      id: uuid(),
+      userId,
+      provider: "github",
+      providerUserId: data.providerUserId,
+      providerLogin: data.providerLogin,
+      accessToken: encrypted,
+      refreshToken: data.refreshToken
+        ? encryptSecret(data.refreshToken)
+        : null,
+      scopes: data.scopes,
+      tokenExpiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
+      updatedAt: now,
+    });
+  }
+}
+
+export async function runExchangeGitHubOAuthCode(code: string) {
+  const { accessToken, refreshToken, expiresAt } =
+    await exchangeGitHubUserAccessToken(code);
+  const octokit = new Octokit({ auth: accessToken });
+  const { data: ghUser } = await octokit.rest.users.getAuthenticated();
+  return {
+    accessToken,
+    refreshToken,
+    expiresAt,
+    scopes: undefined,
+    providerUserId: String(ghUser.id),
+    providerLogin: ghUser.login,
+  };
+}
diff --git a/src/db/mutations/git.ts b/src/db/mutations/git.ts
new file mode 100644
index 0000000..d2a06f2
--- /dev/null
+++ b/src/db/mutations/git.ts
@@ -0,0 +1,1140 @@
+import { createServerFn, useServerFn } from "@tanstack/react-start";
+import { and, desc, eq } from "drizzle-orm";
+import { v7 as uuid } from "uuid";
+import { useCallback } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { z } from "zod";
+import { db } from "~/db";
+import {
+  gitConnections,
+  gitRepositories,
+  gitStatusRules,
+  gitUserLinks,
+  insertGitStatusRuleValidator,
+  projectGitRepositories,
+  taskGitActivity,
+  taskGitBranches,
+  taskGitPullRequests,
+  tasks,
+  projects,
+  updateGitStatusRuleValidator,
+} from "~/db/schema";
+import { requireSessionFromRequest } from "~/lib/session";
+import { getOwningIdentity } from "~/lib/utils";
+import { formatGitHubApiError } from "~/lib/git/errors";
+import { getGitProvider } from "~/lib/git/factory";
+import { pickRicherReviewComment } from "~/lib/git/review-comment-annotation";
+import type { GitReviewComment } from "~/lib/git/types";
+import { upsertTaskPullRequest } from "~/lib/git/upsert-task-pull-request";
+import { resolveProjectRepositoryLink } from "~/lib/git/resolve-project-link";
+import {
+  defaultBranchName,
+  formatTaskKey,
+} from "~/lib/git/task-key";
+import { sync } from "./sync";
+import { env } from "~/env";
+
+const saveProjectGitRepositories = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      projectId: z.string().uuid(),
+      repositoryIds: z.array(z.string().uuid()).max(5),
+      taskKeyPrefix: z
+        .string()
+        .regex(/^[A-Z][A-Z0-9]{0,15}$/)
+        .optional(),
+      defaultBaseBranches: z.record(z.string(), z.string()).optional(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+
+    const project = await db.query.projects.findFirst({
+      where: and(eq(projects.id, data.projectId), eq(projects.owner, owner)),
+    });
+    if (!project) throw new Error("Project not found");
+
+    if (data.taskKeyPrefix) {
+      await db
+        .update(projects)
+        .set({ taskKeyPrefix: data.taskKeyPrefix, updatedAt: new Date() })
+        .where(eq(projects.id, data.projectId));
+    }
+
+    await db
+      .delete(projectGitRepositories)
+      .where(eq(projectGitRepositories.projectId, data.projectId));
+
+    for (const repositoryId of data.repositoryIds) {
+      const repo = await db.query.gitRepositories.findFirst({
+        where: and(
+          eq(gitRepositories.id, repositoryId),
+          eq(gitRepositories.owner, owner)
+        ),
+      });
+      if (!repo) continue;
+
+      await db.insert(projectGitRepositories).values({
+        projectId: data.projectId,
+        repositoryId,
+        owner,
+        isPrimary: false,
+        defaultBaseBranch:
+          data.defaultBaseBranches?.[repositoryId] ?? null,
+      });
+    }
+
+    await sync(`project-update-${data.projectId}`, { id: data.projectId });
+  });
+
+const createTaskBranch = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      repositoryId: z.string().uuid().optional(),
+      branchName: z.string().min(1).max(256).optional(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+
+    const task = await db.query.tasks.findFirst({
+      where: and(eq(tasks.id, data.taskId), eq(tasks.owner, owner)),
+      with: { project: true },
+    });
+    if (!task) throw new Error("Task not found");
+
+    const projectLink = await resolveProjectRepositoryLink(
+      task.projectId,
+      owner,
+      { repositoryId: data.repositoryId, taskId: data.taskId }
+    );
+
+    const connection = projectLink.repository.connection;
+    const repo = projectLink.repository;
+    const taskKey = formatTaskKey(task.project.taskKeyPrefix, task.number);
+    const branchName =
+      data.branchName ?? defaultBranchName(taskKey, task.name);
+    const baseBranch =
+      projectLink.defaultBaseBranch ?? repo.defaultBranch;
+
+    const userLink = await db.query.gitUserLinks.findFirst({
+      where: and(
+        eq(gitUserLinks.userId, session.user.id),
+        eq(gitUserLinks.provider, "github")
+      ),
+    });
+
+    const provider = getGitProvider(connection.provider);
+    const gitBranch = await provider.createBranch(
+      connection,
+      {
+        externalId: repo.externalId,
+        fullName: repo.fullName,
+        defaultBranch: repo.defaultBranch,
+        htmlUrl: repo.htmlUrl,
+        isArchived: repo.isArchived,
+      },
+      {
+        base: baseBranch,
+        name: branchName,
+        userAccessToken: userLink
+          ? (await import("~/lib/git/crypto")).decryptSecret(userLink.accessToken)
+          : undefined,
+      }
+    );
+
+    const existing = await db.query.taskGitBranches.findFirst({
+      where: and(
+        eq(taskGitBranches.taskId, data.taskId),
+        eq(taskGitBranches.repositoryId, repo.id)
+      ),
+    });
+
+    const now = new Date();
+    let branchId: string;
+
+    if (existing) {
+      branchId = existing.id;
+      await db
+        .update(taskGitBranches)
+        .set({
+          ref: gitBranch.ref,
+          sha: gitBranch.sha,
+          state: "active",
+          createdByUserId: session.user.id,
+          updatedAt: now,
+        })
+        .where(eq(taskGitBranches.id, existing.id));
+    } else {
+      branchId = uuid();
+      await db.insert(taskGitBranches).values({
+        id: branchId,
+        taskId: data.taskId,
+        repositoryId: repo.id,
+        owner,
+        ref: gitBranch.ref,
+        sha: gitBranch.sha,
+        createdByUserId: session.user.id,
+        updatedAt: now,
+      });
+    }
+
+    await db.insert(taskGitActivity).values({
+      id: uuid(),
+      taskId: data.taskId,
+      owner,
+      type: "branch_created",
+      payload: { ref: gitBranch.ref, url: gitBranch.htmlUrl },
+      actorLogin: session.user.name,
+      occurredAt: now,
+    });
+
+    await sync(`task-update-${data.taskId}`, { id: data.taskId });
+    return { branchId, ref: gitBranch.ref, url: gitBranch.htmlUrl };
+  });
+
+const startTaskDevelopment = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      repositoryId: z.string().uuid().optional(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+
+    const task = await db.query.tasks.findFirst({
+      where: and(eq(tasks.id, data.taskId), eq(tasks.owner, owner)),
+      with: { project: true },
+    });
+    if (!task) throw new Error("Task not found");
+
+    const projectLink = await resolveProjectRepositoryLink(
+      task.projectId,
+      owner,
+      { repositoryId: data.repositoryId, taskId: data.taskId }
+    );
+
+    const connection = projectLink.repository.connection;
+    const repo = projectLink.repository;
+    const taskKey = formatTaskKey(task.project.taskKeyPrefix, task.number);
+    const branchName = defaultBranchName(taskKey, task.name);
+    const baseBranch =
+      projectLink.defaultBaseBranch ?? repo.defaultBranch;
+
+    const userLink = await db.query.gitUserLinks.findFirst({
+      where: and(
+        eq(gitUserLinks.userId, session.user.id),
+        eq(gitUserLinks.provider, "github")
+      ),
+    });
+
+    const provider = getGitProvider(connection.provider);
+    const gitBranch = await provider.createBranch(
+      connection,
+      {
+        externalId: repo.externalId,
+        fullName: repo.fullName,
+        defaultBranch: repo.defaultBranch,
+        htmlUrl: repo.htmlUrl,
+        isArchived: repo.isArchived,
+      },
+      {
+        base: baseBranch,
+        name: branchName,
+        userAccessToken: userLink
+          ? (await import("~/lib/git/crypto")).decryptSecret(userLink.accessToken)
+          : undefined,
+      }
+    );
+
+    const existing = await db.query.taskGitBranches.findFirst({
+      where: and(
+        eq(taskGitBranches.taskId, data.taskId),
+        eq(taskGitBranches.repositoryId, repo.id)
+      ),
+    });
+
+    const now = new Date();
+    let branchId: string;
+
+    if (existing) {
+      branchId = existing.id;
+      await db
+        .update(taskGitBranches)
+        .set({
+          ref: gitBranch.ref,
+          sha: gitBranch.sha,
+          state: "active",
+          createdByUserId: session.user.id,
+          updatedAt: now,
+        })
+        .where(eq(taskGitBranches.id, existing.id));
+    } else {
+      branchId = uuid();
+      await db.insert(taskGitBranches).values({
+        id: branchId,
+        taskId: data.taskId,
+        repositoryId: repo.id,
+        owner,
+        ref: gitBranch.ref,
+        sha: gitBranch.sha,
+        createdByUserId: session.user.id,
+        updatedAt: now,
+      });
+    }
+
+    await db.insert(taskGitActivity).values({
+      id: uuid(),
+      taskId: data.taskId,
+      owner,
+      type: "branch_created",
+      payload: { ref: gitBranch.ref, url: gitBranch.htmlUrl },
+      actorLogin: session.user.name,
+      occurredAt: now,
+    });
+
+    await sync(`task-update-${data.taskId}`, { id: data.taskId });
+
+    return {
+      branchId,
+      ref: gitBranch.ref,
+      url: gitBranch.htmlUrl,
+      taskKey,
+      checkoutCommand: `git fetch origin && git checkout ${gitBranch.ref}`,
+      repositoryFullName: repo.fullName,
+    };
+  });
+
+const disconnectTaskGitBranch = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      branchId: z.string().uuid(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+
+    const branch = await db.query.taskGitBranches.findFirst({
+      where: and(
+        eq(taskGitBranches.id, data.branchId),
+        eq(taskGitBranches.taskId, data.taskId),
+        eq(taskGitBranches.owner, owner)
+      ),
+      with: { repository: true },
+    });
+    if (!branch) throw new Error("Branch link not found");
+
+    await db
+      .delete(taskGitPullRequests)
+      .where(
+        and(
+          eq(taskGitPullRequests.taskId, data.taskId),
+          eq(taskGitPullRequests.owner, owner),
+          eq(taskGitPullRequests.repositoryId, branch.repositoryId)
+        )
+      );
+
+    await db.delete(taskGitBranches).where(eq(taskGitBranches.id, data.branchId));
+
+    await db.insert(taskGitActivity).values({
+      id: uuid(),
+      taskId: data.taskId,
+      owner,
+      type: "branch_disconnected",
+      payload: {
+        ref: branch.ref,
+        repositoryFullName: branch.repository.fullName,
+      },
+      actorLogin: session.user.name,
+      occurredAt: new Date(),
+    });
+
+    await sync(`task-update-${data.taskId}`, { id: data.taskId });
+  });
+
+const linkTaskPullRequest = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      prUrl: z.string().url(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+
+    const task = await db.query.tasks.findFirst({
+      where: and(eq(tasks.id, data.taskId), eq(tasks.owner, owner)),
+    });
+    if (!task) throw new Error("Task not found");
+
+    const connection = await db.query.gitConnections.findFirst({
+      where: and(
+        eq(gitConnections.owner, owner),
+        eq(gitConnections.provider, "github")
+      ),
+    });
+    if (!connection) throw new Error("GitHub not connected");
+
+    const provider = getGitProvider("github");
+    const parsed = provider.parsePullRequestUrl(data.prUrl);
+    if (!parsed) throw new Error("Invalid pull request URL");
+
+    const fullName = `${parsed.owner}/${parsed.repo}`;
+    const repository = await db.query.gitRepositories.findFirst({
+      where: and(
+        eq(gitRepositories.owner, owner),
+        eq(gitRepositories.fullName, fullName)
+      ),
+    });
+    if (!repository) throw new Error("Repository not linked to workspace");
+
+    const pr = await provider.getPullRequest(
+      connection,
+      {
+        externalId: repository.externalId,
+        fullName: repository.fullName,
+        defaultBranch: repository.defaultBranch,
+        htmlUrl: repository.htmlUrl,
+        isArchived: repository.isArchived,
+      },
+      parsed.number
+    );
+
+    const branch = await db.query.taskGitBranches.findFirst({
+      where: and(
+        eq(taskGitBranches.taskId, data.taskId),
+        eq(taskGitBranches.ref, pr.headRef)
+      ),
+    });
+
+    await upsertTaskPullRequest({
+      owner,
+      taskId: data.taskId,
+      repositoryId: repository.id,
+      branchId: branch?.id ?? null,
+      providerPrId: pr.providerPrId,
+      number: pr.number,
+      url: pr.url,
+      title: pr.title,
+      state: pr.state,
+      headRef: pr.headRef,
+      baseRef: pr.baseRef,
+      mergedAt: pr.mergedAt,
+      closedAt: pr.closedAt,
+    });
+
+    await sync(`task-update-${data.taskId}`, { id: data.taskId });
+  });
+
+const createTaskPullRequest = createServerFn({ method: "POST" })
+  .inputValidator(z.object({ taskId: z.string().uuid() }))
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+
+    const task = await db.query.tasks.findFirst({
+      where: and(eq(tasks.id, data.taskId), eq(tasks.owner, owner)),
+      with: { project: true },
+    });
+    if (!task) throw new Error("Task not found");
+
+    const branches = await db.query.taskGitBranches.findMany({
+      where: and(
+        eq(taskGitBranches.taskId, data.taskId),
+        eq(taskGitBranches.owner, owner)
+      ),
+      with: { repository: { with: { connection: true } } },
+      orderBy: (b, { desc: d }) => [d(b.updatedAt)],
+    });
+    const branch =
+      branches.find((b) => b.state === "active") ?? branches[0];
+    if (!branch?.repository?.connection) {
+      throw new Error("Create a branch before opening a pull request");
+    }
+
+    const projectLink = await db.query.projectGitRepositories.findFirst({
+      where: and(
+        eq(projectGitRepositories.projectId, task.projectId),
+        eq(projectGitRepositories.repositoryId, branch.repositoryId)
+      ),
+    });
+
+    const taskKey = formatTaskKey(task.project.taskKeyPrefix, task.number);
+    const base =
+      projectLink?.defaultBaseBranch ?? branch.repository.defaultBranch;
+    const taskUrl = `${env.BETTER_AUTH_URL}/projects/${task.projectId}/tasks/${task.id}`;
+    const body = `## Task\n\n[${taskKey}: ${task.name}](${taskUrl})\n\n## Summary\n\n${task.description?.slice(0, 500) ?? ""}`;
+
+    const userLink = await db.query.gitUserLinks.findFirst({
+      where: and(
+        eq(gitUserLinks.userId, session.user.id),
+        eq(gitUserLinks.provider, "github")
+      ),
+    });
+
+    const provider = getGitProvider(branch.repository.connection.provider);
+    let pr;
+    try {
+      pr = await provider.createPullRequest(
+        branch.repository.connection,
+        {
+          externalId: branch.repository.externalId,
+          fullName: branch.repository.fullName,
+          defaultBranch: branch.repository.defaultBranch,
+          htmlUrl: branch.repository.htmlUrl,
+          isArchived: branch.repository.isArchived,
+        },
+        {
+          title: `${taskKey}: ${task.name}`,
+          body,
+          head: branch.ref,
+          base,
+          userAccessToken: userLink
+            ? (await import("~/lib/git/crypto")).decryptSecret(
+                userLink.accessToken
+              )
+            : undefined,
+        }
+      );
+    } catch (error) {
+      throw new Error(formatGitHubApiError(error));
+    }
+
+    await upsertTaskPullRequest({
+      owner,
+      taskId: data.taskId,
+      repositoryId: branch.repositoryId,
+      branchId: branch.id,
+      providerPrId: pr.providerPrId,
+      number: pr.number,
+      url: pr.url,
+      title: pr.title,
+      state: pr.state,
+      headRef: pr.headRef,
+      baseRef: pr.baseRef,
+      mergedAt: pr.mergedAt,
+      closedAt: pr.closedAt,
+    });
+
+    await sync(`task-update-${data.taskId}`, { id: data.taskId });
+    return pr;
+  });
+
+const syncGitRepositories = createServerFn({ method: "POST" }).handler(
+  async () => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+    const connection = await db.query.gitConnections.findFirst({
+      where: and(
+        eq(gitConnections.owner, owner),
+        eq(gitConnections.provider, "github")
+      ),
+    });
+    if (!connection) throw new Error("GitHub not connected");
+
+    const provider = getGitProvider("github");
+    const repos = await provider.syncRepositories(connection);
+    const now = new Date();
+
+    for (const repo of repos) {
+      const existing = await db.query.gitRepositories.findFirst({
+        where: and(
+          eq(gitRepositories.connectionId, connection.id),
+          eq(gitRepositories.externalId, repo.externalId)
+        ),
+      });
+      if (existing) {
+        await db
+          .update(gitRepositories)
+          .set({
+            fullName: repo.fullName,
+            defaultBranch: repo.defaultBranch,
+            htmlUrl: repo.htmlUrl,
+            isArchived: repo.isArchived,
+            updatedAt: now,
+          })
+          .where(eq(gitRepositories.id, existing.id));
+      } else {
+        await db.insert(gitRepositories).values({
+          id: uuid(),
+          connectionId: connection.id,
+          owner,
+          externalId: repo.externalId,
+          fullName: repo.fullName,
+          defaultBranch: repo.defaultBranch,
+          htmlUrl: repo.htmlUrl,
+          isArchived: repo.isArchived,
+          updatedAt: now,
+        });
+      }
+    }
+  }
+);
+
+const _completeGitHubInstallation = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      installationId: z.string(),
+      accountLogin: z.string(),
+      accountType: z.string(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { runCompleteGitHubInstallation } = await import(
+      "./git-callback.server"
+    );
+    return runCompleteGitHubInstallation(session, data);
+  });
+
+const _saveGitHubUserLink = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      providerUserId: z.string(),
+      providerLogin: z.string().optional(),
+      accessToken: z.string(),
+      refreshToken: z.string().optional(),
+      scopes: z.string().optional(),
+      expiresAt: z.string().datetime().optional(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { runSaveGitHubUserLink } = await import("./git-callback.server");
+    await runSaveGitHubUserLink(session.user.id, data);
+  });
+
+const createGitStatusRule = createServerFn({ method: "POST" })
+  .inputValidator(insertGitStatusRuleValidator)
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+    const now = new Date();
+    await db.insert(gitStatusRules).values({
+      id: uuid(),
+      owner,
+      pattern: data.pattern,
+      targetStatusId: data.targetStatusId,
+      priority: data.priority ?? 0,
+      projectId: data.projectId ?? null,
+      createdAt: now,
+      updatedAt: now,
+    });
+  });
+
+const updateGitStatusRule = createServerFn({ method: "POST" })
+  .inputValidator(updateGitStatusRuleValidator)
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+    await db
+      .update(gitStatusRules)
+      .set({
+        pattern: data.pattern,
+        targetStatusId: data.targetStatusId,
+        priority: data.priority,
+        projectId: data.projectId,
+        updatedAt: new Date(),
+      })
+      .where(
+        and(eq(gitStatusRules.id, data.id), eq(gitStatusRules.owner, owner))
+      );
+  });
+
+const deleteGitStatusRule = createServerFn({ method: "POST" })
+  .inputValidator(z.object({ id: z.string().uuid() }))
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+    await db
+      .delete(gitStatusRules)
+      .where(
+        and(eq(gitStatusRules.id, data.id), eq(gitStatusRules.owner, owner))
+      );
+  });
+
+const createTaskPullRequestReviewComment = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+      reviewId: z.number().int().positive(),
+      body: z.string().min(1),
+      commitId: z.string().min(7),
+      path: z.string().min(1),
+      line: z.number().int().positive(),
+      side: z.enum(["LEFT", "RIGHT"]).optional(),
+      inReplyTo: z.number().int().optional(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { createTaskPullRequestReviewComment } = await import(
+      "~/db/queries/git.server"
+    );
+    return createTaskPullRequestReviewComment(
+      session,
+      data.taskId,
+      data.pullRequestId,
+      data
+    );
+  });
+
+const startTaskPullRequestReview = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+      commitId: z.string().min(7),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { startTaskPullRequestReview } = await import(
+      "~/db/queries/git.server"
+    );
+    return startTaskPullRequestReview(
+      session,
+      data.taskId,
+      data.pullRequestId,
+      data.commitId
+    );
+  });
+
+const submitTaskPullRequestReview = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+      commitId: z.string().min(7),
+      body: z.string().optional(),
+      event: z.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES"]),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { submitTaskPullRequestReview } = await import(
+      "~/db/queries/git.server"
+    );
+    await submitTaskPullRequestReview(
+      session,
+      data.taskId,
+      data.pullRequestId,
+      data
+    );
+  });
+
+const setTaskPullRequestReviewThreadResolved = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+      threadNodeId: z.string().min(1),
+      resolved: z.boolean(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { setTaskPullRequestReviewThreadResolved } = await import(
+      "~/db/queries/git.server"
+    );
+    await setTaskPullRequestReviewThreadResolved(
+      session,
+      data.taskId,
+      data.pullRequestId,
+      {
+        threadNodeId: data.threadNodeId,
+        resolved: data.resolved,
+      }
+    );
+  });
+
+const mergeTaskPullRequest = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+      mergeMethod: z.enum(["merge", "squash", "rebase"]).optional(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { mergeTaskPullRequest } = await import("~/db/queries/git.server");
+    return mergeTaskPullRequest(
+      session,
+      data.taskId,
+      data.pullRequestId,
+      { mergeMethod: data.mergeMethod }
+    );
+  });
+
+const closeTaskPullRequest = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { closeTaskPullRequest } = await import("~/db/queries/git.server");
+    return closeTaskPullRequest(session, data.taskId, data.pullRequestId);
+  });
+
+const discardTaskPullRequestReview = createServerFn({ method: "POST" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { discardTaskPullRequestReview } = await import(
+      "~/db/queries/git.server"
+    );
+    await discardTaskPullRequestReview(
+      session,
+      data.taskId,
+      data.pullRequestId
+    );
+  });
+
+const disconnectGitHub = createServerFn({ method: "POST" }).handler(async () => {
+  const session = await requireSessionFromRequest();
+  const owner = getOwningIdentity(session);
+  await db
+    .delete(gitConnections)
+    .where(
+      and(
+        eq(gitConnections.owner, owner),
+        eq(gitConnections.provider, "github")
+      )
+    );
+});
+
+type SaveProjectGitInput = {
+  projectId: string;
+  repositoryIds: string[];
+  taskKeyPrefix?: string;
+  defaultBaseBranches?: Record<string, string>;
+};
+
+export function useSaveProjectGitRepositoriesMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(saveProjectGitRepositories);
+  return useCallback(
+    async (input: SaveProjectGitInput) => {
+      await fn({ data: input });
+      queryClient.invalidateQueries({ queryKey: ["git"] });
+      queryClient.invalidateQueries({ queryKey: ["projects"] });
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useCreateTaskBranchMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(createTaskBranch);
+  return useCallback(
+    async (input: { taskId: string; repositoryId?: string; branchName?: string }) => {
+      const result = await fn({ data: input });
+      queryClient.invalidateQueries({ queryKey: ["git", "task", input.taskId] });
+      return result;
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useStartTaskDevelopmentMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(startTaskDevelopment);
+  return useCallback(
+    async (input: { taskId: string; repositoryId?: string }) => {
+      const result = await fn({ data: input });
+      queryClient.invalidateQueries({ queryKey: ["git", "task", input.taskId] });
+      return result;
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useDisconnectTaskGitBranchMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(disconnectTaskGitBranch);
+  return useCallback(
+    async (input: { taskId: string; branchId: string }) => {
+      await fn({ data: input });
+      queryClient.invalidateQueries({ queryKey: ["git", "task", input.taskId] });
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useLinkTaskPullRequestMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(linkTaskPullRequest);
+  return useCallback(
+    async (input: { taskId: string; prUrl: string }) => {
+      await fn({ data: input });
+      queryClient.invalidateQueries({ queryKey: ["git", "task", input.taskId] });
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useCreateTaskPullRequestMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(createTaskPullRequest);
+  return useCallback(
+    async (taskId: string) => {
+      const result = await fn({ data: { taskId } });
+      queryClient.invalidateQueries({ queryKey: ["git", "task", taskId] });
+      return result;
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useSyncGitRepositoriesMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(syncGitRepositories);
+  return useCallback(async () => {
+    await fn();
+    queryClient.invalidateQueries({ queryKey: ["git"] });
+  }, [fn, queryClient]);
+}
+
+export function useCreateGitStatusRuleMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(createGitStatusRule);
+  return useCallback(
+    async (data: {
+      pattern: string;
+      targetStatusId: string;
+      priority?: number;
+      projectId?: string | null;
+    }) => {
+      await fn({ data });
+      queryClient.invalidateQueries({ queryKey: ["git", "status-rules"] });
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useUpdateGitStatusRuleMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(updateGitStatusRule);
+  return useCallback(
+    async (data: z.infer<typeof updateGitStatusRuleValidator>) => {
+      await fn({ data });
+      queryClient.invalidateQueries({ queryKey: ["git", "status-rules"] });
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useDeleteGitStatusRuleMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(deleteGitStatusRule);
+  return useCallback(
+    async (id: string) => {
+      await fn({ data: { id } });
+      queryClient.invalidateQueries({ queryKey: ["git", "status-rules"] });
+    },
+    [fn, queryClient]
+  );
+}
+
+function invalidatePrReviewQueries(
+  queryClient: ReturnType<typeof useQueryClient>,
+  taskId: string,
+  pullRequestId: string
+) {
+  return Promise.all([
+    queryClient.invalidateQueries({
+      queryKey: ["git", "pr-comments", taskId, pullRequestId],
+    }),
+    queryClient.invalidateQueries({
+      queryKey: ["git", "pr-status", taskId, pullRequestId],
+    }),
+    queryClient.invalidateQueries({ queryKey: ["git", "task", taskId] }),
+  ]);
+}
+
+export function useStartTaskPullRequestReviewMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(startTaskPullRequestReview);
+  return useCallback(
+    async (input: {
+      taskId: string;
+      pullRequestId: string;
+      commitId: string;
+    }) => {
+      const result = await fn({ data: input });
+      await invalidatePrReviewQueries(
+        queryClient,
+        input.taskId,
+        input.pullRequestId
+      );
+      return result;
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useCreateTaskPullRequestReviewCommentMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(createTaskPullRequestReviewComment);
+  return useCallback(
+    async (input: {
+      taskId: string;
+      pullRequestId: string;
+      reviewId: number;
+      body: string;
+      commitId: string;
+      path: string;
+      line: number;
+      side?: "LEFT" | "RIGHT";
+      inReplyTo?: number;
+    }) => {
+      const result = await fn({ data: input });
+      const key = ["git", "pr-comments", input.taskId, input.pullRequestId] as const;
+      queryClient.setQueryData(key, (prev: unknown) => {
+        if (!prev || typeof prev !== "object") return prev;
+        const data = prev as {
+          pendingComments?: GitReviewComment[];
+        };
+        const pending = data.pendingComments ?? [];
+        const existing = pending.find((c) => c.id === result.id);
+        const nextPending = existing
+          ? pending.map((c) =>
+              c.id === result.id ? pickRicherReviewComment(c, result) : c
+            )
+          : [...pending, result];
+        return { ...data, pendingComments: nextPending };
+      });
+      await invalidatePrReviewQueries(
+        queryClient,
+        input.taskId,
+        input.pullRequestId
+      );
+      return result;
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useSubmitTaskPullRequestReviewMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(submitTaskPullRequestReview);
+  return useCallback(
+    async (input: {
+      taskId: string;
+      pullRequestId: string;
+      commitId: string;
+      body?: string;
+      event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES";
+    }) => {
+      await fn({ data: input });
+      await invalidatePrReviewQueries(
+        queryClient,
+        input.taskId,
+        input.pullRequestId
+      );
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useSetTaskPullRequestReviewThreadResolvedMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(setTaskPullRequestReviewThreadResolved);
+  return useCallback(
+    async (input: {
+      taskId: string;
+      pullRequestId: string;
+      threadNodeId: string;
+      resolved: boolean;
+    }) => {
+      await fn({ data: input });
+      await invalidatePrReviewQueries(
+        queryClient,
+        input.taskId,
+        input.pullRequestId
+      );
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useMergeTaskPullRequestMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(mergeTaskPullRequest);
+  return useCallback(
+    async (input: {
+      taskId: string;
+      pullRequestId: string;
+      mergeMethod?: "merge" | "squash" | "rebase";
+    }) => {
+      const result = await fn({ data: input });
+      await invalidatePrReviewQueries(
+        queryClient,
+        input.taskId,
+        input.pullRequestId
+      );
+      return result;
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useCloseTaskPullRequestMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(closeTaskPullRequest);
+  return useCallback(
+    async (input: { taskId: string; pullRequestId: string }) => {
+      const result = await fn({ data: input });
+      await invalidatePrReviewQueries(
+        queryClient,
+        input.taskId,
+        input.pullRequestId
+      );
+      return result;
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useDiscardTaskPullRequestReviewMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(discardTaskPullRequestReview);
+  return useCallback(
+    async (input: { taskId: string; pullRequestId: string }) => {
+      await fn({ data: input });
+      await invalidatePrReviewQueries(
+        queryClient,
+        input.taskId,
+        input.pullRequestId
+      );
+    },
+    [fn, queryClient]
+  );
+}
+
+export function useDisconnectGitHubMutation() {
+  const queryClient = useQueryClient();
+  const fn = useServerFn(disconnectGitHub);
+  return useCallback(async () => {
+    await fn();
+    queryClient.invalidateQueries({ queryKey: ["git"] });
+  }, [fn, queryClient]);
+}
diff --git a/src/db/mutations/projects.ts b/src/db/mutations/projects.ts
index 47dbb06..2c9e353 100644
--- a/src/db/mutations/projects.ts
+++ b/src/db/mutations/projects.ts
@@ -17,15 +17,21 @@ import { useQueryClient } from "@tanstack/react-query";
 import { sync } from "./sync";
 import { and, eq } from "drizzle-orm";
 import z from "zod";
+import { suggestTaskKeyPrefix } from "~/lib/git/task-key";
 
 const createProject = createServerFn({ method: "POST" })
   .inputValidator(insertProjectValidator)
   .handler(async ({ data }) => {
     const session = await requireSessionFromRequest();
 
+    const taskKeyPrefix =
+      data.taskKeyPrefix ?? suggestTaskKeyPrefix(data.name ?? "Project");
+
     await db.insert(projects).values({
       ...data,
       id: uuid(),
+      taskKeyPrefix,
+      nextTaskNumber: 1,
       owner: getOwningIdentity(session),
       createdAt: new Date(),
       updatedAt: new Date(),
@@ -64,6 +70,7 @@ const updateProject = createServerFn({ method: "POST" })
         name: data.name,
         description: data.description,
         logo: data.logo,
+        taskKeyPrefix: data.taskKeyPrefix,
         updatedAt: new Date(),
       })
       .where(
diff --git a/src/db/mutations/tasks.ts b/src/db/mutations/tasks.ts
index ee53997..9da594b 100644
--- a/src/db/mutations/tasks.ts
+++ b/src/db/mutations/tasks.ts
@@ -8,6 +8,7 @@ import {
   Label,
   labelsToTasks,
   PRIORITY_VALUES,
+  projects,
   Task,
   taskAssignees,
   tasks,
@@ -158,19 +159,51 @@ const createTask = createServerFn({ method: "POST" })
   .inputValidator(insertTaskValidator)
   .handler(async ({ data }) => {
     const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+
+    const newTask = await db.transaction(async (trx) => {
+      const [project] = await trx
+        .select({
+          id: projects.id,
+          nextTaskNumber: projects.nextTaskNumber,
+        })
+        .from(projects)
+        .where(
+          and(eq(projects.id, data.projectId), eq(projects.owner, owner))
+        )
+        .for("update");
+
+      if (!project) {
+        throw new Error("Project not found");
+      }
+
+      const taskNumber = project.nextTaskNumber;
+
+      await trx
+        .update(projects)
+        .set({
+          nextTaskNumber: taskNumber + 1,
+          updatedAt: new Date(),
+        })
+        .where(eq(projects.id, data.projectId));
+
+      const row = {
+        id: uuid(),
+        number: taskNumber,
+        name: data.name,
+        description: data.description,
+        statusId: data.statusId,
+        projectId: data.projectId,
+        sprintId: data.sprintId,
+        owner,
+        createdAt: new Date(),
+        updatedAt: new Date(),
+      };
+
+      await trx.insert(tasks).values(row);
+      return row;
+    });
 
-    const newTask = {
-      id: uuid(),
-      name: data.name,
-      description: data.description,
-      statusId: data.statusId,
-      projectId: data.projectId,
-      sprintId: data.sprintId,
-      owner: getOwningIdentity(session),
-      createdAt: new Date(),
-      updatedAt: new Date(),
-    };
-    await db.insert(tasks).values(newTask);
     await sync(`task-create`, { data });
     await syncDashboard(newTask.owner, { data });
 
diff --git a/src/db/queries/git-install.ts b/src/db/queries/git-install.ts
new file mode 100644
index 0000000..d9d3577
--- /dev/null
+++ b/src/db/queries/git-install.ts
@@ -0,0 +1,26 @@
+import { createServerFn } from "@tanstack/react-start";
+import { isGitHubConfigured, isGitHubUserOAuthConfigured } from "~/env";
+import {
+  getGitHubAppInstallUrl,
+  getGitHubUserOAuthUrl,
+} from "~/lib/git/github/app";
+import { requireSessionFromRequest } from "~/lib/session";
+import { getOwningIdentity } from "~/lib/utils";
+
+export const fetchGitHubInstallUrls = createServerFn({ method: "GET" }).handler(
+  async () => {
+    const session = await requireSessionFromRequest();
+    if (!isGitHubConfigured()) {
+      return { configured: false as const };
+    }
+    const owner = getOwningIdentity(session);
+    return {
+      configured: true as const,
+      installUrl: getGitHubAppInstallUrl(owner),
+      userOAuthConfigured: isGitHubUserOAuthConfigured(),
+      userOAuthUrl: isGitHubUserOAuthConfigured()
+        ? getGitHubUserOAuthUrl("user-oauth")
+        : null,
+    };
+  }
+);
diff --git a/src/db/queries/git.server.ts b/src/db/queries/git.server.ts
new file mode 100644
index 0000000..2f50c22
--- /dev/null
+++ b/src/db/queries/git.server.ts
@@ -0,0 +1,937 @@
+import "@tanstack/react-start/server-only";
+
+import { and, desc, eq } from "drizzle-orm";
+import { db } from "~/db";
+import {
+  gitConnections,
+  gitRepositories,
+  gitStatusRules,
+  gitUserLinks,
+  projectGitRepositories,
+  taskGitActivity,
+  taskGitBranches,
+  taskGitPullRequests,
+  tasks,
+  projects,
+} from "~/db/schema";
+import { getOwningIdentity } from "~/lib/utils";
+import { formatTaskKey } from "~/lib/git/task-key";
+import {
+  resolveTaskRepository,
+  toGitRepo,
+} from "~/lib/git/resolve-task-repo";
+import {
+  getActiveBranch,
+  getLatestPr,
+  getOpenPr,
+  resolveBaseRef,
+  type TaskDevPhaseContext,
+} from "~/lib/git/task-dev-phase";
+import type { GitPullRequest, GitReviewComment } from "~/lib/git/types";
+import { upsertTaskPullRequest } from "~/lib/git/upsert-task-pull-request";
+import { sync } from "~/db/mutations/sync";
+import { v7 as uuid } from "uuid";
+import { pickRicherReviewComment } from "~/lib/git/review-comment-annotation";
+import { applyReviewThreadMetadata } from "~/lib/git/review-thread";
+import type { requireSessionFromRequest } from "~/lib/session";
+
+function mergeReviewCommentsById(
+  ...lists: GitReviewComment[][]
+): GitReviewComment[] {
+  const byId = new Map<number, GitReviewComment>();
+  for (const list of lists) {
+    for (const comment of list) {
+      const existing = byId.get(comment.id);
+      byId.set(
+        comment.id,
+        existing ? pickRicherReviewComment(existing, comment) : comment
+      );
+    }
+  }
+  return [...byId.values()];
+}
+
+export type TaskGitSummary = {
+  hasBranch: boolean;
+  hasOpenPr: boolean;
+  prState?: string;
+};
+
+export async function getGitConnectionForSession(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>
+) {
+  const owner = getOwningIdentity(session);
+  const connection = await db.query.gitConnections.findFirst({
+    where: and(
+      eq(gitConnections.owner, owner),
+      eq(gitConnections.provider, "github")
+    ),
+  });
+  const userLink = await db.query.gitUserLinks.findFirst({
+    where: and(
+      eq(gitUserLinks.userId, session.user.id),
+      eq(gitUserLinks.provider, "github")
+    ),
+  });
+  return { connection: connection ?? null, userLink: userLink ?? null };
+}
+
+export async function getGitRepositoriesForSession(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>
+) {
+  const owner = getOwningIdentity(session);
+  return db.query.gitRepositories.findMany({
+    where: eq(gitRepositories.owner, owner),
+    orderBy: (r, { asc }) => [asc(r.fullName)],
+  });
+}
+
+export async function getGitStatusRulesForSession(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>
+) {
+  const owner = getOwningIdentity(session);
+  return db.query.gitStatusRules.findMany({
+    where: eq(gitStatusRules.owner, owner),
+    orderBy: (r, { desc: d }) => [d(r.priority)],
+  });
+}
+
+export async function getProjectGitConfig(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  projectId: string
+) {
+  const owner = getOwningIdentity(session);
+  const project = await db.query.projects.findFirst({
+    where: and(eq(projects.id, projectId), eq(projects.owner, owner)),
+  });
+  if (!project) return null;
+
+  const links = await db.query.projectGitRepositories.findMany({
+    where: eq(projectGitRepositories.projectId, projectId),
+    with: { repository: true },
+  });
+
+  return {
+    project,
+    repositories: links.map((l) => ({
+      ...l,
+      repository: l.repository,
+    })),
+  };
+}
+
+export async function getTaskGitContext(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string
+) {
+  const owner = getOwningIdentity(session);
+
+  const task = await db.query.tasks.findFirst({
+    where: and(eq(tasks.id, taskId), eq(tasks.owner, owner)),
+    with: { project: true },
+  });
+  if (!task) return null;
+
+  const taskKey = formatTaskKey(task.project.taskKeyPrefix, task.number);
+
+  const branches = await db.query.taskGitBranches.findMany({
+    where: and(
+      eq(taskGitBranches.taskId, taskId),
+      eq(taskGitBranches.owner, owner)
+    ),
+    with: { repository: true },
+  });
+
+  const pullRequests = await db.query.taskGitPullRequests.findMany({
+    where: and(
+      eq(taskGitPullRequests.taskId, taskId),
+      eq(taskGitPullRequests.owner, owner)
+    ),
+    with: { repository: true, branch: true },
+    orderBy: (pr, { desc: d }) => [d(pr.updatedAt)],
+  });
+
+  const activity = await db.query.taskGitActivity.findMany({
+    where: eq(taskGitActivity.taskId, taskId),
+    orderBy: [desc(taskGitActivity.occurredAt)],
+    limit: 50,
+  });
+
+  const projectRepos = await db.query.projectGitRepositories.findMany({
+    where: eq(projectGitRepositories.projectId, task.projectId),
+    with: { repository: true },
+  });
+
+  return {
+    taskKey,
+    branches,
+    pullRequests,
+    activity: activity.map((a) => ({
+      ...a,
+      payload: JSON.parse(JSON.stringify(a.payload)) as Record<
+        string,
+        string | number | boolean | null
+      >,
+    })),
+    projectRepos,
+  };
+}
+
+export async function getTaskPullRequestDiff(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string
+) {
+  const owner = getOwningIdentity(session);
+
+  const pr = await db.query.taskGitPullRequests.findFirst({
+    where: and(
+      eq(taskGitPullRequests.id, pullRequestId),
+      eq(taskGitPullRequests.taskId, taskId),
+      eq(taskGitPullRequests.owner, owner)
+    ),
+    with: { repository: { with: { connection: true } } },
+  });
+  if (!pr?.repository?.connection) {
+    throw new Error("Pull request not found");
+  }
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(pr.repository.connection.provider);
+  const repo = toGitRepo(pr.repository);
+  const [files, livePr] = await Promise.all([
+    provider.getPullRequestDiff(pr.repository.connection, repo, pr.number),
+    provider.getPullRequest(pr.repository.connection, repo, pr.number),
+  ]);
+
+  return { files, pullRequest: pr, headSha: livePr.headSha };
+}
+
+export async function getTaskBranchCommits(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string
+) {
+  const owner = getOwningIdentity(session);
+  const ctx = await getTaskGitContext(session, taskId);
+  if (!ctx) throw new Error("Task not found");
+
+  const phaseCtx = ctx as TaskDevPhaseContext;
+  const branch = getActiveBranch(phaseCtx);
+  if (!branch) {
+    return { commits: [], baseRef: "main", headRef: "", scope: "branch" as const };
+  }
+
+  const { repository, connection } = await resolveTaskRepository(owner, taskId);
+  const openPr = getOpenPr(phaseCtx);
+  const latestPr = getLatestPr(phaseCtx);
+  const pr = openPr ?? latestPr;
+  const baseRef = resolveBaseRef(phaseCtx, branch, pr);
+  const headRef = branch.ref;
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const compare = await provider.compareBranches(
+    connection,
+    toGitRepo(repository),
+    baseRef,
+    headRef
+  );
+
+  return {
+    commits: compare.commits,
+    baseRef,
+    headRef,
+    scope: "branch" as const,
+  };
+}
+
+export async function getTaskPullRequestCommits(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string
+) {
+  const owner = getOwningIdentity(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const commits = await provider.listPullRequestCommits(
+    connection,
+    toGitRepo(repository),
+    pr.number
+  );
+
+  return {
+    commits,
+    baseRef: pr.baseRef,
+    headRef: pr.headRef,
+    scope: "pull_request" as const,
+    pullRequestId: pr.id,
+    prNumber: pr.number,
+  };
+}
+
+export async function getTaskCommitDiff(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  sha: string,
+  repositoryId?: string
+) {
+  const owner = getOwningIdentity(session);
+  const { repository, connection } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { repositoryId }
+  );
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const files = await provider.getCommitDiff(
+    connection,
+    toGitRepo(repository),
+    sha
+  );
+
+  return { files, sha, scope: "commit" as const };
+}
+
+export async function getTaskBranchDiff(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string
+) {
+  const owner = getOwningIdentity(session);
+  const ctx = await getTaskGitContext(session, taskId);
+  if (!ctx) throw new Error("Task not found");
+
+  const phaseCtx = ctx as TaskDevPhaseContext;
+  const branch = getActiveBranch(phaseCtx);
+  if (!branch) throw new Error("No branch linked to this task");
+
+  const { repository, connection } = await resolveTaskRepository(owner, taskId);
+  const pr = getOpenPr(phaseCtx) ?? getLatestPr(phaseCtx);
+  const baseRef = resolveBaseRef(phaseCtx, branch, pr);
+  const headRef = branch.ref;
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const compare = await provider.compareBranches(
+    connection,
+    toGitRepo(repository),
+    baseRef,
+    headRef
+  );
+
+  return {
+    files: compare.files,
+    baseRef,
+    headRef,
+    scope: "branch" as const,
+  };
+}
+
+export async function getTaskPullRequestReviewComments(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string
+) {
+  const owner = getOwningIdentity(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const userLink = await db.query.gitUserLinks.findFirst({
+    where: and(
+      eq(gitUserLinks.userId, session.user.id),
+      eq(gitUserLinks.provider, "github")
+    ),
+  });
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+  const [installationComments, issueComments, reviews, livePr, reviewThreads] =
+    await Promise.all([
+      provider.listPullRequestReviewComments(connection, repo, pr.number),
+      provider.listPullRequestIssueComments(connection, repo, pr.number),
+      provider.listPullRequestReviews(connection, repo, pr.number),
+      provider.getPullRequest(connection, repo, pr.number),
+      provider.listPullRequestReviewThreads(connection, repo, pr.number),
+    ]);
+
+  let pendingReview: Awaited<
+    ReturnType<typeof provider.findPendingReviewForUser>
+  > = null;
+  let viewerComments: GitReviewComment[] = [];
+  let userAccessToken: string | null = null;
+  if (userLink?.providerLogin) {
+    try {
+      const { decryptSecret } = await import("~/lib/git/crypto");
+      const { pickPendingReviewForViewer } = await import(
+        "~/lib/git/pending-review"
+      );
+      const { Octokit } = await import("@octokit/rest");
+      userAccessToken = decryptSecret(userLink.accessToken);
+      const { data: authUser } = await new Octokit({
+        auth: userAccessToken,
+      }).rest.users.getAuthenticated();
+
+      pendingReview =
+        pickPendingReviewForViewer(reviews, authUser.login) ??
+        (await provider.findPendingReviewForUser(
+          connection,
+          repo,
+          pr.number,
+          userLink.providerLogin,
+          userAccessToken,
+          { retries: 3 }
+        ));
+
+      viewerComments = await provider.listPullRequestReviewCommentsAsUser(
+        connection,
+        repo,
+        pr.number,
+        userAccessToken
+      );
+    } catch {
+      pendingReview = null;
+      viewerComments = [];
+      userAccessToken = null;
+    }
+  }
+
+  const pendingReviewId = pendingReview?.id ?? null;
+  let pendingReviewComments: GitReviewComment[] = [];
+  let threadComments: GitReviewComment[] = [];
+  if (pendingReviewId != null && userAccessToken) {
+    try {
+      pendingReviewComments =
+        await provider.listPullRequestReviewCommentsForReviewAsUser(
+          connection,
+          repo,
+          pr.number,
+          pendingReviewId,
+          userAccessToken
+        );
+    } catch {
+      pendingReviewComments = [];
+    }
+    try {
+      threadComments =
+        await provider.listPullRequestReviewThreadCommentsAsUser(
+          connection,
+          repo,
+          pr.number,
+          userAccessToken
+        );
+    } catch {
+      threadComments = [];
+    }
+  }
+
+  const comments = applyReviewThreadMetadata(
+    mergeReviewCommentsById(
+      installationComments,
+      viewerComments,
+      pendingReviewComments,
+      threadComments
+    ),
+    reviewThreads
+  );
+  const pendingReviewCommentIds = new Set(
+    pendingReviewComments.map((c) => c.id)
+  );
+  const pendingComments =
+    pendingReviewId != null
+      ? comments.filter(
+          (c) =>
+            c.reviewId === pendingReviewId ||
+            pendingReviewCommentIds.has(c.id)
+        )
+      : [];
+  const pendingIds = new Set(pendingComments.map((c) => c.id));
+  const submittedComments = comments.filter((c) => !pendingIds.has(c.id));
+
+  return {
+    comments: submittedComments,
+    issueComments,
+    pendingComments,
+    pendingReview,
+    reviews: reviews.filter((r) => r.state !== "PENDING"),
+    headSha: livePr.headSha,
+    pullRequest: {
+      id: pr.id,
+      number: livePr.number,
+      url: livePr.url,
+      title: livePr.title,
+      body: livePr.body,
+      state: livePr.state,
+      headRef: livePr.headRef,
+      baseRef: livePr.baseRef,
+    },
+    viewerLogin: userLink?.providerLogin ?? null,
+  };
+}
+
+export async function requireGitUserAccessToken(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>
+) {
+  const { token } = await requireGitUserLink(session);
+  return token;
+}
+
+export async function requireGitUserLink(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>
+) {
+  const userLink = await db.query.gitUserLinks.findFirst({
+    where: and(
+      eq(gitUserLinks.userId, session.user.id),
+      eq(gitUserLinks.provider, "github")
+    ),
+  });
+  if (!userLink?.providerLogin) {
+    throw new Error(
+      "Connect your GitHub account in Settings → Integrations to review pull requests."
+    );
+  }
+  const { decryptSecret } = await import("~/lib/git/crypto");
+  return {
+    token: decryptSecret(userLink.accessToken),
+    login: userLink.providerLogin,
+  };
+}
+
+async function resolvePendingReviewForUser(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string,
+  commitId: string
+) {
+  const owner = getOwningIdentity(session);
+  const { token, login } = await requireGitUserLink(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  let pending = await provider.findPendingReviewForUser(
+    connection,
+    repo,
+    pr.number,
+    login,
+    token,
+    { retries: 3 }
+  );
+  if (!pending) {
+    pending = await provider.startPendingReview(
+      connection,
+      repo,
+      pr.number,
+      commitId,
+      token,
+      login
+    );
+  }
+
+  return { provider, connection, repo, pr, token, pending };
+}
+
+export async function startTaskPullRequestReview(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string,
+  commitId: string
+) {
+  const owner = getOwningIdentity(session);
+  const { token, login } = await requireGitUserLink(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  const existing = await provider.findPendingReviewForUser(
+    connection,
+    repo,
+    pr.number,
+    login,
+    token,
+    { retries: 3 }
+  );
+  if (existing) return existing;
+
+  return provider.startPendingReview(
+    connection,
+    repo,
+    pr.number,
+    commitId,
+    token,
+    login
+  );
+}
+
+export async function setTaskPullRequestReviewThreadResolved(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string,
+  input: { threadNodeId: string; resolved: boolean }
+) {
+  const { token } = await requireGitUserLink(session);
+  const owner = getOwningIdentity(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  await provider.setReviewThreadResolved(
+    input.threadNodeId,
+    input.resolved,
+    token
+  );
+}
+
+export async function createTaskPullRequestReviewComment(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string,
+  input: {
+    body: string;
+    commitId: string;
+    path: string;
+    line: number;
+    side?: "LEFT" | "RIGHT";
+    inReplyTo?: number;
+    reviewId: number;
+  }
+) {
+  const { isGitHubApiDebugEnabled, logGitHubApi } = await import(
+    "~/lib/git/github/api-log"
+  );
+  if (isGitHubApiDebugEnabled()) {
+    logGitHubApi("createTaskPullRequestReviewComment (server)", {
+      taskId,
+      pullRequestId,
+      reviewId: input.reviewId,
+      path: input.path,
+      line: input.line,
+      side: input.side,
+      commitId: input.commitId,
+    });
+  }
+
+  const owner = getOwningIdentity(session);
+  const { token } = await requireGitUserLink(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  return provider.createPullRequestReviewComment(
+    connection,
+    repo,
+    pr.number,
+    {
+      body: input.body,
+      commitId: input.commitId,
+      path: input.path,
+      line: input.line,
+      side: input.side,
+      inReplyTo: input.inReplyTo,
+      reviewId: input.reviewId,
+    },
+    token
+  );
+}
+
+export async function submitTaskPullRequestReview(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string,
+  input: {
+    body?: string;
+    event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES";
+    commitId: string;
+  }
+) {
+  const owner = getOwningIdentity(session);
+  const { token, login } = await requireGitUserLink(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  const pending = await provider.findPendingReviewForUser(
+    connection,
+    repo,
+    pr.number,
+    login,
+    token,
+    { retries: 3 }
+  );
+
+  await provider.submitPullRequestReview(
+    connection,
+    repo,
+    pr.number,
+    {
+      reviewId: pending?.id,
+      commitId: input.commitId,
+      body: input.body,
+      event: input.event,
+    },
+    token
+  );
+}
+
+export async function discardTaskPullRequestReview(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string
+) {
+  const owner = getOwningIdentity(session);
+  const { token, login } = await requireGitUserLink(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  const pending = await provider.findPendingReviewForUser(
+    connection,
+    repo,
+    pr.number,
+    login,
+    token,
+    { retries: 3 }
+  );
+  if (!pending) return;
+
+  await provider.discardPendingReview(
+    connection,
+    repo,
+    pr.number,
+    pending.id,
+    token
+  );
+}
+
+export async function getTaskPullRequestMergeStatus(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string
+) {
+  const owner = getOwningIdentity(session);
+  const { repository, connection, pullRequest: pr } = await resolveTaskRepository(
+    owner,
+    taskId,
+    { pullRequestId }
+  );
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  let userAccessToken: string | undefined;
+  try {
+    userAccessToken = await requireGitUserAccessToken(session);
+  } catch {
+    userAccessToken = undefined;
+  }
+
+  return provider.getPullRequestMergeStatus(connection, repo, pr.number, {
+    userAccessToken,
+  });
+}
+
+async function persistTaskPullRequestFromGit(
+  owner: string,
+  taskId: string,
+  repositoryId: string,
+  branchId: string | null,
+  pr: GitPullRequest
+) {
+  await upsertTaskPullRequest({
+    owner,
+    taskId,
+    repositoryId,
+    branchId,
+    providerPrId: pr.providerPrId,
+    number: pr.number,
+    url: pr.url,
+    title: pr.title,
+    state: pr.state,
+    headRef: pr.headRef,
+    baseRef: pr.baseRef,
+    mergedAt: pr.mergedAt,
+    closedAt: pr.closedAt,
+  });
+}
+
+export async function mergeTaskPullRequest(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string,
+  options?: { mergeMethod?: "merge" | "squash" | "rebase" }
+) {
+  const owner = getOwningIdentity(session);
+  const token = await requireGitUserAccessToken(session);
+  const { repository, connection, pullRequest: pr, branch } =
+    await resolveTaskRepository(owner, taskId, { pullRequestId });
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  const merged = await provider.mergePullRequest(
+    connection,
+    repo,
+    pr.number,
+    token,
+    options
+  );
+
+  await persistTaskPullRequestFromGit(
+    owner,
+    taskId,
+    repository.id,
+    branch?.id ?? pr.branchId,
+    merged
+  );
+
+  await db.insert(taskGitActivity).values({
+    id: uuid(),
+    taskId,
+    owner,
+    type: "pull_request",
+    payload: {
+      number: merged.number,
+      state: merged.state,
+      url: merged.url,
+      title: pr.title,
+      action: "merged",
+    },
+    occurredAt: new Date(),
+  });
+
+  await sync(`task-update-${taskId}`, { id: taskId });
+  return merged;
+}
+
+export async function closeTaskPullRequest(
+  session: Awaited<ReturnType<typeof requireSessionFromRequest>>,
+  taskId: string,
+  pullRequestId: string
+) {
+  const owner = getOwningIdentity(session);
+  const token = await requireGitUserAccessToken(session);
+  const { repository, connection, pullRequest: pr, branch } =
+    await resolveTaskRepository(owner, taskId, { pullRequestId });
+  if (!pr) throw new Error("Pull request not found");
+
+  const { getGitProvider } = await import("~/lib/git/factory");
+  const provider = getGitProvider(connection.provider);
+  const repo = toGitRepo(repository);
+
+  const closed = await provider.closePullRequest(
+    connection,
+    repo,
+    pr.number,
+    token
+  );
+
+  await persistTaskPullRequestFromGit(
+    owner,
+    taskId,
+    repository.id,
+    branch?.id ?? pr.branchId,
+    closed
+  );
+
+  await db.insert(taskGitActivity).values({
+    id: uuid(),
+    taskId,
+    owner,
+    type: "pull_request",
+    payload: {
+      number: closed.number,
+      state: closed.state,
+      url: closed.url,
+      title: pr.title,
+      action: "closed",
+    },
+    occurredAt: new Date(),
+  });
+
+  await sync(`task-update-${taskId}`, { id: taskId });
+  return closed;
+}
+
+export async function getTaskGitSummariesForTasks(
+  owner: string,
+  taskIds: string[]
+): Promise<Record<string, TaskGitSummary>> {
+  if (taskIds.length === 0) return {};
+
+  const branches = await db.query.taskGitBranches.findMany({
+    where: and(eq(taskGitBranches.owner, owner)),
+  });
+  const prs = await db.query.taskGitPullRequests.findMany({
+    where: and(eq(taskGitPullRequests.owner, owner)),
+  });
+
+  const result: Record<string, TaskGitSummary> = {};
+  for (const taskId of taskIds) {
+    const taskBranches = branches.filter(
+      (b) => b.taskId === taskId && b.state === "active"
+    );
+    const taskPrs = prs.filter((p) => p.taskId === taskId);
+    const openPr = taskPrs.find((p) => p.state === "open" || p.state === "draft");
+    result[taskId] = {
+      hasBranch: taskBranches.length > 0,
+      hasOpenPr: Boolean(openPr),
+      prState: openPr?.state ?? taskPrs[0]?.state,
+    };
+  }
+  return result;
+}
diff --git a/src/db/queries/git.ts b/src/db/queries/git.ts
new file mode 100644
index 0000000..c1e4be3
--- /dev/null
+++ b/src/db/queries/git.ts
@@ -0,0 +1,306 @@
+import { queryOptions, useQuery, useSuspenseQuery } from "@tanstack/react-query";
+import { createServerFn } from "@tanstack/react-start";
+import { requireSessionFromRequest } from "~/lib/session";
+import { getOwningIdentity } from "~/lib/utils";
+import { z } from "zod";
+
+export type { TaskGitSummary } from "~/db/queries/git.server";
+
+export const fetchGitConnection = createServerFn({ method: "GET" }).handler(
+  async () => {
+    const session = await requireSessionFromRequest();
+    const { getGitConnectionForSession } = await import(
+      "~/db/queries/git.server"
+    );
+    return getGitConnectionForSession(session);
+  }
+);
+
+export const fetchGitRepositories = createServerFn({ method: "GET" }).handler(
+  async () => {
+    const session = await requireSessionFromRequest();
+    const { getGitRepositoriesForSession } = await import(
+      "~/db/queries/git.server"
+    );
+    return getGitRepositoriesForSession(session);
+  }
+);
+
+export const fetchGitStatusRules = createServerFn({ method: "GET" }).handler(
+  async () => {
+    const session = await requireSessionFromRequest();
+    const { getGitStatusRulesForSession } = await import(
+      "~/db/queries/git.server"
+    );
+    return getGitStatusRulesForSession(session);
+  }
+);
+
+export const fetchProjectGitConfig = createServerFn({ method: "GET" })
+  .inputValidator(z.object({ projectId: z.string().uuid() }))
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getProjectGitConfig } = await import("~/db/queries/git.server");
+    return getProjectGitConfig(session, data.projectId);
+  });
+
+export const fetchTaskGitContext = createServerFn({ method: "GET" })
+  .inputValidator(z.object({ taskId: z.string().uuid() }))
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskGitContext } = await import("~/db/queries/git.server");
+    return getTaskGitContext(session, data.taskId);
+  });
+
+export const fetchTaskPullRequestDiff = createServerFn({ method: "GET" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskPullRequestDiff } = await import("~/db/queries/git.server");
+    return getTaskPullRequestDiff(session, data.taskId, data.pullRequestId);
+  });
+
+export const fetchTaskBranchCommits = createServerFn({ method: "GET" })
+  .inputValidator(z.object({ taskId: z.string().uuid() }))
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskBranchCommits } = await import("~/db/queries/git.server");
+    return getTaskBranchCommits(session, data.taskId);
+  });
+
+export const fetchTaskPullRequestCommits = createServerFn({ method: "GET" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskPullRequestCommits } = await import(
+      "~/db/queries/git.server"
+    );
+    return getTaskPullRequestCommits(
+      session,
+      data.taskId,
+      data.pullRequestId
+    );
+  });
+
+export const fetchTaskCommitDiff = createServerFn({ method: "GET" })
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      sha: z.string().min(7),
+      repositoryId: z.string().uuid().optional(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskCommitDiff } = await import("~/db/queries/git.server");
+    return getTaskCommitDiff(
+      session,
+      data.taskId,
+      data.sha,
+      data.repositoryId
+    );
+  });
+
+export const fetchTaskBranchDiff = createServerFn({ method: "GET" })
+  .inputValidator(z.object({ taskId: z.string().uuid() }))
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskBranchDiff } = await import("~/db/queries/git.server");
+    return getTaskBranchDiff(session, data.taskId);
+  });
+
+export const fetchTaskPullRequestMergeStatus = createServerFn({
+  method: "GET",
+})
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskPullRequestMergeStatus } = await import(
+      "~/db/queries/git.server"
+    );
+    return getTaskPullRequestMergeStatus(
+      session,
+      data.taskId,
+      data.pullRequestId
+    );
+  });
+
+export const fetchTaskPullRequestReviewComments = createServerFn({
+  method: "GET",
+})
+  .inputValidator(
+    z.object({
+      taskId: z.string().uuid(),
+      pullRequestId: z.string().uuid(),
+    })
+  )
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const { getTaskPullRequestReviewComments } = await import(
+      "~/db/queries/git.server"
+    );
+    return getTaskPullRequestReviewComments(
+      session,
+      data.taskId,
+      data.pullRequestId
+    );
+  });
+
+export function gitConnectionQueryOptions() {
+  return queryOptions({
+    queryKey: ["git", "connection"],
+    queryFn: () => fetchGitConnection(),
+  });
+}
+
+export function gitRepositoriesQueryOptions() {
+  return queryOptions({
+    queryKey: ["git", "repositories"],
+    queryFn: () => fetchGitRepositories(),
+  });
+}
+
+export function gitStatusRulesQueryOptions() {
+  return queryOptions({
+    queryKey: ["git", "status-rules"],
+    queryFn: () => fetchGitStatusRules(),
+  });
+}
+
+export function projectGitConfigQueryOptions(projectId: string) {
+  return queryOptions({
+    queryKey: ["git", "project", projectId],
+    queryFn: () => fetchProjectGitConfig({ data: { projectId } }),
+  });
+}
+
+export function taskGitContextQueryOptions(taskId: string) {
+  return queryOptions({
+    queryKey: ["git", "task", taskId],
+    queryFn: () => fetchTaskGitContext({ data: { taskId } }),
+  });
+}
+
+export function useGitConnectionQuery() {
+  return useQuery(gitConnectionQueryOptions());
+}
+
+export function useGitRepositoriesQuery() {
+  return useSuspenseQuery(gitRepositoriesQueryOptions());
+}
+
+export function useGitStatusRulesQuery() {
+  return useSuspenseQuery(gitStatusRulesQueryOptions());
+}
+
+export function useProjectGitConfigQuery(projectId: string) {
+  return useQuery(projectGitConfigQueryOptions(projectId));
+}
+
+export function useTaskGitContextQuery(taskId: string, enabled = true) {
+  return useQuery({
+    ...taskGitContextQueryOptions(taskId),
+    enabled,
+  });
+}
+
+export function useTaskBranchCommitsQuery(taskId: string, enabled = true) {
+  return useQuery({
+    queryKey: ["git", "commits", taskId, "branch"],
+    queryFn: () => fetchTaskBranchCommits({ data: { taskId } }),
+    enabled,
+  });
+}
+
+export function useTaskPullRequestCommitsQuery(
+  taskId: string,
+  pullRequestId: string | undefined,
+  enabled = true
+) {
+  return useQuery({
+    queryKey: ["git", "commits", taskId, "pr", pullRequestId],
+    queryFn: () =>
+      fetchTaskPullRequestCommits({
+        data: { taskId, pullRequestId: pullRequestId! },
+      }),
+    enabled: enabled && Boolean(pullRequestId),
+  });
+}
+
+export function useTaskCommitDiffQuery(
+  taskId: string,
+  sha: string | undefined,
+  enabled = true
+) {
+  return useQuery({
+    queryKey: ["git", "diff", taskId, "commit", sha],
+    queryFn: () => fetchTaskCommitDiff({ data: { taskId, sha: sha! } }),
+    enabled: enabled && Boolean(sha),
+  });
+}
+
+export function useTaskBranchDiffQuery(taskId: string, enabled = true) {
+  return useQuery({
+    queryKey: ["git", "diff", taskId, "branch"],
+    queryFn: () => fetchTaskBranchDiff({ data: { taskId } }),
+    enabled,
+  });
+}
+
+export function useTaskPullRequestReviewCommentsQuery(
+  taskId: string,
+  pullRequestId: string | undefined,
+  enabled = true
+) {
+  return useQuery({
+    queryKey: ["git", "pr-comments", taskId, pullRequestId],
+    queryFn: () =>
+      fetchTaskPullRequestReviewComments({
+        data: { taskId, pullRequestId: pullRequestId! },
+      }),
+    enabled: enabled && Boolean(pullRequestId),
+    placeholderData: (prev) => prev,
+  });
+}
+
+export function useTaskPullRequestMergeStatusQuery(
+  taskId: string,
+  pullRequestId: string | undefined,
+  enabled = true
+) {
+  return useQuery({
+    queryKey: ["git", "pr-status", taskId, pullRequestId],
+    queryFn: () =>
+      fetchTaskPullRequestMergeStatus({
+        data: { taskId, pullRequestId: pullRequestId! },
+      }),
+    enabled: enabled && Boolean(pullRequestId),
+    refetchInterval: 60_000,
+  });
+}
+
+export const fetchTaskGitSummaries = createServerFn({ method: "GET" })
+  .inputValidator(z.object({ taskIds: z.array(z.string().uuid()) }))
+  .handler(async ({ data }) => {
+    const session = await requireSessionFromRequest();
+    const owner = getOwningIdentity(session);
+    const { getTaskGitSummariesForTasks } = await import(
+      "~/db/queries/git.server"
+    );
+    return getTaskGitSummariesForTasks(owner, data.taskIds);
+  });
diff --git a/src/db/queries/tasks.ts b/src/db/queries/tasks.ts
index ab2b02c..cf1d45b 100644
--- a/src/db/queries/tasks.ts
+++ b/src/db/queries/tasks.ts
@@ -46,6 +46,8 @@ function stubProject(task: BoardTaskRow): Project {
     id: task.projectId,
     name: "",
     description: null,
+    taskKeyPrefix: null,
+    nextTaskNumber: 1,
     owner: task.owner,
     logo: null,
     createdAt: task.createdAt,
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 5eda446..ff2b9c6 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -12,6 +12,7 @@ import {
   pgEnum,
   primaryKey,
   index,
+  uniqueIndex,
   boolean,
   jsonb,
 } from "drizzle-orm/pg-core";
@@ -59,6 +60,7 @@ export const tasks = createTable(
   "task",
   {
     id: uuid("id").primaryKey(),
+    number: integer("number").notNull(),
     name: varchar("name", { length: 256 }).notNull(),
     description: text("description"),
     statusId: uuid("status_id"),
@@ -76,6 +78,11 @@ export const tasks = createTable(
     index("task_owner_idx").on(example.owner),
     index("task_owner_project_idx").on(example.owner, example.projectId),
     index("task_owner_sprint_idx").on(example.owner, example.sprintId),
+    uniqueIndex("task_owner_project_number_idx").on(
+      example.owner,
+      example.projectId,
+      example.number
+    ),
   ]
 );
 
@@ -172,6 +179,8 @@ export const projects = createTable(
     id: uuid("id").primaryKey(),
     name: varchar("name", { length: 256 }).notNull(),
     description: text("description"),
+    taskKeyPrefix: varchar("task_key_prefix", { length: 16 }),
+    nextTaskNumber: integer("next_task_number").notNull().default(1),
     owner: varchar("owner", { length: 256 }).notNull(),
     logo: varchar("logo", { length: 256 }),
     createdAt: timestamp("created_at", { withTimezone: true })
@@ -179,7 +188,13 @@ export const projects = createTable(
       .notNull(),
     updatedAt: timestamp("updated_at", { withTimezone: true }),
   },
-  (example) => [index("project_owner_idx").on(example.owner)]
+  (example) => [
+    index("project_owner_idx").on(example.owner),
+    uniqueIndex("project_owner_task_key_prefix_idx").on(
+      example.owner,
+      example.taskKeyPrefix
+    ),
+  ]
 );
 
 export const comments = createTable(
@@ -364,6 +379,7 @@ export type TaskWithRelations = Task & {
 export const insertTaskValidator = createInsertSchema(tasks, {
   id: (schema) => schema.optional(),
   owner: (schema) => schema.optional(),
+  number: (schema) => schema.optional(),
 });
 /** Partial task updates; dates are coerced because RPC serializes them as strings. */
 export const updateTaskValidator = z.object({
@@ -378,7 +394,7 @@ export const updateTaskValidator = z.object({
 });
 export type CreateTask = Omit<
   typeof tasks.$inferInsert,
-  "id" | "createdAt" | "updatedAt" | "owner"
+  "id" | "createdAt" | "updatedAt" | "owner" | "number"
 >;
 export type UpdateTask = z.infer<typeof updateTaskValidator>;
 
@@ -481,6 +497,8 @@ export const updateProjectValidator = createInsertSchema(projects, {
   name: (schema) => schema.optional(),
   description: (schema) => schema.optional(),
   logo: (schema) => schema.optional(),
+  taskKeyPrefix: (schema) => schema.optional(),
+  nextTaskNumber: (schema) => schema.optional(),
 });
 export type CreateProject = Omit<
   typeof projects.$inferInsert,
@@ -522,3 +540,5 @@ export type CreateLabel = Omit<
   "id" | "createdAt" | "updatedAt" | "owner"
 >;
 export type UpdateLabel = Omit<Label, "createdAt" | "updatedAt" | "owner">;
+
+export * from "./schema/git";
diff --git a/src/db/schema/git.ts b/src/db/schema/git.ts
new file mode 100644
index 0000000..3cbed85
--- /dev/null
+++ b/src/db/schema/git.ts
@@ -0,0 +1,336 @@
+import { relations, sql } from "drizzle-orm";
+import {
+  boolean,
+  index,
+  integer,
+  jsonb,
+  pgEnum,
+  primaryKey,
+  text,
+  timestamp,
+  uniqueIndex,
+  uuid,
+  varchar,
+} from "drizzle-orm/pg-core";
+import { createInsertSchema } from "drizzle-zod";
+import { z } from "zod";
+import { pgTableCreator } from "drizzle-orm/pg-core";
+
+const createTable = pgTableCreator((name) => `project-y_${name}`);
+
+export const gitProviderEnum = pgEnum("git_provider", [
+  "github",
+  "gitlab",
+  "gitlab_self_hosted",
+]);
+export const GIT_PROVIDER_VALUES = gitProviderEnum.enumValues;
+export type GitProviderType = (typeof GIT_PROVIDER_VALUES)[number];
+
+export const gitPrStateEnum = pgEnum("git_pr_state", [
+  "open",
+  "closed",
+  "merged",
+  "draft",
+]);
+export const GIT_PR_STATE_VALUES = gitPrStateEnum.enumValues;
+export type GitPrState = (typeof GIT_PR_STATE_VALUES)[number];
+
+export const gitBranchStateEnum = pgEnum("git_branch_state", [
+  "active",
+  "deleted",
+]);
+export const GIT_BRANCH_STATE_VALUES = gitBranchStateEnum.enumValues;
+
+export const gitConnections = createTable(
+  "git_connection",
+  {
+    id: uuid("id").primaryKey(),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    provider: gitProviderEnum("provider").notNull(),
+    installationId: varchar("installation_id", { length: 64 }),
+    accountLogin: varchar("account_login", { length: 256 }),
+    accountType: varchar("account_type", { length: 32 }),
+    instanceUrl: varchar("instance_url", { length: 512 }),
+    credentials: text("credentials"),
+    connectedByUserId: varchar("connected_by_user_id", { length: 256 }),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (t) => [
+    index("git_connection_owner_idx").on(t.owner),
+    uniqueIndex("git_connection_owner_provider_install_idx").on(
+      t.owner,
+      t.provider,
+      t.installationId
+    ),
+  ]
+);
+
+export const gitUserLinks = createTable(
+  "git_user_link",
+  {
+    id: uuid("id").primaryKey(),
+    userId: varchar("user_id", { length: 256 }).notNull(),
+    provider: gitProviderEnum("provider").notNull(),
+    providerUserId: varchar("provider_user_id", { length: 256 }).notNull(),
+    providerLogin: varchar("provider_login", { length: 256 }),
+    accessToken: text("access_token").notNull(),
+    refreshToken: text("refresh_token"),
+    tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
+    scopes: text("scopes"),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (t) => [
+    index("git_user_link_user_idx").on(t.userId),
+    uniqueIndex("git_user_link_user_provider_idx").on(t.userId, t.provider),
+  ]
+);
+
+export const gitRepositories = createTable(
+  "git_repository",
+  {
+    id: uuid("id").primaryKey(),
+    connectionId: uuid("connection_id")
+      .notNull()
+      .references(() => gitConnections.id, { onDelete: "cascade" }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    externalId: varchar("external_id", { length: 64 }).notNull(),
+    fullName: varchar("full_name", { length: 512 }).notNull(),
+    defaultBranch: varchar("default_branch", { length: 256 })
+      .notNull()
+      .default("main"),
+    htmlUrl: varchar("html_url", { length: 512 }).notNull(),
+    isArchived: boolean("is_archived").notNull().default(false),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (t) => [
+    index("git_repository_connection_idx").on(t.connectionId),
+    index("git_repository_owner_idx").on(t.owner),
+    uniqueIndex("git_repository_connection_external_idx").on(
+      t.connectionId,
+      t.externalId
+    ),
+  ]
+);
+
+export const projectGitRepositories = createTable(
+  "project_git_repository",
+  {
+    projectId: uuid("project_id").notNull(),
+    repositoryId: uuid("repository_id")
+      .notNull()
+      .references(() => gitRepositories.id, { onDelete: "cascade" }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    isPrimary: boolean("is_primary").notNull().default(false),
+    defaultBaseBranch: varchar("default_base_branch", { length: 256 }),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+  },
+  (t) => [
+    primaryKey({ columns: [t.projectId, t.repositoryId] }),
+    index("project_git_repository_owner_idx").on(t.owner),
+  ]
+);
+
+export const taskGitBranches = createTable(
+  "task_git_branch",
+  {
+    id: uuid("id").primaryKey(),
+    taskId: uuid("task_id").notNull(),
+    repositoryId: uuid("repository_id")
+      .notNull()
+      .references(() => gitRepositories.id, { onDelete: "cascade" }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    ref: varchar("ref", { length: 512 }).notNull(),
+    sha: varchar("sha", { length: 64 }),
+    state: gitBranchStateEnum("state").notNull().default("active"),
+    createdByUserId: varchar("created_by_user_id", { length: 256 }),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (t) => [
+    index("task_git_branch_task_idx").on(t.taskId),
+    index("task_git_branch_owner_ref_idx").on(t.owner, t.ref),
+    uniqueIndex("task_git_branch_task_repo_active_idx").on(
+      t.taskId,
+      t.repositoryId
+    ),
+  ]
+);
+
+export const taskGitPullRequests = createTable(
+  "task_git_pull_request",
+  {
+    id: uuid("id").primaryKey(),
+    taskId: uuid("task_id").notNull(),
+    repositoryId: uuid("repository_id")
+      .notNull()
+      .references(() => gitRepositories.id, { onDelete: "cascade" }),
+    branchId: uuid("branch_id").references(() => taskGitBranches.id, {
+      onDelete: "set null",
+    }),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    number: integer("number").notNull(),
+    providerPrId: varchar("provider_pr_id", { length: 64 }).notNull(),
+    url: varchar("url", { length: 512 }).notNull(),
+    title: varchar("title", { length: 512 }).notNull(),
+    state: gitPrStateEnum("state").notNull().default("open"),
+    headRef: varchar("head_ref", { length: 512 }).notNull(),
+    baseRef: varchar("base_ref", { length: 512 }).notNull(),
+    checksStatus: varchar("checks_status", { length: 32 }),
+    mergedAt: timestamp("merged_at", { withTimezone: true }),
+    closedAt: timestamp("closed_at", { withTimezone: true }),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (t) => [
+    index("task_git_pr_task_idx").on(t.taskId),
+    uniqueIndex("task_git_pr_repo_number_idx").on(t.repositoryId, t.number),
+  ]
+);
+
+export const taskGitActivity = createTable(
+  "task_git_activity",
+  {
+    id: uuid("id").primaryKey(),
+    taskId: uuid("task_id").notNull(),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    type: varchar("type", { length: 64 }).notNull(),
+    payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
+    actorLogin: varchar("actor_login", { length: 256 }),
+    occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+  },
+  (t) => [index("task_git_activity_task_idx").on(t.taskId)]
+);
+
+export const gitStatusRules = createTable(
+  "git_status_rule",
+  {
+    id: uuid("id").primaryKey(),
+    owner: varchar("owner", { length: 256 }).notNull(),
+    projectId: uuid("project_id"),
+    pattern: text("pattern").notNull(),
+    targetStatusId: uuid("target_status_id").notNull(),
+    priority: integer("priority").notNull().default(0),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+    updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
+  },
+  (t) => [index("git_status_rule_owner_idx").on(t.owner)]
+);
+
+export const gitWebhookDeliveries = createTable(
+  "git_webhook_delivery",
+  {
+    id: varchar("id", { length: 64 }).primaryKey(),
+    provider: gitProviderEnum("provider").notNull(),
+    eventType: varchar("event_type", { length: 128 }).notNull(),
+    processedAt: timestamp("processed_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+  },
+  (t) => [index("git_webhook_delivery_provider_idx").on(t.provider)]
+);
+
+export const gitCommitStatusApplications = createTable(
+  "git_commit_status_application",
+  {
+    deliveryId: varchar("delivery_id", { length: 64 }).notNull(),
+    commitSha: varchar("commit_sha", { length: 64 }).notNull(),
+    ruleId: uuid("rule_id").notNull(),
+    taskId: uuid("task_id").notNull(),
+    createdAt: timestamp("created_at", { withTimezone: true })
+      .default(sql`CURRENT_TIMESTAMP`)
+      .notNull(),
+  },
+  (t) => [
+    primaryKey({ columns: [t.deliveryId, t.commitSha, t.ruleId, t.taskId] }),
+  ]
+);
+
+export const gitConnectionRelations = relations(gitConnections, ({ many }) => ({
+  repositories: many(gitRepositories),
+}));
+
+export const gitRepositoryRelations = relations(
+  gitRepositories,
+  ({ one, many }) => ({
+    connection: one(gitConnections, {
+      fields: [gitRepositories.connectionId],
+      references: [gitConnections.id],
+    }),
+    projectLinks: many(projectGitRepositories),
+  })
+);
+
+export const projectGitRepositoryRelations = relations(
+  projectGitRepositories,
+  ({ one }) => ({
+    repository: one(gitRepositories, {
+      fields: [projectGitRepositories.repositoryId],
+      references: [gitRepositories.id],
+    }),
+  })
+);
+
+export const taskGitBranchRelations = relations(taskGitBranches, ({ one }) => ({
+  repository: one(gitRepositories, {
+    fields: [taskGitBranches.repositoryId],
+    references: [gitRepositories.id],
+  }),
+}));
+
+export const taskGitPullRequestRelations = relations(
+  taskGitPullRequests,
+  ({ one }) => ({
+    repository: one(gitRepositories, {
+      fields: [taskGitPullRequests.repositoryId],
+      references: [gitRepositories.id],
+    }),
+    branch: one(taskGitBranches, {
+      fields: [taskGitPullRequests.branchId],
+      references: [taskGitBranches.id],
+    }),
+  })
+);
+
+export const insertGitStatusRuleValidator = createInsertSchema(gitStatusRules, {
+  id: (s) => s.optional(),
+  owner: (s) => s.optional(),
+  projectId: (s) => s.nullable().optional(),
+  updatedAt: (s) => s.optional(),
+  createdAt: (s) => s.optional(),
+});
+
+export const updateGitStatusRuleValidator = z.object({
+  id: z.string().uuid(),
+  pattern: z.string().min(1).optional(),
+  targetStatusId: z.string().uuid().optional(),
+  priority: z.number().int().optional(),
+  projectId: z.string().uuid().nullable().optional(),
+});
+
+export type GitConnection = typeof gitConnections.$inferSelect;
+export type GitRepository = typeof gitRepositories.$inferSelect;
+export type ProjectGitRepository = typeof projectGitRepositories.$inferSelect;
+export type TaskGitBranch = typeof taskGitBranches.$inferSelect;
+export type TaskGitPullRequest = typeof taskGitPullRequests.$inferSelect;
+export type TaskGitActivity = typeof taskGitActivity.$inferSelect;
+export type GitStatusRule = typeof gitStatusRules.$inferSelect;
diff --git a/src/env.ts b/src/env.ts
index fa5dba8..b0c9542 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -19,6 +19,12 @@ export const env = createEnv({
     SYNC_PUBLISH_KEY: z.string().min(1),
     UPLOADTHING_TOKEN: z.string().min(1),
     SYNC_ENGINE_URL: z.string().url().optional(),
+    GITHUB_APP_ID: z.string().min(1).optional(),
+    GITHUB_APP_PRIVATE_KEY: z.string().min(1).optional(),
+    GITHUB_APP_WEBHOOK_SECRET: z.string().min(1).optional(),
+    GITHUB_APP_CLIENT_ID: z.string().min(1).optional(),
+    GITHUB_APP_CLIENT_SECRET: z.string().min(1).optional(),
+    GIT_TOKEN_ENCRYPTION_KEY: z.string().min(32).optional(),
   },
 
   clientPrefix: "PUBLIC_",
@@ -29,3 +35,21 @@ export const env = createEnv({
 
   emptyStringAsUndefined: true,
 });
+
+export function isGitHubConfigured() {
+  return Boolean(
+    env.GITHUB_APP_ID &&
+      env.GITHUB_APP_PRIVATE_KEY &&
+      env.GITHUB_APP_WEBHOOK_SECRET &&
+      env.GIT_TOKEN_ENCRYPTION_KEY
+  );
+}
+
+/** Personal GitHub account linking (user-to-server OAuth). */
+export function isGitHubUserOAuthConfigured() {
+  return Boolean(
+    isGitHubConfigured() &&
+      env.GITHUB_APP_CLIENT_ID &&
+      env.GITHUB_APP_CLIENT_SECRET
+  );
+}
diff --git a/src/hooks/use-git-task-live-sync.ts b/src/hooks/use-git-task-live-sync.ts
new file mode 100644
index 0000000..be21646
--- /dev/null
+++ b/src/hooks/use-git-task-live-sync.ts
@@ -0,0 +1,15 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useEventSource } from "~/hooks/use-event-source";
+
+/** Refetch git task context when webhooks or mutations sync this task. */
+export function useGitTaskLiveSync(taskId: string | undefined) {
+  const queryClient = useQueryClient();
+
+  useEventSource({
+    topics: taskId ? [`task-update-${taskId}`] : [],
+    callback: () => {
+      if (!taskId) return;
+      void queryClient.invalidateQueries({ queryKey: ["git", "task", taskId] });
+    },
+  });
+}
diff --git a/src/lib/format-relative-time.ts b/src/lib/format-relative-time.ts
new file mode 100644
index 0000000..fcff6af
--- /dev/null
+++ b/src/lib/format-relative-time.ts
@@ -0,0 +1,35 @@
+export function formatRelativeTime(
+  date: Date | string,
+  options?: { now?: Date }
+): string {
+  const d = typeof date === "string" ? new Date(date) : date;
+  const now = options?.now ?? new Date();
+  let diffSec = Math.round((d.getTime() - now.getTime()) / 1000);
+  const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+
+  const absSec = Math.abs(diffSec);
+  if (absSec < 60) return rtf.format(diffSec, "second");
+
+  let diffMin = Math.round(diffSec / 60);
+  if (Math.abs(diffMin) < 60) return rtf.format(diffMin, "minute");
+
+  let diffHour = Math.round(diffMin / 60);
+  if (Math.abs(diffHour) < 24) return rtf.format(diffHour, "hour");
+
+  let diffDay = Math.round(diffHour / 24);
+  if (Math.abs(diffDay) < 7) return rtf.format(diffDay, "day");
+
+  return new Intl.DateTimeFormat(undefined, {
+    month: "short",
+    day: "numeric",
+    year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
+  }).format(d);
+}
+
+export function formatDateTimeTooltip(date: Date | string): string {
+  const d = typeof date === "string" ? new Date(date) : date;
+  return new Intl.DateTimeFormat(undefined, {
+    dateStyle: "full",
+    timeStyle: "short",
+  }).format(d);
+}
diff --git a/src/lib/git/activity-label.ts b/src/lib/git/activity-label.ts
new file mode 100644
index 0000000..fd7528e
--- /dev/null
+++ b/src/lib/git/activity-label.ts
@@ -0,0 +1,48 @@
+export function formatGitActivityLabel(
+  type: string,
+  payload: Record<string, string | number | boolean | null>,
+  actorLogin?: string | null
+): string {
+  const actor = actorLogin ? ` · ${actorLogin}` : "";
+
+  switch (type) {
+    case "commit": {
+      const message = payload.message;
+      const text =
+        typeof message === "string" && message.length > 0
+          ? message.slice(0, 80) + (message.length > 80 ? "…" : "")
+          : "Commit pushed";
+      return `${text}${actor}`;
+    }
+    case "branch_created": {
+      const ref = payload.ref;
+      return typeof ref === "string"
+        ? `Branch ${ref} created${actor}`
+        : `Branch created${actor}`;
+    }
+    case "pull_request": {
+      const title = payload.title;
+      const number = payload.number;
+      const state = payload.state;
+      const action = payload.action;
+      const num = typeof number === "number" ? `#${number}` : "";
+      const verb =
+        action === "opened"
+          ? "opened"
+          : action === "reopened"
+            ? "reopened"
+            : action === "merged" || (action === "closed" && state === "merged")
+              ? "merged"
+              : action === "closed"
+                ? "closed"
+                : "linked";
+      const titleText =
+        typeof title === "string" && title.length > 0
+          ? `: ${title.slice(0, 60)}${title.length > 60 ? "…" : ""}`
+          : "";
+      return `Pull request ${num} ${verb}${titleText}${actor}`;
+    }
+    default:
+      return `${type.replace(/_/g, " ")}${actor}`;
+  }
+}
diff --git a/src/lib/git/check-avatar.ts b/src/lib/git/check-avatar.ts
new file mode 100644
index 0000000..75c9a14
--- /dev/null
+++ b/src/lib/git/check-avatar.ts
@@ -0,0 +1,18 @@
+import type { GitPullRequestCheck } from "~/lib/git/types";
+
+/** GitHub avatars for well-known check apps when the API omits `app.owner`. */
+const FALLBACK_AVATARS = {
+  netlify: "https://avatars.githubusercontent.com/u/7892489?v=4&size=64",
+  coderabbit: "https://avatars.githubusercontent.com/u/132027554?v=4&size=64",
+} as const;
+
+export function resolveCheckAvatarUrl(
+  check: GitPullRequestCheck
+): string | null {
+  if (check.avatarUrl) return check.avatarUrl;
+
+  const hay = `${check.appSlug ?? ""} ${check.name}`.toLowerCase();
+  if (hay.includes("netlify")) return FALLBACK_AVATARS.netlify;
+  if (hay.includes("coderabbit")) return FALLBACK_AVATARS.coderabbit;
+  return null;
+}
diff --git a/src/lib/git/constants.ts b/src/lib/git/constants.ts
new file mode 100644
index 0000000..a4d28a5
--- /dev/null
+++ b/src/lib/git/constants.ts
@@ -0,0 +1,2 @@
+export const MULTI_REPO_SELECTION_MESSAGE =
+  "Choose which repository to use — this project has multiple linked repositories.";
diff --git a/src/lib/git/crypto.ts b/src/lib/git/crypto.ts
new file mode 100644
index 0000000..233d38e
--- /dev/null
+++ b/src/lib/git/crypto.ts
@@ -0,0 +1,45 @@
+import "@tanstack/react-start/server-only";
+
+import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
+import { env } from "~/env";
+
+const ALGORITHM = "aes-256-gcm";
+const IV_LENGTH = 12;
+
+function getEncryptionKey(): Buffer {
+  const key = env.GIT_TOKEN_ENCRYPTION_KEY;
+  if (!key) {
+    throw new Error("GIT_TOKEN_ENCRYPTION_KEY is not configured");
+  }
+  return Buffer.from(key.slice(0, 64).padEnd(64, "0").slice(0, 32));
+}
+
+export function encryptSecret(plaintext: string): string {
+  const iv = randomBytes(IV_LENGTH);
+  const cipher = createCipheriv(ALGORITHM, getEncryptionKey(), iv);
+  const encrypted = Buffer.concat([
+    cipher.update(plaintext, "utf8"),
+    cipher.final(),
+  ]);
+  const tag = cipher.getAuthTag();
+  return [
+    iv.toString("base64"),
+    tag.toString("base64"),
+    encrypted.toString("base64"),
+  ].join(":");
+}
+
+export function decryptSecret(ciphertext: string): string {
+  const [ivB64, tagB64, dataB64] = ciphertext.split(":");
+  if (!ivB64 || !tagB64 || !dataB64) {
+    throw new Error("Invalid encrypted secret format");
+  }
+  const iv = Buffer.from(ivB64, "base64");
+  const tag = Buffer.from(tagB64, "base64");
+  const data = Buffer.from(dataB64, "base64");
+  const decipher = createDecipheriv(ALGORITHM, getEncryptionKey(), iv);
+  decipher.setAuthTag(tag);
+  return Buffer.concat([decipher.update(data), decipher.final()]).toString(
+    "utf8"
+  );
+}
diff --git a/src/lib/git/diff-file-tree.ts b/src/lib/git/diff-file-tree.ts
new file mode 100644
index 0000000..0916462
--- /dev/null
+++ b/src/lib/git/diff-file-tree.ts
@@ -0,0 +1,28 @@
+import type { GitStatus, GitStatusEntry } from "@pierre/trees";
+import type { GitDiffFile } from "~/lib/git/types";
+
+export function diffFilesToTreePaths(files: GitDiffFile[]): string[] {
+  return files.map((f) => f.path);
+}
+
+export function diffFilesToGitStatus(files: GitDiffFile[]): GitStatusEntry[] {
+  return files.map((file) => ({
+    path: file.path,
+    status: diffFileStatusToGitStatus(file.status),
+  }));
+}
+
+function diffFileStatusToGitStatus(
+  status: GitDiffFile["status"]
+): GitStatus {
+  switch (status) {
+    case "added":
+      return "added";
+    case "removed":
+      return "deleted";
+    case "modified":
+      return "modified";
+    case "renamed":
+      return "renamed";
+  }
+}
diff --git a/src/lib/git/diff-position.ts b/src/lib/git/diff-position.ts
new file mode 100644
index 0000000..eb73662
--- /dev/null
+++ b/src/lib/git/diff-position.ts
@@ -0,0 +1,41 @@
+/**
+ * Computes GitHub's deprecated but still required `position` index for a line
+ * in a unified diff patch (lines after the first @@ hunk header, 1-based).
+ */
+export function computeDiffPosition(
+  patch: string,
+  line: number,
+  side: "LEFT" | "RIGHT"
+): number | null {
+  let position = 0;
+  let newLine = 0;
+  let oldLine = 0;
+
+  for (const raw of patch.split("\n")) {
+    if (raw.startsWith("@@")) {
+      const match = raw.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+      if (match) {
+        oldLine = parseInt(match[1], 10);
+        newLine = parseInt(match[2], 10);
+      }
+      continue;
+    }
+
+    position += 1;
+
+    if (raw.startsWith("+")) {
+      if (side === "RIGHT" && newLine === line) return position;
+      newLine += 1;
+    } else if (raw.startsWith("-")) {
+      if (side === "LEFT" && oldLine === line) return position;
+      oldLine += 1;
+    } else if (raw.startsWith(" ")) {
+      if (side === "RIGHT" && newLine === line) return position;
+      if (side === "LEFT" && oldLine === line) return position;
+      newLine += 1;
+      oldLine += 1;
+    }
+  }
+
+  return null;
+}
diff --git a/src/lib/git/errors.ts b/src/lib/git/errors.ts
new file mode 100644
index 0000000..a29fca3
--- /dev/null
+++ b/src/lib/git/errors.ts
@@ -0,0 +1,120 @@
+type GitHubApiErrorBody = {
+  message?: string;
+  errors?: Array<{ message?: string; code?: string }>;
+};
+
+type GitHubRequestError = {
+  message?: string;
+  status?: number;
+  response?: { data?: GitHubApiErrorBody; url?: string };
+  request?: { url?: string };
+};
+
+const NO_COMMITS_PATTERN = /no commits between/i;
+const PENDING_REVIEW_EXISTS_PATTERN =
+  /one pending review|user_id can only have one pending review|already submitted a review|review has already been submitted/i;
+
+export function isPendingReviewConflictMessage(message: string): boolean {
+  return (
+    PENDING_REVIEW_EXISTS_PATTERN.test(message) ||
+    /already have a review in progress/i.test(message)
+  );
+}
+
+export function isDuplicatePendingReviewError(error: unknown): boolean {
+  if (!(error && typeof error === "object")) return false;
+  const err = error as GitHubRequestError;
+  if (err.status !== 422) return false;
+  const data = err.response?.data;
+  const parts = [
+    data?.message,
+    ...(data?.errors?.map((e) => e.message) ?? []),
+    err.message,
+  ]
+    .filter(Boolean)
+    .join(" ");
+  return PENDING_REVIEW_EXISTS_PATTERN.test(parts);
+}
+
+function isPullRequestReviewCreationRequest(error: GitHubRequestError): boolean {
+  const url = error.response?.url ?? error.request?.url ?? "";
+  return /\/pulls\/\d+\/reviews(?:\?|$|\/)/.test(url);
+}
+
+export function formatGitHubApiError(error: unknown): string {
+  if (!(error && typeof error === "object")) {
+    return "Git request failed";
+  }
+
+  const err = error as GitHubRequestError;
+  const data = err.response?.data;
+  const detailMessages =
+    data?.errors?.map((e) => e.message).filter(Boolean) ?? [];
+
+  if (detailMessages.some((m) => NO_COMMITS_PATTERN.test(m ?? ""))) {
+    return "Push at least one commit to the branch before opening a pull request.";
+  }
+
+  const pendingReviewConflict =
+    detailMessages.some((m) => PENDING_REVIEW_EXISTS_PATTERN.test(m ?? "")) ||
+    PENDING_REVIEW_EXISTS_PATTERN.test(data?.message ?? "");
+
+  if (pendingReviewConflict && isPullRequestReviewCreationRequest(err)) {
+    return "You already have a review in progress on this pull request. Use Discard review to cancel it, or continue adding comments.";
+  }
+
+  if (detailMessages.length > 0) {
+    return detailMessages.join(" ");
+  }
+
+  const apiMessage = data?.message?.trim();
+  if (apiMessage && !/^validation failed$/i.test(apiMessage)) {
+    return apiMessage;
+  }
+
+  const raw = err.message?.trim();
+  if (raw && !/^validation failed$/i.test(raw)) {
+    return raw;
+  }
+
+  if (err.status === 422) {
+    return "Push at least one commit to the branch before opening a pull request.";
+  }
+
+  return "Failed to complete the Git request";
+}
+
+/** User-facing message for errors surfaced in toasts (server fn, fetch, etc.). */
+export function formatClientError(
+  error: unknown,
+  fallback = "Something went wrong"
+): string {
+  if (error instanceof Error) {
+    const message = error.message.trim();
+    if (message && !looksLikeSerializedPayload(message)) {
+      return message;
+    }
+  }
+
+  if (error && typeof error === "object" && "message" in error) {
+    const message = String((error as { message: unknown }).message).trim();
+    if (message && !looksLikeSerializedPayload(message)) {
+      return formatGitHubApiError(error);
+    }
+  }
+
+  if (typeof error === "string" && error.trim()) {
+    return error.trim();
+  }
+
+  return fallback;
+}
+
+function looksLikeSerializedPayload(message: string): boolean {
+  const trimmed = message.trim();
+  return (
+    trimmed.startsWith("{") ||
+    trimmed.startsWith("[") ||
+    trimmed.includes('"issues"')
+  );
+}
diff --git a/src/lib/git/factory.ts b/src/lib/git/factory.ts
new file mode 100644
index 0000000..c4f1c15
--- /dev/null
+++ b/src/lib/git/factory.ts
@@ -0,0 +1,19 @@
+import type { GitProviderType } from "~/db/schema";
+import type { GitProvider } from "./types";
+import { GitHubProvider } from "./github/provider";
+import { GitLabProvider } from "./gitlab/provider";
+
+const githubProvider = new GitHubProvider();
+const gitlabProvider = new GitLabProvider();
+
+export function getGitProvider(provider: GitProviderType): GitProvider {
+  switch (provider) {
+    case "github":
+      return githubProvider;
+    case "gitlab":
+    case "gitlab_self_hosted":
+      return gitlabProvider;
+    default:
+      throw new Error(`Unknown git provider: ${provider}`);
+  }
+}
diff --git a/src/lib/git/github/api-log.ts b/src/lib/git/github/api-log.ts
new file mode 100644
index 0000000..57107c5
--- /dev/null
+++ b/src/lib/git/github/api-log.ts
@@ -0,0 +1,86 @@
+const LOG_PREFIX = "[git/github]";
+
+/** Log GitHub request payloads in dev or when GITHUB_API_DEBUG=true. */
+export function isGitHubApiDebugEnabled(): boolean {
+  return (
+    process.env.GITHUB_API_DEBUG === "true" ||
+    process.env.NODE_ENV !== "production"
+  );
+}
+
+function redactBody(body: string, maxLen = 120): string {
+  if (body.length <= maxLen) return body;
+  return `${body.slice(0, maxLen)}…`;
+}
+
+export function logGitHubApi(
+  operation: string,
+  details: Record<string, unknown>
+): void {
+  if (!isGitHubApiDebugEnabled()) return;
+  console.info(LOG_PREFIX, operation, JSON.stringify(details, null, 2));
+}
+
+export function logGitHubApiRest(
+  operation: string,
+  args: {
+    method: string;
+    url: string;
+    payload: Record<string, unknown>;
+    status?: number;
+    response?: unknown;
+    error?: unknown;
+  }
+): void {
+  const body =
+    typeof args.payload.body === "string"
+      ? redactBody(args.payload.body)
+      : args.payload.body;
+
+  logGitHubApi(operation, {
+    transport: "rest",
+    method: args.method,
+    url: args.url,
+    payload: { ...args.payload, body },
+    status: args.status,
+    response: args.response,
+    error: formatLogError(args.error),
+  });
+}
+
+export function logGitHubApiGraphql(
+  operation: string,
+  args: {
+    mutation?: string;
+    query?: string;
+    variables?: Record<string, unknown>;
+    response?: unknown;
+    error?: unknown;
+  }
+): void {
+  logGitHubApi(operation, {
+    transport: "graphql",
+    mutation: args.mutation,
+    query: args.query,
+    variables: args.variables,
+    response: args.response,
+    error: formatLogError(args.error),
+  });
+}
+
+function formatLogError(error: unknown): Record<string, unknown> | undefined {
+  if (!error) return undefined;
+  if (error instanceof Error) {
+    const err = error as Error & {
+      status?: number;
+      response?: { data?: unknown; url?: string };
+    };
+    return {
+      message: err.message,
+      status: err.status,
+      url: err.response?.url,
+      data: err.response?.data,
+    };
+  }
+  return { raw: String(error) };
+}
diff --git a/src/lib/git/github/app.ts b/src/lib/git/github/app.ts
new file mode 100644
index 0000000..be91284
--- /dev/null
+++ b/src/lib/git/github/app.ts
@@ -0,0 +1,110 @@
+import "@tanstack/react-start/server-only";
+
+import { App } from "@octokit/app";
+import { env, isGitHubConfigured, isGitHubUserOAuthConfigured } from "~/env";
+
+let githubApp: App | null = null;
+
+export function getGitHubApp(): App {
+  if (!isGitHubConfigured()) {
+    throw new Error("GitHub App is not configured");
+  }
+  if (!githubApp) {
+    githubApp = new App({
+      appId: env.GITHUB_APP_ID!,
+      privateKey: env.GITHUB_APP_PRIVATE_KEY!.replace(/\\n/g, "\n"),
+      oauth: env.GITHUB_APP_CLIENT_ID
+        ? {
+            clientId: env.GITHUB_APP_CLIENT_ID,
+            clientSecret: env.GITHUB_APP_CLIENT_SECRET ?? "",
+          }
+        : undefined,
+    });
+  }
+  return githubApp;
+}
+
+export function getGitHubAppSlug(): string {
+  return process.env.GITHUB_APP_SLUG ?? "project-y";
+}
+
+export function getGitHubAppInstallUrl(state?: string): string {
+  const slug = getGitHubAppSlug();
+  const base = `https://github.com/apps/${slug}/installations/new`;
+  if (!state) return base;
+  return `${base}?state=${encodeURIComponent(state)}`;
+}
+
+/** Must match the callback URL registered on the GitHub App. */
+export function getGitHubOAuthCallbackUrl(): string {
+  const base = env.BETTER_AUTH_URL.replace(/\/$/, "");
+  return `${base}/api/git/github/callback`;
+}
+
+export function getGitHubUserOAuthUrl(state: string): string {
+  if (!isGitHubUserOAuthConfigured()) {
+    throw new Error("GitHub user OAuth is not configured");
+  }
+  const app = getGitHubApp();
+  const { url } = app.oauth.getWebFlowAuthorizationUrl({
+    state,
+    redirectUrl: getGitHubOAuthCallbackUrl(),
+  });
+  return url;
+}
+
+type GitHubUserTokenResponse = {
+  access_token?: string;
+  refresh_token?: string;
+  expires_in?: number;
+  refresh_token_expires_in?: number;
+  error?: string;
+  error_description?: string;
+};
+
+/**
+ * Exchange OAuth code for a user access token.
+ * Uses fetch directly because @octokit/oauth-methods calls scope.split() before
+ * handling github-app responses that omit the scope field.
+ */
+export async function exchangeGitHubUserAccessToken(code: string) {
+  if (!isGitHubUserOAuthConfigured()) {
+    throw new Error("GitHub user OAuth is not configured");
+  }
+
+  const redirectUrl = getGitHubOAuthCallbackUrl();
+  const response = await fetch("https://github.com/login/oauth/access_token", {
+    method: "POST",
+    headers: {
+      Accept: "application/json",
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({
+      client_id: env.GITHUB_APP_CLIENT_ID,
+      client_secret: env.GITHUB_APP_CLIENT_SECRET,
+      code,
+      redirect_uri: redirectUrl,
+    }),
+  });
+
+  const data = (await response.json()) as GitHubUserTokenResponse;
+  if (!response.ok || data.error) {
+    throw new Error(
+      data.error_description ?? data.error ?? "GitHub token exchange failed"
+    );
+  }
+  if (!data.access_token) {
+    throw new Error("GitHub token exchange returned no access_token");
+  }
+
+  const expiresAt =
+    data.expires_in != null
+      ? new Date(Date.now() + data.expires_in * 1000).toISOString()
+      : undefined;
+
+  return {
+    accessToken: data.access_token,
+    refreshToken: data.refresh_token,
+    expiresAt,
+  };
+}
diff --git a/src/lib/git/github/installation-octokit.ts b/src/lib/git/github/installation-octokit.ts
new file mode 100644
index 0000000..64c23b6
--- /dev/null
+++ b/src/lib/git/github/installation-octokit.ts
@@ -0,0 +1,59 @@
+import "@tanstack/react-start/server-only";
+
+import { getGitHubApp } from "./app";
+
+export async function getInstallationOctokit(installationId: number) {
+  const app = getGitHubApp();
+  return app.getInstallationOctokit(installationId);
+}
+
+/** Octokit from @octokit/app — has `.request()`, not `.rest`. */
+export type InstallationOctokit = Awaited<
+  ReturnType<typeof getInstallationOctokit>
+>;
+
+export async function getInstallationAccount(installationId: number) {
+  const octokit = await getInstallationOctokit(installationId);
+  const { data } = await octokit.request("GET /app/installations/{installation_id}", {
+    installation_id: installationId,
+  });
+  const account = data.account;
+  const accountLogin =
+    account && "login" in account
+      ? account.login
+      : account && "slug" in account
+        ? account.slug
+        : "unknown";
+  const accountType =
+    account && "type" in account ? account.type : "Organization";
+  return { accountLogin, accountType };
+}
+
+type InstallationRepo = {
+  id: number;
+  full_name?: string;
+  name?: string;
+  owner?: { login?: string };
+  default_branch?: string;
+  html_url?: string;
+  archived?: boolean;
+};
+
+export async function listInstallationRepositories(
+  octokit: InstallationOctokit
+): Promise<InstallationRepo[]> {
+  const repos: InstallationRepo[] = [];
+  let page = 1;
+
+  while (true) {
+    const { data } = await octokit.request("GET /installation/repositories", {
+      per_page: 100,
+      page,
+    });
+    repos.push(...(data.repositories as InstallationRepo[]));
+    if (repos.length >= data.total_count) break;
+    page += 1;
+  }
+
+  return repos;
+}
diff --git a/src/lib/git/github/post-review-comment.ts b/src/lib/git/github/post-review-comment.ts
new file mode 100644
index 0000000..dc62201
--- /dev/null
+++ b/src/lib/git/github/post-review-comment.ts
@@ -0,0 +1,73 @@
+import { logGitHubApiRest } from "./api-log";
+
+/** Comfort-fade preview supports line/side on PR review comments. */
+const GITHUB_ACCEPT =
+  "application/vnd.github+json, application/vnd.github.comfort-fade-preview+json";
+
+/** POST PR review comment via fetch so the body is not filtered by Octokit OpenAPI types. */
+export async function postPullRequestReviewComment(
+  token: string,
+  owner: string,
+  repo: string,
+  pullNumber: number,
+  payload: Record<string, unknown>
+): Promise<Record<string, unknown>> {
+  const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/comments`;
+  logGitHubApiRest("POST pull review comment (fetch)", {
+    method: "POST",
+    url,
+    payload,
+  });
+
+  const res = await fetch(
+    url,
+    {
+      method: "POST",
+      headers: {
+        Authorization: `Bearer ${token}`,
+        Accept: GITHUB_ACCEPT,
+        "Content-Type": "application/json",
+        "X-GitHub-Api-Version": "2022-11-28",
+      },
+      body: JSON.stringify(payload),
+    }
+  );
+
+  const text = await res.text();
+  let data: Record<string, unknown> = {};
+  if (text) {
+    try {
+      data = JSON.parse(text) as Record<string, unknown>;
+    } catch {
+      data = { message: text };
+    }
+  }
+
+  if (!res.ok) {
+    const message =
+      typeof data.message === "string" ? data.message : res.statusText;
+    const err = Object.assign(new Error(message), {
+      status: res.status,
+      response: { data, url: res.url },
+    });
+    logGitHubApiRest("POST pull review comment failed", {
+      method: "POST",
+      url,
+      payload,
+      status: res.status,
+      response: data,
+      error: err,
+    });
+    throw err;
+  }
+
+  logGitHubApiRest("POST pull review comment ok", {
+    method: "POST",
+    url,
+    payload,
+    status: res.status,
+    response: { id: data.id, pull_request_review_id: data.pull_request_review_id },
+  });
+
+  return data;
+}
diff --git a/src/lib/git/github/provider.ts b/src/lib/git/github/provider.ts
new file mode 100644
index 0000000..0b482db
--- /dev/null
+++ b/src/lib/git/github/provider.ts
@@ -0,0 +1,2004 @@
+import "@tanstack/react-start/server-only";
+
+import { Octokit } from "@octokit/rest";
+import type { GitConnection } from "~/db/schema";
+import type {
+  CreateReviewCommentInput,
+  GitBranch,
+  GitCommit,
+  GitCompareResult,
+  GitDiffFile,
+  GitProvider,
+  GitPullRequest,
+  GitRepo,
+  GitPullRequestCheck,
+  GitPullRequestIssueComment,
+  GitPullRequestMergeStatus,
+  GitPullRequestReview,
+  GitReviewComment,
+  GitReviewThread,
+  SubmitPullRequestReviewInput,
+} from "../types";
+import {
+  getInstallationOctokit,
+  listInstallationRepositories,
+  type InstallationOctokit,
+} from "./installation-octokit";
+import { computeDiffPosition } from "../diff-position";
+import { formatGitHubApiError, isDuplicatePendingReviewError } from "../errors";
+import {
+  isPendingReviewState,
+  pickPendingReviewForViewer,
+} from "../pending-review";
+import { sleep } from "../retry";
+import { logGitHubApi, logGitHubApiGraphql, logGitHubApiRest } from "./api-log";
+import { postPullRequestReviewComment } from "./post-review-comment";
+
+function mapPrState(
+  state: string,
+  merged: boolean,
+  draft: boolean
+): GitPullRequest["state"] {
+  if (merged) return "merged";
+  if (draft) return "draft";
+  if (state === "open") return "open";
+  return "closed";
+}
+
+const NO_COMMITS_USER_MESSAGE =
+  /push at least one commit to the branch before opening a pull request/i;
+
+function parseRepoFullName(fullName: string) {
+  const [owner, repo] = fullName.split("/");
+  if (!owner || !repo) throw new Error(`Invalid repository name: ${fullName}`);
+  return { owner, repo };
+}
+
+export class GitHubProvider implements GitProvider {
+  private async getInstallationClient(
+    connection: GitConnection
+  ): Promise<InstallationOctokit> {
+    if (!connection.installationId) {
+      throw new Error("GitHub connection has no installation ID");
+    }
+    return getInstallationOctokit(Number(connection.installationId));
+  }
+
+  async listAccessibleRepos(connection: GitConnection): Promise<GitRepo[]> {
+    const octokit = await this.getInstallationClient(connection);
+    const repos = await listInstallationRepositories(octokit);
+    return repos.map((r) => ({
+      externalId: String(r.id),
+      fullName: r.full_name ?? `${r.owner?.login}/${r.name}`,
+      defaultBranch: r.default_branch ?? "main",
+      htmlUrl: r.html_url ?? "",
+      isArchived: r.archived ?? false,
+    }));
+  }
+
+  async syncRepositories(connection: GitConnection): Promise<GitRepo[]> {
+    return this.listAccessibleRepos(connection);
+  }
+
+  async createBranch(
+    connection: GitConnection,
+    repo: GitRepo,
+    options: { base: string; name: string; userAccessToken?: string }
+  ): Promise<GitBranch> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+
+    if (options.userAccessToken) {
+      const octokit = new Octokit({ auth: options.userAccessToken });
+      const refResponse = await octokit.rest.git.getRef({
+        owner,
+        repo: repoName,
+        ref: `heads/${options.base}`,
+      });
+      const baseSha = refResponse.data.object.sha;
+
+      try {
+        await octokit.rest.git.createRef({
+          owner,
+          repo: repoName,
+          ref: `refs/heads/${options.name}`,
+          sha: baseSha,
+        });
+      } catch (error: unknown) {
+        const err = error as { status?: number };
+        if (err.status !== 422) throw error;
+      }
+
+      const branchRef = await octokit.rest.git.getRef({
+        owner,
+        repo: repoName,
+        ref: `heads/${options.name}`,
+      });
+
+      return {
+        ref: options.name,
+        sha: branchRef.data.object.sha,
+        htmlUrl: `${repo.htmlUrl}/tree/${options.name}`,
+      };
+    }
+
+    const octokit = await this.getInstallationClient(connection);
+    const { data: refData } = await octokit.request(
+      "GET /repos/{owner}/{repo}/git/ref/{ref}",
+      {
+        owner,
+        repo: repoName,
+        ref: `heads/${options.base}`,
+      }
+    );
+    const baseSha = refData.object.sha;
+
+    try {
+      await octokit.request("POST /repos/{owner}/{repo}/git/refs", {
+        owner,
+        repo: repoName,
+        ref: `refs/heads/${options.name}`,
+        sha: baseSha,
+      });
+    } catch (error: unknown) {
+      const err = error as { status?: number };
+      if (err.status !== 422) throw error;
+    }
+
+    const { data: branchRef } = await octokit.request(
+      "GET /repos/{owner}/{repo}/git/ref/{ref}",
+      {
+        owner,
+        repo: repoName,
+        ref: `heads/${options.name}`,
+      }
+    );
+
+    return {
+      ref: options.name,
+      sha: branchRef.object.sha,
+      htmlUrl: `${repo.htmlUrl}/tree/${options.name}`,
+    };
+  }
+
+  async getPullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitPullRequest> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/pulls/{pull_number}",
+      {
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+      }
+    );
+    return this.mapPullRequest(data);
+  }
+
+  async getPullRequestMergeStatus(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    options?: { userAccessToken?: string }
+  ): Promise<GitPullRequestMergeStatus> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { mergeable, mergeableState, pr } =
+      await this.resolvePullRequestMergeability(
+        octokit,
+        owner,
+        repoName,
+        prNumber,
+        options?.userAccessToken
+      );
+
+    const checks: GitPullRequestCheck[] = [];
+    const seenNames = new Set<string>();
+
+    try {
+      const { data: checkRuns } = await octokit.request(
+        "GET /repos/{owner}/{repo}/commits/{ref}/check-runs",
+        {
+          owner,
+          repo: repoName,
+          ref: pr.head.sha,
+          per_page: 100,
+        }
+      );
+
+      for (const run of checkRuns.check_runs ?? []) {
+        const name = run.name ?? "Check";
+        seenNames.add(name);
+        checks.push({
+          id: `run-${run.id}`,
+          name,
+          status: this.mapCheckRunStatus(run.status),
+          conclusion: this.mapCheckRunConclusion(run.conclusion),
+          htmlUrl: run.html_url ?? pr.html_url,
+          description: run.output?.summary ?? run.output?.title ?? null,
+          source: "check_run",
+          appSlug: run.app?.slug ?? null,
+          avatarUrl: run.app?.owner?.avatar_url ?? null,
+        });
+      }
+    } catch {
+      // Fall through to combined commit status lookup.
+    }
+
+    try {
+      const { data: combined } = await octokit.request(
+        "GET /repos/{owner}/{repo}/commits/{ref}/status",
+        {
+          owner,
+          repo: repoName,
+          ref: pr.head.sha,
+          per_page: 100,
+        }
+      );
+      for (const status of combined.statuses ?? []) {
+        const name = status.context ?? "Status";
+        if (seenNames.has(name)) continue;
+        seenNames.add(name);
+        checks.push({
+          id: `status-${status.id}`,
+          name,
+          status:
+            status.state === "pending"
+              ? "in_progress"
+              : "completed",
+          conclusion: this.mapLegacyStatusState(status.state),
+          htmlUrl: status.target_url ?? pr.html_url,
+          description: status.description ?? null,
+          source: "status",
+          appSlug: null,
+          avatarUrl: status.avatar_url ?? null,
+        });
+      }
+    } catch {
+      // Legacy statuses are optional when check runs cover CI.
+    }
+
+    return {
+      state: mapPrState(
+        pr.state,
+        Boolean(pr.merged_at),
+        pr.draft ?? false
+      ),
+      mergeable,
+      mergeableState,
+      checks,
+    };
+  }
+
+  /** REST `mergeable` is often null; GraphQL by PR `node_id` avoids repo name casing issues. */
+  private async resolvePullRequestMergeability(
+    installationOctokit: InstallationOctokit,
+    owner: string,
+    repo: string,
+    prNumber: number,
+    userAccessToken?: string
+  ): Promise<{
+    mergeable: boolean | null;
+    mergeableState: string;
+    pr: {
+      head: { sha: string };
+      html_url: string;
+      state: string;
+      merged_at: string | null;
+      draft?: boolean;
+    };
+  }> {
+    const fetchRestPull = () =>
+      installationOctokit.request(
+        "GET /repos/{owner}/{repo}/pulls/{pull_number}",
+        { owner, repo, pull_number: prNumber }
+      );
+
+    const { data: initialPr } = await fetchRestPull();
+    let pr = initialPr;
+
+    const repoOwner =
+      pr.base?.repo?.owner?.login ?? pr.head?.repo?.owner?.login ?? owner;
+    const repoName = pr.base?.repo?.name ?? pr.head?.repo?.name ?? repo;
+    const nodeId = pr.node_id;
+
+    const userOctokit = userAccessToken
+      ? new Octokit({ auth: userAccessToken })
+      : null;
+
+    const graphqlFromUser = userOctokit
+      ? await this.fetchPullRequestMergeGraphql(userOctokit, {
+          nodeId,
+          owner: repoOwner,
+          repo: repoName,
+          prNumber,
+        })
+      : null;
+    const graphqlFromInstall = await this.fetchPullRequestMergeGraphql(
+      installationOctokit,
+      { nodeId, owner: repoOwner, repo: repoName, prNumber }
+    );
+
+    const graphql = graphqlFromUser ?? graphqlFromInstall;
+
+    if (graphql) {
+      const fromGraphql = this.resolveMergeabilityFromGraphql(graphql);
+      if (fromGraphql) {
+        return { ...fromGraphql, pr };
+      }
+    }
+
+    let mergeable = pr.mergeable;
+    let mergeableState = pr.mergeable_state ?? "unknown";
+
+    const restResolved = this.resolveMergeabilityFromRest(mergeable, mergeableState);
+    if (restResolved) {
+      return { ...restResolved, pr };
+    }
+
+    for (let attempt = 1; attempt <= 3; attempt++) {
+      await sleep(500 * attempt);
+      const { data } = await fetchRestPull();
+      pr = data;
+      mergeable = data.mergeable;
+      mergeableState = data.mergeable_state ?? "unknown";
+      const resolved = this.resolveMergeabilityFromRest(mergeable, mergeableState);
+      if (resolved) {
+        return { ...resolved, pr };
+      }
+    }
+
+    if (graphql) {
+      const inferred = this.inferMergeabilityFromGraphqlHints(graphql);
+      if (inferred) {
+        return { ...inferred, pr };
+      }
+    }
+
+    return {
+      mergeable,
+      mergeableState,
+      pr,
+    };
+  }
+
+  private resolveMergeabilityFromRest(
+    mergeable: boolean | null,
+    mergeableState: string
+  ): { mergeable: boolean | null; mergeableState: string } | null {
+    if (mergeable === false) {
+      return {
+        mergeable: false,
+        mergeableState:
+          mergeableState === "unknown" ? "dirty" : mergeableState,
+      };
+    }
+    if (mergeable === true && mergeableState !== "unknown") {
+      return { mergeable: true, mergeableState };
+    }
+    if (mergeableState === "dirty") {
+      return { mergeable: false, mergeableState: "dirty" };
+    }
+    return null;
+  }
+
+  private async fetchPullRequestMergeGraphql(
+    octokit: InstallationOctokit | Octokit,
+    params: {
+      nodeId?: string;
+      owner: string;
+      repo: string;
+      prNumber: number;
+    }
+  ): Promise<{
+    mergeable: string;
+    mergeStateStatus: string;
+    hasPotentialMergeCommit: boolean;
+    isDraft: boolean;
+  } | null> {
+    type PullRequestFields = {
+      mergeable?: string | null;
+      mergeStateStatus?: string | null;
+      isDraft?: boolean | null;
+      potentialMergeCommit?: { oid?: string | null } | null;
+    };
+
+    const mapPull = (pull: PullRequestFields | null | undefined) => {
+      if (!pull?.mergeable || !pull.mergeStateStatus) return null;
+      return {
+        mergeable: pull.mergeable,
+        mergeStateStatus: pull.mergeStateStatus,
+        hasPotentialMergeCommit: Boolean(pull.potentialMergeCommit?.oid),
+        isDraft: pull.isDraft ?? false,
+      };
+    };
+
+    if (params.nodeId) {
+      type NodeQuery = {
+        node?: PullRequestFields | null;
+      };
+      try {
+        const result = await octokit.graphql<NodeQuery>(
+          `query($id: ID!) {
+            node(id: $id) {
+              ... on PullRequest {
+                mergeable
+                mergeStateStatus
+                isDraft
+                potentialMergeCommit { oid }
+              }
+            }
+          }`,
+          { id: params.nodeId }
+        );
+        const mapped = mapPull(result.node ?? null);
+        if (mapped) return mapped;
+      } catch (error) {
+        logGitHubApiGraphql("pullRequest mergeability (node) failed", {
+          query: "node PullRequest mergeable",
+          variables: { id: params.nodeId },
+          error,
+        });
+      }
+    }
+
+    type RepoQuery = {
+      repository?: {
+        pullRequest?: PullRequestFields | null;
+      } | null;
+    };
+
+    try {
+      const result = await octokit.graphql<RepoQuery>(
+        `query($owner: String!, $repo: String!, $pr: Int!) {
+          repository(owner: $owner, name: $repo) {
+            pullRequest(number: $pr) {
+              mergeable
+              mergeStateStatus
+              isDraft
+              potentialMergeCommit { oid }
+            }
+          }
+        }`,
+        {
+          owner: params.owner,
+          repo: params.repo,
+          pr: params.prNumber,
+        }
+      );
+      return mapPull(result.repository?.pullRequest ?? null);
+    } catch (error) {
+      logGitHubApiGraphql("pullRequest mergeability (repo) failed", {
+        query: "repository pullRequest mergeable",
+        variables: {
+          owner: params.owner,
+          repo: params.repo,
+          pr: params.prNumber,
+        },
+        error,
+      });
+      return null;
+    }
+  }
+
+  private resolveMergeabilityFromGraphql(graphql: {
+    mergeable: string;
+    mergeStateStatus: string;
+    hasPotentialMergeCommit: boolean;
+    isDraft: boolean;
+  }): { mergeable: boolean | null; mergeableState: string } | null {
+    if (graphql.mergeable === "CONFLICTING") {
+      return { mergeable: false, mergeableState: "dirty" };
+    }
+
+    switch (graphql.mergeStateStatus) {
+      case "DIRTY":
+        return { mergeable: false, mergeableState: "dirty" };
+      case "BEHIND":
+        return { mergeable: false, mergeableState: "behind" };
+      case "BLOCKED":
+        return { mergeable: false, mergeableState: "blocked" };
+      case "UNSTABLE":
+        return { mergeable: false, mergeableState: "unstable" };
+      case "CLEAN":
+      case "HAS_HOOKS":
+        return { mergeable: true, mergeableState: "clean" };
+      case "DRAFT":
+        return { mergeable: false, mergeableState: "blocked" };
+    }
+
+    if (graphql.mergeable === "MERGEABLE") {
+      return { mergeable: true, mergeableState: "clean" };
+    }
+
+    if (
+      !graphql.isDraft &&
+      !graphql.hasPotentialMergeCommit &&
+      graphql.mergeable === "UNKNOWN" &&
+      graphql.mergeStateStatus === "UNKNOWN"
+    ) {
+      return { mergeable: false, mergeableState: "dirty" };
+    }
+
+    return null;
+  }
+
+  /** When primary GraphQL fields stay UNKNOWN but merge is clearly blocked. */
+  private inferMergeabilityFromGraphqlHints(graphql: {
+    mergeable: string;
+    mergeStateStatus: string;
+    hasPotentialMergeCommit: boolean;
+    isDraft: boolean;
+  }): { mergeable: boolean | null; mergeableState: string } | null {
+    if (graphql.isDraft) return null;
+
+    if (
+      !graphql.hasPotentialMergeCommit &&
+      graphql.mergeable !== "MERGEABLE" &&
+      graphql.mergeStateStatus !== "CLEAN" &&
+      graphql.mergeStateStatus !== "HAS_HOOKS"
+    ) {
+      if (graphql.mergeStateStatus === "BEHIND") {
+        return { mergeable: false, mergeableState: "behind" };
+      }
+      if (graphql.mergeStateStatus === "BLOCKED") {
+        return { mergeable: false, mergeableState: "blocked" };
+      }
+      return { mergeable: false, mergeableState: "dirty" };
+    }
+
+    return null;
+  }
+
+  async mergePullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string,
+    options?: { mergeMethod?: "merge" | "squash" | "rebase" }
+  ): Promise<GitPullRequest> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    try {
+      await octokit.rest.pulls.merge({
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+        merge_method: options?.mergeMethod ?? "merge",
+      });
+    } catch (error) {
+      throw new Error(formatGitHubApiError(error));
+    }
+    return this.getPullRequest(connection, repo, prNumber);
+  }
+
+  async closePullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string
+  ): Promise<GitPullRequest> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    try {
+      const { data } = await octokit.rest.pulls.update({
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+        state: "closed",
+      });
+      return this.mapPullRequest(data);
+    } catch (error) {
+      throw new Error(formatGitHubApiError(error));
+    }
+  }
+
+  async listPullRequestsForRef(
+    connection: GitConnection,
+    repo: GitRepo,
+    headRef: string
+  ): Promise<GitPullRequest[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data } = await octokit.request("GET /repos/{owner}/{repo}/pulls", {
+      owner,
+      repo: repoName,
+      head: `${owner}:${headRef}`,
+      state: "all",
+    });
+    return data.map((pr) => this.mapPullRequest(pr));
+  }
+
+  async createPullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    options: {
+      title: string;
+      body: string;
+      head: string;
+      base: string;
+      userAccessToken?: string;
+    }
+  ): Promise<GitPullRequest> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+
+    await this.assertBranchHasCommits(
+      connection,
+      repo,
+      options.base,
+      options.head,
+      options.userAccessToken
+    );
+
+    try {
+      if (options.userAccessToken) {
+        const octokit = new Octokit({ auth: options.userAccessToken });
+        const { data } = await octokit.rest.pulls.create({
+          owner,
+          repo: repoName,
+          title: options.title,
+          body: options.body,
+          head: options.head,
+          base: options.base,
+        });
+        return this.mapPullRequest(data);
+      }
+
+      const octokit = await this.getInstallationClient(connection);
+      const { data } = await octokit.request("POST /repos/{owner}/{repo}/pulls", {
+        owner,
+        repo: repoName,
+        title: options.title,
+        body: options.body,
+        head: options.head,
+        base: options.base,
+      });
+      return this.mapPullRequest(data);
+    } catch (error) {
+      throw new Error(formatGitHubApiError(error));
+    }
+  }
+
+  private async assertBranchHasCommits(
+    connection: GitConnection,
+    repo: GitRepo,
+    base: string,
+    head: string,
+    userAccessToken?: string
+  ): Promise<void> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const basehead = `${base}...${head}`;
+
+    try {
+      if (userAccessToken) {
+        const octokit = new Octokit({ auth: userAccessToken });
+        const { data } = await octokit.rest.repos.compareCommitsWithBasehead({
+          owner,
+          repo: repoName,
+          basehead,
+        });
+        if (data.ahead_by < 1) {
+          throw new Error(
+            "Push at least one commit to the branch before opening a pull request."
+          );
+        }
+        return;
+      }
+
+      const octokit = await this.getInstallationClient(connection);
+      const { data } = await octokit.request(
+        "GET /repos/{owner}/{repo}/compare/{basehead}",
+        { owner, repo: repoName, basehead }
+      );
+      if ((data.ahead_by as number) < 1) {
+        throw new Error(
+          "Push at least one commit to the branch before opening a pull request."
+        );
+      }
+    } catch (error) {
+      if (error instanceof Error && NO_COMMITS_USER_MESSAGE.test(error.message)) {
+        throw error;
+      }
+      // Compare unavailable (permissions, network, etc.) — PR create will validate.
+    }
+  }
+
+  async getPullRequestDiff(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitDiffFile[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data: files } = await octokit.request(
+      "GET /repos/{owner}/{repo}/pulls/{pull_number}/files",
+      {
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+      }
+    );
+
+    return files.map((file: (typeof files)[number]) => this.mapDiffFile(file));
+  }
+
+  async compareBranches(
+    connection: GitConnection,
+    repo: GitRepo,
+    base: string,
+    head: string
+  ): Promise<GitCompareResult> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/compare/{basehead}",
+      { owner, repo: repoName, basehead: `${base}...${head}` }
+    );
+
+    return {
+      baseRef: base,
+      headRef: head,
+      commits: (data.commits ?? []).map((c) => this.mapCommit(c, repo)),
+      files: (data.files ?? []).map((f) => this.mapDiffFile(f)),
+    };
+  }
+
+  async listPullRequestCommits(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitCommit[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits",
+      { owner, repo: repoName, pull_number: prNumber }
+    );
+    return data.map((c) => this.mapCommit(c, repo));
+  }
+
+  async getCommitDiff(
+    connection: GitConnection,
+    repo: GitRepo,
+    sha: string
+  ): Promise<GitDiffFile[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/commits/{commit_sha}",
+      { owner, repo: repoName, commit_sha: sha }
+    );
+    return (data.files ?? []).map((f: (typeof data.files)[number]) =>
+      this.mapDiffFile(f)
+    );
+  }
+
+  async listPullRequestIssueComments(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitPullRequestIssueComment[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const all: GitPullRequestIssueComment[] = [];
+    let page = 1;
+    for (;;) {
+      const { data } = await octokit.request(
+        "GET /repos/{owner}/{repo}/issues/{issue_number}/comments",
+        {
+          owner,
+          repo: repoName,
+          issue_number: prNumber,
+          per_page: 100,
+          page,
+        }
+      );
+      all.push(...data.map((c) => this.mapIssueComment(c)));
+      if (data.length < 100) break;
+      page += 1;
+    }
+    return all;
+  }
+
+  async listPullRequestReviewComments(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitReviewComment[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/pulls/{pull_number}/comments",
+      { owner, repo: repoName, pull_number: prNumber }
+    );
+    return data.map((c) => this.mapReviewComment(c));
+  }
+
+  async listPullRequestReviewCommentsAsUser(
+    _connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string
+  ): Promise<GitReviewComment[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/pulls/{pull_number}/comments",
+      { owner, repo: repoName, pull_number: prNumber }
+    );
+    return data.map((c) => this.mapReviewComment(c));
+  }
+
+  async listPullRequestReviewCommentsForReviewAsUser(
+    _connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    reviewId: number,
+    userAccessToken: string
+  ): Promise<GitReviewComment[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments",
+      { owner, repo: repoName, pull_number: prNumber, review_id: reviewId }
+    );
+    return data.map((c) =>
+      this.mapReviewComment({
+        ...c,
+        pull_request_review_id: c.pull_request_review_id ?? reviewId,
+      })
+    );
+  }
+
+  private async fetchPullRequestReviewThreadsGraphql(
+    octokit: Pick<Octokit, "graphql">,
+    owner: string,
+    repoName: string,
+    prNumber: number
+  ): Promise<{ threads: GitReviewThread[]; comments: GitReviewComment[] }> {
+    type GraphqlCommentNode = {
+      databaseId?: number | string | null;
+      body?: string | null;
+      path?: string | null;
+      line?: number | null;
+      originalLine?: number | null;
+      createdAt?: string | null;
+      url?: string | null;
+      commit?: { oid?: string | null } | null;
+      author?: {
+        login?: string | null;
+        avatarUrl?: string | null;
+        url?: string | null;
+      } | null;
+      pullRequestReview?: { databaseId?: number | string | null } | null;
+      replyTo?: { databaseId?: number | string | null } | null;
+    };
+
+    type GraphqlThreadNode = {
+      id?: string | null;
+      isResolved?: boolean | null;
+      path?: string | null;
+      line?: number | null;
+      originalLine?: number | null;
+      diffSide?: string | null;
+      comments?: {
+        pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
+        nodes?: Array<GraphqlCommentNode | null> | null;
+      } | null;
+    };
+
+    type ThreadQuery = {
+      repository?: {
+        pullRequest?: {
+          reviewThreads?: {
+            pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
+            nodes?: Array<GraphqlThreadNode | null> | null;
+          } | null;
+        } | null;
+      } | null;
+    };
+
+    const threadNodes: GraphqlThreadNode[] = [];
+    let threadsAfter: string | null = null;
+
+    do {
+      const pageResult: ThreadQuery = await octokit.graphql<ThreadQuery>(
+        `query($owner: String!, $name: String!, $number: Int!, $threadsAfter: String) {
+          repository(owner: $owner, name: $name) {
+            pullRequest(number: $number) {
+              reviewThreads(first: 100, after: $threadsAfter) {
+                pageInfo { hasNextPage endCursor }
+                nodes {
+                  id
+                  isResolved
+                  path
+                  line
+                  originalLine
+                  diffSide
+                  comments(first: 50) {
+                    pageInfo { hasNextPage endCursor }
+                    nodes {
+                      databaseId
+                      body
+                      path
+                      line
+                      originalLine
+                      createdAt
+                      url
+                      commit { oid }
+                      author { login avatarUrl url }
+                      pullRequestReview { databaseId }
+                      replyTo { databaseId }
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }`,
+        {
+          owner,
+          name: repoName,
+          number: prNumber,
+          threadsAfter,
+        }
+      );
+      const repository: ThreadQuery["repository"] = pageResult.repository;
+      const connection: NonNullable<
+        NonNullable<ThreadQuery["repository"]>["pullRequest"]
+      >["reviewThreads"] = repository?.pullRequest?.reviewThreads;
+      for (const thread of connection?.nodes ?? []) {
+        if (!thread?.id) continue;
+
+        const commentNodes: GraphqlCommentNode[] = [
+          ...((thread.comments?.nodes ?? []).filter(
+            (n): n is GraphqlCommentNode => n != null
+          )),
+        ];
+        let commentsAfter = thread.comments?.pageInfo?.endCursor ?? null;
+        let commentsHasNext = thread.comments?.pageInfo?.hasNextPage ?? false;
+
+        while (commentsHasNext && commentsAfter) {
+          const { node } = await octokit.graphql<{
+            node?: {
+              comments?: {
+                pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
+                nodes?: Array<GraphqlCommentNode | null> | null;
+              } | null;
+            } | null;
+          }>(
+            `query($threadId: ID!, $commentsAfter: String) {
+              node(id: $threadId) {
+                ... on PullRequestReviewThread {
+                  comments(first: 50, after: $commentsAfter) {
+                    pageInfo { hasNextPage endCursor }
+                    nodes {
+                      databaseId
+                      body
+                      path
+                      line
+                      originalLine
+                      createdAt
+                      url
+                      commit { oid }
+                      author { login avatarUrl url }
+                      pullRequestReview { databaseId }
+                      replyTo { databaseId }
+                    }
+                  }
+                }
+              }
+            }`,
+            { threadId: thread.id, commentsAfter }
+          );
+
+          const page = node?.comments;
+          commentNodes.push(
+            ...((page?.nodes ?? []).filter(
+              (n): n is GraphqlCommentNode => n != null
+            ))
+          );
+          commentsHasNext = page?.pageInfo?.hasNextPage ?? false;
+          commentsAfter = page?.pageInfo?.endCursor ?? null;
+        }
+
+        threadNodes.push({ ...thread, comments: { nodes: commentNodes } });
+      }
+
+      threadsAfter = connection?.pageInfo?.hasNextPage
+        ? (connection.pageInfo.endCursor ?? null)
+        : null;
+    } while (threadsAfter);
+
+    const threads: GitReviewThread[] = [];
+    const comments: GitReviewComment[] = [];
+
+    for (const thread of threadNodes) {
+      if (!thread?.id) continue;
+      const threadSide =
+        thread.diffSide === "LEFT" || thread.diffSide === "RIGHT"
+          ? thread.diffSide
+          : null;
+      const commentIds: number[] = [];
+
+      for (const node of thread.comments?.nodes ?? []) {
+        const path = node?.path ?? thread.path;
+        if (node?.databaseId == null || !path) continue;
+        const id = Number(node.databaseId);
+        commentIds.push(id);
+        const authorLogin = node.author?.login ?? "unknown";
+        comments.push({
+          id,
+          body: node.body ?? "",
+          path,
+          line: node.line ?? thread.line ?? null,
+          originalLine: node.originalLine ?? thread.originalLine ?? null,
+          side: threadSide,
+          commitId: node.commit?.oid ?? "",
+          authorLogin,
+          authorAvatarUrl: node.author?.avatarUrl ?? null,
+          authorHtmlUrl: node.author?.url ?? `https://github.com/${authorLogin}`,
+          createdAt: node.createdAt ? new Date(node.createdAt) : new Date(),
+          inReplyToId:
+            node.replyTo?.databaseId != null
+              ? Number(node.replyTo.databaseId)
+              : null,
+          reviewId: node.pullRequestReview?.databaseId
+            ? Number(node.pullRequestReview.databaseId)
+            : null,
+          url: node.url ?? "",
+          threadNodeId: thread.id,
+          threadIsResolved: thread.isResolved ?? false,
+        });
+      }
+
+      if (commentIds.length > 0) {
+        const threadPath =
+          thread.path ??
+          comments.find((c) => c.id === commentIds[0])?.path ??
+          "";
+        threads.push({
+          nodeId: thread.id,
+          isResolved: thread.isResolved ?? false,
+          path: threadPath,
+          line: thread.line ?? null,
+          originalLine: thread.originalLine ?? null,
+          side: threadSide,
+          commentIds,
+        });
+      }
+    }
+
+    return { threads, comments };
+  }
+
+  async listPullRequestReviewThreads(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitReviewThread[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { threads } = await this.fetchPullRequestReviewThreadsGraphql(
+      octokit,
+      owner,
+      repoName,
+      prNumber
+    );
+    return threads;
+  }
+
+  async listPullRequestReviewThreadCommentsAsUser(
+    _connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string
+  ): Promise<GitReviewComment[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    const { comments } = await this.fetchPullRequestReviewThreadsGraphql(
+      octokit,
+      owner,
+      repoName,
+      prNumber
+    );
+    return comments;
+  }
+
+  async setReviewThreadResolved(
+    threadNodeId: string,
+    resolved: boolean,
+    userAccessToken: string
+  ): Promise<void> {
+    const octokit = new Octokit({ auth: userAccessToken });
+    const mutation = resolved
+      ? `mutation($threadId: ID!) {
+          resolveReviewThread(input: { threadId: $threadId }) {
+            thread { isResolved }
+          }
+        }`
+      : `mutation($threadId: ID!) {
+          unresolveReviewThread(input: { threadId: $threadId }) {
+            thread { isResolved }
+          }
+        }`;
+
+    logGitHubApiGraphql(
+      resolved ? "resolveReviewThread" : "unresolveReviewThread",
+      {
+        mutation,
+        variables: { threadId: threadNodeId },
+      }
+    );
+
+    try {
+      await octokit.graphql(mutation, { threadId: threadNodeId });
+    } catch (error) {
+      throw new Error(formatGitHubApiError(error));
+    }
+  }
+
+  async listPullRequestReviews(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitPullRequestReview[]> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = await this.getInstallationClient(connection);
+    const { data } = await octokit.request(
+      "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews",
+      { owner, repo: repoName, pull_number: prNumber }
+    );
+    return data.map((r) => this.mapPullRequestReview(r));
+  }
+
+  async findPendingReviewForUser(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    authorLogin: string,
+    userAccessToken: string,
+    options?: { retries?: number }
+  ): Promise<GitPullRequestReview | null> {
+    const userOctokit = new Octokit({ auth: userAccessToken });
+    const { data: authUser } = await userOctokit.rest.users.getAuthenticated();
+
+    const fromInstallation = await this.findPendingReviewViaInstallation(
+      connection,
+      repo,
+      prNumber,
+      authUser.login
+    );
+    if (fromInstallation) return fromInstallation;
+
+    const attempts = Math.max(1, options?.retries ?? 1);
+    for (let i = 0; i < attempts; i++) {
+      const pending = await this.findPendingReviewForUserOnce(
+        connection,
+        repo,
+        prNumber,
+        authorLogin,
+        userAccessToken,
+        authUser
+      );
+      if (pending) return pending;
+      if (i < attempts - 1) await sleep(400 * (i + 1));
+    }
+
+    return this.findPendingReviewViaGraphql(repo, prNumber, userAccessToken);
+  }
+
+  /** Installation listing often includes pending reviews the user-token list omits. */
+  private async findPendingReviewViaInstallation(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    viewerLogin: string
+  ): Promise<GitPullRequestReview | null> {
+    const reviews = await this.listPullRequestReviews(connection, repo, prNumber);
+    return pickPendingReviewForViewer(reviews, viewerLogin);
+  }
+
+  private async findPendingReviewViaGraphql(
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string
+  ): Promise<GitPullRequestReview | null> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    const { data: authUser } = await octokit.rest.users.getAuthenticated();
+    const login = authUser.login.toLowerCase();
+
+    type GraphqlResponse = {
+      repository?: {
+        pullRequest?: {
+          reviews?: {
+            nodes?: Array<{
+              databaseId?: string | null;
+              state?: string | null;
+              body?: string | null;
+              submittedAt?: string | null;
+              commit?: { oid?: string | null } | null;
+              author?: { login?: string | null } | null;
+            } | null> | null;
+          } | null;
+        } | null;
+      } | null;
+    };
+
+    try {
+      const result = await octokit.graphql<GraphqlResponse>(
+        `query($owner: String!, $repo: String!, $pr: Int!) {
+          repository(owner: $owner, name: $repo) {
+            pullRequest(number: $pr) {
+              reviews(last: 20, states: [PENDING]) {
+                nodes {
+                  databaseId
+                  state
+                  body
+                  submittedAt
+                  commit { oid }
+                  author { login }
+                }
+              }
+            }
+          }
+        }`,
+        { owner, repo: repoName, pr: prNumber }
+      );
+
+      const nodes = result.repository?.pullRequest?.reviews?.nodes ?? [];
+      for (let i = nodes.length - 1; i >= 0; i--) {
+        const node = nodes[i];
+        if (!node) continue;
+        const id = node.databaseId ? Number(node.databaseId) : NaN;
+        if (!Number.isFinite(id)) continue;
+        if (node.author?.login?.toLowerCase() !== login) continue;
+        const authorLogin = node.author?.login ?? authUser.login;
+        return {
+          id,
+          body: node.body ?? null,
+          state: "PENDING",
+          commitId: node.commit?.oid ?? null,
+          authorLogin,
+          authorAvatarUrl: null,
+          authorHtmlUrl: `https://github.com/${authorLogin}`,
+          submittedAt: null,
+          url: null,
+        };
+      }
+    } catch {
+      return null;
+    }
+
+    return null;
+  }
+
+  private reviewAuthoredBy(
+    review: { user?: { id?: number; login?: string | null } | null },
+    authUser: { id: number; login: string }
+  ): boolean {
+    if (review.user?.id != null && review.user.id === authUser.id) return true;
+    return (
+      review.user?.login?.toLowerCase() === authUser.login.toLowerCase()
+    );
+  }
+
+  private pickPendingReviewForAuthUser(
+    reviews: Array<{
+      id: number;
+      body?: string | null;
+      state: string;
+      commit_id?: string | null;
+      submitted_at?: string | null;
+      user?: { id?: number; login?: string | null } | null;
+      html_url?: string | null;
+    }>,
+    authUser: { id: number; login: string }
+  ): GitPullRequestReview | null {
+    const mine = reviews.filter((review) => this.reviewAuthoredBy(review, authUser));
+    for (let i = mine.length - 1; i >= 0; i--) {
+      const review = mine[i];
+      if (isPendingReviewState(review)) {
+        return this.mapPullRequestReview(review);
+      }
+    }
+    return null;
+  }
+
+  private async findPendingReviewForUserOnce(
+    _connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    _authorLogin: string,
+    userAccessToken: string,
+    authUser: { id: number; login: string }
+  ): Promise<GitPullRequestReview | null> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+
+    const reviews = await octokit.paginate(
+      octokit.rest.pulls.listReviews,
+      {
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+        per_page: 100,
+      }
+    );
+
+    return this.pickPendingReviewForAuthUser(reviews, authUser);
+  }
+
+  async startPendingReview(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    _commitId: string,
+    userAccessToken: string,
+    authorLogin: string
+  ): Promise<GitPullRequestReview> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+
+    const existing = await this.findPendingReviewForUser(
+      connection,
+      repo,
+      prNumber,
+      authorLogin,
+      userAccessToken,
+      { retries: 3 }
+    );
+    if (existing) return existing;
+
+    try {
+      const { data: pr } = await octokit.rest.pulls.get({
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+      });
+      const commitId = _commitId || pr.head.sha;
+      const createPayload = { commit_id: commitId };
+      logGitHubApiRest("POST create pending review", {
+        method: "POST",
+        url: `https://api.github.com/repos/${owner}/${repoName}/pulls/${prNumber}/reviews`,
+        payload: createPayload,
+      });
+      const { data } = await octokit.rest.pulls.createReview({
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+        commit_id: commitId,
+      });
+      logGitHubApi("create pending review ok", { reviewId: data.id, state: data.state });
+      return this.mapPullRequestReview(data);
+    } catch (error) {
+      logGitHubApiRest("POST create pending review failed", {
+        method: "POST",
+        url: `https://api.github.com/repos/${owner}/${repoName}/pulls/${prNumber}/reviews`,
+        payload: { commit_id: "(see prior log)" },
+        error,
+      });
+      if (isDuplicatePendingReviewError(error)) {
+        const pending = await this.findPendingReviewForUser(
+          connection,
+          repo,
+          prNumber,
+          authorLogin,
+          userAccessToken,
+          { retries: 8 }
+        );
+        if (pending) return pending;
+      }
+      throw new Error(
+        isDuplicatePendingReviewError(error)
+          ? "Could not resume your in-progress review. Open the pull request on GitHub to submit or discard it, or try Discard review here."
+          : formatGitHubApiError(error)
+      );
+    }
+  }
+
+  private async resolveReviewNodeId(
+    octokit: Octokit,
+    owner: string,
+    repoName: string,
+    prNumber: number,
+    reviewDatabaseId: number
+  ): Promise<string | null> {
+    type ReviewNodes = {
+      repository?: {
+        pullRequest?: {
+          reviews?: {
+            nodes?: Array<{
+              id?: string;
+              databaseId?: string | number | null;
+            } | null> | null;
+          } | null;
+        } | null;
+      } | null;
+    };
+
+    const variables = { owner, name: repoName, number: prNumber };
+    logGitHubApiGraphql("resolveReviewNodeId query", {
+      query: "pendingReviewNodes",
+      variables,
+    });
+
+    const result = await octokit.graphql<ReviewNodes>(
+      `query($owner: String!, $name: String!, $number: Int!) {
+        repository(owner: $owner, name: $name) {
+          pullRequest(number: $number) {
+            reviews(states: [PENDING], last: 20) {
+              nodes { id databaseId }
+            }
+          }
+        }
+      }`,
+      variables
+    );
+
+    const nodes = result.repository?.pullRequest?.reviews?.nodes ?? [];
+    logGitHubApi("resolveReviewNodeId result", {
+      reviewDatabaseId,
+      pendingNodes: nodes.map((n) => ({
+        id: n?.id,
+        databaseId: n?.databaseId,
+      })),
+    });
+    for (const node of nodes) {
+      if (!node?.id) continue;
+      if (Number(node.databaseId) === reviewDatabaseId) return node.id;
+    }
+    return null;
+  }
+
+  private async createReviewThreadViaGraphql(
+    octokit: Octokit,
+    owner: string,
+    repoName: string,
+    prNumber: number,
+    input: CreateReviewCommentInput
+  ): Promise<GitReviewComment> {
+    const reviewNodeId = await this.resolveReviewNodeId(
+      octokit,
+      owner,
+      repoName,
+      prNumber,
+      input.reviewId
+    );
+    if (!reviewNodeId) {
+      throw new Error(
+        "Could not find your pending review on GitHub. Try Discard review, then start again."
+      );
+    }
+
+    type MutationResult = {
+      addPullRequestReviewThread?: {
+        thread?: {
+          comments?: {
+            nodes?: Array<{
+              databaseId?: number | string | null;
+              body?: string | null;
+              path?: string | null;
+              line?: number | null;
+              originalLine?: number | null;
+              url?: string | null;
+              createdAt?: string | null;
+              author?: {
+                login?: string | null;
+                avatarUrl?: string | null;
+                url?: string | null;
+              } | null;
+            } | null> | null;
+          } | null;
+        } | null;
+      } | null;
+    };
+
+    const mutationInput = {
+      pullRequestReviewId: reviewNodeId,
+      body: input.body,
+      path: input.path,
+      line: input.line,
+      side: input.side === "LEFT" ? "LEFT" : "RIGHT",
+      subjectType: "LINE",
+    };
+
+    logGitHubApiGraphql("addPullRequestReviewThread", {
+      mutation: "addPullRequestReviewThread",
+      variables: {
+        input: mutationInput,
+        reviewDatabaseId: input.reviewId,
+      },
+    });
+
+    let result: MutationResult;
+    try {
+      result = await octokit.graphql<MutationResult>(
+        `mutation($input: AddPullRequestReviewThreadInput!) {
+          addPullRequestReviewThread(input: $input) {
+            thread {
+              comments(first: 1) {
+                nodes {
+                  databaseId
+                  body
+                  path
+                  line
+                  originalLine
+                  url
+                  createdAt
+                  author { login avatarUrl url }
+                }
+              }
+            }
+          }
+        }`,
+        { input: mutationInput }
+      );
+    } catch (error) {
+      logGitHubApiGraphql("addPullRequestReviewThread failed", {
+        mutation: "addPullRequestReviewThread",
+        variables: { input: mutationInput },
+        error,
+      });
+      throw error;
+    }
+
+    const comment =
+      result.addPullRequestReviewThread?.thread?.comments?.nodes?.[0];
+    if (!comment?.databaseId) {
+      throw new Error("GitHub did not return the new review comment.");
+    }
+
+    const authorLogin = comment.author?.login ?? "unknown";
+    return {
+      id: Number(comment.databaseId),
+      body: comment.body ?? input.body,
+      path: comment.path ?? input.path,
+      line: comment.line ?? input.line,
+      originalLine: comment.originalLine ?? null,
+      side: input.side ?? "RIGHT",
+      commitId: input.commitId,
+      authorLogin,
+      authorAvatarUrl: comment.author?.avatarUrl ?? null,
+      authorHtmlUrl: comment.author?.url ?? `https://github.com/${authorLogin}`,
+      createdAt: comment.createdAt
+        ? new Date(comment.createdAt)
+        : new Date(),
+      inReplyToId: input.inReplyTo ?? null,
+      reviewId: input.reviewId,
+      url: comment.url ?? "",
+    };
+  }
+
+  async createPullRequestReviewComment(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    input: CreateReviewCommentInput,
+    userAccessToken: string
+  ): Promise<GitReviewComment> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    const side = input.side ?? "RIGHT";
+
+    logGitHubApi("createPullRequestReviewComment", {
+      prNumber,
+      input: {
+        reviewId: input.reviewId,
+        path: input.path,
+        line: input.line,
+        side: input.side,
+        commitId: input.commitId,
+        bodyLength: input.body.length,
+        inReplyTo: input.inReplyTo,
+      },
+    });
+
+    try {
+      const { data: review } = await octokit.rest.pulls.getReview({
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+        review_id: input.reviewId,
+      });
+      logGitHubApi("getReview before comment", {
+        reviewId: input.reviewId,
+        state: review.state,
+        submitted_at: review.submitted_at,
+        commit_id: review.commit_id,
+      });
+      if (!isPendingReviewState(review)) {
+        throw new Error(
+          "This review is no longer in progress. Discard and start a new review, or refresh the page."
+        );
+      }
+      const commitId = review.commit_id ?? input.commitId;
+
+      const diffFiles = await this.getPullRequestDiff(
+        connection,
+        repo,
+        prNumber
+      );
+      const file = diffFiles.find((f) => f.path === input.path);
+      const position =
+        file?.patch != null
+          ? computeDiffPosition(file.patch, input.line, side)
+          : null;
+
+      if (input.inReplyTo != null) {
+        const data = await postPullRequestReviewComment(
+          userAccessToken,
+          owner,
+          repoName,
+          prNumber,
+          { body: input.body, in_reply_to: input.inReplyTo }
+        );
+        return this.mapReviewComment(
+          data as Parameters<typeof this.mapReviewComment>[0]
+        );
+      }
+
+      try {
+        return await this.createReviewThreadViaGraphql(
+          octokit,
+          owner,
+          repoName,
+          prNumber,
+          input
+        );
+      } catch (graphqlError) {
+        logGitHubApi("GraphQL thread failed, trying REST fallback", {
+          reviewId: input.reviewId,
+          error:
+            graphqlError instanceof Error ? graphqlError.message : graphqlError,
+        });
+
+        if (isDuplicatePendingReviewError(graphqlError)) {
+          throw graphqlError;
+        }
+
+        const tryRestComment = async (payload: Record<string, unknown>) => {
+          const data = await postPullRequestReviewComment(
+            userAccessToken,
+            owner,
+            repoName,
+            prNumber,
+            payload
+          );
+          return this.mapReviewComment(
+            data as Parameters<typeof this.mapReviewComment>[0]
+          );
+        };
+
+        const linePayload = {
+          body: input.body,
+          commit_id: commitId,
+          path: input.path,
+          line: input.line,
+          side,
+        };
+
+        try {
+          return await tryRestComment(linePayload);
+        } catch (lineError) {
+          if (position == null) {
+            throw graphqlError instanceof Error ? graphqlError : lineError;
+          }
+          try {
+            return await tryRestComment({
+              body: input.body,
+              commit_id: commitId,
+              path: input.path,
+              position,
+            });
+          } catch (positionError) {
+            throw positionError;
+          }
+        }
+      }
+    } catch (error) {
+      if (error instanceof Error && error.message.includes("no longer in progress")) {
+        throw error;
+      }
+      throw new Error(formatGitHubApiError(error));
+    }
+  }
+
+  async submitPullRequestReview(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    input: SubmitPullRequestReviewInput,
+    userAccessToken: string
+  ): Promise<void> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    try {
+      if (input.reviewId != null) {
+        await octokit.rest.pulls.submitReview({
+          owner,
+          repo: repoName,
+          pull_number: prNumber,
+          review_id: input.reviewId,
+          event: input.event,
+          body: input.body,
+        });
+        return;
+      }
+      await octokit.rest.pulls.createReview({
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+        commit_id: input.commitId,
+        event: input.event,
+        body: input.body,
+      });
+    } catch (error) {
+      throw new Error(formatGitHubApiError(error));
+    }
+  }
+
+  async discardPendingReview(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    reviewId: number,
+    userAccessToken: string
+  ): Promise<void> {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    const octokit = new Octokit({ auth: userAccessToken });
+    try {
+      await octokit.rest.pulls.deletePendingReview({
+        owner,
+        repo: repoName,
+        pull_number: prNumber,
+        review_id: reviewId,
+      });
+    } catch (error) {
+      throw new Error(formatGitHubApiError(error));
+    }
+  }
+
+  parsePullRequestUrl(url: string) {
+    const match = url.match(
+      /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/i
+    );
+    if (!match) return null;
+    return {
+      owner: match[1],
+      repo: match[2],
+      number: parseInt(match[3], 10),
+    };
+  }
+
+  private mapCommit(
+    data: {
+      sha: string;
+      commit: {
+        message: string;
+        author: { date?: string | null; name?: string | null } | null;
+      };
+      author?: { login?: string | null } | null;
+      html_url?: string;
+    },
+    repo: GitRepo
+  ): GitCommit {
+    const { owner, repo: repoName } = parseRepoFullName(repo.fullName);
+    return {
+      sha: data.sha,
+      message: data.commit.message.split("\n")[0] ?? data.commit.message,
+      authorLogin: data.author?.login ?? data.commit.author?.name ?? null,
+      committedAt: new Date(data.commit.author?.date ?? Date.now()),
+      htmlUrl:
+        data.html_url ??
+        `https://github.com/${owner}/${repoName}/commit/${data.sha}`,
+    };
+  }
+
+  private mapDiffFile(file: {
+    filename: string;
+    previous_filename?: string | null;
+    status: string;
+    patch?: string | null;
+  }): GitDiffFile {
+    return {
+      path: file.filename,
+      oldPath: file.previous_filename ?? undefined,
+      status: file.status as GitDiffFile["status"],
+      patch: file.patch ?? undefined,
+    };
+  }
+
+  private mapIssueComment(data: {
+    id: number;
+    body?: string | null;
+    created_at: string;
+    updated_at?: string | null;
+    html_url: string;
+    user?: {
+      login?: string | null;
+      avatar_url?: string | null;
+      html_url?: string | null;
+      type?: string | null;
+    } | null;
+  }): GitPullRequestIssueComment {
+    const login = data.user?.login ?? "unknown";
+    return {
+      id: data.id,
+      body: data.body ?? "",
+      authorLogin: login,
+      authorAvatarUrl: data.user?.avatar_url ?? null,
+      authorHtmlUrl: data.user?.html_url ?? `https://github.com/${login}`,
+      isBot: data.user?.type === "Bot" || /\[bot\]$/i.test(login),
+      createdAt: new Date(data.created_at),
+      updatedAt: data.updated_at ? new Date(data.updated_at) : null,
+      url: data.html_url,
+    };
+  }
+
+  private mapReviewComment(data: {
+    id: number;
+    body: string;
+    path: string;
+    line?: number | null;
+    original_line?: number | null;
+    side?: string | null;
+    commit_id: string;
+    user?: {
+      login?: string | null;
+      avatar_url?: string | null;
+      html_url?: string | null;
+    } | null;
+    created_at: string;
+    in_reply_to_id?: number | null;
+    pull_request_review_id?: number | null;
+    html_url: string;
+  }): GitReviewComment {
+    return {
+      id: data.id,
+      body: data.body,
+      path: data.path,
+      line: data.line ?? null,
+      originalLine: data.original_line ?? null,
+      side: data.side === "LEFT" || data.side === "RIGHT" ? data.side : null,
+      commitId: data.commit_id,
+      authorLogin: data.user?.login ?? "unknown",
+      authorAvatarUrl: data.user?.avatar_url ?? null,
+      authorHtmlUrl: data.user?.html_url ?? null,
+      createdAt: new Date(data.created_at),
+      inReplyToId: data.in_reply_to_id ?? null,
+      reviewId: data.pull_request_review_id ?? null,
+      url: data.html_url,
+    };
+  }
+
+  private mapPullRequestReview(data: {
+    id: number;
+    body?: string | null;
+    state: string;
+    commit_id?: string | null;
+    user?: {
+      login?: string | null;
+      avatar_url?: string | null;
+      html_url?: string | null;
+    } | null;
+    submitted_at?: string | null;
+    html_url?: string | null;
+  }): GitPullRequestReview {
+    const state = data.state.toUpperCase().replace(/ /g, "_");
+    const normalizedState =
+      state === "COMMENTED" ||
+      state === "APPROVED" ||
+      state === "CHANGES_REQUESTED" ||
+      state === "DISMISSED" ||
+      state === "PENDING"
+        ? state
+        : data.state === "pending" || !data.state?.trim()
+          ? "PENDING"
+          : "COMMENTED";
+
+    return {
+      id: data.id,
+      body: data.body ?? null,
+      state: normalizedState as GitPullRequestReview["state"],
+      commitId: data.commit_id ?? null,
+      authorLogin: data.user?.login ?? "unknown",
+      authorAvatarUrl: data.user?.avatar_url ?? null,
+      authorHtmlUrl: data.user?.html_url ?? null,
+      submittedAt: data.submitted_at ? new Date(data.submitted_at) : null,
+      url: data.html_url ?? null,
+    };
+  }
+
+  private mapCheckRunStatus(
+    status: string | null | undefined
+  ): GitPullRequestCheck["status"] {
+    switch (status) {
+      case "queued":
+        return "queued";
+      case "in_progress":
+        return "in_progress";
+      case "waiting":
+        return "waiting";
+      case "pending":
+        return "pending";
+      case "requested":
+        return "requested";
+      default:
+        return "completed";
+    }
+  }
+
+  private mapCheckRunConclusion(
+    conclusion: string | null | undefined
+  ): GitPullRequestCheck["conclusion"] {
+    switch (conclusion) {
+      case "success":
+        return "success";
+      case "failure":
+        return "failure";
+      case "neutral":
+        return "neutral";
+      case "cancelled":
+        return "cancelled";
+      case "skipped":
+        return "skipped";
+      case "timed_out":
+        return "timed_out";
+      case "action_required":
+        return "action_required";
+      case "stale":
+        return "stale";
+      default:
+        return null;
+    }
+  }
+
+  private mapLegacyStatusState(
+    state: string
+  ): GitPullRequestCheck["conclusion"] {
+    switch (state) {
+      case "success":
+        return "success";
+      case "failure":
+      case "error":
+        return "failure";
+      case "pending":
+        return null;
+      default:
+        return "neutral";
+    }
+  }
+
+  private mapPullRequest(data: {
+    id: number;
+    number: number;
+    html_url: string;
+    title: string;
+    body?: string | null;
+    state: string;
+    merged_at: string | null;
+    closed_at: string | null;
+    draft?: boolean;
+    head: { ref: string; sha: string };
+    base: { ref: string };
+  }): GitPullRequest {
+    return {
+      providerPrId: String(data.id),
+      number: data.number,
+      url: data.html_url,
+      title: data.title,
+      body: data.body ?? null,
+      state: mapPrState(data.state, Boolean(data.merged_at), data.draft ?? false),
+      headRef: data.head.ref,
+      baseRef: data.base.ref,
+      headSha: data.head.sha,
+      mergedAt: data.merged_at ? new Date(data.merged_at) : null,
+      closedAt: data.closed_at ? new Date(data.closed_at) : null,
+    };
+  }
+}
diff --git a/src/lib/git/github/webhooks.ts b/src/lib/git/github/webhooks.ts
new file mode 100644
index 0000000..758a8e6
--- /dev/null
+++ b/src/lib/git/github/webhooks.ts
@@ -0,0 +1,463 @@
+import "@tanstack/react-start/server-only";
+
+import { createHmac, timingSafeEqual } from "node:crypto";
+import { and, eq } from "drizzle-orm";
+import { v7 as uuid } from "uuid";
+import { db } from "~/db";
+import {
+  gitCommitStatusApplications,
+  gitConnections,
+  gitRepositories,
+  gitStatusRules,
+  gitWebhookDeliveries,
+  projects,
+  taskGitActivity,
+  taskGitBranches,
+  tasks,
+} from "~/db/schema";
+import { env } from "~/env";
+import { getGitProvider } from "../factory";
+import { matchStatusRules } from "../status-rules";
+import { branchMatchesTaskKey, extractTaskKeysFromText, formatTaskKey } from "../task-key";
+import { sync } from "~/db/mutations/sync";
+import { upsertTaskPullRequest } from "../upsert-task-pull-request";
+
+export function verifyGitHubWebhookSignature(
+  payload: string,
+  signature: string | null
+): boolean {
+  if (!signature || !env.GITHUB_APP_WEBHOOK_SECRET) return false;
+  const expected =
+    "sha256=" +
+    createHmac("sha256", env.GITHUB_APP_WEBHOOK_SECRET)
+      .update(payload)
+      .digest("hex");
+  try {
+    return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
+  } catch {
+    return false;
+  }
+}
+
+async function isDeliveryProcessed(deliveryId: string) {
+  const existing = await db.query.gitWebhookDeliveries.findFirst({
+    where: eq(gitWebhookDeliveries.id, deliveryId),
+  });
+  return Boolean(existing);
+}
+
+async function markDeliveryProcessed(
+  deliveryId: string,
+  eventType: string
+) {
+  await db.insert(gitWebhookDeliveries).values({
+    id: deliveryId,
+    provider: "github",
+    eventType,
+    processedAt: new Date(),
+  });
+}
+
+async function findConnectionByInstallationId(installationId: number) {
+  return db.query.gitConnections.findFirst({
+    where: eq(gitConnections.installationId, String(installationId)),
+  });
+}
+
+async function syncReposForConnection(connectionId: string, owner: string) {
+  const connection = await db.query.gitConnections.findFirst({
+    where: eq(gitConnections.id, connectionId),
+  });
+  if (!connection) return;
+
+  const provider = getGitProvider("github");
+  const repos = await provider.syncRepositories(connection);
+  const now = new Date();
+
+  for (const repo of repos) {
+    const existing = await db.query.gitRepositories.findFirst({
+      where: and(
+        eq(gitRepositories.connectionId, connection.id),
+        eq(gitRepositories.externalId, repo.externalId)
+      ),
+    });
+
+    if (existing) {
+      await db
+        .update(gitRepositories)
+        .set({
+          fullName: repo.fullName,
+          defaultBranch: repo.defaultBranch,
+          htmlUrl: repo.htmlUrl,
+          isArchived: repo.isArchived,
+          updatedAt: now,
+        })
+        .where(eq(gitRepositories.id, existing.id));
+    } else {
+      await db.insert(gitRepositories).values({
+        id: uuid(),
+        connectionId: connection.id,
+        owner,
+        externalId: repo.externalId,
+        fullName: repo.fullName,
+        defaultBranch: repo.defaultBranch,
+        htmlUrl: repo.htmlUrl,
+        isArchived: repo.isArchived,
+        updatedAt: now,
+      });
+    }
+  }
+}
+
+async function resolveTaskByKey(
+  owner: string,
+  prefix: string,
+  number: number
+) {
+  const project = await db.query.projects.findFirst({
+    where: and(eq(projects.owner, owner), eq(projects.taskKeyPrefix, prefix)),
+  });
+  if (!project) return null;
+
+  return db.query.tasks.findFirst({
+    where: and(
+      eq(tasks.owner, owner),
+      eq(tasks.projectId, project.id),
+      eq(tasks.number, number)
+    ),
+  });
+}
+
+async function findRepositoryByFullName(owner: string, fullName: string) {
+  return db.query.gitRepositories.findFirst({
+    where: and(
+      eq(gitRepositories.owner, owner),
+      eq(gitRepositories.fullName, fullName)
+    ),
+  });
+}
+
+async function linkPullRequestToTask(params: {
+  owner: string;
+  taskId: string;
+  repositoryId: string;
+  branchId?: string | null;
+  pr: {
+    providerPrId: string;
+    number: number;
+    url: string;
+    title: string;
+    state: "open" | "closed" | "merged" | "draft";
+    headRef: string;
+    baseRef: string;
+    mergedAt: Date | null;
+    closedAt: Date | null;
+  };
+}) {
+  return upsertTaskPullRequest({
+    owner: params.owner,
+    taskId: params.taskId,
+    repositoryId: params.repositoryId,
+    branchId: params.branchId,
+    providerPrId: params.pr.providerPrId,
+    number: params.pr.number,
+    url: params.pr.url,
+    title: params.pr.title,
+    state: params.pr.state,
+    headRef: params.pr.headRef,
+    baseRef: params.pr.baseRef,
+    mergedAt: params.pr.mergedAt,
+    closedAt: params.pr.closedAt,
+  });
+}
+
+export async function processGitHubWebhook(
+  eventType: string,
+  deliveryId: string,
+  payload: Record<string, unknown>
+) {
+  if (await isDeliveryProcessed(deliveryId)) {
+    return { skipped: true };
+  }
+
+  try {
+    if (eventType === "ping") {
+      return { processed: true, ping: true };
+    }
+
+    if (eventType === "installation") {
+      const action = payload.action as string;
+      const installation = payload.installation as {
+        id: number;
+        account?: { login?: string; type?: string };
+      };
+      if (action === "deleted") {
+        await db
+          .delete(gitConnections)
+          .where(
+            eq(gitConnections.installationId, String(installation.id))
+          );
+      }
+    }
+
+    if (
+      eventType === "installation" ||
+      eventType === "installation_repositories"
+    ) {
+      const installation = payload.installation as { id: number };
+      const connection = await findConnectionByInstallationId(
+        installation.id
+      );
+      if (connection) {
+        await syncReposForConnection(connection.id, connection.owner);
+      }
+    }
+
+    if (eventType === "push") {
+      await handlePushEvent(payload, deliveryId);
+    }
+
+    if (eventType.startsWith("pull_request")) {
+      await handlePullRequestEvent(payload);
+    }
+
+    await markDeliveryProcessed(deliveryId, eventType);
+    return { processed: true };
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    console.error(
+      `[github-webhook] ${eventType} delivery=${deliveryId} failed: ${message}`,
+      error
+    );
+    throw error;
+  }
+}
+
+async function handlePushEvent(
+  payload: Record<string, unknown>,
+  deliveryId: string
+) {
+  const repo = payload.repository as { full_name?: string; id?: number };
+  const ref = payload.ref as string;
+  const commits = (payload.commits as { id: string; message: string }[]) ?? [];
+  const pusher = payload.pusher as { name?: string } | undefined;
+  const installation = payload.installation as { id: number } | undefined;
+
+  if (!repo?.full_name || !installation) return;
+
+  const connection = await findConnectionByInstallationId(installation.id);
+  if (!connection) return;
+
+  const repository = await findRepositoryByFullName(
+    connection.owner,
+    repo.full_name
+  );
+  if (!repository) return;
+
+  const branchRef = ref.replace(/^refs\/heads\//, "");
+  const rules = await db.query.gitStatusRules.findMany({
+    where: eq(gitStatusRules.owner, connection.owner),
+  });
+
+  for (const commit of commits) {
+    const keys = extractTaskKeysFromText(commit.message);
+    const match = matchStatusRules(commit.message, rules, keys);
+
+    if (match) {
+      const task = await resolveTaskByKey(
+        connection.owner,
+        match.taskKey.prefix,
+        match.taskKey.number
+      );
+      if (task && task.statusId !== match.rule.targetStatusId) {
+        const applied = await db.query.gitCommitStatusApplications.findFirst({
+          where: and(
+            eq(gitCommitStatusApplications.deliveryId, deliveryId),
+            eq(gitCommitStatusApplications.commitSha, commit.id),
+            eq(gitCommitStatusApplications.ruleId, match.rule.id),
+            eq(gitCommitStatusApplications.taskId, task.id)
+          ),
+        });
+        if (!applied) {
+          await db.insert(gitCommitStatusApplications).values({
+            deliveryId,
+            commitSha: commit.id,
+            ruleId: match.rule.id,
+            taskId: task.id,
+          });
+          await db
+            .update(tasks)
+            .set({
+              statusId: match.rule.targetStatusId,
+              updatedAt: new Date(),
+            })
+            .where(eq(tasks.id, task.id));
+          await sync(`task-update-${task.id}`, { id: task.id });
+        }
+      }
+    }
+
+    for (const key of keys) {
+      const task = await resolveTaskByKey(
+        connection.owner,
+        key.prefix,
+        key.number
+      );
+      if (!task) continue;
+
+      await db.insert(taskGitActivity).values({
+        id: uuid(),
+        taskId: task.id,
+        owner: connection.owner,
+        type: "commit",
+        payload: {
+          sha: commit.id,
+          message: commit.message,
+          ref: branchRef,
+          repositoryId: repository.id,
+        },
+        actorLogin: pusher?.name ?? null,
+        occurredAt: new Date(),
+      });
+
+      const taskKeyStr = formatTaskKey(key.prefix, key.number);
+      let linkedBranch = await db.query.taskGitBranches.findFirst({
+        where: and(
+          eq(taskGitBranches.taskId, task.id),
+          eq(taskGitBranches.repositoryId, repository.id)
+        ),
+      });
+
+      if (
+        !linkedBranch &&
+        branchMatchesTaskKey(branchRef, taskKeyStr)
+      ) {
+        const branchId = uuid();
+        await db.insert(taskGitBranches).values({
+          id: branchId,
+          taskId: task.id,
+          repositoryId: repository.id,
+          owner: connection.owner,
+          ref: branchRef,
+          sha: commit.id,
+          updatedAt: new Date(),
+        });
+        linkedBranch = await db.query.taskGitBranches.findFirst({
+          where: eq(taskGitBranches.id, branchId),
+        });
+      }
+
+      if (linkedBranch && linkedBranch.ref === branchRef) {
+        await db
+          .update(taskGitBranches)
+          .set({ sha: commit.id, updatedAt: new Date() })
+          .where(eq(taskGitBranches.id, linkedBranch.id));
+      }
+
+      await sync(`task-update-${task.id}`, { id: task.id });
+    }
+  }
+}
+
+async function handlePullRequestEvent(payload: Record<string, unknown>) {
+  const action = payload.action as string | undefined;
+  const pr = payload.pull_request as {
+    id: number;
+    number: number;
+    html_url: string;
+    title: string;
+    state: string;
+    merged_at: string | null;
+    closed_at: string | null;
+    draft?: boolean;
+    head: { ref: string };
+    base: { ref: string };
+    body?: string | null;
+  };
+  const repo = payload.repository as { full_name?: string };
+  const installation = payload.installation as { id: number } | undefined;
+
+  if (!pr || !repo?.full_name || !installation) return;
+
+  const connection = await findConnectionByInstallationId(installation.id);
+  if (!connection) return;
+
+  const repository = await findRepositoryByFullName(
+    connection.owner,
+    repo.full_name
+  );
+  if (!repository) return;
+
+  const state =
+    pr.merged_at ? "merged" : pr.draft ? "draft" : pr.state === "open" ? "open" : "closed";
+
+  const linkedBranch = await db.query.taskGitBranches.findFirst({
+    where: and(
+      eq(taskGitBranches.owner, connection.owner),
+      eq(taskGitBranches.repositoryId, repository.id),
+      eq(taskGitBranches.ref, pr.head.ref)
+    ),
+  });
+
+  let taskId = linkedBranch?.taskId ?? null;
+
+  if (!taskId) {
+    const text = `${pr.title}\n${pr.body ?? ""}`;
+    const keys = extractTaskKeysFromText(text);
+    for (const key of keys) {
+      const task = await resolveTaskByKey(
+        connection.owner,
+        key.prefix,
+        key.number
+      );
+      if (task) {
+        taskId = task.id;
+        break;
+      }
+    }
+  }
+
+  if (!taskId) return;
+
+  await linkPullRequestToTask({
+    owner: connection.owner,
+    taskId,
+    repositoryId: repository.id,
+    branchId: linkedBranch?.id,
+    pr: {
+      providerPrId: String(pr.id),
+      number: pr.number,
+      url: pr.html_url,
+      title: pr.title,
+      state,
+      headRef: pr.head.ref,
+      baseRef: pr.base.ref,
+      mergedAt: pr.merged_at ? new Date(pr.merged_at) : null,
+      closedAt: pr.closed_at ? new Date(pr.closed_at) : null,
+    },
+  });
+
+  const logActivity =
+    action === "opened" ||
+    action === "reopened" ||
+    action === "closed";
+
+  if (logActivity) {
+    await db.insert(taskGitActivity).values({
+      id: uuid(),
+      taskId,
+      owner: connection.owner,
+      type: "pull_request",
+      payload: {
+        number: pr.number,
+        state,
+        url: pr.html_url,
+        title: pr.title,
+        action: action === "closed" && state === "merged" ? "closed" : action,
+      },
+      occurredAt: new Date(),
+    });
+  }
+
+  await sync(`task-update-${taskId}`, { id: taskId });
+}
diff --git a/src/lib/git/gitlab/provider.ts b/src/lib/git/gitlab/provider.ts
new file mode 100644
index 0000000..ff6c666
--- /dev/null
+++ b/src/lib/git/gitlab/provider.ts
@@ -0,0 +1,88 @@
+import type { GitConnection } from "~/db/schema";
+import type { GitProvider } from "../types";
+
+const NOT_IMPLEMENTED = "GitLab integration is not yet available";
+
+export class GitLabProvider implements GitProvider {
+  listAccessibleRepos(_connection: GitConnection) {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  syncRepositories(_connection: GitConnection) {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  createBranch() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  getPullRequest() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestsForRef() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  createPullRequest() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  getPullRequestDiff() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  compareBranches() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestCommits() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  getCommitDiff() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestIssueComments() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestReviewComments() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestReviewCommentsAsUser() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestReviewCommentsForReviewAsUser() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestReviewThreadCommentsAsUser() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestReviewThreads() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  setReviewThreadResolved() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  listPullRequestReviews() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  findPendingReviewForUser() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  startPendingReview() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  createPullRequestReviewComment() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  submitPullRequestReview() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  discardPendingReview() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  getPullRequestMergeStatus() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  mergePullRequest() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  closePullRequest() {
+    return Promise.reject(new Error(NOT_IMPLEMENTED));
+  }
+  parsePullRequestUrl() {
+    return null;
+  }
+}
diff --git a/src/lib/git/pending-review.ts b/src/lib/git/pending-review.ts
new file mode 100644
index 0000000..50cbcd2
--- /dev/null
+++ b/src/lib/git/pending-review.ts
@@ -0,0 +1,30 @@
+import type { GitPullRequestReview } from "~/lib/git/types";
+
+/** Whether a GitHub pull request review is still in progress (not submitted). */
+export function isPendingReviewState(review: {
+  state?: string | null;
+  submitted_at?: string | null;
+}): boolean {
+  if (review.submitted_at) return false;
+  const state = (review.state ?? "").toUpperCase().replace(/ /g, "_");
+  if (state === "DISMISSED" || state === "APPROVED" || state === "CHANGES_REQUESTED") {
+    return false;
+  }
+  return true;
+}
+
+/** Pick the current viewer's unsubmitted review from a list (newest first). */
+export function pickPendingReviewForViewer(
+  reviews: GitPullRequestReview[],
+  viewerLogin: string
+): GitPullRequestReview | null {
+  const login = viewerLogin.toLowerCase();
+  for (let i = reviews.length - 1; i >= 0; i--) {
+    const review = reviews[i];
+    if (review.authorLogin.toLowerCase() !== login) continue;
+    if (review.submittedAt != null) continue;
+    if (review.state === "DISMISSED") continue;
+    return review;
+  }
+  return null;
+}
diff --git a/src/lib/git/pr-check-summary.ts b/src/lib/git/pr-check-summary.ts
new file mode 100644
index 0000000..a8ec02c
--- /dev/null
+++ b/src/lib/git/pr-check-summary.ts
@@ -0,0 +1,190 @@
+import type { GitPullRequestCheck } from "~/lib/git/types";
+
+export type PrCheckGroup = {
+  neutral: GitPullRequestCheck[];
+  successful: GitPullRequestCheck[];
+  failed: GitPullRequestCheck[];
+  inProgress: GitPullRequestCheck[];
+};
+
+export function groupPullRequestChecks(
+  checks: GitPullRequestCheck[]
+): PrCheckGroup {
+  const inProgress = checks.filter((c) => c.status !== "completed");
+  const completed = checks.filter((c) => c.status === "completed");
+
+  const failed = completed.filter(
+    (c) =>
+      c.conclusion === "failure" ||
+      c.conclusion === "timed_out" ||
+      c.conclusion === "action_required"
+  );
+  const neutral = completed.filter(
+    (c) =>
+      c.conclusion === "neutral" ||
+      c.conclusion === "skipped" ||
+      c.conclusion === "cancelled" ||
+      c.conclusion === "stale" ||
+      c.conclusion === null
+  );
+  const successful = completed.filter((c) => c.conclusion === "success");
+
+  return { neutral, successful, failed, inProgress };
+}
+
+export function checksSummaryLabel(group: PrCheckGroup): {
+  headline: string;
+  detail: string | null;
+  tone: "success" | "failure" | "pending" | "none";
+} {
+  const total =
+    group.neutral.length +
+    group.successful.length +
+    group.failed.length +
+    group.inProgress.length;
+
+  if (total === 0) {
+    return {
+      headline: "No checks reported",
+      detail: null,
+      tone: "none",
+    };
+  }
+
+  if (group.inProgress.length > 0) {
+    return {
+      headline: "Checks in progress",
+      detail: formatCheckCounts(group),
+      tone: "pending",
+    };
+  }
+
+  if (group.failed.length > 0) {
+    return {
+      headline: "Some checks failed",
+      detail: formatCheckCounts(group),
+      tone: "failure",
+    };
+  }
+
+  if (group.successful.length === 0) {
+    return {
+      headline: "No successful checks",
+      detail: formatCheckCounts(group),
+      tone: "none",
+    };
+  }
+
+  return {
+    headline: "All checks have passed",
+    detail: formatCheckCounts(group),
+    tone: "success",
+  };
+}
+
+function formatCheckCounts(group: PrCheckGroup): string {
+  const parts: string[] = [];
+  if (group.neutral.length > 0) {
+    parts.push(
+      `${group.neutral.length} neutral check${group.neutral.length === 1 ? "" : "s"}`
+    );
+  }
+  if (group.successful.length > 0) {
+    parts.push(
+      `${group.successful.length} successful check${group.successful.length === 1 ? "" : "s"}`
+    );
+  }
+  if (group.failed.length > 0) {
+    parts.push(
+      `${group.failed.length} failed check${group.failed.length === 1 ? "" : "s"}`
+    );
+  }
+  if (group.inProgress.length > 0) {
+    parts.push(
+      `${group.inProgress.length} in progress`
+    );
+  }
+  return parts.join(", ");
+}
+
+export function canMergePullRequest(params: {
+  state: string;
+  mergeable: boolean | null;
+  mergeableState: string;
+  checkGroup: PrCheckGroup;
+}): boolean {
+  if (params.state !== "open") return false;
+  if (params.mergeable !== true) return false;
+  if (params.checkGroup.failed.length > 0) return false;
+  if (params.checkGroup.inProgress.length > 0) return false;
+  const state = params.mergeableState.toLowerCase();
+  return state === "clean";
+}
+
+export function mergeabilityMessage(params: {
+  mergeable: boolean | null;
+  mergeableState: string;
+}): { ok: boolean; title: string; detail: string } {
+  const state = params.mergeableState.toLowerCase();
+
+  if (params.mergeable === true && state === "clean") {
+    return {
+      ok: true,
+      title: "No conflicts with base branch",
+      detail: "Merging can be performed automatically.",
+    };
+  }
+
+  if (state === "dirty" || state === "conflicting") {
+    return {
+      ok: false,
+      title: "Conflicts must be resolved",
+      detail: "This branch has merge conflicts with the base branch.",
+    };
+  }
+
+  if (params.mergeable === false && state === "unknown") {
+    return {
+      ok: false,
+      title: "Conflicts must be resolved",
+      detail: "This branch cannot be merged until conflicts with the base branch are fixed.",
+    };
+  }
+
+  if (state === "behind") {
+    return {
+      ok: false,
+      title: "Branch is out of date",
+      detail: "Update the branch before merging.",
+    };
+  }
+  if (state === "blocked") {
+    return {
+      ok: false,
+      title: "Merging is blocked",
+      detail: "Required reviews or checks are not satisfied yet.",
+    };
+  }
+  if (state === "unstable") {
+    return {
+      ok: false,
+      title: "Checks must pass before merge",
+      detail: "Some required checks are failing or still running.",
+    };
+  }
+
+  if (params.mergeable === null && state === "unknown") {
+    return {
+      ok: false,
+      title: "Conflicts must be resolved",
+      detail:
+        "GitHub has not confirmed mergeability yet, but this pull request cannot be merged until you resolve conflicts with the base branch (check the PR on GitHub).",
+    };
+  }
+
+  return {
+    ok: false,
+    title: "Merge status unknown",
+    detail: "Open the pull request on GitHub for details.",
+  };
+}
diff --git a/src/lib/git/pr-review-feed-entries.ts b/src/lib/git/pr-review-feed-entries.ts
new file mode 100644
index 0000000..7610f60
--- /dev/null
+++ b/src/lib/git/pr-review-feed-entries.ts
@@ -0,0 +1,167 @@
+import type {
+  GitPullRequestIssueComment,
+  GitPullRequestReview,
+  GitReviewComment,
+} from "~/lib/git/types";
+
+export type ReviewCommentWithMeta = {
+  comment: GitReviewComment;
+  pending: boolean;
+};
+
+export type PrReviewCommentThread = {
+  root: GitReviewComment;
+  replies: GitReviewComment[];
+  pendingCommentIds: Set<number>;
+  threadNodeId: string | null;
+  isResolved: boolean;
+  at: Date;
+};
+
+export type PrReviewFeedEntry =
+  | {
+      kind: "review";
+      at: Date;
+      review: GitPullRequestReview;
+      /** Inline comments submitted together with this review (e.g. request changes). */
+      attachedComments: GitReviewComment[];
+    }
+  | { kind: "thread"; at: Date; thread: PrReviewCommentThread }
+  | { kind: "issue_comment"; at: Date; comment: GitPullRequestIssueComment };
+
+function shouldIncludeReview(review: GitPullRequestReview): boolean {
+  if (review.state === "PENDING") return false;
+  if (review.state === "COMMENTED" && !review.body?.trim()) return false;
+  return true;
+}
+
+function latestThreadActivityAt(
+  root: GitReviewComment,
+  replies: GitReviewComment[]
+): Date {
+  const latest = Math.max(
+    root.createdAt.getTime(),
+    ...replies.map((r) => r.createdAt.getTime())
+  );
+  return new Date(latest);
+}
+
+/** Group line comments into threads via `inReplyToId`. */
+export function groupReviewCommentsIntoThreads(
+  items: ReviewCommentWithMeta[]
+): PrReviewCommentThread[] {
+  const visible = items.filter((item) => item.comment.body?.trim());
+  if (visible.length === 0) return [];
+
+  const byId = new Map(visible.map((item) => [item.comment.id, item]));
+  const childrenOf = new Map<number, ReviewCommentWithMeta[]>();
+  const roots: ReviewCommentWithMeta[] = [];
+
+  for (const item of visible) {
+    const parentId = item.comment.inReplyToId;
+    if (parentId != null && byId.has(parentId)) {
+      const siblings = childrenOf.get(parentId) ?? [];
+      siblings.push(item);
+      childrenOf.set(parentId, siblings);
+    } else {
+      roots.push(item);
+    }
+  }
+
+  function collectReplies(parentId: number): ReviewCommentWithMeta[] {
+    const direct = childrenOf.get(parentId) ?? [];
+    const nested: ReviewCommentWithMeta[] = [];
+    for (const child of direct) {
+      nested.push(child, ...collectReplies(child.comment.id));
+    }
+    return nested.sort(
+      (a, b) => a.comment.createdAt.getTime() - b.comment.createdAt.getTime()
+    );
+  }
+
+  const threads = roots.map((root) => {
+    const replyItems = collectReplies(root.comment.id);
+    const pendingCommentIds = new Set<number>();
+    if (root.pending) pendingCommentIds.add(root.comment.id);
+    for (const reply of replyItems) {
+      if (reply.pending) pendingCommentIds.add(reply.comment.id);
+    }
+    return {
+      root: root.comment,
+      replies: replyItems.map((r) => r.comment),
+      pendingCommentIds,
+      threadNodeId: root.comment.threadNodeId ?? null,
+      isResolved: root.comment.threadIsResolved ?? false,
+      at: latestThreadActivityAt(root.comment, replyItems.map((r) => r.comment)),
+    };
+  });
+
+  threads.sort((a, b) => a.at.getTime() - b.at.getTime());
+  return threads;
+}
+
+export function buildPrReviewFeedEntries(
+  reviews: GitPullRequestReview[],
+  submittedComments: GitReviewComment[],
+  pendingComments: GitReviewComment[],
+  issueComments: GitPullRequestIssueComment[] = []
+): PrReviewFeedEntry[] {
+  const entries: PrReviewFeedEntry[] = [];
+  const includedReviews = reviews.filter(shouldIncludeReview);
+  const submittedReviewIds = new Set(includedReviews.map((r) => r.id));
+
+  const commentsByReviewId = new Map<number, GitReviewComment[]>();
+  for (const comment of submittedComments) {
+    if (
+      comment.reviewId == null ||
+      !submittedReviewIds.has(comment.reviewId)
+    ) {
+      continue;
+    }
+    const list = commentsByReviewId.get(comment.reviewId) ?? [];
+    list.push(comment);
+    commentsByReviewId.set(comment.reviewId, list);
+  }
+
+  const threadSourceComments = [
+    ...submittedComments.filter(
+      (comment) =>
+        comment.reviewId == null ||
+        !submittedReviewIds.has(comment.reviewId)
+    ),
+    ...pendingComments,
+  ];
+
+  for (const review of includedReviews) {
+    entries.push({
+      kind: "review",
+      at: review.submittedAt ?? new Date(0),
+      review,
+      attachedComments: commentsByReviewId.get(review.id) ?? [],
+    });
+  }
+
+  const pendingCommentIds = new Set(pendingComments.map((c) => c.id));
+  const threads = groupReviewCommentsIntoThreads([
+    ...threadSourceComments.map((comment) => ({
+      comment,
+      pending: pendingCommentIds.has(comment.id),
+    })),
+  ]);
+
+  for (const thread of threads) {
+    entries.push({ kind: "thread", at: thread.at, thread });
+  }
+
+  for (const comment of issueComments) {
+    if (!comment.body?.trim()) continue;
+    entries.push({
+      kind: "issue_comment",
+      at: comment.createdAt,
+      comment,
+    });
+  }
+
+  entries.sort((a, b) => a.at.getTime() - b.at.getTime());
+  return entries;
+}
diff --git a/src/lib/git/preprocess-github-markdown.ts b/src/lib/git/preprocess-github-markdown.ts
new file mode 100644
index 0000000..47f38da
--- /dev/null
+++ b/src/lib/git/preprocess-github-markdown.ts
@@ -0,0 +1,17 @@
+/** GitHub bots often nest `<details>`; outer height clipping blocks interacting with them. */
+export function hasGitHubCollapsibleSections(body: string): boolean {
+  return /<details[\s>]/i.test(body);
+}
+
+/** Prepare GitHub PR/issue comment bodies for markdown + limited HTML rendering. */
+export function preprocessGitHubMarkdown(body: string): string {
+  return (
+    body
+      // Bot metadata (e.g. coderabbit suggestion markers)
+      .replace(/<!--[\s\S]*?-->/g, "")
+      // GitHub-only fenced blocks → plain code fences for display
+      .replace(/```suggestion\n/g, "```\n")
+      .replace(/```suggestion\r\n/g, "```\r\n")
+      .trim()
+  );
+}
diff --git a/src/lib/git/resolve-project-link.ts b/src/lib/git/resolve-project-link.ts
new file mode 100644
index 0000000..f077f5c
--- /dev/null
+++ b/src/lib/git/resolve-project-link.ts
@@ -0,0 +1,62 @@
+import "@tanstack/react-start/server-only";
+
+import { and, desc, eq } from "drizzle-orm";
+import { db } from "~/db";
+import { projectGitRepositories, taskGitBranches } from "~/db/schema";
+import { MULTI_REPO_SELECTION_MESSAGE } from "./constants";
+
+export { MULTI_REPO_SELECTION_MESSAGE };
+
+/**
+ * Resolves a project↔repo link for git operations on a task.
+ * Uses explicit repositoryId, else the task's linked branch repo, else the only linked repo.
+ */
+export async function resolveProjectRepositoryLink(
+  projectId: string,
+  owner: string,
+  options?: { repositoryId?: string; taskId?: string }
+) {
+  const links = await db.query.projectGitRepositories.findMany({
+    where: eq(projectGitRepositories.projectId, projectId),
+    with: { repository: { with: { connection: true } } },
+  });
+
+  const withConnection = links.filter((l) => l.repository?.connection);
+  if (withConnection.length === 0) {
+    throw new Error("No repository linked to this project");
+  }
+
+  if (options?.repositoryId) {
+    const link = withConnection.find(
+      (l) => l.repositoryId === options.repositoryId
+    );
+    if (!link) {
+      throw new Error("Repository is not linked to this project");
+    }
+    return link;
+  }
+
+  if (options?.taskId) {
+    const branches = await db.query.taskGitBranches.findMany({
+      where: and(
+        eq(taskGitBranches.taskId, options.taskId),
+        eq(taskGitBranches.owner, owner)
+      ),
+      orderBy: [desc(taskGitBranches.updatedAt)],
+    });
+    const active =
+      branches.find((b) => b.state === "active") ?? branches[0];
+    if (active) {
+      const link = withConnection.find(
+        (l) => l.repositoryId === active.repositoryId
+      );
+      if (link) return link;
+    }
+  }
+
+  if (withConnection.length === 1) {
+    return withConnection[0];
+  }
+
+  throw new Error(MULTI_REPO_SELECTION_MESSAGE);
+}
diff --git a/src/lib/git/resolve-task-repo.ts b/src/lib/git/resolve-task-repo.ts
new file mode 100644
index 0000000..5bb0852
--- /dev/null
+++ b/src/lib/git/resolve-task-repo.ts
@@ -0,0 +1,101 @@
+import "@tanstack/react-start/server-only";
+
+import { and, eq } from "drizzle-orm";
+import { db } from "~/db";
+import {
+  gitRepositories,
+  projectGitRepositories,
+  taskGitBranches,
+  taskGitPullRequests,
+  tasks,
+} from "~/db/schema";
+import { resolveProjectRepositoryLink } from "./resolve-project-link";
+import type { GitRepo } from "./types";
+
+export function toGitRepo(
+  repository: typeof gitRepositories.$inferSelect
+): GitRepo {
+  return {
+    externalId: repository.externalId,
+    fullName: repository.fullName,
+    defaultBranch: repository.defaultBranch,
+    htmlUrl: repository.htmlUrl,
+    isArchived: repository.isArchived,
+  };
+}
+
+export async function resolveTaskRepository(
+  owner: string,
+  taskId: string,
+  options?: { repositoryId?: string; pullRequestId?: string }
+) {
+  const task = await db.query.tasks.findFirst({
+    where: and(eq(tasks.id, taskId), eq(tasks.owner, owner)),
+    with: { project: true },
+  });
+  if (!task) throw new Error("Task not found");
+
+  if (options?.pullRequestId) {
+    const pr = await db.query.taskGitPullRequests.findFirst({
+      where: and(
+        eq(taskGitPullRequests.id, options.pullRequestId),
+        eq(taskGitPullRequests.taskId, taskId),
+        eq(taskGitPullRequests.owner, owner)
+      ),
+      with: {
+        repository: { with: { connection: true } },
+        branch: true,
+      },
+    });
+    if (!pr?.repository?.connection) throw new Error("Pull request not found");
+    return {
+      task,
+      repository: pr.repository,
+      connection: pr.repository.connection,
+      branch: pr.branch ?? null,
+      pullRequest: pr,
+    };
+  }
+
+  const branchWhere = options?.repositoryId
+    ? and(
+        eq(taskGitBranches.taskId, taskId),
+        eq(taskGitBranches.owner, owner),
+        eq(taskGitBranches.repositoryId, options.repositoryId)
+      )
+    : and(eq(taskGitBranches.taskId, taskId), eq(taskGitBranches.owner, owner));
+
+  const branches = await db.query.taskGitBranches.findMany({
+    where: branchWhere,
+    with: { repository: { with: { connection: true } } },
+    orderBy: (b, { desc: d }) => [d(b.updatedAt)],
+  });
+  const branch = branches[0];
+
+  if (branch?.repository?.connection) {
+    return {
+      task,
+      repository: branch.repository,
+      connection: branch.repository.connection,
+      branch,
+      pullRequest: null as null,
+    };
+  }
+
+  const projectLink = await resolveProjectRepositoryLink(
+    task.projectId,
+    owner,
+    {
+      repositoryId: options?.repositoryId,
+      taskId,
+    }
+  );
+
+  return {
+    task,
+    repository: projectLink.repository,
+    connection: projectLink.repository.connection,
+    branch: null as null,
+    pullRequest: null as null,
+  };
+}
diff --git a/src/lib/git/retry.ts b/src/lib/git/retry.ts
new file mode 100644
index 0000000..421bda0
--- /dev/null
+++ b/src/lib/git/retry.ts
@@ -0,0 +1,3 @@
+export function sleep(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/src/lib/git/review-comment-annotation.ts b/src/lib/git/review-comment-annotation.ts
new file mode 100644
index 0000000..82ff5c5
--- /dev/null
+++ b/src/lib/git/review-comment-annotation.ts
@@ -0,0 +1,91 @@
+import type { FileDiffMetadata } from "@pierre/diffs";
+import type { DiffLineAnnotation } from "@pierre/diffs";
+import type { GitReviewComment } from "~/lib/git/types";
+
+export type DiffLineIndex = {
+  deletions: Set<number>;
+  additions: Set<number>;
+};
+
+export function buildDiffLineIndex(fileDiff: FileDiffMetadata): DiffLineIndex {
+  const deletions = new Set<number>();
+  const additions = new Set<number>();
+  for (const hunk of fileDiff.hunks) {
+    for (let i = 0; i < hunk.deletionCount; i++) {
+      deletions.add(hunk.deletionStart + i);
+    }
+    for (let i = 0; i < hunk.additionCount; i++) {
+      additions.add(hunk.additionStart + i);
+    }
+  }
+  return { deletions, additions };
+}
+
+export function inferCommentSide(
+  comment: GitReviewComment
+): "LEFT" | "RIGHT" {
+  if (comment.side === "LEFT" || comment.side === "RIGHT") {
+    return comment.side;
+  }
+  if (comment.originalLine != null && comment.line == null) return "LEFT";
+  return "RIGHT";
+}
+
+function lineForSide(
+  comment: GitReviewComment,
+  side: "LEFT" | "RIGHT"
+): number | null {
+  if (side === "LEFT") {
+    return comment.originalLine ?? comment.line;
+  }
+  return comment.line ?? comment.originalLine;
+}
+
+function pierreSideForGitSide(side: "LEFT" | "RIGHT"): "deletions" | "additions" {
+  return side === "LEFT" ? "deletions" : "additions";
+}
+
+/** Prefer comments that still have line/side after GitHub refetch. */
+export function pickRicherReviewComment(
+  a: GitReviewComment,
+  b: GitReviewComment
+): GitReviewComment {
+  const score = (c: GitReviewComment) =>
+    (c.line != null ? 2 : 0) +
+    (c.originalLine != null ? 2 : 0) +
+    (c.side != null ? 1 : 0);
+  const winner = score(a) >= score(b) ? a : b;
+  const other = winner === a ? b : a;
+  return {
+    ...winner,
+    inReplyToId: winner.inReplyToId ?? other.inReplyToId,
+  };
+}
+
+export function commentToDiffAnnotation(
+  comment: GitReviewComment,
+  index?: DiffLineIndex
+): DiffLineAnnotation<GitReviewComment> | null {
+  const gitSide = inferCommentSide(comment);
+  let line = lineForSide(comment, gitSide);
+  if (line == null) return null;
+
+  let pierreSide = pierreSideForGitSide(gitSide);
+
+  if (index) {
+    const primary = index[pierreSide];
+    if (!primary.has(line)) {
+      const altGitSide = gitSide === "LEFT" ? "RIGHT" : "LEFT";
+      const altLine = lineForSide(comment, altGitSide);
+      const altPierreSide = pierreSideForGitSide(altGitSide);
+      if (altLine != null && index[altPierreSide].has(altLine)) {
+        line = altLine;
+        pierreSide = altPierreSide;
+      } else if (!primary.has(line)) {
+        return null;
+      }
+    }
+  }
+
+  return { side: pierreSide, lineNumber: line, metadata: comment };
+}
diff --git a/src/lib/git/review-thread.ts b/src/lib/git/review-thread.ts
new file mode 100644
index 0000000..fdf4820
--- /dev/null
+++ b/src/lib/git/review-thread.ts
@@ -0,0 +1,33 @@
+import type { GitReviewComment, GitReviewThread } from "~/lib/git/types";
+
+export function applyReviewThreadMetadata(
+  comments: GitReviewComment[],
+  threads: GitReviewThread[]
+): GitReviewComment[] {
+  const threadByCommentId = new Map<number, GitReviewThread>();
+  for (const thread of threads) {
+    for (const id of thread.commentIds) {
+      threadByCommentId.set(id, thread);
+    }
+  }
+
+  return comments.map((comment) => {
+    const thread = threadByCommentId.get(comment.id);
+    if (!thread) return comment;
+    return {
+      ...comment,
+      threadNodeId: thread.nodeId,
+      threadIsResolved: thread.isResolved,
+    };
+  });
+}
+
+/** First comment in a thread (for resolve UI — one control per conversation). */
+export function isReviewThreadRoot(
+  comment: GitReviewComment,
+  comments: GitReviewComment[]
+): boolean {
+  if (!comment.threadNodeId) return false;
+  if (comment.inReplyToId == null) return true;
+  return !comments.some((c) => c.id === comment.inReplyToId);
+}
diff --git a/src/lib/git/scroll-diff-to-line.ts b/src/lib/git/scroll-diff-to-line.ts
new file mode 100644
index 0000000..54f67e7
--- /dev/null
+++ b/src/lib/git/scroll-diff-to-line.ts
@@ -0,0 +1,37 @@
+/** Best-effort scroll to a line in a Pierre / unified diff container. */
+export function scrollDiffToLine(
+  container: HTMLElement | null,
+  line: number,
+  side: "LEFT" | "RIGHT"
+): boolean {
+  if (!container) return false;
+
+  const lineText = String(line);
+
+  const rows = container.querySelectorAll("tr");
+  for (const row of rows) {
+    const cells = row.querySelectorAll("td, th");
+    for (const cell of cells) {
+      if (cell.textContent?.trim() !== lineText) continue;
+      const inDeletion =
+        cell.className.includes("deletion") ||
+        cell.closest('[class*="deletion"]') != null;
+      const inAddition =
+        cell.className.includes("addition") ||
+        cell.closest('[class*="addition"]') != null;
+      if (side === "LEFT" && !inDeletion && inAddition) continue;
+      if (side === "RIGHT" && !inAddition && inDeletion) continue;
+      row.scrollIntoView({ block: "center", behavior: "smooth" });
+      return true;
+    }
+  }
+
+  for (const el of container.querySelectorAll("td, span, div")) {
+    if (el.textContent?.trim() === lineText) {
+      el.scrollIntoView({ block: "center", behavior: "smooth" });
+      return true;
+    }
+  }
+
+  return false;
+}
diff --git a/src/lib/git/status-rules.ts b/src/lib/git/status-rules.ts
new file mode 100644
index 0000000..1fa3033
--- /dev/null
+++ b/src/lib/git/status-rules.ts
@@ -0,0 +1,79 @@
+import type { GitStatusRule } from "~/db/schema";
+import { extractTaskKeysFromText } from "./task-key";
+
+export type StatusRuleMatch = {
+  rule: GitStatusRule;
+  taskKey: { prefix: string; number: number };
+};
+
+/**
+ * Placeholder in commit status rule patterns. Replaced with the literal task key
+ * (e.g. PY-12). Use `#?KEY` when the hash is optional: `fixes #PY-12` or `fixes PY-12`.
+ */
+export const STATUS_RULE_KEY_PLACEHOLDER = "KEY";
+
+/** GitHub-style closing keywords (https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue). */
+export const CLOSING_KEYWORDS =
+  "fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved";
+
+/**
+ * Shared convention for commit messages and status rules:
+ * - Reference tasks as `PY-12` or `#PY-12`
+ * - Close/move with `<keyword> #?KEY` (e.g. `fixes #PY-12`, `resolves PY-12`)
+ * Patterns are JavaScript regex; matching is case-insensitive. Do not use `(?i)`.
+ */
+export const COMMIT_STATUS_RULE_CONVENTION = {
+  taskReference: "PY-12 or #PY-12",
+  closeExample: "fixes #PY-12",
+  wipExample: "#PY-12 wip",
+  placeholder: STATUS_RULE_KEY_PLACEHOLDER,
+} as const;
+
+/** `(?i)` is PCRE-style; JavaScript RegExp uses the `i` flag instead. */
+export function normalizeStatusRulePattern(pattern: string): string {
+  return pattern.replace(/^\(\?i\)/, "").trim();
+}
+
+export function buildRuleRegex(pattern: string, taskKey: string): RegExp {
+  const escapedKey = taskKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+  const withKey = normalizeStatusRulePattern(pattern).replaceAll(
+    STATUS_RULE_KEY_PLACEHOLDER,
+    escapedKey
+  );
+  return new RegExp(withKey, "i");
+}
+
+export function matchStatusRules(
+  message: string,
+  rules: GitStatusRule[],
+  taskKeys: { prefix: string; number: number }[]
+): StatusRuleMatch | null {
+  const keys =
+    taskKeys.length > 0 ? taskKeys : extractTaskKeysFromText(message);
+
+  const sortedRules = [...rules].sort((a, b) => b.priority - a.priority);
+
+  for (const taskKey of keys) {
+    const displayKey = `${taskKey.prefix}-${taskKey.number}`;
+    for (const rule of sortedRules) {
+      let regex: RegExp;
+      try {
+        regex = buildRuleRegex(rule.pattern, displayKey);
+      } catch {
+        continue;
+      }
+      if (regex.test(message)) {
+        return { rule, taskKey };
+      }
+    }
+  }
+
+  return null;
+}
+
+export const DEFAULT_STATUS_RULE_PATTERNS = {
+  /** e.g. fixes #PY-12, resolves PY-12 */
+  close: `(?:${CLOSING_KEYWORDS})\\s+#?${STATUS_RULE_KEY_PLACEHOLDER}\\b`,
+  /** e.g. #PY-12 wip, PY-12 in progress */
+  wip: `#?${STATUS_RULE_KEY_PLACEHOLDER}\\b\\s+(?:wip|in[- ]?progress)`,
+} as const;
diff --git a/src/lib/git/task-dev-phase.ts b/src/lib/git/task-dev-phase.ts
new file mode 100644
index 0000000..5d8bcf5
--- /dev/null
+++ b/src/lib/git/task-dev-phase.ts
@@ -0,0 +1,79 @@
+import type { TaskGitPullRequest } from "~/db/schema";
+
+type ProjectRepoLink = {
+  repositoryId: string;
+  defaultBaseBranch: string | null;
+  repository: { defaultBranch: string };
+};
+
+type BranchRow = {
+  id: string;
+  ref: string;
+  sha: string | null;
+  state: string;
+  repositoryId: string;
+  repository: { fullName: string; htmlUrl: string; defaultBranch: string };
+};
+
+type PrRow = TaskGitPullRequest & {
+  updatedAt: Date | string;
+};
+
+export type TaskDevPhase =
+  | "no_repo"
+  | "not_started"
+  | "branch_only"
+  | "open_pr"
+  | "closed_pr";
+
+export type TaskDevPhaseContext = {
+  projectRepos: ProjectRepoLink[];
+  branches: BranchRow[];
+  pullRequests: PrRow[];
+};
+
+export function getActiveBranch(ctx: TaskDevPhaseContext) {
+  return ctx.branches.find((b) => b.state === "active") ?? ctx.branches[0];
+}
+
+export function getOpenPr(ctx: TaskDevPhaseContext) {
+  return ctx.pullRequests.find(
+    (p) => p.state === "open" || p.state === "draft"
+  );
+}
+
+export function getLatestPr(ctx: TaskDevPhaseContext) {
+  return ctx.pullRequests[0] ?? null;
+}
+
+export function getTaskDevPhase(ctx: TaskDevPhaseContext): TaskDevPhase {
+  if (ctx.projectRepos.length === 0) return "no_repo";
+  if (getOpenPr(ctx)) return "open_pr";
+  if (getActiveBranch(ctx)) return "branch_only";
+  if (getLatestPr(ctx)) return "closed_pr";
+  return "not_started";
+}
+
+export function resolveBaseRef(
+  ctx: TaskDevPhaseContext,
+  branch: BranchRow | undefined,
+  pr: PrRow | null | undefined
+): string {
+  if (pr?.baseRef) return pr.baseRef;
+  if (branch) {
+    const link = ctx.projectRepos.find(
+      (r) => r.repositoryId === branch.repositoryId
+    );
+    if (link?.defaultBaseBranch) return link.defaultBaseBranch;
+    return branch.repository.defaultBranch;
+  }
+  if (ctx.projectRepos.length === 1) {
+    const link = ctx.projectRepos[0];
+    return link?.defaultBaseBranch ?? link?.repository.defaultBranch ?? "main";
+  }
+  return "main";
+}
+
+export function canReviewInline(phase: TaskDevPhase): boolean {
+  return phase === "open_pr";
+}
diff --git a/src/lib/git/task-git-review-nav.tsx b/src/lib/git/task-git-review-nav.tsx
new file mode 100644
index 0000000..81a2e48
--- /dev/null
+++ b/src/lib/git/task-git-review-nav.tsx
@@ -0,0 +1,60 @@
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useMemo,
+  useState,
+  type ReactNode,
+} from "react";
+
+export type TaskGitReviewLineFocus = {
+  path: string;
+  line: number;
+  side: "LEFT" | "RIGHT";
+};
+
+type TaskGitReviewNavContextValue = {
+  lineFocus: TaskGitReviewLineFocus | null;
+  focusLineInDiff: (target: TaskGitReviewLineFocus) => void;
+  clearLineFocus: () => void;
+};
+
+const TaskGitReviewNavContext =
+  createContext<TaskGitReviewNavContextValue | null>(null);
+
+export function TaskGitReviewNavProvider({
+  children,
+  onOpenDevelopment,
+}: {
+  children: ReactNode;
+  onOpenDevelopment: () => void;
+}) {
+  const [lineFocus, setLineFocus] = useState<TaskGitReviewLineFocus | null>(
+    null
+  );
+
+  const focusLineInDiff = useCallback(
+    (target: TaskGitReviewLineFocus) => {
+      onOpenDevelopment();
+      setLineFocus(target);
+    },
+    [onOpenDevelopment]
+  );
+
+  const clearLineFocus = useCallback(() => setLineFocus(null), []);
+
+  const value = useMemo(
+    () => ({ lineFocus, focusLineInDiff, clearLineFocus }),
+    [lineFocus, focusLineInDiff, clearLineFocus]
+  );
+
+  return (
+    <TaskGitReviewNavContext.Provider value={value}>
+      {children}
+    </TaskGitReviewNavContext.Provider>
+  );
+}
+
+export function useTaskGitReviewNav() {
+  return useContext(TaskGitReviewNavContext);
+}
diff --git a/src/lib/git/task-key.ts b/src/lib/git/task-key.ts
new file mode 100644
index 0000000..ceef30c
--- /dev/null
+++ b/src/lib/git/task-key.ts
@@ -0,0 +1,68 @@
+/** Suggest a task key prefix from a project name (e.g. "Project Y" → "PY"). */
+export function suggestTaskKeyPrefix(projectName: string): string {
+  const words = projectName
+    .trim()
+    .split(/\s+/)
+    .filter(Boolean);
+  if (words.length === 0) return "TASK";
+  if (words.length === 1) {
+    const w = words[0].replace(/[^a-zA-Z0-9]/g, "");
+    return (w.slice(0, 4) || "TASK").toUpperCase();
+  }
+  return words
+    .map((w) => w[0])
+    .join("")
+    .replace(/[^a-zA-Z0-9]/g, "")
+    .slice(0, 6)
+    .toUpperCase() || "TASK";
+}
+
+export function formatTaskKey(prefix: string | null | undefined, number: number) {
+  if (!prefix) return `#${number}`;
+  return `${prefix}-${number}`;
+}
+
+/** Matches PY-12 and GitHub-style #PY-12 (case-insensitive). */
+const TASK_KEY_REGEX = /#?([A-Za-z][A-Za-z0-9]*)-(\d+)\b/gi;
+
+export function extractTaskKeysFromText(text: string): { prefix: string; number: number }[] {
+  const results: { prefix: string; number: number }[] = [];
+  const seen = new Set<string>();
+  for (const match of text.matchAll(TASK_KEY_REGEX)) {
+    const prefix = match[1].toUpperCase();
+    const number = parseInt(match[2], 10);
+    const key = `${prefix}-${number}`;
+    if (!seen.has(key)) {
+      seen.add(key);
+      results.push({ prefix, number });
+    }
+  }
+  return results;
+}
+
+export function slugifyForBranch(text: string, maxLength = 40): string {
+  return text
+    .toLowerCase()
+    .normalize("NFKD")
+    .replace(/[\u0300-\u036f]/g, "")
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^-+|-+$/g, "")
+    .slice(0, maxLength)
+    .replace(/-+$/, "");
+}
+
+export function defaultBranchName(
+  taskKey: string,
+  taskName: string
+): string {
+  const slug = slugifyForBranch(taskName);
+  const base = taskKey.toLowerCase();
+  return slug ? `${base}-${slug}` : base;
+}
+
+/** True when a git branch ref belongs to this task key (e.g. py-42 or py-42-fix-login). */
+export function branchMatchesTaskKey(branchRef: string, taskKey: string): boolean {
+  const prefix = taskKey.toLowerCase();
+  const ref = branchRef.toLowerCase();
+  return ref === prefix || ref.startsWith(`${prefix}-`);
+}
diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts
new file mode 100644
index 0000000..7dfe4d4
--- /dev/null
+++ b/src/lib/git/types.ts
@@ -0,0 +1,331 @@
+import type { GitConnection, GitProviderType } from "~/db/schema";
+
+export type GitRepo = {
+  externalId: string;
+  fullName: string;
+  defaultBranch: string;
+  htmlUrl: string;
+  isArchived: boolean;
+};
+
+export type GitBranch = {
+  ref: string;
+  sha: string;
+  htmlUrl: string;
+};
+
+export type GitPullRequest = {
+  providerPrId: string;
+  number: number;
+  url: string;
+  title: string;
+  body: string | null;
+  state: "open" | "closed" | "merged" | "draft";
+  headRef: string;
+  baseRef: string;
+  headSha: string;
+  mergedAt: Date | null;
+  closedAt: Date | null;
+};
+
+export type GitCommit = {
+  sha: string;
+  message: string;
+  authorLogin: string | null;
+  committedAt: Date;
+  htmlUrl: string;
+};
+
+export type GitCompareResult = {
+  commits: GitCommit[];
+  files: GitDiffFile[];
+  baseRef: string;
+  headRef: string;
+};
+
+export type GitReviewComment = {
+  id: number;
+  body: string;
+  path: string;
+  line: number | null;
+  originalLine: number | null;
+  side: "LEFT" | "RIGHT" | null;
+  commitId: string;
+  authorLogin: string;
+  authorAvatarUrl: string | null;
+  authorHtmlUrl: string | null;
+  createdAt: Date;
+  inReplyToId: number | null;
+  reviewId: number | null;
+  url: string;
+  /** GraphQL node id of the review thread (for resolve / unresolve). */
+  threadNodeId?: string;
+  threadIsResolved?: boolean;
+};
+
+export type GitReviewThread = {
+  nodeId: string;
+  isResolved: boolean;
+  path: string;
+  line: number | null;
+  originalLine: number | null;
+  side: "LEFT" | "RIGHT" | null;
+  commentIds: number[];
+};
+
+/** General PR conversation comment (timeline), not tied to a diff line. */
+export type GitPullRequestIssueComment = {
+  id: number;
+  body: string;
+  authorLogin: string;
+  authorAvatarUrl: string | null;
+  authorHtmlUrl: string | null;
+  isBot: boolean;
+  createdAt: Date;
+  updatedAt: Date | null;
+  url: string;
+};
+
+export type GitCheckConclusion =
+  | "success"
+  | "failure"
+  | "neutral"
+  | "cancelled"
+  | "skipped"
+  | "timed_out"
+  | "action_required"
+  | "stale"
+  | null;
+
+export type GitCheckStatus =
+  | "queued"
+  | "in_progress"
+  | "completed"
+  | "waiting"
+  | "pending"
+  | "requested";
+
+export type GitPullRequestCheck = {
+  id: string;
+  name: string;
+  status: GitCheckStatus;
+  conclusion: GitCheckConclusion;
+  htmlUrl: string;
+  description: string | null;
+  source: "check_run" | "status";
+  /** GitHub App slug when from a check run. */
+  appSlug: string | null;
+  /** App or status creator avatar from GitHub. */
+  avatarUrl: string | null;
+};
+
+export type GitPullRequestMergeStatus = {
+  state: GitPullRequest["state"];
+  mergeable: boolean | null;
+  mergeableState: string;
+  checks: GitPullRequestCheck[];
+};
+
+export type GitPullRequestReview = {
+  id: number;
+  body: string | null;
+  state: "COMMENTED" | "APPROVED" | "CHANGES_REQUESTED" | "DISMISSED" | "PENDING";
+  commitId: string | null;
+  authorLogin: string;
+  authorAvatarUrl: string | null;
+  authorHtmlUrl: string | null;
+  submittedAt: Date | null;
+  url: string | null;
+};
+
+export type CreateReviewCommentInput = {
+  body: string;
+  commitId: string;
+  path: string;
+  line: number;
+  side?: "LEFT" | "RIGHT";
+  inReplyTo?: number;
+  reviewId: number;
+};
+
+export type SubmitPullRequestReviewInput = {
+  reviewId?: number;
+  commitId: string;
+  body?: string;
+  event: "COMMENT" | "APPROVE" | "REQUEST_CHANGES";
+};
+
+export type GitDiffFile = {
+  path: string;
+  oldPath?: string;
+  status: "added" | "removed" | "modified" | "renamed";
+  patch?: string;
+  oldContent?: string;
+  newContent?: string;
+};
+
+export type GitWebhookEvent = {
+  provider: GitProviderType;
+  eventType: string;
+  deliveryId: string;
+  payload: unknown;
+};
+
+export interface GitProvider {
+  listAccessibleRepos(connection: GitConnection): Promise<GitRepo[]>;
+  syncRepositories(connection: GitConnection): Promise<GitRepo[]>;
+  createBranch(
+    connection: GitConnection,
+    repo: GitRepo,
+    options: { base: string; name: string; userAccessToken?: string }
+  ): Promise<GitBranch>;
+  getPullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitPullRequest>;
+  listPullRequestsForRef(
+    connection: GitConnection,
+    repo: GitRepo,
+    headRef: string
+  ): Promise<GitPullRequest[]>;
+  createPullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    options: {
+      title: string;
+      body: string;
+      head: string;
+      base: string;
+      userAccessToken?: string;
+    }
+  ): Promise<GitPullRequest>;
+  getPullRequestDiff(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitDiffFile[]>;
+  compareBranches(
+    connection: GitConnection,
+    repo: GitRepo,
+    base: string,
+    head: string
+  ): Promise<GitCompareResult>;
+  listPullRequestCommits(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitCommit[]>;
+  getCommitDiff(
+    connection: GitConnection,
+    repo: GitRepo,
+    sha: string
+  ): Promise<GitDiffFile[]>;
+  listPullRequestIssueComments(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitPullRequestIssueComment[]>;
+  listPullRequestReviewComments(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitReviewComment[]>;
+  /** Includes pending review comments visible only to the author until submit. */
+  listPullRequestReviewCommentsAsUser(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string
+  ): Promise<GitReviewComment[]>;
+  listPullRequestReviewCommentsForReviewAsUser(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    reviewId: number,
+    userAccessToken: string
+  ): Promise<GitReviewComment[]>;
+  /** Line-accurate thread comments (GraphQL); best source for pending review placement. */
+  listPullRequestReviewThreadCommentsAsUser(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string
+  ): Promise<GitReviewComment[]>;
+  listPullRequestReviewThreads(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitReviewThread[]>;
+  setReviewThreadResolved(
+    threadNodeId: string,
+    resolved: boolean,
+    userAccessToken: string
+  ): Promise<void>;
+  listPullRequestReviews(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number
+  ): Promise<GitPullRequestReview[]>;
+  findPendingReviewForUser(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    authorLogin: string,
+    userAccessToken: string,
+    options?: { retries?: number }
+  ): Promise<GitPullRequestReview | null>;
+  startPendingReview(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    commitId: string,
+    userAccessToken: string,
+    authorLogin: string
+  ): Promise<GitPullRequestReview>;
+  createPullRequestReviewComment(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    input: CreateReviewCommentInput,
+    userAccessToken: string
+  ): Promise<GitReviewComment>;
+  submitPullRequestReview(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    input: SubmitPullRequestReviewInput,
+    userAccessToken: string
+  ): Promise<void>;
+  discardPendingReview(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    reviewId: number,
+    userAccessToken: string
+  ): Promise<void>;
+  getPullRequestMergeStatus(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    options?: { userAccessToken?: string }
+  ): Promise<GitPullRequestMergeStatus>;
+  mergePullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string,
+    options?: { mergeMethod?: "merge" | "squash" | "rebase" }
+  ): Promise<GitPullRequest>;
+  closePullRequest(
+    connection: GitConnection,
+    repo: GitRepo,
+    prNumber: number,
+    userAccessToken: string
+  ): Promise<GitPullRequest>;
+  parsePullRequestUrl(url: string): {
+    owner: string;
+    repo: string;
+    number: number;
+  } | null;
+}
diff --git a/src/lib/git/upsert-task-pull-request.ts b/src/lib/git/upsert-task-pull-request.ts
new file mode 100644
index 0000000..19c8e1c
--- /dev/null
+++ b/src/lib/git/upsert-task-pull-request.ts
@@ -0,0 +1,77 @@
+import "@tanstack/react-start/server-only";
+
+import { and, eq } from "drizzle-orm";
+import { v7 as uuid } from "uuid";
+import { db } from "~/db";
+import { taskGitPullRequests, type GitPrState } from "~/db/schema";
+
+export type UpsertTaskPullRequestInput = {
+  owner: string;
+  taskId: string;
+  repositoryId: string;
+  branchId?: string | null;
+  providerPrId: string;
+  number: number;
+  url: string;
+  title: string;
+  state: GitPrState;
+  headRef: string;
+  baseRef: string;
+  mergedAt: Date | null;
+  closedAt: Date | null;
+};
+
+/** Insert or update by (repositoryId, number) — matches webhook + manual link behavior. */
+export async function upsertTaskPullRequest(
+  input: UpsertTaskPullRequestInput
+): Promise<string> {
+  const existing = await db.query.taskGitPullRequests.findFirst({
+    where: and(
+      eq(taskGitPullRequests.repositoryId, input.repositoryId),
+      eq(taskGitPullRequests.number, input.number)
+    ),
+  });
+
+  const now = new Date();
+
+  if (existing) {
+    await db
+      .update(taskGitPullRequests)
+      .set({
+        taskId: input.taskId,
+        branchId: input.branchId ?? existing.branchId,
+        owner: input.owner,
+        providerPrId: input.providerPrId,
+        url: input.url,
+        title: input.title,
+        state: input.state,
+        headRef: input.headRef,
+        baseRef: input.baseRef,
+        mergedAt: input.mergedAt,
+        closedAt: input.closedAt,
+        updatedAt: now,
+      })
+      .where(eq(taskGitPullRequests.id, existing.id));
+    return existing.id;
+  }
+
+  const id = uuid();
+  await db.insert(taskGitPullRequests).values({
+    id,
+    taskId: input.taskId,
+    repositoryId: input.repositoryId,
+    branchId: input.branchId ?? null,
+    owner: input.owner,
+    number: input.number,
+    providerPrId: input.providerPrId,
+    url: input.url,
+    title: input.title,
+    state: input.state,
+    headRef: input.headRef,
+    baseRef: input.baseRef,
+    mergedAt: input.mergedAt,
+    closedAt: input.closedAt,
+    updatedAt: now,
+  });
+  return id;
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 1802a89..4170b78 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -20,12 +20,15 @@ import { Route as SignedInTasksRouteImport } from './routes/_signed-in/tasks'
 import { Route as SignedInStatusesRouteImport } from './routes/_signed-in/statuses'
 import { Route as SignedInLabelsRouteImport } from './routes/_signed-in/labels'
 import { Route as SignedInDashboardRouteImport } from './routes/_signed-in/dashboard'
+import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github'
 import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
 import { Route as SignedInTasksTaskIdRouteImport } from './routes/_signed-in/tasks.$taskId'
 import { Route as SignedInSprintsNewRouteImport } from './routes/_signed-in/sprints.new'
 import { Route as SignedInSettingsOrganizationRouteImport } from './routes/_signed-in/settings.organization'
+import { Route as SignedInSettingsIntegrationsRouteImport } from './routes/_signed-in/settings.integrations'
 import { Route as SignedInSettingsAccountRouteImport } from './routes/_signed-in/settings.account'
 import { Route as SignedInProjectsNewRouteImport } from './routes/_signed-in/projects.new'
+import { Route as ApiGitGithubCallbackRouteImport } from './routes/api/git/github/callback'
 import { Route as SignedInSprintsSprintIdTasksRouteImport } from './routes/_signed-in/sprints.$sprintId.tasks'
 import { Route as SignedInSprintsSprintIdSettingsRouteImport } from './routes/_signed-in/sprints.$sprintId.settings'
 import { Route as SignedInSettingsOrganizationNewRouteImport } from './routes/_signed-in/settings.organization.new'
@@ -88,6 +91,11 @@ const SignedInDashboardRoute = SignedInDashboardRouteImport.update({
   path: '/dashboard',
   getParentRoute: () => SignedInRoute,
 } as any)
+const ApiWebhooksGithubRoute = ApiWebhooksGithubRouteImport.update({
+  id: '/api/webhooks/github',
+  path: '/api/webhooks/github',
+  getParentRoute: () => rootRouteImport,
+} as any)
 const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
   id: '/api/auth/$',
   path: '/api/auth/$',
@@ -109,6 +117,12 @@ const SignedInSettingsOrganizationRoute =
     path: '/settings/organization',
     getParentRoute: () => SignedInRoute,
   } as any)
+const SignedInSettingsIntegrationsRoute =
+  SignedInSettingsIntegrationsRouteImport.update({
+    id: '/settings/integrations',
+    path: '/settings/integrations',
+    getParentRoute: () => SignedInRoute,
+  } as any)
 const SignedInSettingsAccountRoute = SignedInSettingsAccountRouteImport.update({
   id: '/settings/account',
   path: '/settings/account',
@@ -119,6 +133,11 @@ const SignedInProjectsNewRoute = SignedInProjectsNewRouteImport.update({
   path: '/projects/new',
   getParentRoute: () => SignedInRoute,
 } as any)
+const ApiGitGithubCallbackRoute = ApiGitGithubCallbackRouteImport.update({
+  id: '/api/git/github/callback',
+  path: '/api/git/github/callback',
+  getParentRoute: () => rootRouteImport,
+} as any)
 const SignedInSprintsSprintIdTasksRoute =
   SignedInSprintsSprintIdTasksRouteImport.update({
     id: '/sprints/$sprintId/tasks',
@@ -175,15 +194,18 @@ export interface FileRoutesByFullPath {
   '/sign-in/$': typeof SignInSplatRoute
   '/projects/new': typeof SignedInProjectsNewRoute
   '/settings/account': typeof SignedInSettingsAccountRoute
+  '/settings/integrations': typeof SignedInSettingsIntegrationsRoute
   '/settings/organization': typeof SignedInSettingsOrganizationRouteWithChildren
   '/sprints/new': typeof SignedInSprintsNewRoute
   '/tasks/$taskId': typeof SignedInTasksTaskIdRoute
   '/api/auth/$': typeof ApiAuthSplatRoute
+  '/api/webhooks/github': typeof ApiWebhooksGithubRoute
   '/projects/$projectId/settings': typeof SignedInProjectsProjectIdSettingsRoute
   '/projects/$projectId/tasks': typeof SignedInProjectsProjectIdTasksRouteWithChildren
   '/settings/organization/new': typeof SignedInSettingsOrganizationNewRoute
   '/sprints/$sprintId/settings': typeof SignedInSprintsSprintIdSettingsRoute
   '/sprints/$sprintId/tasks': typeof SignedInSprintsSprintIdTasksRouteWithChildren
+  '/api/git/github/callback': typeof ApiGitGithubCallbackRoute
   '/projects/$projectId/tasks/$taskId': typeof SignedInProjectsProjectIdTasksTaskIdRoute
   '/sprints/$sprintId/tasks/$taskId': typeof SignedInSprintsSprintIdTasksTaskIdRoute
 }
@@ -200,15 +222,18 @@ export interface FileRoutesByTo {
   '/sign-in/$': typeof SignInSplatRoute
   '/projects/new': typeof SignedInProjectsNewRoute
   '/settings/account': typeof SignedInSettingsAccountRoute
+  '/settings/integrations': typeof SignedInSettingsIntegrationsRoute
   '/settings/organization': typeof SignedInSettingsOrganizationRouteWithChildren
   '/sprints/new': typeof SignedInSprintsNewRoute
   '/tasks/$taskId': typeof SignedInTasksTaskIdRoute
   '/api/auth/$': typeof ApiAuthSplatRoute
+  '/api/webhooks/github': typeof ApiWebhooksGithubRoute
   '/projects/$projectId/settings': typeof SignedInProjectsProjectIdSettingsRoute
   '/projects/$projectId/tasks': typeof SignedInProjectsProjectIdTasksRouteWithChildren
   '/settings/organization/new': typeof SignedInSettingsOrganizationNewRoute
   '/sprints/$sprintId/settings': typeof SignedInSprintsSprintIdSettingsRoute
   '/sprints/$sprintId/tasks': typeof SignedInSprintsSprintIdTasksRouteWithChildren
+  '/api/git/github/callback': typeof ApiGitGithubCallbackRoute
   '/projects/$projectId/tasks/$taskId': typeof SignedInProjectsProjectIdTasksTaskIdRoute
   '/sprints/$sprintId/tasks/$taskId': typeof SignedInSprintsSprintIdTasksTaskIdRoute
 }
@@ -227,15 +252,18 @@ export interface FileRoutesById {
   '/sign-in/$': typeof SignInSplatRoute
   '/_signed-in/projects/new': typeof SignedInProjectsNewRoute
   '/_signed-in/settings/account': typeof SignedInSettingsAccountRoute
+  '/_signed-in/settings/integrations': typeof SignedInSettingsIntegrationsRoute
   '/_signed-in/settings/organization': typeof SignedInSettingsOrganizationRouteWithChildren
   '/_signed-in/sprints/new': typeof SignedInSprintsNewRoute
   '/_signed-in/tasks/$taskId': typeof SignedInTasksTaskIdRoute
   '/api/auth/$': typeof ApiAuthSplatRoute
+  '/api/webhooks/github': typeof ApiWebhooksGithubRoute
   '/_signed-in/projects/$projectId/settings': typeof SignedInProjectsProjectIdSettingsRoute
   '/_signed-in/projects/$projectId/tasks': typeof SignedInProjectsProjectIdTasksRouteWithChildren
   '/_signed-in/settings/organization/new': typeof SignedInSettingsOrganizationNewRoute
   '/_signed-in/sprints/$sprintId/settings': typeof SignedInSprintsSprintIdSettingsRoute
   '/_signed-in/sprints/$sprintId/tasks': typeof SignedInSprintsSprintIdTasksRouteWithChildren
+  '/api/git/github/callback': typeof ApiGitGithubCallbackRoute
   '/_signed-in/projects/$projectId/tasks/$taskId': typeof SignedInProjectsProjectIdTasksTaskIdRoute
   '/_signed-in/sprints/$sprintId/tasks/$taskId': typeof SignedInSprintsSprintIdTasksTaskIdRoute
 }
@@ -254,15 +282,18 @@ export interface FileRouteTypes {
     | '/sign-in/$'
     | '/projects/new'
     | '/settings/account'
+    | '/settings/integrations'
     | '/settings/organization'
     | '/sprints/new'
     | '/tasks/$taskId'
     | '/api/auth/$'
+    | '/api/webhooks/github'
     | '/projects/$projectId/settings'
     | '/projects/$projectId/tasks'
     | '/settings/organization/new'
     | '/sprints/$sprintId/settings'
     | '/sprints/$sprintId/tasks'
+    | '/api/git/github/callback'
     | '/projects/$projectId/tasks/$taskId'
     | '/sprints/$sprintId/tasks/$taskId'
   fileRoutesByTo: FileRoutesByTo
@@ -279,15 +310,18 @@ export interface FileRouteTypes {
     | '/sign-in/$'
     | '/projects/new'
     | '/settings/account'
+    | '/settings/integrations'
     | '/settings/organization'
     | '/sprints/new'
     | '/tasks/$taskId'
     | '/api/auth/$'
+    | '/api/webhooks/github'
     | '/projects/$projectId/settings'
     | '/projects/$projectId/tasks'
     | '/settings/organization/new'
     | '/sprints/$sprintId/settings'
     | '/sprints/$sprintId/tasks'
+    | '/api/git/github/callback'
     | '/projects/$projectId/tasks/$taskId'
     | '/sprints/$sprintId/tasks/$taskId'
   id:
@@ -305,15 +339,18 @@ export interface FileRouteTypes {
     | '/sign-in/$'
     | '/_signed-in/projects/new'
     | '/_signed-in/settings/account'
+    | '/_signed-in/settings/integrations'
     | '/_signed-in/settings/organization'
     | '/_signed-in/sprints/new'
     | '/_signed-in/tasks/$taskId'
     | '/api/auth/$'
+    | '/api/webhooks/github'
     | '/_signed-in/projects/$projectId/settings'
     | '/_signed-in/projects/$projectId/tasks'
     | '/_signed-in/settings/organization/new'
     | '/_signed-in/sprints/$sprintId/settings'
     | '/_signed-in/sprints/$sprintId/tasks'
+    | '/api/git/github/callback'
     | '/_signed-in/projects/$projectId/tasks/$taskId'
     | '/_signed-in/sprints/$sprintId/tasks/$taskId'
   fileRoutesById: FileRoutesById
@@ -327,6 +364,8 @@ export interface RootRouteChildren {
   ApiUploadthingRoute: typeof ApiUploadthingRoute
   SignInSplatRoute: typeof SignInSplatRoute
   ApiAuthSplatRoute: typeof ApiAuthSplatRoute
+  ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute
+  ApiGitGithubCallbackRoute: typeof ApiGitGithubCallbackRoute
 }
 
 declare module '@tanstack/react-router' {
@@ -408,6 +447,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof SignedInDashboardRouteImport
       parentRoute: typeof SignedInRoute
     }
+    '/api/webhooks/github': {
+      id: '/api/webhooks/github'
+      path: '/api/webhooks/github'
+      fullPath: '/api/webhooks/github'
+      preLoaderRoute: typeof ApiWebhooksGithubRouteImport
+      parentRoute: typeof rootRouteImport
+    }
     '/api/auth/$': {
       id: '/api/auth/$'
       path: '/api/auth/$'
@@ -436,6 +482,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof SignedInSettingsOrganizationRouteImport
       parentRoute: typeof SignedInRoute
     }
+    '/_signed-in/settings/integrations': {
+      id: '/_signed-in/settings/integrations'
+      path: '/settings/integrations'
+      fullPath: '/settings/integrations'
+      preLoaderRoute: typeof SignedInSettingsIntegrationsRouteImport
+      parentRoute: typeof SignedInRoute
+    }
     '/_signed-in/settings/account': {
       id: '/_signed-in/settings/account'
       path: '/settings/account'
@@ -450,6 +503,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof SignedInProjectsNewRouteImport
       parentRoute: typeof SignedInRoute
     }
+    '/api/git/github/callback': {
+      id: '/api/git/github/callback'
+      path: '/api/git/github/callback'
+      fullPath: '/api/git/github/callback'
+      preLoaderRoute: typeof ApiGitGithubCallbackRouteImport
+      parentRoute: typeof rootRouteImport
+    }
     '/_signed-in/sprints/$sprintId/tasks': {
       id: '/_signed-in/sprints/$sprintId/tasks'
       path: '/sprints/$sprintId/tasks'
@@ -565,6 +625,7 @@ interface SignedInRouteChildren {
   SignedInTasksRoute: typeof SignedInTasksRouteWithChildren
   SignedInProjectsNewRoute: typeof SignedInProjectsNewRoute
   SignedInSettingsAccountRoute: typeof SignedInSettingsAccountRoute
+  SignedInSettingsIntegrationsRoute: typeof SignedInSettingsIntegrationsRoute
   SignedInSettingsOrganizationRoute: typeof SignedInSettingsOrganizationRouteWithChildren
   SignedInSprintsNewRoute: typeof SignedInSprintsNewRoute
   SignedInProjectsProjectIdSettingsRoute: typeof SignedInProjectsProjectIdSettingsRoute
@@ -580,6 +641,7 @@ const SignedInRouteChildren: SignedInRouteChildren = {
   SignedInTasksRoute: SignedInTasksRouteWithChildren,
   SignedInProjectsNewRoute: SignedInProjectsNewRoute,
   SignedInSettingsAccountRoute: SignedInSettingsAccountRoute,
+  SignedInSettingsIntegrationsRoute: SignedInSettingsIntegrationsRoute,
   SignedInSettingsOrganizationRoute:
     SignedInSettingsOrganizationRouteWithChildren,
   SignedInSprintsNewRoute: SignedInSprintsNewRoute,
@@ -605,6 +667,8 @@ const rootRouteChildren: RootRouteChildren = {
   ApiUploadthingRoute: ApiUploadthingRoute,
   SignInSplatRoute: SignInSplatRoute,
   ApiAuthSplatRoute: ApiAuthSplatRoute,
+  ApiWebhooksGithubRoute: ApiWebhooksGithubRoute,
+  ApiGitGithubCallbackRoute: ApiGitGithubCallbackRoute,
 }
 export const routeTree = rootRouteImport
   ._addFileChildren(rootRouteChildren)
diff --git a/src/routes/_signed-in/settings.integrations.tsx b/src/routes/_signed-in/settings.integrations.tsx
new file mode 100644
index 0000000..fbdb2de
--- /dev/null
+++ b/src/routes/_signed-in/settings.integrations.tsx
@@ -0,0 +1,269 @@
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import { Plus, Trash2 } from "lucide-react";
+import { PageLayout } from "~/components/PageLayout";
+import { PageSection, PageSectionContent } from "~/components/PageSection";
+import { Button } from "~/components/ui/button";
+import { Badge } from "~/components/ui/badge";
+import {
+  gitConnectionQueryOptions,
+  gitStatusRulesQueryOptions,
+} from "~/db/queries/git";
+import { fetchGitHubInstallUrls } from "~/db/queries/git-install";
+import {
+  useCreateGitStatusRuleMutation,
+  useDeleteGitStatusRuleMutation,
+  useDisconnectGitHubMutation,
+  useSyncGitRepositoriesMutation,
+} from "~/db/mutations/git";
+import { useStatusesQuery } from "~/db/queries/statuses";
+import { Input } from "~/components/ui/input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "~/components/ui/select";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { z } from "zod";
+import {
+  COMMIT_STATUS_RULE_CONVENTION,
+  DEFAULT_STATUS_RULE_PATTERNS,
+} from "~/lib/git/status-rules";
+
+const integrationsSearchSchema = z.object({
+  installed: z.enum(["1", "error"]).optional().catch(undefined),
+  user: z.enum(["connected", "error"]).optional().catch(undefined),
+  error: z.string().optional().catch(undefined),
+});
+
+export const Route = createFileRoute("/_signed-in/settings/integrations")({
+  validateSearch: integrationsSearchSchema,
+  head: () => ({
+    meta: [{ title: "Integrations" }],
+  }),
+  loader: async ({ context }) => {
+    await context.queryClient.ensureQueryData(gitConnectionQueryOptions());
+    await context.queryClient.ensureQueryData(gitStatusRulesQueryOptions());
+  },
+  component: IntegrationsPage,
+});
+
+function IntegrationsPage() {
+  const search = Route.useSearch();
+  const queryClient = useQueryClient();
+  const { data: connectionData } = useSuspenseQuery(gitConnectionQueryOptions());
+  const { data: rules } = useSuspenseQuery(gitStatusRulesQueryOptions());
+  const { data: installUrls } = useSuspenseQuery({
+    queryKey: ["git", "install-urls"],
+    queryFn: () => fetchGitHubInstallUrls(),
+  });
+  const statusesQuery = useStatusesQuery();
+  const syncRepos = useSyncGitRepositoriesMutation();
+  const disconnect = useDisconnectGitHubMutation();
+  const createRule = useCreateGitStatusRuleMutation();
+  const deleteRule = useDeleteGitStatusRuleMutation();
+
+  const [newPattern, setNewPattern] = useState<string>(
+    DEFAULT_STATUS_RULE_PATTERNS.close
+  );
+  const [newStatusId, setNewStatusId] = useState("");
+
+  const connection = connectionData.connection;
+  const userLink = connectionData.userLink;
+
+  useEffect(() => {
+    if (search.installed === "1") {
+      toast.success("GitHub App connected");
+      void queryClient.invalidateQueries({ queryKey: ["git"] });
+    } else if (search.installed === "error") {
+      toast.error("GitHub installation failed. Try Install again from this page.");
+    }
+    if (search.user === "connected") {
+      toast.success("GitHub account linked");
+      void queryClient.invalidateQueries({ queryKey: ["git"] });
+    } else if (search.user === "error") {
+      toast.error("GitHub account link failed");
+    }
+    if (search.error) {
+      toast.error(`GitHub: ${search.error}`);
+    }
+  }, [
+    search.installed,
+    search.user,
+    search.error,
+    queryClient,
+  ]);
+
+  return (
+    <PageLayout title="Integrations">
+      <p className="text-muted-foreground mb-6 text-sm">
+        Connect Git hosting and automate workflows.
+      </p>
+      <PageSection title="GitHub">
+        <PageSectionContent>
+        {!installUrls.configured ? (
+          <p className="text-muted-foreground text-sm">
+            GitHub App is not configured. Set GITHUB_APP_* and GIT_TOKEN_ENCRYPTION_KEY
+            environment variables.
+          </p>
+        ) : connection ? (
+          <div className="space-y-4">
+            <div className="flex flex-wrap items-center gap-2">
+              <Badge variant="secondary">Connected</Badge>
+              <span className="text-sm">
+                {connection.accountLogin} ({connection.accountType})
+              </span>
+            </div>
+            <div className="flex flex-wrap gap-2">
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                onClick={() => syncRepos().then(() => toast.success("Repositories synced"))}
+              >
+                Sync repositories
+              </Button>
+              <Button
+                type="button"
+                variant="destructive"
+                size="sm"
+                onClick={() =>
+                  disconnect().then(() => toast.success("GitHub disconnected"))
+                }
+              >
+                Disconnect
+              </Button>
+            </div>
+          </div>
+        ) : (
+          <div className="space-y-3">
+            <p className="text-muted-foreground text-sm">
+              Install the GitHub App on your organization or account to link repositories,
+              branches, and pull requests to tasks.
+            </p>
+            <Button asChild>
+              <a href={installUrls.installUrl}>Install GitHub App</a>
+            </Button>
+          </div>
+        )}
+
+        {installUrls.configured && (
+          <div className="mt-6 space-y-2 border-t pt-4">
+            <h4 className="text-sm font-medium">Personal GitHub (optional)</h4>
+            <p className="text-muted-foreground text-sm">
+              Connect your GitHub account to create branches and PRs when the app lacks
+              permission.
+            </p>
+            {!installUrls.userOAuthConfigured ? (
+              <p className="text-muted-foreground text-sm">
+                Set GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET, and add your
+                callback URL in the GitHub App settings.
+              </p>
+            ) : userLink ? (
+              <Badge variant="outline">Connected as @{userLink.providerLogin}</Badge>
+            ) : (
+              <Button asChild variant="outline" size="sm">
+                <a href={installUrls.userOAuthUrl!}>Connect GitHub account</a>
+              </Button>
+            )}
+          </div>
+        )}
+        </PageSectionContent>
+      </PageSection>
+
+      {connection && (
+        <PageSection title="Commit status rules">
+          <PageSectionContent>
+          <p className="text-muted-foreground mb-4 text-sm">
+            Reference tasks as{" "}
+            <code className="text-xs">{COMMIT_STATUS_RULE_CONVENTION.taskReference}</code>{" "}
+            in commit messages (same style as GitHub issue links). Use{" "}
+            <code className="text-xs">{COMMIT_STATUS_RULE_CONVENTION.placeholder}</code> in
+            patterns; it becomes the project key (e.g. PY-42). Optional hash:{" "}
+            <code className="text-xs">#?KEY</code> matches{" "}
+            <code className="text-xs">{COMMIT_STATUS_RULE_CONVENTION.closeExample}</code> and{" "}
+            <code className="text-xs">fixes PY-42</code>.
+          </p>
+          <p className="text-muted-foreground mb-4 text-sm">
+            Default close pattern:{" "}
+            <code className="text-xs">{DEFAULT_STATUS_RULE_PATTERNS.close}</code>
+          </p>
+          <ul className="mb-4 space-y-2">
+            {rules.map((rule) => {
+              const status = statusesQuery.data?.find(
+                (s) => s.id === rule.targetStatusId
+              );
+              return (
+                <li
+                  key={rule.id}
+                  className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
+                >
+                  <span>
+                    <code className="text-xs">{rule.pattern}</code>
+                    {" → "}
+                    {status?.name ?? rule.targetStatusId}
+                  </span>
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="icon"
+                    onClick={() => deleteRule(rule.id)}
+                  >
+                    <Trash2 className="size-4" />
+                  </Button>
+                </li>
+              );
+            })}
+          </ul>
+          <div className="flex flex-wrap items-end gap-2">
+            <div className="min-w-[200px] flex-1">
+              <Input
+                value={newPattern}
+                onChange={(e) => setNewPattern(e.target.value)}
+                placeholder="Pattern with KEY"
+              />
+            </div>
+            <Select value={newStatusId} onValueChange={setNewStatusId}>
+              <SelectTrigger className="w-[180px]">
+                <SelectValue placeholder="Target status" />
+              </SelectTrigger>
+              <SelectContent>
+                {statusesQuery.data?.map((s) => (
+                  <SelectItem key={s.id} value={s.id}>
+                    {s.name}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+            <Button
+              type="button"
+              size="sm"
+              disabled={!newStatusId}
+              onClick={() =>
+                createRule({
+                  pattern: newPattern,
+                  targetStatusId: newStatusId,
+                  priority: 0,
+                }).then(() => toast.success("Rule added"))
+              }
+            >
+              <Plus className="size-4" />
+              Add rule
+            </Button>
+          </div>
+          </PageSectionContent>
+        </PageSection>
+      )}
+
+      <p className="text-muted-foreground text-sm">
+        <Link to="/settings/organization" className="underline">
+          Organization settings
+        </Link>{" "}
+        · GitLab support coming soon
+      </p>
+    </PageLayout>
+  );
+}
diff --git a/src/routes/api/git/github/callback.ts b/src/routes/api/git/github/callback.ts
new file mode 100644
index 0000000..795ff1e
--- /dev/null
+++ b/src/routes/api/git/github/callback.ts
@@ -0,0 +1,105 @@
+import { createFileRoute } from "@tanstack/react-router";
+import {
+  runCompleteGitHubInstallation,
+  runExchangeGitHubOAuthCode,
+  runSaveGitHubUserLink,
+} from "~/db/mutations/git-callback.server";
+import { getInstallationAccount } from "~/lib/git/github/installation-octokit";
+import { isGitHubConfigured } from "~/env";
+import { auth } from "~/lib/auth";
+
+const redirectBase = "/settings/integrations";
+
+function redirectToIntegrations(
+  origin: string,
+  params?: Record<string, string>
+) {
+  const target = new URL(redirectBase, origin);
+  if (params) {
+    for (const [key, value] of Object.entries(params)) {
+      target.searchParams.set(key, value);
+    }
+  }
+  return Response.redirect(target.toString(), 302);
+}
+
+export const Route = createFileRoute("/api/git/github/callback")({
+  server: {
+    handlers: {
+      GET: async ({ request }) => {
+        try {
+          if (!isGitHubConfigured()) {
+            return new Response("GitHub integration is not configured", {
+              status: 503,
+            });
+          }
+
+          const url = new URL(request.url);
+          const installationId = url.searchParams.get("installation_id");
+          const setupAction = url.searchParams.get("setup_action");
+          const code = url.searchParams.get("code");
+          const state = url.searchParams.get("state");
+          const error = url.searchParams.get("error");
+
+          if (error) {
+            return redirectToIntegrations(url.origin, { error });
+          }
+
+          const session = await auth.api.getSession({
+            headers: request.headers,
+          });
+
+          const returnPath = `${url.pathname}${url.search}`;
+
+          if (!session) {
+            const signInUrl = new URL("/sign-in", url.origin);
+            signInUrl.searchParams.set("redirectTo", returnPath);
+            return Response.redirect(signInUrl.toString(), 302);
+          }
+
+          const shouldCompleteInstallation =
+            installationId &&
+            (!setupAction ||
+              setupAction === "install" ||
+              setupAction === "update");
+
+          if (code && state === "user-oauth") {
+            try {
+              const tokenData = await runExchangeGitHubOAuthCode(code);
+              await runSaveGitHubUserLink(session.user.id, tokenData);
+              return redirectToIntegrations(url.origin, { user: "connected" });
+            } catch (e) {
+              console.error("GitHub user OAuth failed", e);
+              return redirectToIntegrations(url.origin, { user: "error" });
+            }
+          }
+
+          if (shouldCompleteInstallation) {
+            try {
+              const { accountLogin, accountType } = await getInstallationAccount(
+                Number(installationId)
+              );
+
+              await runCompleteGitHubInstallation(session, {
+                installationId,
+                accountLogin,
+                accountType,
+              });
+
+              return redirectToIntegrations(url.origin, { installed: "1" });
+            } catch (e) {
+              console.error("GitHub installation callback failed", e);
+              return redirectToIntegrations(url.origin, { installed: "error" });
+            }
+          }
+
+          return redirectToIntegrations(url.origin);
+        } catch (e) {
+          console.error("GitHub callback failed", e);
+          const url = new URL(request.url);
+          return redirectToIntegrations(url.origin, { user: "error" });
+        }
+      },
+    },
+  },
+});
diff --git a/src/routes/api/webhooks/github.ts b/src/routes/api/webhooks/github.ts
new file mode 100644
index 0000000..d114057
--- /dev/null
+++ b/src/routes/api/webhooks/github.ts
@@ -0,0 +1,43 @@
+import { createFileRoute } from "@tanstack/react-router";
+import {
+  processGitHubWebhook,
+  verifyGitHubWebhookSignature,
+} from "~/lib/git/github/webhooks";
+
+export const Route = createFileRoute("/api/webhooks/github")({
+  server: {
+    handlers: {
+      POST: async ({ request }) => {
+        const deliveryId = request.headers.get("X-GitHub-Delivery");
+        const eventType = request.headers.get("X-GitHub-Event");
+        const signature = request.headers.get("X-Hub-Signature-256");
+
+        if (!deliveryId || !eventType) {
+          return new Response("Missing GitHub headers", { status: 400 });
+        }
+
+        const payload = await request.text();
+        if (!verifyGitHubWebhookSignature(payload, signature)) {
+          return new Response("Invalid signature", { status: 401 });
+        }
+
+        try {
+          const json = JSON.parse(payload) as Record<string, unknown>;
+          await processGitHubWebhook(eventType, deliveryId, json);
+          return new Response("OK", { status: 200 });
+        } catch (error) {
+          const detail =
+            error instanceof Error ? error.message : "Unknown error";
+          console.error(
+            `GitHub webhook processing failed (${eventType}, ${deliveryId}):`,
+            error
+          );
+          return new Response(
+            `Webhook handler error: ${detail}`,
+            { status: 500 }
+          );
+        }
+      },
+    },
+  },
+});
diff --git a/src/routes/sign-in.$.tsx b/src/routes/sign-in.$.tsx
index 52b0773..993a75f 100644
--- a/src/routes/sign-in.$.tsx
+++ b/src/routes/sign-in.$.tsx
@@ -14,6 +14,16 @@ import { VerifyEmailPending } from "~/components/auth/verify-email-pending";
 
 const signInSearchSchema = z.object({
   tab: z.enum(["sign-in", "sign-up"]).optional().catch(undefined),
+  redirectTo: z
+    .string()
+    .optional()
+    .refine(
+      (value) =>
+        value === undefined ||
+        (value.startsWith("/") && !value.startsWith("//")),
+      { message: "Invalid redirect" }
+    )
+    .catch(undefined),
 });
 
 export const Route = createFileRoute("/sign-in/$")({
@@ -23,7 +33,8 @@ export const Route = createFileRoute("/sign-in/$")({
 
 function RouteComponent() {
   const navigate = useNavigate();
-  const { tab } = Route.useSearch();
+  const { tab, redirectTo } = Route.useSearch();
+  const afterAuthPath = redirectTo ?? "/dashboard";
   const [isLoading, setIsLoading] = useState(false);
   const [pendingVerificationEmail, setPendingVerificationEmail] = useState<
     string | null
@@ -69,7 +80,11 @@ function RouteComponent() {
         return;
       }
 
-      navigate({ to: "/dashboard" });
+      if (redirectTo) {
+        window.location.assign(redirectTo);
+      } else {
+        navigate({ to: "/dashboard" });
+      }
     } catch (error) {
       toast.error(error instanceof Error ? error.message : "Sign in failed");
     } finally {
@@ -132,7 +147,10 @@ function RouteComponent() {
         </Tabs>
 
         <AuthDivider />
-        <GoogleSignInButton disabled={isLoading} />
+        <GoogleSignInButton
+          disabled={isLoading}
+          callbackURL={afterAuthPath}
+        />
       </div>
     </AuthLayout>
   );
diff --git a/src/styles/app.css b/src/styles/app.css
index 8b9dcfc..17eed7a 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -180,3 +180,87 @@
 }
 
 @plugin 'tailwind-scrollbar';
+
+/* PR description panel (development tab) */
+.pr-description-markdown.gh-markdown {
+  font-size: 0.875rem;
+  line-height: 1.65;
+}
+
+.pr-description-markdown.gh-markdown h1,
+.pr-description-markdown.gh-markdown h2 {
+  margin-top: 0.5rem;
+  margin-bottom: 0.25rem;
+  font-size: 0.875rem;
+  font-weight: 600;
+}
+
+.pr-description-markdown.gh-markdown > div > :first-child,
+.pr-description-markdown.gh-markdown h1:first-child,
+.pr-description-markdown.gh-markdown h2:first-child,
+.pr-description-markdown.gh-markdown h3:first-child,
+.pr-description-markdown.gh-markdown p:first-child,
+.pr-description-markdown.gh-markdown .gh-markdown-p:first-child {
+  margin-top: 0;
+}
+
+.pr-description-markdown.gh-markdown h3,
+.pr-description-markdown.gh-markdown h4 {
+  margin-top: 0.375rem;
+  margin-bottom: 0.125rem;
+  font-size: 0.75rem;
+  font-weight: 600;
+}
+
+.pr-description-markdown.gh-markdown p,
+.pr-description-markdown.gh-markdown .gh-markdown-p {
+  margin-top: 0.375rem;
+  margin-bottom: 0.375rem;
+}
+
+.pr-description-markdown.gh-markdown ul,
+.pr-description-markdown.gh-markdown ol {
+  margin-top: 0.375rem;
+  margin-bottom: 0.375rem;
+}
+
+.pr-description-markdown.gh-markdown pre {
+  margin-top: 0.5rem;
+  margin-bottom: 0.5rem;
+  font-size: 0.75rem;
+}
+
+/* GitHub PR comment markdown (sidebar + review UI) */
+.gh-markdown details > :not(summary) {
+  padding: 0 0.625rem 0.625rem;
+}
+
+.gh-markdown details pre {
+  margin-top: 0.5rem;
+}
+
+/* Inline PR comments sit inside Pierre’s monospace diff — reset to UI sans */
+.diff-inline-comment-wrap,
+.diff-inline-comment {
+  font-family: var(--font-sans);
+  letter-spacing: normal;
+}
+
+.diff-inline-comment .gh-markdown,
+.diff-inline-comment .gh-markdown p,
+.diff-inline-comment .gh-markdown li,
+.diff-inline-comment .gh-markdown summary,
+.diff-inline-comment .gh-markdown h1,
+.diff-inline-comment .gh-markdown h2,
+.diff-inline-comment .gh-markdown h3,
+.diff-inline-comment .gh-markdown h4 {
+  font-family: inherit;
+  color: inherit;
+}
+
+.diff-inline-comment .gh-markdown pre,
+.diff-inline-comment .gh-markdown pre code,
+.diff-inline-comment .gh-markdown :not(pre) > code {
+  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
+    monospace;
+}
diff --git a/start-database.sh b/start-database.sh
index 0945dc3..dc49e8b 100755
--- a/start-database.sh
+++ b/start-database.sh
@@ -27,9 +27,10 @@ if [ "$(docker ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
   exit 0
 fi
 
-# import env variables from .env
-set -a
-source .env
+# Read database URL only (do not source .env — values may contain spaces).
+NETLIFY_DATABASE_URL="$(
+  grep -E '^NETLIFY_DATABASE_URL=' .env | head -1 | sed -E 's/^NETLIFY_DATABASE_URL=//' | sed -E 's/^["'\'']|["'\'']$//g'
+)"
 
 DB_PASSWORD=$(echo "$NETLIFY_DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}')
 DB_PORT=$(echo "$NETLIFY_DATABASE_URL" | awk -F':' '{print $4}' | awk -F'/' '{print $1}')
diff --git a/vite.config.ts b/vite.config.ts
index 2905bed..ddc9599 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,6 +13,8 @@ export default defineConfig({
   },
   server: {
     port: 3001,
+    // Listen on IPv4 too; default [::1] breaks smee/curl to 127.0.0.1
+    host: true,
   },
   plugins: [
     devtools(),
