/ opencodehub / src / lib / pr-codeowner.ts
Swadhin Biswas feat(api): add runner, analytics, packages, and security services · 3/6/2026
28f7954
84 lines · 2.7 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
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { and, eq } from "drizzle-orm";
import { schema } from "@/db";
import { resolveRepoPath } from "@/lib/git-storage";
import { CODEOWNERS_PATHS, parseCodeOwners, findOwnersForFile, expandOwnersToUsernames } from "@/lib/codeowners";

export async function checkCodeOwnerApprovalsForPR(
  db: NodePgDatabase<typeof schema>,
  pullRequestId: string
): Promise<{ ok: boolean; reason?: string }> {
  const pr = await db.query.pullRequests.findFirst({
    where: eq(schema.pullRequests.id, pullRequestId),
  });
  if (!pr) return { ok: false, reason: "Pull request not found" };

  const repo = await db.query.repositories.findFirst({
    where: eq(schema.repositories.id, pr.repositoryId),
  });
  if (!repo) return { ok: false, reason: "Repository not found" };

  const localRepoPath = await resolveRepoPath(repo.diskPath);
  const { getChangedFiles } = await import("./git");
  const changedFiles = await getChangedFiles(localRepoPath, pr.baseBranch, pr.headBranch);
  if (changedFiles.length === 0) return { ok: true };

  const fs = await import("node:fs/promises");
  const path = await import("node:path");
  let codeOwnersContent: string | null = null;
  for (const candidatePath of CODEOWNERS_PATHS) {
    try {
      codeOwnersContent = await fs.readFile(path.join(localRepoPath, candidatePath), "utf-8");
      break;
    } catch {
      // continue
    }
  }

  if (!codeOwnersContent) {
    return { ok: true };
  }

  const codeOwners = parseCodeOwners(codeOwnersContent);
  const approvals = await db.query.pullRequestReviews.findMany({
    where: and(
      eq(schema.pullRequestReviews.pullRequestId, pr.id),
      eq(schema.pullRequestReviews.state, "approved")
    ),
    orderBy: (reviews, { desc }) => [desc(reviews.submittedAt)],
  });
  const approverUserIds = approvals.map((r) => r.reviewerId);
  if (approverUserIds.length === 0) {
    return { ok: false, reason: "Code owner approval required" };
  }

  const approverUsers = await db.query.users.findMany({
    where: (users, { inArray }) => inArray(users.id, approverUserIds),
  });
  const approverUsernames = new Set(approverUsers.map((u) => u.username));

  for (const file of changedFiles) {
    const owners = findOwnersForFile(codeOwners, file);
    if (owners.length === 0) continue;

    const expandedOwners = await expandOwnersToUsernames({
      db,
      repository: repo,
      owners,
    });

    const hasOwnerApproval = Array.from(expandedOwners).some((owner) =>
      approverUsernames.has(owner)
    );

    if (!hasOwnerApproval) {
      return {
        ok: false,
        reason: `Missing Code Owner approval for ${file}`,
      };
    }
  }

  return { ok: true };
}