mirror of
https://github.com/esphome/esphome.git
synced 2026-05-28 21:59:59 +08:00
[ci] Add code-owner-approved label workflow (#14421)
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.11) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.11) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.11) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 1/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 2/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 3/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 4/4 (push) Has been cancelled
CI / Test components batch (${{ matrix.components }}) (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled
Stale / stale (push) Has been cancelled
Lock closed issues and PRs / lock (push) Has been cancelled
Publish Release / Initialize build (push) Has been cancelled
Publish Release / Build and publish to PyPi (push) Has been cancelled
Publish Release / Build ESPHome amd64 (push) Has been cancelled
Publish Release / Build ESPHome arm64 (push) Has been cancelled
Publish Release / Publish ESPHome docker to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome docker to ghcr (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to ghcr (push) Has been cancelled
Publish Release / deploy-ha-addon-repo (push) Has been cancelled
Publish Release / deploy-esphome-schema (push) Has been cancelled
Publish Release / version-notifier (push) Has been cancelled
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.11) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.11) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.11) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 1/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 2/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 3/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 4/4 (push) Has been cancelled
CI / Test components batch (${{ matrix.components }}) (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled
Stale / stale (push) Has been cancelled
Lock closed issues and PRs / lock (push) Has been cancelled
Publish Release / Initialize build (push) Has been cancelled
Publish Release / Build and publish to PyPi (push) Has been cancelled
Publish Release / Build ESPHome amd64 (push) Has been cancelled
Publish Release / Build ESPHome arm64 (push) Has been cancelled
Publish Release / Publish ESPHome docker to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome docker to ghcr (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to ghcr (push) Has been cancelled
Publish Release / deploy-ha-addon-repo (push) Has been cancelled
Publish Release / deploy-esphome-schema (push) Has been cancelled
Publish Release / version-notifier (push) Has been cancelled
This commit is contained in:
@@ -7,6 +7,7 @@ const {
|
|||||||
hasDashboardChanges,
|
hasDashboardChanges,
|
||||||
hasGitHubActionsChanges,
|
hasGitHubActionsChanges,
|
||||||
} = require('../detect-tags');
|
} = require('../detect-tags');
|
||||||
|
const { loadCodeowners, getEffectiveOwners } = require('../codeowners');
|
||||||
|
|
||||||
// Strategy: Merge branch detection
|
// Strategy: Merge branch detection
|
||||||
async function detectMergeBranch(context) {
|
async function detectMergeBranch(context) {
|
||||||
@@ -148,51 +149,15 @@ async function detectGitHubActionsChanges(changedFiles) {
|
|||||||
// Strategy: Code owner detection
|
// Strategy: Code owner detection
|
||||||
async function detectCodeOwner(github, context, changedFiles) {
|
async function detectCodeOwner(github, context, changedFiles) {
|
||||||
const labels = new Set();
|
const labels = new Set();
|
||||||
const { owner, repo } = context.repo;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
const codeownersPatterns = loadCodeowners();
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
path: 'CODEOWNERS',
|
|
||||||
});
|
|
||||||
|
|
||||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
|
|
||||||
const prAuthor = context.payload.pull_request.user.login;
|
const prAuthor = context.payload.pull_request.user.login;
|
||||||
|
|
||||||
const codeownersLines = codeownersContent.split('\n')
|
// Check if PR author is a codeowner of any changed file
|
||||||
.map(line => line.trim())
|
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||||
.filter(line => line && !line.startsWith('#'));
|
if (effective.users.has(prAuthor)) {
|
||||||
|
labels.add('by-code-owner');
|
||||||
const codeownersRegexes = codeownersLines.map(line => {
|
|
||||||
const parts = line.split(/\s+/);
|
|
||||||
const pattern = parts[0];
|
|
||||||
const owners = parts.slice(1);
|
|
||||||
|
|
||||||
let regex;
|
|
||||||
if (pattern.endsWith('*')) {
|
|
||||||
const dir = pattern.slice(0, -1);
|
|
||||||
regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
|
|
||||||
} else if (pattern.includes('*')) {
|
|
||||||
// First escape all regex special chars except *, then replace * with .*
|
|
||||||
const regexPattern = pattern
|
|
||||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
.replace(/\*/g, '.*');
|
|
||||||
regex = new RegExp(`^${regexPattern}$`);
|
|
||||||
} else {
|
|
||||||
regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { regex, owners };
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const file of changedFiles) {
|
|
||||||
for (const { regex, owners } of codeownersRegexes) {
|
|
||||||
if (regex.test(file) && owners.some(owner => owner === `@${prAuthor}`)) {
|
|
||||||
labels.add('by-code-owner');
|
|
||||||
return labels;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Failed to read or parse CODEOWNERS file:', error.message);
|
console.log('Failed to read or parse CODEOWNERS file:', error.message);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Shared CODEOWNERS parsing and matching utilities.
|
||||||
|
//
|
||||||
|
// Used by:
|
||||||
|
// - codeowner-review-request.yml
|
||||||
|
// - codeowner-approved-label.yml
|
||||||
|
// - auto-label-pr/detectors.js (detectCodeOwner)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a CODEOWNERS glob pattern to a RegExp.
|
||||||
|
*
|
||||||
|
* Handles **, *, and ? wildcards after escaping regex-special characters.
|
||||||
|
*/
|
||||||
|
function globToRegex(pattern) {
|
||||||
|
let regexStr = pattern
|
||||||
|
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1')
|
||||||
|
.replace(/\*\*/g, '\x00GLOBSTAR\x00') // protect ** from next replace
|
||||||
|
.replace(/\*/g, '[^/]*') // single star
|
||||||
|
.replace(/\x00GLOBSTAR\x00/g, '.*') // restore globstar
|
||||||
|
.replace(/\?/g, '.');
|
||||||
|
return new RegExp('^' + regexStr + '$');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw CODEOWNERS file content into an array of
|
||||||
|
* { pattern, regex, owners } objects.
|
||||||
|
*
|
||||||
|
* Each `owners` entry is the raw string from the file (e.g. "@user" or
|
||||||
|
* "@esphome/core").
|
||||||
|
*/
|
||||||
|
function parseCodeowners(content) {
|
||||||
|
const lines = content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line && !line.startsWith('#'));
|
||||||
|
|
||||||
|
const patterns = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length < 2) continue;
|
||||||
|
|
||||||
|
const pattern = parts[0];
|
||||||
|
const owners = parts.slice(1);
|
||||||
|
const regex = globToRegex(pattern);
|
||||||
|
patterns.push({ pattern, regex, owners });
|
||||||
|
}
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse the CODEOWNERS file via the GitHub API.
|
||||||
|
*
|
||||||
|
* @param {object} github - octokit instance from actions/github-script
|
||||||
|
* @param {string} owner - repo owner
|
||||||
|
* @param {string} repo - repo name
|
||||||
|
* @param {string} [ref] - git ref (SHA / branch) to read from
|
||||||
|
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
|
||||||
|
*/
|
||||||
|
async function fetchCodeowners(github, owner, repo, ref) {
|
||||||
|
const params = { owner, repo, path: 'CODEOWNERS' };
|
||||||
|
if (ref) params.ref = ref;
|
||||||
|
|
||||||
|
const { data: file } = await github.rest.repos.getContent(params);
|
||||||
|
const content = Buffer.from(file.content, 'base64').toString('utf8');
|
||||||
|
return parseCodeowners(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify raw owner strings into individual users and teams.
|
||||||
|
*
|
||||||
|
* @param {string[]} rawOwners - e.g. ["@user1", "@esphome/core"]
|
||||||
|
* @returns {{ users: string[], teams: string[] }}
|
||||||
|
* users – login names without "@"
|
||||||
|
* teams – team slugs without the "org/" prefix
|
||||||
|
*/
|
||||||
|
function classifyOwners(rawOwners) {
|
||||||
|
const users = [];
|
||||||
|
const teams = [];
|
||||||
|
for (const o of rawOwners) {
|
||||||
|
const clean = o.startsWith('@') ? o.slice(1) : o;
|
||||||
|
if (clean.includes('/')) {
|
||||||
|
teams.push(clean.split('/')[1]);
|
||||||
|
} else {
|
||||||
|
users.push(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { users, teams };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each file, find its effective codeowners using GitHub's
|
||||||
|
* "last match wins" semantics, then union across all files.
|
||||||
|
*
|
||||||
|
* @param {string[]} files - list of file paths
|
||||||
|
* @param {Array} codeownersPatterns - from parseCodeowners / fetchCodeowners
|
||||||
|
* @returns {{ users: Set<string>, teams: Set<string>, matchedFileCount: number }}
|
||||||
|
*/
|
||||||
|
function getEffectiveOwners(files, codeownersPatterns) {
|
||||||
|
const users = new Set();
|
||||||
|
const teams = new Set();
|
||||||
|
let matchedFileCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Last matching pattern wins for each file
|
||||||
|
let effectiveOwners = null;
|
||||||
|
for (const { regex, owners } of codeownersPatterns) {
|
||||||
|
if (regex.test(file)) {
|
||||||
|
effectiveOwners = owners;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (effectiveOwners) {
|
||||||
|
matchedFileCount++;
|
||||||
|
const classified = classifyOwners(effectiveOwners);
|
||||||
|
for (const u of classified.users) users.add(u);
|
||||||
|
for (const t of classified.teams) teams.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { users, teams, matchedFileCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse the CODEOWNERS file from disk.
|
||||||
|
*
|
||||||
|
* Use this when the repo is already checked out (avoids an API call).
|
||||||
|
*
|
||||||
|
* @param {string} [repoRoot='.'] - path to the repo root
|
||||||
|
* @returns {Array<{pattern: string, regex: RegExp, owners: string[]}>}
|
||||||
|
*/
|
||||||
|
function loadCodeowners(repoRoot = '.') {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const content = fs.readFileSync(path.join(repoRoot, 'CODEOWNERS'), 'utf8');
|
||||||
|
return parseCodeowners(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
globToRegex,
|
||||||
|
parseCodeowners,
|
||||||
|
fetchCodeowners,
|
||||||
|
loadCodeowners,
|
||||||
|
classifyOwners,
|
||||||
|
getEffectiveOwners
|
||||||
|
};
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# This workflow adds/removes a 'code-owner-approved' label when a
|
||||||
|
# component-specific codeowner approves (or dismisses) a PR.
|
||||||
|
# This helps maintainers prioritize PRs that have codeowner sign-off.
|
||||||
|
#
|
||||||
|
# Only component-specific codeowners count — the catch-all @esphome/core
|
||||||
|
# team is excluded so the label reflects domain-expert approval.
|
||||||
|
|
||||||
|
name: Codeowner Approved Label
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted, dismissed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
codeowner-approved:
|
||||||
|
name: Run
|
||||||
|
if: ${{ github.repository == 'esphome/esphome' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout base branch
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.base.sha }}
|
||||||
|
|
||||||
|
- name: Check codeowner approval and update label
|
||||||
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
|
||||||
|
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const pr_number = context.payload.pull_request.number;
|
||||||
|
const LABEL_NAME = 'code-owner-approved';
|
||||||
|
|
||||||
|
console.log(`Processing PR #${pr_number} for codeowner approval label`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the list of changed files in this PR (with pagination)
|
||||||
|
const prFiles = await github.paginate(
|
||||||
|
github.rest.pulls.listFiles,
|
||||||
|
{
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pr_number
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const changedFiles = prFiles.map(file => file.filename);
|
||||||
|
console.log(`Found ${changedFiles.length} changed files`);
|
||||||
|
|
||||||
|
if (changedFiles.length === 0) {
|
||||||
|
console.log('No changed files found, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CODEOWNERS from the checked-out base branch
|
||||||
|
const codeownersPatterns = loadCodeowners();
|
||||||
|
|
||||||
|
// Get effective owners using last-match-wins semantics
|
||||||
|
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||||
|
|
||||||
|
// Only keep individual component-specific codeowners (exclude teams)
|
||||||
|
const componentCodeowners = effective.users;
|
||||||
|
|
||||||
|
console.log(`Component-specific codeowners for changed files: ${Array.from(componentCodeowners).join(', ') || '(none)'}`);
|
||||||
|
|
||||||
|
if (componentCodeowners.size === 0) {
|
||||||
|
console.log('No component-specific codeowners found for changed files');
|
||||||
|
// Remove label if present since there are no component codeowners
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pr_number,
|
||||||
|
name: LABEL_NAME
|
||||||
|
});
|
||||||
|
console.log(`Removed '${LABEL_NAME}' label (no component codeowners)`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
console.log(`Failed to remove label: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all reviews on the PR
|
||||||
|
const reviews = await github.paginate(
|
||||||
|
github.rest.pulls.listReviews,
|
||||||
|
{
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
pull_number: pr_number
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the latest review per user (reviews are returned chronologically)
|
||||||
|
const latestReviewByUser = new Map();
|
||||||
|
for (const review of reviews) {
|
||||||
|
// Skip bot reviews and comment-only reviews
|
||||||
|
if (!review.user || review.user.type === 'Bot' || review.state === 'COMMENTED') continue;
|
||||||
|
latestReviewByUser.set(review.user.login, review);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any component-specific codeowner has an active approval
|
||||||
|
let hasCodeownerApproval = false;
|
||||||
|
for (const [login, review] of latestReviewByUser) {
|
||||||
|
if (review.state === 'APPROVED' && componentCodeowners.has(login)) {
|
||||||
|
console.log(`Codeowner '${login}' has approved`);
|
||||||
|
hasCodeownerApproval = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current labels to check if label is already present
|
||||||
|
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pr_number
|
||||||
|
});
|
||||||
|
const hasLabel = currentLabels.some(label => label.name === LABEL_NAME);
|
||||||
|
|
||||||
|
if (hasCodeownerApproval && !hasLabel) {
|
||||||
|
// Add the label
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pr_number,
|
||||||
|
labels: [LABEL_NAME]
|
||||||
|
});
|
||||||
|
console.log(`Added '${LABEL_NAME}' label`);
|
||||||
|
} else if (!hasCodeownerApproval && hasLabel) {
|
||||||
|
// Remove the label
|
||||||
|
try {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: pr_number,
|
||||||
|
name: LABEL_NAME
|
||||||
|
});
|
||||||
|
console.log(`Removed '${LABEL_NAME}' label`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status !== 404) {
|
||||||
|
console.log(`Failed to remove label: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Label already ${hasLabel ? 'present' : 'absent'}, no change needed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
core.setFailed(`Failed to process codeowner approval label: ${error.message}`);
|
||||||
|
}
|
||||||
@@ -24,10 +24,17 @@ jobs:
|
|||||||
if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }}
|
if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout base branch
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.base.sha }}
|
||||||
|
|
||||||
- name: Request reviews from component codeowners
|
- name: Request reviews from component codeowners
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
|
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
|
||||||
|
|
||||||
const owner = context.repo.owner;
|
const owner = context.repo.owner;
|
||||||
const repo = context.repo.repo;
|
const repo = context.repo.repo;
|
||||||
const pr_number = context.payload.pull_request.number;
|
const pr_number = context.payload.pull_request.number;
|
||||||
@@ -38,12 +45,15 @@ jobs:
|
|||||||
const BOT_COMMENT_MARKER = '<!-- codeowner-review-request-bot -->';
|
const BOT_COMMENT_MARKER = '<!-- codeowner-review-request-bot -->';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the list of changed files in this PR
|
// Get the list of changed files in this PR (with pagination)
|
||||||
const { data: files } = await github.rest.pulls.listFiles({
|
const files = await github.paginate(
|
||||||
owner,
|
github.rest.pulls.listFiles,
|
||||||
repo,
|
{
|
||||||
pull_number: pr_number
|
owner,
|
||||||
});
|
repo,
|
||||||
|
pull_number: pr_number
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const changedFiles = files.map(file => file.filename);
|
const changedFiles = files.map(file => file.filename);
|
||||||
console.log(`Found ${changedFiles.length} changed files`);
|
console.log(`Found ${changedFiles.length} changed files`);
|
||||||
@@ -53,32 +63,10 @@ jobs:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch CODEOWNERS file from root
|
// Parse CODEOWNERS from the checked-out base branch
|
||||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
const codeownersPatterns = loadCodeowners();
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
path: 'CODEOWNERS',
|
|
||||||
ref: context.payload.pull_request.base.sha
|
|
||||||
});
|
|
||||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
|
|
||||||
|
|
||||||
// Parse CODEOWNERS file to extract all patterns and their owners
|
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
|
||||||
const codeownersLines = codeownersContent.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line && !line.startsWith('#'));
|
|
||||||
|
|
||||||
const codeownersPatterns = [];
|
|
||||||
|
|
||||||
// Convert CODEOWNERS pattern to regex (robust glob handling)
|
|
||||||
function globToRegex(pattern) {
|
|
||||||
// Escape regex special characters except for glob wildcards
|
|
||||||
let regexStr = pattern
|
|
||||||
.replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars
|
|
||||||
.replace(/\*\*/g, '.*') // globstar
|
|
||||||
.replace(/\*/g, '[^/]*') // single star
|
|
||||||
.replace(/\?/g, '.'); // question mark
|
|
||||||
return new RegExp('^' + regexStr + '$');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create comment body
|
// Helper function to create comment body
|
||||||
function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
|
function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
|
||||||
@@ -93,50 +81,11 @@ jobs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const line of codeownersLines) {
|
// Match changed files against CODEOWNERS patterns using last-match-wins semantics
|
||||||
const parts = line.split(/\s+/);
|
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||||
if (parts.length < 2) continue;
|
const matchedOwners = effective.users;
|
||||||
|
const matchedTeams = effective.teams;
|
||||||
const pattern = parts[0];
|
const matchedFileCount = effective.matchedFileCount;
|
||||||
const owners = parts.slice(1);
|
|
||||||
|
|
||||||
// Use robust glob-to-regex conversion
|
|
||||||
const regex = globToRegex(pattern);
|
|
||||||
codeownersPatterns.push({ pattern, regex, owners });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
|
|
||||||
|
|
||||||
// Match changed files against CODEOWNERS patterns
|
|
||||||
const matchedOwners = new Set();
|
|
||||||
const matchedTeams = new Set();
|
|
||||||
const fileMatches = new Map(); // Track which files matched which patterns
|
|
||||||
|
|
||||||
for (const file of changedFiles) {
|
|
||||||
for (const { pattern, regex, owners } of codeownersPatterns) {
|
|
||||||
if (regex.test(file)) {
|
|
||||||
console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`);
|
|
||||||
|
|
||||||
if (!fileMatches.has(file)) {
|
|
||||||
fileMatches.set(file, []);
|
|
||||||
}
|
|
||||||
fileMatches.get(file).push({ pattern, owners });
|
|
||||||
|
|
||||||
// Add owners to the appropriate set (remove @ prefix)
|
|
||||||
for (const owner of owners) {
|
|
||||||
const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner;
|
|
||||||
if (cleanOwner.includes('/')) {
|
|
||||||
// Team mention (org/team-name)
|
|
||||||
const teamName = cleanOwner.split('/')[1];
|
|
||||||
matchedTeams.add(teamName);
|
|
||||||
} else {
|
|
||||||
// Individual user
|
|
||||||
matchedOwners.add(cleanOwner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedOwners.size === 0 && matchedTeams.size === 0) {
|
if (matchedOwners.size === 0 && matchedTeams.size === 0) {
|
||||||
console.log('No codeowners found for any changed files');
|
console.log('No codeowners found for any changed files');
|
||||||
@@ -170,11 +119,14 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for completed reviews to avoid re-requesting users who have already reviewed
|
// Check for completed reviews to avoid re-requesting users who have already reviewed
|
||||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
const reviews = await github.paginate(
|
||||||
owner,
|
github.rest.pulls.listReviews,
|
||||||
repo,
|
{
|
||||||
pull_number: pr_number
|
owner,
|
||||||
});
|
repo,
|
||||||
|
pull_number: pr_number
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const reviewedUsers = new Set();
|
const reviewedUsers = new Set();
|
||||||
reviews.forEach(review => {
|
reviews.forEach(review => {
|
||||||
@@ -247,7 +199,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalReviewers = reviewersList.length + teamsList.length;
|
const totalReviewers = reviewersList.length + teamsList.length;
|
||||||
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`);
|
console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${matchedFileCount} matched files`);
|
||||||
|
|
||||||
// Request reviews
|
// Request reviews
|
||||||
try {
|
try {
|
||||||
@@ -279,7 +231,7 @@ jobs:
|
|||||||
|
|
||||||
// Only add a comment if there are new codeowners to mention (not previously pinged)
|
// Only add a comment if there are new codeowners to mention (not previously pinged)
|
||||||
if (reviewersList.length > 0 || teamsList.length > 0) {
|
if (reviewersList.length > 0 || teamsList.length > 0) {
|
||||||
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true);
|
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, true);
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
owner,
|
owner,
|
||||||
@@ -297,7 +249,7 @@ jobs:
|
|||||||
|
|
||||||
// Only try to add a comment if there are new codeowners to mention
|
// Only try to add a comment if there are new codeowners to mention
|
||||||
if (reviewersList.length > 0 || teamsList.length > 0) {
|
if (reviewersList.length > 0 || teamsList.length > 0) {
|
||||||
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false);
|
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
|
|||||||
Reference in New Issue
Block a user