/ opencodehub / src / lib / codeowners-enforcement.ts
swadhinbiswas feat: OpenCodeHub v1.1.0 - Enterprise Features & Storage Adapters · 2/15/2026
719a1cb
158 lines · 4.6 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
/**
 * 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[];
    }[];
}

/**
 * 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: [] };
    }

    // Try to find CODEOWNERS file
    let codeOwnersContent: string | null = null;
    for (const path of CODEOWNERS_PATHS) {
        try {
            const result = await getFileContent(repositoryId, repo.defaultBranch, path);
            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 [];

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

    return [];
}