286 lines
8.6 KiB
JavaScript
286 lines
8.6 KiB
JavaScript
'use strict';
|
|
|
|
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
|
|
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
|
|
|
|
const PRODUCT_GROUPS = [
|
|
{
|
|
primary: 'wxianfeng',
|
|
backup: 'typefield',
|
|
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
|
|
},
|
|
{
|
|
primary: 'typefield',
|
|
backup: 'wxianfeng',
|
|
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
|
|
},
|
|
{
|
|
primary: 'haofeng0705',
|
|
backup: 'typefield',
|
|
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
|
|
},
|
|
];
|
|
|
|
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
|
|
|
|
const MODULES = [
|
|
{
|
|
id: 'security',
|
|
label: '登录、认证、权限、安全',
|
|
primary: 'hlzjsong',
|
|
backup: 'typefield',
|
|
requiresSecondary: true,
|
|
matches: pathStartsWith([
|
|
'internal/auth/',
|
|
'internal/keychain/',
|
|
'internal/audit/',
|
|
'internal/pat/',
|
|
'internal/security/',
|
|
'internal/safety/',
|
|
'pkg/edition/',
|
|
]),
|
|
},
|
|
{
|
|
id: 'delivery',
|
|
label: 'CI、测试、发布、安装',
|
|
primary: 'haofeng0705',
|
|
backup: 'wxianfeng',
|
|
requiresSecondary: true,
|
|
matches: (path) =>
|
|
path.startsWith('.github/') ||
|
|
path.startsWith('scripts/release/') ||
|
|
path.startsWith('scripts/policy/') ||
|
|
path.startsWith('scripts/dev/') ||
|
|
path.startsWith('scripts/install') ||
|
|
path.startsWith('Formula/') ||
|
|
path.startsWith('build/') ||
|
|
path.startsWith('internal/upgrade/') ||
|
|
path.startsWith('internal/app/upgrade') ||
|
|
path.startsWith('test/') ||
|
|
path.startsWith('verify/') ||
|
|
path.startsWith('.workflow/') ||
|
|
path === 'coverage.txt' ||
|
|
path === 'coverage-base.txt' ||
|
|
path === '.goreleaser.yaml' ||
|
|
path === 'package.json' ||
|
|
path === 'package-lock.json' ||
|
|
path === 'docs/releasing.md',
|
|
},
|
|
{
|
|
id: 'architecture',
|
|
label: 'DWS 架构、公共内核',
|
|
primary: 'wxianfeng',
|
|
backup: 'typefield',
|
|
requiresSecondary: true,
|
|
matches: pathStartsWith([
|
|
'cmd/',
|
|
'internal/apiclient/',
|
|
'internal/app/',
|
|
'internal/cli/',
|
|
'internal/cobracmd/',
|
|
'internal/corecmd/',
|
|
'internal/errors/',
|
|
'internal/executor/',
|
|
'internal/generator/',
|
|
'internal/i18n/',
|
|
'internal/interfacesnapshot/',
|
|
'internal/jsonutil/',
|
|
'internal/localio/',
|
|
'internal/logging/',
|
|
'internal/output/',
|
|
'internal/pipeline/',
|
|
'internal/plugin/',
|
|
'internal/profilectx/',
|
|
'internal/registry/',
|
|
'internal/syncdata/',
|
|
'internal/testseam/',
|
|
'internal/transport/',
|
|
'pkg/',
|
|
]),
|
|
},
|
|
{
|
|
id: 'compatibility',
|
|
label: '兼容性',
|
|
primary: 'wxianfeng',
|
|
backup: 'typefield',
|
|
requiresSecondary: true,
|
|
matches: (path) =>
|
|
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
|
|
path.includes('schema_compat'),
|
|
},
|
|
];
|
|
|
|
function productMatches(path, product) {
|
|
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
|
|
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
|
|
}
|
|
|
|
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
|
|
group.products.map((product) => ({
|
|
id: `product:${product}`,
|
|
label: `产品:${product}`,
|
|
primary: group.primary,
|
|
backup: group.backup,
|
|
requiresSecondary: false,
|
|
matches: (path) => productMatches(path, product),
|
|
})),
|
|
);
|
|
|
|
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
|
|
|
|
function normalizedPaths(file) {
|
|
return [file?.filename, file?.previous_filename]
|
|
.filter((path) => typeof path === 'string' && path !== '')
|
|
.map((path) => path.toLowerCase());
|
|
}
|
|
|
|
function compareStats(left, right) {
|
|
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
|
|
}
|
|
|
|
function classifyFiles(files) {
|
|
const counts = new Map();
|
|
for (const file of files || []) {
|
|
const matchingModules = new Set();
|
|
for (const path of normalizedPaths(file)) {
|
|
const matches = ALL_MODULES.filter((module) => module.matches(path));
|
|
const securityOrDelivery = matches.filter(
|
|
(module) => module.id === 'security' || module.id === 'delivery',
|
|
);
|
|
const effectiveMatches = securityOrDelivery.length > 0
|
|
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
|
|
: matches;
|
|
for (const match of effectiveMatches) {
|
|
matchingModules.add(match.id);
|
|
}
|
|
if (
|
|
effectiveMatches.length === 0 &&
|
|
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
|
|
) {
|
|
matchingModules.add('architecture');
|
|
}
|
|
}
|
|
for (const moduleID of matchingModules) {
|
|
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
return [...counts.entries()]
|
|
.map(([id, files]) => {
|
|
const index = ALL_MODULES.findIndex((module) => module.id === id);
|
|
return {module: {...ALL_MODULES[index], order: index}, files};
|
|
})
|
|
.sort(compareStats);
|
|
}
|
|
|
|
function chooseModuleReviewer(module, unavailable) {
|
|
return [module.primary, module.backup].find(
|
|
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
|
|
);
|
|
}
|
|
|
|
function addReviewer(reviewers, reviewer) {
|
|
if (reviewer && !reviewers.includes(reviewer)) {
|
|
reviewers.push(reviewer);
|
|
}
|
|
}
|
|
|
|
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
|
|
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
|
|
const candidates = [];
|
|
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
|
|
if (
|
|
eligible.has(reviewer.toLowerCase()) &&
|
|
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
|
|
) {
|
|
candidates.push(reviewer);
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
async function requestReviewersWithFallback({
|
|
candidates,
|
|
requiredReviewers,
|
|
satisfiedReviewers = [],
|
|
requestReviewer,
|
|
onFailure = () => {},
|
|
}) {
|
|
const alreadySatisfied = new Set(
|
|
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
|
|
);
|
|
const satisfied = new Set();
|
|
const requested = [];
|
|
|
|
for (const reviewer of candidates) {
|
|
if (satisfied.size >= requiredReviewers) {
|
|
break;
|
|
}
|
|
const normalizedReviewer = reviewer.toLowerCase();
|
|
if (alreadySatisfied.has(normalizedReviewer)) {
|
|
satisfied.add(normalizedReviewer);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const shouldContinue = await requestReviewer(reviewer);
|
|
if (shouldContinue === false) {
|
|
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
|
|
}
|
|
requested.push(reviewer);
|
|
satisfied.add(normalizedReviewer);
|
|
} catch (error) {
|
|
onFailure(reviewer, error);
|
|
}
|
|
}
|
|
|
|
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
|
|
}
|
|
|
|
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
|
|
const modules = classifyFiles(files);
|
|
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
|
|
const reviewers = [];
|
|
const primaryModule = modules[0];
|
|
|
|
if (!primaryModule) {
|
|
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
|
|
}
|
|
|
|
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
|
|
|
|
const requiresSecondary =
|
|
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
|
|
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
|
|
if (requiresSecondary) {
|
|
addReviewer(
|
|
reviewers,
|
|
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
|
|
);
|
|
}
|
|
|
|
for (const reviewer of fallbackReviewers) {
|
|
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
|
|
break;
|
|
}
|
|
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
|
|
addReviewer(reviewers, reviewer);
|
|
}
|
|
}
|
|
|
|
return {
|
|
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
|
|
reviewers,
|
|
requiredReviewers: requiresSecondary ? 2 : 1,
|
|
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
REVIEWER_POOL,
|
|
classifyFiles,
|
|
requestReviewersWithFallback,
|
|
resolveReviewRouting,
|
|
reviewerCandidates,
|
|
};
|