mirror of
https://github.com/esphome/esphome.git
synced 2026-05-19 23:07:22 +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,
|
||||
hasGitHubActionsChanges,
|
||||
} = require('../detect-tags');
|
||||
const { loadCodeowners, getEffectiveOwners } = require('../codeowners');
|
||||
|
||||
// Strategy: Merge branch detection
|
||||
async function detectMergeBranch(context) {
|
||||
@@ -148,51 +149,15 @@ async function detectGitHubActionsChanges(changedFiles) {
|
||||
// Strategy: Code owner detection
|
||||
async function detectCodeOwner(github, context, changedFiles) {
|
||||
const labels = new Set();
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: 'CODEOWNERS',
|
||||
});
|
||||
|
||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
|
||||
const codeownersPatterns = loadCodeowners();
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
|
||||
const codeownersLines = codeownersContent.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Check if PR author is a codeowner of any changed file
|
||||
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||
if (effective.users.has(prAuthor)) {
|
||||
labels.add('by-code-owner');
|
||||
}
|
||||
} catch (error) {
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user