Use strict equality (===) to prevent type coercion bugs
if (e.status !== 404) throw e;
1// Shared helpers for pr_labeler.yml and tag-external-issues.yml.2//3// Usage from actions/github-script (requires actions/checkout first):4// const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);56const fs = require('fs');7const path = require('path');89function loadConfig() {10 const configPath = path.join(__dirname, 'pr-labeler-config.json');11 let raw;12 try {13 raw = fs.readFileSync(configPath, 'utf8');14 } catch (e) {15 throw new Error(`Failed to read ${configPath}: ${e.message}`);16 }17 let config;18 try {19 config = JSON.parse(raw);20 } catch (e) {21 throw new Error(`Failed to parse pr-labeler-config.json: ${e.message}`);22 }23 const required = [24 'labelColor', 'sizeThresholds', 'fileRules',25 'typeToLabel', 'scopeToLabel', 'trustedThreshold',26 'excludedFiles', 'excludedPaths',27 ];28 const missing = required.filter(k => !(k in config));29 if (missing.length > 0) {30 throw new Error(`pr-labeler-config.json missing required keys: ${missing.join(', ')}`);31 }32 return config;33}3435function init(github, owner, repo, config, core) {36 if (!core) {37 throw new Error('init() requires a `core` parameter (e.g., from actions/github-script)');38 }39 const {40 trustedThreshold,41 labelColor,42 sizeThresholds,43 scopeToLabel,44 typeToLabel,45 fileRules: fileRulesDef,46 excludedFiles,47 excludedPaths,48 } = config;4950 const sizeLabels = sizeThresholds.map(t => t.label);51 const allTypeLabels = [...new Set(Object.values(typeToLabel))];52 const tierLabels = ['new-contributor', 'trusted-contributor'];5354 // ── Label management ──────────────────────────────────────────────5556 async function ensureLabel(name, color = labelColor) {57 try {58 await github.rest.issues.getLabel({ owner, repo, name });59 } catch (e) {60 if (e.status !== 404) throw e;61 try {62 await github.rest.issues.createLabel({ owner, repo, name, color });63 } catch (createErr) {64 // 422 = label created by a concurrent run between our get and create65 if (createErr.status !== 422) throw createErr;66 core.info(`Label "${name}" creation returned 422 (likely already exists)`);67 }68 }69 }7071 // ── Size calculation ──────────────────────────────────────────────7273 function getSizeLabel(totalChanged) {74 for (const t of sizeThresholds) {75 if (t.max != null && totalChanged < t.max) return t.label;76 }77 // Last entry has no max — it's the catch-all78 return sizeThresholds[sizeThresholds.length - 1].label;79 }8081 function computeSize(files) {82 const excluded = new Set(excludedFiles);83 const totalChanged = files.reduce((sum, f) => {84 const p = f.filename ?? '';85 const base = p.split('/').pop();86 if (excluded.has(base)) return sum;87 for (const prefix of excludedPaths) {88 if (p.startsWith(prefix)) return sum;89 }90 return sum + (f.additions ?? 0) + (f.deletions ?? 0);91 }, 0);92 return { totalChanged, sizeLabel: getSizeLabel(totalChanged) };93 }9495 // ── File-based labels ─────────────────────────────────────────────9697 function buildFileRules() {98 return fileRulesDef.map((rule, i) => {99 let test;100 if (rule.prefix) test = p => p.startsWith(rule.prefix);101 else if (rule.suffix) test = p => p.endsWith(rule.suffix);102 else if (rule.exact) test = p => p === rule.exact;103 else if (rule.pattern) {104 const re = new RegExp(rule.pattern);105 test = p => re.test(p);106 } else {107 throw new Error(108 `fileRules[${i}] (label: "${rule.label}") has no recognized matcher ` +109 `(expected one of: prefix, suffix, exact, pattern)`110 );111 }112 return { label: rule.label, test, skipExcluded: !!rule.skipExcludedFiles };113 });114 }115116 function matchFileLabels(files, fileRules) {117 const rules = fileRules || buildFileRules();118 const excluded = new Set(excludedFiles);119 const labels = new Set();120 for (const rule of rules) {121 // skipExcluded: ignore files whose basename is in the top-level122 // "excludedFiles" list (e.g. uv.lock) so lockfile-only changes123 // don't trigger package labels.124 const candidates = rule.skipExcluded125 ? files.filter(f => !excluded.has((f.filename ?? '').split('/').pop()))126 : files;127 if (candidates.some(f => rule.test(f.filename ?? ''))) {128 labels.add(rule.label);129 }130 }131 return labels;132 }133134 // ── Title-based labels ────────────────────────────────────────────135136 function matchTitleLabels(title) {137 const labels = new Set();138 const m = (title ?? '').match(/^(\w+)(?:\(([^)]+)\))?(!)?:/);139 if (!m) return { labels, type: null, typeLabel: null, scopes: [], breaking: false };140141 const type = m[1].toLowerCase();142 const scopeStr = m[2] ?? '';143 const breaking = !!m[3];144145 const typeLabel = typeToLabel[type] || null;146 if (typeLabel) labels.add(typeLabel);147 if (breaking) labels.add('breaking');148149 const scopes = scopeStr.split(',').map(s => s.trim()).filter(Boolean);150 for (const scope of scopes) {151 const sl = scopeToLabel[scope];152 if (sl) labels.add(sl);153 }154155 return { labels, type, typeLabel, scopes, breaking };156 }157158 // ── Org membership ────────────────────────────────────────────────159160 async function checkMembership(author, userType) {161 if (userType === 'Bot') {162 console.log(`${author} is a Bot — treating as internal`);163 return { isExternal: false };164 }165166 try {167 const membership = await github.rest.orgs.getMembershipForUser({168 org: 'langchain-ai',169 username: author,170 });171 const isExternal = membership.data.state !== 'active';172 console.log(173 isExternal174 ? `${author} has pending membership — treating as external`175 : `${author} is an active member of langchain-ai`,176 );177 return { isExternal };178 } catch (e) {179 if (e.status === 404) {180 console.log(`${author} is not a member of langchain-ai`);181 return { isExternal: true };182 }183 // Non-404 errors (rate limit, auth failure, server error) must not184 // silently default to external — rethrow to fail the step.185 throw new Error(186 `Membership check failed for ${author} (${e.status}): ${e.message}`,187 );188 }189 }190191 // ── Contributor analysis ──────────────────────────────────────────192193 async function getContributorInfo(contributorCache, author, userType) {194 if (contributorCache.has(author)) return contributorCache.get(author);195196 const { isExternal } = await checkMembership(author, userType);197198 let mergedCount = null;199 if (isExternal) {200 try {201 const result = await github.rest.search.issuesAndPullRequests({202 q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,203 per_page: 1,204 });205 mergedCount = result?.data?.total_count ?? null;206 } catch (e) {207 if (e?.status !== 422) throw e;208 core.warning(`Search failed for ${author}; skipping tier.`);209 }210 }211212 const info = { isExternal, mergedCount };213 contributorCache.set(author, info);214 return info;215 }216217 // ── Tier label resolution ───────────────────────────────────────────218219 async function applyTierLabel(issueNumber, author, { skipNewContributor = false } = {}) {220 let mergedCount;221 try {222 const result = await github.rest.search.issuesAndPullRequests({223 q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,224 per_page: 1,225 });226 mergedCount = result?.data?.total_count;227 } catch (error) {228 if (error?.status !== 422) throw error;229 core.warning(`Search failed for ${author}; skipping tier label.`);230 return;231 }232233 if (mergedCount == null) {234 core.warning(`Search response missing total_count for ${author}; skipping tier label.`);235 return;236 }237238 let tierLabel = null;239 if (mergedCount >= trustedThreshold) tierLabel = 'trusted-contributor';240 else if (mergedCount === 0 && !skipNewContributor) tierLabel = 'new-contributor';241242 if (tierLabel) {243 await ensureLabel(tierLabel);244 await github.rest.issues.addLabels({245 owner, repo, issue_number: issueNumber, labels: [tierLabel],246 });247 console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`);248 } else {249 console.log(`No tier label for ${author} (${mergedCount} merged PRs)`);250 }251252 return tierLabel;253 }254255 return {256 ensureLabel,257 getSizeLabel,258 computeSize,259 buildFileRules,260 matchFileLabels,261 matchTitleLabels,262 allTypeLabels,263 checkMembership,264 getContributorInfo,265 applyTierLabel,266 sizeLabels,267 tierLabels,268 trustedThreshold,269 labelColor,270 };271}272273function loadAndInit(github, owner, repo, core) {274 const config = loadConfig();275 return { config, h: init(github, owner, repo, config, core) };276}277278module.exports = { loadConfig, init, loadAndInit };
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.