mirror of
https://github.com/esphome/esphome.git
synced 2026-05-23 03:06:05 +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
|
||||
};
|
||||
@@ -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 }}
|
||||
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: Request reviews from component codeowners
|
||||
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;
|
||||
@@ -38,12 +45,15 @@ jobs:
|
||||
const BOT_COMMENT_MARKER = '<!-- codeowner-review-request-bot -->';
|
||||
|
||||
try {
|
||||
// Get the list of changed files in this PR
|
||||
const { data: files } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
});
|
||||
// Get the list of changed files in this PR (with pagination)
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
}
|
||||
);
|
||||
|
||||
const changedFiles = files.map(file => file.filename);
|
||||
console.log(`Found ${changedFiles.length} changed files`);
|
||||
@@ -53,32 +63,10 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch CODEOWNERS file from root
|
||||
const { data: codeownersFile } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: 'CODEOWNERS',
|
||||
ref: context.payload.pull_request.base.sha
|
||||
});
|
||||
const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
|
||||
// Parse CODEOWNERS from the checked-out base branch
|
||||
const codeownersPatterns = loadCodeowners();
|
||||
|
||||
// Parse CODEOWNERS file to extract all patterns and their owners
|
||||
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 + '$');
|
||||
}
|
||||
console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
|
||||
|
||||
// Helper function to create comment body
|
||||
function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
|
||||
@@ -93,50 +81,11 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of codeownersLines) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const pattern = parts[0];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Match changed files against CODEOWNERS patterns using last-match-wins semantics
|
||||
const effective = getEffectiveOwners(changedFiles, codeownersPatterns);
|
||||
const matchedOwners = effective.users;
|
||||
const matchedTeams = effective.teams;
|
||||
const matchedFileCount = effective.matchedFileCount;
|
||||
|
||||
if (matchedOwners.size === 0 && matchedTeams.size === 0) {
|
||||
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
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
});
|
||||
const reviews = await github.paginate(
|
||||
github.rest.pulls.listReviews,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr_number
|
||||
}
|
||||
);
|
||||
|
||||
const reviewedUsers = new Set();
|
||||
reviews.forEach(review => {
|
||||
@@ -247,7 +199,7 @@ jobs:
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
@@ -279,7 +231,7 @@ jobs:
|
||||
|
||||
// Only add a comment if there are new codeowners to mention (not previously pinged)
|
||||
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({
|
||||
owner,
|
||||
@@ -297,7 +249,7 @@ jobs:
|
||||
|
||||
// Only try to add a comment if there are new codeowners to mention
|
||||
if (reviewersList.length > 0 || teamsList.length > 0) {
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false);
|
||||
const commentBody = createCommentBody(reviewersList, teamsList, matchedFileCount, false);
|
||||
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
Reference in New Issue
Block a user