mirror of
https://github.com/hwchase17/langchain.git
synced 2026-03-18 11:07:36 +00:00
Consolidate four separate PR labeling workflows (`pr_labeler_file.yml`, `pr_labeler_title.yml`, `pr_size_labeler.yml`, and the PR-handling half of `tag-external-contributions.yml`) into a single `pr_labeler.yml` workflow. The old workflows raced against each other — concurrent label mutations could drop or duplicate labels depending on execution order. A unified workflow with concurrency grouping eliminates that class of bug.
272 lines
9.0 KiB
JavaScript
272 lines
9.0 KiB
JavaScript
// Shared helpers for pr_labeler.yml and tag-external-issues.yml.
|
|
//
|
|
// Usage from actions/github-script (requires actions/checkout first):
|
|
// const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo);
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
function loadConfig() {
|
|
const configPath = path.join(__dirname, 'pr-labeler-config.json');
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(configPath, 'utf8');
|
|
} catch (e) {
|
|
throw new Error(`Failed to read ${configPath}: ${e.message}`);
|
|
}
|
|
let config;
|
|
try {
|
|
config = JSON.parse(raw);
|
|
} catch (e) {
|
|
throw new Error(`Failed to parse pr-labeler-config.json: ${e.message}`);
|
|
}
|
|
const required = [
|
|
'labelColor', 'sizeThresholds', 'fileRules',
|
|
'typeToLabel', 'scopeToLabel', 'trustedThreshold',
|
|
'excludedFiles', 'excludedPaths',
|
|
];
|
|
const missing = required.filter(k => !(k in config));
|
|
if (missing.length > 0) {
|
|
throw new Error(`pr-labeler-config.json missing required keys: ${missing.join(', ')}`);
|
|
}
|
|
return config;
|
|
}
|
|
|
|
function init(github, owner, repo, config) {
|
|
const {
|
|
trustedThreshold,
|
|
labelColor,
|
|
sizeThresholds,
|
|
scopeToLabel,
|
|
typeToLabel,
|
|
fileRules: fileRulesDef,
|
|
excludedFiles,
|
|
excludedPaths,
|
|
} = config;
|
|
|
|
const sizeLabels = sizeThresholds.map(t => t.label);
|
|
const allTypeLabels = [...new Set(Object.values(typeToLabel))];
|
|
const tierLabels = ['new-contributor', 'trusted-contributor'];
|
|
|
|
// ── Label management ──────────────────────────────────────────────
|
|
|
|
async function ensureLabel(name, color = labelColor) {
|
|
try {
|
|
await github.rest.issues.getLabel({ owner, repo, name });
|
|
} catch (e) {
|
|
if (e.status !== 404) throw e;
|
|
try {
|
|
await github.rest.issues.createLabel({ owner, repo, name, color });
|
|
} catch (createErr) {
|
|
// 422 = label created by a concurrent run between our get and create
|
|
if (createErr.status !== 422) throw createErr;
|
|
const core = require('@actions/core');
|
|
core.info(`Label "${name}" creation returned 422 (likely already exists)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Size calculation ──────────────────────────────────────────────
|
|
|
|
function getSizeLabel(totalChanged) {
|
|
for (const t of sizeThresholds) {
|
|
if (t.max != null && totalChanged < t.max) return t.label;
|
|
}
|
|
// Last entry has no max — it's the catch-all
|
|
return sizeThresholds[sizeThresholds.length - 1].label;
|
|
}
|
|
|
|
function computeSize(files) {
|
|
const excluded = new Set(excludedFiles);
|
|
const totalChanged = files.reduce((sum, f) => {
|
|
const p = f.filename ?? '';
|
|
const base = p.split('/').pop();
|
|
if (excluded.has(base)) return sum;
|
|
for (const prefix of excludedPaths) {
|
|
if (p.startsWith(prefix)) return sum;
|
|
}
|
|
return sum + (f.additions ?? 0) + (f.deletions ?? 0);
|
|
}, 0);
|
|
return { totalChanged, sizeLabel: getSizeLabel(totalChanged) };
|
|
}
|
|
|
|
// ── File-based labels ─────────────────────────────────────────────
|
|
|
|
function buildFileRules() {
|
|
return fileRulesDef.map((rule, i) => {
|
|
let test;
|
|
if (rule.prefix) test = p => p.startsWith(rule.prefix);
|
|
else if (rule.suffix) test = p => p.endsWith(rule.suffix);
|
|
else if (rule.exact) test = p => p === rule.exact;
|
|
else if (rule.pattern) {
|
|
const re = new RegExp(rule.pattern);
|
|
test = p => re.test(p);
|
|
} else {
|
|
throw new Error(
|
|
`fileRules[${i}] (label: "${rule.label}") has no recognized matcher ` +
|
|
`(expected one of: prefix, suffix, exact, pattern)`
|
|
);
|
|
}
|
|
return { label: rule.label, test };
|
|
});
|
|
}
|
|
|
|
function matchFileLabels(files, fileRules) {
|
|
const rules = fileRules || buildFileRules();
|
|
const labels = new Set();
|
|
for (const rule of rules) {
|
|
if (files.some(f => rule.test(f.filename ?? ''))) {
|
|
labels.add(rule.label);
|
|
}
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
// ── Title-based labels ────────────────────────────────────────────
|
|
|
|
function matchTitleLabels(title) {
|
|
const labels = new Set();
|
|
const m = (title ?? '').match(/^(\w+)(?:\(([^)]+)\))?(!)?:/);
|
|
if (!m) return { labels, type: null, typeLabel: null, scopes: [], breaking: false };
|
|
|
|
const type = m[1].toLowerCase();
|
|
const scopeStr = m[2] ?? '';
|
|
const breaking = !!m[3];
|
|
|
|
const typeLabel = typeToLabel[type] || null;
|
|
if (typeLabel) labels.add(typeLabel);
|
|
if (breaking) labels.add('breaking');
|
|
|
|
const scopes = scopeStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
for (const scope of scopes) {
|
|
const sl = scopeToLabel[scope];
|
|
if (sl) labels.add(sl);
|
|
}
|
|
|
|
return { labels, type, typeLabel, scopes, breaking };
|
|
}
|
|
|
|
// ── Org membership ────────────────────────────────────────────────
|
|
|
|
async function checkMembership(author, userType) {
|
|
if (userType === 'Bot') {
|
|
console.log(`${author} is a Bot — treating as internal`);
|
|
return { isExternal: false };
|
|
}
|
|
|
|
try {
|
|
const membership = await github.rest.orgs.getMembershipForUser({
|
|
org: 'langchain-ai',
|
|
username: author,
|
|
});
|
|
const isExternal = membership.data.state !== 'active';
|
|
console.log(
|
|
isExternal
|
|
? `${author} has pending membership — treating as external`
|
|
: `${author} is an active member of langchain-ai`,
|
|
);
|
|
return { isExternal };
|
|
} catch (e) {
|
|
if (e.status === 404) {
|
|
console.log(`${author} is not a member of langchain-ai`);
|
|
return { isExternal: true };
|
|
}
|
|
// Non-404 errors (rate limit, auth failure, server error) must not
|
|
// silently default to external — rethrow to fail the step.
|
|
throw new Error(
|
|
`Membership check failed for ${author} (${e.status}): ${e.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Contributor analysis ──────────────────────────────────────────
|
|
|
|
async function getContributorInfo(contributorCache, author, userType) {
|
|
if (contributorCache.has(author)) return contributorCache.get(author);
|
|
|
|
const { isExternal } = await checkMembership(author, userType);
|
|
|
|
let mergedCount = null;
|
|
if (isExternal) {
|
|
try {
|
|
const result = await github.rest.search.issuesAndPullRequests({
|
|
q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,
|
|
per_page: 1,
|
|
});
|
|
mergedCount = result?.data?.total_count ?? null;
|
|
} catch (e) {
|
|
if (e?.status !== 422) throw e;
|
|
const core = require('@actions/core');
|
|
core.warning(`Search failed for ${author}; skipping tier.`);
|
|
}
|
|
}
|
|
|
|
const info = { isExternal, mergedCount };
|
|
contributorCache.set(author, info);
|
|
return info;
|
|
}
|
|
|
|
// ── Tier label resolution ───────────────────────────────────────────
|
|
|
|
async function applyTierLabel(issueNumber, author, { skipNewContributor = false } = {}) {
|
|
const core = require('@actions/core');
|
|
let mergedCount;
|
|
try {
|
|
const result = await github.rest.search.issuesAndPullRequests({
|
|
q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,
|
|
per_page: 1,
|
|
});
|
|
mergedCount = result?.data?.total_count;
|
|
} catch (error) {
|
|
if (error?.status !== 422) throw error;
|
|
core.warning(`Search failed for ${author}; skipping tier label.`);
|
|
return;
|
|
}
|
|
|
|
if (mergedCount == null) {
|
|
core.warning(`Search response missing total_count for ${author}; skipping tier label.`);
|
|
return;
|
|
}
|
|
|
|
let tierLabel = null;
|
|
if (mergedCount >= trustedThreshold) tierLabel = 'trusted-contributor';
|
|
else if (mergedCount === 0 && !skipNewContributor) tierLabel = 'new-contributor';
|
|
|
|
if (tierLabel) {
|
|
await ensureLabel(tierLabel);
|
|
await github.rest.issues.addLabels({
|
|
owner, repo, issue_number: issueNumber, labels: [tierLabel],
|
|
});
|
|
console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`);
|
|
} else {
|
|
console.log(`No tier label for ${author} (${mergedCount} merged PRs)`);
|
|
}
|
|
|
|
return tierLabel;
|
|
}
|
|
|
|
return {
|
|
ensureLabel,
|
|
getSizeLabel,
|
|
computeSize,
|
|
buildFileRules,
|
|
matchFileLabels,
|
|
matchTitleLabels,
|
|
allTypeLabels,
|
|
checkMembership,
|
|
getContributorInfo,
|
|
applyTierLabel,
|
|
sizeLabels,
|
|
tierLabels,
|
|
trustedThreshold,
|
|
labelColor,
|
|
};
|
|
}
|
|
|
|
function loadAndInit(github, owner, repo) {
|
|
const config = loadConfig();
|
|
return { config, h: init(github, owner, repo, config) };
|
|
}
|
|
|
|
module.exports = { loadConfig, init, loadAndInit };
|