diff --git a/.github/OWNERS b/.github/OWNERS
new file mode 100644
index 0000000000..72d835acd4
--- /dev/null
+++ b/.github/OWNERS
@@ -0,0 +1,11 @@
+* @andrewnester @anton-107 @denik @pietern @shreyas-goenka @simonfaltum
+/cmd/bundle/ @andrewnester @anton-107 @denik @pietern @shreyas-goenka @simonfaltum @lennartkats-db
+/libs/template/ @andrewnester @anton-107 @denik @pietern @shreyas-goenka @simonfaltum @lennartkats-db
+/acceptance/pipelines/ @jefferycheng1 @kanterov @lennartkats-db
+/cmd/pipelines/ @jefferycheng1 @kanterov @lennartkats-db
+/cmd/labs/ @alexott @nfx
+/cmd/apps/ @databricks/eng-apps-devex
+/cmd/workspace/apps/ @databricks/eng-apps-devex
+/libs/apps/ @databricks/eng-apps-devex
+/acceptance/apps/ @databricks/eng-apps-devex
+/experimental/aitools/ @databricks/eng-apps-devex @lennartkats-db
diff --git a/.github/scripts/owners.js b/.github/scripts/owners.js
new file mode 100644
index 0000000000..d8bd70a347
--- /dev/null
+++ b/.github/scripts/owners.js
@@ -0,0 +1,68 @@
+const fs = require("fs");
+
+/**
+ * Parse an OWNERS file (same format as CODEOWNERS).
+ * Returns array of { pattern, owners } rules.
+ *
+ * By default, team refs (org/team) are filtered out and @ is stripped.
+ * Pass { includeTeams: true } to keep team refs (with @ stripped).
+ *
+ * @param {string} filePath - absolute path to the OWNERS file
+ * @param {{ includeTeams?: boolean }} [opts]
+ * @returns {Array<{pattern: string, owners: string[]}>}
+ */
+function parseOwnersFile(filePath, opts) {
+ const includeTeams = opts && opts.includeTeams;
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
+ const rules = [];
+ for (const raw of lines) {
+ const line = raw.trim();
+ if (!line || line.startsWith("#")) continue;
+ const parts = line.split(/\s+/);
+ if (parts.length < 2) continue;
+ const pattern = parts[0];
+ const owners = parts
+ .slice(1)
+ .filter((p) => p.startsWith("@") && (includeTeams || !p.includes("/")))
+ .map((p) => p.slice(1));
+ rules.push({ pattern, owners });
+ }
+ return rules;
+}
+
+/**
+ * Match a filepath against an OWNERS pattern.
+ * Supports: "*" (catch-all), "/dir/" (prefix), "/path/file" (exact).
+ */
+function ownersMatch(pattern, filepath) {
+ if (pattern === "*") return true;
+ let p = pattern;
+ if (p.startsWith("/")) p = p.slice(1);
+ if (p.endsWith("/")) return filepath.startsWith(p);
+ return filepath === p;
+}
+
+/**
+ * Find owners for a file. Last match wins, like CODEOWNERS.
+ * @returns {string[]} owner logins
+ */
+function findOwners(filepath, rules) {
+ let matched = [];
+ for (const rule of rules) {
+ if (ownersMatch(rule.pattern, filepath)) {
+ matched = rule.owners;
+ }
+ }
+ return matched;
+}
+
+/**
+ * Get core team from the * catch-all rule.
+ * @returns {string[]} logins
+ */
+function getCoreTeam(rules) {
+ const catchAll = rules.find((r) => r.pattern === "*");
+ return catchAll ? catchAll.owners : [];
+}
+
+module.exports = { parseOwnersFile, ownersMatch, findOwners, getCoreTeam };
diff --git a/.github/workflows/maintainer-approval.js b/.github/workflows/maintainer-approval.js
new file mode 100644
index 0000000000..52c62bf631
--- /dev/null
+++ b/.github/workflows/maintainer-approval.js
@@ -0,0 +1,83 @@
+const path = require("path");
+const {
+ parseOwnersFile,
+ findOwners,
+ getCoreTeam,
+} = require("../scripts/owners");
+
+// Check if the PR author is exempted.
+// If ALL changed files are owned by non-core-team owners that include the
+// author, the PR can merge with any approval (not necessarily core team).
+function isExempted(authorLogin, files, rules, coreTeam) {
+ if (files.length === 0) return false;
+ const coreSet = new Set(coreTeam);
+ for (const { filename } of files) {
+ const owners = findOwners(filename, rules);
+ const nonCoreOwners = owners.filter((o) => !coreSet.has(o));
+ if (nonCoreOwners.length === 0 || !nonCoreOwners.includes(authorLogin)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+module.exports = async ({ github, context, core }) => {
+ const ownersPath = path.join(
+ process.env.GITHUB_WORKSPACE,
+ ".github",
+ "OWNERS"
+ );
+ const rules = parseOwnersFile(ownersPath);
+ const coreTeam = getCoreTeam(rules);
+
+ if (coreTeam.length === 0) {
+ core.setFailed(
+ "Could not determine core team from .github/OWNERS (no * rule found)."
+ );
+ return;
+ }
+
+ const reviews = await github.paginate(github.rest.pulls.listReviews, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number,
+ });
+
+ const coreTeamApproved = reviews.some(
+ ({ state, user }) =>
+ state === "APPROVED" && user && coreTeam.includes(user.login)
+ );
+
+ if (coreTeamApproved) {
+ const approver = reviews.find(
+ ({ state, user }) =>
+ state === "APPROVED" && user && coreTeam.includes(user.login)
+ );
+ core.info(`Core team approval from @${approver.user.login}`);
+ return;
+ }
+
+ // Check exemption rules based on file ownership.
+ const { pull_request: pr } = context.payload;
+ const authorLogin = pr?.user?.login;
+
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number,
+ });
+
+ if (authorLogin && isExempted(authorLogin, files, rules, coreTeam)) {
+ const hasAnyApproval = reviews.some(({ state }) => state === "APPROVED");
+ if (!hasAnyApproval) {
+ core.setFailed(
+ "PR from exempted author still needs at least one approval."
+ );
+ }
+ return;
+ }
+
+ core.setFailed(
+ `Requires approval from a core team member: ${coreTeam.join(", ")}.`
+ );
+};
diff --git a/.github/workflows/maintainer-approval.yml b/.github/workflows/maintainer-approval.yml
new file mode 100644
index 0000000000..5ddec5a165
--- /dev/null
+++ b/.github/workflows/maintainer-approval.yml
@@ -0,0 +1,32 @@
+name: Maintainer approval
+
+on:
+ pull_request_target:
+ pull_request_review:
+ types: [submitted, dismissed]
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ pull-requests: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ sparse-checkout: |
+ .github/workflows
+ .github/scripts
+ .github/OWNERS
+ - name: Require core team approval
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ with:
+ retries: 3
+ script: |-
+ const script = require('./.github/workflows/maintainer-approval.js');
+ await script({ context, github, core });
diff --git a/.github/workflows/suggest-reviewers.js b/.github/workflows/suggest-reviewers.js
new file mode 100644
index 0000000000..4d38a1be23
--- /dev/null
+++ b/.github/workflows/suggest-reviewers.js
@@ -0,0 +1,354 @@
+const path = require("path");
+const { execSync } = require("child_process");
+const {
+ parseOwnersFile,
+ findOwners,
+} = require("../scripts/owners");
+
+const MENTION_REVIEWERS = true;
+const OWNERS_LINK = "[OWNERS](.github/OWNERS)";
+const MARKER = "";
+
+const loginCache = {};
+
+function classifyFile(filepath, totalFiles) {
+ const base = path.basename(filepath);
+ if (base.startsWith("out.") || base === "output.txt") {
+ return 0.01 / Math.max(totalFiles, 1);
+ }
+ if (filepath.startsWith("acceptance/") || filepath.startsWith("integration/")) {
+ return 0.2;
+ }
+ if (filepath.endsWith("_test.go")) return 0.3;
+ return filepath.endsWith(".go") ? 1.0 : 0.5;
+}
+
+async function getChangedFiles(github, owner, repo, prNumber) {
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ owner,
+ repo,
+ pull_number: prNumber,
+ });
+ return files.map((f) => f.filename);
+}
+
+function gitLog(filepath) {
+ try {
+ const out = execSync(
+ `git log -50 --no-merges --since="12 months ago" --format="%H|%an|%aI" -- "${filepath}"`,
+ { encoding: "utf-8" }
+ );
+ const entries = [];
+ for (const line of out.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ const parts = trimmed.split("|", 3);
+ if (parts.length !== 3) continue;
+ const date = new Date(parts[2]);
+ if (isNaN(date.getTime())) continue;
+ entries.push({ sha: parts[0], name: parts[1], date });
+ }
+ return entries;
+ } catch {
+ return [];
+ }
+}
+
+async function resolveLogin(github, owner, repo, sha, authorName) {
+ if (authorName in loginCache) return loginCache[authorName];
+ try {
+ const { data } = await github.rest.repos.getCommit({ owner, repo, ref: sha });
+ const login = data.author?.login || null;
+ loginCache[authorName] = login;
+ return login;
+ } catch {
+ loginCache[authorName] = null;
+ return null;
+ }
+}
+
+function parseOwnersForFiles(changedFiles, ownersPath) {
+ const rules = parseOwnersFile(ownersPath, { includeTeams: true });
+ const allOwners = new Set();
+ for (const filepath of changedFiles) {
+ for (const o of findOwners(filepath, rules)) allOwners.add(o);
+ }
+ return Array.from(allOwners).sort();
+}
+
+async function scoreContributors(files, prAuthor, now, github, owner, repo) {
+ const scores = {};
+ const dirScores = {};
+ let scoredCount = 0;
+ const authorLogin = prAuthor.toLowerCase();
+ const totalFiles = files.length;
+
+ for (const filepath of files) {
+ const weight = classifyFile(filepath, totalFiles);
+ let history = gitLog(filepath);
+ if (history.length === 0) {
+ const parent = path.dirname(filepath);
+ if (parent && parent !== ".") {
+ history = gitLog(parent);
+ }
+ }
+ if (history.length === 0) continue;
+
+ const topDir = path.dirname(filepath) || ".";
+ let fileContributed = false;
+ for (const { sha, name, date } of history) {
+ if (name.endsWith("[bot]")) continue;
+ const login = await resolveLogin(github, owner, repo, sha, name);
+ if (!login || login.toLowerCase() === authorLogin) continue;
+ const daysAgo = Math.max(0, (now - date) / 86400000);
+ const s = weight * Math.pow(0.5, daysAgo / 150);
+ scores[login] = (scores[login] || 0) + s;
+ if (!dirScores[login]) dirScores[login] = {};
+ dirScores[login][topDir] = (dirScores[login][topDir] || 0) + s;
+ fileContributed = true;
+ }
+ if (fileContributed) scoredCount++;
+ }
+ return { scores, dirScores, scoredCount };
+}
+
+function topDirs(ds, n = 3) {
+ return Object.entries(ds || {})
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, n)
+ .map(([d]) => d);
+}
+
+function fmtReviewer(login, dirs) {
+ const mention = MENTION_REVIEWERS ? `@${login}` : login;
+ const dirList = dirs.map((d) => `\`${d}/\``).join(", ");
+ return `- ${mention} -- recent work in ${dirList}`;
+}
+
+function selectReviewers(ss) {
+ if (ss.length === 0) return [];
+ const out = [ss[0]];
+ if (ss.length >= 2 && ss[0][1] < 1.5 * ss[1][1]) {
+ out.push(ss[1]);
+ if (ss.length >= 3 && ss[1][1] < 1.5 * ss[2][1]) {
+ out.push(ss[2]);
+ }
+ }
+ return out;
+}
+
+function computeConfidence(ss, scoredCount) {
+ if (ss.length === 0) return "low";
+ if (ss.length === 1) return "high";
+ if (ss.length === 2) {
+ if (ss[0][1] > 2 * ss[1][1]) return "high";
+ if (ss[0][1] > 1.5 * ss[1][1]) return "medium";
+ return "low";
+ }
+ if (scoredCount < 3) return "low";
+ if (ss[0][1] > 2 * ss[2][1]) return "high";
+ if (ss[0][1] > 1.5 * ss[2][1]) return "medium";
+ return "low";
+}
+
+function fmtEligible(owners) {
+ if (MENTION_REVIEWERS) return owners.map((o) => `@${o}`).join(", ");
+ return owners.join(", ");
+}
+
+async function countRecentReviews(github, owner, repo, logins, days = 30) {
+ const since = new Date(Date.now() - days * 86400000)
+ .toISOString()
+ .slice(0, 10);
+ const counts = {};
+ for (const login of logins) {
+ try {
+ const { data } = await github.rest.search.issuesAndPullRequests({
+ q: `repo:${owner}/${repo} reviewed-by:${login} is:pr created:>${since}`,
+ });
+ counts[login] = data.total_count;
+ } catch {
+ // skip on error
+ }
+ }
+ return counts;
+}
+
+async function selectRoundRobin(github, owner, repo, eligibleOwners, prAuthor) {
+ const candidates = eligibleOwners
+ .filter((o) => !o.includes("/") && o.toLowerCase() !== prAuthor.toLowerCase());
+ if (candidates.length === 0) return null;
+ const counts = await countRecentReviews(github, owner, repo, candidates);
+ if (Object.keys(counts).length === 0) {
+ return candidates[Math.floor(Math.random() * candidates.length)];
+ }
+ return candidates.reduce((best, c) =>
+ (counts[c] || 0) < (counts[best] || 0) ? c : best
+ );
+}
+
+function buildComment(
+ sortedScores,
+ dirScores,
+ totalFiles,
+ scoredCount,
+ eligibleOwners,
+ prAuthor,
+ roundRobinReviewer
+) {
+ const reviewers = selectReviewers(sortedScores);
+ const suggestedLogins = new Set(reviewers.map(([login]) => login.toLowerCase()));
+ const eligible = eligibleOwners.filter(
+ (o) =>
+ o.toLowerCase() !== prAuthor.toLowerCase() &&
+ !suggestedLogins.has(o.toLowerCase())
+ );
+
+ const lines = [MARKER];
+ if (reviewers.length > 0) {
+ lines.push(
+ "## Suggested reviewers",
+ "",
+ "Based on git history of the changed files, these people are best suited to review:",
+ ""
+ );
+ for (const [login] of reviewers) {
+ lines.push(fmtReviewer(login, topDirs(dirScores[login])));
+ }
+ lines.push("", `Confidence: ${computeConfidence(sortedScores, scoredCount)}`);
+ if (eligible.length > 0) {
+ lines.push(
+ "",
+ "## Eligible reviewers",
+ "",
+ "Based on OWNERS, these people or teams could also review:",
+ "",
+ fmtEligible(eligible)
+ );
+ }
+ } else if (eligible.length > 0) {
+ if (roundRobinReviewer) {
+ const mention = MENTION_REVIEWERS
+ ? `@${roundRobinReviewer}`
+ : roundRobinReviewer;
+ const remaining = eligible.filter(
+ (o) => o.toLowerCase() !== roundRobinReviewer.toLowerCase()
+ );
+ lines.push(
+ "## Suggested reviewer",
+ "",
+ "Could not determine reviewers from git history.",
+ "Round-robin suggestion (based on recent review load):",
+ "",
+ `- ${mention}`
+ );
+ if (remaining.length > 0) {
+ lines.push(
+ "",
+ "## Eligible reviewers",
+ "",
+ "Based on OWNERS, these people or teams could also review:",
+ "",
+ fmtEligible(remaining)
+ );
+ }
+ } else {
+ lines.push(
+ "## Eligible reviewers",
+ "",
+ "Could not determine reviewers from git history. Based on OWNERS, these people or teams could review:",
+ "",
+ fmtEligible(eligible)
+ );
+ }
+ } else {
+ lines.push(
+ "## Suggested reviewers",
+ "",
+ `Could not determine reviewers from git history. Please pick from ${OWNERS_LINK}.`
+ );
+ }
+
+ lines.push(
+ "",
+ `Suggestions based on git history of ${totalFiles} changed files ` +
+ `(${scoredCount} scored). ` +
+ `See ${OWNERS_LINK} for path-specific ownership rules.`
+ );
+ return lines.join("\n") + "\n";
+}
+
+async function findExistingComment(github, owner, repo, prNumber) {
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: prNumber,
+ });
+ const match = comments.find((c) => c.body && c.body.includes(MARKER));
+ return match ? match.id : null;
+}
+
+module.exports = async ({ github, context, core }) => {
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const prNumber = context.payload.pull_request.number;
+ const prAuthor = context.payload.pull_request.user.login;
+
+ const files = await getChangedFiles(github, owner, repo, prNumber);
+ if (files.length === 0) {
+ core.info("No changed files found.");
+ return;
+ }
+
+ const now = new Date();
+ const ownersPath = path.join(
+ process.env.GITHUB_WORKSPACE,
+ ".github",
+ "OWNERS"
+ );
+
+ const { scores, dirScores, scoredCount } = await scoreContributors(
+ files,
+ prAuthor,
+ now,
+ github,
+ owner,
+ repo
+ );
+ const sortedScores = Object.entries(scores).sort((a, b) => b[1] - a[1]);
+ const eligible = parseOwnersForFiles(files, ownersPath);
+
+ let roundRobin = null;
+ if (selectReviewers(sortedScores).length === 0 && eligible.length > 0) {
+ roundRobin = await selectRoundRobin(github, owner, repo, eligible, prAuthor);
+ }
+
+ const comment = buildComment(
+ sortedScores,
+ dirScores,
+ files.length,
+ scoredCount,
+ eligible,
+ prAuthor,
+ roundRobin
+ );
+
+ core.info(comment);
+
+ const existingId = await findExistingComment(github, owner, repo, prNumber);
+ if (existingId) {
+ await github.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: existingId,
+ body: comment,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body: comment,
+ });
+ }
+};
diff --git a/.github/workflows/suggest-reviewers.yml b/.github/workflows/suggest-reviewers.yml
index 15a32c2c7d..066bc6021b 100644
--- a/.github/workflows/suggest-reviewers.yml
+++ b/.github/workflows/suggest-reviewers.yml
@@ -21,15 +21,10 @@ jobs:
with:
fetch-depth: 0
- - name: Install uv
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- with:
- version: "0.6.5"
-
- name: Suggest reviewers
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- PR_NUMBER: ${{ github.event.pull_request.number }}
- PR_AUTHOR: ${{ github.event.pull_request.user.login }}
- run: uv run tools/suggest_reviewers.py
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ with:
+ retries: 3
+ script: |-
+ const script = require('./.github/workflows/suggest-reviewers.js');
+ await script({ context, github, core });
diff --git a/tools/suggest_reviewers.py b/tools/suggest_reviewers.py
deleted file mode 100644
index 76609e5566..0000000000
--- a/tools/suggest_reviewers.py
+++ /dev/null
@@ -1,306 +0,0 @@
-#!/usr/bin/env python3
-# /// script
-# requires-python = ">=3.12"
-# ///
-
-import fnmatch
-import os
-import subprocess
-import sys
-from datetime import datetime, timezone
-from pathlib import Path
-
-MENTION_REVIEWERS = True
-CODEOWNERS_LINK = "[CODEOWNERS](.github/CODEOWNERS)"
-MARKER = ""
-_login_cache: dict[str, str | None] = {}
-
-
-def classify_file(path: str, total_files: int) -> float:
- p = Path(path)
- if p.name.startswith("out.") or p.name == "output.txt":
- return 0.01 / max(total_files, 1)
- if path.startswith(("acceptance/", "integration/")):
- return 0.2
- if path.endswith("_test.go"):
- return 0.3
- return 1.0 if path.endswith(".go") else 0.5
-
-
-def get_changed_files(pr_number: str) -> list[str]:
- r = subprocess.run(
- ["gh", "pr", "diff", "--name-only", pr_number],
- capture_output=True,
- encoding="utf-8",
- )
- if r.returncode != 0:
- print(f"gh pr diff failed: {r.stderr.strip()}", file=sys.stderr)
- sys.exit(1)
- return [f.strip() for f in r.stdout.splitlines() if f.strip()]
-
-
-def git_log(path: str) -> list[tuple[str, str, datetime]]:
- r = subprocess.run(
- ["git", "log", "-50", "--no-merges", "--since=12 months ago", "--format=%H|%an|%aI", "--", path],
- capture_output=True,
- encoding="utf-8",
- )
- if r.returncode != 0:
- return []
- entries = []
- for line in r.stdout.splitlines():
- parts = line.strip().split("|", 2)
- if len(parts) != 3:
- continue
- try:
- entries.append((parts[0], parts[1], datetime.fromisoformat(parts[2])))
- except ValueError:
- continue
- return entries
-
-
-def resolve_login(repo: str, sha: str, author_name: str) -> str | None:
- if author_name in _login_cache:
- return _login_cache[author_name]
- r = subprocess.run(
- ["gh", "api", f"repos/{repo}/commits/{sha}", "--jq", ".author.login"],
- capture_output=True,
- encoding="utf-8",
- )
- login = r.stdout.strip() or None if r.returncode == 0 else None
- _login_cache[author_name] = login
- return login
-
-
-def _codeowners_match(pattern: str, filepath: str) -> bool:
- if pattern.startswith("/"):
- pattern = pattern[1:]
- if pattern.endswith("/"):
- return filepath.startswith(pattern)
- return fnmatch.fnmatch(filepath, pattern) or filepath == pattern
- return fnmatch.fnmatch(filepath, pattern) or fnmatch.fnmatch(Path(filepath).name, pattern)
-
-
-def parse_codeowners(changed_files: list[str]) -> list[str]:
- path = Path(".github/CODEOWNERS")
- if not path.exists():
- return []
- rules: list[tuple[str, list[str]]] = []
- for line in path.read_text().splitlines():
- line = line.strip()
- if not line or line.startswith("#"):
- continue
- parts = line.split()
- owners = [p for p in parts[1:] if p.startswith("@")]
- if len(parts) >= 2 and owners:
- rules.append((parts[0], owners))
-
- all_owners: set[str] = set()
- for filepath in changed_files:
- matched = []
- for pattern, owners in rules:
- if _codeowners_match(pattern, filepath):
- matched = owners
- all_owners.update(matched)
- return sorted(all_owners)
-
-
-def score_contributors(
- files: list[str], pr_author: str, now: datetime, repo: str
-) -> tuple[dict[str, float], dict[str, dict[str, float]], int]:
- scores: dict[str, float] = {}
- dir_scores: dict[str, dict[str, float]] = {}
- scored_count = 0
- author_login = pr_author.lower()
-
- total_files = len(files)
- for filepath in files:
- weight = classify_file(filepath, total_files)
- history = git_log(filepath)
- if not history:
- parent = str(Path(filepath).parent)
- if parent and parent != ".":
- history = git_log(parent)
- if not history:
- continue
-
- top_dir = str(Path(filepath).parent) or "."
- file_contributed = False
- for sha, name, commit_date in history:
- if name.endswith("[bot]"):
- continue
- login = resolve_login(repo, sha, name)
- if not login or login.lower() == author_login:
- continue
- days_ago = max(0, (now - commit_date).total_seconds() / 86400)
- s = weight * (0.5 ** (days_ago / 150))
- scores[login] = scores.get(login, 0) + s
- dir_scores.setdefault(login, {})
- dir_scores[login][top_dir] = dir_scores[login].get(top_dir, 0) + s
- file_contributed = True
- if file_contributed:
- scored_count += 1
- return scores, dir_scores, scored_count
-
-
-def top_dirs(ds: dict[str, float], n: int = 3) -> list[str]:
- return [d for d, _ in sorted(ds.items(), key=lambda x: -x[1])[:n]]
-
-
-def fmt_reviewer(login: str, dirs: list[str]) -> str:
- mention = f"@{login}" if MENTION_REVIEWERS else login
- return f"- {mention} -- recent work in {', '.join(f'`{d}/`' for d in dirs)}"
-
-
-def select_reviewers(ss: list[tuple[str, float]]) -> list[tuple[str, float]]:
- if not ss:
- return []
- out = [ss[0]]
- if len(ss) >= 2 and ss[0][1] < 1.5 * ss[1][1]:
- out.append(ss[1])
- if len(ss) >= 3 and ss[1][1] < 1.5 * ss[2][1]:
- out.append(ss[2])
- return out
-
-
-def compute_confidence(ss: list[tuple[str, float]], scored_count: int) -> str:
- if not ss:
- return "low"
- if len(ss) == 1:
- return "high"
- if len(ss) == 2:
- if ss[0][1] > 2 * ss[1][1]:
- return "high"
- if ss[0][1] > 1.5 * ss[1][1]:
- return "medium"
- return "low"
- if scored_count < 3:
- return "low"
- if ss[0][1] > 2 * ss[2][1]:
- return "high"
- if ss[0][1] > 1.5 * ss[2][1]:
- return "medium"
- return "low"
-
-
-def fmt_eligible(owners: list[str]) -> str:
- if MENTION_REVIEWERS:
- return ", ".join(owners)
- return ", ".join(o.lstrip("@") for o in owners)
-
-
-def build_comment(
- sorted_scores: list[tuple[str, float]],
- dir_scores: dict[str, dict[str, float]],
- total_files: int,
- scored_count: int,
- eligible_owners: list[str],
- pr_author: str,
-) -> str:
- reviewers = select_reviewers(sorted_scores)
- suggested_logins = {login.lower() for login, _ in reviewers}
- eligible = [
- o
- for o in eligible_owners
- if o.lstrip("@").lower() != pr_author.lower() and o.lstrip("@").lower() not in suggested_logins
- ]
-
- lines = [MARKER]
- if reviewers:
- lines += [
- "## Suggested reviewers",
- "",
- "Based on git history of the changed files, these people are best suited to review:",
- "",
- ]
- for login, _ in reviewers:
- lines.append(fmt_reviewer(login, top_dirs(dir_scores.get(login, {}))))
- lines += ["", f"Confidence: {compute_confidence(sorted_scores, scored_count)}"]
- if eligible:
- lines += [
- "",
- "## Eligible reviewers",
- "",
- "Based on CODEOWNERS, these people or teams could also review:",
- "",
- fmt_eligible(eligible),
- ]
- elif eligible:
- lines += [
- "## Eligible reviewers",
- "",
- "Could not determine reviewers from git history. Based on CODEOWNERS, these people or teams could review:",
- "",
- fmt_eligible(eligible),
- ]
- else:
- lines += [
- "## Suggested reviewers",
- "",
- f"Could not determine reviewers from git history. Please pick from {CODEOWNERS_LINK}.",
- ]
-
- lines += [
- "",
- f"Suggestions based on git history of {total_files} changed files "
- f"({scored_count} scored). "
- f"See {CODEOWNERS_LINK} for path-specific ownership rules.",
- ]
- return "\n".join(lines) + "\n"
-
-
-def find_existing_comment(repo: str, pr_number: str) -> str | None:
- r = subprocess.run(
- [
- "gh",
- "api",
- f"repos/{repo}/issues/{pr_number}/comments",
- "--paginate",
- "--jq",
- f'.[] | select(.body | contains("{MARKER}")) | .id',
- ],
- capture_output=True,
- encoding="utf-8",
- )
- if r.returncode != 0:
- print(f"gh api comments failed: {r.stderr.strip()}", file=sys.stderr)
- sys.exit(1)
- for cid in r.stdout.splitlines():
- cid = cid.strip()
- if cid:
- return cid
- return None
-
-
-def main():
- repo = os.environ["GITHUB_REPOSITORY"]
- pr_number = os.environ["PR_NUMBER"]
- pr_author = os.environ["PR_AUTHOR"]
-
- files = get_changed_files(pr_number)
- if not files:
- print("No changed files found.")
- return
-
- now = datetime.now(timezone.utc)
- scores, dir_scores, scored_count = score_contributors(files, pr_author, now, repo)
- sorted_scores = sorted(scores.items(), key=lambda x: -x[1])
- eligible = parse_codeowners(files)
- comment = build_comment(sorted_scores, dir_scores, len(files), scored_count, eligible, pr_author)
-
- print(comment)
- existing_id = find_existing_comment(repo, pr_number)
- if existing_id:
- subprocess.run(
- ["gh", "api", f"repos/{repo}/issues/comments/{existing_id}", "-X", "PATCH", "-f", f"body={comment}"],
- check=True,
- )
- else:
- subprocess.run(
- ["gh", "pr", "comment", pr_number, "--body", comment],
- check=True,
- )
-
-
-if __name__ == "__main__":
- main()