/ opencodehub / src / lib / codeowners-enforcement.ts
Swadhin Biswas Feature/ai code reviewer (#73) · 8/2/2026
105a287
207 lines · 6.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
/**
 * CODEOWNERS Enforcement Library
 * Validates that all required code owners have approved before merge
 */

import { getDatabase, schema } from "@/db";
import { eq, and, inArray } from "drizzle-orm";
import { logger } from "./logger";
import { parseCodeOwners, findOwnersForFiles, CODEOWNERS_PATHS } from "./codeowners";
import { getFileContent } from "./git";

interface CodeOwnerCheck {
    canMerge: boolean;
    missingApprovals: {
        path: string;
        requiredOwners: string[];
        approvedBy: string[];
    }[];
}

export function branchRuleMatches(pattern: string, branch: string): boolean {
    if (pattern === branch) return true;
    if (pattern.endsWith("*")) return branch.startsWith(pattern.slice(0, -1));
    return false;
}

export async function isCodeOwnerEnforced(
    repositoryId: string,
    baseBranch: string,
    prStateId?: string | null
): Promise<boolean> {
    const db = getDatabase();

    // 1. Check Custom PR State
    if (prStateId) {
        const stateDef = await db.query.prStateDefinitions?.findFirst({
            where: eq(schema.prStateDefinitions.id, prStateId)
        });
        if (stateDef?.requireCodeOwner) return true;
    }

    // 2. Check Global Review Policy
    const reviewRequirements = await db.query.reviewRequirements?.findFirst({
        where: eq(schema.reviewRequirements.repositoryId, repositoryId)
    });
    if (reviewRequirements?.requireCodeOwner) return true;

    // 3. Check Branch Protection Rules
    const protectionRules = await db.query.branchProtection?.findMany({
        where: and(
            eq(schema.branchProtection.repositoryId, repositoryId),
            eq(schema.branchProtection.active, true)
        )
    });
    
    if (protectionRules) {
        const matchingRule = protectionRules.find(rule => branchRuleMatches(rule.pattern, baseBranch));
        if (matchingRule?.requireCodeOwnerReviews) return true;
    }

    return false;
}

/**
 * Check if all required code owners have approved a PR
 */
export async function checkCodeOwnerApprovals(
    repositoryId: string,
    pullRequestId: string,
    changedFiles: string[]
): Promise<CodeOwnerCheck> {
    const db = getDatabase();

    // Get repository info
    const repo = await db.query.repositories.findFirst({
        where: eq(schema.repositories.id, repositoryId),
    });

    if (!repo) {
        return { canMerge: true, missingApprovals: [] };
    }

    const { resolveRepoPath } = await import("./git-storage");
    const repoPath = await resolveRepoPath(repo.diskPath);

    // Try to find CODEOWNERS file
    let codeOwnersContent: string | null = null;
    for (const path of CODEOWNERS_PATHS) {
        try {
            const result = await getFileContent(repoPath, path, repo.defaultBranch);
            if (result && result.content) {
                codeOwnersContent = result.content;
                break;
            }
        } catch {
            // File doesn't exist at this path
        }
    }

    if (!codeOwnersContent) {
        // No CODEOWNERS file - allow merge
        return { canMerge: true, missingApprovals: [] };
    }

    // Parse CODEOWNERS
    const codeOwners = parseCodeOwners(codeOwnersContent);

    // Get all required owners for changed files
    const allOwners = findOwnersForFiles(codeOwners, changedFiles);

    if (allOwners.length === 0) {
        return { canMerge: true, missingApprovals: [] };
    }

    // Get PR approvals
    const reviews = await db.query.pullRequestReviews.findMany({
        where: and(
            eq(schema.pullRequestReviews.pullRequestId, pullRequestId),
            eq(schema.pullRequestReviews.state, "approved")
        ),
    });

    // Get usernames of approvers
    const approverIds = reviews.map(r => r.reviewerId);
    const approverUsernames = new Set<string>();

    if (approverIds.length > 0) {
        const approvers = await db.query.users.findMany({
            where: inArray(schema.users.id, approverIds),
        });
        approvers.forEach(u => approverUsernames.add(u.username.toLowerCase()));
    }

    // Check if all owners have approved
    const missingApprovals: CodeOwnerCheck["missingApprovals"] = [];

    // Group by path for better error messages
    const pathOwnerMap = new Map<string, Set<string>>();
    for (const file of changedFiles) {
        const owners = findOwnersForFiles(codeOwners, [file]);
        if (owners.length > 0) {
            pathOwnerMap.set(file, new Set(owners.map(o => o.replace("@", "").toLowerCase())));
        }
    }

    for (const [path, requiredOwners] of pathOwnerMap) {
        const requiredArray = Array.from(requiredOwners);
        const hasApproval = requiredArray.some(owner => approverUsernames.has(owner));

        if (!hasApproval) {
            missingApprovals.push({
                path,
                requiredOwners: requiredArray.map(o => `@${o}`),
                approvedBy: Array.from(approverUsernames),
            });
        }
    }

    const canMerge = missingApprovals.length === 0;

    if (!canMerge) {
        logger.info({
            pullRequestId,
            missingCount: missingApprovals.length,
        }, "CODEOWNERS approval check failed");
    }

    return { canMerge, missingApprovals };
}

/**
 * Get a summary of CODEOWNERS requirements for a PR
 */
export async function getCodeOwnersSummary(
    repositoryId: string,
    changedFiles: string[]
): Promise<{ path: string; owners: string[] }[]> {
    const db = getDatabase();

    const repo = await db.query.repositories.findFirst({
        where: eq(schema.repositories.id, repositoryId),
    });

    if (!repo) return [];

    const { resolveRepoPath } = await import("./git-storage");
    const repoPath = await resolveRepoPath(repo.diskPath);

    // Try to find CODEOWNERS file
    for (const path of CODEOWNERS_PATHS) {
        try {
            const result = await getFileContent(repoPath, path, repo.defaultBranch);
            if (result?.content) {
                const codeOwners = parseCodeOwners(result.content);
                return changedFiles.map(file => ({
                    path: file,
                    owners: findOwnersForFiles(codeOwners, [file]),
                }));
            }
        } catch {
            // Continue to next path
        }
    }

    return [];
}