Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitec9e3e3unknown_key

feat: CODEOWNERS auto-assign + required reviews before merge (branch protection)

feat: CODEOWNERS auto-assign + required reviews before merge (branch protection)

- Add pr_review_requests table (migration 0077) for tracking CODEOWNERS-assigned reviewers
- Extend src/lib/codeowners.ts with matchOwners() and getCodeownersForRepo() helpers
- Create src/lib/branch-rules.ts with checkMergeEligible() that enforces required_approvals
- On PR creation: diff changed files, parse CODEOWNERS, insert review requests, post AI comment
- On merge: block if required approval count not met with human-readable error
- PR detail view: show requested reviewers with pending/approved/changes_requested status
- Repo settings: Branch Protection section to add/delete rules (pattern + required approvals + codeowner toggle + dismiss stale)

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: 8286942
6 files changed+5831ec9e3e33565942e5f4f1ff0e63bc8e96191c3fb3
6 changed files+583−1
Addeddrizzle/0077_codeowners_review_requests.sql+15−0View fileUnifiedSplit
1-- Migration 0077: CODEOWNERS auto-assign + required reviews before merge
2-- Adds pr_review_requests table for tracking requested reviewers per PR.
3-- (branch_protection table already exists from an earlier migration.)
4
5CREATE TABLE IF NOT EXISTS pr_review_requests (
6 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 pr_id uuid NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE,
8 reviewer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
9 requested_by uuid REFERENCES users(id),
10 created_at timestamp DEFAULT now(),
11 UNIQUE(pr_id, reviewer_id)
12);
13
14CREATE INDEX IF NOT EXISTS pr_review_requests_pr ON pr_review_requests(pr_id);
15CREATE INDEX IF NOT EXISTS pr_review_requests_reviewer ON pr_review_requests(reviewer_id);
Modifiedsrc/db/schema.ts+25−0View fileUnifiedSplit
489489 (table) => [index("code_owners_repo").on(table.repositoryId)]
490490);
491491
492/**
493 * PR review requests — tracks reviewers auto-assigned via CODEOWNERS
494 * or manually requested. The UNIQUE constraint prevents duplicate requests.
495 * Migration 0077.
496 */
497export const prReviewRequests = pgTable(
498 "pr_review_requests",
499 {
500 id: uuid("id").primaryKey().defaultRandom(),
501 prId: uuid("pr_id")
502 .notNull()
503 .references(() => pullRequests.id, { onDelete: "cascade" }),
504 reviewerId: uuid("reviewer_id")
505 .notNull()
506 .references(() => users.id, { onDelete: "cascade" }),
507 requestedBy: uuid("requested_by").references(() => users.id),
508 createdAt: timestamp("created_at").defaultNow().notNull(),
509 },
510 (table) => [
511 index("pr_review_requests_pr").on(table.prId),
512 index("pr_review_requests_reviewer").on(table.reviewerId),
513 ]
514);
515
492516/**
493517 * Per-repo AI chat sessions — conversational repo assistant.
494518 */
10011025export type Reaction = typeof reactions.$inferSelect;
10021026export type PrReview = typeof prReviews.$inferSelect;
10031027export type CodeOwner = typeof codeOwners.$inferSelect;
1028export type PrReviewRequest = typeof prReviewRequests.$inferSelect;
10041029export type AiChat = typeof aiChats.$inferSelect;
10051030export type AuditLogEntry = typeof auditLog.$inferSelect;
10061031export type Deployment = typeof deployments.$inferSelect;
Addedsrc/lib/branch-rules.ts+98−0View fileUnifiedSplit
1/**
2 * Branch rules — higher-level wrapper around the existing `branch_protection`
3 * table + `pr_reviews` table. Provides `checkMergeEligible` for the merge
4 * route to call, and `getBranchRules` for the repo-settings UI.
5 *
6 * The underlying enforcement engine lives in `src/lib/branch-protection.ts`.
7 * This module exposes a simplified interface used by the route layer.
8 */
9
10import { eq } from "drizzle-orm";
11import { db } from "../db";
12import { branchProtection, prReviews } from "../db/schema";
13import {
14 matchProtection,
15 countHumanApprovals,
16} from "./branch-protection";
17
18export interface BranchRule {
19 id: string;
20 pattern: string; // branch name pattern (e.g. "main", "release/*")
21 requiredReviews: number; // 0 = no requirement
22 requireCodeownerReview: boolean;
23 dismissStaleReviews: boolean;
24}
25
26/**
27 * Return all branch-protection rules for a repository, mapped to the
28 * simplified BranchRule interface the UI and merge-check use.
29 */
30export async function getBranchRules(repoId: string): Promise<BranchRule[]> {
31 try {
32 const rows = await db
33 .select()
34 .from(branchProtection)
35 .where(eq(branchProtection.repositoryId, repoId));
36
37 return rows.map((r) => ({
38 id: r.id,
39 pattern: r.pattern,
40 requiredReviews: r.requiredApprovals,
41 requireCodeownerReview: r.requireHumanReview, // closest semantic match
42 dismissStaleReviews: r.dismissStaleReviews,
43 }));
44 } catch {
45 return [];
46 }
47}
48
49export interface MergeEligibleResult {
50 eligible: boolean;
51 reason?: string;
52 approvalCount: number;
53 requiredCount: number;
54}
55
56/**
57 * Check whether a PR is eligible to be merged, based on the branch-protection
58 * rules for `targetBranch`. This supplements (does not replace) the full gate
59 * check in the merge route — it specifically handles the human-review count
60 * requirement coming from `branch_protection.required_approvals`.
61 *
62 * Returns `eligible: true` when:
63 * - No rule matches the target branch, OR
64 * - The rule's `requiredApprovals` threshold is satisfied.
65 */
66export async function checkMergeEligible(
67 prId: string,
68 repoId: string,
69 targetBranch: string
70): Promise<MergeEligibleResult> {
71 try {
72 const rule = await matchProtection(repoId, targetBranch);
73
74 if (!rule || rule.requiredApprovals === 0) {
75 // Count anyway for informational display
76 const approvalCount = await countHumanApprovals(prId);
77 return { eligible: true, approvalCount, requiredCount: 0 };
78 }
79
80 const approvalCount = await countHumanApprovals(prId);
81 const requiredCount = rule.requiredApprovals;
82
83 if (approvalCount < requiredCount) {
84 return {
85 eligible: false,
86 reason: `This PR requires ${requiredCount} approval${requiredCount !== 1 ? "s" : ""} before merging. Currently: ${approvalCount} approval${approvalCount !== 1 ? "s" : ""}.`,
87 approvalCount,
88 requiredCount,
89 };
90 }
91
92 return { eligible: true, approvalCount, requiredCount };
93 } catch {
94 // On error, allow the merge to proceed — the full gate check is the
95 // primary enforcement mechanism.
96 return { eligible: true, approvalCount: 0, requiredCount: 0 };
97 }
98}
Modifiedsrc/lib/codeowners.ts+46−0View fileUnifiedSplit
2424 teamMembers,
2525 users,
2626} from "../db/schema";
27import { getBlob } from "../git/repository";
2728
2829export interface OwnerRule {
2930 pattern: string;
3536 owners: string[];
3637}
3738
39/** Public alias used by new callers (matches task spec interface). */
40export type CodeOwnerRule = OwnerRule;
41
3842export function isTeamToken(token: string): boolean {
3943 return token.includes("/");
4044}
191195 return [];
192196 }
193197}
198
199/**
200 * matchOwners — public alias for `reviewersForChangedFiles` using in-memory
201 * rules instead of DB. Accepts the pre-parsed rules array directly.
202 * Deduplicated; team tokens are NOT expanded here (use expandOwnerTokens).
203 */
204export function matchOwners(filePaths: string[], rules: OwnerRule[]): string[] {
205 const out = new Set<string>();
206 for (const p of filePaths) {
207 for (const u of ownersForPath(p, rules)) out.add(u);
208 }
209 return Array.from(out);
210}
211
212/**
213 * Fetch and parse the CODEOWNERS file for a repo from git.
214 * Checks three canonical locations in order:
215 * 1. `CODEOWNERS`
216 * 2. `.github/CODEOWNERS`
217 * 3. `docs/CODEOWNERS`
218 * Returns [] if none found.
219 */
220export async function getCodeownersForRepo(
221 owner: string,
222 repo: string,
223 branch: string
224): Promise<OwnerRule[]> {
225 const candidates = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
226
227 for (const path of candidates) {
228 try {
229 const blob = await getBlob(owner, repo, branch, path);
230 if (blob && !blob.isBinary && blob.content) {
231 return parseCodeowners(blob.content);
232 }
233 } catch {
234 // file not found — try next candidate
235 }
236 }
237
238 return [];
239}
Modifiedsrc/routes/pulls.tsx+138−0View fileUnifiedSplit
2020 pullRequests,
2121 prComments,
2222 prReviews,
23 prReviewRequests,
2324 repositories,
2425 users,
2526 issues,
123124import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
124125import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
125126import { BOT_USERNAME } from "../lib/bot-user";
127import {
128 getCodeownersForRepo,
129 reviewersForChangedFiles,
130} from "../lib/codeowners";
131import { checkMergeEligible } from "../lib/branch-rules";
126132
127133const pulls = new Hono<AuthEnv>();
128134
31833189 })
31843190 .returning();
31853191
3192 // CODEOWNERS — auto-request reviewers based on changed files.
3193 // Fire-and-forget; errors never block PR creation.
3194 (async () => {
3195 try {
3196 const repoDir = getRepoPath(ownerName, repoName);
3197 // Get list of changed files between base and head
3198 const diffProc = Bun.spawn(
3199 ["git", "diff", "--name-only", `${baseBranch}...${headBranch}`],
3200 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3201 );
3202 const rawDiff = await new Response(diffProc.stdout).text();
3203 await diffProc.exited;
3204 const changedFiles = rawDiff.trim().split("\n").filter(Boolean);
3205
3206 if (changedFiles.length > 0) {
3207 // Get CODEOWNERS from the default branch of the repo
3208 const rules = await getCodeownersForRepo(
3209 ownerName,
3210 repoName,
3211 resolved.repo.defaultBranch
3212 );
3213 if (rules.length > 0) {
3214 const ownerUsernames = await reviewersForChangedFiles(
3215 resolved.repo.id,
3216 changedFiles
3217 );
3218 // Filter out the PR author
3219 const filteredOwners = ownerUsernames.filter(
3220 (u) => u !== resolved.owner.username
3221 );
3222
3223 if (filteredOwners.length > 0) {
3224 // Look up user IDs for the owner usernames
3225 const reviewerUsers = await db
3226 .select({ id: users.id, username: users.username })
3227 .from(users)
3228 .where(
3229 inArray(
3230 users.username,
3231 filteredOwners
3232 )
3233 );
3234
3235 // Create review request rows (UNIQUE constraint prevents dupes)
3236 if (reviewerUsers.length > 0) {
3237 await db
3238 .insert(prReviewRequests)
3239 .values(
3240 reviewerUsers.map((u) => ({
3241 prId: pr.id,
3242 reviewerId: u.id,
3243 requestedBy: null as string | null,
3244 }))
3245 )
3246 .onConflictDoNothing();
3247
3248 // Add a PR comment announcing the auto-assigned reviewers
3249 const mentionList = reviewerUsers
3250 .map((u) => `@${u.username}`)
3251 .join(", ");
3252 await db.insert(prComments).values({
3253 pullRequestId: pr.id,
3254 authorId: user.id,
3255 body: `AI: Requested review from ${mentionList} based on CODEOWNERS`,
3256 isAiReview: true,
3257 });
3258 }
3259 }
3260 }
3261 }
3262 } catch (err) {
3263 console.warn("[codeowners] auto-assign failed:", err instanceof Error ? err.message : err);
3264 }
3265 })();
3266
31863267 // Skip AI review on drafts — it runs again when the PR is marked ready.
31873268 if (!isDraft && isAiReviewEnabled()) {
31883269 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
33403421 const changesRequested = [...latestReviewByReviewer.values()].filter(r => r.state === "changes_requested");
33413422 const viewerHasReviewed = user ? latestReviewByReviewer.has(user.id) : false;
33423423
3424 // Requested reviewers from CODEOWNERS auto-assign (migration 0077).
3425 const requestedReviewerRows = await db
3426 .select({
3427 reviewerUsername: users.username,
3428 reviewerId: prReviewRequests.reviewerId,
3429 createdAt: prReviewRequests.createdAt,
3430 })
3431 .from(prReviewRequests)
3432 .innerJoin(users, eq(prReviewRequests.reviewerId, users.id))
3433 .where(eq(prReviewRequests.prId, pr.id))
3434 .orderBy(asc(prReviewRequests.createdAt))
3435 .catch(() => [] as { reviewerUsername: string; reviewerId: string; createdAt: Date }[]);
3436
33433437 // Suggested reviewers — best-effort, never throws
33443438 let reviewerSuggestions: ReviewerCandidate[] = [];
33453439 try {
40034097 <PrRiskCard risk={prRisk} calculating={prRiskCalculating} />
40044098 )}
40054099
4100 {/* ─── Requested reviewers (CODEOWNERS auto-assign, migration 0077) ─── */}
4101 {requestedReviewerRows.length > 0 && (
4102 <div class="prs-review-summary" style="margin-top:14px">
4103 <div style="font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-weight:700;margin-bottom:4px">
4104 Review requested
4105 </div>
4106 {requestedReviewerRows.map((rr) => {
4107 const hasReviewed = latestReviewByReviewer.has(rr.reviewerId);
4108 const review = latestReviewByReviewer.get(rr.reviewerId);
4109 const statusIcon = !hasReviewed ? "⏳" : review?.state === "approved" ? "✓" : "✗";
4110 const statusColor = !hasReviewed
4111 ? "var(--text-muted)"
4112 : review?.state === "approved"
4113 ? "#34d399"
4114 : "#f87171";
4115 return (
4116 <div class="prs-review-row" style={`gap:8px`}>
4117 <span class="prs-reviewer-avatar">
4118 {rr.reviewerUsername.slice(0, 1).toUpperCase()}
4119 </span>
4120 <a href={`/${rr.reviewerUsername}`}
4121 style="flex:1;font-size:13px;color:var(--text);font-weight:600;text-decoration:none">
4122 {rr.reviewerUsername}
4123 </a>
4124 <span style={`font-size:12px;font-weight:600;color:${statusColor}`}>
4125 {statusIcon} {!hasReviewed ? "Pending" : review?.state === "approved" ? "Approved" : "Changes requested"}
4126 </span>
4127 </div>
4128 );
4129 })}
4130 </div>
4131 )}
4132
40064133 {/* ─── Review summary ─────────────────────────────────── */}
40074134 {(approvals.length > 0 || changesRequested.length > 0) && (
40084135 <div class="prs-review-summary">
48484975 );
48494976 }
48504977
4978 // Required reviews check — branch-protection `required_approvals` gate.
4979 // Evaluated before running expensive gate checks so the feedback is fast.
4980 {
4981 const eligibility = await checkMergeEligible(pr.id, resolved.repo.id, pr.baseBranch);
4982 if (!eligibility.eligible && eligibility.reason) {
4983 return c.redirect(
4984 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(eligibility.reason)}`
4985 );
4986 }
4987 }
4988
48514989 // Resolve head SHA
48524990 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
48534991 if (!headSha) {
Modifiedsrc/routes/repo-settings.tsx+261−1View fileUnifiedSplit
55import { Hono } from "hono";
66import { eq, and } from "drizzle-orm";
77import { db } from "../db";
8import { repositories, users, repoTransfers } from "../db/schema";
8import { repositories, users, repoTransfers, branchProtection } from "../db/schema";
99import { Layout } from "../views/layout";
1010import { RepoHeader } from "../views/components";
1111import { softAuth, requireAuth } from "../middleware/auth";
583583
584584 const branches = await listBranches(ownerName, repoName);
585585
586 // Branch protection rules for the "Branch protection" settings section.
587 const existingBranchRules = await db
588 .select()
589 .from(branchProtection)
590 .where(eq(branchProtection.repositoryId, repo.id))
591 .catch(() => [] as typeof branchProtection.$inferSelect[]);
592
586593 return c.html(
587594 <Layout title={`Settings — ${ownerName}/${repoName}`} user={user}>
588595 <RepoHeader owner={ownerName} repo={repoName} />
10911098 </form>
10921099 </section>
10931100
1101 {/* ─── Branch protection ─── */}
1102 <section class="repo-settings-section" id="branch-protection">
1103 <div class="repo-settings-section-head">
1104 <div class="repo-settings-section-eyebrow">Branch protection</div>
1105 <h2 class="repo-settings-section-title">Required reviews before merge</h2>
1106 <p class="repo-settings-section-desc">
1107 Protect branches by requiring a minimum number of human approvals before
1108 a pull request can be merged. Patterns support wildcards (e.g.{" "}
1109 <code>release/*</code>). CODEOWNERS auto-assignment applies independently.
1110 </p>
1111 </div>
1112
1113 {/* Existing rules list */}
1114 {existingBranchRules.length > 0 && (
1115 <div style="padding: var(--space-3) var(--space-5) 0">
1116 {existingBranchRules.map((rule) => (
1117 <div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)">
1118 <span style="flex:1;font-family:var(--font-mono);font-size:13px;color:var(--text-strong)">
1119 {rule.pattern}
1120 </span>
1121 <span style="font-size:12.5px;color:var(--text-muted)">
1122 {rule.requiredApprovals} required approval{rule.requiredApprovals !== 1 ? "s" : ""}
1123 </span>
1124 {rule.requireHumanReview && (
1125 <span style="font-size:11px;font-weight:600;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.12);color:#b69dff">
1126 codeowner review required
1127 </span>
1128 )}
1129 {rule.dismissStaleReviews && (
1130 <span style="font-size:11px;color:var(--text-muted)">
1131 dismiss stale
1132 </span>
1133 )}
1134 <form method="post" action={`/${ownerName}/${repoName}/settings/branch-protection/${rule.id}/delete`}>
1135 <button type="submit"
1136 class="repo-settings-cta-secondary"
1137 style="padding:4px 10px;font-size:12px;color:var(--red,#f87171)"
1138 onclick="return confirm('Delete this branch protection rule?')"
1139 >
1140 Delete
1141 </button>
1142 </form>
1143 </div>
1144 ))}
1145 </div>
1146 )}
1147
1148 {/* Add new rule form */}
1149 <form
1150 method="post"
1151 action={`/${ownerName}/${repoName}/settings/branch-protection`}
1152 >
1153 <div class="repo-settings-section-body">
1154 <div style="display:grid;grid-template-columns:1fr 120px;gap:var(--space-3);align-items:end">
1155 <div class="repo-settings-field" style="margin-bottom:0">
1156 <label class="repo-settings-field-label" for="bp_pattern">
1157 Branch name pattern
1158 </label>
1159 <input
1160 class="repo-settings-input"
1161 name="pattern"
1162 id="bp_pattern"
1163 placeholder="main"
1164 required
1165 aria-label="Branch name pattern"
1166 />
1167 <div class="repo-settings-field-hint">
1168 Exact name or glob like <code>release/*</code>
1169 </div>
1170 </div>
1171 <div class="repo-settings-field" style="margin-bottom:0">
1172 <label class="repo-settings-field-label" for="bp_required">
1173 Required approvals
1174 </label>
1175 <input
1176 class="repo-settings-input"
1177 name="required_approvals"
1178 id="bp_required"
1179 type="number"
1180 min="0"
1181 max="10"
1182 value="1"
1183 aria-label="Number of required approvals"
1184 />
1185 </div>
1186 </div>
1187 <div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:8px">
1188 <label class="repo-settings-toggle-row" aria-label="Require codeowner review">
1189 <input
1190 type="checkbox"
1191 name="require_codeowner_review"
1192 value="1"
1193 />
1194 <span class="repo-settings-toggle-text">
1195 <span class="repo-settings-toggle-text-title">
1196 Require codeowner review
1197 </span>
1198 <span class="repo-settings-toggle-text-hint">
1199 At least one CODEOWNERS-matched reviewer must approve before merging.
1200 </span>
1201 </span>
1202 </label>
1203 <label class="repo-settings-toggle-row" aria-label="Dismiss stale reviews">
1204 <input
1205 type="checkbox"
1206 name="dismiss_stale_reviews"
1207 value="1"
1208 />
1209 <span class="repo-settings-toggle-text">
1210 <span class="repo-settings-toggle-text-title">
1211 Dismiss stale reviews on new push
1212 </span>
1213 <span class="repo-settings-toggle-text-hint">
1214 Prior approvals are dismissed when the head branch receives a new commit.
1215 </span>
1216 </span>
1217 </label>
1218 </div>
1219 </div>
1220 <div class="repo-settings-section-foot">
1221 <button type="submit" class="repo-settings-cta">
1222 Add rule <span class="arrow"></span>
1223 </button>
1224 </div>
1225 </form>
1226 </section>
1227
10941228 {/* ─── Danger zone ─── */}
10951229 <section class="repo-settings-danger">
10961230 <div class="repo-settings-danger-head">
15851719 }
15861720);
15871721
1722// ─── Branch protection: add rule ───────────────────────────────────────────
1723repoSettings.post(
1724 "/:owner/:repo/settings/branch-protection",
1725 requireAuth,
1726 requireRepoAccess("admin"),
1727 async (c) => {
1728 const { owner: ownerName, repo: repoName } = c.req.param();
1729 const user = c.get("user")!;
1730 const body = await c.req.parseBody();
1731
1732 const [owner] = await db
1733 .select()
1734 .from(users)
1735 .where(eq(users.username, ownerName))
1736 .limit(1);
1737 if (!owner || owner.id !== user.id) {
1738 return c.redirect(`/${ownerName}/${repoName}`);
1739 }
1740
1741 const [repo] = await db
1742 .select()
1743 .from(repositories)
1744 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1745 .limit(1);
1746 if (!repo) return c.notFound();
1747
1748 const pattern = String(body.pattern || "").trim();
1749 if (!pattern) {
1750 return c.redirect(
1751 `/${ownerName}/${repoName}/settings?error=Pattern+is+required#branch-protection`
1752 );
1753 }
1754
1755 const requiredApprovals = Math.max(0, parseInt(String(body.required_approvals || "1"), 10) || 0);
1756 const requireHumanReview = body.require_codeowner_review === "1";
1757 const dismissStaleReviews = body.dismiss_stale_reviews === "1";
1758
1759 try {
1760 await db
1761 .insert(branchProtection)
1762 .values({
1763 repositoryId: repo.id,
1764 pattern,
1765 requiredApprovals,
1766 requireHumanReview,
1767 dismissStaleReviews,
1768 })
1769 .onConflictDoUpdate({
1770 target: [branchProtection.repositoryId, branchProtection.pattern],
1771 set: {
1772 requiredApprovals,
1773 requireHumanReview,
1774 dismissStaleReviews,
1775 updatedAt: new Date(),
1776 },
1777 });
1778
1779 await audit({
1780 userId: user.id,
1781 repositoryId: repo.id,
1782 action: "branch_protection.updated",
1783 targetType: "repository",
1784 targetId: repo.id,
1785 metadata: { pattern, requiredApprovals, requireHumanReview, dismissStaleReviews },
1786 });
1787 } catch (err) {
1788 return c.redirect(
1789 `/${ownerName}/${repoName}/settings?error=${encodeURIComponent("Failed to save rule: " + String(err instanceof Error ? err.message : err))}#branch-protection`
1790 );
1791 }
1792
1793 return c.redirect(
1794 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+saved#branch-protection`
1795 );
1796 }
1797);
1798
1799// ─── Branch protection: delete rule ────────────────────────────────────────
1800repoSettings.post(
1801 "/:owner/:repo/settings/branch-protection/:ruleId/delete",
1802 requireAuth,
1803 requireRepoAccess("admin"),
1804 async (c) => {
1805 const { owner: ownerName, repo: repoName, ruleId } = c.req.param();
1806 const user = c.get("user")!;
1807
1808 const [owner] = await db
1809 .select()
1810 .from(users)
1811 .where(eq(users.username, ownerName))
1812 .limit(1);
1813 if (!owner || owner.id !== user.id) {
1814 return c.redirect(`/${ownerName}/${repoName}`);
1815 }
1816
1817 const [repo] = await db
1818 .select()
1819 .from(repositories)
1820 .where(and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)))
1821 .limit(1);
1822 if (!repo) return c.notFound();
1823
1824 await db
1825 .delete(branchProtection)
1826 .where(
1827 and(
1828 eq(branchProtection.id, ruleId),
1829 eq(branchProtection.repositoryId, repo.id)
1830 )
1831 );
1832
1833 await audit({
1834 userId: user.id,
1835 repositoryId: repo.id,
1836 action: "branch_protection.deleted",
1837 targetType: "repository",
1838 targetId: repo.id,
1839 metadata: { ruleId },
1840 });
1841
1842 return c.redirect(
1843 `/${ownerName}/${repoName}/settings?success=Branch+protection+rule+deleted#branch-protection`
1844 );
1845 }
1846);
1847
15881848export default repoSettings;
15891849