Files
PaperClipAI/.github/scripts/check-pr-linked-issue.mjs
T
Nicky LeachandPaperclip 131d476a7e fix(ci): make PR-template inline-description contract explicit (#10558)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors use the PR template to describe changes before review
> - The linked-issue gate reads literal template labels, not freeform
prose
> - The template did not state that rule, so a good prose summary could
fail on first submission
> - The gate also skipped two issue template paths that the PR template
already points to
> - This pull request makes the template and the gate match the same
contract
> - The benefit is that a good-faith author can pass the check on the
first try

## Linked Issues or Issue Description

No public issue exists.

Related public PR: #7762.

The problem is a mismatch between the PR template and the linked-issue
gate.
The template gave a bare placeholder and did not explain the
literal-label rule.
The gate also missed the enhancement and docs issue templates.

## What Changed

- Replaced the bare PR-template placeholder with labeled inline
skeletons for bug, feature, and adapter paths.
- Added one sentence that says the gate reads literal labels on separate
lines.
- Added the enhancement and docs issue template field sets to the
linked-issue gate.
- Added and updated tests for prose-only bodies, template skeleton
bodies, and extra issue template coverage.

## Verification

- `node --test .github/scripts/tests/check-pr-linked-issue.test.mjs`
- The branch contains one commit:
`561f1ed3a1434ed4562306f74d40968163ef1444`

## Risks

Low risk.
The three-field minimum stays in place.
The main change is clearer author guidance in the PR template.

## Model Used

OpenAI Codex, GPT-5, tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-31 09:46:26 -07:00

201 lines
7.5 KiB
JavaScript

#!/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,
/(?<!\w)#\d+/,
];
// Prefixes where neither a linked issue nor an inline description is required
const SKIP_ISSUE_PREFIXES = ['docs', 'chore', 'build', 'ci', 'style', 'test', 'revert'];
// Minimum number of template fields the PR body must match to count as an
// inline issue description.
const INLINE_DESCRIPTION_MIN_FIELDS = 3;
// Per-template field labels. Each field is an array of accepted variants; the
// field counts as "present" if any variant appears as a markdown heading
// (`## Label`) or as a bolded/plain label on its own line (`**Label**` /
// `Label:`). Matching is case-insensitive.
const TEMPLATE_FIELDS = {
bug: [
['What happened', 'What happened?'],
['Expected behavior', 'Expected behaviour'],
['Steps to reproduce', 'Reproduction steps', 'Repro steps'],
['Paperclip version', 'Paperclip version or commit', 'Version or commit', 'Version/commit'],
['Deployment mode'],
],
feature: [
['Problem or motivation', 'Problem', 'Motivation'],
['Proposed solution', 'Solution'],
['Alternatives considered', 'Alternatives'],
['Roadmap alignment', 'Roadmap'],
],
adapter: [
['Agent or provider', 'Agent', 'Provider', 'Adapter'],
["Why this adapter is useful", "Why it's useful", 'Why useful', 'Use case'],
['How the agent is invoked', 'How it is invoked', "How it's invoked", 'Invocation'],
],
// Labels below match .github/ISSUE_TEMPLATE/enhancement.yml exactly.
enhancement: [
['What existing behavior does this improve?', 'What existing behavior does this improve'],
['Subsystem affected'],
['Current behavior'],
['Proposed behavior'],
['Reason and benefit'],
['Breaking changes'],
],
// Labels below match .github/ISSUE_TEMPLATE/docs_issue.yml exactly. The
// template has 4 distinct fields, so it meets the 3-field minimum. A
// "docs"-prefixed PR skips this check; this set helps a non-"docs"-prefixed
// PR that describes a documentation issue inline.
docs: [
['Issue type'],
['Where is the issue?', 'Where is the issue'],
["What's wrong?", "What's wrong"],
['Suggested fix'],
],
};
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// A generic "label line" is a markdown heading (`## Label`) or a bolded label on
// its own line (`**Label**`). The content scan stops at a label line, because
// that line starts a new field.
const LABEL_LINE = /^\s*(?:#{1,6}\s+\S|(?:\*\*|__)[^*_].*(?:\*\*|__)\s*[:?]?\s*$)/;
// Build the regex that matches one field label on its own line.
function labelLinePattern(label) {
const esc = escapeRegExp(label);
// Accept markdown headings or bolded/plain labels on their own line.
// Examples: "## What happened?", "**Expected behavior**", "Problem:".
return new RegExp(
`^\\s*(?:#{1,6}\\s+|\\*\\*\\s*|__\\s*)?${esc}(?:\\s*[:?])?(?:\\s*\\*\\*|\\s*__)?\\s*$`,
'i'
);
}
// Every known field label from every template, precompiled. The generic
// LABEL_LINE regex sees a heading or a bold label as a field boundary, but not a
// plain "Label:" line. A skeleton of stacked plain labels needs each label to
// act as a boundary. Without this list the scan reads the next label as
// content, so it counts an empty field as filled.
const KNOWN_LABEL_PATTERNS = Object.values(TEMPLATE_FIELDS)
.flat(2)
.map(labelLinePattern);
// Return true if the line starts a new field. The line is a heading, a bold
// label, or a plain line that equals a known field label.
function isFieldBoundary(line) {
return LABEL_LINE.test(line) || KNOWN_LABEL_PATTERNS.some(p => 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(/<!--[\s\S]*?-->/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);
}