mirror of
https://github.com/hwchase17/langchain.git
synced 2026-03-18 11:07:36 +00:00
ci: consolidate PR labeling into unified workflows (#36042)
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.
This commit is contained in:
84
.github/scripts/pr-labeler-config.json
vendored
Normal file
84
.github/scripts/pr-labeler-config.json
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"trustedThreshold": 5,
|
||||
"labelColor": "b76e79",
|
||||
"sizeThresholds": [
|
||||
{ "label": "size: XS", "max": 50 },
|
||||
{ "label": "size: S", "max": 200 },
|
||||
{ "label": "size: M", "max": 500 },
|
||||
{ "label": "size: L", "max": 1000 },
|
||||
{ "label": "size: XL" }
|
||||
],
|
||||
"excludedFiles": ["uv.lock"],
|
||||
"excludedPaths": ["docs/"],
|
||||
"typeToLabel": {
|
||||
"feat": "feature",
|
||||
"fix": "fix",
|
||||
"docs": "documentation",
|
||||
"style": "linting",
|
||||
"refactor": "refactor",
|
||||
"perf": "performance",
|
||||
"test": "tests",
|
||||
"build": "infra",
|
||||
"ci": "infra",
|
||||
"chore": "infra",
|
||||
"revert": "revert",
|
||||
"release": "release",
|
||||
"hotfix": "hotfix",
|
||||
"breaking": "breaking"
|
||||
},
|
||||
"scopeToLabel": {
|
||||
"core": "core",
|
||||
"langchain": "langchain",
|
||||
"langchain-classic": "langchain-classic",
|
||||
"model-profiles": "model-profiles",
|
||||
"standard-tests": "standard-tests",
|
||||
"text-splitters": "text-splitters",
|
||||
"anthropic": "anthropic",
|
||||
"chroma": "chroma",
|
||||
"deepseek": "deepseek",
|
||||
"exa": "exa",
|
||||
"fireworks": "fireworks",
|
||||
"groq": "groq",
|
||||
"huggingface": "huggingface",
|
||||
"mistralai": "mistralai",
|
||||
"nomic": "nomic",
|
||||
"ollama": "ollama",
|
||||
"openai": "openai",
|
||||
"openrouter": "openrouter",
|
||||
"perplexity": "perplexity",
|
||||
"qdrant": "qdrant",
|
||||
"xai": "xai",
|
||||
"deps": "dependencies",
|
||||
"docs": "documentation",
|
||||
"infra": "infra"
|
||||
},
|
||||
"fileRules": [
|
||||
{ "label": "core", "prefix": "libs/core/" },
|
||||
{ "label": "langchain-classic", "prefix": "libs/langchain/" },
|
||||
{ "label": "langchain", "prefix": "libs/langchain_v1/" },
|
||||
{ "label": "standard-tests", "prefix": "libs/standard-tests/" },
|
||||
{ "label": "model-profiles", "prefix": "libs/model-profiles/" },
|
||||
{ "label": "text-splitters", "prefix": "libs/text-splitters/" },
|
||||
{ "label": "integration", "prefix": "libs/partners/" },
|
||||
{ "label": "anthropic", "prefix": "libs/partners/anthropic/" },
|
||||
{ "label": "chroma", "prefix": "libs/partners/chroma/" },
|
||||
{ "label": "deepseek", "prefix": "libs/partners/deepseek/" },
|
||||
{ "label": "exa", "prefix": "libs/partners/exa/" },
|
||||
{ "label": "fireworks", "prefix": "libs/partners/fireworks/" },
|
||||
{ "label": "groq", "prefix": "libs/partners/groq/" },
|
||||
{ "label": "huggingface", "prefix": "libs/partners/huggingface/" },
|
||||
{ "label": "mistralai", "prefix": "libs/partners/mistralai/" },
|
||||
{ "label": "nomic", "prefix": "libs/partners/nomic/" },
|
||||
{ "label": "ollama", "prefix": "libs/partners/ollama/" },
|
||||
{ "label": "openai", "prefix": "libs/partners/openai/" },
|
||||
{ "label": "openrouter", "prefix": "libs/partners/openrouter/" },
|
||||
{ "label": "perplexity", "prefix": "libs/partners/perplexity/" },
|
||||
{ "label": "qdrant", "prefix": "libs/partners/qdrant/" },
|
||||
{ "label": "xai", "prefix": "libs/partners/xai/" },
|
||||
{ "label": "github_actions", "prefix": ".github/workflows/" },
|
||||
{ "label": "github_actions", "prefix": ".github/actions/" },
|
||||
{ "label": "dependencies", "suffix": "pyproject.toml" },
|
||||
{ "label": "dependencies", "exact": "uv.lock" },
|
||||
{ "label": "dependencies", "pattern": "(?:^|/)requirements[^/]*\\.txt$" }
|
||||
]
|
||||
}
|
||||
271
.github/scripts/pr-labeler.js
vendored
Normal file
271
.github/scripts/pr-labeler.js
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user