Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/OWNERS
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions .github/scripts/owners.js
Original file line number Diff line number Diff line change
@@ -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 };
83 changes: 83 additions & 0 deletions .github/workflows/maintainer-approval.js
Original file line number Diff line number Diff line change
@@ -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(", ")}.`
);
};
32 changes: 32 additions & 0 deletions .github/workflows/maintainer-approval.yml
Original file line number Diff line number Diff line change
@@ -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 });
Loading
Loading