.github/scripts/pr-labeler.js JAVASCRIPT 279 lines View on github.com → Search inside
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 };

Code quality findings 17

Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (e.status !== 404) throw e;
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (createErr.status !== 422) throw createErr;
Use strict inequality (!==) to prevent type coercion bugs
info correctness loose-inequality
if (t.max != null && totalChanged < t.max) return t.label;
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
else if (rule.exact) test = p => p === rule.exact;
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (userType === 'Bot') {
Remove debugging statements or use a logging library
info correctness console-log
console.log(`${author} is a Bot — treating as internal`);
Ensure try blocks have corresponding catch or finally blocks
info correctness try-without-catch
try {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
const isExternal = membership.data.state !== 'active';
Remove debugging statements or use a logging library
info correctness console-log
console.log(
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (e.status === 404) {
Remove debugging statements or use a logging library
info correctness console-log
console.log(`${author} is not a member of langchain-ai`);
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (e?.status !== 422) throw e;
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (error?.status !== 422) throw error;
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
if (mergedCount == null) {
Use strict equality (===) to prevent type coercion bugs
info correctness loose-equality
else if (mergedCount === 0 && !skipNewContributor) tierLabel = 'new-contributor';
Remove debugging statements or use a logging library
info correctness console-log
console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`);
Remove debugging statements or use a logging library
info correctness console-log
console.log(`No tier label for ${author} (${mergedCount} merged PRs)`);

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.