#!/usr/bin/env node /** * check-pr-linked-issue.mjs * Checks that a PR body either links an existing issue/PR or inlines an * issue-template-shaped description. Respects conventional commit prefixes — * skips check for docs/chore/build/ci/style/test/revert prefixed PRs. * * Exports: * checkLinkedIssue(prBody, prTitle) → { passed, failures } * hasInlineIssueDescription(prBody) → boolean */ import { fileURLToPath } from 'node:url'; const ISSUE_PATTERNS = [ /(?:fixes|closes|resolves|refs)\s+#\d+/i, /(?:^|[\s(])https:\/\/github\.com\/paperclipai\/paperclip\/issues\/\d+(?=$|[\s),:;!?]|[.](?![\w-]))/i, /(? p.test(line)); } // Return true if the line holds real content, not a bare placeholder. The // default template skeleton puts a lone "-" under each label, so a label with // only "-", blank lines, or a "[...]" placeholder does not count as filled. function lineHasContent(line) { let text = line.trim(); if (!text) return false; // Drop a leading list marker ("- ", "* ", "1. ") before the check. text = text.replace(/^[-*+]\s*/, '').replace(/^\d+[.)]\s*/, '').trim(); if (!text) return false; // Treat a whole-line bracket placeholder ("[describe here]") as empty. if (/^\[.*\]$/.test(text)) return false; return true; } // Return true if a label variant appears on its own line AND at least one // content line follows it before the next label line. function isFieldFilled(lines, variants) { const patterns = variants.map(labelLinePattern); for (let i = 0; i < lines.length; i += 1) { if (!patterns.some(p => p.test(lines[i]))) continue; for (let j = i + 1; j < lines.length; j += 1) { if (isFieldBoundary(lines[j])) break; // next field starts here if (lineHasContent(lines[j])) return true; } } return false; } function countMatchedFields(lines, fieldSet) { let matched = 0; for (const variants of fieldSet) { if (isFieldFilled(lines, variants)) matched += 1; } return matched; } // Remove HTML comments. The PR template puts its guidance and its example // issue links ("Fixes: #123") inside comments, so the gate must not read them // as author content. function stripHtmlComments(body) { return body.replace(//g, ''); } export function hasInlineIssueDescription(body) { if (!body || !body.trim()) return false; // Strip the guidance comments, then scan the body line by line. const lines = stripHtmlComments(body).split(/\r?\n/); for (const fieldSet of Object.values(TEMPLATE_FIELDS)) { if (countMatchedFields(lines, fieldSet) >= INLINE_DESCRIPTION_MIN_FIELDS) { return true; } } return false; } function parsePrefix(title) { if (!title) return null; const match = title.match(/^([a-z]+)(?:\([^)]*\))?:/); return match ? match[1].toLowerCase() : null; } export function checkLinkedIssue(body, prTitle = '') { const prefix = parsePrefix(prTitle); if (prefix && SKIP_ISSUE_PREFIXES.includes(prefix)) { return { passed: true, failures: [] }; } if (!body || !body.trim()) { return { passed: false, failures: ['PR body is empty — please fill out the PR template'] }; } const linked = ISSUE_PATTERNS.some(p => p.test(stripHtmlComments(body))); const inlined = hasInlineIssueDescription(body); const passed = linked || inlined; return { passed, failures: passed ? [] : [ 'No linked issue or inline issue description found — either tag an existing issue ' + 'with `Fixes #NNN` / `Closes #NNN` / `Refs #NNN`, or describe the underlying issue ' + 'inline in the PR body following one of our issue templates ' + '(https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). ' + 'See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".', ], }; } if (process.argv[1] === fileURLToPath(import.meta.url)) { const body = process.env.PR_BODY ?? ''; const title = process.env.PR_TITLE ?? ''; const result = checkLinkedIssue(body, title); console.log(JSON.stringify(result)); process.exit(result.passed ? 0 : 1); }